rvlib-shipkit 1.3.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 +37 -0
- 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 +12 -2
- package/src/check.ts +6 -4
- package/src/checks/SK001-package-json-shape.check.ts +20 -2
- package/src/checks/SK009-commit-ai-tells.check.ts +90 -72
- package/src/checks/SK014-prose-ste.check.ts +16 -6
- package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
- package/src/checks/SK027-agent-wiki-links.check.ts +30 -8
- package/src/checks/SK028-readme.check.ts +6 -2
- package/src/cli/cmd_CrossRepoActivity.ts +4 -1
- package/src/collections/collection-types.ts +15 -0
- package/src/collections/providers.ts +6 -2
- 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/budget.ts +19 -9
- package/src/core/check-runner.ts +26 -5
- package/src/core/discover-checks.ts +8 -5
- package/src/core/layout.ts +2 -0
- package/src/core/motherhome.ts +88 -66
- package/src/core/run-check.ts +2 -5
- package/src/core/run-ci.ts +3 -3
- package/src/core/wire.ts +38 -9
- package/src/home/activity-shape.ts +4 -1
- package/src/home/activity.ts +22 -15
- 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 +2 -1
- 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 +3 -3
- package/src/home/process-rows.ts +23 -6
- package/src/home/resources.ts +67 -15
- package/src/home/run-daemon.ts +6 -1
- package/src/home/run-pm.ts +29 -5
- package/src/home/widgets.ts +32 -3
- package/src/hooks/commit-message.ts +2 -3
- package/src/hooks/run-hooks.ts +12 -2
- package/src/ledger/gate-checks.ts +3 -3
- package/src/ledger/store.ts +11 -0
- package/src/ledger/world-cache.ts +17 -15
- package/src/repo-config.ts +6 -1
- package/src/serve/hub-app/activity-view.tsx +3 -3
- 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/dock.css +1 -1
- package/src/serve/hub-app/home-st.ts +1 -1
- package/src/serve/hub-app/home-strip.css +32 -17
- package/src/serve/hub-app/home-strip.tsx +99 -89
- package/src/serve/hub-app/hub-app.tsx +7 -1
- package/src/serve/hub-app/hub-types.ts +6 -3
- package/src/serve/hub-app/hub.css +24 -2
- package/src/serve/hub-app/icons.tsx +4 -0
- package/src/serve/hub-app/keys.ts +6 -6
- package/src/serve/hub-app/machine-st.ts +26 -37
- package/src/serve/hub-app/palette-st.ts +5 -5
- package/src/serve/hub-app/palette-view.tsx +1 -1
- package/src/serve/hub-app/project-list.tsx +3 -3
- package/src/serve/hub-app/repo-st.ts +1 -1
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/timeline-view.tsx +14 -11
- package/src/serve/project-gutter.ts +1 -2
- package/src/serve/warm-cell.ts +49 -16
- 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/ledger/store.ts
CHANGED
|
@@ -105,6 +105,17 @@ export class Answers {
|
|
|
105
105
|
this.h = null
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/** the busy timeout sqlite holds on the open handle, in ms: how long its
|
|
109
|
+
* last statement could wait on a writer. Null with no handle */
|
|
110
|
+
busyTimeout(): number | null {
|
|
111
|
+
try {
|
|
112
|
+
const r = this.h?.query('pragma busy_timeout').get() as Record<string, number> | null | undefined
|
|
113
|
+
return r == null ? null : (Object.values(r)[0] ?? null)
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
/** the stored answer, or null when the check must run */
|
|
109
120
|
read<T>(ns: string, name: string, key: string): Stored<T> | null {
|
|
110
121
|
const row = this.with(READ_BUSY_MS, (h) => h.query('select v, at, keeps, payload from answers where ns = ? and name = ? and key = ?').get(ns, name, key) as Row | null)
|
|
@@ -31,9 +31,8 @@
|
|
|
31
31
|
// A measured negative may be kept only for a check about the WORLD. One about
|
|
32
32
|
// THIS REPO is fixed by a human and the next run goes green, so replaying its
|
|
33
33
|
// red would be the cache lying about work already done.
|
|
34
|
-
import { FOREVER_MS,
|
|
34
|
+
import { FOREVER_MS, type Line, type Stamp } from '../core/wire.ts'
|
|
35
35
|
import { FRESH_ENV, withAnswers, type Answers, type Stored } from './store.ts'
|
|
36
|
-
import { CACHED, RAN } from '../core/outcome-words.ts'
|
|
37
36
|
|
|
38
37
|
/** the longest a MEASURED NEGATIVE is remembered, whatever TTL was declared.
|
|
39
38
|
* Long enough to stop a re-ask storm inside one working session, short
|
|
@@ -80,11 +79,21 @@ function adoptTimed(a: Answers, ns: string, name: string, key: string): Stored<L
|
|
|
80
79
|
return head != null && e.key.slice(head[0].length) === rest && Array.isArray(e.payload) && e.payload.length > 0 && e.payload.every((l) => l.kind === 'ok' || l.kind === 'note')
|
|
81
80
|
})
|
|
82
81
|
if (old == null) return null
|
|
83
|
-
const payload = old.payload
|
|
82
|
+
const payload = unbake(old.payload)
|
|
84
83
|
a.write([{ ns, name, key, payload, at: old.at, keeps: FOREVER_MS }], { now: old.at })
|
|
85
84
|
return { ...old, key, keeps: FOREVER_MS, payload }
|
|
86
85
|
}
|
|
87
86
|
|
|
87
|
+
/** the times an older build wrote into the first line's text, as `(cached at
|
|
88
|
+
* <stamp> until <stamp>)` or `(ran at <stamp>, kept until <stamp>)`: the
|
|
89
|
+
* entry holds them as data, so the text drops them */
|
|
90
|
+
const BAKED = / \((?:cached|ran) at \d{4}-\d\d-\d\d \d\d:\d\d:\d\d,? (?:kept )?until [^)]*\)$/
|
|
91
|
+
const unbake = (lines: Line[]): Line[] => lines.map((l, i) => (i === 0 ? { ...l, text: l.text.replace(BAKED, '') } : l))
|
|
92
|
+
|
|
93
|
+
/** the first line, stamped with when its answer was measured and until when it keeps */
|
|
94
|
+
const stamped = (lines: Line[], stamp: Stamp): Line[] => lines.map((l, i) => (i === 0 ? { ...l, stamp } : l))
|
|
95
|
+
const untilOf = (at: number, keeps: number): number | null => (keeps >= FOREVER_MS ? null : at + keeps)
|
|
96
|
+
|
|
88
97
|
/** the answer for this key, or the one a forever key adopts */
|
|
89
98
|
const readOrAdopt = (a: Answers, ns: string, name: string, key: string): Stored<Line[]> | null => a.read<Line[]>(ns, name, key) ?? adoptTimed(a, ns, name, key)
|
|
90
99
|
|
|
@@ -104,11 +113,9 @@ export function replayWorld(c: WorldCache, name: string, key: string, ttlMs: num
|
|
|
104
113
|
if (entry == null || !Array.isArray(entry.payload)) return null
|
|
105
114
|
const keeps = entry.keeps ?? ttlMs
|
|
106
115
|
if (now - entry.at >= keeps || now < entry.at) return null
|
|
107
|
-
// the
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
// every fresh-vs-cached alternation, so neither side annotates.
|
|
111
|
-
return entry.payload
|
|
116
|
+
// the times ride as data on the first line, rendered by whoever prints it
|
|
117
|
+
// against its own clock, so a line read a day later says how old it is
|
|
118
|
+
return stamped(unbake(entry.payload), { at: entry.at, until: untilOf(entry.at, keeps), cached: true })
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
export type Measured = {
|
|
@@ -150,11 +157,6 @@ export function rememberWorld(c: WorldCache, name: string, key: string, ttlMs: n
|
|
|
150
157
|
return m.lines
|
|
151
158
|
}
|
|
152
159
|
const now = c.now()
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
: keepsFor >= FOREVER_MS ? { ...l, text: `${l.text} (${CACHED} at ${fmtStamp(now)} until its declaration changes)` }
|
|
156
|
-
: { ...l, text: `${l.text} (${m.clean ? `${CACHED} at ${fmtStamp(now)} until` : `${RAN} at ${fmtStamp(now)}, kept until`} ${fmtStamp(now + keepsFor)})` },
|
|
157
|
-
)
|
|
158
|
-
withAnswers(c.db, (a) => a.write([{ ns: c.ns, name, key, payload: lines, at: now, keeps: keepsFor }], { now }))
|
|
159
|
-
return lines
|
|
160
|
+
withAnswers(c.db, (a) => a.write([{ ns: c.ns, name, key, payload: m.lines, at: now, keeps: keepsFor }], { now }))
|
|
161
|
+
return stamped(m.lines, { at: now, until: untilOf(now, keepsFor) })
|
|
160
162
|
}
|
package/src/repo-config.ts
CHANGED
|
@@ -146,12 +146,17 @@ export type RepoConfig = {
|
|
|
146
146
|
/** notify command for new RED needs while the hub serves, `{message}` substituted, as in a push-notification CLI */
|
|
147
147
|
notifyCommand?: string
|
|
148
148
|
/** the `.shipkit/<venue>/` release contracts: which ones this repo is excused
|
|
149
|
-
* from,
|
|
149
|
+
* from, any manifest the published toolkit does not carry, and the dir
|
|
150
|
+
* its site serves when the deploy does not say. */
|
|
150
151
|
contracts?: {
|
|
151
152
|
/** venue id to the reason it is waived. The only bypass, and it always prints */
|
|
152
153
|
exempt?: Record<string, string>
|
|
153
154
|
/** manifests declared by this repo, beside the built-in venues */
|
|
154
155
|
extra?: ContractManifest[]
|
|
156
|
+
/** the dir the site serves, repo-relative, where the identity contract
|
|
157
|
+
* wants favicon.png, og.png and logo.*. Absent: the dir the deploy names
|
|
158
|
+
* (a Pages workflow's artifact path, a wrangler output dir), else `public` */
|
|
159
|
+
webRoot?: string
|
|
155
160
|
}
|
|
156
161
|
/** repo-contributed .gitignore lines, rendered INSIDE rule SK004's managed
|
|
157
162
|
* block — for ecosystem-specific names the published lib may not carry */
|
|
@@ -71,12 +71,12 @@ export const failingOf = (r: ActivityRow): StatusCheck[] => {
|
|
|
71
71
|
if (r.failing != null) return r.failing
|
|
72
72
|
const c = r.status
|
|
73
73
|
if (c == null) return []
|
|
74
|
-
const one = (id: string, status: 'fail' | 'warn'): StatusCheck => ({ id, title: id, status, at: c.since[id] ?? null, line: '' })
|
|
74
|
+
const one = (id: string, status: 'fail' | 'warn'): StatusCheck => ({ id, title: id, status, at: c.since[id] ?? null, line: '', lines: [] })
|
|
75
75
|
return [...c.reds.map((id) => one(id, 'fail')), ...c.warns.map((id) => one(id, 'warn'))]
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** each failing and warning check: its id (a link to its page), its title,
|
|
79
|
-
* how old that outcome is, and
|
|
79
|
+
* how old that outcome is, and every line it printed about it */
|
|
80
80
|
export const CheckList = observer(({ app, r }: { app: HomeSt; r: ActivityRow }) => {
|
|
81
81
|
const now = app.activity.now
|
|
82
82
|
return (
|
|
@@ -93,7 +93,7 @@ export const CheckList = observer(({ app, r }: { app: HomeSt; r: ActivityRow })
|
|
|
93
93
|
{c.title !== c.id ? <span className="acchktitle">{c.title}</span> : null}
|
|
94
94
|
{c.at == null ? null : <span className="acdim" title={stampWords(c.at)}>{ageShort(c.at, now)}</span>}
|
|
95
95
|
</span>
|
|
96
|
-
{c.line === '' ?
|
|
96
|
+
{(c.lines ?? (c.line === '' ? [] : [c.line])).map((l, i) => <span key={i} className="acchkline">{l}</span>)}
|
|
97
97
|
</li>
|
|
98
98
|
))}
|
|
99
99
|
</ul>
|
|
@@ -12,6 +12,7 @@ import type { RepoSt } from './repo-st.ts'
|
|
|
12
12
|
import { applyRunEvent, idleRun, startRun, stripText, type RunProgress } from './run-strip.ts'
|
|
13
13
|
import type { GateBlock, Resource, ResourceState, SlowSuite, CheckEvent, WhenAt } from './hub-types.ts'
|
|
14
14
|
import { WHEN_OPTION } from './check-words.ts'
|
|
15
|
+
import { lineText } from '../../core/wire.ts'
|
|
15
16
|
|
|
16
17
|
/** the colour rule, the SAME one the server applies, because a row must not
|
|
17
18
|
* change meaning between a live result and the poll that confirms it */
|
|
@@ -199,7 +200,7 @@ export class ChecksSvc {
|
|
|
199
200
|
state: stateOf(e),
|
|
200
201
|
severity: e.status === 'fail' ? (e.advisory ? 'yellow' : 'red') : e.status === 'warn' ? 'yellow' : undefined,
|
|
201
202
|
check: { ...u, status: e.status, replayed: e.replayed, lastMs: e.replayed ? null : Math.round(e.ms), lastAt: Date.now(), advisory: e.advisory },
|
|
202
|
-
md: e.lines.length === 0 ? r.md : `\`\`\`\n${e.lines.map((l) => (l.kind === 'fix-fail' || l.kind === 'fix-warn' ? ` fix: ${l
|
|
203
|
+
md: e.lines.length === 0 ? r.md : `\`\`\`\n${e.lines.map((l) => (l.kind === 'fix-fail' || l.kind === 'fix-warn' ? ` fix: ${lineText(l)}` : l.kind === 'fail' ? `FAIL: ${lineText(l)}` : l.kind === 'warn' ? `WARN ${lineText(l)}` : l.kind === 'skip' ? `skip: ${lineText(l)}` : `ok: ${lineText(l)}`)).join('\n')}\n\`\`\``,
|
|
203
204
|
}
|
|
204
205
|
})
|
|
205
206
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The checks page and the asks page: one dense table each, in the activity
|
|
2
|
+
// page's table style, one line per item under group headers that count their
|
|
3
|
+
// lines. What needs attention comes first; the quiet groups (passing, off,
|
|
4
|
+
// done) fold by default and each viewer's fold choice is kept. The filter
|
|
5
|
+
// input and the severity keys narrow the lines, since both read
|
|
6
|
+
// `visibleInstances`. Groups, order and words: col-tables.ts.
|
|
7
|
+
import { observer } from 'mobx-react-lite'
|
|
8
|
+
import type { ReactNode } from 'react'
|
|
9
|
+
import { checkName } from './check-words.ts'
|
|
10
|
+
import { askGroups, checkGroups, dayMs, findingLine, planWord, type TableGroup } from './col-tables.ts'
|
|
11
|
+
import { durationTone, fmtMs } from './dur.ts'
|
|
12
|
+
import type { Resource } from './hub-types.ts'
|
|
13
|
+
import type { RepoSt } from './repo-st.ts'
|
|
14
|
+
import { TimeAgo } from './time-ago.tsx'
|
|
15
|
+
|
|
16
|
+
const emptyWords = (app: RepoSt): string => (app.filter === 'red' ? 'no reds here' : app.filter === 'needs' ? 'nothing here needs you' : app.query.trim() !== '' ? 'nothing matches the filter' : 'nothing here yet')
|
|
17
|
+
|
|
18
|
+
/** the page frame: the collection's title and count, then the table */
|
|
19
|
+
const Page = ({ title, n, cols, empty, children }: { title: string; n: number; cols: ReactNode; empty: string; children: ReactNode }) => (
|
|
20
|
+
<div className="ctpage">
|
|
21
|
+
<div className="cghead">{title}<span className="cnt">{n}</span></div>
|
|
22
|
+
{n === 0 ? <div className="ph">{empty}</div> : (
|
|
23
|
+
<table className="actable cttable">
|
|
24
|
+
<colgroup>{cols}</colgroup>
|
|
25
|
+
{children}
|
|
26
|
+
</table>
|
|
27
|
+
)}
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
/** one group: its header line (a click folds it), then its lines unless folded */
|
|
32
|
+
const Group = observer(<K extends string>({ app, col, g, span, row }: { app: RepoSt; col: string; g: TableGroup<K>; span: number; row: (r: Resource) => ReactNode }) => {
|
|
33
|
+
const key = `${col}:${g.key}`
|
|
34
|
+
const folded = app.cols.groupFolded(key, g.foldedByDefault)
|
|
35
|
+
return (
|
|
36
|
+
<tbody className="ctgroup">
|
|
37
|
+
<tr className={`ctgrp g-${g.key}`} data-k={g.key} onClick={() => app.cols.toggleGroup(key, g.foldedByDefault)}>
|
|
38
|
+
<td colSpan={span}><span className="ctcaret">{folded ? '▸' : '▾'}</span>{g.label}</td>
|
|
39
|
+
</tr>
|
|
40
|
+
{folded ? null : g.rows.map(row)}
|
|
41
|
+
</tbody>
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const DOT: Record<string, { tone: string; mark: string }> = {
|
|
46
|
+
fail: { tone: 't-red', mark: '●' },
|
|
47
|
+
warn: { tone: 't-warn', mark: '●' },
|
|
48
|
+
pass: { tone: 't-ok', mark: '●' },
|
|
49
|
+
skip: { tone: 'acdim', mark: '●' },
|
|
50
|
+
never: { tone: 'acdim', mark: '○' },
|
|
51
|
+
off: { tone: 'acdim', mark: '○' },
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CheckRow = observer(({ app, r }: { app: RepoSt; r: Resource }) => {
|
|
55
|
+
const c = r.check!
|
|
56
|
+
const name = checkName(c.checkId, r.title)
|
|
57
|
+
const dot = c.when === 'off' ? DOT.off! : c.status == null ? DOT.never! : DOT[c.status]!
|
|
58
|
+
const finding = findingLine(c)
|
|
59
|
+
const tone = durationTone(c.lastMs, c.timeoutMs, c.budgetMs)
|
|
60
|
+
return (
|
|
61
|
+
<tr className={`ctrow s-${c.status ?? 'never'}`} data-k={c.checkId} onClick={() => app.cols.select(r.collection, r.id)} title={c.description}>
|
|
62
|
+
<td className={`ctdot ${dot.tone}`}>{dot.mark}</td>
|
|
63
|
+
<td className={`ctid ${c.status === 'fail' ? 't-red' : c.status === 'warn' ? 't-warn' : ''}`}>{c.checkId}</td>
|
|
64
|
+
<td className="cttitle">{name.title === c.checkId ? '' : name.title}</td>
|
|
65
|
+
<td className="acwhen">{c.lastAt == null ? '' : <TimeAgo clock={app.clock} at={c.lastAt} />}</td>
|
|
66
|
+
<td className="ctdur">
|
|
67
|
+
{c.lastMs == null ? null : <span className={`dnum ${tone ?? ''}`}>{fmtMs(c.lastMs)}</span>}
|
|
68
|
+
<span className="ctbudget">{` / ${fmtMs(c.budgetMs)}`}</span>
|
|
69
|
+
</td>
|
|
70
|
+
<td className="ctfind" title={c.lastLines.length > 0 ? c.lastLines.join('\n') : undefined}>{finding}</td>
|
|
71
|
+
<td className="ctplan">{planWord(c)}</td>
|
|
72
|
+
</tr>
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export const CheckTable = observer(({ app, id }: { app: RepoSt; id: string }) => {
|
|
77
|
+
const col = app.cols.sidebarCols.find((x) => x.id === id)
|
|
78
|
+
if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
|
|
79
|
+
const items = app.cols.visibleInstances(col)
|
|
80
|
+
const cols = (
|
|
81
|
+
<>
|
|
82
|
+
<col className="c-dot" /><col className="c-id" /><col className="c-title" /><col className="c-age" /><col className="c-dur" /><col /><col className="c-plan" />
|
|
83
|
+
</>
|
|
84
|
+
)
|
|
85
|
+
return (
|
|
86
|
+
<Page title={col.title} n={items.length} cols={cols} empty={emptyWords(app)}>
|
|
87
|
+
{checkGroups(items).map((g) => <Group key={g.key} app={app} col={col.id} g={g} span={7} row={(r) => <CheckRow key={r.id} app={app} r={r} />} />)}
|
|
88
|
+
</Page>
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const AskRow = observer(({ app, r }: { app: RepoSt; r: Resource }) => {
|
|
93
|
+
const a = r.ask
|
|
94
|
+
const done = r.id.startsWith('ask:done/')
|
|
95
|
+
const at = dayMs(done ? (a?.closed ?? a?.created) : a?.created)
|
|
96
|
+
const blocks = a == null || a.blocks === '-' ? '' : a.blocks
|
|
97
|
+
return (
|
|
98
|
+
<tr className={`ctrow s-${done ? 'done' : 'open'}`} data-k={r.id} onClick={() => app.cols.select(r.collection, r.id)} title={r.file}>
|
|
99
|
+
<td className={`ctkind ${done ? 'acdim' : ''}`}>{a?.kind ?? r.slot ?? ''}</td>
|
|
100
|
+
<td className={`cttitle ${done ? 'acdim' : ''}`}>{r.title}</td>
|
|
101
|
+
<td className="acwhen">{at == null ? '' : <TimeAgo clock={app.clock} at={at} />}</td>
|
|
102
|
+
<td className="ctblocks" title={blocks === '' ? undefined : blocks}>{blocks}</td>
|
|
103
|
+
{done ? <td /> : a?.probe != null
|
|
104
|
+
? <td className="ctprobe t-ok" title={`closes itself once \`${a.probe}\` exits 0`}>auto close</td>
|
|
105
|
+
: <td className="ctprobe acdim" title="no probe: done by hand">manual</td>}
|
|
106
|
+
</tr>
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
export const AskTable = observer(({ app, id }: { app: RepoSt; id: string }) => {
|
|
111
|
+
const col = app.cols.sidebarCols.find((x) => x.id === id)
|
|
112
|
+
if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
|
|
113
|
+
const items = app.cols.visibleInstances(col)
|
|
114
|
+
const cols = (
|
|
115
|
+
<>
|
|
116
|
+
<col className="c-kind" /><col /><col className="c-age" /><col className="c-blocks" /><col className="c-probe" />
|
|
117
|
+
</>
|
|
118
|
+
)
|
|
119
|
+
return (
|
|
120
|
+
<Page title={col.title} n={items.length} cols={cols} empty={emptyWords(app)}>
|
|
121
|
+
{askGroups(items).map((g) => <Group key={g.key} app={app} col={col.id} g={g} span={5} row={(r) => <AskRow key={r.id} app={app} r={r} />} />)}
|
|
122
|
+
</Page>
|
|
123
|
+
)
|
|
124
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The checks page and the asks page as dense tables: which group each line
|
|
2
|
+
// belongs to, the order inside a group, and every word a line says. Pure, so
|
|
3
|
+
// a test pins them rather than a screenshot (col-tables-view.tsx draws them).
|
|
4
|
+
import { parseDuration } from '../../core/wire.ts'
|
|
5
|
+
import { spanShort } from '../../plan/plan-timeline.ts'
|
|
6
|
+
import type { CachePop } from './check-words.ts'
|
|
7
|
+
import type { CheckFacts, Resource } from './hub-types.ts'
|
|
8
|
+
|
|
9
|
+
/** one group of lines under its header. `key` is what the viewer's fold
|
|
10
|
+
* choice is remembered under, with the collection before it */
|
|
11
|
+
export type TableGroup<K extends string> = { key: K; label: string; rows: Resource[]; foldedByDefault: boolean }
|
|
12
|
+
|
|
13
|
+
export type CheckGroupKey = 'fail' | 'warn' | 'never' | 'pass' | 'off'
|
|
14
|
+
|
|
15
|
+
/** the groups in the order a reader wants them: what needs attention first */
|
|
16
|
+
const CHECK_GROUPS: { key: CheckGroupKey; word: string; folded: boolean }[] = [
|
|
17
|
+
{ key: 'fail', word: 'failing', folded: false },
|
|
18
|
+
{ key: 'warn', word: 'warning', folded: false },
|
|
19
|
+
{ key: 'never', word: 'never ran', folded: false },
|
|
20
|
+
{ key: 'pass', word: 'passing', folded: true },
|
|
21
|
+
{ key: 'off', word: 'off', folded: true },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/** a skipped check had nothing to look at, which is nothing to fix: it sits
|
|
25
|
+
* with the passing ones, its dot grey */
|
|
26
|
+
export function checkGroupOf(c: CheckFacts): CheckGroupKey {
|
|
27
|
+
if (c.when === 'off') return 'off'
|
|
28
|
+
if (c.status === 'fail') return 'fail'
|
|
29
|
+
if (c.status === 'warn') return 'warn'
|
|
30
|
+
if (c.status == null) return 'never'
|
|
31
|
+
return 'pass'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const idOf = (r: Resource): string => r.check?.checkId ?? r.id
|
|
35
|
+
|
|
36
|
+
/** most recent first; a check with no result by its id */
|
|
37
|
+
const byRecent = (a: Resource, b: Resource): number => (b.check?.lastAt ?? -1) - (a.check?.lastAt ?? -1) || idOf(a).localeCompare(idOf(b))
|
|
38
|
+
|
|
39
|
+
export function checkGroups(rows: readonly Resource[]): TableGroup<CheckGroupKey>[] {
|
|
40
|
+
const withFacts = rows.filter((r) => r.check != null)
|
|
41
|
+
return CHECK_GROUPS.map((g) => {
|
|
42
|
+
const mine = withFacts.filter((r) => checkGroupOf(r.check!) === g.key).sort(byRecent)
|
|
43
|
+
return { key: g.key, label: `${mine.length} ${g.word}`, rows: mine, foldedByDefault: g.folded }
|
|
44
|
+
}).filter((g) => g.rows.length > 0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** a wire line as plain text: the markdown ticks it is rendered with go */
|
|
48
|
+
const plain = (l: string): string => l.replace(/`/g, '').trim()
|
|
49
|
+
|
|
50
|
+
const escape = (x: string): string => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
51
|
+
|
|
52
|
+
/** a line without what its row already shows: the verdict word (the dot
|
|
53
|
+
* says it) and the check's own id (the id column says it) */
|
|
54
|
+
export function strippedLine(line: string, id: string): string {
|
|
55
|
+
const lead = new RegExp(`^(?:(?:FAIL|WARN|✗|⚠):?\\s*|\\[${escape(id)}\\]\\s*|${escape(id)}(?:\\s+—\\s+|:\\s+|\\s+|$))`)
|
|
56
|
+
let l = line
|
|
57
|
+
for (let m = l.match(lead); m != null && m[0] !== ''; m = l.match(lead)) l = l.slice(m[0].length)
|
|
58
|
+
return l.trim()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** the first line of the last run that says something: a failure or a
|
|
62
|
+
* warning first, else any line that is not the bare `ok: <id>` header */
|
|
63
|
+
export function findingLine(c: CheckFacts): string {
|
|
64
|
+
const lines = c.lastLines.map(plain).filter((l) => l !== '')
|
|
65
|
+
const loud = lines.filter((l) => /^(FAIL|WARN|✗|⚠)/.test(l))
|
|
66
|
+
const rest = lines.filter((l) => !/^(FAIL|WARN|✗|⚠|ok\b|skip\b)/.test(l))
|
|
67
|
+
for (const l of [...loud, ...rest]) {
|
|
68
|
+
const s = strippedLine(l, c.checkId)
|
|
69
|
+
if (s !== '') return s
|
|
70
|
+
}
|
|
71
|
+
return ''
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** how a check is planned, as one short word a column scans: what makes it
|
|
75
|
+
* run again (a file change, a span of time, never again) or how often it
|
|
76
|
+
* runs when it remembers nothing */
|
|
77
|
+
export function planWord(u: CachePop): string {
|
|
78
|
+
if (u.when === 'off') return 'off'
|
|
79
|
+
if ((u.inputs ?? []).length > 0 || u.reads === true) return 'on change'
|
|
80
|
+
if (u.ttl === 'forever') return 'once'
|
|
81
|
+
if (u.ttl != null && u.ttl !== '') {
|
|
82
|
+
try { return `every ${spanShort(parseDuration(u.ttl))}` } catch { return `every ${u.ttl}` }
|
|
83
|
+
}
|
|
84
|
+
return u.when === 'release' ? 'every release' : u.when === 'health' ? 'health pass' : 'every run'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type AskGroupKey = 'open' | 'done'
|
|
88
|
+
|
|
89
|
+
/** a `YYYY-MM-DD` stamp as ms, null when the file carries none */
|
|
90
|
+
export const dayMs = (d: string | null | undefined): number | null => {
|
|
91
|
+
if (d == null) return null
|
|
92
|
+
const t = Date.parse(d)
|
|
93
|
+
return Number.isNaN(t) ? null : t
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const NOTHING = new Set(['', '-'])
|
|
97
|
+
const blocksOf = (r: Resource): string => r.ask?.blocks ?? '-'
|
|
98
|
+
|
|
99
|
+
/** open asks by what they block (an ask that blocks nothing last), the
|
|
100
|
+
* oldest first inside one; done asks after, the last closed first */
|
|
101
|
+
export function askGroups(rows: readonly Resource[]): TableGroup<AskGroupKey>[] {
|
|
102
|
+
const isDone = (r: Resource): boolean => r.id.startsWith('ask:done/')
|
|
103
|
+
const open = rows.filter((r) => !isDone(r)).sort((a, b) => {
|
|
104
|
+
const na = NOTHING.has(blocksOf(a)) ? 1 : 0
|
|
105
|
+
const nb = NOTHING.has(blocksOf(b)) ? 1 : 0
|
|
106
|
+
return na - nb || blocksOf(a).localeCompare(blocksOf(b)) || (dayMs(a.ask?.created) ?? 0) - (dayMs(b.ask?.created) ?? 0) || a.id.localeCompare(b.id)
|
|
107
|
+
})
|
|
108
|
+
const closedAt = (r: Resource): number => dayMs(r.ask?.closed) ?? dayMs(r.ask?.created) ?? 0
|
|
109
|
+
const done = rows.filter(isDone).sort((a, b) => closedAt(b) - closedAt(a) || a.id.localeCompare(b.id))
|
|
110
|
+
const groups: TableGroup<AskGroupKey>[] = [
|
|
111
|
+
{ key: 'open', label: `${open.length} open`, rows: open, foldedByDefault: false },
|
|
112
|
+
{ key: 'done', label: `${done.length} done`, rows: done, foldedByDefault: true },
|
|
113
|
+
]
|
|
114
|
+
return groups.filter((g) => g.rows.length > 0)
|
|
115
|
+
}
|
|
@@ -9,6 +9,20 @@ import type { Col, ColMeta, HubProblem, Resource } from './hub-types.ts'
|
|
|
9
9
|
import type { PlanRow } from '../../plan/planner.ts'
|
|
10
10
|
import { readScreen, SCREEN_WRITE_MS, writeScreen } from './screen-cache.ts'
|
|
11
11
|
|
|
12
|
+
/** where a viewer's fold choices on the checks and asks tables are kept,
|
|
13
|
+
* one per browser and not per repo: `checks:pass` open is a habit */
|
|
14
|
+
export const TABLE_FOLDS_KEY = 'hub:table-folds'
|
|
15
|
+
|
|
16
|
+
const readFolds = (): Map<string, boolean> => {
|
|
17
|
+
try {
|
|
18
|
+
const v = JSON.parse(globalThis.localStorage?.getItem(TABLE_FOLDS_KEY) ?? '{}') as unknown
|
|
19
|
+
if (v == null || typeof v !== 'object') return new Map()
|
|
20
|
+
return new Map(Object.entries(v).filter((e): e is [string, boolean] => typeof e[1] === 'boolean'))
|
|
21
|
+
} catch {
|
|
22
|
+
return new Map()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
/** what /api/resources.json answers */
|
|
13
27
|
type Answer = { collections: ColMeta[]; resources: Resource[]; planned?: PlanRow[] | null; hub?: HubProblem[] }
|
|
14
28
|
|
|
@@ -43,6 +57,9 @@ export class ColsSvc {
|
|
|
43
57
|
proofDraft = ''
|
|
44
58
|
/** user fold choices win; default = open when the section holds needs */
|
|
45
59
|
foldOverride = new Map<string, boolean>()
|
|
60
|
+
/** the viewer's fold choices on the table groups (`checks:pass`,
|
|
61
|
+
* `asks:done`), true = folded; a group with none folds by its default */
|
|
62
|
+
tableFolds = readFolds()
|
|
46
63
|
private timer: ReturnType<typeof setInterval> | null = null
|
|
47
64
|
|
|
48
65
|
constructor(private st: RepoSt) {
|
|
@@ -177,6 +194,17 @@ export class ColsSvc {
|
|
|
177
194
|
openSection(c: Col): void {
|
|
178
195
|
this.foldOverride.set(c.id, true)
|
|
179
196
|
}
|
|
197
|
+
/** a table group is folded: a live search shows every match, and a group
|
|
198
|
+
* the viewer never touched folds by its default */
|
|
199
|
+
groupFolded(key: string, byDefault: boolean): boolean {
|
|
200
|
+
if (this.st.query.trim() !== '') return false
|
|
201
|
+
return this.tableFolds.get(key) ?? byDefault
|
|
202
|
+
}
|
|
203
|
+
toggleGroup(key: string, byDefault: boolean): void {
|
|
204
|
+
if (this.st.query.trim() !== '') return
|
|
205
|
+
this.tableFolds.set(key, !this.groupFolded(key, byDefault))
|
|
206
|
+
try { globalThis.localStorage?.setItem(TABLE_FOLDS_KEY, JSON.stringify(Object.fromEntries(this.tableFolds))) } catch { /* private window: the choice lasts the tab */ }
|
|
207
|
+
}
|
|
180
208
|
/** rail red dot — the section holds at least one red item */
|
|
181
209
|
hasRed(c: Col): boolean {
|
|
182
210
|
return c.instances.some((i) => i.severity === 'red')
|
|
@@ -31,5 +31,5 @@
|
|
|
31
31
|
.dockanim>[data-panel]{transition:flex-grow 180ms ease-out}
|
|
32
32
|
@media (prefers-reduced-motion:reduce){.dockanim>[data-panel]{transition:none}}
|
|
33
33
|
/* the runs page's record chart: its (?) in its top left corner */
|
|
34
|
-
.rchart{position:relative;flex:none;overflow:hidden;margin-
|
|
34
|
+
.rchart{position:relative;flex:none;overflow:hidden;margin-top:var(--s3)}
|
|
35
35
|
.rchelp{position:absolute;top:0;left:0;z-index:4}
|
|
@@ -68,7 +68,7 @@ export class HomeSt {
|
|
|
68
68
|
planned: PlannedSt
|
|
69
69
|
/** what every repo committed and holds, polled while it is the page */
|
|
70
70
|
activity: ActivitySt
|
|
71
|
-
/** the one picker, cmd+
|
|
71
|
+
/** the one picker, cmd+j */
|
|
72
72
|
palette: PaletteSt
|
|
73
73
|
/** the widths of the project list and a repo's sidebar */
|
|
74
74
|
layout: LayoutSt
|
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
.hswatch input::-webkit-color-swatch{border:none;border-radius:2px}
|
|
13
13
|
.halert{display:inline-flex;align-items:center;font-size:12px;color:var(--errink)}
|
|
14
14
|
.herr{font-size:12px;color:var(--errink);max-width:40ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
15
|
-
/* the
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
.
|
|
19
|
-
.
|
|
20
|
-
.
|
|
15
|
+
/* the daemon or pm down: toasts over the bottom right corner of the page
|
|
16
|
+
column (hub.css .appcol is their box), just above the dock, never taking
|
|
17
|
+
room, so one coming or going moves nothing */
|
|
18
|
+
.htoasts{position:absolute;right:var(--s3);bottom:var(--s3);z-index:25;display:flex;flex-direction:column;align-items:flex-end;gap:var(--s1);max-width:min(60ch,calc(100% - 2 * var(--s3)));pointer-events:none}
|
|
19
|
+
.htoast{max-width:100%;padding:var(--s1) var(--s2);background:var(--panel);border:1px solid var(--red);border-radius:4px;box-shadow:0 6px 20px var(--shadow);font-size:12px;color:var(--errink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:auto}
|
|
20
|
+
.htoast b{color:var(--red);margin-right:var(--s2)}
|
|
21
21
|
.hdot{display:inline-block;width:8px;height:8px;border-radius:4px;margin-right:var(--s1);background:var(--line)}
|
|
22
22
|
.hdot.up{background:var(--ok)}.hdot.starting,.hdot.stopping{background:var(--warn)}.hdot.crashed{background:var(--red)}
|
|
23
23
|
.hres{display:inline-flex;align-items:center;padding:var(--s0) var(--s2);font-size:12px}
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
/* each widget card exactly as wide as what it holds; a widget's leading
|
|
31
31
|
<header> joins the title line, dim */
|
|
32
32
|
.hmon{display:flex;flex-wrap:wrap;align-items:flex-start;gap:var(--s2)}
|
|
33
|
-
.hwidget{flex:0 1 auto;max-width:100%;padding:var(--
|
|
33
|
+
.hwidget{flex:0 1 auto;max-width:100%;padding:var(--s0) var(--s2);border:1px solid var(--line);border-radius:3px;font-size:12px;line-height:1.4;overflow:auto}
|
|
34
34
|
.hwidget h3{margin:0;font-size:12px;font-weight:650;white-space:nowrap}
|
|
35
35
|
.hwidget h3 .hdim{font-weight:400}
|
|
36
36
|
/* meters in two columns: <table class="wmeters">, label · bar · value, twice a row */
|
|
@@ -38,8 +38,13 @@
|
|
|
38
38
|
.wmeters td:nth-child(3n){padding-right:var(--s3)}
|
|
39
39
|
.wmeters td:last-child{padding-right:0}
|
|
40
40
|
.wmeters .wbar{margin-right:0}
|
|
41
|
+
/* a meter's value: small, its digits one width so a column never jitters */
|
|
42
|
+
.wmeters .wv{font-size:11px;font-variant-numeric:tabular-nums}
|
|
41
43
|
.hwidget table{border-collapse:collapse}
|
|
42
44
|
.hwidget td{padding:0 var(--s3) 0 0;white-space:nowrap;vertical-align:middle}
|
|
45
|
+
/* a widget error: in its card where the html goes, or one mark after the cards */
|
|
46
|
+
.hwerr{font-size:12px;color:var(--warn);white-space:nowrap;cursor:help}
|
|
47
|
+
.hwerr .gpwarn{margin:0 var(--s1) 0 0}
|
|
43
48
|
.hbad{color:var(--errink)}
|
|
44
49
|
.hwait{flex:1;min-height:0;padding:var(--s4);color:var(--dim);font-size:13px;overflow:auto}
|
|
45
50
|
.hwait p{margin:0 0 var(--s2)}
|
|
@@ -57,7 +62,7 @@
|
|
|
57
62
|
sideways */
|
|
58
63
|
.gpwrap{container-type:inline-size;font-size:13px}
|
|
59
64
|
.c-gpname{width:180px}.c-gpver{width:56px}.c-gppid{width:64px}.c-gpstate{width:110px}
|
|
60
|
-
.c-gpb.take{width:78px}.c-gpb.
|
|
65
|
+
.c-gpb.take{width:78px}.c-gpb.stop,.c-gpb.restart{width:24px}
|
|
61
66
|
.gptable td{vertical-align:middle}
|
|
62
67
|
.gptable td.acname{vertical-align:top}
|
|
63
68
|
.gpcore{color:var(--ink)}
|
|
@@ -78,20 +83,30 @@
|
|
|
78
83
|
.gpstate{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums}
|
|
79
84
|
.gpstate.up{color:var(--ok)}.gpstate.crashed{color:var(--red)}.gpstate.starting{color:var(--warn)}
|
|
80
85
|
.gptable td.gpb{padding-left:0;padding-right:var(--s1);text-align:right;overflow:visible}
|
|
81
|
-
.gpb
|
|
86
|
+
.gptable td.gpb.stop,.gptable td.gpb.restart{padding:0;text-align:center}
|
|
87
|
+
/* a small icon button: a process's start or stop, its restart, the open before its url */
|
|
88
|
+
.gpi{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:3px;background:none;color:var(--dim);cursor:pointer;vertical-align:middle}
|
|
89
|
+
.gpi .hico{width:14px;height:14px;color:inherit}
|
|
90
|
+
.gpi:hover:not(:disabled){color:var(--ink);background:var(--panel2)}
|
|
91
|
+
.gpi.go{color:var(--ok)}
|
|
92
|
+
.gpi:disabled{opacity:.35;cursor:default}
|
|
93
|
+
.gphost .gpi{margin-right:var(--s1)}
|
|
94
|
+
/* the app button while its app builds: a spinner, never faded */
|
|
95
|
+
.gpi[aria-busy]:disabled{opacity:1;cursor:progress}
|
|
82
96
|
.gpb button.hsm{padding:0 var(--s1);font-size:11px}
|
|
83
97
|
.gpb.act{white-space:nowrap}
|
|
84
98
|
.gpb.act button + button{margin-left:var(--s1)}
|
|
85
|
-
.gphead div{display:flex;
|
|
99
|
+
.gphead div{display:flex;align-items:center;gap:var(--s1)}
|
|
100
|
+
.gphead .sp{flex:1}
|
|
86
101
|
.gphead button.hsm{padding:0 var(--s1);font-size:11px}
|
|
87
|
-
|
|
102
|
+
/* what a process has to say (its note, a refused take over): a mark after
|
|
103
|
+
its state words, the words on hover, never a line of its own */
|
|
104
|
+
.gpwarn{display:inline-block;vertical-align:middle;width:6px;height:6px;margin-left:var(--s1);border-radius:1px;background:var(--warn)}
|
|
105
|
+
.gpstate span[title]{cursor:help}
|
|
88
106
|
@container (max-width:620px){.c-gphost,.gphost,.c-gpver,.gpver,.c-gppid,.gppid{display:none}.gptable .c-name{width:96px}.c-gpname{width:110px}.c-gpstate{width:88px}}
|
|
89
107
|
/* a phone: the state words go too, the dot and the tint still say it, and
|
|
90
|
-
only start (stop)
|
|
91
|
-
@container (max-width:440px){.c-gpstate,.gpstate,.c-gpb.act,.gpb.act,.c-gpb.take,.gpb.take,.c-gpb.restart,.gpb.restart
|
|
92
|
-
.hopen{margin-left:var(--s2)}
|
|
93
|
-
.hopen button{display:inline-flex;align-items:center;padding:var(--s0) var(--s1)}
|
|
94
|
-
.hopen .hico{color:inherit}
|
|
108
|
+
only start (stop) stays, the label taking what is left */
|
|
109
|
+
@container (max-width:440px){.c-gpstate,.gpstate,.c-gpb.act,.gpb.act,.c-gpb.take,.gpb.take,.c-gpb.restart,.gpb.restart{display:none}.gptable .c-name{width:64px}.c-gpname{width:auto}}
|
|
95
110
|
.hsec{min-width:0}
|
|
96
111
|
.hsec h2{display:flex;align-items:center;gap:var(--s2);height:28px;margin:0 0 var(--s2);padding-bottom:var(--s1);border-bottom:1px solid var(--line);
|
|
97
112
|
font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);font-weight:600}
|
|
@@ -107,7 +122,7 @@
|
|
|
107
122
|
.hnote{font-size:12px;color:var(--warn)}
|
|
108
123
|
/* a filled bar any widget can draw: <span class="wbar" style="--p:51%"><i></i></span>,
|
|
109
124
|
green below 70%, `mid` from 70%, `hi` from 90% (the widget picks the class) */
|
|
110
|
-
.wbar{display:inline-block;vertical-align:middle;width:
|
|
125
|
+
.wbar{display:inline-block;vertical-align:middle;width:40px;height:7px;margin-right:var(--s2);border-radius:4px;background:var(--line);overflow:hidden}
|
|
111
126
|
.wbar > i{display:block;height:100%;width:var(--p,0%);background:var(--ok)}
|
|
112
127
|
.wbar.mid > i{background:var(--warn)}
|
|
113
128
|
.wbar.hi > i{background:var(--red)}
|