mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +01:00
One row per command attempt, not two aggregates written only on success. command_uses holds last-use only and daily_uses counts successes; both are rate-limiter state and both are written by store.record_use, which the dispatcher calls only when a handler returns cleanly. A gate refusal and a crash returned before it, so a command that failed every time looked exactly like one nobody ran, and nothing knew which command a counted message was. command_events now takes every attempt with its outcome, its duration and the item it served; upstream_calls takes every outbound request made while serving one. The two writes stay separate on purpose -- recording a refusal as a use is what slid the cooldown window forward every time somebody was told to wait -- and test_dispatcher_outcomes asserts last_used does not move. Backfilled from journald joined against message_events, with daily_uses filling the residual so the two sources cannot double-count. Two things were wrong on the first attempt and both were found by measuring rather than by reasoning: the window has to be symmetric, because sent_at_ms comes from the sender's phone and the journal stamp from this host (194 of 299 lines landed *before* their own message, by up to 3.2 s); and matching has to be a global closest-pair assignment, because per-row greedy cannot separate two commands seconds apart. Together those took the dropped rows from 137 to zero, and recovered 418 invocations back to 29 August. Also: !analityka in the group, twelve Polish lines, naming at most three people and only ones whose display name has been seen; an Analytics tab with Nivo charts; and continuous latency measurement, which answers the open question in DOCS-latency.md -- a text send is 235 ms and an image send about 2.2 s, so the upload really is where the time goes. EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a schema bump rebuilds, and what leaving a group clears. daily_uses left the first (it is a backfill source now) and stayed in the second, and _drop_reshaped took over the repair a bump used to do for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
71 lines
2.6 KiB
TypeScript
71 lines
2.6 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
||
|
||
import type { AnalyticsGroup, Coverage } from "../api/analytics-types.ts";
|
||
|
||
import { api } from "../api/client.ts";
|
||
import { AnalyticsPanels } from "./analytics-panels.tsx";
|
||
|
||
// The footnote every panel is read under. Two of the three classes of row
|
||
// were reconstructed rather than observed, and a chart that does not say so
|
||
// is a chart that quietly overstates what is known.
|
||
export function Sources({ coverage: measured }: Readonly<{ coverage: Coverage }>): React.JSX.Element {
|
||
return (
|
||
<p className="footnote" data-testid="sources">
|
||
{measured.events} recorded {measured.events === 1 ? "use" : "uses"} over{" "}
|
||
{measured.days} {measured.days === 1 ? "day" : "days"}
|
||
{measured.first_day === "" ? "" : ` (${measured.first_day} – ${measured.last_day})`}.{" "}
|
||
<strong>{measured.sources.live}</strong> observed live,{" "}
|
||
<strong>{measured.sources.journald}</strong> reconstructed from the journal with a real hour,{" "}
|
||
<strong>{measured.sources.daily_uses}</strong> recovered as daily counts with no hour at
|
||
all — those are counted everywhere and excluded from every chart with an hour on
|
||
its axis, because they have none.
|
||
{measured.enough_for_a_trend
|
||
? ""
|
||
: " Too few days to state a trend; the series is drawn, the direction is not claimed."}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
export function Analytics(): React.JSX.Element {
|
||
const [groups, setGroups] = useState<AnalyticsGroup[] | null>(null);
|
||
const [group, setGroup] = useState<null | string>(null);
|
||
|
||
const refresh = useCallback(() => {
|
||
void api.analyticsGroups().then((body) => {
|
||
setGroups(body.groups);
|
||
});
|
||
}, []);
|
||
|
||
useEffect(refresh, [refresh]);
|
||
|
||
const pick = useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
|
||
setGroup(event.target.value === "" ? null : event.target.value);
|
||
}, []);
|
||
|
||
if (groups === null) {
|
||
return <p>Reading the database…</p>;
|
||
}
|
||
|
||
return (
|
||
<section>
|
||
<h2>Analytics</h2>
|
||
<label>
|
||
Group{" "}
|
||
{/* A group id is 44 characters of base64, so it is picked and never
|
||
typed -- the same reason the Settings tab renders one as a select. */}
|
||
<select data-testid="group-picker" onChange={pick} value={group ?? ""}>
|
||
<option value="">Every group ({groups.length})</option>
|
||
{groups.map((one) => {
|
||
return (
|
||
<option key={one.id} value={one.id}>
|
||
{one.id.slice(0, 18)}… — {one.events} uses
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
</label>
|
||
<AnalyticsPanels group={group} />
|
||
</section>
|
||
);
|
||
}
|