commit e904c928c3207919a3a43bfbc97709be0437e834
parent 31012e50c40db26f7667e225ad085952ab4ff358
Author: finwo <finwo@pm.me>
Date: Sat, 26 Sep 2026 12:54:27 +0200
Add the single page application and serve it from the conductor
Diffstat:
26 files changed, 2300 insertions(+), 22 deletions(-)
diff --git a/.dockerignore b/.dockerignore
@@ -1,7 +1,9 @@
-# Build context: only what the images actually need.
+# Build context: only what the images actually need. web/ stays: the bundle
+# is built in its own stage from these sources.
.git/
node_modules/
data/
+web/dist/
test/
docs/
examples/
diff --git a/.gitignore b/.gitignore
@@ -2,6 +2,9 @@
/node_modules/
/package-lock.json
+# the built application bundle
+/web/dist/
+
# runtime state: sqlite db, mirrors, log spool, artifact storage
/data/
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
@@ -30,6 +30,22 @@ COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund \
&& npm cache clean --force
+# The bundle is the same bytes on every architecture, so it is built once on
+# whatever is doing the building. esbuild has no riscv64 release, and
+# emulating one to produce an identical file would be slow for nothing.
+FROM --platform=$BUILDPLATFORM alpine:${ALPINE_VERSION} AS web
+
+RUN apk add --no-cache nodejs npm
+
+WORKDIR /app
+
+# The dev dependencies the deps stage deliberately skipped.
+COPY package.json ./
+RUN npm install --no-audit --no-fund
+
+COPY web/ ./web/
+RUN node web/build.cjs
+
FROM alpine:${ALPINE_VERSION}
# git is needed for the mirrors the conductor reads pipelines and source
@@ -46,7 +62,7 @@ COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src/ ./src/
COPY migrations/ ./migrations/
-COPY assets/ ./assets/
+COPY --from=web /app/web/dist ./web/dist
# Paths point into the volume rather than at the defaults, which are
# relative to the working directory.
diff --git a/package.json b/package.json
@@ -9,6 +9,7 @@
"dev": "node --watch src/conductor/index.js",
"worker": "node src/worker/agent.js",
"migrate": "node src/lib/db/migrate-cli.js",
+ "build": "node web/build.cjs",
"test": "node --test \"test/**/*.test.js\""
},
"dependencies": {
@@ -22,6 +23,11 @@
"mysql2": "^3.24.4",
"pg": "^8.16.3"
},
+ "devDependencies": {
+ "cpy": "11.1.0",
+ "esbuild": "0.25.10",
+ "mithril": "2.2.15"
+ },
"engines": {
"node": ">=24.0.0"
}
diff --git a/src/conductor/app.js b/src/conductor/app.js
@@ -24,7 +24,7 @@ import { createRetention } from './retention.js';
import apiRoutes from './routes/api.js';
import { ok } from './routes/envelope.js';
import authRoutes from './auth/index.js';
-import staticRoutes from '../lib/static.js';
+import staticRoutes, { contentSecurityPolicy } from '../lib/static.js';
export async function createServices(cfg, options = {}) {
ensureStateDirs(cfg);
@@ -91,7 +91,12 @@ export async function buildServer(services, options = {}) {
await fastify.register(authRoutes, { ...services });
if (options.ui !== false) {
- await fastify.register(staticRoutes, {});
+ // The application talks to the provider directly, so its origin has to
+ // be reachable from the page.
+ const provider = cfg.auth.oidc.discovery_url ? new URL(cfg.auth.oidc.discovery_url).origin : null;
+ await fastify.register(staticRoutes, {
+ csp: contentSecurityPolicy(provider ? [provider] : []),
+ });
}
return fastify;
diff --git a/src/conductor/routes/jobs.js b/src/conductor/routes/jobs.js
@@ -128,17 +128,18 @@ export default async function jobRoutes(fastify, { cfg, auth, db, projects, sche
return canManageProject(user, project);
}
- fastify.get('/projects/:project/jobs', async (req, reply) => {
- const project = await projects.get(req.params.project);
- if (!project) throw new UnknownProject();
-
+ async function listJobs(req, reply, { project = null } = {}) {
const user = await auth.identify(req).catch(() => null);
- const scope = await mayRead(req, project)
+ const scope = project && await mayRead(req, project)
? { sql: '1 = 1', params: {} }
: visible(user, { project: 'p', job: 'j' });
- const filters = [scope.sql, 'j.project_id = {project}'];
- const params = { ...scope.params, project: project.id };
+ const filters = [scope.sql];
+ const params = { ...scope.params };
+ if (project) {
+ filters.push('j.project_id = {project}');
+ params.project = project.id;
+ }
if (typeof req.query.state === 'string') {
filters.push('j.state = {state}');
params.state = req.query.state;
@@ -155,6 +156,14 @@ export default async function jobRoutes(fastify, { cfg, auth, db, projects, sche
});
return ok(reply, window.rows, pageBlock({ ...window, url: base + req.url }));
+ }
+
+ fastify.get('/jobs', async (req, reply) => listJobs(req, reply));
+
+ fastify.get('/projects/:project/jobs', async (req, reply) => {
+ const project = await projects.get(req.params.project);
+ if (!project) throw new UnknownProject();
+ return listJobs(req, reply, { project });
});
// A job id is unique on its own, so it is addressable with or without its
diff --git a/test/api.test.js b/test/api.test.js
@@ -59,14 +59,17 @@ test('a url under the api that matches no route is enveloped too', async () => {
});
});
-// The handlers are installed on the API plugin alone. Were they installed at
-// the root they would answer for the interface as well, and a mistyped page
-// url would return JSON to somebody holding a browser.
-test('a url outside the api is left to the interface', async () => {
+// The envelope handlers sit on the api plugin alone, or a browser asking
+// for a page would be handed JSON.
+test('a page request outside the api is answered with the application', async () => {
await withApi({ visibility: 'public' }, async (h) => {
- const res = await h.app.inject({ method: 'GET', url: '/no/such/page' });
+ const res = await h.app.inject({
+ method: 'GET',
+ url: '/no/such/page',
+ headers: { accept: 'text/html' },
+ });
- assert.ok(!res.body.includes('"ok":false'), 'the interface must not answer in the api envelope');
+ assert.ok(!res.body.includes('"ok":false'), 'a browser must not be handed the api envelope');
});
});
diff --git a/test/ascii.test.js b/test/ascii.test.js
@@ -13,10 +13,9 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-// Vendored third party files are excluded. The rule is about code we
-// write; rewriting someone else's minified build or their licence text to
-// satisfy our own house style would be both pointless and wrong.
-const SKIP_DIRS = new Set(['.git', 'node_modules', 'data', 'tmp', 'vendor']);
+// The rule is about code we write, not about a build output or a
+// dependency. This walks the filesystem, so .gitignore does not apply.
+const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist']);
const CHECK_EXT = new Set([
'.js', '.mjs', '.cjs', '.json', '.md', '.sql', '.yml', '.yaml',
'.html', '.css', '.sh', '.txt',
diff --git a/test/static.test.js b/test/static.test.js
@@ -0,0 +1,124 @@
+// test/static.test.js - serving the built application
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import Fastify from 'fastify';
+import staticRoutes, { contentSecurityPolicy } from '../src/lib/static.js';
+
+async function serve(files) {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-web-'));
+ for (const [name, body] of Object.entries(files)) {
+ await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true });
+ await fs.writeFile(path.join(dir, name), body);
+ }
+
+ const app = Fastify();
+ await app.register(staticRoutes, { dir });
+ return { app, cleanup: async () => { await app.close(); await fs.rm(dir, { recursive: true, force: true }); } };
+}
+
+const BUNDLE = {
+ 'index.html': '<!DOCTYPE html><html><body><script src="main.js"></script></body></html>',
+ 'main.js': 'console.log(1)\n',
+ 'global.css': 'body{}\n',
+ 'assets/icon.svg': '<svg></svg>',
+};
+
+test('what is on disk is served, with its type and an etag', async () => {
+ const { app, cleanup } = await serve(BUNDLE);
+
+ const js = await app.inject({ method: 'GET', url: '/main.js' });
+ assert.equal(js.statusCode, 200);
+ assert.match(js.headers['content-type'], /text\/javascript/);
+ assert.ok(js.headers.etag);
+
+ const css = await app.inject({ method: 'GET', url: '/global.css' });
+ assert.match(css.headers['content-type'], /text\/css/);
+
+ const nested = await app.inject({ method: 'GET', url: '/assets/icon.svg' });
+ assert.equal(nested.statusCode, 200);
+ assert.match(nested.headers['content-type'], /image\/svg/);
+
+ await cleanup();
+});
+
+test('an unchanged file is answered with a not modified', async () => {
+ const { app, cleanup } = await serve(BUNDLE);
+
+ const first = await app.inject({ method: 'GET', url: '/main.js' });
+ const again = await app.inject({
+ method: 'GET', url: '/main.js', headers: { 'if-none-match': first.headers.etag },
+ });
+
+ assert.equal(again.statusCode, 304);
+ assert.equal(again.body, '');
+ await cleanup();
+});
+
+test('the root serves the index', async () => {
+ const { app, cleanup } = await serve(BUNDLE);
+
+ const res = await app.inject({ method: 'GET', url: '/' });
+ assert.equal(res.statusCode, 200);
+ assert.match(res.body, /<!DOCTYPE html>/);
+ await cleanup();
+});
+
+// The routes are built from what was found, so there is no path to traverse.
+test('nothing outside the directory can be requested', async () => {
+ const { app, cleanup } = await serve(BUNDLE);
+
+ for (const url of ['/../package.json', '/%2e%2e/package.json', '/assets/../../package.json']) {
+ const res = await app.inject({ method: 'GET', url, headers: { accept: 'application/json' } });
+ assert.equal(res.statusCode, 404, `${url} must not resolve`);
+ }
+ await cleanup();
+});
+
+test('a deep link is answered with the index, and a missing file is not', async () => {
+ const { app, cleanup } = await serve(BUNDLE);
+
+ const page = await app.inject({ method: 'GET', url: '/jobs/abc', headers: { accept: 'text/html' } });
+ assert.equal(page.statusCode, 200);
+ assert.match(page.body, /<!DOCTYPE html>/);
+
+ const asset = await app.inject({ method: 'GET', url: '/missing.js', headers: { accept: '*/*' } });
+ assert.equal(asset.statusCode, 404);
+
+ const posted = await app.inject({ method: 'POST', url: '/jobs', headers: { accept: 'text/html' } });
+ assert.equal(posted.statusCode, 404);
+
+ await cleanup();
+});
+
+test('the index carries a content security policy and other files do not', async () => {
+ const { app, cleanup } = await serve(BUNDLE);
+
+ const index = await app.inject({ method: 'GET', url: '/' });
+ const policy = index.headers['content-security-policy'];
+ assert.ok(policy, 'the index must carry a policy');
+ assert.match(policy, /script-src 'self'/);
+ assert.ok(!policy.includes('unsafe-inline'), 'nothing in the bundle is inline');
+
+ const js = await app.inject({ method: 'GET', url: '/main.js' });
+ assert.equal(js.headers['content-security-policy'], undefined);
+
+ await cleanup();
+});
+
+test('a provider origin is allowed to be connected to', () => {
+ const policy = contentSecurityPolicy(['https://idp.example.com']);
+ assert.match(policy, /connect-src 'self' https:\/\/idp\.example\.com/);
+ assert.match(policy, /frame-ancestors 'none'/);
+});
+
+test('a tree with no build serves nothing rather than failing', async () => {
+ const { app, cleanup } = await serve({});
+
+ const res = await app.inject({ method: 'GET', url: '/' });
+ assert.equal(res.statusCode, 404);
+ await cleanup();
+});
diff --git a/test/syntax.test.js b/test/syntax.test.js
@@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url';
const execFileAsync = promisify(execFile);
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const SKIP = new Set(['.git', 'node_modules', 'data', 'tmp']);
+const SKIP = new Set(['.git', 'node_modules', 'dist']);
async function* walk(dir) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
diff --git a/web/build.cjs b/web/build.cjs
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+
+// require('dotenv').config();
+const cpy = require('cpy').default;
+const fs = require('node:fs');
+const { dirname } = require('node:path');
+const { fileURLToPath } = require('node:url');
+const { exec } = require('node:child_process');
+const esbuild = require('esbuild');
+// const { nodeModulesPolyfillPlugin } = require('esbuild-plugins-node-modules-polyfill');
+
+// const __dirname = dirname(fileURLToPath(import.meta.url));
+
+const entryPoints = {
+ 'main': __dirname + '/src/main.tsx',
+};
+
+const config = {
+ format: 'iife', // immediately invoked function expression
+ platform: 'browser',
+ target: ['chrome108','firefox107'],
+ mainFields: ['browser','module','main'],
+ bundle: true,
+ outdir: __dirname + '/dist',
+ entryPoints: [
+ ...Object.values(entryPoints),
+ // __dirname+'/src/service-worker.ts',
+ ],
+ minify: false,
+ // define,
+
+ jsxFactory : 'm',
+ jsxFragment : '"["',
+
+ loader: {
+ '.eot' : 'dataurl',
+ '.html' : 'text',
+ '.wasm' : 'dataurl',
+ '.png' : 'dataurl',
+ '.svg' : 'dataurl',
+ '.ttf' : 'dataurl',
+ '.woff' : 'dataurl',
+ '.woff2': 'dataurl',
+ },
+
+ // Kept for when a Buffer is needed.
+ // plugins: [
+ // nodeModulesPolyfillPlugin({
+ // globals: {
+ // Buffer: true
+ // }
+ // })
+ // ],
+
+};
+
+const buildList = [];
+const styles = ['global.css'];
+
+esbuild
+ .build(config)
+ .then(async () => {
+ try { fs.mkdirSync('./dist/assets'); } catch { /* empty */ }
+ // Nothing in src/assets yet, and cpy throws on a glob that matches none.
+ // await cpy(__dirname + '/src/assets/*', __dirname + '/dist/assets');
+ // await cpy(__dirname + '/src/assets/**/*', __dirname + '/dist/assets');
+// try { fs.mkdirSync('./dist/AppModule'); } catch { /* empty */ }
+ fs.copyFileSync(__dirname + `/src/global.css`, config.outdir + `/global.css`);
+ for(const name of Object.keys(entryPoints)) {
+ buildList.push(`${name}.js`);
+ try {
+ fs.statSync(config.outdir + `/${name}.css`);
+ styles.push(`${name}.css`);
+ } catch(e) {
+ // Intentionally empty
+ }
+ }
+
+ fs.writeFileSync(config.outdir + `/index.html`, `<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0">
+ <title>conductor</title>
+ <!-- <link rel="icon" href="/assets/favicon.ico"> -->
+ <!-- <link rel="apple-touch-icon" href="/assets/apple-touch-icon.png"> -->
+ <!-- <link rel="icon" type="image/png" sizes="192x192" href="/assets/icon-192x192.png"> -->
+ <!-- <link rel="icon" type="image/png" sizes="512x512" href="/assets/icon-512x512.png"> -->
+ <!-- <link rel="manifest" href="/assets/manifest.json"/> -->
+ <!-- <link rel="stylesheet" href="/assets/fonts/karla/karla.css"/> -->
+ ${styles.map(name => `<link rel="stylesheet" href="${name}"/>`).join('\n ')}
+ </head>
+ <body>
+ ${buildList.map(name => `<script src="${name}" defer></script>`).join('\n ')}
+ </body>
+</html>
+`);
+
+ // Build favicons
+ // await new Promise(done => {
+ // exec(`ffmpeg -n -i ${config.outdir}/assets/icon.png -vf scale=32:32 -sws_flags area ${config.outdir}/assets/favicon.ico`, done);
+ // });
+ // await new Promise(done => {
+ // exec(`ffmpeg -n -i ${config.outdir}/assets/icon.png -vf scale=180:180 -sws_flags area ${config.outdir}/assets/apple-touch-icon.png`, done);
+ // });
+
+ // Build app icons
+ // const manifest = require(__dirname+'/dist/assets/manifest.json');
+ // for(const format of manifest.icons) {
+ // const scale = format.sizes.split('x').join(':');
+ // await new Promise(done => {
+ // exec(`ffmpeg -n -i ${config.outdir}/assets/icon.png -vf scale=${scale} -sws_flags area ${config.outdir}${format.src}`, done);
+ // });
+ // }
+
+ // const files = await new Promise(done => {
+ // exec(`find ${config.outdir} -type f`, (err,stdout, stderr) => {
+ // done(stdout
+ // .split('\n')
+ // .map(file => file.slice(config.outdir.length))
+ // .filter(file => file)
+ // );
+ // });
+ // });
+ // let sw = fs.readFileSync(`${config.outdir}/service-worker.js`, 'utf-8')
+ // .split('"--FILES--"').join(JSON.stringify(['/',...files]));
+ // fs.writeFileSync(`${config.outdir}/service-worker.js`, sw);
+ })
+ // Without this a failed build still exits 0, and the image ships empty.
+ .catch(e => { console.error(e); process.exit(1); });
diff --git a/web/src/component/layout.tsx b/web/src/component/layout.tsx
@@ -0,0 +1,99 @@
+// web/src/component/layout.tsx - chrome, badges, paging and failure
+
+import m from 'mithril';
+import * as auth from '../util/auth';
+import { ApiError } from '../util/api';
+import { ago, stamp } from '../util/format';
+
+export const session = {
+ user: null as { id: string; username: string; role: string } | null,
+};
+
+const Link = (href: string, label: string) => (
+ <m.route.Link href={href} class={m.route.get().startsWith(href) ? 'active' : ''}>{label}</m.route.Link>
+);
+
+export const Chrome: m.Component = {
+ view(vnode) {
+ return [
+ <header>
+ <nav>
+ <span class="brand">conductor</span>
+ {Link('/jobs', 'jobs')}
+ {Link('/projects', 'projects')}
+ {Link('/workers', 'workers')}
+ {session.user?.role === 'admin' ? Link('/users', 'users') : null}
+ {Link('/sessions', 'sessions')}
+ </nav>
+ <div class="who">
+ {session.user
+ ? [
+ <span>{session.user.username}</span>,
+ <button class="link" onclick={() => auth.signOut()}>sign out</button>,
+ ]
+ : <button class="link" onclick={() => auth.signIn()}>sign in</button>}
+ </div>
+ </header>,
+ <main>{vnode.children}</main>,
+ ];
+ },
+};
+
+export const Badge: m.Component<{ state: string }> = {
+ view: ({ attrs }) => <span class={`badge s-${attrs.state}`}>{attrs.state}</span>,
+};
+
+export const Loading: m.Component = {
+ view: () => <p class="muted">loading</p>,
+};
+
+export const Failed: m.Component<{ error: unknown }> = {
+ view({ attrs }) {
+ const e = attrs.error as ApiError;
+ const unauthenticated = e?.name === 'NotAuthenticated';
+ return (
+ <div class="error">
+ <strong>{unauthenticated ? 'Not signed in' : (e?.name ?? 'Error')}</strong>
+ <p>{e?.message ?? String(attrs.error)}</p>
+ {unauthenticated ? <button onclick={() => auth.signIn()}>sign in</button> : null}
+ </div>
+ );
+ },
+};
+
+// A polled entity that vanishes has to say so, rather than the region it
+// was in quietly disappearing.
+export const Gone: m.Component<{ what: string }> = {
+ view: ({ attrs }) => (
+ <div class="error">
+ <strong>Gone</strong>
+ <p>{`That ${attrs.what} no longer exists, or is no longer yours to see.`}</p>
+ </div>
+ ),
+};
+
+export const When: m.Component<{ at: number | null }> = {
+ view: ({ attrs }) => <span class="when" title={stamp(attrs.at)}>{ago(attrs.at)}</span>,
+};
+
+type PagerAttrs = { offset: number; total: number; limit: number; onmove: (offset: number) => void };
+
+export const Pager: m.Component<PagerAttrs> = {
+ view({ attrs }) {
+ const { offset, total, limit } = attrs;
+ if (total <= limit) return null;
+
+ const last = Math.min(offset + limit, total);
+ return (
+ <div class="pager">
+ <button disabled={offset === 0} onclick={() => attrs.onmove(Math.max(0, offset - limit))}>previous</button>
+ <span>{`${total === 0 ? 0 : offset + 1} to ${last} of ${total}`}</span>
+ <button disabled={last >= total} onclick={() => attrs.onmove(offset + limit)}>next</button>
+ </div>
+ );
+ },
+};
+
+export const Empty: m.Component<{ what: string }> = {
+ view: ({ attrs }) => <p class="muted">{`No ${attrs.what} to show.`}</p>,
+};
diff --git a/web/src/global.css b/web/src/global.css
@@ -0,0 +1,265 @@
+/* web/src/global.css - the whole application's styling */
+
+:root {
+ color-scheme: light dark;
+ --bg: #f6f7f9;
+ --panel: #ffffff;
+ --fg: #1c1f23;
+ --muted: #6b7280;
+ --line: #d6dae0;
+ --accent: #2f6feb;
+ --ok: #1a7f37;
+ --bad: #b42318;
+ --warn: #9a6700;
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #16181c;
+ --panel: #1e2126;
+ --fg: #e7e9ea;
+ --muted: #9198a1;
+ --line: #333940;
+ --accent: #4d8bf5;
+ --ok: #3fb950;
+ --bad: #f97066;
+ --warn: #d29922;
+ }
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font: 15px/1.5 system-ui, sans-serif;
+}
+
+header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0 1.5rem;
+ background: var(--panel);
+ border-bottom: 1px solid var(--line);
+}
+
+nav { display: flex; align-items: center; gap: 1.25rem; }
+
+.brand { font-weight: 700; letter-spacing: 0.02em; padding-right: 0.5rem; }
+
+nav a {
+ display: block;
+ padding: 0.9rem 0;
+ color: var(--muted);
+ text-decoration: none;
+ border-bottom: 2px solid transparent;
+}
+
+nav a:hover { color: var(--fg); }
+nav a.active { color: var(--fg); border-bottom-color: var(--accent); }
+
+.who { display: flex; align-items: center; gap: 0.75rem; color: var(--muted); }
+
+main { max-width: 68rem; margin: 0 auto; padding: 1.5rem; }
+
+h1 { display: flex; align-items: center; gap: 0.6rem; margin: 0 0 1rem; font-size: 1.35rem; }
+h2 { margin: 1.75rem 0 0.75rem; font-size: 1.05rem; }
+
+a { color: var(--accent); }
+
+table { width: 100%; border-collapse: collapse; }
+
+th {
+ text-align: left;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--muted);
+ font-weight: 600;
+}
+
+th, td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); }
+tbody tr:hover { background: var(--panel); }
+tr.disabled { opacity: 0.55; }
+
+.mono { font-family: var(--mono); font-size: 0.9em; }
+.muted { color: var(--muted); }
+.ref { font-family: var(--mono); font-size: 0.9em; color: var(--muted); }
+.ellipsis { max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.badge {
+ display: inline-block;
+ padding: 0.1rem 0.5rem;
+ border-radius: 999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: lowercase;
+ border: 1px solid var(--line);
+ color: var(--muted);
+}
+
+.badge.s-success { color: var(--ok); border-color: var(--ok); }
+.badge.s-failed, .badge.s-timeout { color: var(--bad); border-color: var(--bad); }
+.badge.s-running { color: var(--accent); border-color: var(--accent); }
+.badge.s-cancelled, .badge.s-skipped { color: var(--muted); }
+.badge.s-queued, .badge.s-pending { color: var(--warn); border-color: var(--warn); }
+
+.facts {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 0.35rem 1rem;
+ margin: 0 0 1rem;
+}
+
+.facts dt { color: var(--muted); }
+.facts dd { margin: 0; }
+
+.panel {
+ padding: 1rem 1.25rem;
+ margin-bottom: 1.25rem;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+}
+
+.panel h2 { margin-top: 0; }
+
+label { display: block; margin-bottom: 0.75rem; }
+label > span { display: block; margin-bottom: 0.25rem; color: var(--muted); font-size: 0.9rem; }
+label.check { display: flex; align-items: center; gap: 0.5rem; }
+label.check > span { margin: 0; }
+
+input, select {
+ width: 100%;
+ max-width: 28rem;
+ padding: 0.45rem 0.6rem;
+ background: var(--bg);
+ color: inherit;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ font: inherit;
+}
+
+input[type="checkbox"] { width: auto; }
+
+button {
+ padding: 0.45rem 0.9rem;
+ background: var(--accent);
+ color: #fff;
+ border: 0;
+ border-radius: 6px;
+ font: inherit;
+ cursor: pointer;
+}
+
+button:disabled { opacity: 0.5; cursor: default; }
+
+button.link {
+ padding: 0;
+ background: none;
+ color: var(--accent);
+ text-decoration: underline;
+}
+
+.actions { display: flex; align-items: center; gap: 0.75rem; margin: 1rem 0; }
+
+.pager { display: flex; align-items: center; gap: 1rem; margin-top: 1rem; color: var(--muted); }
+
+.error {
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--bad);
+ border-radius: 6px;
+ color: var(--bad);
+}
+
+.error p { margin: 0.35rem 0 0; }
+
+.reveal {
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--warn);
+ border-radius: 6px;
+}
+
+.reveal code {
+ display: block;
+ margin: 0.5rem 0;
+ padding: 0.5rem;
+ background: var(--bg);
+ border-radius: 4px;
+ word-break: break-all;
+}
+
+.stage { margin-bottom: 1rem; }
+.stage h2 { font-size: 0.8rem; text-transform: uppercase; color: var(--muted); margin: 0 0 0.5rem; }
+
+.tasks { display: flex; flex-wrap: wrap; gap: 0.5rem; }
+
+.task {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-left-width: 3px;
+ border-radius: 6px;
+ text-decoration: none;
+ color: inherit;
+}
+
+.task .name { font-family: var(--mono); font-size: 0.9em; }
+.task.s-success { border-left-color: var(--ok); }
+.task.s-failed, .task.s-timeout { border-left-color: var(--bad); }
+.task.s-running { border-left-color: var(--accent); }
+.task.s-queued { border-left-color: var(--warn); }
+
+.log {
+ max-height: 32rem;
+ overflow: auto;
+ padding: 0.75rem 1rem;
+ background: #0d1117;
+ color: #d0d7de;
+ border-radius: 8px;
+ font-family: var(--mono);
+ font-size: 0.85rem;
+ line-height: 1.45;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.log .bold { font-weight: 700; }
+.log .faint { opacity: 0.6; }
+.log .italic { font-style: italic; }
+.log .underline { text-decoration: underline; }
+
+.log .fg-black { color: #6e7681; }
+.log .fg-red { color: #ff7b72; }
+.log .fg-green { color: #3fb950; }
+.log .fg-yellow { color: #d29922; }
+.log .fg-blue { color: #58a6ff; }
+.log .fg-magenta { color: #bc8cff; }
+.log .fg-cyan { color: #39c5cf; }
+.log .fg-white { color: #b1bac4; }
+
+.log .fg-bright-black { color: #8b949e; }
+.log .fg-bright-red { color: #ffa198; }
+.log .fg-bright-green { color: #56d364; }
+.log .fg-bright-yellow { color: #e3b341; }
+.log .fg-bright-blue { color: #79c0ff; }
+.log .fg-bright-magenta { color: #d2a8ff; }
+.log .fg-bright-cyan { color: #56d4dd; }
+.log .fg-bright-white { color: #f0f6fc; }
+
+.log .bg-red { background: #490202; }
+.log .bg-green { background: #033a16; }
+.log .bg-yellow { background: #341a00; }
+.log .bg-blue { background: #0c2d6b; }
+.log .bg-magenta { background: #2f0c60; }
+.log .bg-cyan { background: #043440; }
+.log .bg-white { background: #30363d; }
diff --git a/web/src/main.tsx b/web/src/main.tsx
@@ -0,0 +1,65 @@
+// web/src/main.tsx - routes, and getting a token before any of them run
+
+import m from 'mithril';
+import * as auth from './util/auth';
+import { get } from './util/api';
+import { Chrome, Failed, session } from './component/layout';
+import { Jobs } from './screen/jobs';
+import { Job } from './screen/job';
+import { Task } from './screen/task';
+import { Projects } from './screen/projects';
+import { Project } from './screen/project';
+import { Workers, Worker } from './screen/workers';
+import { Users, Sessions } from './screen/users';
+
+const root = document.body;
+
+function wrap(screen: m.Component): m.RouteResolver {
+ return { render: () => m(Chrome, m(screen)) };
+}
+
+const routes: m.RouteDefs = {
+ '/jobs': wrap(Jobs),
+ '/jobs/:job': wrap(Job),
+ '/tasks/:task': wrap(Task),
+ '/projects': wrap(Projects),
+ '/projects/:project': wrap(Project),
+ '/projects/:project/jobs': wrap(Jobs),
+ '/workers': wrap(Workers),
+ '/workers/:worker': wrap(Worker),
+ '/users': wrap(Users),
+ '/sessions': wrap(Sessions),
+};
+
+function fail(error: unknown) {
+ m.mount(root, { view: () => m(Chrome, m(Failed, { error })) });
+}
+
+async function start() {
+ let resume: string | null = null;
+ try {
+ resume = await auth.completeSignIn();
+ } catch (e) {
+ fail(e);
+ return;
+ }
+
+ if (!auth.signedIn()) {
+ await auth.signIn();
+ return;
+ }
+
+ try {
+ session.user = await get('/users/me');
+ } catch (e) {
+ auth.forget();
+ fail(e);
+ return;
+ }
+
+ m.route.prefix = '#!';
+ m.route(root, '/jobs', routes);
+ if (resume) m.route.set(resume.replace(/^#!/, ''));
+}
+
+start();
diff --git a/web/src/screen/job.tsx b/web/src/screen/job.tsx
@@ -0,0 +1,138 @@
+// web/src/screen/job.tsx - one job, and the stage graph of its tasks
+
+import m from 'mithril';
+import { get, post, ApiError } from '../util/api';
+import { poll } from '../util/poll';
+import { settled, duration, shortSha, stamp } from '../util/format';
+import { Badge, Failed, Gone, Loading, When } from '../component/layout';
+
+type Task = {
+ id: string; name: string; base_name: string; state: string; arch: string | null;
+ attempt: number; exit_code: number | null; error: string | null; worker_name: string | null;
+ created_at: number; started_at: number | null; finished_at: number | null;
+};
+
+type Dep = { task_id: string; depends_on_id: string; kind: string };
+
+// The longest path to each task, so tasks that can run together line up.
+// Memoised per task rather than sharing one visited set, which would let a
+// diamond settle at whichever depth it was reached by first.
+function stages(tasks: Task[], deps: Dep[]): Task[][] {
+ const incoming = new Map<string, string[]>();
+ for (const task of tasks) incoming.set(task.id, []);
+ for (const dep of deps) incoming.get(dep.task_id)?.push(dep.depends_on_id);
+
+ const depth = new Map<string, number>();
+ const measure = (id: string, seen: Set<string>): number => {
+ if (depth.has(id)) return depth.get(id) as number;
+ if (seen.has(id)) return 0;
+ seen.add(id);
+
+ let best = 0;
+ for (const parent of incoming.get(id) ?? []) {
+ if (!incoming.has(parent)) continue;
+ best = Math.max(best, measure(parent, seen) + 1);
+ }
+ seen.delete(id);
+ depth.set(id, best);
+ return best;
+ };
+
+ const out: Task[][] = [];
+ for (const task of tasks) {
+ const at = measure(task.id, new Set());
+ (out[at] ??= []).push(task);
+ }
+ return out.filter(Boolean);
+}
+
+export const Job: m.Component = {
+ oninit(vnode: any) {
+ const id = m.route.param('job');
+ vnode.state.job = null;
+ vnode.state.stages = [];
+ vnode.state.error = null;
+ vnode.state.gone = false;
+ vnode.state.busy = false;
+
+ vnode.state.load = async () => {
+ try {
+ vnode.state.job = await get<any>(`/jobs/${id}`);
+ const graph = await get<{ tasks: Task[]; deps: Dep[] }>(`/jobs/${id}/tasks`, { limit: 200 });
+ vnode.state.stages = stages(graph.tasks, graph.deps);
+ vnode.state.error = null;
+ } catch (e) {
+ if ((e as ApiError).status === 404) vnode.state.gone = true;
+ else vnode.state.error = e;
+ return false;
+ }
+ return !settled(vnode.state.job.state);
+ };
+
+ vnode.state.poller = poll(vnode.state.load, 4000);
+ vnode.state.poller.start();
+ },
+
+ onremove: (vnode: any) => vnode.state.poller.stop(),
+
+ view(vnode: any) {
+ const { job, error, gone } = vnode.state;
+ if (gone) return <Gone what="job" />;
+ if (error) return <Failed error={error} />;
+ if (!job) return <Loading />;
+
+ const act = async (what: string) => {
+ vnode.state.busy = true;
+ try {
+ const res = await post<any>(`/jobs/${job.id}/${what}`);
+ if (res?.job_id) m.route.set(`/jobs/${res.job_id}`);
+ else await vnode.state.load();
+ } catch (e) {
+ vnode.state.error = e;
+ } finally {
+ vnode.state.busy = false;
+ }
+ };
+
+ return [
+ <h1>
+ <m.route.Link href={`/projects/${job.project_id}`}>{job.project_id}</m.route.Link>
+ {` #${job.number}`}
+ <Badge state={job.state} />
+ </h1>,
+
+ <dl class="facts">
+ <dt>ref</dt><dd class="ref">{job.ref?.replace(/^refs\/heads\//, '')}</dd>
+ <dt>commit</dt><dd class="mono">{shortSha(job.head_sha)}</dd>
+ <dt>trigger</dt><dd>{job.trigger_type}{job.actor ? ` by ${job.actor}` : ''}</dd>
+ <dt>started</dt><dd>{stamp(job.started_at)}</dd>
+ <dt>took</dt><dd>{duration(job.started_at, job.finished_at)}</dd>
+ <dt>created</dt><dd><When at={job.created_at} /></dd>
+ </dl>,
+
+ job.error ? <div class="error"><p>{job.error}</p></div> : null,
+
+ <div class="actions">
+ {job.state === 'running'
+ ? <button disabled={vnode.state.busy} onclick={() => act('cancel')}>cancel</button>
+ : null}
+ <button disabled={vnode.state.busy} onclick={() => act('retry')}>retry</button>
+ </div>,
+
+ vnode.state.stages.map((stage: Task[], at: number) => (
+ <section class="stage">
+ <h2>{`stage ${at + 1}`}</h2>
+ <div class="tasks">
+ {stage.map((task) => (
+ <m.route.Link href={`/tasks/${task.id}`} class={`task s-${task.state}`} key={task.id}>
+ <span class="name">{task.name}</span>
+ <Badge state={task.state} />
+ <span class="muted">{duration(task.started_at, task.finished_at)}</span>
+ </m.route.Link>
+ ))}
+ </div>
+ </section>
+ )),
+ ];
+ },
+};
diff --git a/web/src/screen/jobs.tsx b/web/src/screen/jobs.tsx
@@ -0,0 +1,81 @@
+// web/src/screen/jobs.tsx - jobs, everywhere or within one project
+
+import m from 'mithril';
+import { page, Page } from '../util/api';
+import { poll } from '../util/poll';
+import { settled, duration, shortSha } from '../util/format';
+import { Badge, Empty, Failed, Loading, Pager, When } from '../component/layout';
+
+const LIMIT = 25;
+
+type Job = {
+ id: string; project_id: string; number: number; ref: string; head_sha: string;
+ trigger_type: string; actor: string | null; title: string | null; state: string;
+ created_at: number; started_at: number | null; finished_at: number | null;
+};
+
+export const Jobs: m.Component = {
+ oninit(vnode: any) {
+ const project = m.route.param('project');
+ vnode.state.offset = 0;
+ vnode.state.rows = null as Page<Job> | null;
+ vnode.state.error = null as unknown;
+
+ vnode.state.load = async () => {
+ try {
+ const path = project ? `/projects/${encodeURIComponent(project)}/jobs` : '/jobs';
+ vnode.state.rows = await page<Job>(path, { query: { offset: vnode.state.offset, limit: LIMIT } });
+ vnode.state.error = null;
+ } catch (e) {
+ vnode.state.error = e;
+ return false;
+ }
+ return vnode.state.rows.rows.some((j: Job) => !settled(j.state));
+ };
+
+ vnode.state.poller = poll(vnode.state.load, 4000);
+ vnode.state.poller.start();
+ },
+
+ onremove: (vnode: any) => vnode.state.poller.stop(),
+
+ view(vnode: any) {
+ const { rows, error } = vnode.state;
+ if (error) return <Failed error={error} />;
+ if (!rows) return <Loading />;
+
+ const move = (offset: number) => {
+ vnode.state.offset = offset;
+ vnode.state.poller.stop();
+ vnode.state.poller.start();
+ };
+
+ return [
+ <h1>jobs</h1>,
+ rows.rows.length === 0 ? <Empty what="jobs" /> : (
+ <table>
+ <thead>
+ <tr>
+ <th>job</th><th>project</th><th>ref</th><th>commit</th>
+ <th>state</th><th>took</th><th>created</th>
+ </tr>
+ </thead>
+ <tbody>
+ {rows.rows.map((job) => (
+ <tr key={job.id}>
+ <td><m.route.Link href={`/jobs/${job.id}`}>{`#${job.number}`}</m.route.Link></td>
+ <td><m.route.Link href={`/projects/${job.project_id}`}>{job.project_id}</m.route.Link></td>
+ <td class="ref">{job.ref?.replace(/^refs\/heads\//, '') ?? ''}</td>
+ <td class="mono">{shortSha(job.head_sha)}</td>
+ <td><Badge state={job.state} /></td>
+ <td>{duration(job.started_at, job.finished_at)}</td>
+ <td><When at={job.created_at} /></td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ ),
+ <Pager offset={rows.offset} total={rows.total} limit={LIMIT} onmove={move} />,
+ ];
+ },
+};
diff --git a/web/src/screen/project.tsx b/web/src/screen/project.tsx
@@ -0,0 +1,219 @@
+// web/src/screen/project.tsx - one project: settings, variables, jobs
+
+import m from 'mithril';
+import { get, page, patch, post, put, del, ApiError } from '../util/api';
+import { Badge, Empty, Failed, Gone, Loading, When } from '../component/layout';
+import { duration, shortSha } from '../util/format';
+
+// Empty means follow the server default, zero means keep forever. They are
+// different answers and the form has to keep them apart.
+function retentionValue(raw: string): number | null {
+ const trimmed = raw.trim();
+ return trimmed === '' ? null : Number(trimmed);
+}
+
+export const Project: m.Component = {
+ oninit(vnode: any) {
+ const id = m.route.param('project');
+ vnode.state.id = id;
+ vnode.state.project = null;
+ vnode.state.variables = [];
+ vnode.state.jobs = [];
+ vnode.state.error = null;
+ vnode.state.gone = false;
+ vnode.state.secret = null;
+ vnode.state.newVar = { name: '', value: '', masked: true };
+
+ vnode.state.load = async () => {
+ try {
+ vnode.state.project = await get<any>(`/projects/${id}`);
+ vnode.state.jobs = (await page<any>(`/projects/${id}/jobs`, { query: { limit: 10 } })).rows;
+ try {
+ vnode.state.variables = await get<any[]>(`/projects/${id}/variables`);
+ } catch {
+ // Readable without being manageable, which is not an error here.
+ vnode.state.variables = null;
+ }
+ vnode.state.error = null;
+ } catch (e) {
+ if ((e as ApiError).status === 404) vnode.state.gone = true;
+ else vnode.state.error = e;
+ }
+ m.redraw();
+ };
+ vnode.state.load();
+ },
+
+ view(vnode: any) {
+ const { project, error, gone, id } = vnode.state;
+ if (gone) return <Gone what="project" />;
+ if (error && !project) return <Failed error={error} />;
+ if (!project) return <Loading />;
+
+ const save = async (body: unknown) => {
+ try {
+ vnode.state.project = await patch<any>(`/projects/${id}`, body);
+ vnode.state.error = null;
+ } catch (e) {
+ vnode.state.error = e;
+ }
+ };
+
+ const rotate = async () => {
+ try {
+ vnode.state.secret = (await post<any>(`/projects/${id}/trigger-secret`)).trigger_secret;
+ } catch (e) {
+ vnode.state.error = e;
+ }
+ };
+
+ const setVariable = async (e: Event) => {
+ e.preventDefault();
+ const { name, value, masked } = vnode.state.newVar;
+ try {
+ await put(`/projects/${id}/variables/${encodeURIComponent(name)}`, { value, masked });
+ vnode.state.newVar = { name: '', value: '', masked: true };
+ await vnode.state.load();
+ } catch (err) {
+ vnode.state.error = err;
+ }
+ };
+
+ const retention = (key: string, label: string) => (
+ <label>
+ <span>{label}</span>
+ <input
+ type="number"
+ min="0"
+ placeholder="server default"
+ value={project[key] === null ? '' : project[key]}
+ onchange={(e: any) => save({ [key]: retentionValue(e.target.value) })}
+ />
+ </label>
+ );
+
+ return [
+ <h1>{project.name}</h1>,
+ error ? <Failed error={error} /> : null,
+
+ <section class="panel">
+ <h2>settings</h2>
+ <dl class="facts">
+ <dt>repository</dt><dd class="mono">{project.repo_url}</dd>
+ <dt>branch</dt><dd class="ref">{project.default_branch}</dd>
+ <dt>config</dt><dd class="mono">{project.config_path}</dd>
+ </dl>
+ <label>
+ <span>visibility</span>
+ <select value={project.visibility} onchange={(e: any) => save({ visibility: e.target.value })}>
+ <option value="private">private</option>
+ <option value="public">public</option>
+ </select>
+ </label>
+ <label class="check">
+ <input
+ type="checkbox"
+ checked={project.enabled === 1}
+ onchange={(e: any) => save({ enabled: e.target.checked })}
+ />
+ <span>enabled</span>
+ </label>
+ </section>,
+
+ <section class="panel">
+ <h2>retention</h2>
+ <p class="muted">Empty follows the server default. Zero keeps forever.</p>
+ {retention('artifact_keep_jobs', 'artifact jobs')}
+ {retention('artifact_keep_days', 'artifact days')}
+ {retention('log_keep_days', 'log days')}
+ </section>,
+
+ <section class="panel">
+ <h2>trigger secret</h2>
+ {vnode.state.secret
+ ? (
+ <div class="reveal">
+ <p class="muted">Copy it now. It cannot be shown again.</p>
+ <code class="mono">{vnode.state.secret}</code>
+ <button onclick={() => { vnode.state.secret = null; }}>done</button>
+ </div>
+ )
+ : <p class="muted">{project.has_trigger_secret ? 'A secret is set.' : 'No secret set.'}</p>}
+ <button onclick={rotate}>generate a new secret</button>
+ </section>,
+
+ vnode.state.variables === null ? null : (
+ <section class="panel">
+ <h2>variables</h2>
+ {vnode.state.variables.length === 0 ? <Empty what="variables" /> : (
+ <table>
+ <tbody>
+ {vnode.state.variables.map((v: any) => (
+ <tr key={v.name}>
+ <td class="mono">{v.name}</td>
+ <td>{v.masked ? 'masked' : 'visible'}</td>
+ <td>
+ <button
+ class="link"
+ onclick={async () => {
+ await del(`/projects/${id}/variables/${encodeURIComponent(v.name)}`);
+ await vnode.state.load();
+ }}
+ >
+ remove
+ </button>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ )}
+ <form onsubmit={setVariable}>
+ <label>
+ <span>name</span>
+ <input
+ required
+ value={vnode.state.newVar.name}
+ oninput={(e: any) => { vnode.state.newVar.name = e.target.value; }}
+ />
+ </label>
+ <label>
+ <span>value</span>
+ <input
+ required
+ value={vnode.state.newVar.value}
+ oninput={(e: any) => { vnode.state.newVar.value = e.target.value; }}
+ />
+ </label>
+ <label class="check">
+ <input
+ type="checkbox"
+ checked={vnode.state.newVar.masked}
+ onchange={(e: any) => { vnode.state.newVar.masked = e.target.checked; }}
+ />
+ <span>mask in logs</span>
+ </label>
+ <button type="submit">set</button>
+ </form>
+ </section>
+ ),
+
+ <h2>recent jobs</h2>,
+ vnode.state.jobs.length === 0 ? <Empty what="jobs" /> : (
+ <table>
+ <tbody>
+ {vnode.state.jobs.map((job: any) => (
+ <tr key={job.id}>
+ <td><m.route.Link href={`/jobs/${job.id}`}>{`#${job.number}`}</m.route.Link></td>
+ <td class="mono">{shortSha(job.head_sha)}</td>
+ <td><Badge state={job.state} /></td>
+ <td>{duration(job.started_at, job.finished_at)}</td>
+ <td><When at={job.created_at} /></td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ ),
+ ];
+ },
+};
diff --git a/web/src/screen/projects.tsx b/web/src/screen/projects.tsx
@@ -0,0 +1,124 @@
+// web/src/screen/projects.tsx - the project list, and creating one
+
+import m from 'mithril';
+import { page, post, Page } from '../util/api';
+import { Empty, Failed, Loading, Pager, When } from '../component/layout';
+
+const LIMIT = 25;
+
+type Project = {
+ id: string; name: string; repo_url: string; default_branch: string;
+ visibility: string; enabled: number; job_counter: number; created_at: number;
+};
+
+export const Projects: m.Component = {
+ oninit(vnode: any) {
+ vnode.state.offset = 0;
+ vnode.state.rows = null as Page<Project> | null;
+ vnode.state.error = null;
+ vnode.state.creating = false;
+ vnode.state.created = null;
+ vnode.state.form = { id: '', name: '', repo_url: '', default_branch: 'main' };
+
+ vnode.state.load = async () => {
+ try {
+ vnode.state.rows = await page<Project>('/projects', {
+ query: { offset: vnode.state.offset, limit: LIMIT },
+ });
+ vnode.state.error = null;
+ } catch (e) {
+ vnode.state.error = e;
+ }
+ m.redraw();
+ };
+ vnode.state.load();
+ },
+
+ view(vnode: any) {
+ const { rows, error, created } = vnode.state;
+ if (error && !rows) return <Failed error={error} />;
+ if (!rows) return <Loading />;
+
+ const create = async (e: Event) => {
+ e.preventDefault();
+ try {
+ vnode.state.created = await post<any>('/projects', vnode.state.form);
+ vnode.state.creating = false;
+ vnode.state.form = { id: '', name: '', repo_url: '', default_branch: 'main' };
+ await vnode.state.load();
+ } catch (err) {
+ vnode.state.error = err;
+ }
+ };
+
+ const field = (key: string, label: string, required = false) => (
+ <label>
+ <span>{label}</span>
+ <input
+ value={vnode.state.form[key]}
+ required={required}
+ oninput={(e: any) => { vnode.state.form[key] = e.target.value; }}
+ />
+ </label>
+ );
+
+ return [
+ <h1>projects</h1>,
+ error ? <Failed error={error} /> : null,
+
+ // Shown once, because it is never readable again.
+ created?.trigger_secret
+ ? (
+ <div class="reveal">
+ <strong>Trigger secret for {created.id}</strong>
+ <p class="muted">Copy it now. It cannot be shown again.</p>
+ <code class="mono">{created.trigger_secret}</code>
+ <button onclick={() => { vnode.state.created = null; }}>done</button>
+ </div>
+ )
+ : null,
+
+ vnode.state.creating
+ ? (
+ <form class="panel" onsubmit={create}>
+ {field('id', 'id')}
+ {field('name', 'name')}
+ {field('repo_url', 'repository url', true)}
+ {field('default_branch', 'default branch')}
+ <div class="actions">
+ <button type="submit">create</button>
+ <button type="button" onclick={() => { vnode.state.creating = false; }}>cancel</button>
+ </div>
+ </form>
+ )
+ : <div class="actions"><button onclick={() => { vnode.state.creating = true; }}>new project</button></div>,
+
+ rows.rows.length === 0 ? <Empty what="projects" /> : (
+ <table>
+ <thead>
+ <tr><th>project</th><th>repository</th><th>branch</th><th>visibility</th><th>jobs</th><th>created</th></tr>
+ </thead>
+ <tbody>
+ {rows.rows.map((p) => (
+ <tr key={p.id} class={p.enabled ? '' : 'disabled'}>
+ <td><m.route.Link href={`/projects/${p.id}`}>{p.name}</m.route.Link></td>
+ <td class="mono ellipsis">{p.repo_url}</td>
+ <td class="ref">{p.default_branch}</td>
+ <td>{p.visibility}</td>
+ <td>{p.job_counter}</td>
+ <td><When at={p.created_at} /></td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ ),
+
+ <Pager
+ offset={rows.offset}
+ total={rows.total}
+ limit={LIMIT}
+ onmove={(offset: number) => { vnode.state.offset = offset; vnode.state.load(); }}
+ />,
+ ];
+ },
+};
diff --git a/web/src/screen/task.tsx b/web/src/screen/task.tsx
@@ -0,0 +1,141 @@
+// web/src/screen/task.tsx - one task, its log, outputs and artifacts
+
+import m from 'mithril';
+import { get, page, ApiError } from '../util/api';
+import { poll } from '../util/poll';
+import { ansi } from '../util/ansi';
+import { settled, duration, bytes, stamp } from '../util/format';
+import { Badge, Empty, Failed, Gone, Loading, When } from '../component/layout';
+
+type Log = { offset: number; size: number; data: string; expired: boolean };
+
+const FOLLOW_SLACK = 24;
+
+export const Task: m.Component = {
+ oninit(vnode: any) {
+ const id = m.route.param('task');
+ vnode.state.task = null;
+ vnode.state.log = '';
+ vnode.state.at = 0;
+ vnode.state.expired = false;
+ vnode.state.outputs = [];
+ vnode.state.artifacts = [];
+ vnode.state.error = null;
+ vnode.state.gone = false;
+ vnode.state.follow = true;
+
+ vnode.state.load = async () => {
+ try {
+ const detail = await get<any>(`/tasks/${id}`);
+ vnode.state.task = detail.task;
+
+ const chunk = await get<Log>(`/tasks/${id}/log`, { offset: vnode.state.at });
+ vnode.state.expired = chunk.expired;
+ if (chunk.data) {
+ vnode.state.log += chunk.data;
+ vnode.state.at = chunk.offset + new TextEncoder().encode(chunk.data).length;
+ }
+
+ if (settled(detail.task.state)) {
+ vnode.state.outputs = (await page<any>(`/tasks/${id}/outputs`, { query: { limit: 200 } })).rows;
+ vnode.state.artifacts = (await page<any>(`/tasks/${id}/artifacts`, { query: { limit: 200 } })).rows;
+ }
+ vnode.state.error = null;
+ } catch (e) {
+ if ((e as ApiError).status === 404) vnode.state.gone = true;
+ else vnode.state.error = e;
+ return false;
+ }
+ return !settled(vnode.state.task.state);
+ };
+
+ vnode.state.poller = poll(vnode.state.load, 3000);
+ vnode.state.poller.start();
+ },
+
+ onremove: (vnode: any) => vnode.state.poller.stop(),
+
+ // Whether the reader was at the bottom has to be decided before the new
+ // bytes land, or the answer is always no.
+ onbeforeupdate(vnode: any) {
+ const pane = vnode.state.pane as HTMLElement | undefined;
+ if (pane) {
+ vnode.state.follow = pane.scrollTop + pane.clientHeight >= pane.scrollHeight - FOLLOW_SLACK;
+ }
+ return true;
+ },
+
+ onupdate(vnode: any) {
+ const pane = vnode.state.pane as HTMLElement | undefined;
+ if (pane && vnode.state.follow) pane.scrollTop = pane.scrollHeight;
+ },
+
+ view(vnode: any) {
+ const { task, error, gone } = vnode.state;
+ if (gone) return <Gone what="task" />;
+ if (error) return <Failed error={error} />;
+ if (!task) return <Loading />;
+
+ return [
+ <h1>
+ <m.route.Link href={`/jobs/${task.job_id}`}>{`#${task.job_number}`}</m.route.Link>
+ {` ${task.name}`}
+ <Badge state={task.state} />
+ </h1>,
+
+ <dl class="facts">
+ <dt>image</dt><dd class="mono">{task.image}</dd>
+ {task.arch ? [<dt>arch</dt>, <dd>{task.arch}</dd>] : null}
+ <dt>worker</dt><dd>{task.worker_name ?? ''}</dd>
+ <dt>attempt</dt><dd>{`${task.attempt} of ${task.max_attempts}`}</dd>
+ {task.exit_code !== null ? [<dt>exit</dt>, <dd class="mono">{task.exit_code}</dd>] : null}
+ <dt>took</dt><dd>{duration(task.started_at, task.finished_at)}</dd>
+ <dt>started</dt><dd><When at={task.started_at} /></dd>
+ </dl>,
+
+ task.error ? <div class="error"><p>{task.error}</p></div> : null,
+
+ <h2>log</h2>,
+ vnode.state.expired
+ ? <p class="muted">This log has been removed by the retention policy.</p>
+ : (
+ <pre class="log" oncreate={(v: any) => { vnode.state.pane = v.dom; v.dom.scrollTop = v.dom.scrollHeight; }}>
+ {vnode.state.log ? ansi(vnode.state.log) : <span class="muted">no output yet</span>}
+ </pre>
+ ),
+
+ <h2>outputs</h2>,
+ vnode.state.outputs.length === 0
+ ? <Empty what="outputs" />
+ : (
+ <table>
+ <tbody>
+ {vnode.state.outputs.map((o: any) => (
+ <tr key={o.name}>
+ <td class="mono">{o.name}</td>
+ <td class="mono">{o.masked ? <span class="muted">masked</span> : o.value}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ ),
+
+ <h2>artifacts</h2>,
+ vnode.state.artifacts.length === 0
+ ? <Empty what="artifacts" />
+ : (
+ <table>
+ <tbody>
+ {vnode.state.artifacts.map((a: any) => (
+ <tr key={a.id}>
+ <td><a href={a.url}>{a.path}</a></td>
+ <td>{bytes(a.size)}</td>
+ <td title={stamp(a.expires_at)}>{a.expires_at ? 'expires' : ''}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ ),
+ ];
+ },
+};
diff --git a/web/src/screen/users.tsx b/web/src/screen/users.tsx
@@ -0,0 +1,208 @@
+// web/src/screen/users.tsx - accounts and live sessions
+
+import m from 'mithril';
+import { page, patch, post, del } from '../util/api';
+import { session, Empty, Failed, Loading, Pager, When } from '../component/layout';
+
+const LIMIT = 25;
+
+export const Users: m.Component = {
+ oninit(vnode: any) {
+ vnode.state.offset = 0;
+ vnode.state.rows = null;
+ vnode.state.error = null;
+ vnode.state.form = { username: '', password: '', role: 'user' };
+
+ vnode.state.load = async () => {
+ try {
+ vnode.state.rows = await page<any>('/users', {
+ query: { offset: vnode.state.offset, limit: LIMIT },
+ });
+ vnode.state.error = null;
+ } catch (e) {
+ vnode.state.error = e;
+ }
+ m.redraw();
+ };
+ vnode.state.load();
+ },
+
+ view(vnode: any) {
+ const { rows, error } = vnode.state;
+ if (error && !rows) return <Failed error={error} />;
+ if (!rows) return <Loading />;
+
+ const admin = session.user?.role === 'admin';
+
+ const create = async (e: Event) => {
+ e.preventDefault();
+ try {
+ await post('/users', vnode.state.form);
+ vnode.state.form = { username: '', password: '', role: 'user' };
+ await vnode.state.load();
+ } catch (err) {
+ vnode.state.error = err;
+ }
+ };
+
+ const change = async (id: string, body: unknown) => {
+ try {
+ await patch(`/users/${id}`, body);
+ await vnode.state.load();
+ } catch (err) {
+ vnode.state.error = err;
+ }
+ };
+
+ return [
+ <h1>users</h1>,
+ error ? <Failed error={error} /> : null,
+
+ admin
+ ? (
+ <form class="panel" onsubmit={create}>
+ <label>
+ <span>username</span>
+ <input
+ required
+ value={vnode.state.form.username}
+ oninput={(e: any) => { vnode.state.form.username = e.target.value; }}
+ />
+ </label>
+ <label>
+ <span>password</span>
+ <input
+ required
+ type="password"
+ value={vnode.state.form.password}
+ oninput={(e: any) => { vnode.state.form.password = e.target.value; }}
+ />
+ </label>
+ <label>
+ <span>role</span>
+ <select
+ value={vnode.state.form.role}
+ onchange={(e: any) => { vnode.state.form.role = e.target.value; }}
+ >
+ <option value="user">user</option>
+ <option value="admin">admin</option>
+ </select>
+ </label>
+ <button type="submit">create</button>
+ </form>
+ )
+ : null,
+
+ <table>
+ <thead><tr><th>username</th><th>role</th><th>source</th><th>holdings</th><th>last seen</th><th /></tr></thead>
+ <tbody>
+ {rows.rows.map((u: any) => (
+ <tr key={u.id} class={u.disabled ? 'disabled' : ''}>
+ <td>{u.username}</td>
+ <td>
+ {admin && u.id !== session.user?.id
+ ? (
+ <select value={u.role} onchange={(e: any) => change(u.id, { role: e.target.value })}>
+ <option value="user">user</option>
+ <option value="admin">admin</option>
+ </select>
+ )
+ : u.role}
+ </td>
+ <td>{u.source}</td>
+ <td class="muted">{`${u.project_count ?? 0} projects, ${u.worker_count ?? 0} workers`}</td>
+ <td><When at={u.last_login_at} /></td>
+ <td>
+ {admin && u.id !== session.user?.id
+ ? [
+ <button class="link" onclick={() => change(u.id, { disabled: !u.disabled })}>
+ {u.disabled ? 'enable' : 'disable'}
+ </button>,
+ <button
+ class="link"
+ onclick={async () => { await del(`/users/${u.id}`); await vnode.state.load(); }}
+ >
+ remove
+ </button>,
+ ]
+ : null}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>,
+
+ <Pager
+ offset={rows.offset}
+ total={rows.total}
+ limit={LIMIT}
+ onmove={(offset: number) => { vnode.state.offset = offset; vnode.state.load(); }}
+ />,
+ ];
+ },
+};
+
+export const Sessions: m.Component = {
+ oninit(vnode: any) {
+ vnode.state.offset = 0;
+ vnode.state.rows = null;
+ vnode.state.error = null;
+
+ vnode.state.load = async () => {
+ try {
+ vnode.state.rows = await page<any>('/sessions', {
+ query: { offset: vnode.state.offset, limit: LIMIT },
+ });
+ vnode.state.error = null;
+ } catch (e) {
+ vnode.state.error = e;
+ }
+ m.redraw();
+ };
+ vnode.state.load();
+ },
+
+ view(vnode: any) {
+ const { rows, error } = vnode.state;
+ if (error && !rows) return <Failed error={error} />;
+ if (!rows) return <Loading />;
+
+ return [
+ <h1>sessions</h1>,
+ <p class="muted">Revoking a session signs that browser out the next time it asks for a token.</p>,
+ error ? <Failed error={error} /> : null,
+
+ rows.rows.length === 0 ? <Empty what="sessions" /> : (
+ <table>
+ <thead><tr><th>browser</th><th>address</th><th>signed in</th><th>last used</th><th>expires</th><th /></tr></thead>
+ <tbody>
+ {rows.rows.map((s: any) => (
+ <tr key={s.id}>
+ <td class="ellipsis">{s.user_agent ?? 'unknown'}</td>
+ <td class="mono">{s.ip ?? ''}</td>
+ <td><When at={s.issued_at} /></td>
+ <td><When at={s.last_used_at} /></td>
+ <td><When at={s.expires_at} /></td>
+ <td>
+ <button
+ class="link"
+ onclick={async () => { await del(`/sessions/${s.id}`); await vnode.state.load(); }}
+ >
+ revoke
+ </button>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ ),
+
+ <Pager
+ offset={rows.offset}
+ total={rows.total}
+ limit={LIMIT}
+ onmove={(offset: number) => { vnode.state.offset = offset; vnode.state.load(); }}
+ />,
+ ];
+ },
+};
diff --git a/web/src/screen/workers.tsx b/web/src/screen/workers.tsx
@@ -0,0 +1,187 @@
+// web/src/screen/workers.tsx - registered workers, and one worker's machines
+
+import m from 'mithril';
+import { get, page, patch, post, del, ApiError } from '../util/api';
+import { Badge, Empty, Failed, Gone, Loading, Pager, When } from '../component/layout';
+import { duration } from '../util/format';
+
+const LIMIT = 25;
+
+export const Workers: m.Component = {
+ oninit(vnode: any) {
+ vnode.state.offset = 0;
+ vnode.state.rows = null;
+ vnode.state.error = null;
+ vnode.state.token = null;
+ vnode.state.name = '';
+
+ vnode.state.load = async () => {
+ try {
+ vnode.state.rows = await page<any>('/workers', {
+ query: { offset: vnode.state.offset, limit: LIMIT },
+ });
+ vnode.state.error = null;
+ } catch (e) {
+ vnode.state.error = e;
+ }
+ m.redraw();
+ };
+ vnode.state.load();
+ },
+
+ view(vnode: any) {
+ const { rows, error } = vnode.state;
+ if (error && !rows) return <Failed error={error} />;
+ if (!rows) return <Loading />;
+
+ const create = async (e: Event) => {
+ e.preventDefault();
+ try {
+ vnode.state.token = await post<any>('/workers', { name: vnode.state.name });
+ vnode.state.name = '';
+ await vnode.state.load();
+ } catch (err) {
+ vnode.state.error = err;
+ }
+ };
+
+ return [
+ <h1>workers</h1>,
+ error ? <Failed error={error} /> : null,
+
+ vnode.state.token
+ ? (
+ <div class="reveal">
+ <strong>{`Token for ${vnode.state.token.name}`}</strong>
+ <p class="muted">Copy it now. It cannot be shown again.</p>
+ <code class="mono">{vnode.state.token.token}</code>
+ <button onclick={() => { vnode.state.token = null; }}>done</button>
+ </div>
+ )
+ : null,
+
+ <form class="panel" onsubmit={create}>
+ <label>
+ <span>name</span>
+ <input required value={vnode.state.name} oninput={(e: any) => { vnode.state.name = e.target.value; }} />
+ </label>
+ <button type="submit">register a worker</button>
+ </form>,
+
+ rows.rows.length === 0 ? <Empty what="workers" /> : (
+ <table>
+ <thead><tr><th>worker</th><th>enabled</th><th>last seen</th><th>address</th></tr></thead>
+ <tbody>
+ {rows.rows.map((w: any) => (
+ <tr key={w.id}>
+ <td><m.route.Link href={`/workers/${w.id}`}>{w.name}</m.route.Link></td>
+ <td>{w.enabled ? 'yes' : 'no'}</td>
+ <td><When at={w.last_seen_at} /></td>
+ <td class="mono">{w.last_ip ?? ''}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ ),
+
+ <Pager
+ offset={rows.offset}
+ total={rows.total}
+ limit={LIMIT}
+ onmove={(offset: number) => { vnode.state.offset = offset; vnode.state.load(); }}
+ />,
+ ];
+ },
+};
+
+export const Worker: m.Component = {
+ oninit(vnode: any) {
+ const id = m.route.param('worker');
+ vnode.state.id = id;
+ vnode.state.worker = null;
+ vnode.state.agents = [];
+ vnode.state.tasks = [];
+ vnode.state.error = null;
+ vnode.state.gone = false;
+
+ vnode.state.load = async () => {
+ try {
+ vnode.state.worker = await get<any>(`/workers/${id}`);
+ vnode.state.agents = await get<any[]>(`/workers/${id}/agents`);
+ vnode.state.tasks = (await page<any>(`/workers/${id}/tasks`, { query: { limit: 25 } })).rows;
+ vnode.state.error = null;
+ } catch (e) {
+ if ((e as ApiError).status === 404) vnode.state.gone = true;
+ else vnode.state.error = e;
+ }
+ m.redraw();
+ };
+ vnode.state.load();
+ },
+
+ view(vnode: any) {
+ const { worker, error, gone, id } = vnode.state;
+ if (gone) return <Gone what="worker" />;
+ if (error && !worker) return <Failed error={error} />;
+ if (!worker) return <Loading />;
+
+ return [
+ <h1>{worker.name}</h1>,
+ error ? <Failed error={error} /> : null,
+
+ <div class="actions">
+ <label class="check">
+ <input
+ type="checkbox"
+ checked={worker.enabled === 1}
+ onchange={async (e: any) => {
+ vnode.state.worker = await patch<any>(`/workers/${id}`, { enabled: e.target.checked });
+ }}
+ />
+ <span>enabled</span>
+ </label>
+ <button
+ onclick={async () => {
+ await del(`/workers/${id}`);
+ m.route.set('/workers');
+ }}
+ >
+ remove
+ </button>
+ </div>,
+
+ <h2>machines</h2>,
+ vnode.state.agents.length === 0 ? <Empty what="machines" /> : (
+ <table>
+ <thead><tr><th>machine</th><th>architectures</th><th>features</th><th>last seen</th></tr></thead>
+ <tbody>
+ {vnode.state.agents.map((a: any) => (
+ <tr key={a.id}>
+ <td>{a.name}</td>
+ <td class="mono">{(a.arches ?? []).join(', ')}</td>
+ <td class="mono">{(a.features ?? []).join(', ')}</td>
+ <td><When at={a.last_seen_at} /></td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ ),
+
+ <h2>recent tasks</h2>,
+ vnode.state.tasks.length === 0 ? <Empty what="tasks" /> : (
+ <table>
+ <tbody>
+ {vnode.state.tasks.map((t: any) => (
+ <tr key={t.id}>
+ <td><m.route.Link href={`/tasks/${t.id}`}>{t.name}</m.route.Link></td>
+ <td><Badge state={t.state} /></td>
+ <td>{duration(t.started_at, t.finished_at)}</td>
+ <td><When at={t.created_at} /></td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ ),
+ ];
+ },
+};
diff --git a/web/src/util/ansi.ts b/web/src/util/ansi.ts
@@ -0,0 +1,72 @@
+// web/src/util/ansi.ts - terminal colour codes into vnodes
+//
+// Vnodes, never markup: a log must not be able to become a tag.
+
+import m from 'mithril';
+
+const COLOURS = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
+
+// Cursor moves, erases, and the carriage return a progress bar leaves.
+const NOISE = /\u001b\[[0-9;?]*[A-Za-z]|\u001b\][^\u0007]*\u0007|\r(?!\n)/g;
+
+type Style = { fg?: string; bg?: string; bold?: boolean; faint?: boolean; italic?: boolean; underline?: boolean };
+
+function classOf(style: Style): string {
+ const parts: string[] = [];
+ if (style.fg) parts.push(`fg-${style.fg}`);
+ if (style.bg) parts.push(`bg-${style.bg}`);
+ if (style.bold) parts.push('bold');
+ if (style.faint) parts.push('faint');
+ if (style.italic) parts.push('italic');
+ if (style.underline) parts.push('underline');
+ return parts.join(' ');
+}
+
+function apply(style: Style, codes: number[]): Style {
+ let next = { ...style };
+ for (const code of codes) {
+ if (code === 0) next = {};
+ else if (code === 1) next.bold = true;
+ else if (code === 2) next.faint = true;
+ else if (code === 3) next.italic = true;
+ else if (code === 4) next.underline = true;
+ else if (code === 22) { next.bold = false; next.faint = false; }
+ else if (code === 23) next.italic = false;
+ else if (code === 24) next.underline = false;
+ else if (code >= 30 && code <= 37) next.fg = COLOURS[code - 30];
+ else if (code === 39) next.fg = undefined;
+ else if (code >= 40 && code <= 47) next.bg = COLOURS[code - 40];
+ else if (code === 49) next.bg = undefined;
+ else if (code >= 90 && code <= 97) next.fg = `bright-${COLOURS[code - 90]}`;
+ else if (code >= 100 && code <= 107) next.bg = `bright-${COLOURS[code - 100]}`;
+ }
+ return next;
+}
+
+export function ansi(text: string): m.Children {
+ const out: m.Children[] = [];
+ let style: Style = {};
+ let at = 0;
+
+ const sgr = /\u001b\[([0-9;]*)m/g;
+ let match = sgr.exec(text);
+
+ const push = (chunk: string) => {
+ if (!chunk) return;
+ const clean = chunk.replace(NOISE, '');
+ if (!clean) return;
+ const name = classOf(style);
+ out.push(name ? m('span', { class: name }, clean) : clean);
+ };
+
+ while (match !== null) {
+ push(text.slice(at, match.index));
+ const codes = match[1].split(';').filter(Boolean).map(Number);
+ style = apply(style, codes.length > 0 ? codes : [0]);
+ at = match.index + match[0].length;
+ match = sgr.exec(text);
+ }
+ push(text.slice(at));
+
+ return out;
+}
diff --git a/web/src/util/api.ts b/web/src/util/api.ts
@@ -0,0 +1,99 @@
+// web/src/util/api.ts - the conductor's API, unwrapped
+
+import { bearer, forget, refresh, signedIn } from './auth';
+
+export type Page<T> = { rows: T[]; offset: number; total: number; next?: string };
+
+export class ApiError extends Error {
+ name: string;
+ status: number;
+ details?: unknown;
+
+ constructor(status: number, body: { name?: string; message?: string; details?: unknown }) {
+ super(body.message || `request failed: ${status}`);
+ this.name = body.name || 'Error';
+ this.status = status;
+ this.details = body.details;
+ }
+}
+
+type Options = {
+ method?: string;
+ body?: unknown;
+ query?: Record<string, string | number | undefined>;
+};
+
+function urlFor(path: string, query?: Options['query']): string {
+ const url = new URL(`api/v1${path}`, location.origin + location.pathname);
+ for (const [key, value] of Object.entries(query ?? {})) {
+ if (value !== undefined) url.searchParams.set(key, String(value));
+ }
+ return url.pathname + url.search;
+}
+
+async function send(url: string, options: Options, token: string | null): Promise<Response> {
+ const headers: Record<string, string> = {};
+ if (token) headers.authorization = `Bearer ${token}`;
+ if (options.body !== undefined) headers['content-type'] = 'application/json';
+
+ return fetch(url, {
+ method: options.method ?? 'GET',
+ headers,
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
+ });
+}
+
+export async function request<T>(path: string, options: Options = {}): Promise<T> {
+ const url = path.startsWith('http') ? path : urlFor(path, options.query);
+ let res = await send(url, options, await bearer());
+
+ // One retry: the token may have been accepted a moment ago and expired
+ // since, and that is not worth showing anyone.
+ if (res.status === 401 && signedIn()) {
+ try {
+ await refresh();
+ res = await send(url, options, await bearer());
+ } catch {
+ forget();
+ }
+ }
+
+ if (res.status === 204) return undefined as T;
+
+ let body: { ok?: boolean; data?: T; error?: { name?: string; message?: string; details?: unknown } };
+ try {
+ body = await res.json();
+ } catch {
+ throw new ApiError(res.status, { message: `the conductor did not answer in JSON (${res.status})` });
+ }
+
+ if (!res.ok || body.ok === false) throw new ApiError(res.status, body.error ?? {});
+ return body.data as T;
+}
+
+// The envelope's pagination, flattened onto the rows it describes.
+export async function page<T>(path: string, options: Options = {}): Promise<Page<T>> {
+ const url = path.startsWith('http') ? path : urlFor(path, options.query);
+ let res = await send(url, options, await bearer());
+
+ if (res.status === 401 && signedIn()) {
+ try {
+ await refresh();
+ res = await send(url, options, await bearer());
+ } catch {
+ forget();
+ }
+ }
+
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok || body.ok === false) throw new ApiError(res.status, body.error ?? {});
+
+ const block = body.pagination ?? { offset: 0, total: (body.data ?? []).length };
+ return { rows: body.data ?? [], offset: block.offset, total: block.total, next: block.next };
+}
+
+export const get = <T>(path: string, query?: Options['query']) => request<T>(path, { query });
+export const post = <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body });
+export const patch = <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body });
+export const put = <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body });
+export const del = <T>(path: string) => request<T>(path, { method: 'DELETE' });
diff --git a/web/src/util/auth.ts b/web/src/util/auth.ts
@@ -0,0 +1,179 @@
+// web/src/util/auth.ts - the authorization code flow, against either provider
+//
+// The conductor answers discovery for both its own login page and a
+// configured provider, so there is one code path here rather than two.
+
+const STORE = 'conductor.refresh';
+const PENDING = 'conductor.pending';
+
+export type Discovery = {
+ issuer: string;
+ authorization_endpoint: string;
+ token_endpoint: string;
+ end_session_endpoint?: string;
+ revocation_endpoint?: string;
+ client_id: string;
+ scopes?: string;
+};
+
+let discovery: Discovery | null = null;
+let accessToken: string | null = null;
+let expiresAt = 0;
+
+function encode(bytes: Uint8Array): string {
+ let s = '';
+ for (const b of bytes) s += String.fromCharCode(b);
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+function random(): string {
+ return encode(crypto.getRandomValues(new Uint8Array(32)));
+}
+
+async function challengeFor(verifier: string): Promise<string> {
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
+ return encode(new Uint8Array(digest));
+}
+
+// The fragment is the router's, so the code comes back on the query instead.
+function redirectUri(): string {
+ return location.origin + location.pathname;
+}
+
+export function refreshToken(): string | null {
+ return localStorage.getItem(STORE);
+}
+
+export function signedIn(): boolean {
+ return refreshToken() !== null;
+}
+
+export async function load(): Promise<Discovery> {
+ if (discovery) return discovery;
+ const res = await fetch('.well-known/openid-configuration');
+ if (!res.ok) throw new Error(`discovery failed: ${res.status}`);
+ discovery = await res.json();
+ return discovery as Discovery;
+}
+
+export async function signIn(): Promise<void> {
+ const doc = await load();
+ const verifier = random();
+ const state = random();
+ sessionStorage.setItem(PENDING, JSON.stringify({ verifier, state, at: location.hash }));
+
+ const url = new URL(doc.authorization_endpoint);
+ url.searchParams.set('response_type', 'code');
+ url.searchParams.set('client_id', doc.client_id);
+ url.searchParams.set('redirect_uri', redirectUri());
+ url.searchParams.set('scope', doc.scopes || 'openid');
+ url.searchParams.set('state', state);
+ url.searchParams.set('code_challenge', await challengeFor(verifier));
+ url.searchParams.set('code_challenge_method', 'S256');
+ location.assign(url.toString());
+}
+
+// Returns where the browser was headed before it was sent to sign in.
+export async function completeSignIn(): Promise<string | null> {
+ const query = new URLSearchParams(location.search);
+ const code = query.get('code');
+ const error = query.get('error');
+ if (!code && !error) return null;
+
+ const raw = sessionStorage.getItem(PENDING);
+ sessionStorage.removeItem(PENDING);
+ history.replaceState(null, '', redirectUri() + location.hash);
+
+ if (error) throw new Error(query.get('error_description') || error);
+ if (!raw) throw new Error('this sign in did not start here');
+
+ const pending = JSON.parse(raw);
+ if (pending.state !== query.get('state')) throw new Error('this sign in did not start here');
+
+ const doc = await load();
+ await exchange(doc, {
+ grant_type: 'authorization_code',
+ code: code as string,
+ redirect_uri: redirectUri(),
+ code_verifier: pending.verifier,
+ client_id: doc.client_id,
+ });
+ return pending.at || null;
+}
+
+async function exchange(doc: Discovery, body: Record<string, string>): Promise<void> {
+ const res = await fetch(doc.token_endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams(body).toString(),
+ });
+ if (!res.ok) {
+ let detail = `${res.status}`;
+ try {
+ const payload = await res.json();
+ detail = payload.error_description || payload.error || detail;
+ } catch { /* the status is all there is */ }
+ throw new Error(detail);
+ }
+
+ const grant = await res.json();
+ accessToken = grant.access_token;
+ expiresAt = Date.now() + (grant.expires_in ?? 300) * 1000;
+ if (grant.refresh_token) localStorage.setItem(STORE, grant.refresh_token);
+}
+
+let refreshing: Promise<void> | null = null;
+
+// Concurrent requests finding an expired token must refresh once between
+// them, not once each.
+export async function refresh(): Promise<void> {
+ if (refreshing) return refreshing;
+ const token = refreshToken();
+ if (!token) throw new Error('not signed in');
+
+ const doc = await load();
+ refreshing = exchange(doc, {
+ grant_type: 'refresh_token',
+ refresh_token: token,
+ client_id: doc.client_id,
+ }).finally(() => { refreshing = null; });
+
+ return refreshing;
+}
+
+// Renewed a little early, so a request is not sent with a token that dies
+// on the way.
+export async function bearer(): Promise<string | null> {
+ if (!refreshToken()) return null;
+ if (!accessToken || Date.now() > expiresAt - 10_000) await refresh();
+ return accessToken;
+}
+
+export function forget(): void {
+ accessToken = null;
+ expiresAt = 0;
+ localStorage.removeItem(STORE);
+}
+
+export async function signOut(): Promise<void> {
+ const token = refreshToken();
+ const doc = discovery;
+
+ if (doc?.revocation_endpoint && token) {
+ await fetch(doc.revocation_endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ token, client_id: doc.client_id }).toString(),
+ }).catch(() => { /* the local session goes either way */ });
+ }
+
+ forget();
+
+ if (doc?.end_session_endpoint) {
+ const url = new URL(doc.end_session_endpoint);
+ url.searchParams.set('post_logout_redirect_uri', redirectUri());
+ location.assign(url.toString());
+ return;
+ }
+ location.assign(redirectUri());
+}
diff --git a/web/src/util/format.ts b/web/src/util/format.ts
@@ -0,0 +1,70 @@
+// web/src/util/format.ts - timestamps, durations and sizes
+
+const UNITS: [number, string][] = [
+ [60, 'second'],
+ [60, 'minute'],
+ [24, 'hour'],
+ [7, 'day'],
+ [4.348, 'week'],
+ [12, 'month'],
+];
+
+// Rendered from the clock at draw time rather than baked in, so a page left
+// open does not keep insisting something happened two seconds ago.
+export function ago(at: number | null | undefined, now = Date.now()): string {
+ if (!at) return '';
+ let value = (now - at) / 1000;
+ if (value < 45) return 'just now';
+
+ for (const [step, unit] of UNITS) {
+ if (Math.abs(value) < step) {
+ const n = Math.round(value);
+ return `${n} ${unit}${n === 1 ? '' : 's'} ago`;
+ }
+ value /= step;
+ }
+ const years = Math.round(value);
+ return `${years} year${years === 1 ? '' : 's'} ago`;
+}
+
+export function duration(from: number | null | undefined, to: number | null | undefined): string {
+ if (!from) return '';
+ const ms = (to ?? Date.now()) - from;
+ if (ms < 0) return '';
+
+ const seconds = Math.floor(ms / 1000);
+ if (seconds < 60) return `${seconds}s`;
+
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
+
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
+}
+
+export function bytes(size: number | null | undefined): string {
+ if (size === null || size === undefined) return '';
+ if (size < 1024) return `${size} B`;
+
+ const units = ['KiB', 'MiB', 'GiB', 'TiB'];
+ let value = size / 1024;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit += 1;
+ }
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
+}
+
+export function stamp(at: number | null | undefined): string {
+ return at ? new Date(at).toLocaleString() : '';
+}
+
+export function shortSha(sha: string | null | undefined): string {
+ return sha ? sha.slice(0, 8) : '';
+}
+
+const TERMINAL = ['success', 'failed', 'cancelled', 'skipped', 'timeout'];
+
+export function settled(state: string): boolean {
+ return TERMINAL.includes(state);
+}
diff --git a/web/src/util/poll.ts b/web/src/util/poll.ts
@@ -0,0 +1,34 @@
+// web/src/util/poll.ts - repeat while there is still something to watch
+
+import m from 'mithril';
+
+type Load = () => Promise<boolean>;
+
+// The loader says whether to keep going, so polling stops the moment an
+// entity settles rather than running for as long as the page is open.
+export function poll(load: Load, interval: number) {
+ let timer = 0;
+ let stopped = false;
+
+ async function tick() {
+ if (stopped) return;
+ let again = false;
+ try {
+ again = await load();
+ } finally {
+ m.redraw();
+ }
+ if (again && !stopped) timer = window.setTimeout(tick, interval);
+ }
+
+ return {
+ start() {
+ stopped = false;
+ tick();
+ },
+ stop() {
+ stopped = true;
+ clearTimeout(timer);
+ },
+ };
+}