conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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:
M.dockerignore | 4+++-
M.gitignore | 3+++
Mdeploy/Dockerfile | 18+++++++++++++++++-
Mpackage.json | 6++++++
Msrc/conductor/app.js | 9+++++++--
Msrc/conductor/routes/jobs.js | 23++++++++++++++++-------
Mtest/api.test.js | 15+++++++++------
Mtest/ascii.test.js | 7+++----
Atest/static.test.js | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/syntax.test.js | 2+-
Aweb/build.cjs | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/component/layout.tsx | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/global.css | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/main.tsx | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/screen/job.tsx | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/screen/jobs.tsx | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/screen/project.tsx | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/screen/projects.tsx | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/screen/task.tsx | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/screen/users.tsx | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/screen/workers.tsx | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/util/ansi.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/util/api.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/util/auth.ts | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/util/format.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/util/poll.ts | 34++++++++++++++++++++++++++++++++++
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); + }, + }; +}