Let a dashboard be read against runs instead of the live engine
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { slotsFor } from "@/components/Common/UplotChart"
|
||||
import { useLiveValues } from "@/components/Flow/liveStore"
|
||||
import { useBoundValues } from "./dataContext"
|
||||
import { usePalette } from "./settings"
|
||||
import { useUi } from "./ui"
|
||||
import { rowsOf } from "./ui/core/config"
|
||||
@@ -24,7 +24,7 @@ import type { WidgetProps } from "./widgets"
|
||||
export function BarWidget({ widget }: WidgetProps) {
|
||||
const { Bar } = useUi()
|
||||
const rows = rowsOf(widget).filter((row) => row.message)
|
||||
const live = useLiveValues(rows.map((row) => row.message as string))
|
||||
const live = useBoundValues(rows.map((row) => row.message as string))
|
||||
// The dashboard's own data colours, in the order a chart would take them, so
|
||||
// a bar and a chart of the same readings agree about which line is which.
|
||||
const colors = slotsFor(rows.length, usePalette())
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "@/components/Common/RangePicker"
|
||||
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
|
||||
import { useLiveValue, useLiveValues } from "@/components/Flow/liveStore"
|
||||
import { NO_CURVE } from "@/components/Runs/queries"
|
||||
import { type DataContext, useDataContext } from "./dataContext"
|
||||
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
|
||||
import { usePalette } from "./settings"
|
||||
import { useUi } from "./ui"
|
||||
@@ -90,6 +92,10 @@ function merge(fetched: HistoryPoint[], tail: HistoryPoint[], cap: number) {
|
||||
* different set of hooks, and React counts them.
|
||||
*/
|
||||
export function ChartWidget(props: WidgetProps) {
|
||||
// A dashboard opened against something — runs today, a replayed day later —
|
||||
// draws from that instead, whichever source it was pointed at otherwise.
|
||||
const context = useDataContext()
|
||||
if (context) return <ContextChart {...props} context={context} />
|
||||
return config(props.widget).source === "query" ? (
|
||||
<QueryChart {...props} />
|
||||
) : (
|
||||
@@ -97,6 +103,62 @@ export function ChartWidget(props: WidgetProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same chart, drawn from a data context.
|
||||
*
|
||||
* One line per member per bound message: three runs of a two-series chart is
|
||||
* six curves, capped like any other chart at what the ramp can tell apart.
|
||||
*/
|
||||
function ContextChart({
|
||||
widget,
|
||||
context,
|
||||
}: WidgetProps & { context: DataContext }) {
|
||||
const cfg = widget.config as {
|
||||
series?: Series[]
|
||||
runs?: { metric?: string }
|
||||
}
|
||||
const bound = [
|
||||
...(cfg.series ?? []).map((entry) => entry.message),
|
||||
cfg.runs?.metric,
|
||||
].filter((name): name is string => Boolean(name))
|
||||
|
||||
const palette = usePalette()
|
||||
const drawn = bound.flatMap((name) => {
|
||||
const lines = context.lines(name) ?? []
|
||||
// The message name is only worth writing when the chart draws more than
|
||||
// one of them; otherwise the member's own label is the whole story.
|
||||
return lines.map((line) => ({
|
||||
label: bound.length > 1 ? `${name} · ${line.label}` : line.label,
|
||||
points: line.points,
|
||||
}))
|
||||
})
|
||||
const shown = drawn.slice(0, MAX_SERIES)
|
||||
const points = shown.reduce((total, line) => total + line.points.length, 0)
|
||||
|
||||
if (bound.length === 0) {
|
||||
return <p className="text-muted-foreground">Pick a message.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<UplotChart
|
||||
labels={shown.map((line) => line.label)}
|
||||
plots={shown.map((line) => line.points)}
|
||||
palette={palette}
|
||||
xTime={context.xTime}
|
||||
pending={context.pending}
|
||||
empty={points === 0 ? NO_CURVE : undefined}
|
||||
{...presentation(widget.config as Record<string, unknown>)}
|
||||
/>
|
||||
{drawn.length > MAX_SERIES && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Showing {MAX_SERIES} of {drawn.length} lines.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Several messages over time, drawn on one axis.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { shortId } from "@/components/Runs/queries"
|
||||
import type { DataContext } from "./dataContext"
|
||||
|
||||
/**
|
||||
* What this dashboard is being shown against, and the way back out.
|
||||
*
|
||||
* Above the canvas rather than on it: the widgets are the dashboard, and this
|
||||
* is a statement about the whole page. Written generically enough that a
|
||||
* replayed day says its own name here without a second bar.
|
||||
*/
|
||||
export function ContextBar({
|
||||
context,
|
||||
members,
|
||||
onRemove,
|
||||
onExit,
|
||||
}: {
|
||||
context: DataContext
|
||||
/** One chip per member, by id. */
|
||||
members: string[]
|
||||
onRemove: (id: string) => void
|
||||
onExit: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
|
||||
<span className="font-medium text-sm">
|
||||
Showing {context.label}
|
||||
{context.pending ? "…" : ""}
|
||||
</span>
|
||||
|
||||
{members.length > 1 &&
|
||||
members.map((id) => (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 font-mono text-muted-foreground text-xs"
|
||||
>
|
||||
{shortId(id)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(id)}
|
||||
aria-label={`Stop showing ${id}`}
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{members.length > 1 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
single readings come from {shortId(members[0])}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 text-sm">
|
||||
<Link to="/runs" className="text-muted-foreground hover:underline">
|
||||
Runs
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExit}
|
||||
className="text-muted-foreground hover:underline"
|
||||
>
|
||||
Show live
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useBoundValue } from "./dataContext"
|
||||
import { ICON_COLORS, ICONS } from "./icons"
|
||||
import { config } from "./ui/core/config"
|
||||
import type { WidgetProps } from "./widgets"
|
||||
@@ -24,7 +24,7 @@ type ForecastItem = {
|
||||
export function ForecastWidget({ widget }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const message = cfg.message ? String(cfg.message) : ""
|
||||
const live = useLiveValue(message || undefined)
|
||||
const live = useBoundValue(message || undefined)
|
||||
if (!message) {
|
||||
return <p className="text-muted-foreground">Pick a message.</p>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useBoundValue } from "./dataContext"
|
||||
import { ICON_COLORS, ICONS } from "./icons"
|
||||
import { config, text } from "./ui/core/config"
|
||||
import type { WidgetProps } from "./widgets"
|
||||
@@ -33,7 +33,7 @@ const matches = (value: unknown, at: unknown) =>
|
||||
export function IconWidget({ widget }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const live = useLiveValue(message || undefined)
|
||||
const live = useBoundValue(message || undefined)
|
||||
|
||||
const value = live?.value
|
||||
const rules = (cfg.rules ?? []) as Rule[]
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useQueries } from "@tanstack/react-query"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type { RunDetail } from "@/client"
|
||||
import type { LiveValue } from "@/components/Flow/liveStore"
|
||||
import {
|
||||
compareQueryOptions,
|
||||
runQueryOptions,
|
||||
shortId,
|
||||
} from "@/components/Runs/queries"
|
||||
import { type Dashboard, flatWidgets, pagesOf } from "./DashboardView"
|
||||
import {
|
||||
type ContextLine,
|
||||
type DataContext,
|
||||
DataContextProvider,
|
||||
} from "./dataContext"
|
||||
|
||||
/**
|
||||
* The message names this dashboard would want a curve for.
|
||||
*
|
||||
* Only charts: a stat shows one number and reads the run's result instead, so
|
||||
* asking the server for its whole series would be a query nobody draws.
|
||||
*/
|
||||
function chartedNames(dashboard: Dashboard | undefined): string[] {
|
||||
const page = pagesOf(dashboard ?? ({} as Dashboard))[0]
|
||||
if (!page) return []
|
||||
const names = new Set<string>()
|
||||
for (const widget of flatWidgets(page)) {
|
||||
if (widget.type !== "chart") continue
|
||||
const config = (widget.config ?? {}) as {
|
||||
series?: { message?: string }[]
|
||||
runs?: { metric?: string }
|
||||
}
|
||||
for (const entry of config.series ?? [])
|
||||
if (entry.message) names.add(entry.message)
|
||||
if (config.runs?.metric) names.add(config.runs.metric)
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
|
||||
/** A run's own label in a legend, beside four others. */
|
||||
const runLabel = (run: RunDetail) =>
|
||||
run.seed === null || run.seed === undefined
|
||||
? shortId(run.id)
|
||||
: `${shortId(run.id)} (seed ${run.seed})`
|
||||
|
||||
/**
|
||||
* A dashboard drawn against runs rather than against the engine.
|
||||
*
|
||||
* The values a run finished with are its `result`, which the API reports with
|
||||
* the flow prefix stripped — a run of `study` reports `accuracy`, while the
|
||||
* dashboard binds `study.accuracy` — so they are qualified again here. The
|
||||
* curves come from the comparison endpoint, which already answers in the
|
||||
* shape a chart draws.
|
||||
*
|
||||
* Where several runs are shown, a chart draws all of them and anything with
|
||||
* room for one number shows the first, which is the one the bar names.
|
||||
*/
|
||||
export function RunsContextProvider({
|
||||
runIds,
|
||||
dashboard,
|
||||
children,
|
||||
}: {
|
||||
runIds: string[]
|
||||
dashboard: Dashboard | undefined
|
||||
children: ReactNode
|
||||
}) {
|
||||
const runs = useQueries({
|
||||
queries: runIds.map((id) => runQueryOptions(id)),
|
||||
})
|
||||
const names = chartedNames(dashboard)
|
||||
const series = useQueries({
|
||||
queries: names.map((name) => compareQueryOptions(runIds, name)),
|
||||
})
|
||||
|
||||
const loaded = runs
|
||||
.map((query) => query.data)
|
||||
.filter((run): run is RunDetail => Boolean(run))
|
||||
const primary = loaded[0]
|
||||
|
||||
const finals = new Map<string, LiveValue>()
|
||||
if (primary) {
|
||||
const at = primary.finished_at ? Date.parse(String(primary.finished_at)) : 0
|
||||
for (const [key, value] of Object.entries(primary.result ?? {})) {
|
||||
finals.set(key.includes(".") ? key : `${primary.flow}.${key}`, {
|
||||
value,
|
||||
ts: at ? at / 1000 : null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const curves = new Map<string, ContextLine[]>()
|
||||
names.forEach((name, index) => {
|
||||
const answer = series[index]?.data
|
||||
if (!answer) return
|
||||
curves.set(
|
||||
name,
|
||||
(answer.lines ?? []).map((line, position) => ({
|
||||
// The endpoint labels by run id; the run rows carry the nicer name.
|
||||
label: loaded[position] ? runLabel(loaded[position]) : line.label,
|
||||
points: (line.points ?? []).map(([step, value]) => ({
|
||||
ts: step,
|
||||
value,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
const flow = primary?.flow ?? ""
|
||||
const context: DataContext = {
|
||||
kind: "runs",
|
||||
label:
|
||||
runIds.length === 1
|
||||
? `run ${shortId(runIds[0])}${flow ? ` of ${flow}` : ""}`
|
||||
: `${runIds.length} runs${flow ? ` of ${flow}` : ""}`,
|
||||
readOnly: true,
|
||||
// A run's metrics are indexed by step, not by the clock.
|
||||
xTime: false,
|
||||
pending:
|
||||
runs.some((query) => query.isPending) ||
|
||||
series.some((query) => query.isPending),
|
||||
value: (name) => finals.get(name),
|
||||
lines: (name) => curves.get(name),
|
||||
}
|
||||
|
||||
return <DataContextProvider value={context}>{children}</DataContextProvider>
|
||||
}
|
||||
|
||||
/** The runs a dashboard was opened against, from `?runs=a,b,c`. */
|
||||
export const runIdsOf = (runs: string | undefined) =>
|
||||
runs ? runs.split(",").filter(Boolean) : []
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
import type { HistoryPoint } from "@/client"
|
||||
import {
|
||||
type LiveValue,
|
||||
useLiveValue,
|
||||
useLiveValues,
|
||||
} from "@/components/Flow/liveStore"
|
||||
|
||||
/** One member's curve for a bound message: a run, later a replayed day. */
|
||||
export type ContextLine = {
|
||||
label: string
|
||||
/** `ts` carries whatever the x axis is — a step for a run, a moment for a
|
||||
* replay. Which of the two it is, is `xTime` on the context. */
|
||||
points: HistoryPoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a dashboard's widgets read from, when it is not the live engine.
|
||||
*
|
||||
* A dashboard binds its widgets to message names. Normally those resolve
|
||||
* against what the engine is holding right now, which is what makes a wall
|
||||
* panel a wall panel. A data context puts something else behind the same
|
||||
* names, so the page someone already built to watch a training run live is
|
||||
* also the page that shows a finished one — no second dashboard, no widget
|
||||
* that knows what a run is.
|
||||
*
|
||||
* `kind` is for the bar at the top to name what is being shown. Widgets must
|
||||
* not switch on it: the point of this seam is that "three runs" and "last
|
||||
* Tuesday, replayed" are the same shape, and Labs is the second one.
|
||||
*
|
||||
* Deliberately plain data and two lookups rather than hooks, so a provider is
|
||||
* free to re-render with a fresh object whenever its data moves — a run
|
||||
* finishing, a replay ticking — without every consumer subscribing to
|
||||
* something of its own.
|
||||
*
|
||||
* What a consumer may not assume: that x is a time (`xTime`), that there is
|
||||
* exactly one member (`lines` returns as many as there are), or that the data
|
||||
* is settled (`pending`).
|
||||
*/
|
||||
export type DataContext = {
|
||||
kind: string
|
||||
/** What is being shown, in words: "3 runs of demo_training". */
|
||||
label: string
|
||||
/** Nothing here can be published to; the input widgets go quiet. */
|
||||
readOnly: boolean
|
||||
/** Whether the x values are moments. False for a run, whose x is a step. */
|
||||
xTime: boolean
|
||||
pending: boolean
|
||||
/** The single value a stat or a gauge should show for this name. */
|
||||
value: (name: string) => LiveValue | undefined
|
||||
/** One line per member for this name, or undefined if it carries none. */
|
||||
lines: (name: string) => ContextLine[] | undefined
|
||||
}
|
||||
|
||||
const DataCtx = createContext<DataContext | null>(null)
|
||||
|
||||
export const DataContextProvider = DataCtx.Provider
|
||||
|
||||
/** The context a widget is being drawn in, or null on a live dashboard. */
|
||||
export const useDataContext = () => useContext(DataCtx)
|
||||
|
||||
/**
|
||||
* What a widget bound to this name should show.
|
||||
*
|
||||
* The one reading every value widget goes through, so putting a dashboard in
|
||||
* a context is a change in one place rather than in each tile. Both hooks are
|
||||
* called either way — React counts them — and which answer is used is decided
|
||||
* afterwards.
|
||||
*
|
||||
* Not `useLiveValue` itself: a dashboard's own settings read live values too,
|
||||
* and opening a page against a run must not change what look or theme it is
|
||||
* drawn in.
|
||||
*/
|
||||
export function useBoundValue(name: string | undefined) {
|
||||
const context = useDataContext()
|
||||
const live = useLiveValue(name)
|
||||
return context && name ? context.value(name) : live
|
||||
}
|
||||
|
||||
/** The same, for a widget that binds several names at once. */
|
||||
export function useBoundValues(names: string[]) {
|
||||
const context = useDataContext()
|
||||
const live = useLiveValues(names)
|
||||
return context ? names.map((name) => context.value(name)) : live
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { handleError } from "@/utils"
|
||||
// The transmit ring's rule lives with the dashboard's own geometry, and CSS is
|
||||
// chunked per entry — so the sheet is pulled in wherever the pulse is drawn.
|
||||
import "./ui/core/core.css"
|
||||
import { useDataContext } from "./dataContext"
|
||||
import { usePublishMessage } from "./queries"
|
||||
import { useLocked } from "./settings"
|
||||
|
||||
@@ -54,7 +55,10 @@ function confirms(live: unknown, sent: unknown): boolean {
|
||||
export function usePublish(widget: WidgetDef, dashboard: string) {
|
||||
const cfg = (widget.config ?? {}) as Record<string, unknown>
|
||||
const target = cfg.target == null ? "" : String(cfg.target)
|
||||
const locked = useLocked()
|
||||
// A dashboard opened against a run or a replay is a picture of something
|
||||
// that already happened; there is nothing for a control to publish to.
|
||||
const context = useDataContext()
|
||||
const locked = useLocked() || Boolean(context?.readOnly)
|
||||
const publish = usePublishMessage()
|
||||
const live = useLiveValue(target || undefined)
|
||||
const { showErrorToast } = useCustomToast()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import type { WidgetDef } from "@/client"
|
||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BarWidget } from "./BarWidget"
|
||||
import { ChartWidget } from "./ChartWidget"
|
||||
import { ClockWidget } from "./ClockWidget"
|
||||
import { ColorWidget } from "./ColorWidget"
|
||||
import { useBoundValue } from "./dataContext"
|
||||
// The grid's own rules live beside the components that use them; CSS is
|
||||
// chunked per entry, so the sheet is pulled in wherever a widget is drawn.
|
||||
import "./dashboard.css"
|
||||
@@ -197,7 +197,7 @@ function StatWidget({ widget }: WidgetProps) {
|
||||
const { Readout } = useUi()
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const live = useLiveValue(message || undefined)
|
||||
const live = useBoundValue(message || undefined)
|
||||
if (!message) return <Unbound />
|
||||
|
||||
return (
|
||||
@@ -219,7 +219,7 @@ function GaugeWidget({ widget }: WidgetProps) {
|
||||
const { Gauge } = useUi()
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const live = useLiveValue(message || undefined)
|
||||
const live = useBoundValue(message || undefined)
|
||||
if (!message) return <Unbound />
|
||||
|
||||
return (
|
||||
@@ -304,7 +304,7 @@ function dayLabel(when: Date, now: Date): string {
|
||||
function AgendaWidget({ widget }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const live = useLiveValue(message || undefined)
|
||||
const live = useBoundValue(message || undefined)
|
||||
if (!message) return <Unbound />
|
||||
|
||||
const now = new Date()
|
||||
@@ -360,7 +360,7 @@ function AgendaWidget({ widget }: WidgetProps) {
|
||||
function NotificationWidget({ widget }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const live = useLiveValue(message || undefined)
|
||||
const live = useBoundValue(message || undefined)
|
||||
if (!message) return <Unbound />
|
||||
|
||||
const record = (live?.value ?? null) as Record<string, unknown> | null
|
||||
|
||||
@@ -9,17 +9,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { compareQueryOptions, runMetricsQueryOptions, shortId } from "./queries"
|
||||
|
||||
/**
|
||||
* Why a finished run can have nothing to draw.
|
||||
*
|
||||
* A cache hit restores what a node returned, not the values it emitted along
|
||||
* the way, so a run whose training node was reused has a result and no curve.
|
||||
* Said here rather than left as an empty chart, which reads as a fault.
|
||||
*/
|
||||
export const NO_CURVE =
|
||||
"No curve was recorded. A node restored from the cache replays no emissions, so a run that reused an earlier one draws nothing here — its outputs are still on the result."
|
||||
import {
|
||||
compareQueryOptions,
|
||||
NO_CURVE,
|
||||
runMetricsQueryOptions,
|
||||
shortId,
|
||||
} from "./queries"
|
||||
|
||||
/** The metrics one run recorded, in the order they are worth offering. */
|
||||
export function useMetricNames(runId: string | undefined) {
|
||||
|
||||
@@ -17,17 +17,13 @@ import {
|
||||
} from "@/components/ui/table"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { cn, dur, si } from "@/lib/utils"
|
||||
import {
|
||||
MetricPicker,
|
||||
NO_CURVE,
|
||||
RunMetricChart,
|
||||
useMetricNames,
|
||||
} from "./MetricChart"
|
||||
import { MetricPicker, RunMetricChart, useMetricNames } from "./MetricChart"
|
||||
import { OpenInDashboard } from "./OpenInDashboard"
|
||||
import {
|
||||
CARD,
|
||||
downloadArtifact,
|
||||
isLive,
|
||||
NO_CURVE,
|
||||
paramText,
|
||||
runQueryOptions,
|
||||
shortCommit,
|
||||
|
||||
@@ -125,6 +125,16 @@ export async function downloadArtifact(digest: string, name: string) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a finished run can have nothing to draw.
|
||||
*
|
||||
* A cache hit restores what a node returned, not the values it emitted along
|
||||
* the way, so a run whose training node was reused has a result and no curve.
|
||||
* Said out loud rather than left as an empty chart, which reads as a fault.
|
||||
*/
|
||||
export const NO_CURVE =
|
||||
"No curve was recorded. A node restored from the cache replays no emissions, so a run that reused an earlier one draws nothing here — its outputs are still on the result."
|
||||
|
||||
/** A run id, short enough for a table cell. The tail is the random half. */
|
||||
export const shortId = (id: string) => id.slice(-8)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user