Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3637,7 +3637,10 @@ wrote. Five things about the history are load-bearing:
- **The run going now is read from memory.** The Logs tab is the screen you open when something is wrong, and the
database may be the thing that is wrong, so `/analytics/logs` without a `run` reads the ring and
`/analytics/logs/runs` lists this run first whatever the database says, answering `pastUnavailable` when it
cannot read the rest. A history that could not be started costs the history, never the bot.
cannot read the rest. A history that could not be started costs the history, never the bot. **Refresh**
reads the run shown and the list again at once and says so; a read that fails, by hand or on the timer, says why
beside it while the lines already read stay on screen (`RefreshLogs`), since an error page in their place would hide
them exactly when they are wanted.
- **Search is literal and spans every run.** The ring and `readLogLines` both match the message and the stringified
context, so a guild id or an error reference finds every line about it, and a search is escaped before it becomes
a pattern, so `(` or `.*` finds those characters. The runs list counts what the search finds in each run at the
Expand Down
39 changes: 38 additions & 1 deletion dashboard/src/features/owner/OwnerAnalytics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { barWidth } from "@/components/primitives/UsageBars";
import { levelFrom, shortDay, windowFrom } from "@/features/owner/owner.utils";
import { OwnerPage } from "@/features/owner/OwnerPage";
import { expectNoViolations } from "@/test/axe";
import { logRuns, PAST_RUN } from "@/test/fixtures/owner";
import { logFeed, logRuns, PAST_RUN } from "@/test/fixtures/owner";
import { renderWithProviders } from "@/test/renderWithProviders";
import { server } from "@/test/setup";

Expand Down Expand Up @@ -200,6 +200,43 @@ describe("the logs tab", () => {
expect(screen.getByRole("radio", { name: /Running now/ })).toBeInTheDocument();
expect(screen.getByText(/Deleted the logs of the run going now/)).toBeInTheDocument();
});

/** A paused feed, or a past run, otherwise shows what it read until the tab is opened again. */
it("reads the logs again on demand and says that it has", async () => {
const user = userEvent.setup();
renderTab("logs");
await screen.findByText("[READY] Logged in");

const latest = { at: "2026-08-01T12:01:00.000Z", level: "info", message: "[HEARTBEAT] Still here", context: {} };
server.use(
http.get("/api/analytics/logs", () => HttpResponse.json({ ...logFeed, lines: [latest, ...logFeed.lines] })),
);
await user.click(screen.getByRole("button", { name: "Refresh" }));

expect(await screen.findByText("[HEARTBEAT] Still here")).toBeInTheDocument();
expect((await screen.findByText(/^Refreshed at /)).closest("[role=status]")).toHaveClass("text-success");
});

/** A failed refresh says why, and keeps the lines it already had rather than swapping them for an error page. */
it("says why a refresh failed, and keeps the lines it had", async () => {
const user = userEvent.setup();
renderTab("logs");
await screen.findByText("[READY] Logged in");

server.use(
http.get("/api/analytics/logs", () =>
HttpResponse.json({ error: { code: "internal", message: "The log store is busy." } }, { status: 500 }),
),
);
await user.click(screen.getByRole("button", { name: "Refresh" }));

const note = await screen.findByText(
/^Couldn’t refresh the logs\. The log store is busy\. The lines shown were read at /,
);
expect(note.closest("[role=status]")).toHaveClass("text-destructive-text");
expect(screen.getByText("[READY] Logged in")).toBeInTheDocument();
expect(screen.queryByText(/^Refreshed at /)).toBeNull();
});
});

