diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4ef56e43..2d102db6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -50,7 +50,7 @@ body: id: version attributes: label: Testify version or commit - placeholder: 2.4.3 + placeholder: 2.4.4 validations: required: true diff --git a/AGENTS.md b/AGENTS.md index 2ed5e030..496f8718 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -387,7 +387,8 @@ rather than a wait. Do not write the old "up to an hour to propagate" line back - Slash → `events/command/interactionCreate.event.ts` → `checks.ts` gates → `command.run(input, client)` - Prefix → `events/message/…` → `core/prefix.ts` resolves name or alias → **the same** `command.run`. The prefix matches in - any case, so `T?help` — what a phone's auto-capitalisation types — runs `t?help` + any case, so `T?help` — what a phone's auto-capitalisation types — runs `t?help`. A prefix run naming no + subcommand the command has is refused by `runChecks` in the reader's language, before a cooldown starts - Components → the one `interactionCreate` listener → `client.buttons` registry keyed by the custom-ID prefix → `button.run(interaction, { client, action, args })` - Right-click → the same listener → `client.contextMenus` by name → `checkContextMenu` → `menu.run(interaction, client)` @@ -3636,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 diff --git a/dashboard/package.json b/dashboard/package.json index 8ba7ade7..100bbf9e 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@testify/dashboard", - "version": "2.4.3", + "version": "2.4.4", "private": true, "type": "module", "scripts": { diff --git a/dashboard/src/features/owner/OwnerAnalytics.test.tsx b/dashboard/src/features/owner/OwnerAnalytics.test.tsx index acf428cb..42e365a5 100644 --- a/dashboard/src/features/owner/OwnerAnalytics.test.tsx +++ b/dashboard/src/features/owner/OwnerAnalytics.test.tsx @@ -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"; @@ -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", () => { diff --git a/dashboard/src/features/owner/components/RefreshLogs.tsx b/dashboard/src/features/owner/components/RefreshLogs.tsx new file mode 100644 index 00000000..50a43c7f --- /dev/null +++ b/dashboard/src/features/owner/components/RefreshLogs.tsx @@ -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; + /** 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({ 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 { + 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 = ( + <> +