Home: one sparkline style with a left fade, SI values, a full-width activity table, roomier panels
The trend curve was two components: a rich one in the node panel and on edges,
and a bare line in the flow table. It is one `Sparkline` now, taking the colour
token, the height and whether the live dot and the readout show. The flow table
draws its rollup in the chart ramp with no dot — the rollups are polled, so the
right edge is the last completed slice rather than this instant — and keeps its
"nothing yet" state, as the panel keeps its three distinct silences.
All of them fade out to the left, through an SVG mask over the curve and its
area. The dot sits outside the mask: the newest reading is the one thing that
must stay solid.
`si` replaces `compact` and the ad-hoc "k" the uPlot axis carried. It prefixes
k/M/G and m/µ, but only outside 0.01–1000, where the plain number is already
the shortest thing to read and a written unit ("0.4 ms") stays honest. The
sparkline readout asks for four digits, so two neighbouring readings never
collapse into one string. Exact counts and anything the user acts on — a
payload, a form field, the edge inspector's value — are left unrounded.
`src/lib/utils.check.ts` asserts the rounding cases.
The activity table now spans its card: the flow name anchors the left, the four
numbers read down their own centre, and the trend takes the slack on the right.
Panel rhythm steps up one notch, gap-5 to gap-6 outside and gap-2 to gap-3
within a section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
+5
-7
@@ -18,21 +18,19 @@ should reopen it.
|
||||
- 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
|
||||
- BUG/UI the codeberg icon should be replaced by a gitea icon
|
||||
- FEAT/UX mirror the "publish" system from the flow editor to the dashboard; i.e. dashboards are always editable (edit mode) when not in the "read-only wallpanel" mode. Changes are made effective by clicking "Publish" in the toolbar (identical to flows).
|
||||
- BUG/UI replace the search bar in flows/dashboard by a search icon which opens the search bar upon click. Also the create button should become a "+" icon button; both right aligned
|
||||
- FEAT/UI introduce an "Publish all" (checkmark icon button) to the flow and dashboard overview which publishs all changes without having to access the individual flows
|
||||
- BUG/UI add a bit more vertical spacing between elements in the (node) panels
|
||||
- FEAT/UI add a loading animation for the initial app load and when loading individual pages; make sure that elements e.g. in the home dashboard load independently to ensure a fast loading of the initial site but figures charts, tables, graph etc. follow after that
|
||||
- FEAT/UI introduce a graph panel which renders at the top right next to the graph view (to make more use of the horizontal space) and which allows (de-) selecting flows to be excluded from the graph view or search for individual nodes where only the flows containing this node should be shown (like slicing the brain)
|
||||
- FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a chat side panel) to explain the error(s)
|
||||
- FEAT/UI trend graphs (in the home view table, but also nodes/edges view) should fade out towards the left end
|
||||
- BUG/UI unify the trend graph view; adopt the style we use in the panels/ edges into the the trend graphs in the table of the home view
|
||||
- BUG/UI ensure the content of the table (flow activity) in the home view uses the full width (e.g. we could right align the trend graphs, center the colums in the middle and left align the name of the flow)
|
||||
- FEAT/UI introduce si unit style shortening of large values (like k, M, ..) globally
|
||||
- FEAT/UI durations are written as a shortened number beside a fixed unit, so a slow run reads "1.2k ms" rather than "1.2 s". A duration formatter that steps the unit itself (µs/ms/s/min) would read better wherever `si` is followed by "ms"
|
||||
- CHORE/UI `biome check ./src` reports an ineffective suppression at `FlowEditor.tsx:473` (`useExhaustiveDependencies` no longer fires there)
|
||||
- FEAT/UI text labels in the brain graph view should only show upon hovering. Edges/nodes which fire seldomly should dim out over time (up to a lower limit). Get inspiration on how "Obsidian" visualizes the graph view. Also make sure nodes shapes are visible on both light and dark themes (consider using fill color instead of shadows) -> web-search / research about visualization of large graphs and ensure high quality visual design
|
||||
- BUG/UI make sure the edges point towards the center of the nodes in the brain graph view (currently some of them seem a bit off-center)
|
||||
|
||||
- FEAT/UI labels in flows (indicating dashboard widget connections) naturally can't pulse. Instead add an animation (enlightning fade) from either ltr or rtl depending if the label is in- or outbound
|
||||
- FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a side panel like in flows/nodes to make it a chat panel which can open on any screen (stacks below any other existing panel -> introduce stacking) to give support on errors/write code, generate dashboards etc) to explain the error(s)
|
||||
|
||||
### Connector write paths
|
||||
|
||||
Needs someone watching the real hardware, so it is not a background task. This
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useId } from "react"
|
||||
|
||||
import type { HistoryPoint } from "@/client"
|
||||
import { cn, si } from "@/lib/utils"
|
||||
|
||||
/** Room above and below the curve for the stroke, in viewBox units. */
|
||||
const PAD = 8
|
||||
|
||||
/** Above this ratio a linear series is all baseline and one spike. */
|
||||
const LOG_RATIO = 100
|
||||
|
||||
/** How far the left end fades over, in viewBox units. */
|
||||
const FADE = 40
|
||||
|
||||
/** Enough digits to tell two neighbouring readings apart. */
|
||||
const READOUT_DIGITS = 4
|
||||
|
||||
/**
|
||||
* Curve and area for a series, in a 0–100 box.
|
||||
*
|
||||
* The awkward series are the point: one reading has no line to draw, a series
|
||||
* that never moved has no span to divide by, and one spanning decades is only
|
||||
* legible once the exponent is what varies.
|
||||
*/
|
||||
function shape(points: HistoryPoint[]) {
|
||||
const values = points.map((point) => point.value)
|
||||
const low = Math.min(...values)
|
||||
const high = Math.max(...values)
|
||||
// Logs need every reading on the same side of zero.
|
||||
const logged = low > 0 && high / low >= LOG_RATIO
|
||||
const project = (value: number) => (logged ? Math.log10(value) : value)
|
||||
const floor = project(low)
|
||||
const span = project(high) - floor
|
||||
|
||||
const y = (value: number) =>
|
||||
span === 0
|
||||
? 50
|
||||
: 100 - PAD - ((project(value) - floor) / span) * (100 - 2 * PAD)
|
||||
const x = (index: number) =>
|
||||
points.length === 1 ? 100 : (index / (points.length - 1)) * 100
|
||||
|
||||
const line = points
|
||||
.map(
|
||||
(point, index) =>
|
||||
`${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`,
|
||||
)
|
||||
.join(" ")
|
||||
|
||||
return {
|
||||
low,
|
||||
high,
|
||||
line,
|
||||
area: `${line} L100,100 L0,100 Z`,
|
||||
end: y(values[values.length - 1]),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a series has been moving — the one trend curve, drawn the same way in
|
||||
* the node panel, on an edge and in the flow table.
|
||||
*
|
||||
* The left end fades out, so the oldest readings read as the tail they are
|
||||
* rather than as an edge the series was cut at. The fade masks the curve and
|
||||
* its area together; the live dot sits outside the mask, since the newest
|
||||
* reading is the one thing that must stay solid.
|
||||
*
|
||||
* Needs at least one point: an empty series has no story, and what to say
|
||||
* instead is the caller's to decide.
|
||||
*/
|
||||
export function Sparkline({
|
||||
points,
|
||||
color = "var(--primary)",
|
||||
height = "h-8",
|
||||
dot = true,
|
||||
readout = true,
|
||||
}: {
|
||||
points: HistoryPoint[]
|
||||
/** The token the curve, its area and the dot are drawn in. */
|
||||
color?: string
|
||||
/** Tailwind height of the curve's box. */
|
||||
height?: string
|
||||
/** A dot on the newest reading; only honest where the series is live. */
|
||||
dot?: boolean
|
||||
/** The current value and the range, beside the curve. */
|
||||
readout?: boolean
|
||||
}) {
|
||||
const id = useId()
|
||||
const { low, high, line, area, end } = shape(points)
|
||||
|
||||
return (
|
||||
// The gap leaves the live dot room to sit on the last reading without
|
||||
// touching the labels.
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("relative min-w-0 flex-1", height)}>
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="h-full w-full overflow-visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
{/* A mask reads luminance, so white here is "keep this", not a
|
||||
colour the design system has anything to say about. */}
|
||||
<linearGradient
|
||||
id={`${id}-ramp`}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2={FADE}
|
||||
y2="0"
|
||||
>
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="1" />
|
||||
</linearGradient>
|
||||
{/* Wider than the box, so a round cap or a stroke leaning past the
|
||||
viewBox is faded rather than cut. */}
|
||||
<mask
|
||||
id={`${id}-fade`}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="-10"
|
||||
y="-10"
|
||||
width="120"
|
||||
height="120"
|
||||
>
|
||||
<rect
|
||||
x="-10"
|
||||
y="-10"
|
||||
width="120"
|
||||
height="120"
|
||||
fill={`url(#${id}-ramp)`}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
{points.length > 1 ? (
|
||||
<g mask={`url(#${id}-fade)`}>
|
||||
<path d={area} fill={`url(#${id})`} />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
// Keeps the stroke even, though the box is far wider than tall.
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</g>
|
||||
) : null}
|
||||
</svg>
|
||||
{/* The newest reading is always the right edge, so the dot only needs to
|
||||
know how high it sits. */}
|
||||
{dot ? (
|
||||
<>
|
||||
<span
|
||||
className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full"
|
||||
style={{
|
||||
left: "100%",
|
||||
top: `${end}%`,
|
||||
background: color,
|
||||
opacity: 0.2,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full"
|
||||
style={{ left: "100%", top: `${end}%`, background: color }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{/* A shared minimum width, so the curves all end on the same line. */}
|
||||
{readout ? (
|
||||
<div className="min-w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs leading-tight">
|
||||
<div className="font-medium">
|
||||
{si(points[points.length - 1].value, READOUT_DIGITS)}
|
||||
</div>
|
||||
{/* A series that never moved has no range worth repeating. */}
|
||||
{low === high ? null : (
|
||||
<div className="text-muted-foreground">
|
||||
{si(low, READOUT_DIGITS)}–{si(high, READOUT_DIGITS)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import "uplot/dist/uPlot.min.css"
|
||||
|
||||
import type { HistoryPoint } from "@/client"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { compact } from "@/lib/utils"
|
||||
import { si } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* How many lines one chart carries.
|
||||
@@ -29,17 +29,6 @@ function token(name: string): string {
|
||||
|
||||
const seriesColor = (index: number) => token(`--chart-${(index % 5) + 1}`)
|
||||
|
||||
/**
|
||||
* An axis tick, kept short.
|
||||
*
|
||||
* The gutter the ticks are drawn in has a fixed width, so a grouped "15,000"
|
||||
* is clipped to something that reads as a different number entirely.
|
||||
*/
|
||||
const tick = (value: number) =>
|
||||
Math.abs(value) >= 1000
|
||||
? `${+(value / 1000).toPrecision(3)}k`
|
||||
: compact(value)
|
||||
|
||||
/** The series joined onto one x axis, which is what uPlot draws. */
|
||||
function table(plots: HistoryPoint[][]): uPlot.AlignedData {
|
||||
return uPlot.join(
|
||||
@@ -156,7 +145,10 @@ export function UplotChart({
|
||||
{
|
||||
...axis,
|
||||
size: 46,
|
||||
values: (_self: uPlot, ticks: number[]) => ticks.map(tick),
|
||||
// The gutter has a fixed width, so a grouped "15,000" would be
|
||||
// clipped to something that reads as a different number entirely.
|
||||
values: (_self: uPlot, ticks: number[]) =>
|
||||
ticks.map((value) => si(value)),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -168,9 +160,8 @@ export function UplotChart({
|
||||
// rebuilt chart.
|
||||
stroke: () => seriesColor(index),
|
||||
// The cursor readout is what decides how wide the legend gets, so
|
||||
// it is rounded here and the unit named in the card's title.
|
||||
value: (_self: uPlot, raw: number) =>
|
||||
Number.isFinite(raw) ? compact(raw) : "--",
|
||||
// it is shortened here and the unit named in the card's title.
|
||||
value: (_self: uPlot, raw: number) => si(raw),
|
||||
// Series arrive on their own clocks; a joined table is mostly
|
||||
// holes, and a line with a hole per point is not a line.
|
||||
spanGaps: true,
|
||||
|
||||
@@ -75,8 +75,8 @@ export function FlowPanel({
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-6 p-4">
|
||||
<div className="grid gap-3">
|
||||
<span className={PANEL_SECTION}>Running</span>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -94,7 +94,7 @@ export function FlowPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-3">
|
||||
<span className={PANEL_SECTION}>Contents</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-mono">{definition.name}</span> namespaces
|
||||
@@ -106,7 +106,7 @@ export function FlowPanel({
|
||||
</div>
|
||||
|
||||
{hasDraft ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-3">
|
||||
<span className={PANEL_SECTION}>Unpublished changes</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The engine is still running the last published version of this
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useEffect, useId, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import type { HistoryPoint } from "@/client"
|
||||
import { Sparkline } from "@/components/Common/Sparkline"
|
||||
import { qualify } from "./deriveEdges"
|
||||
import { useLiveValue } from "./liveStore"
|
||||
import { messageHistoryQueryOptions } from "./queries"
|
||||
@@ -9,20 +10,6 @@ import { messageHistoryQueryOptions } from "./queries"
|
||||
/** The same bound the server keeps, so the live tail cannot outgrow the window. */
|
||||
const WINDOW = 120
|
||||
|
||||
/** Room above and below the curve for the stroke, in viewBox units. */
|
||||
const PAD = 8
|
||||
|
||||
/** Above this ratio a linear series is all baseline and one spike. */
|
||||
const LOG_RATIO = 100
|
||||
|
||||
/** Short enough for a label, precise enough to tell two of them apart. */
|
||||
function compact(value: number): string {
|
||||
const size = Math.abs(value)
|
||||
if (size === 0) return "0"
|
||||
if (size >= 1e6 || size < 1e-2) return value.toExponential(1)
|
||||
return String(Number(value.toPrecision(4)))
|
||||
}
|
||||
|
||||
/** How a value that cannot be plotted still reads. */
|
||||
function describe(value: unknown): string {
|
||||
if (typeof value === "string") return value
|
||||
@@ -42,46 +29,6 @@ function useSettled(value: string): string {
|
||||
return settled
|
||||
}
|
||||
|
||||
/**
|
||||
* Curve and area for a series, in a 0–100 box.
|
||||
*
|
||||
* The awkward series are the point: one reading has no line to draw, a series
|
||||
* that never moved has no span to divide by, and one spanning decades is only
|
||||
* legible once the exponent is what varies.
|
||||
*/
|
||||
export function shape(points: HistoryPoint[]) {
|
||||
const values = points.map((point) => point.value)
|
||||
const low = Math.min(...values)
|
||||
const high = Math.max(...values)
|
||||
// Logs need every reading on the same side of zero.
|
||||
const logged = low > 0 && high / low >= LOG_RATIO
|
||||
const project = (value: number) => (logged ? Math.log10(value) : value)
|
||||
const floor = project(low)
|
||||
const span = project(high) - floor
|
||||
|
||||
const y = (value: number) =>
|
||||
span === 0
|
||||
? 50
|
||||
: 100 - PAD - ((project(value) - floor) / span) * (100 - 2 * PAD)
|
||||
const x = (index: number) =>
|
||||
points.length === 1 ? 100 : (index / (points.length - 1)) * 100
|
||||
|
||||
const line = points
|
||||
.map(
|
||||
(point, index) =>
|
||||
`${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`,
|
||||
)
|
||||
.join(" ")
|
||||
|
||||
return {
|
||||
low,
|
||||
high,
|
||||
line,
|
||||
area: `${line} L100,100 L0,100 Z`,
|
||||
end: y(values[values.length - 1]),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How one message has been moving, as a sparkline with a live end.
|
||||
*
|
||||
@@ -100,7 +47,6 @@ export function MessageSparkline({
|
||||
const live = useLiveValue(qualify(flow, message))
|
||||
const { data } = useQuery(messageHistoryQueryOptions(flow, message))
|
||||
const [tail, setTail] = useState<HistoryPoint[]>([])
|
||||
const gradient = useId()
|
||||
|
||||
// Pointing the field at another message makes the collected tail meaningless.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the name is what invalidates the tail, not anything the effect reads.
|
||||
@@ -147,64 +93,6 @@ export function MessageSparkline({
|
||||
)
|
||||
}
|
||||
|
||||
const { low, high, line, area, end } = shape(points)
|
||||
|
||||
return (
|
||||
// The gap leaves the live dot room to sit on the last reading without
|
||||
// touching the labels.
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative h-8 min-w-0 flex-1">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="h-full w-full overflow-visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradient} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{points.length > 1 ? (
|
||||
<>
|
||||
<path d={area} fill={`url(#${gradient})`} />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
// Keeps the stroke even, though the box is far wider than tall.
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</svg>
|
||||
{/* The newest reading is always the right edge, so the dot only needs to
|
||||
know how high it sits. */}
|
||||
<span
|
||||
className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20"
|
||||
style={{ left: "100%", top: `${end}%` }}
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary"
|
||||
style={{ left: "100%", top: `${end}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* A shared minimum width, so the curves all end on the same line. */}
|
||||
<div className="min-w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs leading-tight">
|
||||
<div className="font-medium">
|
||||
{compact(points[points.length - 1].value)}
|
||||
</div>
|
||||
{/* A series that never moved has no range worth repeating. */}
|
||||
{low === high ? null : (
|
||||
<div className="text-muted-foreground">
|
||||
{compact(low)}–{compact(high)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
// The value is live here, so the dot on the newest reading is earned.
|
||||
return <Sparkline points={points} />
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ function PortList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={SECTION}>{title}</span>
|
||||
<Button
|
||||
@@ -366,7 +366,7 @@ function FreeParamsForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={SECTION}>Settings</span>
|
||||
<Button
|
||||
@@ -704,7 +704,7 @@ function SharingSection({
|
||||
|
||||
if (shared) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-3">
|
||||
<span className={SECTION}>Shared</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Runs <span className="font-mono">{shared}</span> from the library
|
||||
@@ -730,7 +730,7 @@ function SharingSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-3">
|
||||
<span className={SECTION}>Reuse</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Move this node's code to the library so other flows can run it too.
|
||||
@@ -901,7 +901,7 @@ function PanelBody({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("grid gap-5 p-4", expanded && "max-w-2xl")}>
|
||||
<div className={cn("grid gap-6 p-4", expanded && "max-w-2xl")}>
|
||||
<PortList
|
||||
title="Consumes"
|
||||
specs={node.requires ?? []}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { UplotChart } from "@/components/Common/UplotChart"
|
||||
import { useEngineEvents } from "@/components/Flow/liveStore"
|
||||
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn, compact } from "@/lib/utils"
|
||||
import { cn, si } from "@/lib/utils"
|
||||
import {
|
||||
ago,
|
||||
auditQueryOptions,
|
||||
@@ -293,7 +293,7 @@ export function HealthActivity() {
|
||||
{run.status}
|
||||
</span>
|
||||
<span className="w-16 text-right text-muted-foreground">
|
||||
{compact(run.duration_ms)} ms
|
||||
{si(run.duration_ms)} ms
|
||||
</span>
|
||||
<span className="w-16 text-right text-xs text-muted-foreground">
|
||||
{ago(run.started_at)}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
import type { FlowRollup, HistoryPoint } from "@/client"
|
||||
import { shape } from "@/components/Flow/MessageSparkline"
|
||||
import { Sparkline } from "@/components/Common/Sparkline"
|
||||
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { compact } from "@/lib/utils"
|
||||
import { si } from "@/lib/utils"
|
||||
import {
|
||||
ago,
|
||||
CARD,
|
||||
@@ -34,7 +34,13 @@ function Tile({
|
||||
)
|
||||
}
|
||||
|
||||
/** A flow's execution trend, drawn from the 60 slices the rollup carries. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function Spark({ counts }: { counts: number[] }) {
|
||||
const points: HistoryPoint[] = counts.map((value, index) => ({
|
||||
ts: index,
|
||||
@@ -43,22 +49,14 @@ function Spark({ counts }: { counts: number[] }) {
|
||||
if (points.every((point) => point.value === 0)) {
|
||||
return <span className="text-xs text-muted-foreground">nothing yet</span>
|
||||
}
|
||||
const { line } = shape(points)
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="h-6 w-24 overflow-visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth="1.5"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<Sparkline
|
||||
points={points}
|
||||
color="var(--chart-1)"
|
||||
height="h-6"
|
||||
dot={false}
|
||||
readout={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,8 +127,8 @@ export function HealthOverview() {
|
||||
/>
|
||||
<Tile
|
||||
label="Loop lag"
|
||||
value={`${compact(summary?.loop_lag.ewma ?? 0)} ms`}
|
||||
note={`peak ${compact(summary?.loop_lag.max_60s ?? 0)} ms in the last minute`}
|
||||
value={`${si(summary?.loop_lag.ewma ?? 0)} ms`}
|
||||
note={`peak ${si(summary?.loop_lag.max_60s ?? 0)} ms in the last minute`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -138,21 +136,27 @@ export function HealthOverview() {
|
||||
<section className="grid gap-3">
|
||||
<h2 className={PANEL_SECTION}>Flow activity (24h)</h2>
|
||||
<div className={`${CARD} overflow-x-auto`}>
|
||||
{/* The name anchors the left, the numbers read down their own
|
||||
centre, and the trend takes whatever width is left over. */}
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr className="text-left">
|
||||
<th className="pb-2 font-medium">Flow</th>
|
||||
<th className="pb-2 font-medium">Executions</th>
|
||||
<th className="pb-2 font-medium">Errors</th>
|
||||
<th className="pb-2 font-medium">Avg</th>
|
||||
<th className="pb-2 font-medium">Lag</th>
|
||||
<th className="pb-2 font-medium">Trend</th>
|
||||
<tr>
|
||||
<th className="pb-2 text-left font-medium">Flow</th>
|
||||
<th className="px-3 pb-2 text-center font-medium">
|
||||
Executions
|
||||
</th>
|
||||
<th className="px-3 pb-2 text-center font-medium">Errors</th>
|
||||
<th className="px-3 pb-2 text-center font-medium">Avg</th>
|
||||
<th className="px-3 pb-2 text-center font-medium">Lag</th>
|
||||
<th className="w-full min-w-32 pb-2 pl-3 text-right font-medium">
|
||||
Trend
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(flows ?? []).map((row: FlowRollup) => (
|
||||
<tr key={row.flow} className="border-t border-border">
|
||||
<td className="py-2">
|
||||
<td className="py-2 pr-3">
|
||||
<Link
|
||||
to="/flows/$flowName"
|
||||
params={{ flowName: row.flow }}
|
||||
@@ -161,17 +165,21 @@ export function HealthOverview() {
|
||||
{row.flow || "—"}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2">{row.executions}</td>
|
||||
<td className="py-2">
|
||||
<td className="px-3 py-2 text-center">{row.executions}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{row.errors ? (
|
||||
<Badge variant="destructive">{row.errors} failed</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">none</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">{compact(row.avg_ms)} ms</td>
|
||||
<td className="py-2">{compact(row.avg_lag_ms)} ms</td>
|
||||
<td className="py-2">
|
||||
<td className="whitespace-nowrap px-3 py-2 text-center">
|
||||
{si(row.avg_ms)} ms
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-center">
|
||||
{si(row.avg_lag_ms)} ms
|
||||
</td>
|
||||
<td className="py-2 pl-3 text-right">
|
||||
<Spark counts={row.spark} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The cases `si` gets wrong when its rounding is written the obvious way.
|
||||
*
|
||||
* Run: `bun src/lib/utils.check.ts` (there is no unit runner; the suite in
|
||||
* `tests/` drives a running stack).
|
||||
*/
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import { si } from "./utils"
|
||||
|
||||
// Nothing is prefixed in the plain window: these are written with a unit after
|
||||
// them, and "400m ms" is not an improvement on "0.4 ms".
|
||||
assert.equal(si(0), "0")
|
||||
assert.equal(si(0.4), "0.4")
|
||||
assert.equal(si(203.63), "204")
|
||||
|
||||
// Rounding must not leave a value in the step it just rounded out of.
|
||||
assert.equal(si(999.7), "1k")
|
||||
assert.equal(si(15000), "15k")
|
||||
assert.equal(si(-2.5e6), "-2.5M")
|
||||
|
||||
// A small reading has to survive as itself; a series of these is not zeroes.
|
||||
assert.equal(si(0.0031, 4), "3.1m")
|
||||
assert.equal(si(0.000031), "31µ")
|
||||
assert.equal(si(1e-9), "1.0e-9")
|
||||
|
||||
// Two neighbouring readings stay apart at the sparkline readout's four digits.
|
||||
assert.notEqual(si(1234, 4), si(1235, 4))
|
||||
|
||||
assert.equal(si(Number.NaN), "--")
|
||||
|
||||
console.log("si: ok")
|
||||
@@ -5,12 +5,37 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/** Steps of a thousand, largest first. */
|
||||
const SI_STEPS: [number, string][] = [
|
||||
[1e9, "G"],
|
||||
[1e6, "M"],
|
||||
[1e3, "k"],
|
||||
[1e-3, "m"],
|
||||
[1e-6, "µ"],
|
||||
]
|
||||
|
||||
/**
|
||||
* A reading at about three significant digits: 203.63 → 204, 2.714 → 2.7.
|
||||
* A reading at `digits` significant digits, with an SI prefix where the plain
|
||||
* number would be long: 15000 → "15k", 0.0031 → "3.1m", 203.63 → "204".
|
||||
*
|
||||
* Shared by the health tables and the chart legends, so a value read off the
|
||||
* cursor cannot grow wide enough to push a legend out of its card.
|
||||
* Between 0.01 and 1000 the plain number is the shortest thing to read, so
|
||||
* nothing is prefixed there — "0.4" beats "400m", and a value written with its
|
||||
* unit after it ("0.4 ms") stays honest. Past those bounds the prefix is what
|
||||
* keeps a reading in its column, and beyond giga or micro the exponent is.
|
||||
*
|
||||
* Where it applies: anything read at a glance and bounded by the width of a
|
||||
* tile, a table cell, an axis gutter or a legend. Exact counts (executions,
|
||||
* failures) and anything the user is about to act on — a payload, a form
|
||||
* field, the edge inspector's value — stay unrounded.
|
||||
*/
|
||||
export function compact(value: number): string {
|
||||
return String(Math.abs(value) >= 100 ? Math.round(value) : +value.toFixed(1))
|
||||
export function si(value: number, digits = 3): string {
|
||||
if (!Number.isFinite(value)) return "--"
|
||||
// Rounded before the step is picked, so 999.7 reads as "1k", not "1000".
|
||||
const rounded = Number(value.toPrecision(digits))
|
||||
const size = Math.abs(rounded)
|
||||
if (size === 0) return "0"
|
||||
if (size >= 0.01 && size < 1e3) return String(rounded)
|
||||
if (size >= 1e12 || size < 1e-6) return rounded.toExponential(1)
|
||||
const [factor, suffix] = SI_STEPS.find(([step]) => size >= step) ?? [1, ""]
|
||||
return `${Number((rounded / factor).toPrecision(digits))}${suffix}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user