diff --git a/NOTEPAD.md b/NOTEPAD.md index bc993af..270deea 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -13,8 +13,6 @@ should reopen it. ### To be sorted -- BUG: Logs currently only have small time window compared to e.g. graphs (in the home view); can we fix this, especially for the purpose of tracing back events? -- FEAT: we should introduce a unified way to select time ranges in graph views with some convenience buttons like -1h -6h -24h . Make sure this also applies to the smaller "trend" charts like shown in the node panel or on edges; here we could show the time range selector upon hovering the graph (presets are fine) - INFRA: ensure that all the packages/ dependencies needed to run fluksio are available on arm to make this software runnable on e.g. raspbian - INFRA: merge the philosophy statement at the beginning of vision.md into the rest of the document. Dissolve the decision dates and fold the decisions into a clean structure - BUG/UI mobile friendly support is degraded: 1) toolbar in the "Flows" viewport extend mobile viewport width 2) position of nodes should never be static; always adjust such that there are as few as possible overlaps (of nodes and edge labels) and direction is left to right (desktop) or top to bottom (mobile) with a minimal (but clean) overall edge length 3) Dashboard view is not mobile friendly at all; as dashboard design is infeasible on mobile, render all widgets in a vertically stacked order. This allows to inspect each widget and make changes. Layout changes are not a feature on mobile 4) the home view is not responsive; all items shown there should re-order on mobile such that no scrollbars appear. Make sure the mobile support is anchored in the design such that future work does not break it @@ -63,7 +61,9 @@ is what M4 still waits on, together with porting the flows. - CHORE/FLOW: `Pipeline.flush` releasing a held value runs its cascade without a run id, so those executions land in the minute rollups but in no run. Threading the scheduling run's id through the queue item would close it. - CHORE/API: the metrics collector is a bus subscriber, so a storm that overflows the bus queue undercounts. The events dropped are the same ones the websocket drops; exact accounting would need the collector to be fed from the engine rather than the bus. - CHORE/API: `/observability/summary` reports the work queue's `depth` as the Redis stream length, which is the journal size (capped at `STREAM_MAXLEN`) rather than a backlog. The health screen shows `pending` instead; the field name still invites the wrong reading. -- FEAT/UI: the health screen's window is fixed at 24 hours and the charts fold minute buckets in Python. A range picker (and `date_bin()` behind it) is the next step if anyone wants a week. +- PERF/API: the health block picks its window now, but `/observability/timeseries` and `/observability/flows` still read every `metric_minute` row in it and fold them in Python. `bucket_s` only coarsens what comes back, so the 7d preset pulls a week of rows on each 30 s poll. `date_bin()` is what makes the long windows cheap. +- CHORE/API: `/observability/summary` still returns `failures_24h`, which nothing reads any more — the Home tile counts errors over the selected window from the rollups instead. Drop the field, or let the summary take a window. +- CHORE/UI: the Home block's "Changes" list is the newest 15 audit rows whatever range is selected. Deliberate — an audit trail is worth reading past the window — but it sits under a control that governs everything else on the screen. - CHORE/FLOW: run records for a deleted flow stay until the retention window passes, so a flow that no longer exists keeps appearing in the history. Deliberate — it is a record of what ran — but `forget_flow` could offer to clear it. - CHORE/API: nothing can ask the collector to flush now, so anything needing the tables to be current has to wait out `FLUSH_INTERVAL_S` — which is what the soak harness does before clearing its own rows. - CHORE/API: `MetricsCollector._start_run`'s `existing is not None` branch is unreachable: a redelivery only arrives after the record it would update has been dropped. @@ -203,6 +203,7 @@ Open on purpose. Each names what should bring it back. - CHORE/INFRA: `requires-python` is capped below 3.14 because the MCP SDK wants a newer starlette there than the pinned `sentry-sdk<2` allows. Lift the cap when sentry-sdk moves to 2.x. - CHORE/INFRA: `bun run --filter frontend build` fails on this workspace with `crypto.hash is not a function` — Vite 7 wants Node 20.12+ and the host has 18. The Docker image builds fine, so it only bites local bundling; `bunx tsc` still type-checks. - FEAT/UI: an endpoint's edge routes straight across the graph, so it can pass behind a node that sits between the lane and the node it wires to. Readable, but a routed edge would be tidier. +- FEAT/UI: the node-panel and edge trend curves take no range, unlike the health block. They are drawn from a Redis ring of the last 120 values per message, which has no window to ask for — a hover caption names what the curve covers instead of a picker promising a span nothing can serve. Reopen if per-message history ever gains a time window. - FEAT/UI: an e-ink rendering profile for a dashboard — motion off, hover-only affordances resolved to something visible, high-contrast palette, thick strokes, and a repaint cadence low enough for a display that takes a second to settle. Reopen when a panel with such a display is actually hung. ## Blocked diff --git a/frontend/src/components/Common/RangePicker.tsx b/frontend/src/components/Common/RangePicker.tsx new file mode 100644 index 0000000..8985f28 --- /dev/null +++ b/frontend/src/components/Common/RangePicker.tsx @@ -0,0 +1,75 @@ +import { cn } from "@/lib/utils" + +/** + * A window of history, and everything a query needs to ask for it. + * + * `bucketS` keeps the number of points a chart draws roughly constant: a week + * of minute buckets is ten thousand readings nobody can see and a megabyte of + * JSON every refresh, so the longer windows are folded coarser server-side. + */ +export type Range = { label: string; hours: number; bucketS: number } + +/** + * The windows on offer. + * + * Bounded by what the collector keeps: it prunes buckets, failures and runs at + * `OBS_RETENTION_DAYS` (30 by default), so a week is behind the last preset + * rather than an empty chart. + */ +export const RANGES: Range[] = [ + { label: "1h", hours: 1, bucketS: 60 }, + { label: "6h", hours: 6, bucketS: 60 }, + { label: "24h", hours: 24, bucketS: 60 }, + { label: "7d", hours: 168, bucketS: 900 }, +] + +/** A day: long enough to hold a night's worth of trouble, short enough to read. */ +export const DEFAULT_RANGE = RANGES[2] + +/** + * Where the window starts, as the stamp the history endpoints take. + * + * Read at fetch time rather than when the options are built, so the window + * slides with the clock instead of freezing where the screen opened. + */ +export const rangeStart = (range: Range) => + new Date(Date.now() - range.hours * 3600_000).toISOString() + +/** + * The window a screen is showing, as presets. + * + * The one segmented shape: a single border pill, transparent segments, + * bg-accent on the selected one (root DESIGN-GUIDELINES.md). + */ +export function RangePicker({ + value, + onChange, +}: { + value: Range + onChange: (range: Range) => void +}) { + return ( +
+ ) +} diff --git a/frontend/src/components/Flow/MessageSparkline.tsx b/frontend/src/components/Flow/MessageSparkline.tsx index 263ad8d..074cdab 100644 --- a/frontend/src/components/Flow/MessageSparkline.tsx +++ b/frontend/src/components/Flow/MessageSparkline.tsx @@ -3,6 +3,11 @@ import { useEffect, useState } from "react" import type { HistoryPoint } from "@/client" import { Sparkline } from "@/components/Common/Sparkline" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import { qualify } from "./deriveEdges" import { useLiveValue } from "./liveStore" import { messageHistoryQueryOptions } from "./queries" @@ -16,6 +21,27 @@ function describe(value: unknown): string { return JSON.stringify(value) ?? String(value) } +/** A stretch of time in the coarsest unit that still says it. */ +function span(seconds: number): string { + if (seconds < 90) return `${Math.round(seconds)}s` + if (seconds < 5400) return `${Math.round(seconds / 60)}m` + return `${Math.round(seconds / 3600)}h` +} + +/** + * What the curve is actually showing. + * + * These are a ring of the last `WINDOW` values per message, not a window of + * time — a message that fires twice an hour and one that fires at 10 Hz draw + * the same width for wildly different spans. The readings themselves are what + * says which, so the caption reads it off them rather than claiming a range. + */ +function caption(points: HistoryPoint[]): string { + const readings = `The last ${points.length} reading${points.length === 1 ? "" : "s"}` + const covered = points[points.length - 1].ts - points[0].ts + return covered > 0 ? `${readings}, over ${span(covered)}.` : `${readings}.` +} + /** * The name only settles once typing stops. Without this, every keystroke in the * message field would ask the server for a history. @@ -93,6 +119,17 @@ export function MessageSparkline({ ) } - // The value is live here, so the dot on the newest reading is earned. - return{failuresAt.at === null - ? "Nothing has failed in the last day." - : "Nothing failed in this minute."} + ? `Nothing has failed in the last ${range.label}.` + : failuresAt.pinned !== null + ? "Nothing failed in this minute." + : "No failure from this minute is in the recent list."}
)} diff --git a/frontend/src/components/Health/HealthOverview.tsx b/frontend/src/components/Health/HealthOverview.tsx index a08e5c6..1f67d28 100644 --- a/frontend/src/components/Health/HealthOverview.tsx +++ b/frontend/src/components/Health/HealthOverview.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" import type { FlowRollup, HistoryPoint } from "@/client" +import { type Range, RangePicker } from "@/components/Common/RangePicker" import { Sparkline } from "@/components/Common/Sparkline" import { PANEL_SECTION } from "@/components/Flow/SidePanel" import { Badge } from "@/components/ui/badge" @@ -37,9 +38,11 @@ function Tile({ /** * A flow's execution trend, drawn from the 60 slices the rollup carries. * - * The same curve the node panel and the edge popover draw, in the chart ramp - * this page's other graphs use. No live dot: the rollups are polled, so the - * right edge is the last completed slice rather than this instant. + * Sixty slices of whatever window is selected, so the curve stays the same + * width and only its resolution moves. The same curve the node panel and the + * edge popover draw, in the chart ramp this page's other graphs use. No live + * dot: the rollups are polled, so the right edge is the last completed slice + * rather than this instant. */ function Spark({ counts }: { counts: number[] }) { const points: HistoryPoint[] = counts.map((value, index) => ({ @@ -61,28 +64,40 @@ function Spark({ counts }: { counts: number[] }) { } /** - * How the engine is doing, and how each flow has been doing for a day. + * How the engine is doing, and how each flow has been doing over the window. * - * The tiles are the standing state; the table below is the same day the charts - * cover, one row per flow. + * The tiles are the standing state; the table below is the same window the + * charts cover, one row per flow. The range control sits on this heading + * because it governs the whole health block, the activity below included — + * one window, not one per card. */ -export function HealthOverview() { +export function HealthOverview({ + range, + onRangeChange, +}: { + range: Range + onRangeChange: (range: Range) => void +}) { const { data: summary } = useQuery(summaryQueryOptions()) - const { data: flows } = useQuery(flowRollupsQueryOptions()) + const { data: flows } = useQuery(flowRollupsQueryOptions(range)) // Shares the list below's cache entry, for the one tile that dates them. - const { data: failures } = useQuery(failuresQueryOptions()) + const { data: failures } = useQuery(failuresQueryOptions(range)) const queue = (summary?.queue ?? {}) as Record
@@ -112,8 +127,12 @@ export function HealthOverview() {
}
/>
Flow activity (24h)
+ Flow activity ({range.label})