rvlib-shipkit 0.38.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// THE ONE RUN CHART: every check run a bar at its true start and length,
|
|
2
|
+
// parallel ones stacked in lanes, each invocation a band behind its bars, now
|
|
3
|
+
// on the right. The live strip under the header draws it, folded over a
|
|
4
|
+
// rolling window and unfolded over the whole record, and nothing else draws a
|
|
5
|
+
// run. Geometry is timeline-geometry.ts, view state chart-view.ts; this file
|
|
6
|
+
// only draws.
|
|
7
|
+
import { observer } from 'mobx-react-lite'
|
|
8
|
+
import { useEffect, useLayoutEffect, useRef } from 'react'
|
|
9
|
+
import type { RepoLane } from '../../ledger/live-timeline.ts'
|
|
10
|
+
import type { TimelineBar } from '../../ledger/run-timeline.ts'
|
|
11
|
+
import type { ChartTip, ChartView } from './chart-view.ts'
|
|
12
|
+
import { durationTone, fmtMs } from './dur.ts'
|
|
13
|
+
import { bandLabels, gapSlices, ROWS, ticks, xOf, type GapSlice, type PlacedBar } from './timeline-geometry.ts'
|
|
14
|
+
import { CACHED } from '../../core/outcome-words.ts'
|
|
15
|
+
|
|
16
|
+
/** who started a run, in words. A starter this build does not name reads as
|
|
17
|
+
* the word the record holds */
|
|
18
|
+
export const BY_WORD: Record<string, string> = { cli: 'terminal', hub: 'hub', schedule: 'daily pass', save: 'save trigger', unknown: 'before the record' }
|
|
19
|
+
export const byWords = (by: string): string => BY_WORD[by] ?? by
|
|
20
|
+
|
|
21
|
+
/** the one tone a bar wears: running in the accent, then the verdict, a
|
|
22
|
+
* cached grey, and a pass that ran in the duration tone the hub uses */
|
|
23
|
+
export const barTone = (b: TimelineBar): string =>
|
|
24
|
+
b.running === true ? 'run' : b.status === 'fail' ? 'red' : b.status === 'warn' ? 'warn' : b.replayed || b.status === 'skip' ? 'rep' : durationTone(b.ms) ?? 'plain'
|
|
25
|
+
|
|
26
|
+
/** what a bar did, in words: `passed in 900ms`, `running, 4 200ms so far` */
|
|
27
|
+
export const verdictWords = (b: TimelineBar, now?: number): string =>
|
|
28
|
+
b.running === true
|
|
29
|
+
? `running, ${fmtMs((now ?? b.end) - b.start)} so far`
|
|
30
|
+
: b.replayed ? `${CACHED}, nothing ran` : `${b.status === 'pass' ? 'passed' : b.status === 'fail' ? 'failed' : b.status === 'warn' ? 'warned' : 'skipped'} in ${fmtMs(b.ms)}`
|
|
31
|
+
|
|
32
|
+
export const clockWords = (t: number): string => new Date(t).toLocaleTimeString([], { hour12: false })
|
|
33
|
+
|
|
34
|
+
/** what hovering an idle slice adds under its words: when it began, and
|
|
35
|
+
* when it ended or that it still grows */
|
|
36
|
+
export const gapTipWords = (g: GapSlice): string => (g.live ? `since ${clockWords(g.t0)}, nothing ran` : `${clockWords(g.t0)} to ${clockWords(g.t1)}, nothing ran`)
|
|
37
|
+
|
|
38
|
+
/** a point on the viewport: the tooltip is fixed there, out of every box
|
|
39
|
+
* of the strip that clips */
|
|
40
|
+
export type TipAt = { x: number; y: number }
|
|
41
|
+
|
|
42
|
+
/** what hovering a bar says: its check, its verdict with its duration or its
|
|
43
|
+
* elapsed time, when it started, the run and who started it */
|
|
44
|
+
export const barTip = (b: PlacedBar, now: number, at: TipAt): ChartTip => ({
|
|
45
|
+
...at,
|
|
46
|
+
head: b.id,
|
|
47
|
+
tone: b.status === 'fail' ? 'red' : b.status === 'warn' ? 'warn' : 'idle',
|
|
48
|
+
lines: [verdictWords(b, now), `${clockWords(b.start)} · ${b.label} · ${byWords(b.by)}`],
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/** right of the pointer, or left of it past the middle of the window, so a
|
|
52
|
+
* tooltip over the running bars at the right edge stays on screen */
|
|
53
|
+
export const tipStyle = (t: TipAt, viewportW: number): { left: number; top: number } | { right: number; top: number } =>
|
|
54
|
+
t.x > viewportW / 2 ? { right: viewportW - t.x, top: t.y } : { left: t.x, top: t.y }
|
|
55
|
+
|
|
56
|
+
/** under the element the pointer entered, at the pointer's x */
|
|
57
|
+
const tipAtEvent = (e: React.MouseEvent<Element>): TipAt => ({ x: e.clientX, y: e.currentTarget.getBoundingClientRect().bottom + 4 })
|
|
58
|
+
|
|
59
|
+
const Tip = observer(({ view }: { view: ChartView }) => {
|
|
60
|
+
const t = view.hover
|
|
61
|
+
if (t == null) return null
|
|
62
|
+
return (
|
|
63
|
+
<div className="tlaxtip" style={tipStyle(t, globalThis.innerWidth ?? Number.POSITIVE_INFINITY)}>
|
|
64
|
+
<b className={`tl ${t.tone}`}>{t.head}</b>
|
|
65
|
+
{t.lines.map((l) => <span key={l}>{l}</span>)}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
export type ChartProps = {
|
|
71
|
+
view: ChartView
|
|
72
|
+
compact?: boolean
|
|
73
|
+
/** other repos with something in flight, one collapsed lane each */
|
|
74
|
+
repos?: RepoLane[]
|
|
75
|
+
/** keep now in sight: while the reader has not scrolled away, the view
|
|
76
|
+
* follows the right edge as the window slides */
|
|
77
|
+
follow?: boolean
|
|
78
|
+
onBar: (b: PlacedBar) => void
|
|
79
|
+
onRepo?: (r: RepoLane) => void
|
|
80
|
+
/** the zoom control, floating over the top right corner: shown while the
|
|
81
|
+
* pointer is on the chart and while it is zoomed away from home */
|
|
82
|
+
zoomable?: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** the key that brings a chart that stopped following back to now */
|
|
86
|
+
export const FOLLOW_KEY = 'k'
|
|
87
|
+
|
|
88
|
+
/** how far a press moves before it is a pan rather than a click */
|
|
89
|
+
export const DRAG_PX = 4
|
|
90
|
+
|
|
91
|
+
/** a press that moved this far is a pan, and never also a click */
|
|
92
|
+
export const isPan = (x0: number, x: number): boolean => Math.abs(x - x0) >= DRAG_PX
|
|
93
|
+
|
|
94
|
+
export const TimelineChart = observer(({ view, compact = false, repos = [], follow = false, onBar, onRepo, zoomable = false }: ChartProps) => {
|
|
95
|
+
const cap = view.cap
|
|
96
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
97
|
+
/** where to scroll once the next zoom has rendered */
|
|
98
|
+
const pending = useRef<number | null>(null)
|
|
99
|
+
const placed = useRef(false)
|
|
100
|
+
/** a pan in progress: a tooltip fixed to the window would stay behind
|
|
101
|
+
* while the bars move under it, so none opens */
|
|
102
|
+
const panning = useRef(false)
|
|
103
|
+
const hover = (t: ChartTip | null): void => {
|
|
104
|
+
if (t == null || !panning.current) view.setHover(t)
|
|
105
|
+
}
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const el = ref.current
|
|
108
|
+
if (el == null) return
|
|
109
|
+
view.setViewW(el.clientWidth)
|
|
110
|
+
const ro = new ResizeObserver(() => view.setViewW(el.clientWidth))
|
|
111
|
+
ro.observe(el)
|
|
112
|
+
// a pan away from the right edge stops following, a pan back to it
|
|
113
|
+
// follows again (chart-view.ts scrolled)
|
|
114
|
+
const onScroll = (): void => { if (follow) view.scrolled(el.scrollLeft, el.clientWidth) }
|
|
115
|
+
el.addEventListener('scroll', onScroll)
|
|
116
|
+
// a vertical wheel zooms, so it must be able to stop the page scroll:
|
|
117
|
+
// react attaches wheel listeners passive, which cannot
|
|
118
|
+
const onWheel = (e: WheelEvent): void => {
|
|
119
|
+
if (compact || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
|
|
120
|
+
e.preventDefault()
|
|
121
|
+
pending.current = view.wheel(e.deltaY, e.clientX - el.getBoundingClientRect().left, el.scrollLeft)
|
|
122
|
+
}
|
|
123
|
+
el.addEventListener('wheel', onWheel, { passive: false })
|
|
124
|
+
// the pan is a drag: the chart shows no scrollbar. A drag past a few
|
|
125
|
+
// px is a pan and never also a click on the bar it started on
|
|
126
|
+
let drag: { x: number; left: number; moved: boolean } | null = null
|
|
127
|
+
let swallow = false
|
|
128
|
+
const onDown = (e: PointerEvent): void => { if (!compact && e.button === 0) drag = { x: e.clientX, left: el.scrollLeft, moved: false } }
|
|
129
|
+
const onMove = (e: PointerEvent): void => {
|
|
130
|
+
if (drag == null) return
|
|
131
|
+
if (!drag.moved && !isPan(drag.x, e.clientX)) return
|
|
132
|
+
if (!drag.moved) {
|
|
133
|
+
drag.moved = true
|
|
134
|
+
panning.current = true
|
|
135
|
+
view.setHover(null)
|
|
136
|
+
}
|
|
137
|
+
el.classList.add('dragging')
|
|
138
|
+
el.scrollLeft = drag.left - (e.clientX - drag.x)
|
|
139
|
+
}
|
|
140
|
+
const onUp = (): void => {
|
|
141
|
+
swallow = drag?.moved === true
|
|
142
|
+
drag = null
|
|
143
|
+
panning.current = false
|
|
144
|
+
el.classList.remove('dragging')
|
|
145
|
+
}
|
|
146
|
+
const onClick = (e: MouseEvent): void => {
|
|
147
|
+
if (!swallow) return
|
|
148
|
+
swallow = false
|
|
149
|
+
e.stopPropagation()
|
|
150
|
+
e.preventDefault()
|
|
151
|
+
}
|
|
152
|
+
el.addEventListener('pointerdown', onDown)
|
|
153
|
+
window.addEventListener('pointermove', onMove)
|
|
154
|
+
window.addEventListener('pointerup', onUp)
|
|
155
|
+
el.addEventListener('click', onClick, true)
|
|
156
|
+
return () => {
|
|
157
|
+
ro.disconnect()
|
|
158
|
+
el.removeEventListener('wheel', onWheel)
|
|
159
|
+
el.removeEventListener('scroll', onScroll)
|
|
160
|
+
el.removeEventListener('pointerdown', onDown)
|
|
161
|
+
window.removeEventListener('pointermove', onMove)
|
|
162
|
+
window.removeEventListener('pointerup', onUp)
|
|
163
|
+
el.removeEventListener('click', onClick, true)
|
|
164
|
+
}
|
|
165
|
+
}, [view, compact, follow])
|
|
166
|
+
const l = view.layout
|
|
167
|
+
useLayoutEffect(() => {
|
|
168
|
+
const el = ref.current
|
|
169
|
+
if (el == null || l == null) return
|
|
170
|
+
if (pending.current != null) {
|
|
171
|
+
el.scrollLeft = pending.current
|
|
172
|
+
pending.current = null
|
|
173
|
+
} else if (!placed.current || (follow && view.follow)) {
|
|
174
|
+
// following: now is the right edge, and that is where the eye is
|
|
175
|
+
el.scrollLeft = el.scrollWidth
|
|
176
|
+
placed.current = true
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
const r = compact ? ROWS.compact : ROWS.full
|
|
180
|
+
const lanes = (l?.lanes ?? 0) + repos.length
|
|
181
|
+
const lanesH = lanes * (r.lane + r.gap)
|
|
182
|
+
const top = r.band + r.conc
|
|
183
|
+
const h = top + Math.max(r.lane, lanesH) + r.axis
|
|
184
|
+
const laneY = (n: number): number => top + n * (r.lane + r.gap)
|
|
185
|
+
// the concurrency band: the count on one scale that always fits the cap
|
|
186
|
+
const concBase = r.band + r.conc - 2
|
|
187
|
+
const concUnit = (r.conc - 4) / Math.max(1, l?.concMax ?? 0, cap ?? 0)
|
|
188
|
+
const now = l?.axis.now ?? 0
|
|
189
|
+
const zoomBy = (f: number): void => {
|
|
190
|
+
const el = ref.current
|
|
191
|
+
if (el != null) pending.current = view.zoomBy(f, el.clientWidth / 2, el.scrollLeft)
|
|
192
|
+
}
|
|
193
|
+
const chart = (
|
|
194
|
+
<div className={`tlax${compact ? ' compact' : ''}`} ref={ref}>
|
|
195
|
+
{l == null ? <div style={{ height: h }} /> : (
|
|
196
|
+
<div className="tlaxin" style={{ width: l.axis.width }}>
|
|
197
|
+
<svg className="tlsvg" width={l.axis.width} height={h}>
|
|
198
|
+
{l.bands.map((b) => (
|
|
199
|
+
<rect key={b.runId} className={`tlband${b.reds > 0 ? ' red' : ''}${b.running ? ' run' : ''}`} x={b.x} y={0} width={Math.max(1, b.w)} height={top + lanesH} />
|
|
200
|
+
))}
|
|
201
|
+
{compact ? null : bandLabels(l.bands).map((b) => (
|
|
202
|
+
<text key={b.runId} className="tlbandlbl" x={b.x} y={11}
|
|
203
|
+
onMouseEnter={(e) => hover({ ...tipAtEvent(e), head: b.full, tone: 'idle', lines: [] })}
|
|
204
|
+
onMouseLeave={() => hover(null)}>{b.text}</text>
|
|
205
|
+
))}
|
|
206
|
+
{compact ? null : (
|
|
207
|
+
<g className="tlconcg">
|
|
208
|
+
{l.conc.map((c) => (
|
|
209
|
+
<rect key={c.x} className={`tlconc${c.over ? ' over' : ''}`} x={c.x} y={concBase - c.n * concUnit} width={Math.max(1, c.w)} height={c.n * concUnit}
|
|
210
|
+
onMouseEnter={(e) => hover({ ...tipAtEvent(e), head: `${c.n} in flight`, tone: c.over ? 'warn' : 'idle', lines: cap == null ? [] : [`cap ${cap}${c.over ? `, ${c.n - cap} over it` : ''}`] })}
|
|
211
|
+
onMouseLeave={() => hover(null)} />
|
|
212
|
+
))}
|
|
213
|
+
{cap == null ? null : (
|
|
214
|
+
<>
|
|
215
|
+
<line className="tlcap" x1={0} x2={l.axis.width} y1={concBase - cap * concUnit} y2={concBase - cap * concUnit} />
|
|
216
|
+
<text className="tlcaplbl" x={l.axis.width - 4} y={concBase - cap * concUnit - 2} textAnchor="end">cap {cap}</text>
|
|
217
|
+
</>
|
|
218
|
+
)}
|
|
219
|
+
</g>
|
|
220
|
+
)}
|
|
221
|
+
{compact ? null : gapSlices(l.axis).map((g) => (
|
|
222
|
+
<g key={g.t0} className={`tlgap${g.live ? ' live' : ''}`}
|
|
223
|
+
onMouseEnter={(e) => hover({ ...tipAtEvent(e), head: g.full, tone: 'idle', lines: [gapTipWords(g)] })}
|
|
224
|
+
onMouseLeave={() => hover(null)}>
|
|
225
|
+
<rect x={g.x} y={0} width={g.w} height={h} />
|
|
226
|
+
{g.text == null ? null : <text x={g.x + g.w / 2} y={top + Math.max(r.lane, lanesH) / 2 + 3} textAnchor="middle">{g.text}</text>}
|
|
227
|
+
</g>
|
|
228
|
+
))}
|
|
229
|
+
{compact ? null : ticks(l.axis).map((k) => (
|
|
230
|
+
<g key={k.t} className="tltick">
|
|
231
|
+
<line x1={k.x} x2={k.x} y1={top + lanesH} y2={top + lanesH + 4} />
|
|
232
|
+
<text x={k.x} y={h - 5} textAnchor="middle">{clockWords(k.t)}</text>
|
|
233
|
+
</g>
|
|
234
|
+
))}
|
|
235
|
+
{l.bars.map((b) => (
|
|
236
|
+
<rect key={`${b.runId}:${b.id}:${b.start}`} className={`tlb ${barTone(b)}`} x={b.x} y={laneY(b.lane)} width={Math.max(2, b.w)} height={r.lane}
|
|
237
|
+
onMouseEnter={(e) => hover(barTip(b, now, tipAtEvent(e)))}
|
|
238
|
+
onMouseLeave={() => hover(null)}
|
|
239
|
+
onClick={() => onBar(b)} />
|
|
240
|
+
))}
|
|
241
|
+
{repos.map((rl, k) => {
|
|
242
|
+
const x = xOf(l.axis, Math.max(rl.start, l.axis.segs[0]?.t0 ?? rl.start))
|
|
243
|
+
const lane = l.lanes + k
|
|
244
|
+
return (
|
|
245
|
+
<rect key={rl.repo} className="tlb repo" x={x} y={laneY(lane)} width={Math.max(2, l.axis.nowX - x)} height={r.lane}
|
|
246
|
+
onMouseEnter={(e) => hover({ ...tipAtEvent(e), head: rl.repo, tone: 'idle', lines: [`${rl.running} running: ${rl.checks.slice(0, 4).join(', ')}${rl.checks.length > 4 ? ', …' : ''}`, `${rl.label} · ${byWords(rl.by)}`, onRepo == null ? '' : 'click opens its hub when it serves'].filter((x) => x !== '') })}
|
|
247
|
+
onMouseLeave={() => hover(null)}
|
|
248
|
+
onClick={() => onRepo?.(rl)} />
|
|
249
|
+
)
|
|
250
|
+
})}
|
|
251
|
+
<line className="tlnow" x1={l.axis.nowX} x2={l.axis.nowX} y1={0} y2={top + lanesH} />
|
|
252
|
+
</svg>
|
|
253
|
+
<Tip view={view} />
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
)
|
|
258
|
+
if (!zoomable && !follow) return chart
|
|
259
|
+
return (
|
|
260
|
+
<div className={`tlchart${view.zoomed ? ' zoomed' : ''}`}>
|
|
261
|
+
{chart}
|
|
262
|
+
<span className="tlfloat">
|
|
263
|
+
{follow && !view.follow
|
|
264
|
+
? <button className="tllive" onClick={() => view.attach()} title={`the view stays where it is; back to now and follow it (${FOLLOW_KEY})`}>● live</button>
|
|
265
|
+
: null}
|
|
266
|
+
{zoomable ? <span className="seg tlzoom">
|
|
267
|
+
<button onClick={() => zoomBy(1 / 2)} disabled={view.level <= 1} title="zoom out">−</button>
|
|
268
|
+
<button onClick={() => zoomBy(1 / view.level)} disabled={view.level <= 1} title="fit the whole record">fit</button>
|
|
269
|
+
<button onClick={() => zoomBy(2)} disabled={view.level >= view.maxZoom} title="zoom in (or the wheel over the chart)">+</button>
|
|
270
|
+
</span> : null}
|
|
271
|
+
</span>
|
|
272
|
+
</div>
|
|
273
|
+
)
|
|
274
|
+
})
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// The horizontal run timeline, as geometry: one time axis with now on the
|
|
2
|
+
// right, every bar at its true start and length, parallel bars stacked in
|
|
3
|
+
// lanes, and the zoom math. Pure, so a test pins every position and the view
|
|
4
|
+
// only draws what this returns. The live strip is its one user, with two
|
|
5
|
+
// windows: folded, a rolling window that ends now; unfolded, the whole record.
|
|
6
|
+
//
|
|
7
|
+
// ONE rule bends a plain linear axis over the record: an idle stretch longer
|
|
8
|
+
// than GAP_MS is folded to a fixed GAP_PX slice that says how long it was,
|
|
9
|
+
// the trailing one (last run to now) included, whose words grow with the
|
|
10
|
+
// clock. Runs are seconds long and hours apart, and a linear axis over twelve
|
|
11
|
+
// of them draws every bar as the same hairline. Inside a stretch of activity
|
|
12
|
+
// the scale is linear and the same for every stretch, so two bars side by
|
|
13
|
+
// side compare by width.
|
|
14
|
+
import type { TimelineBar, TimelineInvocation } from '../../ledger/run-timeline.ts'
|
|
15
|
+
|
|
16
|
+
/** an idle stretch longer than this is folded into a break */
|
|
17
|
+
export const GAP_MS = 60_000
|
|
18
|
+
/** how wide a folded idle slice is drawn: room for `idle 29m 55s` */
|
|
19
|
+
export const GAP_PX = 80
|
|
20
|
+
/** the margin at either end of the axis */
|
|
21
|
+
export const PAD_PX = 8
|
|
22
|
+
/** the zoom range. 1 fits the whole record in the view */
|
|
23
|
+
export const ZOOM_MIN = 1
|
|
24
|
+
/** the finest scale the zoom reaches, in px per ms: a millisecond is never
|
|
25
|
+
* worth more than this */
|
|
26
|
+
export const MAX_PX_PER_MS = 0.5
|
|
27
|
+
/** ticks are never closer than this */
|
|
28
|
+
export const TICK_MIN_PX = 72
|
|
29
|
+
|
|
30
|
+
export type Span = { start: number; end: number }
|
|
31
|
+
|
|
32
|
+
/** the rows of a chart, in px: band labels, the concurrency band, the lanes,
|
|
33
|
+
* the time axis. Compact is the folded strip: thin lanes and nothing else */
|
|
34
|
+
export const ROWS = {
|
|
35
|
+
full: { band: 16, conc: 20, lane: 8, gap: 2, axis: 18 },
|
|
36
|
+
compact: { band: 0, conc: 0, lane: 3, gap: 1, axis: 0 },
|
|
37
|
+
} as const
|
|
38
|
+
|
|
39
|
+
/** how tall a chart with `lanes` lanes draws */
|
|
40
|
+
export const chartHeight = (lanes: number, compact = false): number => {
|
|
41
|
+
const r = compact ? ROWS.compact : ROWS.full
|
|
42
|
+
return r.band + r.conc + Math.max(r.lane, lanes * (r.lane + r.gap)) + r.axis
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** a stretch of activity drawn linearly, from x0 */
|
|
46
|
+
export type Segment = { t0: number; t1: number; x0: number }
|
|
47
|
+
/** a folded idle stretch, drawn at x, GAP_PX wide */
|
|
48
|
+
export type Gap = { x: number; t0: number; t1: number }
|
|
49
|
+
|
|
50
|
+
export type Axis = { segs: Segment[]; gaps: Gap[]; pxPerMs: number; width: number; now: number; nowX: number }
|
|
51
|
+
|
|
52
|
+
/** the stretches of activity: every span merged with the next one when the
|
|
53
|
+
* idle time between them is short enough to draw as it is, and the last one
|
|
54
|
+
* carried to now when now is close */
|
|
55
|
+
export function stretches(spans: Span[], now: number, gapMs = GAP_MS): Span[] {
|
|
56
|
+
const sorted = spans.filter((s) => s.end >= s.start).sort((a, b) => a.start - b.start)
|
|
57
|
+
const out: Span[] = []
|
|
58
|
+
for (const s of sorted) {
|
|
59
|
+
const last = out.at(-1)
|
|
60
|
+
if (last != null && s.start - last.end <= gapMs) last.end = Math.max(last.end, s.end)
|
|
61
|
+
else out.push({ ...s })
|
|
62
|
+
}
|
|
63
|
+
const last = out.at(-1)
|
|
64
|
+
if (last == null) return [{ start: now, end: now }]
|
|
65
|
+
if (now - last.end <= gapMs) last.end = Math.max(last.end, now)
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** a rolling window: one linear stretch from `from` to now, no breaks */
|
|
70
|
+
export function windowAxis(from: number, now: number, pxPerMs: number): Axis {
|
|
71
|
+
const seg = { t0: from, t1: now, x0: PAD_PX }
|
|
72
|
+
const nowX = PAD_PX + (now - from) * pxPerMs
|
|
73
|
+
return { segs: [seg], gaps: [], pxPerMs, width: nowX + PAD_PX, now, nowX }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** the scale that fits a rolling window in `viewW` */
|
|
77
|
+
export const fitWindow = (from: number, now: number, viewW: number): number => Math.max(1, viewW - 2 * PAD_PX) / Math.max(1, now - from)
|
|
78
|
+
|
|
79
|
+
/** the scale that fits every stretch and every break in `viewW` */
|
|
80
|
+
export function fitScale(spans: Span[], now: number, viewW: number): number {
|
|
81
|
+
const st = stretches(spans, now)
|
|
82
|
+
const active = st.reduce((a, s) => a + (s.end - s.start), 0)
|
|
83
|
+
const breaks = st.length - 1 + (now > st.at(-1)!.end ? 1 : 0)
|
|
84
|
+
const room = Math.max(1, viewW - 2 * PAD_PX - breaks * GAP_PX)
|
|
85
|
+
return active <= 0 ? MAX_PX_PER_MS : Math.min(MAX_PX_PER_MS, room / active)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** the axis at a given scale */
|
|
89
|
+
export function buildAxis(spans: Span[], now: number, pxPerMs: number): Axis {
|
|
90
|
+
const st = stretches(spans, now)
|
|
91
|
+
const segs: Segment[] = []
|
|
92
|
+
const gaps: Gap[] = []
|
|
93
|
+
let x = PAD_PX
|
|
94
|
+
st.forEach((s, i) => {
|
|
95
|
+
if (i > 0) {
|
|
96
|
+
gaps.push({ x, t0: st[i - 1]!.end, t1: s.start })
|
|
97
|
+
x += GAP_PX
|
|
98
|
+
}
|
|
99
|
+
segs.push({ t0: s.start, t1: s.end, x0: x })
|
|
100
|
+
x += (s.end - s.start) * pxPerMs
|
|
101
|
+
})
|
|
102
|
+
const lastEnd = st.at(-1)!.end
|
|
103
|
+
if (now > lastEnd) {
|
|
104
|
+
// the trailing slice: fixed width, its words grow with the clock
|
|
105
|
+
gaps.push({ x, t0: lastEnd, t1: now })
|
|
106
|
+
x += GAP_PX
|
|
107
|
+
}
|
|
108
|
+
return { segs, gaps, pxPerMs, width: x + PAD_PX, now, nowX: x }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** where a moment is drawn. A moment inside a break sits on the break's
|
|
112
|
+
* proportional point, so the inverse below round trips everywhere */
|
|
113
|
+
export function xOf(a: Axis, t: number): number {
|
|
114
|
+
for (const s of a.segs) if (t >= s.t0 && t <= s.t1) return s.x0 + (t - s.t0) * a.pxPerMs
|
|
115
|
+
for (const g of a.gaps) if (t > g.t0 && t < g.t1) return g.x + ((t - g.t0) / (g.t1 - g.t0)) * GAP_PX
|
|
116
|
+
const first = a.segs[0]
|
|
117
|
+
if (first != null && t < first.t0) return PAD_PX
|
|
118
|
+
return a.nowX
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** which moment is drawn at x */
|
|
122
|
+
export function tOf(a: Axis, x: number): number {
|
|
123
|
+
for (const s of a.segs) {
|
|
124
|
+
const x1 = s.x0 + (s.t1 - s.t0) * a.pxPerMs
|
|
125
|
+
if (x >= s.x0 && x <= x1) return s.t0 + (x - s.x0) / a.pxPerMs
|
|
126
|
+
}
|
|
127
|
+
for (const g of a.gaps) if (x > g.x && x < g.x + GAP_PX) return g.t0 + ((x - g.x) / GAP_PX) * (g.t1 - g.t0)
|
|
128
|
+
return x < PAD_PX ? (a.segs[0]?.t0 ?? a.now) : a.now
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** each bar's lane: the first lane free by the time it starts, so bars that
|
|
132
|
+
* ran at once stack and bars that ran one after another share a lane */
|
|
133
|
+
export function packLanes(bars: Span[], cap = Number.POSITIVE_INFINITY): { lanes: number[]; count: number; hidden: number } {
|
|
134
|
+
const order = bars.map((b, i) => i).sort((i, j) => bars[i]!.start - bars[j]!.start || bars[i]!.end - bars[j]!.end)
|
|
135
|
+
const ends: number[] = []
|
|
136
|
+
// -1 = past the cap: counted, never drawn over another bar
|
|
137
|
+
const lanes: number[] = new Array(bars.length).fill(-1)
|
|
138
|
+
let hidden = 0
|
|
139
|
+
for (const i of order) {
|
|
140
|
+
const b = bars[i]!
|
|
141
|
+
let lane = ends.findIndex((e) => e <= b.start)
|
|
142
|
+
if (lane === -1) {
|
|
143
|
+
if (ends.length >= cap) { hidden++; continue }
|
|
144
|
+
lane = ends.length
|
|
145
|
+
ends.push(b.end)
|
|
146
|
+
} else ends[lane] = b.end
|
|
147
|
+
lanes[i] = lane
|
|
148
|
+
}
|
|
149
|
+
return { lanes, count: ends.length, hidden }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** the zoom after one wheel step, clamped to the range this record allows */
|
|
153
|
+
export function zoomStep(zoom: number, deltaY: number, maxZoom: number): number {
|
|
154
|
+
return Math.min(maxZoom, Math.max(ZOOM_MIN, zoom * Math.exp(-deltaY * 0.002)))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** the most a record can be zoomed: until a millisecond is MAX_PX_PER_MS */
|
|
158
|
+
export const maxZoom = (fit: number): number => Math.max(ZOOM_MIN, MAX_PX_PER_MS / fit)
|
|
159
|
+
|
|
160
|
+
/** where to scroll after a zoom so the moment under the pointer stays under
|
|
161
|
+
* it. `cx` is the pointer's offset inside the viewport */
|
|
162
|
+
export function anchorScroll(before: Axis, after: Axis, scrollLeft: number, cx: number): number {
|
|
163
|
+
const t = tOf(before, scrollLeft + cx)
|
|
164
|
+
return Math.max(0, xOf(after, t) - cx)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const STEPS = [100, 250, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000, 1_800_000, 3_600_000]
|
|
168
|
+
|
|
169
|
+
/** the tick step for a scale: the smallest round step at least TICK_MIN_PX wide */
|
|
170
|
+
export const tickStep = (pxPerMs: number): number => STEPS.find((s) => s * pxPerMs >= TICK_MIN_PX) ?? STEPS.at(-1)!
|
|
171
|
+
|
|
172
|
+
/** the ticks along every stretch, at round moments of the clock */
|
|
173
|
+
export function ticks(a: Axis): { x: number; t: number }[] {
|
|
174
|
+
const step = tickStep(a.pxPerMs)
|
|
175
|
+
const out: { x: number; t: number }[] = []
|
|
176
|
+
for (const s of a.segs) {
|
|
177
|
+
for (let t = Math.ceil(s.t0 / step) * step; t <= s.t1; t += step) out.push({ x: xOf(a, t), t })
|
|
178
|
+
}
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** an idle stretch, in short units: `42s`, `4m 12s`, `3h 5m`, `2d 4h`.
|
|
183
|
+
* Floored, so a stretch that grows with the clock turns over once a second,
|
|
184
|
+
* and seconds are dropped past an hour */
|
|
185
|
+
export function idleWords(ms: number): string {
|
|
186
|
+
const s = Math.max(0, Math.floor(ms / 1_000))
|
|
187
|
+
if (s < 60) return `${s}s`
|
|
188
|
+
const m = Math.floor(s / 60)
|
|
189
|
+
if (m < 60) return `${m}m ${s % 60}s`
|
|
190
|
+
const h = Math.floor(m / 60)
|
|
191
|
+
if (h < 48) return `${h}h ${m % 60}m`
|
|
192
|
+
return `${Math.floor(h / 24)}d ${h % 24}h`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** a folded idle stretch as drawn: its slice, the words inside it (null when
|
|
196
|
+
* not even the bare duration fits, the tooltip then says it), the whole
|
|
197
|
+
* words, and whether it is the trailing one, growing until the next run */
|
|
198
|
+
export type GapSlice = { x: number; w: number; t0: number; t1: number; text: string | null; full: string; live: boolean }
|
|
199
|
+
|
|
200
|
+
/** every idle slice of an axis: `idle 4m 12s` when it fits, `4m 12s` when
|
|
201
|
+
* only the duration does, no words when not even that */
|
|
202
|
+
export function gapSlices(a: Axis, charPx = 6, padPx = 3): GapSlice[] {
|
|
203
|
+
const room = Math.floor((GAP_PX - 2 * padPx) / charPx)
|
|
204
|
+
return a.gaps.map((g) => {
|
|
205
|
+
const bare = idleWords(g.t1 - g.t0)
|
|
206
|
+
const full = `idle ${bare}`
|
|
207
|
+
const text = full.length <= room ? full : bare.length <= room ? bare : null
|
|
208
|
+
return { x: g.x, w: GAP_PX, t0: g.t0, t1: g.t1, text, full, live: g.t1 === a.now }
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type PlacedBar = TimelineBar & { x: number; w: number; lane: number; runId: string; label: string; by: string }
|
|
213
|
+
export type PlacedBand = { runId: string; label: string; x: number; w: number; reds: number; running: boolean }
|
|
214
|
+
|
|
215
|
+
/** one step of the concurrency band: `n` checks in flight from x for w px */
|
|
216
|
+
export type ConcStep = { x: number; w: number; n: number; over: boolean }
|
|
217
|
+
|
|
218
|
+
export type Layout = {
|
|
219
|
+
axis: Axis
|
|
220
|
+
bars: PlacedBar[]
|
|
221
|
+
bands: PlacedBand[]
|
|
222
|
+
lanes: number
|
|
223
|
+
hidden: number
|
|
224
|
+
/** how many checks were in flight at each moment, as steps */
|
|
225
|
+
conc: ConcStep[]
|
|
226
|
+
/** the most in flight at once in view */
|
|
227
|
+
concMax: number
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** how many bars are in flight at each moment, as a step function: the
|
|
231
|
+
* count holds from each point to the next. An end and a start at the same
|
|
232
|
+
* moment do not stack, and a bar of no length (a replay) is never in flight */
|
|
233
|
+
export function concurrency(bars: Span[]): { t: number; n: number }[] {
|
|
234
|
+
const ev: [number, number][] = []
|
|
235
|
+
for (const b of bars) if (b.end > b.start) ev.push([b.start, 1], [b.end, -1])
|
|
236
|
+
ev.sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
|
237
|
+
const out: { t: number; n: number }[] = []
|
|
238
|
+
let n = 0
|
|
239
|
+
for (const [t, d] of ev) {
|
|
240
|
+
n += d
|
|
241
|
+
const last = out.at(-1)
|
|
242
|
+
if (last != null && last.t === t) last.n = n
|
|
243
|
+
else out.push({ t, n })
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** the stretches where the count ran past the cap */
|
|
249
|
+
export function overCap(series: { t: number; n: number }[], cap: number): Span[] {
|
|
250
|
+
const out: Span[] = []
|
|
251
|
+
series.forEach((p, i) => {
|
|
252
|
+
const next = series[i + 1]
|
|
253
|
+
if (p.n > cap && next != null) {
|
|
254
|
+
const last = out.at(-1)
|
|
255
|
+
if (last != null && last.end === p.t) last.end = next.t
|
|
256
|
+
else out.push({ start: p.t, end: next.t })
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
return out
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** the label each band shows: whole when it fits before the next band's
|
|
263
|
+
* label, cut with an ellipsis when only part fits, none when not even a
|
|
264
|
+
* few letters do. The full label stays on hover */
|
|
265
|
+
export function bandLabels(bands: { runId: string; label: string; x: number; w: number }[], charPx = 6, padPx = 3): { runId: string; x: number; text: string; full: string }[] {
|
|
266
|
+
const sorted = [...bands].sort((a, b) => a.x - b.x || a.runId.localeCompare(b.runId))
|
|
267
|
+
const out: { runId: string; x: number; text: string; full: string }[] = []
|
|
268
|
+
sorted.forEach((b, i) => {
|
|
269
|
+
const next = sorted.slice(i + 1).find((o) => o.x > b.x)
|
|
270
|
+
const blocked = sorted.slice(i + 1).some((o) => o.x === b.x)
|
|
271
|
+
if (blocked) return
|
|
272
|
+
const room = Math.min(b.w, (next?.x ?? Number.POSITIVE_INFINITY) - b.x) - 2 * padPx
|
|
273
|
+
const fits = Math.floor(room / charPx)
|
|
274
|
+
if (fits >= b.label.length) out.push({ runId: b.runId, x: b.x + padPx, text: b.label, full: b.label })
|
|
275
|
+
else if (fits >= 4) out.push({ runId: b.runId, x: b.x + padPx, text: `${b.label.slice(0, fits - 1)}…`, full: b.label })
|
|
276
|
+
})
|
|
277
|
+
return out
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** every invocation laid on one axis: a band per invocation, and its bars in
|
|
281
|
+
* lanes shared across the whole axis. With `from`, the axis is the rolling
|
|
282
|
+
* window from there to now, and what lies wholly before it is not drawn. A
|
|
283
|
+
* bar still running ends at now. `laneCap` bounds the lanes, for a strip of
|
|
284
|
+
* a fixed height */
|
|
285
|
+
export function layoutTimeline(invs: TimelineInvocation[], now: number, pxPerMs: number, o: { from?: number; laneCap?: number; cap?: number } = {}): Layout {
|
|
286
|
+
const from = o.from
|
|
287
|
+
const inView = (end: number): boolean => from == null || end >= from
|
|
288
|
+
const endOf = (e: number, running?: boolean): number => (running === true ? now : e)
|
|
289
|
+
const shown = invs.filter((i) => inView(endOf(i.ended, i.running)))
|
|
290
|
+
const axis = from == null ? buildAxis(shown.map((i) => ({ start: i.started, end: endOf(i.ended, i.running) })), now, pxPerMs) : windowAxis(from, now, pxPerMs)
|
|
291
|
+
const flat = shown.flatMap((i) => i.bars.map((b) => ({ ...b, end: endOf(b.end, b.running), runId: i.runId, label: i.label, by: i.by }))).filter((b) => inView(b.end))
|
|
292
|
+
const { lanes, count, hidden } = packLanes(flat, o.laneCap)
|
|
293
|
+
const clip = (t: number): number => (from == null ? t : Math.max(from, t))
|
|
294
|
+
const bars = flat.flatMap((b, i) => {
|
|
295
|
+
if (lanes[i]! < 0) return []
|
|
296
|
+
const x = xOf(axis, clip(b.start))
|
|
297
|
+
return [{ ...b, x, w: xOf(axis, clip(b.end)) - x, lane: lanes[i]! }]
|
|
298
|
+
})
|
|
299
|
+
const bands = shown.map((i) => {
|
|
300
|
+
const x = xOf(axis, clip(i.started))
|
|
301
|
+
return { runId: i.runId, label: i.label, x, w: xOf(axis, clip(endOf(i.ended, i.running))) - x, reds: i.reds, running: i.running === true }
|
|
302
|
+
})
|
|
303
|
+
// every bar counts, the ones past the lane cap included: the band says
|
|
304
|
+
// how many ran, the lanes only what fits
|
|
305
|
+
const series = concurrency(flat.map((b) => ({ start: clip(b.start), end: b.end })))
|
|
306
|
+
const conc: ConcStep[] = []
|
|
307
|
+
series.forEach((p, i) => {
|
|
308
|
+
const next = series[i + 1]
|
|
309
|
+
if (next == null || p.n === 0) return
|
|
310
|
+
const x = xOf(axis, p.t)
|
|
311
|
+
conc.push({ x, w: xOf(axis, next.t) - x, n: p.n, over: o.cap != null && p.n > o.cap })
|
|
312
|
+
})
|
|
313
|
+
return { axis, bars, bands, lanes: count, hidden, conc, concMax: Math.max(0, ...series.map((p) => p.n)) }
|
|
314
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The runs page, as it holds the record: what the server shaped from the run
|
|
2
|
+
// record (ledger/run-timeline.ts), and which invocations the reader opened.
|
|
3
|
+
// Loaded when the page opens and again after a run ends, so it is never a
|
|
4
|
+
// poll the rest of the page pays for. The live strip's unfolded chart reads
|
|
5
|
+
// the same record from here for the runs older than its own window.
|
|
6
|
+
import { testId } from '../../collections/collection-types.ts'
|
|
7
|
+
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
8
|
+
import type { Timeline } from '../../ledger/run-timeline.ts'
|
|
9
|
+
import type { HubSt } from './hub-st.ts'
|
|
10
|
+
|
|
11
|
+
export type { Timeline }
|
|
12
|
+
|
|
13
|
+
export class TimelineSvc {
|
|
14
|
+
data: Timeline | null = null
|
|
15
|
+
error = ''
|
|
16
|
+
loading = false
|
|
17
|
+
/** what the reader opened and closed. The newest invocation is open until
|
|
18
|
+
* the reader closes it, every other one is shut until opened */
|
|
19
|
+
opened = new Set<string>()
|
|
20
|
+
closed = new Set<string>()
|
|
21
|
+
constructor(private st: HubSt) {
|
|
22
|
+
makeAutoObservable<this, 'st'>(this, { st: false, data: observableRef })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
isOpen(runId: string, index: number): boolean {
|
|
26
|
+
return this.opened.has(runId) || (index === 0 && !this.closed.has(runId))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
toggle(runId: string, index: number): void {
|
|
30
|
+
if (this.isOpen(runId, index)) {
|
|
31
|
+
this.opened.delete(runId)
|
|
32
|
+
this.closed.add(runId)
|
|
33
|
+
} else {
|
|
34
|
+
this.closed.delete(runId)
|
|
35
|
+
this.opened.add(runId)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** the check page, from a bar or a row */
|
|
40
|
+
openCheck(checkId: string): void {
|
|
41
|
+
this.st.cols.select('checks', `check:${checkId}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** a test file's page, from a row of the to fix list */
|
|
45
|
+
openTest(file: string): void {
|
|
46
|
+
this.st.cols.select('tests', testId(file))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async load(): Promise<void> {
|
|
50
|
+
runInAction(() => { this.loading = true })
|
|
51
|
+
try {
|
|
52
|
+
const r = await fetch('/api/timeline.json')
|
|
53
|
+
if (!r.ok) throw new Error(await r.text())
|
|
54
|
+
const t = (await r.json()) as Timeline
|
|
55
|
+
runInAction(() => { this.data = t; this.error = '' })
|
|
56
|
+
} catch (e) {
|
|
57
|
+
runInAction(() => { this.error = (e as Error).message })
|
|
58
|
+
} finally {
|
|
59
|
+
runInAction(() => { this.loading = false })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|