rvlib-shipkit 1.2.0 → 1.4.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 +104 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +4 -2
- package/src/assets/kits/identity-kit.ts +15 -13
- package/src/assets/run-assets.ts +15 -10
- package/src/check.ts +26 -10
- package/src/checks/SK001-package-json-shape.check.ts +34 -5
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK014-prose-ste.check.ts +16 -6
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
- package/src/checks/SK024-identity-colors.check.ts +5 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
- package/src/checks/SK028-readme.check.ts +83 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Ci.ts +5 -0
- package/src/cli/cmd_CrossRepoActivity.ts +119 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +9 -1
- package/src/collections/checks.ts +3 -2
- package/src/collections/collection-types.ts +15 -0
- package/src/collections/providers.ts +7 -3
- package/src/collections/test-time.ts +10 -1
- package/src/contracts/apply-contracts.ts +9 -8
- package/src/contracts/contract-types.ts +8 -2
- package/src/contracts/contracts-engine.ts +34 -13
- package/src/contracts/manifests/identity.contract.ts +6 -5
- package/src/contracts/manifests/social.contract.ts +4 -2
- package/src/contracts/manifests/steam.contract.ts +7 -3
- package/src/contracts/web-surface.ts +59 -0
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/budget.ts +19 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-runner.ts +66 -17
- package/src/core/discover-checks.ts +10 -5
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- package/src/core/identity-color.ts +1 -1
- package/src/core/layout.ts +2 -0
- package/src/core/motherhome.ts +88 -66
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +14 -5
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +8 -7
- package/src/core/run-ci.ts +46 -15
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/wire.ts +49 -9
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +138 -0
- package/src/home/activity.ts +256 -0
- package/src/home/global-units.ts +3 -2
- package/src/home/gui-app/ShipkitGui.swift +144 -25
- package/src/home/gui-app.ts +93 -30
- package/src/home/home-launchd.ts +10 -2
- package/src/home/home-routes.ts +34 -6
- package/src/home/home-start.ts +33 -5
- package/src/home/machine-stores.ts +4 -2
- package/src/home/open-app.ts +23 -10
- package/src/home/pm-config.ts +15 -11
- package/src/home/pm-inputs.ts +76 -0
- package/src/home/pm.ts +40 -7
- package/src/home/process-rows.ts +222 -0
- package/src/home/resources.ts +99 -20
- package/src/home/run-daemon.ts +12 -7
- package/src/home/run-pm.ts +30 -5
- package/src/home/serve-web.ts +6 -3
- package/src/home/supervise.ts +16 -1
- package/src/home/widget-text.ts +119 -0
- package/src/home/widgets.ts +32 -3
- package/src/hooks/commit-message.ts +2 -3
- package/src/hooks/run-hooks.ts +32 -5
- package/src/ledger/db.ts +1 -1
- package/src/ledger/gate-checks.ts +20 -6
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +39 -10
- package/src/ledger/store.ts +30 -2
- package/src/ledger/world-cache.ts +63 -14
- package/src/plan/cadence.ts +21 -5
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +138 -13
- package/src/plan/repo-source.ts +120 -8
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +17 -4
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-words.ts +2 -0
- package/src/serve/hub-app/checks-svc.ts +2 -1
- package/src/serve/hub-app/col-tables-view.tsx +124 -0
- package/src/serve/hub-app/col-tables.ts +115 -0
- package/src/serve/hub-app/cols-svc.ts +28 -0
- package/src/serve/hub-app/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- package/src/serve/hub-app/hash-route.ts +7 -1
- package/src/serve/hub-app/header-bar.tsx +79 -104
- package/src/serve/hub-app/home-st.ts +50 -13
- package/src/serve/hub-app/home-strip.css +81 -23
- package/src/serve/hub-app/home-strip.tsx +243 -102
- package/src/serve/hub-app/home.html +4 -0
- package/src/serve/hub-app/hub-app.tsx +103 -161
- package/src/serve/hub-app/hub-types.ts +6 -3
- package/src/serve/hub-app/hub.css +101 -39
- package/src/serve/hub-app/icons.tsx +27 -3
- package/src/serve/hub-app/index.html +4 -0
- package/src/serve/hub-app/keys.ts +44 -9
- package/src/serve/hub-app/live-svc.ts +24 -64
- package/src/serve/hub-app/machine-st.ts +65 -13
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/next-run.ts +1 -1
- package/src/serve/hub-app/palette-st.ts +13 -9
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +21 -85
- package/src/serve/hub-app/planned-view.tsx +12 -7
- package/src/serve/hub-app/planned.css +5 -2
- package/src/serve/hub-app/project-list.css +43 -21
- package/src/serve/hub-app/project-list.tsx +122 -23
- package/src/serve/hub-app/projects-svc.ts +120 -12
- package/src/serve/hub-app/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +26 -7
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/tests-view.tsx +31 -4
- package/src/serve/hub-app/timeline-chart.tsx +31 -27
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +2 -2
- package/src/serve/hub-app/timeline-view.tsx +29 -9
- package/src/serve/hub-runtime.ts +17 -9
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +100 -20
- package/src/serve/project-gutter.ts +34 -13
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/serve-hub.ts +13 -8
- package/src/serve/tracked-watch.ts +33 -1
- package/src/serve/warm-cell.ts +49 -16
- package/src/shipkit-files.ts +20 -4
- package/src/suite/judge-suites.ts +42 -17
- package/src/suite/suite-cli.ts +5 -8
- package/src/wiki/lint-md.ts +2 -0
- package/src/wiki/scan-md.ts +37 -10
- package/src/checks/SK023-stores-under-cap.check.ts +0 -49
- package/src/checks/contracts.check.ts +0 -20
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// THE DOCK, as the page holds it: the run chart docked under the whole page,
|
|
2
|
+
// every house repo's runs on one clock, one group of lanes per repo that ran
|
|
3
|
+
// something in the window. A node of the root (home-st.ts).
|
|
4
|
+
//
|
|
5
|
+
// Three sizes, cycled by one key (keys.ts), and never nothing: the line, one
|
|
6
|
+
// row saying what runs, what waits and the last check that ended; slim, the
|
|
7
|
+
// last two minutes in thin lanes; open, the last ten in the full chart, its
|
|
8
|
+
// height dragged and kept. The size and the height are read from this browser
|
|
9
|
+
// before the first render, so the page paints at its final layout and a
|
|
10
|
+
// toggle moves nothing but the boundary.
|
|
11
|
+
//
|
|
12
|
+
// the one constraint a reader breaks first: it polls ONE route for every repo
|
|
13
|
+
// (/api/house-runs.json, cached on the server), and only while the page is
|
|
14
|
+
// visible. In a background tab it costs nothing.
|
|
15
|
+
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
16
|
+
import type { HouseGroup, HouseView } from '../../ledger/house-runs.ts'
|
|
17
|
+
import type { TimelineBar } from '../../ledger/run-timeline.ts'
|
|
18
|
+
import { ChartView, type ChartSource } from './chart-view.ts'
|
|
19
|
+
import { nextOption, type CycleOption } from './cycle-button.tsx'
|
|
20
|
+
import { ClockSvc } from './clock-svc.ts'
|
|
21
|
+
import { serverNow } from './live-svc.ts'
|
|
22
|
+
import { ROWS, type RowSize } from './timeline-geometry.ts'
|
|
23
|
+
import { ageWords } from './time-ago.tsx'
|
|
24
|
+
import type { HomeSt } from './home-st.ts'
|
|
25
|
+
|
|
26
|
+
export type DockSize = 'line' | 'slim' | 'open'
|
|
27
|
+
|
|
28
|
+
/** the sizes, in the order the size button and the key cycle them */
|
|
29
|
+
export const DOCK_SIZES = [{ value: 'line', label: 'line' }, { value: 'slim', label: 'slim' }, { value: 'open', label: 'open' }] as const satisfies readonly CycleOption<DockSize>[]
|
|
30
|
+
|
|
31
|
+
/** the one key's cycle: the line, slim, open, the line again */
|
|
32
|
+
export const nextSize = (s: DockSize): DockSize => nextOption(DOCK_SIZES, s)
|
|
33
|
+
|
|
34
|
+
/** where this browser keeps the dock, and its heights in px */
|
|
35
|
+
export const DOCK = { sizeKey: 'hub:dock', heightKey: 'hub:dock-height', line: 22, slim: 72, min: 140, max: 720, def: 280 } as const
|
|
36
|
+
|
|
37
|
+
/** how often it asks while it shows: the server shares one answer a second,
|
|
38
|
+
* and the open dock redraws at this pace */
|
|
39
|
+
export const DOCK_POLL_MS = 2_000
|
|
40
|
+
/** the slim dock's window; open, the server's whole window (LIVE_HISTORY_MS) */
|
|
41
|
+
export const SLIM_WINDOW_MS = 120_000
|
|
42
|
+
/** the most lanes one repo takes */
|
|
43
|
+
export const LANE_CAP: Record<Exclude<DockSize, 'line'>, number> = { slim: 3, open: 8 }
|
|
44
|
+
/** the height a group's label needs beside its lanes */
|
|
45
|
+
export const LABEL_PX = 14
|
|
46
|
+
/** the fewest lanes a group takes at a lane size, so its label fits */
|
|
47
|
+
export const minLanesFor = (rows: RowSize): number => Math.ceil(LABEL_PX / (ROWS[rows].lane + ROWS[rows].gap))
|
|
48
|
+
|
|
49
|
+
/** a kept size, read. `hidden`, which an older build kept, is the line now */
|
|
50
|
+
export const parseSize = (v: unknown): DockSize => (v === 'line' || v === 'hidden' ? 'line' : v === 'open' ? 'open' : 'slim')
|
|
51
|
+
|
|
52
|
+
/** an open height in its bounds, or the default for anything else */
|
|
53
|
+
export const clampHeight = (v: unknown): number => {
|
|
54
|
+
const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN
|
|
55
|
+
return Number.isFinite(n) && n >= DOCK.min && n <= DOCK.max ? Math.round(n) : DOCK.def
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** the dock as this browser left it: slim at the default height when it
|
|
59
|
+
* kept nothing or cannot say */
|
|
60
|
+
export function readDock(): { size: DockSize; height: number } {
|
|
61
|
+
try {
|
|
62
|
+
return { size: parseSize(globalThis.localStorage?.getItem(DOCK.sizeKey)), height: clampHeight(globalThis.localStorage?.getItem(DOCK.heightKey)) }
|
|
63
|
+
} catch {
|
|
64
|
+
return { size: 'slim', height: DOCK.def }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const keep = (key: string, v: string): void => {
|
|
69
|
+
try {
|
|
70
|
+
globalThis.localStorage?.setItem(key, v)
|
|
71
|
+
} catch { /* private window or blocked site data: the dock lasts the tab */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** the dock's height in px at a size */
|
|
75
|
+
export const dockPx = (size: DockSize, height: number): number => (size === 'line' ? DOCK.line : size === 'slim' ? DOCK.slim : height)
|
|
76
|
+
|
|
77
|
+
/** a verdict as one glyph */
|
|
78
|
+
const MARK: Record<TimelineBar['status'], string> = { pass: '✓', fail: '✗', warn: '!', skip: '○' }
|
|
79
|
+
|
|
80
|
+
/** the line's words: `2 running · 5 queued · last: app-one typecheck ✓ 12s
|
|
81
|
+
* ago`, `idle` with the last check when the window holds one. Queued is
|
|
82
|
+
* every check of a run in flight that is neither done nor running */
|
|
83
|
+
export function lineWords(view: HouseView | null, now: number): string {
|
|
84
|
+
if (view == null) return 'reading…'
|
|
85
|
+
let last: { repo: string; bar: TimelineBar } | null = null
|
|
86
|
+
let queued = 0
|
|
87
|
+
for (const g of view.groups) {
|
|
88
|
+
queued += g.runs.reduce((a, r) => a + r.total - r.done, 0) - g.busy
|
|
89
|
+
for (const i of g.invocations) for (const b of i.bars) if (b.running !== true && (last == null || b.end > last.bar.end)) last = { repo: g.repo, bar: b }
|
|
90
|
+
}
|
|
91
|
+
const lastWords = last == null ? '' : `last: ${last.repo} ${last.bar.id} ${MARK[last.bar.status]}${last.bar.replayed ? ' cached' : ''} ${ageWords(last.bar.end, now)}`
|
|
92
|
+
const head = view.busy > 0 ? `${view.busy} running · ${Math.max(0, queued)} queued` : 'idle'
|
|
93
|
+
return [head, lastWords, ...(view.held ?? [])].filter((w) => w !== '').join(' · ')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** a pill of the dock's header: a word or two in a dim box, its hover the
|
|
97
|
+
* whole story. The last run adds its repo, its verdict and its age */
|
|
98
|
+
export type DockPill = {
|
|
99
|
+
key: 'state' | 'queued' | 'held' | 'last' | 'settling' | 'window'
|
|
100
|
+
tone: 'dim' | 'accent' | 'warn'
|
|
101
|
+
text: string
|
|
102
|
+
title: string
|
|
103
|
+
repo?: string
|
|
104
|
+
mark?: string
|
|
105
|
+
markTone?: 'ok' | 'red' | 'warn' | 'dim'
|
|
106
|
+
age?: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** the verdict glyph a pill wears, and its colour */
|
|
110
|
+
const PILL_MARK: Record<TimelineBar['status'], { mark: string; tone: 'ok' | 'red' | 'warn' | 'dim' }> = {
|
|
111
|
+
pass: { mark: '✓', tone: 'ok' }, fail: { mark: '✕', tone: 'red' }, warn: { mark: '!', tone: 'warn' }, skip: { mark: '○', tone: 'dim' },
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const windowWords = (ms: number): string => `last ${Math.round(ms / 60_000)} min`
|
|
115
|
+
|
|
116
|
+
/** the longest check id a pill shows whole */
|
|
117
|
+
export const ID_MAX = 16
|
|
118
|
+
export const cutId = (id: string): string => (id.length <= ID_MAX ? id : `${id.slice(0, ID_MAX - 1)}…`)
|
|
119
|
+
|
|
120
|
+
/** `settling: app-one, 48s`, one line of Planner.held */
|
|
121
|
+
const SETTLING = /^settling: (.+), (\d+s)$/
|
|
122
|
+
|
|
123
|
+
/** the header's status, left to right: what runs or idle, what waits, what
|
|
124
|
+
* holds the plan, the last check that ended, the settling repos as one,
|
|
125
|
+
* and slim and open their window. The row folds from the end */
|
|
126
|
+
export function dockPills(view: HouseView | null, now: number, size: DockSize): DockPill[] {
|
|
127
|
+
if (view == null) return [{ key: 'state', tone: 'dim', text: 'reading…', title: 'the runs of every repo are being read' }]
|
|
128
|
+
let last: { repo: string; bar: TimelineBar } | null = null
|
|
129
|
+
let queued = 0
|
|
130
|
+
for (const g of view.groups) {
|
|
131
|
+
queued += g.runs.reduce((a, r) => a + r.total - r.done, 0) - g.busy
|
|
132
|
+
for (const i of g.invocations) for (const b of i.bars) if (b.running !== true && (last == null || b.end > last.bar.end)) last = { repo: g.repo, bar: b }
|
|
133
|
+
}
|
|
134
|
+
queued = Math.max(0, queued)
|
|
135
|
+
const flight = `${view.busy} check(s) in flight in every repo, cap ${view.cap}, ${queued} waiting for a slot`
|
|
136
|
+
const out: DockPill[] = [view.busy > 0
|
|
137
|
+
? { key: 'state', tone: 'accent', text: `${view.busy} running`, title: flight }
|
|
138
|
+
: { key: 'state', tone: 'dim', text: 'idle', title: flight }]
|
|
139
|
+
if (queued > 0) out.push({ key: 'queued', tone: 'dim', text: `${queued} queued`, title: flight })
|
|
140
|
+
const settling: string[] = []
|
|
141
|
+
for (const line of view.held ?? []) {
|
|
142
|
+
const m = line.match(SETTLING)
|
|
143
|
+
if (m != null) settling.push(`${m[1]} ${m[2]}`)
|
|
144
|
+
else out.push({ key: 'held', tone: 'warn', text: line.replace(/ on \d+ cores$/, ''), title: line })
|
|
145
|
+
}
|
|
146
|
+
if (last != null) {
|
|
147
|
+
const v = PILL_MARK[last.bar.status]
|
|
148
|
+
out.push({
|
|
149
|
+
key: 'last', tone: 'dim', repo: last.repo, mark: v.mark, markTone: v.tone, text: cutId(last.bar.id), age: ageWords(last.bar.end, now),
|
|
150
|
+
title: `the last check that ended: ${last.repo} ${last.bar.id} ${v.mark}${last.bar.replayed ? ' cached' : ''} ${ageWords(last.bar.end, now)}`,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
if (settling.length > 0) out.push({ key: 'settling', tone: 'dim', text: `settling ${settling.length}`, title: settling.join('\n') })
|
|
154
|
+
if (size !== 'line') {
|
|
155
|
+
const ms = size === 'open' ? view.historyMs : SLIM_WINDOW_MS
|
|
156
|
+
out.push({ key: 'window', tone: 'dim', text: windowWords(ms), title: 'the window the chart draws, ending now' })
|
|
157
|
+
}
|
|
158
|
+
return out
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** the groups a window draws: a repo that ran nothing since `from` has none,
|
|
162
|
+
* and the rest stand in the order of the project list, a repo it does not
|
|
163
|
+
* list after them by name */
|
|
164
|
+
export function dockGroups(view: HouseView, from: number, order: readonly string[]): HouseGroup[] {
|
|
165
|
+
const at = (repo: string): number => {
|
|
166
|
+
const i = order.indexOf(repo)
|
|
167
|
+
return i < 0 ? order.length : i
|
|
168
|
+
}
|
|
169
|
+
return view.groups
|
|
170
|
+
.filter((g) => g.invocations.some((i) => i.running === true || i.ended >= from))
|
|
171
|
+
.sort((a, b) => at(a.repo) - at(b.repo) || a.repo.localeCompare(b.repo))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** what the chart draws at a size: the window that ends now, one group per repo */
|
|
175
|
+
export function dockSource(view: HouseView | null, now: number, size: DockSize, order: readonly string[]): ChartSource | null {
|
|
176
|
+
if (view == null || size === 'line') return null
|
|
177
|
+
const windowMs = size === 'open' ? view.historyMs : SLIM_WINDOW_MS
|
|
178
|
+
const groups = dockGroups(view, now - windowMs, order)
|
|
179
|
+
return {
|
|
180
|
+
invocations: groups.flatMap((g) => g.invocations),
|
|
181
|
+
groups: groups.map((g) => ({ key: g.repo, invocations: g.invocations })),
|
|
182
|
+
now,
|
|
183
|
+
windowMs,
|
|
184
|
+
laneCap: LANE_CAP[size],
|
|
185
|
+
minLanes: minLanesFor(size === 'open' ? 'full' : 'slim'),
|
|
186
|
+
zoomable: size === 'open',
|
|
187
|
+
cap: view.cap,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export class DockSt {
|
|
192
|
+
size: DockSize
|
|
193
|
+
/** the open height, kept even while the line or slim shows */
|
|
194
|
+
height: number
|
|
195
|
+
view: HouseView | null = null
|
|
196
|
+
/** the page clock when `view` arrived */
|
|
197
|
+
receivedAt = 0
|
|
198
|
+
error = ''
|
|
199
|
+
/** the page is in front: a background tab asks nothing */
|
|
200
|
+
visible = true
|
|
201
|
+
clock = new ClockSvc()
|
|
202
|
+
chart: ChartView
|
|
203
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
204
|
+
private inFlight = false
|
|
205
|
+
private readonly onVisibility = (): void => this.setVisible(globalThis.document?.visibilityState !== 'hidden')
|
|
206
|
+
|
|
207
|
+
constructor(private home: HomeSt) {
|
|
208
|
+
const kept = readDock()
|
|
209
|
+
this.size = kept.size
|
|
210
|
+
this.height = kept.height
|
|
211
|
+
this.chart = new ChartView(null, () => dockSource(this.view, this.now, this.size, this.home.projects.list.map((p) => p.name)))
|
|
212
|
+
makeAutoObservable<this, 'home' | 'timer' | 'inFlight' | 'onVisibility'>(this, { home: false, timer: false, inFlight: false, onVisibility: false, view: observableRef, chart: false, clock: false })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** its height on the page now */
|
|
216
|
+
get px(): number {
|
|
217
|
+
return dockPx(this.size, this.height)
|
|
218
|
+
}
|
|
219
|
+
get rows(): RowSize {
|
|
220
|
+
return this.size === 'open' ? 'full' : 'slim'
|
|
221
|
+
}
|
|
222
|
+
get windowMs(): number {
|
|
223
|
+
return this.size === 'open' ? (this.view?.historyMs ?? SLIM_WINDOW_MS) : SLIM_WINDOW_MS
|
|
224
|
+
}
|
|
225
|
+
/** the server clock, as of the page clock's last tick */
|
|
226
|
+
get now(): number {
|
|
227
|
+
return this.view == null ? this.clock.now : serverNow(this.view, this.receivedAt, this.clock.now)
|
|
228
|
+
}
|
|
229
|
+
get groups(): HouseGroup[] {
|
|
230
|
+
return this.view == null ? [] : dockGroups(this.view, this.now - this.windowMs, this.home.projects.list.map((p) => p.name))
|
|
231
|
+
}
|
|
232
|
+
/** it asks while the page is in front: every size shows something */
|
|
233
|
+
get wanted(): boolean {
|
|
234
|
+
return this.visible
|
|
235
|
+
}
|
|
236
|
+
/** the line's words, on the clock: the header's whole status on hover */
|
|
237
|
+
get line(): string {
|
|
238
|
+
return lineWords(this.view, this.now)
|
|
239
|
+
}
|
|
240
|
+
/** the header's status as pills, on the clock */
|
|
241
|
+
get pills(): DockPill[] {
|
|
242
|
+
return dockPills(this.view, this.now, this.size)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
cycle(): void {
|
|
246
|
+
this.setSize(nextSize(this.size))
|
|
247
|
+
}
|
|
248
|
+
setSize(s: DockSize): void {
|
|
249
|
+
if (s === this.size) return
|
|
250
|
+
this.size = s
|
|
251
|
+
keep(DOCK.sizeKey, s)
|
|
252
|
+
// a size opens live, on its whole window
|
|
253
|
+
this.chart.home()
|
|
254
|
+
this.chart.attach()
|
|
255
|
+
this.sync()
|
|
256
|
+
}
|
|
257
|
+
/** a drag of the separator above the open dock ended at this height */
|
|
258
|
+
setHeight(px: number): void {
|
|
259
|
+
this.height = clampHeight(Math.round(Math.min(DOCK.max, Math.max(DOCK.min, px))))
|
|
260
|
+
keep(DOCK.heightKey, String(this.height))
|
|
261
|
+
}
|
|
262
|
+
setVisible(v: boolean): void {
|
|
263
|
+
this.visible = v
|
|
264
|
+
this.sync()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** start or stop asking, as `wanted` says */
|
|
268
|
+
sync(): void {
|
|
269
|
+
if (this.wanted && this.timer == null) {
|
|
270
|
+
void this.poll()
|
|
271
|
+
this.timer = setInterval(() => void this.poll(), DOCK_POLL_MS)
|
|
272
|
+
} else if (!this.wanted && this.timer != null) {
|
|
273
|
+
clearInterval(this.timer)
|
|
274
|
+
this.timer = null
|
|
275
|
+
}
|
|
276
|
+
// the line's ages and the slim chart's two minutes move every second,
|
|
277
|
+
// so they tick. Open, a thousand bars over ten minutes redraw only
|
|
278
|
+
// when an answer lands (each answer ticks the clock): a second's
|
|
279
|
+
// slide is under two px there, and a redraw a second is what costs
|
|
280
|
+
if (this.wanted && this.size !== 'open') this.clock.start()
|
|
281
|
+
else this.clock.stop()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
boot(): void {
|
|
285
|
+
globalThis.document?.addEventListener('visibilitychange', this.onVisibility)
|
|
286
|
+
this.onVisibility()
|
|
287
|
+
}
|
|
288
|
+
dispose(): void {
|
|
289
|
+
globalThis.document?.removeEventListener('visibilitychange', this.onVisibility)
|
|
290
|
+
this.visible = false
|
|
291
|
+
this.sync()
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async poll(): Promise<void> {
|
|
295
|
+
// a slow answer must not stack a second request behind it
|
|
296
|
+
if (this.inFlight) return
|
|
297
|
+
this.inFlight = true
|
|
298
|
+
try {
|
|
299
|
+
const r = await fetch('/api/house-runs.json')
|
|
300
|
+
if (!r.ok) throw new Error(await r.text())
|
|
301
|
+
const v = (await r.json()) as HouseView
|
|
302
|
+
runInAction(() => {
|
|
303
|
+
this.view = v
|
|
304
|
+
this.receivedAt = Date.now()
|
|
305
|
+
this.clock.tick(this.receivedAt)
|
|
306
|
+
this.error = ''
|
|
307
|
+
})
|
|
308
|
+
} catch (e) {
|
|
309
|
+
runInAction(() => { this.error = `the runs could not be read: ${(e as Error).message}` })
|
|
310
|
+
} finally {
|
|
311
|
+
this.inFlight = false
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** a bar: its check's page in its repo */
|
|
316
|
+
openCheck(repo: string, check: string): void {
|
|
317
|
+
const h = this.home
|
|
318
|
+
if (h.machine != null) return h.openCheck(repo, check)
|
|
319
|
+
const own = h.shown
|
|
320
|
+
if (own != null && own.name === repo) own.cols.select('checks', `check:${check}`)
|
|
321
|
+
else h.projects.onSelect(repo)
|
|
322
|
+
}
|
|
323
|
+
/** a group's label: its repo's page */
|
|
324
|
+
openRepo(repo: string): void {
|
|
325
|
+
const h = this.home
|
|
326
|
+
if (h.machine != null) return h.select(repo)
|
|
327
|
+
if (h.shown?.name !== repo) h.projects.onSelect(repo)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// THE DOCK, drawn: under the whole page, on every page, what shipkit runs in
|
|
2
|
+
// every house repo, as the one run chart (timeline-chart.tsx) with one group
|
|
3
|
+
// of lanes per repo. Its header is one row: the size as a cycling button,
|
|
4
|
+
// then the status as small pills that fold into +N rather than wrap. The
|
|
5
|
+
// state is dock-st.ts; the height is the page's panel (hub-app.tsx).
|
|
6
|
+
import { observer } from 'mobx-react-lite'
|
|
7
|
+
import type { HomeSt } from './home-st.ts'
|
|
8
|
+
import { CycleButton } from './cycle-button.tsx'
|
|
9
|
+
import { DOCK_KEY } from './keys.ts'
|
|
10
|
+
import { DOCK_SIZES, SLIM_WINDOW_MS, windowWords, type DockPill } from './dock-st.ts'
|
|
11
|
+
import { PillRow } from './pill-row.tsx'
|
|
12
|
+
import { TimelineChart } from './timeline-chart.tsx'
|
|
13
|
+
import type { PlacedGroup } from './timeline-geometry.ts'
|
|
14
|
+
|
|
15
|
+
/** what the dock is, on its size button's hover */
|
|
16
|
+
export const DOCK_HELP = `every check shipkit runs in every repo, whoever started it: a page, an agent in a terminal, the plan. The line, the pills alone; slim, the last two minutes, one group of lanes per repo; open, the last ten, zoomed by the wheel and panned by a drag, its height dragged at the top edge`
|
|
17
|
+
|
|
18
|
+
/** a group's label: the repo's icon and its name in its colour */
|
|
19
|
+
const GroupLabel = observer(({ app, g }: { app: HomeSt; g: PlacedGroup }) => {
|
|
20
|
+
const p = app.projects.list.find((x) => x.name === g.key)
|
|
21
|
+
const id = app.projects.identityOf(g.key)
|
|
22
|
+
return (
|
|
23
|
+
<button className="dgroup" onClick={() => app.dock.openRepo(g.key)}
|
|
24
|
+
title={`${g.key}: open the repo${g.hidden > 0 ? `\n${g.hidden} check run(s) did not fit in its lanes` : ''}`}>
|
|
25
|
+
{p?.icon != null ? <img src={p.icon} alt="" width={12} height={12} draggable={false} /> : <i className="dgico" style={{ background: id.colorDark }} />}
|
|
26
|
+
<span style={{ color: id.colorDark }}>{g.key}</span>
|
|
27
|
+
{/* slim folds its lanes on purpose: the count is news only open */}
|
|
28
|
+
{g.hidden > 0 && app.dock.size === 'open' ? <b>+{g.hidden}</b> : null}
|
|
29
|
+
</button>
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** one pill's face: the repo icon on the last run, its verdict, its words, its age */
|
|
34
|
+
const PillFace = observer(({ app, p }: { app: HomeSt; p: DockPill }) => {
|
|
35
|
+
const pj = p.repo == null ? null : app.projects.list.find((x) => x.name === p.repo)
|
|
36
|
+
return (
|
|
37
|
+
<span className={`pchip ${p.tone}`}>
|
|
38
|
+
{p.repo == null ? null : pj?.icon != null
|
|
39
|
+
? <img src={pj.icon} alt="" width={12} height={12} draggable={false} />
|
|
40
|
+
: <i className="dgico" style={{ background: app.projects.identityOf(p.repo).colorDark }} />}
|
|
41
|
+
{p.mark == null ? null : <b className={`pcmark ${p.markTone ?? 'dim'}`}>{p.mark}</b>}
|
|
42
|
+
<span className="pctext">{p.text}</span>
|
|
43
|
+
{p.age == null ? null : <span className="pcage">{p.age}</span>}
|
|
44
|
+
</span>
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export const Dock = observer(({ app }: { app: HomeSt }) => {
|
|
49
|
+
const d = app.dock
|
|
50
|
+
const v = d.view
|
|
51
|
+
const groups = d.groups
|
|
52
|
+
return (
|
|
53
|
+
<div className={`dock ${d.size}`}>
|
|
54
|
+
<div className="dhead">
|
|
55
|
+
<CycleButton options={DOCK_SIZES} value={d.size} onChange={(s) => d.setSize(s)} shortcut={DOCK_KEY} title={DOCK_HELP} />
|
|
56
|
+
<PillRow items={d.pills.map((p) => ({ key: p.key, title: p.title, node: <PillFace app={app} p={p} /> }))} />
|
|
57
|
+
{d.error !== '' ? <span className="warnline">{d.error}</span> : null}
|
|
58
|
+
</div>
|
|
59
|
+
{d.size === 'line' ? null : <div className="dbody">
|
|
60
|
+
{v == null ? null : groups.length === 0
|
|
61
|
+
? <div className="dempty">nothing ran in the {windowWords(d.size === 'open' ? v.historyMs : SLIM_WINDOW_MS)}</div>
|
|
62
|
+
: <TimelineChart view={d.chart} compact={d.size === 'slim'} rows={d.rows} follow zoomable={d.size === 'open'}
|
|
63
|
+
onBar={(b) => { if (b.group != null) d.openCheck(b.group, b.id) }}
|
|
64
|
+
groupLabel={(g) => <GroupLabel app={app} g={g} />} />}
|
|
65
|
+
</div>}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* ── the dock (dock-view.tsx): the run chart under the whole page, every
|
|
2
|
+
repo's runs, one group of lanes each. Its height is its panel's, set by
|
|
3
|
+
the page before the first paint, so nothing inside it sizes the page. The
|
|
4
|
+
header is one row, the chart under it scrolls when the groups outgrow it ── */
|
|
5
|
+
.dock{display:flex;flex-direction:column;height:100%;background:var(--bg-dock);font-size:11px;color:var(--dim)}
|
|
6
|
+
.dhead{display:flex;gap:var(--s2);align-items:center;height:22px;flex:none;padding:0 var(--s3)}
|
|
7
|
+
/* the status pills take the room left, and fold rather than wrap */
|
|
8
|
+
.dhead .pchips{flex:1}
|
|
9
|
+
.dbody{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:0 var(--s3) var(--s1);scrollbar-width:thin;scrollbar-color:var(--line) transparent}
|
|
10
|
+
.dempty{padding:var(--s1) 0;color:var(--dim)}
|
|
11
|
+
.dock .tlax.compact{height:auto;padding-top:0}
|
|
12
|
+
/* a group's label stands in a gutter left of the chart, level with its lanes */
|
|
13
|
+
.tlgroups{display:flex;align-items:flex-start}
|
|
14
|
+
.tlgroups > .tlax,.tlgroups > .tlchart{flex:1;min-width:0}
|
|
15
|
+
.tlgutter{position:relative;flex:none;width:132px}
|
|
16
|
+
.tlglbl{position:absolute;left:0;right:0;display:flex;align-items:center;overflow:hidden}
|
|
17
|
+
.dgroup{display:flex;gap:var(--s1);align-items:center;max-width:100%;padding:0;border:0;background:transparent;font-size:10px;line-height:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
|
|
18
|
+
.dgroup img{flex:none;border-radius:2px}
|
|
19
|
+
.dgroup span{overflow:hidden;text-overflow:ellipsis}
|
|
20
|
+
.dgroup:hover span{text-decoration:underline}
|
|
21
|
+
.dgroup b{font-weight:500;color:var(--warn)}
|
|
22
|
+
.dgico{flex:none;width:8px;height:8px;border-radius:2px}
|
|
23
|
+
.tlgrp{stroke:var(--line)}
|
|
24
|
+
/* the separator over the dock: a 1px rule, dragged only while the dock is open */
|
|
25
|
+
.rsep.hsep{width:auto;height:1px}
|
|
26
|
+
.rsep.hsep::before{top:-4px;bottom:-4px;left:0;right:0;cursor:row-resize}
|
|
27
|
+
.rsep.hsep.fixed::before{cursor:default;pointer-events:none}
|
|
28
|
+
.rsep.hsep.fixed:hover{background:var(--line)}
|
|
29
|
+
/* a size change of the dock slides the boundary (dock-anim.ts): the class is
|
|
30
|
+
on the split only while it runs, so a drag and the first paint stay instant */
|
|
31
|
+
.dockanim>[data-panel]{transition:flex-grow 180ms ease-out}
|
|
32
|
+
@media (prefers-reduced-motion:reduce){.dockanim>[data-panel]{transition:none}}
|
|
33
|
+
/* the runs page's record chart: its (?) in its top left corner */
|
|
34
|
+
.rchart{position:relative;flex:none;overflow:hidden;margin-top:var(--s3)}
|
|
35
|
+
.rchelp{position:absolute;top:0;left:0;z-index:4}
|
|
@@ -20,9 +20,11 @@ export type HashSel =
|
|
|
20
20
|
/** where the test checks' time goes: every one of them (check null), or
|
|
21
21
|
* one test check (`test`, `test:<unit>`) */
|
|
22
22
|
| { t: 'tests'; check: string | null }
|
|
23
|
+
/** what administers the repo: archive, reload its hub, keep it active */
|
|
24
|
+
| { t: 'admin' }
|
|
23
25
|
|
|
24
26
|
export type ParsedHash = {
|
|
25
|
-
kind: 'asset' | 'cat' | 'col' | 'item' | 'status' | 'tests' | 'none'
|
|
27
|
+
kind: 'asset' | 'cat' | 'col' | 'item' | 'status' | 'tests' | 'admin' | 'none'
|
|
26
28
|
/** the collection, for an item. null for everything else */
|
|
27
29
|
col: string | null
|
|
28
30
|
id: string
|
|
@@ -37,6 +39,7 @@ const CAT = 'cat-'
|
|
|
37
39
|
/** prefixed, so no asset id can shadow it */
|
|
38
40
|
const STATUS = 'page:status'
|
|
39
41
|
const TESTS = 'page:tests'
|
|
42
|
+
const ADMIN = 'page:admin'
|
|
40
43
|
|
|
41
44
|
/** what this selection puts in the url. null only for no selection at all,
|
|
42
45
|
* and then the hash is left alone rather than wiped */
|
|
@@ -49,6 +52,7 @@ export function hashFor(sel: HashSel | null): string | null {
|
|
|
49
52
|
case 'item': return `${ITEM}${sel.col}:${sel.id}`
|
|
50
53
|
case 'status': return STATUS
|
|
51
54
|
case 'tests': return sel.check == null ? TESTS : `${TESTS}:${sel.check}`
|
|
55
|
+
case 'admin': return ADMIN
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
|
|
@@ -57,6 +61,7 @@ export function parseHash(raw: string): ParsedHash {
|
|
|
57
61
|
if (raw === '') return none
|
|
58
62
|
if (raw === STATUS) return { kind: 'status', col: null, id: '', tools: null }
|
|
59
63
|
if (raw === TESTS) return { kind: 'tests', col: null, id: '', tools: null }
|
|
64
|
+
if (raw === ADMIN) return { kind: 'admin', col: null, id: '', tools: null }
|
|
60
65
|
// a check id carries colons of its own (`test:rvlib-shipkit`)
|
|
61
66
|
if (raw.startsWith(`${TESTS}:`)) {
|
|
62
67
|
const check = raw.slice(TESTS.length + 1)
|
|
@@ -98,6 +103,7 @@ export function selOf(p: ParsedHash): HashSel | null {
|
|
|
98
103
|
case 'item': return { t: 'item', col: p.col!, id: p.id }
|
|
99
104
|
case 'status': return { t: 'status' }
|
|
100
105
|
case 'tests': return { t: 'tests', check: p.id === '' ? null : p.id }
|
|
106
|
+
case 'admin': return { t: 'admin' }
|
|
101
107
|
}
|
|
102
108
|
}
|
|
103
109
|
|