omp-conductor 0.15.11 → 0.15.13
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/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/dashboard/server.ts
CHANGED
|
@@ -28,8 +28,12 @@ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync }
|
|
|
28
28
|
import { join } from "node:path";
|
|
29
29
|
import { loadConfig, stateDir } from "../config.ts";
|
|
30
30
|
import { healthCheck, livingDaemon, type DaemonRecord } from "../lifecycle.ts";
|
|
31
|
-
import { classifyDaemonProjectHealth } from "../fleet.ts";
|
|
31
|
+
import { classifyDaemonProjectHealth, fleetLayers, type FleetLayers } from "../fleet.ts";
|
|
32
|
+
import { statusSnapshot, type StatusSnapshot } from "../daemon.ts";
|
|
33
|
+
import { boardJson, boardSnapshotOnce } from "../board.ts";
|
|
34
|
+
import { dbPath, openStore } from "../store.ts";
|
|
32
35
|
import type { ProjectConfig } from "../types.ts";
|
|
36
|
+
import type { BaseHealth, DigestBacklog, HeldNotice, ReportRecord, RunRecord, TurnOverride, VerbLedgerEntry } from "../types.ts";
|
|
33
37
|
|
|
34
38
|
/** The dashboard's default bind address — loopback, like the daemon port. */
|
|
35
39
|
export const DASHBOARD_HOST = "127.0.0.1";
|
|
@@ -162,11 +166,154 @@ export async function dashboardProjects(): Promise<DashboardProjectView[]> {
|
|
|
162
166
|
return Promise.all(cfg.projects.map((project) => probeProject(project, record)));
|
|
163
167
|
}
|
|
164
168
|
|
|
169
|
+
/**
|
|
170
|
+
* One project on the fleet-overview row (epic #292, slice 2/5). Every field is
|
|
171
|
+
* the existing producer's own number — `statusSnapshot` for workers, spend and
|
|
172
|
+
* base health, `fleetLayers` for the dispatch verdict — so a row can never
|
|
173
|
+
* disagree with `omp-conductor status` read at the same moment.
|
|
174
|
+
*/
|
|
175
|
+
export interface FleetOverviewRow {
|
|
176
|
+
name: string;
|
|
177
|
+
repo: string;
|
|
178
|
+
/** The same shared daemon-health verdict `/api/projects` reports (#379). */
|
|
179
|
+
daemon: DashboardProjectView["daemon"];
|
|
180
|
+
/** The dispatch layer: `stopped` | `paused` | `running`. */
|
|
181
|
+
dispatch: FleetLayers["dispatch"];
|
|
182
|
+
liveWorkers: number;
|
|
183
|
+
spendTodayUsd: number;
|
|
184
|
+
baseHealth: BaseHealth[];
|
|
185
|
+
/**
|
|
186
|
+
* True when the daemon this project is pinned to is unreachable or serves
|
|
187
|
+
* another project. The UI renders DEGRADED instead of a fabricated healthy
|
|
188
|
+
* empty row — never zeros that read as a working fleet.
|
|
189
|
+
*/
|
|
190
|
+
degraded: boolean;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Every configured project as a fleet-overview row. Reuses `dashboardProjects`
|
|
195
|
+
* for the membership verdict (one pidfile + one `/healthz` read serves the
|
|
196
|
+
* whole call), then `statusSnapshot` and `fleetLayers` per project for the
|
|
197
|
+
* numbers the `status` CLI renders from the very same producers.
|
|
198
|
+
*/
|
|
199
|
+
export async function fleetOverview(): Promise<FleetOverviewRow[]> {
|
|
200
|
+
const views = await dashboardProjects();
|
|
201
|
+
return views.map((view) => {
|
|
202
|
+
const snap = statusSnapshot(view.name);
|
|
203
|
+
const layers = fleetLayers(view.name);
|
|
204
|
+
return {
|
|
205
|
+
name: view.name,
|
|
206
|
+
repo: view.repo,
|
|
207
|
+
daemon: view.daemon,
|
|
208
|
+
dispatch: layers.dispatch,
|
|
209
|
+
liveWorkers: snap.liveWorkers,
|
|
210
|
+
spendTodayUsd: snap.spendTodayUsd,
|
|
211
|
+
baseHealth: snap.baseHealth,
|
|
212
|
+
degraded: view.daemon.state === "unreachable" || view.daemon.state === "other-project",
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// The read-only monitoring surface (epic #292, slice 2/5). Each handler hands
|
|
219
|
+
// the existing CLI producer its arguments and returns what it already computes
|
|
220
|
+
// verbatim — nothing here re-derives a lane, a spend figure or a hold.
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/** `/api/projects/:name/board` — the exact lanes `omp-conductor board --json`
|
|
224
|
+
* prints for the same project at the same moment, parsed so the UI consumes
|
|
225
|
+
* them as an object. */
|
|
226
|
+
async function boardProducer(name: string): Promise<unknown> {
|
|
227
|
+
const snapshot = await boardSnapshotOnce(name);
|
|
228
|
+
return JSON.parse(boardJson(snapshot)) as unknown;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** `/api/projects/:name/status` — the `StatusSnapshot` plus the `fleetLayers`
|
|
232
|
+
* verdict, structured (not the rendered text). */
|
|
233
|
+
async function statusProducer(name: string): Promise<{ status: StatusSnapshot; fleetLayers: FleetLayers }> {
|
|
234
|
+
return { status: statusSnapshot(name), fleetLayers: fleetLayers(name) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `/api/projects/:name/ledger` — the verb ledger + turn-override ledger, with
|
|
238
|
+
* the CLI's own defaults (limit 50) so the two cannot disagree. */
|
|
239
|
+
async function ledgerProducer(
|
|
240
|
+
name: string,
|
|
241
|
+
opts: { issue?: number; limit: number },
|
|
242
|
+
): Promise<{ verb: VerbLedgerEntry[]; turnOverrides: TurnOverride[] }> {
|
|
243
|
+
const store = openStore(dbPath());
|
|
244
|
+
try {
|
|
245
|
+
return {
|
|
246
|
+
verb: store.verbLedger(name, opts),
|
|
247
|
+
turnOverrides: store.turnOverrideLedger(name, opts),
|
|
248
|
+
};
|
|
249
|
+
} finally {
|
|
250
|
+
store.close();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** `/api/projects/:name/reports` — the store readers `status` already uses:
|
|
255
|
+
* open reports, the digest backlog, and the held notices awaiting a digest. */
|
|
256
|
+
async function reportsProducer(
|
|
257
|
+
name: string,
|
|
258
|
+
): Promise<{ openReports: ReportRecord[]; digestBacklog: DigestBacklog; heldNotices: HeldNotice[] }> {
|
|
259
|
+
const store = openStore(dbPath());
|
|
260
|
+
try {
|
|
261
|
+
return {
|
|
262
|
+
openReports: store.openReports(name),
|
|
263
|
+
digestBacklog: store.digestBacklog(name),
|
|
264
|
+
heldNotices: store.undigestedNotices(name),
|
|
265
|
+
};
|
|
266
|
+
} finally {
|
|
267
|
+
store.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `/api/projects/:name/runs/:issue` — every run row for the issue: attempts,
|
|
272
|
+
* states, spend, failure classes, PR URLs. */
|
|
273
|
+
async function runsProducer(name: string, issue: number): Promise<{ runs: RunRecord[] }> {
|
|
274
|
+
const store = openStore(dbPath());
|
|
275
|
+
try {
|
|
276
|
+
return { runs: store.runsForIssue(name, issue) };
|
|
277
|
+
} finally {
|
|
278
|
+
store.close();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The deps a live dashboard is wired with. Every endpoint defaults to the real
|
|
284
|
+
* producer above; a test overrides only the slice it exercises.
|
|
285
|
+
*/
|
|
165
286
|
export interface DashboardHttpDeps {
|
|
166
287
|
/** The bearer token every `/api/*` request must carry. */
|
|
167
288
|
token: string;
|
|
168
289
|
/** The `/api/projects` payload. */
|
|
169
290
|
projects(): Promise<DashboardProjectView[]>;
|
|
291
|
+
/** The `/api/overview` fleet rows. */
|
|
292
|
+
overview(): Promise<FleetOverviewRow[]>;
|
|
293
|
+
board(name: string): Promise<unknown>;
|
|
294
|
+
status(name: string): Promise<{ status: StatusSnapshot; fleetLayers: FleetLayers }>;
|
|
295
|
+
ledger(
|
|
296
|
+
name: string,
|
|
297
|
+
opts: { issue?: number; limit: number },
|
|
298
|
+
): Promise<{ verb: VerbLedgerEntry[]; turnOverrides: TurnOverride[] }>;
|
|
299
|
+
reports(
|
|
300
|
+
name: string,
|
|
301
|
+
): Promise<{ openReports: ReportRecord[]; digestBacklog: DigestBacklog; heldNotices: HeldNotice[] }>;
|
|
302
|
+
runs(name: string, issue: number): Promise<{ runs: RunRecord[] }>;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The deps bound to the real producers — what `startDashboard` serves with. */
|
|
306
|
+
export function defaultDashboardDeps(token: string): DashboardHttpDeps {
|
|
307
|
+
return {
|
|
308
|
+
token,
|
|
309
|
+
projects: dashboardProjects,
|
|
310
|
+
overview: fleetOverview,
|
|
311
|
+
board: boardProducer,
|
|
312
|
+
status: statusProducer,
|
|
313
|
+
ledger: ledgerProducer,
|
|
314
|
+
reports: reportsProducer,
|
|
315
|
+
runs: runsProducer,
|
|
316
|
+
};
|
|
170
317
|
}
|
|
171
318
|
|
|
172
319
|
const STATIC_FILES: Record<string, string> = {
|
|
@@ -190,22 +337,89 @@ function bearerToken(req: Request): string | undefined {
|
|
|
190
337
|
return header.startsWith(prefix) ? header.slice(prefix.length).trim() : undefined;
|
|
191
338
|
}
|
|
192
339
|
|
|
340
|
+
/**
|
|
341
|
+
* The ledger's query params. `limit` defaults to 50 — the CLI's own default —
|
|
342
|
+
* so the endpoint and `omp-conductor ledger` cannot disagree. An explicit
|
|
343
|
+
* non-positive or non-integral limit or issue is a client error (400), never a
|
|
344
|
+
* silent fallback that hides a typo'd URL.
|
|
345
|
+
*/
|
|
346
|
+
function parseLedgerParams(url: URL): { issue?: number; limit: number } | null {
|
|
347
|
+
const issueParam = url.searchParams.get("issue");
|
|
348
|
+
const limitParam = url.searchParams.get("limit");
|
|
349
|
+
// `Number`, not `parseInt`: `1.5` and `-1` must be rejected, never truncated
|
|
350
|
+
// down to a valid-seeming value.
|
|
351
|
+
const issueNumber = issueParam === null ? undefined : Number(issueParam);
|
|
352
|
+
const issue = issueNumber !== undefined && Number.isInteger(issueNumber) ? issueNumber : undefined;
|
|
353
|
+
const limitParamValue = limitParam === null ? 50 : Number(limitParam);
|
|
354
|
+
const limit = Number.isInteger(limitParamValue) ? limitParamValue : Number.NaN;
|
|
355
|
+
if (issueParam !== null && (issue === undefined || issue < 1)) return null;
|
|
356
|
+
if (!Number.isSafeInteger(limit) || limit < 1) return null;
|
|
357
|
+
return { ...(issue === undefined ? {} : { issue }), limit };
|
|
358
|
+
}
|
|
359
|
+
|
|
193
360
|
/**
|
|
194
361
|
* The dashboard's whole HTTP surface, separated from the socket exactly like
|
|
195
362
|
* `daemonHttpResponse` so a test can drive it without binding a port. The UI
|
|
196
363
|
* ships on disk and is served without auth; `/api/*` is bearer-authenticated.
|
|
197
364
|
*/
|
|
198
365
|
export async function dashboardResponse(req: Request, d: DashboardHttpDeps): Promise<Response> {
|
|
199
|
-
const
|
|
366
|
+
const url = new URL(req.url);
|
|
367
|
+
const path = url.pathname;
|
|
200
368
|
if (path.startsWith("/api/")) {
|
|
201
369
|
const presented = bearerToken(req);
|
|
202
370
|
if (presented === undefined || !verifyDashboardToken(presented, d.token)) {
|
|
203
371
|
// Bare 401: no body, no scheme hint — nothing about the token leaks.
|
|
204
372
|
return new Response("", { status: 401 });
|
|
205
373
|
}
|
|
206
|
-
|
|
207
|
-
|
|
374
|
+
// Read-only surface: every /api route is a GET. Everything else stays
|
|
375
|
+
// 404 (after the auth check, so the surface cannot be probed).
|
|
376
|
+
if (req.method !== "GET") return new Response("", { status: 404 });
|
|
377
|
+
|
|
378
|
+
if (path === "/api/projects") return Response.json(await d.projects());
|
|
379
|
+
if (path === "/api/overview") return Response.json(await d.overview());
|
|
380
|
+
|
|
381
|
+
// `/api/projects/:name/{board,status,ledger,reports}` and
|
|
382
|
+
// `/api/projects/:name/runs/:issue`. Project names are matched against the
|
|
383
|
+
// configured set so an unknown name is a 404, never the producer's throw.
|
|
384
|
+
const name = (m: RegExpExecArray): string | undefined => {
|
|
385
|
+
try {
|
|
386
|
+
return decodeURIComponent(m[1]!);
|
|
387
|
+
} catch {
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
const known = (project: string | undefined): project is string =>
|
|
392
|
+
project !== undefined && loadConfig().projects.some((p) => p.name === project);
|
|
393
|
+
|
|
394
|
+
const proj = /^\/api\/projects\/([^/]+)\/(board|status|ledger|reports)$/.exec(path);
|
|
395
|
+
if (proj !== null) {
|
|
396
|
+
const project = name(proj);
|
|
397
|
+
if (!known(project)) return new Response("", { status: 404 });
|
|
398
|
+
switch (proj[2]) {
|
|
399
|
+
case "board":
|
|
400
|
+
return Response.json(await d.board(project!));
|
|
401
|
+
case "status":
|
|
402
|
+
return Response.json(await d.status(project!));
|
|
403
|
+
case "ledger": {
|
|
404
|
+
const opts = parseLedgerParams(url);
|
|
405
|
+
if (opts === null) return new Response("", { status: 400 });
|
|
406
|
+
return Response.json(await d.ledger(project!, opts));
|
|
407
|
+
}
|
|
408
|
+
case "reports":
|
|
409
|
+
return Response.json(await d.reports(project!));
|
|
410
|
+
}
|
|
208
411
|
}
|
|
412
|
+
|
|
413
|
+
const runs = /^\/api\/projects\/([^/]+)\/runs\/(\d+)$/.exec(path);
|
|
414
|
+
if (runs !== null) {
|
|
415
|
+
const project = name(runs);
|
|
416
|
+
const issue = Number.parseInt(runs[2]!, 10);
|
|
417
|
+
if (!known(project) || !Number.isSafeInteger(issue)) {
|
|
418
|
+
return new Response("", { status: 404 });
|
|
419
|
+
}
|
|
420
|
+
return Response.json(await d.runs(project!, issue));
|
|
421
|
+
}
|
|
422
|
+
|
|
209
423
|
return new Response("", { status: 404 });
|
|
210
424
|
}
|
|
211
425
|
// Everything outside /api is static UI, unauthenticated by contract.
|
|
@@ -243,7 +457,7 @@ export async function startDashboard(opts: DashboardStartOpts = {}): Promise<voi
|
|
|
243
457
|
const server = Bun.serve({
|
|
244
458
|
hostname: host,
|
|
245
459
|
port: opts.port ?? DASHBOARD_PORT,
|
|
246
|
-
fetch: (req) => dashboardResponse(req,
|
|
460
|
+
fetch: (req) => dashboardResponse(req, defaultDashboardDeps(token)),
|
|
247
461
|
});
|
|
248
462
|
// An IPv6 literal needs brackets to be a clickable URL.
|
|
249
463
|
const displayHost = host.includes(":") ? `[${host}]` : host;
|
package/src/dashboard/style.css
CHANGED
|
@@ -35,7 +35,7 @@ h1 {
|
|
|
35
35
|
|
|
36
36
|
main {
|
|
37
37
|
padding: 0 1.5rem 2rem;
|
|
38
|
-
max-width:
|
|
38
|
+
max-width: 76rem;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
code {
|
|
@@ -167,6 +167,174 @@ button {
|
|
|
167
167
|
font-weight: 600;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/* Monitoring surface (#294) */
|
|
171
|
+
.is-clickable {
|
|
172
|
+
cursor: pointer;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.is-clickable:hover {
|
|
176
|
+
border-color: var(--muted);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.back {
|
|
180
|
+
font-size: 0.9rem;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
a {
|
|
184
|
+
color: var(--ink);
|
|
185
|
+
text-decoration: underline;
|
|
186
|
+
text-underline-offset: 2px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.state-degraded {
|
|
190
|
+
color: var(--down);
|
|
191
|
+
font-weight: 800;
|
|
192
|
+
letter-spacing: 0.04em;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.state-muted,
|
|
196
|
+
.state-red {
|
|
197
|
+
color: var(--down);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.state-green {
|
|
201
|
+
color: var(--up);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.meta {
|
|
205
|
+
color: var(--muted);
|
|
206
|
+
font-size: 0.85rem;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.base {
|
|
210
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.summary {
|
|
214
|
+
display: flex;
|
|
215
|
+
flex-direction: column;
|
|
216
|
+
gap: 0.25rem;
|
|
217
|
+
margin: 0.25rem 0 1rem;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.holds {
|
|
221
|
+
margin: 0.25rem 0 0;
|
|
222
|
+
padding-left: 1.25rem;
|
|
223
|
+
color: var(--warn);
|
|
224
|
+
font-size: 0.85rem;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.tabs {
|
|
228
|
+
display: flex;
|
|
229
|
+
gap: 0.4rem;
|
|
230
|
+
margin: 1rem 0;
|
|
231
|
+
flex-wrap: wrap;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.tabs button {
|
|
235
|
+
background: transparent;
|
|
236
|
+
color: var(--ink);
|
|
237
|
+
border: 1px solid var(--line);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.tabs button.active {
|
|
241
|
+
background: var(--ink);
|
|
242
|
+
color: var(--bg);
|
|
243
|
+
border-color: var(--ink);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.lanes {
|
|
247
|
+
display: flex;
|
|
248
|
+
gap: 0.75rem;
|
|
249
|
+
overflow-x: auto;
|
|
250
|
+
align-items: flex-start;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.lane {
|
|
254
|
+
flex: 0 0 13rem;
|
|
255
|
+
background: var(--card);
|
|
256
|
+
border: 1px solid var(--line);
|
|
257
|
+
border-radius: 10px;
|
|
258
|
+
padding: 0.6rem;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.lane-title {
|
|
262
|
+
font-weight: 700;
|
|
263
|
+
font-size: 0.8rem;
|
|
264
|
+
letter-spacing: 0.05em;
|
|
265
|
+
color: var(--muted);
|
|
266
|
+
margin-bottom: 0.5rem;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.cards {
|
|
270
|
+
display: flex;
|
|
271
|
+
flex-direction: column;
|
|
272
|
+
gap: 0.4rem;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.card {
|
|
276
|
+
display: flex;
|
|
277
|
+
flex-direction: column;
|
|
278
|
+
align-items: flex-start;
|
|
279
|
+
gap: 0.1rem;
|
|
280
|
+
text-align: left;
|
|
281
|
+
background: var(--bg);
|
|
282
|
+
border: 1px solid var(--line);
|
|
283
|
+
color: var(--ink);
|
|
284
|
+
border-radius: 8px;
|
|
285
|
+
padding: 0.5rem 0.6rem;
|
|
286
|
+
cursor: pointer;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.card:hover {
|
|
290
|
+
border-color: var(--muted);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.card-state {
|
|
294
|
+
font-size: 0.78rem;
|
|
295
|
+
color: var(--muted);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.empty {
|
|
299
|
+
color: var(--muted);
|
|
300
|
+
font-size: 0.85rem;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
table.ledger,
|
|
304
|
+
table.runs {
|
|
305
|
+
width: 100%;
|
|
306
|
+
border-collapse: collapse;
|
|
307
|
+
font-size: 0.85rem;
|
|
308
|
+
margin-bottom: 1.25rem;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
table.ledger th,
|
|
312
|
+
table.ledger td,
|
|
313
|
+
table.runs th,
|
|
314
|
+
table.runs td {
|
|
315
|
+
border-bottom: 1px solid var(--line);
|
|
316
|
+
padding: 0.4rem 0.6rem;
|
|
317
|
+
text-align: left;
|
|
318
|
+
vertical-align: top;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.mono {
|
|
322
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
323
|
+
font-size: 0.82rem;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
ul.reports,
|
|
327
|
+
#run-attempts .empty {
|
|
328
|
+
list-style: none;
|
|
329
|
+
margin: 0 0 1rem;
|
|
330
|
+
padding: 0;
|
|
331
|
+
color: var(--ink);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
ul.reports li {
|
|
335
|
+
padding: 0.15rem 0;
|
|
336
|
+
}
|
|
337
|
+
|
|
170
338
|
@media (prefers-color-scheme: dark) {
|
|
171
339
|
:root {
|
|
172
340
|
--bg: #17171a;
|