describe("the runtime tab", () => {
Expand Down
118 changes: 118 additions & 0 deletions dashboard/src/features/owner/components/RefreshLogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { AlertTriangle, Check, RefreshCw } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { describe } from "@/app/ErrorState";
import { Button } from "@/components/primitives";
import { cn } from "@/lib/cn";
import { clockTime } from "@/lib/datetime";

const REFRESHED_NOTE_MS = 5_000;

interface Read {
isError: boolean;
error: unknown;
}

type Outcome =
| { state: "idle" }
| { state: "refreshing" }
| { state: "refreshed"; at: number }
| { state: "failed"; error: unknown; at: number };

const timeOf = (at: number): string => clockTime(new Date(at).toISOString());

/** Reads the logs again on demand and says how that went; a failure stands until a later read of the lines works. */
export function RefreshLogs({
refetch,
failure,
readAt,
children,
}: {
refetch: () => Promise<readonly Read[]>;
/** Why the latest read failed, or null; only said here while older lines are still on screen. */
failure: unknown;
/** When the lines on screen were read, or 0 before any were. */
readAt: number;
/** The row's other actions, between the button and what it says. */
children?: ReactNode;
}): React.JSX.Element {
const { t } = useTranslation();
const [outcome, setOutcome] = useState<Outcome>({ state: "idle" });
const refreshing = outcome.state === "refreshing";

useEffect(() => {
if (outcome.state !== "refreshed") return;
const timer = setTimeout(() => {
setOutcome({ state: "idle" });
}, REFRESHED_NOTE_MS);
return () => {
clearTimeout(timer);
};
}, [outcome]);

async function refresh(): Promise<void> {
if (refreshing) return;
setOutcome({ state: "refreshing" });

let failed: Read | undefined;
try {
failed = (await refetch()).find((read) => read.isError);
} catch (error) {
failed = { isError: true, error };
}

setOutcome(
failed === undefined
? { state: "refreshed", at: Date.now() }
: { state: "failed", error: failed.error, at: Date.now() },
);
}

const shownFailure =
outcome.state === "failed" && readAt <= outcome.at
? outcome.error
: failure !== null && readAt > 0
? failure
: null;

let note: ReactNode = null;
if (shownFailure !== null) {
const reason = describe(shownFailure, t).body;
note = (
<>
<AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>
{readAt > 0
? t("owner.logsRefreshFailedStale", { reason, time: timeOf(readAt) })
: t("owner.logsRefreshFailed", { reason })}
</span>
</>
);
} else if (outcome.state === "refreshed") {
note = (
<>
<Check size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>{t("owner.logsRefreshed", { time: timeOf(outcome.at) })}</span>
</>
);
}

return (
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" aria-busy={refreshing} onClick={() => void refresh()}>
<RefreshCw size={15} aria-hidden="true" className={cn(refreshing && "animate-spin")} />
{refreshing ? t("owner.logsRefreshing") : t("owner.logsRefresh")}
</Button>
{children}
<span
role="status"
className={cn(
"flex items-start gap-2 text-xs",
shownFailure !== null ? "text-destructive-text" : "text-success",
)}
>
{note}
</span>
</div>
);
}
15 changes: 9 additions & 6 deletions dashboard/src/features/owner/tabs/LogsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SearchField, Warning } from "@/components/form";
import { Button, Card, type Segment, SegmentedControl, Skeleton } from "@/components/primitives";
import { LogLines } from "@/features/owner/components/LogLines";
import { LogRunList } from "@/features/owner/components/LogRunList";
import { RefreshLogs } from "@/features/owner/components/RefreshLogs";
import { useDeleteLogs, useLogRuns, useLogs } from "@/features/owner/useOwner";
import { useDebounced } from "@/hooks/useDebounced";
import { type TranslationKey } from "@/i18n";
Expand Down Expand Up @@ -83,11 +84,13 @@ export function LogsTab({
}

const cleared = current !== undefined && !past && undo.isHidden(keyOf(current.id));
const failure = logs.isError ? logs.error : runs.isError ? runs.error : null;
const refetch = () => Promise.all(chosen === undefined ? [runs.refetch()] : [runs.refetch(), logs.refetch()]);

return (
<div className="flex flex-col gap-4">
<Card padding="compact" className="flex flex-col gap-3">
{runs.isError ? (
{runs.isError && listed === undefined ? (
<ErrorState as="h2" error={runs.error} onRetry={() => void runs.refetch()} />
) : listed === undefined ? (
<Skeleton className="h-40 w-full" />
Expand Down Expand Up @@ -123,7 +126,7 @@ export function LogsTab({
onChange={onLevel}
/>

<div className="flex flex-wrap gap-2">
<RefreshLogs refetch={refetch} failure={failure} readAt={logs.dataUpdatedAt}>
{!past && (
<Button
variant="ghost"
Expand All @@ -147,7 +150,7 @@ export function LogsTab({
{shownRun.current ? t("owner.logsClear") : t("owner.logsDelete")}
</Button>
)}
</div>
</RefreshLogs>
</Card>

<p className="text-muted-foreground text-sm" aria-live="polite">
Expand All @@ -164,12 +167,12 @@ export function LogsTab({
</Warning>
)}

{/* An unreadable feed must not render as an empty one — this is the screen you open when things are wrong. */}
{logs.isError ? (
{/* An unreadable feed must not render as an empty one; lines already read stay, and the failure is said above. */}
{logs.isError && logs.data === undefined ? (
<ErrorState as="h2" error={logs.error} onRetry={() => void logs.refetch()} />
) : (
<Card padding="compact" aria-busy={logs.isPending}>
{logs.isPending ? (
{logs.data === undefined ? (
<Skeleton className="h-64 w-full" />
) : (
<LogLines lines={cleared ? [] : logs.data.lines} past={past} />
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1916,6 +1916,11 @@
"droppedAttachment": "Die Antwort enthielt auch einen Anhang, der nur in Discord funktioniert.",
"logsPause": "Pausieren",
"logsResume": "Fortsetzen",
"logsRefresh": "Aktualisieren",
"logsRefreshing": "Wird aktualisiert…",
"logsRefreshed": "Aktualisiert um {{time}}.",
"logsRefreshFailed": "Die Logs konnten nicht aktualisiert werden. {{reason}}",
"logsRefreshFailedStale": "Die Logs konnten nicht aktualisiert werden. {{reason}} Die angezeigten Zeilen wurden um {{time}} gelesen.",
"usageSummary_one": "{{count, number}} Befehl in den letzten {{days}} Tagen ausgeführt, {{failing}} davon fehlgeschlagen.",
"usageSummary_other": "{{count, number}} Befehle in den letzten {{days}} Tagen ausgeführt, {{failing}} davon fehlgeschlagen.",
"unknown": "Unbekannt",
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1916,6 +1916,11 @@
"droppedAttachment": "The reply also carried an attachment, which only works inside Discord.",
"logsPause": "Pause",
"logsResume": "Resume",
"logsRefresh": "Refresh",
"logsRefreshing": "Refreshing…",
"logsRefreshed": "Refreshed at {{time}}.",
"logsRefreshFailed": "Couldn’t refresh the logs. {{reason}}",
"logsRefreshFailedStale": "Couldn’t refresh the logs. {{reason}} The lines shown were read at {{time}}.",
"usageSummary_one": "{{count, number}} command run in the last {{days}} days, {{failing}} of them failing.",
"usageSummary_other": "{{count, number}} commands run in the last {{days}} days, {{failing}} of them failing.",
"unknown": "Unknown",
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1973,6 +1973,11 @@
"droppedAttachment": "La respuesta también incluía un archivo adjunto, que solo funciona dentro de Discord.",
"logsPause": "Pausar",
"logsResume": "Reanudar",
"logsRefresh": "Actualizar",
"logsRefreshing": "Actualizando…",
"logsRefreshed": "Actualizado a las {{time}}.",
"logsRefreshFailed": "No se pudieron actualizar los registros. {{reason}}",
"logsRefreshFailedStale": "No se pudieron actualizar los registros. {{reason}} Las líneas que se muestran se leyeron a las {{time}}.",
"usageSummary_one": "{{count, number}} comando ejecutado en los últimos {{days}} días, {{failing}} con errores.",
"usageSummary_many": "{{count, number}} comandos ejecutados en los últimos {{days}} días, {{failing}} con errores.",
"usageSummary_other": "{{count, number}} comandos ejecutados en los últimos {{days}} días, {{failing}} con errores.",
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1973,6 +1973,11 @@
"droppedAttachment": "La réponse contenait aussi une pièce jointe, qui ne fonctionne que dans Discord.",
"logsPause": "Mettre en pause",
"logsResume": "Reprendre",
"logsRefresh": "Actualiser",
"logsRefreshing": "Actualisation…",
"logsRefreshed": "Actualisé à {{time}}.",
"logsRefreshFailed": "Impossible d’actualiser les journaux. {{reason}}",
"logsRefreshFailedStale": "Impossible d’actualiser les journaux. {{reason}} Les lignes affichées ont été lues à {{time}}.",
"usageSummary_one": "{{count, number}} commande exécutée ces {{days}} derniers jours, dont {{failing}} en échec.",
"usageSummary_many": "{{count, number}} commandes exécutées ces {{days}} derniers jours, dont {{failing}} en échec.",
"usageSummary_other": "{{count, number}} commandes exécutées ces {{days}} derniers jours, dont {{failing}} en échec.",
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -1973,6 +1973,11 @@
"droppedAttachment": "La risposta conteneva anche un allegato, che funziona solo dentro Discord.",
"logsPause": "Pausa",
"logsResume": "Riprendi",
"logsRefresh": "Aggiorna",
"logsRefreshing": "Aggiornamento…",
"logsRefreshed": "Aggiornato alle {{time}}.",
"logsRefreshFailed": "Impossibile aggiornare i log. {{reason}}",
"logsRefreshFailedStale": "Impossibile aggiornare i log. {{reason}} Le righe mostrate sono state lette alle {{time}}.",
"usageSummary_one": "{{count, number}} comando eseguito negli ultimi {{days}} giorni, di cui {{failing}} non riusciti.",
"usageSummary_many": "{{count, number}} comandi eseguiti negli ultimi {{days}} giorni, di cui {{failing}} non riusciti.",
"usageSummary_other": "{{count, number}} comandi eseguiti negli ultimi {{days}} giorni, di cui {{failing}} non riusciti.",
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1859,6 +1859,11 @@
"droppedAttachment": "返信には添付ファイルも含まれていましたが、これは Discord の中でしか動きません。",
"logsPause": "一時停止",
"logsResume": "再開",
"logsRefresh": "更新",
"logsRefreshing": "更新中…",
"logsRefreshed": "{{time}} に更新しました。",
"logsRefreshFailed": "ログを更新できませんでした。{{reason}}",
"logsRefreshFailedStale": "ログを更新できませんでした。{{reason}}表示中の行は {{time}} に読み込んだものです。",
"usageSummary_other": "直近 {{days}} 日間に {{count, number}} 件のコマンドが実行され、そのうち {{failing}} 件が失敗しました。",
"unknown": "不明",
"created": "作成日",
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/i18n/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1916,6 +1916,11 @@
"droppedAttachment": "Het antwoord bevatte ook een bijlage, die alleen in Discord werkt.",
"logsPause": "Pauzeren",
"logsResume": "Hervatten",
"logsRefresh": "Verversen",
"logsRefreshing": "Bezig met verversen…",
"logsRefreshed": "Ververst om {{time}}.",
"logsRefreshFailed": "De logs konden niet worden ververst. {{reason}}",
"logsRefreshFailedStale": "De logs konden niet worden ververst. {{reason}} De getoonde regels zijn om {{time}} gelezen.",
"usageSummary_one": "{{count, number}} commando uitgevoerd in de laatste {{days}} dagen, waarvan {{failing}} mislukt.",
"usageSummary_other": "{{count, number}} commando’s uitgevoerd in de laatste {{days}} dagen, waarvan {{failing}} mislukt.",
"unknown": "Onbekend",
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -2030,6 +2030,11 @@
"droppedAttachment": "В ответе было также вложение, которое работает только в Discord.",
"logsPause": "Пауза",
"logsResume": "Продолжить",
"logsRefresh": "Обновить",
"logsRefreshing": "Обновление…",
"logsRefreshed": "Обновлено в {{time}}.",
"logsRefreshFailed": "Не удалось обновить журналы. {{reason}}",
"logsRefreshFailedStale": "Не удалось обновить журналы. {{reason}} Показанные строки прочитаны в {{time}}.",
"usageSummary_one": "Выполнено команд за последние {{days}} дн.: {{count, number}}, из них с ошибкой — {{failing}}.",
"usageSummary_few": "Выполнено команд за последние {{days}} дн.: {{count, number}}, из них с ошибкой — {{failing}}.",
"usageSummary_many": "Выполнено команд за последние {{days}} дн.: {{count, number}}, из них с ошибкой — {{failing}}.",
Expand Down
Loading