diff --git a/app/src/server/backups.ts b/app/src/server/backups.ts index 15fefe6..153c4a3 100644 --- a/app/src/server/backups.ts +++ b/app/src/server/backups.ts @@ -40,6 +40,45 @@ export interface BackupRequest { at?: string; } +export interface Schedule { + enabled: boolean; + everyHours: number; + hour: number; + keepDays: number; +} + +export const defaultSchedule: Schedule = { enabled: true, everyHours: 24, hour: 3, keepDays: 14 }; +export const periods = [1, 2, 3, 4, 6, 8, 12, 24, 48, 72, 168]; + +export function nextRun(schedule: Schedule, now: number) { + const period = schedule.everyHours * 3600_000; + const anchor = schedule.hour * 3600_000; + return anchor + Math.ceil((now - anchor) / period) * period; +} + +export async function readSchedule(root = backupRoot()): Promise { + return { ...defaultSchedule, ...(await json>(path.join(root, '.schedule.json')).catch(missing)) }; +} + +export async function writeSchedule(input: unknown, login: string, root = backupRoot()) { + const { enabled, everyHours, hour, keepDays } = (input ?? {}) as Record; + const whole = (value: unknown, least: number, most: number) => Number.isInteger(value) && (value as number) >= least && (value as number) <= most; + if (typeof enabled !== 'boolean' || !periods.includes(everyHours as number) || !whole(hour, 0, 23) || !whole(keepDays, 1, 90)) { + throw new Refusal(400, `A schedule is on or off, every ${periods.join(', ')} hours, at an hour from 0 to 23, kept 1 to 90 days.`); + } + await mkdir(root, { recursive: true }); + await writeFile(path.join(root, '.schedule.json.partial'), JSON.stringify({ enabled, everyHours, hour, keepDays })); + await rename(path.join(root, '.schedule.json.partial'), path.join(root, '.schedule.json')); + await audit(login, 'schedule', '*', `${enabled ? `every ${everyHours} h from ${hour}:00 UTC` : 'off'}, ${keepDays} days`, root); +} + +function missing(error: NodeJS.ErrnoException) { + if (error.code === 'ENOENT') { + return undefined; + } + throw error; +} + export const projectPattern = /^[a-z0-9][a-z0-9_-]*$/; export const stampPattern = /^\d{8}T\d{6}Z$/; const hour = 3600_000; @@ -79,7 +118,12 @@ export function stampTime(stamp: string) { return Date.parse(`${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T${stamp.slice(9, 11)}:${stamp.slice(11, 13)}:${stamp.slice(13, 15)}Z`); } -export async function backupConditions(now: number, root = backupRoot(), hours = 26) { +export async function backupConditions(now: number, root = backupRoot(), least = 26) { + const schedule = await readSchedule(root); + if (!schedule.enabled) { + return []; + } + const hours = Math.max(least, schedule.everyHours + 2); return (await listBackups(root)).flatMap(({ project, archives }) => { const latest = archives.find((archive) => !archive.imported); if (!latest) { diff --git a/app/src/server/config.ts b/app/src/server/config.ts index 8d22d33..ba832cf 100644 --- a/app/src/server/config.ts +++ b/app/src/server/config.ts @@ -27,6 +27,8 @@ export const config = { dockerSocket: process.env.DOCKER_SOCKET ?? '/var/run/docker.sock', hostRoot: process.env.HOST_ROOT ?? '/proc/1/root', dataDir: process.env.DATA_DIR ?? '/data', + logsDir: process.env.LOGS_DIR ?? '/logs', + logsKeepDays: Number(process.env.LOGS_KEEP_DAYS ?? 7), sablierUrl: process.env.SABLIER_URL ?? 'http://sablier:10000', self: process.env.HOSTNAME ?? '', sshTarget: process.env.SSH_TARGET ?? 'ubuntu@localhost', diff --git a/app/src/server/logstore.ts b/app/src/server/logstore.ts new file mode 100644 index 0000000..6e84b4c --- /dev/null +++ b/app/src/server/logstore.ts @@ -0,0 +1,224 @@ +import { mkdirSync } from 'node:fs'; +import type http from 'node:http'; +import path from 'node:path'; +import { DatabaseSync, type SQLInputValue } from 'node:sqlite'; +import { Demuxer, docker, LogLines, stream } from './docker.ts'; +import { resumeAfter } from './terminal.ts'; + +export const levels = ['error', 'warning', 'info', 'debug'] as const; +export type Level = (typeof levels)[number]; +const day = 86_400_000; +const longest = 8192; + +export function levelOf(text: string): Level { + const lower = text.toLowerCase(); + if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(lower)) return 'error'; + if (/\b(warn|warning|wrn|caution)\b/.test(lower)) return 'warning'; + if (/\b(debug|dbg|trace|verbose)\b/.test(lower)) return 'debug'; + return 'info'; +} + +export interface StoredLine { + id: number; + at: number; + time: string; + project: string; + service: string; + container: string; + stream: 'stdout' | 'stderr'; + level: Level; + text: string; +} + +export interface LogQuery { + projects?: string[]; + containers?: string[]; + streams?: string[]; + levels?: string[]; + text?: string; + from?: number; + to?: number; + before?: number; + after?: number; + limit?: number; +} + +interface Source { + id: string; + name: string; + project: string; + service: string; +} + +export class LogStore { + private readonly db: DatabaseSync; + private readonly following = new Map(); + private pending: (Omit & { level: Level })[] = []; + + constructor( + directory: string, + private readonly keepDays = 7, + ) { + mkdirSync(directory, { recursive: true }); + this.db = new DatabaseSync(path.join(directory, 'logs.db')); + this.db.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS lines ( + id INTEGER PRIMARY KEY, at INTEGER NOT NULL, time TEXT NOT NULL, project TEXT NOT NULL, service TEXT NOT NULL, + container TEXT NOT NULL, stream TEXT NOT NULL, level TEXT NOT NULL, text TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS lines_at ON lines (at); + CREATE INDEX IF NOT EXISTS lines_container_at ON lines (container, at); + CREATE TABLE IF NOT EXISTS cursors (container TEXT PRIMARY KEY, time TEXT NOT NULL); + `); + } + + add(line: Omit) { + this.pending.push({ ...line, at: Date.parse(line.time) || Date.now(), level: levelOf(line.text), text: line.text.slice(0, longest) }); + } + + flush() { + if (!this.pending.length) { + return; + } + const lines = this.pending; + this.pending = []; + const insert = this.db.prepare('INSERT INTO lines (at, time, project, service, container, stream, level, text) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); + const cursor = this.db.prepare('INSERT INTO cursors (container, time) VALUES (?, ?) ON CONFLICT (container) DO UPDATE SET time = excluded.time'); + this.db.exec('BEGIN'); + try { + for (const line of lines) { + insert.run(line.at, line.time, line.project, line.service, line.container, line.stream, line.level, line.text); + cursor.run(line.container, line.time); + } + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + } + + prune(now = Date.now()) { + this.db.prepare('DELETE FROM lines WHERE at < ?').run(now - this.keepDays * day); + } + + cursor(container: string) { + return (this.db.prepare('SELECT time FROM cursors WHERE container = ?').get(container) as { time: string } | undefined)?.time; + } + + query(filter: LogQuery) { + const clauses: string[] = []; + const values: SQLInputValue[] = []; + const within = (column: string, list?: string[]) => { + if (list?.length) { + clauses.push(`${column} IN (${list.map(() => '?').join(', ')})`); + values.push(...list); + } + }; + within('project', filter.projects); + within('container', filter.containers); + within('stream', filter.streams); + within('level', filter.levels); + for (const [clause, value] of [ + ['at >= ?', filter.from], + ['at <= ?', filter.to], + ['id < ?', filter.before], + ['id > ?', filter.after], + ] as const) { + if (value !== undefined) { + clauses.push(clause); + values.push(value); + } + } + if (filter.text) { + clauses.push("text LIKE ? ESCAPE '\\'"); + values.push(`%${filter.text.replace(/[\\%_]/g, '\\$&')}%`); + } + const limit = Math.min(Math.max(filter.limit ?? 200, 1), 1000); + const order = filter.after !== undefined ? 'ASC' : 'DESC'; + const rows = this.db + .prepare(`SELECT * FROM lines ${clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''} ORDER BY id ${order} LIMIT ?`) + .all(...values, limit + 1) as unknown as StoredLine[]; + const lines = rows.slice(0, limit); + return { lines: order === 'ASC' ? lines.reverse() : lines, more: rows.length > limit }; + } + + stats() { + const totals = this.db.prepare("SELECT count(*) AS lines, min(at) AS oldest, sum(level = 'error') AS errors FROM lines").get() as { + lines: number; + oldest: number | null; + errors: number | null; + }; + const { size } = this.db.prepare('SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()').get() as { size: number }; + return { lines: totals.lines, oldest: totals.oldest, errors: totals.errors ?? 0, bytes: size }; + } + + sources(now = Date.now()) { + return this.db + .prepare('SELECT project, service, container, max(at) AS last FROM lines WHERE at >= ? GROUP BY project, service, container ORDER BY project, service') + .all(now - this.keepDays * day) as { project: string; service: string; container: string; last: number }[]; + } + + private async follow(source: Source) { + const since = this.cursor(source.name); + const query = `since=${since ? resumeAfter(since) : Math.floor((Date.now() - this.keepDays * day) / 1000)}`; + const { Config } = await docker<{ Config: { Tty: boolean } }>('GET', `/containers/${source.id}/json`); + const output = await stream('GET', `/containers/${source.id}/logs?stdout=1&stderr=1×tamps=1&follow=1&${query}`); + if (!this.following.has(source.id)) { + output.destroy(); + return; + } + this.following.set(source.id, output); + const demuxer = Config.Tty ? undefined : new Demuxer(); + const lines = new LogLines(); + output.on('data', (chunk: Buffer) => { + const parsed = demuxer + ? demuxer.push(chunk).flatMap((frame) => lines.push(frame.stream === 2 ? 'stderr' : 'stdout', frame.data)) + : lines.push('stdout', chunk); + for (const line of parsed) { + this.add({ time: line.time, project: source.project, service: source.service, container: source.name, stream: line.stream, text: line.text }); + } + }); + output.on('close', () => this.following.delete(source.id)); + } + + track(views: { id: string; name: string; project: string; service: string; state: string }[]) { + const running = new Set(views.filter((view) => view.state === 'running').map((view) => view.id)); + for (const [id, output] of this.following) { + if (!running.has(id)) { + output?.destroy(); + this.following.delete(id); + } + } + for (const view of views.filter((candidate) => running.has(candidate.id) && !this.following.has(candidate.id))) { + this.following.set(view.id, null); + this.follow(view).catch((error: unknown) => { + this.following.delete(view.id); + console.error(`following the logs of ${view.name} failed:`, error); + }); + } + } + + start(list: () => Promise<{ id: string; name: string; project: string; service: string; state: string }[]>) { + const report = (task: string) => (error: unknown) => console.error(`${task} failed:`, error); + const timers = [ + setInterval(() => void list().then((views) => this.track(views), report('log sources')), 5000), + setInterval(() => { + try { + this.flush(); + } catch (error) { + report('storing logs')(error); + } + }, 1000), + setInterval(() => this.prune(), 3600_000), + ]; + void list().then((views) => this.track(views), report('log sources')); + this.prune(); + return () => timers.forEach(clearInterval); + } + + close() { + this.flush(); + this.db.close(); + } +} diff --git a/app/src/server/main.ts b/app/src/server/main.ts index dd01b10..0ca8daf 100644 --- a/app/src/server/main.ts +++ b/app/src/server/main.ts @@ -5,7 +5,7 @@ import { WebSocketServer } from 'ws'; import { Access, permits, scopePattern } from './access.ts'; import { Alerts, ruleKinds, type RuleKind } from './alerts.ts'; import { authenticate, callback, githubToken, login, logout, organisationMembers, roleNow, sessionMs, type Role, type User } from './auth.ts'; -import { download, listBackups, order, requests, upload } from './backups.ts'; +import { download, listBackups, nextRun, order, readSchedule, requests, upload, writeSchedule } from './backups.ts'; import { config } from './config.ts'; import { docker } from './docker.ts'; import { docs } from './docs.ts'; @@ -17,14 +17,19 @@ import { open, openSocket, parseOpen } from './proxy.ts'; import { Refusal } from './refusal.ts'; import { appliedAt, appliedCompose, appliedValueNames, redeployStatus, startRedeploy } from './rollout.ts'; import { composeReferences, parseKeys, projectPattern, SecretStore } from './secrets.ts'; -import { downloadLogs, logs, searchLogs, terminal } from './terminal.ts'; +import { LogStore } from './logstore.ts'; +import { hostShell, type ShellMode } from './shell.ts'; +import { createVolume, downloadFile, listFiles, removeVolume } from './volumes.ts'; +import { downloadLogs, logs, terminal } from './terminal.ts'; const history = new History(config.dataDir); const access = new Access(config.dataDir); const alerts = new Alerts(config.dataDir); const store = config.secretsKey ? new SecretStore(config.dataDir, parseKeys(config.secretsKey), path.join(config.hostRoot, 'etc/graphite')) : undefined; +const logStore = new LogStore(config.logsDir, config.logsKeepDays); collect(history); monitor(alerts); +logStore.start(containers); const types: Record = { '.html': 'text/html; charset=utf-8', @@ -151,12 +156,11 @@ async function needsRedeploy(project: string, views: ContainerView[]) { return changed !== null && created.length > 0 && changed > Math.max(...created, (await appliedAt(project)) ?? 0); } -async function secretProject(project: string, views: ContainerView[]) { +async function secretProject(project: string) { const [compose, values] = await Promise.all([appliedCompose(project), appliedValueNames(project)]); const stored = new Set(secretStore().list(project).map((secret) => secret.name)); return { project, - needsRedeploy: await needsRedeploy(project, views), missing: [...composeReferences(compose)].filter(([name, reference]) => reference.required && !stored.has(name) && !values.has(name)).map(([name]) => name), }; } @@ -167,7 +171,7 @@ async function secretsApi(user: User, request: http.IncomingMessage, route: stri await requireOwnerNow(request); const views = await containers(); const slugs = new Set([...secrets.projects(), ...views.map((view) => view.project)].filter((slug) => projectPattern.test(slug) && slug !== 'console' && slug !== 'other')); - return { projects: await Promise.all([...slugs].sort().map((slug) => secretProject(slug, views))), secrets: secrets.list() }; + return { projects: await Promise.all([...slugs].sort().map((slug) => secretProject(slug))), secrets: secrets.list() }; } const match = /^(GET|POST) \/api\/secrets\/([a-z0-9-]+)(?:\/([A-Z_][A-Z0-9_]*|import|delete))?(?:\/(reveal|restore))?$/.exec(route); if (!match) { @@ -176,7 +180,7 @@ async function secretsApi(user: User, request: http.IncomingMessage, route: stri const [, method, project, target, action] = match; await requireNow(request, user, `secrets:${project}`); if (method === 'GET' && !target) { - return { project: await secretProject(project, await containers()), secrets: secrets.list(project) }; + return { project: await secretProject(project), secrets: secrets.list(project) }; } if (method === 'GET' && !action && /^[A-Z_]/.test(target)) { return { ...secrets.detail(project, target), file: `${project}/compose.yaml`, usage: composeReferences(await appliedCompose(project)).get(target)?.lines ?? [] }; @@ -232,7 +236,13 @@ async function api(user: User, request: http.IncomingMessage, url: URL) { if (!user.owner) { throw new Refusal(403, 'Only organisation owners can do that.'); } - return { projects: await listBackups(), requests: await requests() }; + const [projects, schedule, recent] = await Promise.all([listBackups(), readSchedule(), requests()]); + return { projects, schedule, next: schedule.enabled ? nextRun(schedule, Date.now()) : null, requests: recent }; + } + if (route === 'POST /api/backups/schedule') { + await requireOwnerNow(request); + await writeSchedule(await body(request), user.login); + return { ok: true }; } const backup = /^POST \/api\/backups\/([a-z0-9][a-z0-9_-]*)\/(?:run|(\d{8}T\d{6}Z)\/restore)$/.exec(route); if (backup) { @@ -272,15 +282,24 @@ async function api(user: User, request: http.IncomingMessage, url: URL) { await email('Test alert from the Graphite console', `${user.login} sent this test from ${config.appUrl}/#/alerts.`); return { ok: true }; } - if (route === 'GET /api/logs/search') { - const text = (url.searchParams.get('q') ?? '').trim(); - const window = { '15m': 900_000, '1h': 3600_000, '6h': 21_600_000, '24h': 86_400_000, '7d': 604_800_000 }[url.searchParams.get('since') ?? '1h']; - if (text.length < 2 || !window) { - throw new Refusal(400, 'Search for at least two characters over 15m, 1h, 6h, 24h or 7d.'); - } - const project = url.searchParams.get('project'); - const chosen = (await containers()).filter((container) => !project || container.project === project); - return searchLogs(chosen, text, Date.now() - window); + if (route === 'GET /api/logs') { + const list = (name: string) => url.searchParams.get(name)?.split(',').filter(Boolean); + const number = (name: string) => (url.searchParams.has(name) ? Number(url.searchParams.get(name)) : undefined); + const since = { '15m': 900_000, '1h': 3600_000, '6h': 21_600_000, '24h': 86_400_000, '7d': 604_800_000 }[url.searchParams.get('since') ?? '']; + return logStore.query({ + projects: list('projects'), + containers: list('containers'), + streams: list('streams'), + levels: list('levels'), + text: url.searchParams.get('q')?.trim() || undefined, + from: since ? Date.now() - since : undefined, + before: number('before'), + after: number('after'), + limit: number('limit'), + }); + } + if (route === 'GET /api/logs/sources') { + return { sources: logStore.sources(), ...logStore.stats(), keepDays: config.logsKeepDays }; } if (route === 'GET /api/docs') { return docs(); @@ -362,6 +381,24 @@ async function api(user: User, request: http.IncomingMessage, url: URL) { closeRevoked(grant[1]); return { ok: true, scopes: access.scopes(grant[1]) }; } + if (route === 'POST /api/volumes') { + await requireOwnerNow(request); + await createVolume(((await body(request)) as { name?: unknown }).name); + await refreshDisk(); + return { ok: true }; + } + const volume = /^(GET|POST) \/api\/volumes\/([A-Za-z0-9][A-Za-z0-9_.-]*)\/(files|delete)$/.exec(route); + if (volume) { + await requireOwnerNow(request); + if (volume[1] === 'GET' && volume[3] === 'files') { + return listFiles(volume[2], url.searchParams.get('path') ?? ''); + } + if (volume[1] === 'POST' && volume[3] === 'delete') { + await removeVolume(volume[2]); + await refreshDisk(); + return { ok: true }; + } + } if (route === 'POST /api/prune') { await requireNow(request, user, 'area:images'); const result = await docker<{ SpaceReclaimed: number; ImagesDeleted: { Deleted?: string }[] | null }>( @@ -474,6 +511,11 @@ async function handle(request: http.IncomingMessage, response: http.ServerRespon if (logFile && request.method === 'GET') { return downloadLogs(response, await findContainer(logFile[1])); } + const volumeFile = /^\/api\/volumes\/([A-Za-z0-9][A-Za-z0-9_.-]*)\/file$/.exec(url.pathname); + if (volumeFile && request.method === 'GET') { + await requireOwnerNow(request); + return downloadFile(response, volumeFile[1], url.searchParams.get('path') ?? ''); + } const archive = /^\/api\/backups\/([a-z0-9][a-z0-9_-]*)\/(\d{8}T\d{6}Z)\.tar$/.exec(url.pathname); if (archive && request.method === 'GET') { await requireOwnerNow(request); @@ -527,8 +569,13 @@ server.on('upgrade', (request, socket, head) => { return openSocket(request, socket, head, target, url.search); } const found = url.pathname === '/ws/host' ? undefined : await findContainer(url.searchParams.get('container') ?? ''); - const scope = url.pathname === '/ws/host' ? 'area:shell' : url.pathname === '/ws/terminal' ? `project:${found!.project}` : undefined; + const mode: ShellMode = url.searchParams.get('mode') === 'root' ? 'root' : 'operator'; + const scope = url.pathname === '/ws/host' ? (mode === 'root' ? 'owner:shell' : 'area:shell') : url.pathname === '/ws/terminal' ? `project:${found!.project}` : undefined; const role = scope ? await requireNow(request, user, scope) : undefined; + const host = url.pathname === '/ws/host' ? await hostShell(mode, user.login) : undefined; + if (host) { + console.log(`${user.login} opened a ${mode} host shell`); + } sockets.handleUpgrade(request, socket, head, (client) => { if (scope && role) { hold(request, user, scope, role, client, () => client.terminate()); @@ -538,8 +585,8 @@ server.on('upgrade', (request, socket, head) => { ? logs(client, found!.id, url.searchParams) : url.pathname === '/ws/terminal' ? terminal(client, { container: found!.id }) - : url.pathname === '/ws/host' - ? terminal(client, 'host') + : host + ? terminal(client, { host }) : Promise.reject(new Error('Unknown socket.')); run.catch((error: unknown) => { client.send(`\r\n${error instanceof Error ? error.message : 'The session failed.'}\r\n`); diff --git a/app/src/server/rollout.ts b/app/src/server/rollout.ts index d92d6d8..200a0ee 100644 --- a/app/src/server/rollout.ts +++ b/app/src/server/rollout.ts @@ -1,4 +1,4 @@ -import { readFile, stat } from 'node:fs/promises'; +import { readFile, stat, utimes, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { config } from './config.ts'; import { Demuxer, docker, stream } from './docker.ts'; @@ -47,7 +47,7 @@ export async function onHost(command: string[]) { const unit = (project: string) => `graphite-redeploy-${project}`; export async function redeployStatus(project: string) { - const { output } = await onHost(['systemctl', 'show', unit(project), '--property=ActiveState,SubState,InvocationID,StateChangeTimestamp']); + const { output } = await onHost(['systemctl', 'show', unit(project), '--timestamp=unix', '--property=ActiveState,SubState,InvocationID,StateChangeTimestamp']); const properties = Object.fromEntries(output.split('\n').map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)])); const state = properties.ActiveState === 'activating' || properties.SubState === 'running' @@ -61,7 +61,13 @@ export async function redeployStatus(project: string) { state !== 'none' && properties.InvocationID ? (await onHost(['journalctl', `_SYSTEMD_INVOCATION_ID=${properties.InvocationID}`, '--no-pager', '--output=cat', '--lines=30'])).output : ''; - return { state, log, at: properties.StateChangeTimestamp || null }; + const changed = Number(properties.StateChangeTimestamp?.replace(/^@/, '')) * 1000 || null; + if (state === 'done' && changed && ((await appliedAt(project)) ?? 0) < changed) { + const marker = applied(project, '.applied'); + await writeFile(marker, '', { flag: 'a' }); + await utimes(marker, changed / 1000, changed / 1000); + } + return { state, log, at: changed }; } export async function startRedeploy(project: string) { diff --git a/app/src/server/shell.ts b/app/src/server/shell.ts new file mode 100644 index 0000000..b6242d7 --- /dev/null +++ b/app/src/server/shell.ts @@ -0,0 +1,133 @@ +import { chmod, mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { config } from './config.ts'; +import { onHost } from './rollout.ts'; + +export type ShellMode = 'operator' | 'root'; + +export const operator = 'graphite-ops'; +const guardPath = '/usr/local/share/graphite/shell-guard.bash'; +const word = '(^|[;&|("\'`] *|(sudo|exec|nohup|time|env|xargs) +([^ ]+ +)*)'; +const kept = '(etc/graphite|srv/graphite|var/lib/graphite|var/lib/docker)'; +const end = '([ "\'`;&|)]|$)'; + +export const blocked = [ + String.raw`${word}rm +(-[A-Za-z-]+ +)*(/|/\*|/(bin|boot|etc|home|lib|lib64|opt|root|sbin|srv|usr|var)/?)${end}`, + String.raw`${word}(rm|mv|shred|truncate|chmod|chown|chattr) +.*/${kept}(/|${end})`, + String.raw`> */${kept}/`, + String.raw`${word}(mkfs[.a-z0-9]*|wipefs|fdisk|sfdisk|parted|mkswap)${end}`, + String.raw`${word}dd .*of=/dev/`, + String.raw`> */dev/(sd|vd|xvd|nvme)`, + String.raw`${word}(shutdown|reboot|halt|poweroff)${end}`, + String.raw`${word}init +[06]${end}`, + String.raw`${word}systemctl +(poweroff|reboot|halt|kexec|rescue|emergency)${end}`, + String.raw`${word}systemctl +(stop|disable|mask|kill|restart) +(docker|containerd|graphite-agent|ssh|sshd)(\.service|\.socket)?${end}`, + String.raw`${word}service +(docker|containerd|ssh|sshd) +(stop|restart)`, + String.raw`${word}docker +(container +)?(rm|stop|kill|pause|restart|update|rename) +.*console`, + String.raw`${word}docker +compose +.*console.* (down|stop|rm|kill|pause)${end}`, + String.raw`${word}docker +volume +(rm +.*console_data|prune)`, + String.raw`${word}docker +system +prune +.*(--volumes|-a|--all)`, + String.raw`${word}docker +network +rm +.*edge`, + String.raw`:\(\) *\{ *:\|: *& *\} *; *:`, + String.raw`${word}ufw +(disable|reset)${end}`, + String.raw`${word}iptables +.*(-F|--flush)`, + String.raw`${word}nft +flush +ruleset`, + String.raw`${word}(userdel|deluser) +`, + String.raw`${word}passwd +root${end}`, + String.raw`${word}(chmod|chown) +-R +[^ ]+ +/${end}`, + String.raw`${word}trap${end}`, + String.raw`${word}(bash|sh|dash|zsh|ksh|fish)( +-[-A-Za-z]+)*$`, + String.raw`${word}(su${end}|sudo +(-i|-s|su|bash|sh)${end})`, +]; + +export const confirmed = [ + String.raw`${word}rm +.*-[A-Za-z]*[rRf]`, + String.raw`${word}docker +(rm|stop|kill|restart|rmi|container +(rm|prune)|image +(rm|prune)|system +prune|network +rm|volume +rm)${end}`, + String.raw`${word}docker +compose +.* (down|rm|stop|kill)${end}`, + String.raw`${word}systemctl +(stop|restart|disable|mask) `, + String.raw`${word}(kill|pkill|killall) `, + String.raw`${word}(apt|apt-get|dpkg) +.*(remove|purge|autoremove)${end}`, + String.raw`${word}(truncate|shred) `, + String.raw`${word}(chmod|chown) +-R `, + String.raw`${word}git +(reset +--hard|clean +-[a-z]*f)`, + String.raw`${word}crontab +-r`, + String.raw`> */etc/`, +]; + +const pattern = (list: string[]) => list.map((entry) => `(${entry})`).join('|'); + +export function classify(command: string) { + return new RegExp(pattern(blocked)).test(command) ? 'block' : new RegExp(pattern(confirmed)).test(command) ? 'confirm' : 'allow'; +} + +const quote = (text: string) => `'${text.replaceAll("'", "'\\''")}'`; + +export function guardScript() { + return `[ -r /etc/profile ] && . /etc/profile +[ -r "$HOME/.bashrc" ] && . "$HOME/.bashrc" +GRAPHITE_BLOCK=${quote(pattern(blocked))} +GRAPHITE_CONFIRM=${quote(pattern(confirmed))} +readonly GRAPHITE_BLOCK GRAPHITE_CONFIRM GRAPHITE_LOGIN GRAPHITE_MODE +PS1='\\[\\e[2m\\]'"$GRAPHITE_MODE"'@\\h\\[\\e[0m\\]:\\w\\$ ' +shopt -s extdebug +__graphite_guard() { + [ -n "\${COMP_LINE-}" ] && return 0 + local command=$BASH_COMMAND answer + case $command in __graphite_guard*) return 0 ;; esac + if printf '%s' "$command" | grep -Eq -- "$GRAPHITE_BLOCK"; then + logger -t graphite-shell -- "$GRAPHITE_LOGIN ($GRAPHITE_MODE) blocked: $command" + printf '\\033[31mBlocked by the shell guard: this could take down the VPS or the console, or leave the guard. Use SSH if it is really needed.\\033[0m\\n' >&2 + return 1 + fi + if printf '%s' "$command" | grep -Eq -- "$GRAPHITE_CONFIRM"; then + printf '\\033[33mDestructive: %s\\nType yes to run it: \\033[0m' "$command" >&2 + read -r answer &2 + return 1 + fi + fi + logger -t graphite-shell -- "$GRAPHITE_LOGIN ($GRAPHITE_MODE): $command" + return 0 +} +readonly -f __graphite_guard +trap __graphite_guard DEBUG +logger -t graphite-shell -- "$GRAPHITE_LOGIN opened a $GRAPHITE_MODE shell" +`; +} + +const reads = ['ps', 'stats', 'logs', 'top', 'images', 'volume ls', 'network ls', 'compose ls', 'system df']; +export const sudoers = `${operator} ALL=(root) NOPASSWD: ${reads.flatMap((command) => [`/usr/bin/docker ${command}`, `/usr/bin/docker ${command} *`]).join(', ')}\n`; + +let prepared: Promise | undefined; + +async function prepare() { + const guard = path.join(config.hostRoot, guardPath); + await mkdir(path.dirname(guard), { recursive: true }); + await writeFile(guard, guardScript()); + await chmod(guard, 0o644); + const result = await onHost([ + 'sh', + '-c', + `set -e +id ${operator} >/dev/null 2>&1 || useradd --system --create-home --shell /bin/bash ${operator} +printf '%s' ${quote(sudoers)} > /etc/sudoers.d/${operator}.new +chmod 440 /etc/sudoers.d/${operator}.new +visudo -cf /etc/sudoers.d/${operator}.new >/dev/null +mv /etc/sudoers.d/${operator}.new /etc/sudoers.d/${operator}`, + ]); + if (result.code !== 0) { + throw new Error(`Preparing the host shell failed: ${result.output}`); + } +} + +export async function hostShell(mode: ShellMode, login: string) { + prepared ??= prepare().catch((error: unknown) => { + prepared = undefined; + throw error; + }); + await prepared; + const shell = ['env', `GRAPHITE_LOGIN=${login}`, `GRAPHITE_MODE=${mode}`, 'TERM=xterm-256color', '/bin/bash', '--rcfile', guardPath, '-i']; + return ['nsenter', '--target', '1', '--all', '--', ...(mode === 'root' ? shell : ['runuser', '-u', operator, '--', ...shell.slice(0, 3), `HOME=/home/${operator}`, ...shell.slice(3)])]; +} diff --git a/app/src/server/terminal.ts b/app/src/server/terminal.ts index d2bb554..b940158 100644 --- a/app/src/server/terminal.ts +++ b/app/src/server/terminal.ts @@ -1,20 +1,18 @@ import type http from 'node:http'; import type { WebSocket } from 'ws'; import { config } from './config.ts'; -import { Demuxer, docker, LogLines, stream, upgrade, type LogLine } from './docker.ts'; +import { Demuxer, docker, LogLines, stream, upgrade } from './docker.ts'; const shell = ['/bin/sh', '-c', 'if command -v bash >/dev/null; then exec bash; else exec sh; fi']; -const hostShell = ['nsenter', '--target', '1', '--all', '--', '/bin/bash', '-l']; -export async function terminal(socket: WebSocket, target: 'host' | { container: string }) { - const container = target === 'host' ? config.self : target.container; - const { Id: exec } = await docker<{ Id: string }>('POST', `/containers/${container}/exec`, { +export async function terminal(socket: WebSocket, target: { host: string[] } | { container: string }) { + const { Id: exec } = await docker<{ Id: string }>('POST', `/containers/${'host' in target ? config.self : target.container}/exec`, { AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Env: ['TERM=xterm-256color'], - Cmd: target === 'host' ? hostShell : shell, + Cmd: 'host' in target ? target.host : shell, }); const pipe = await upgrade(`/exec/${exec}/start`, { Detach: false, Tty: true }); @@ -88,27 +86,6 @@ export async function logs(socket: WebSocket, container: string, params: URLSear }); } -export async function searchLogs(containers: { id: string; name: string }[], text: string, since: number, limit = 500) { - const needle = text.toLowerCase(); - const found: (LogLine & { container: string })[] = []; - await Promise.all( - containers.map(async (container) => { - const { output, read } = await readLogs(container.id, `follow=0&tail=20000&since=${Math.floor(since / 1000)}`); - let matches = 0; - for await (const chunk of output) { - for (const line of read(chunk as Buffer)) { - if (matches < limit && line.text.toLowerCase().includes(needle)) { - found.push({ ...line, container: container.name }); - matches += 1; - } - } - } - }), - ); - found.sort((a, b) => b.time.localeCompare(a.time)); - return { lines: found.slice(0, limit), more: found.length > limit }; -} - export async function downloadLogs(response: http.ServerResponse, container: { id: string; name: string }) { const { output, read } = await readLogs(container.id, 'tail=all'); response.writeHead(200, { diff --git a/app/src/server/volumes.ts b/app/src/server/volumes.ts new file mode 100644 index 0000000..325dbca --- /dev/null +++ b/app/src/server/volumes.ts @@ -0,0 +1,73 @@ +import { createReadStream } from 'node:fs'; +import { lstat, readdir, realpath, stat } from 'node:fs/promises'; +import type { ServerResponse } from 'node:http'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { config } from './config.ts'; +import { docker } from './docker.ts'; +import { Refusal } from './refusal.ts'; + +export const volumePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; + +function check(name: string) { + if (!volumePattern.test(name)) { + throw new Refusal(400, `${name} is not a volume name.`); + } +} + +export async function inside(name: string, relative: string, volumes = path.join(config.hostRoot, 'var/lib/docker/volumes')) { + check(name); + const root = await realpath(path.join(volumes, name, '_data')).catch(() => { + throw new Refusal(404, `There is no volume ${name}.`); + }); + const target = await realpath(path.join(root, relative)).catch(() => { + throw new Refusal(404, `There is no ${relative} in ${name}.`); + }); + if (target !== root && !target.startsWith(root + path.sep)) { + throw new Refusal(403, 'That path leads out of the volume.'); + } + return target; +} + +export async function listFiles(name: string, relative: string, volumes?: string) { + const folder = await inside(name, relative, volumes); + const entries = await readdir(folder, { withFileTypes: true }); + const rows = await Promise.all( + entries.slice(0, 5000).map(async (entry) => { + const info = await lstat(path.join(folder, entry.name)); + const kind = entry.isDirectory() ? 'folder' : entry.isSymbolicLink() ? 'link' : entry.isFile() ? 'file' : 'other'; + return { name: entry.name, kind, size: info.size, modified: info.mtimeMs }; + }), + ); + return rows.sort((a, b) => Number(b.kind === 'folder') - Number(a.kind === 'folder') || a.name.localeCompare(b.name)); +} + +export async function downloadFile(response: ServerResponse, name: string, relative: string) { + const file = await inside(name, relative); + const info = await stat(file); + if (!info.isFile()) { + throw new Refusal(400, 'Only a file can be downloaded.'); + } + response.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Length': info.size, + 'Content-Disposition': `attachment; filename="${path.basename(file).replace(/["\\\r\n]/g, '_')}"`, + 'Cache-Control': 'no-store', + }); + await pipeline(createReadStream(file), response); +} + +export async function createVolume(name: unknown) { + if (typeof name !== 'string') { + throw new Refusal(400, 'Name the volume.'); + } + check(name); + await docker('POST', '/volumes/create', { Name: name, Labels: { 'graphite.created-by': 'console' } }); +} + +export async function removeVolume(name: string) { + check(name); + await docker('DELETE', `/volumes/${encodeURIComponent(name)}`).catch((error: Error) => { + throw new Refusal(409, error.message.replace(/^Docker answered \d+ for [^:]+: /, '')); + }); +} diff --git a/app/src/web/App.tsx b/app/src/web/App.tsx index 49f3ad3..f76c642 100644 --- a/app/src/web/App.tsx +++ b/app/src/web/App.tsx @@ -11,7 +11,7 @@ import { ContainerPage } from './pages/ContainerPage'; import { Docs } from './pages/Docs'; import { HostShell } from './pages/HostShell'; import { Images } from './pages/Images'; -import { LogSearch } from './pages/LogSearch'; +import { LogBrowser } from './pages/LogBrowser'; import { Overview } from './pages/Overview'; import { ProjectPage } from './pages/ProjectPage'; import { SecretPage, Secrets } from './pages/Secrets'; @@ -45,13 +45,13 @@ function Page({ hash, user }: { hash: string; user: User }) { if (docs) return ; const secret = /^\/secrets\/([a-z0-9-]+)\/([A-Z_][A-Z0-9_]*)(?:\/(history|usage))?$/.exec(hash); if (secret && can(user, `secrets:${secret[1]}`)) return ; - if (user.owner && hash === '/secrets') return ; + if (user.owner && hash === '/secrets') return ; if (user.owner && hash === '/backups') return ; if (hash === '/images') return ; - if (hash === '/volumes') return ; + if (hash === '/volumes') return ; if (hash === '/alerts') return ; - if (hash === '/logs') return ; - if (can(user, 'area:shell') && hash === '/shell') return ; + if (hash === '/logs') return ; + if (can(user, 'area:shell') && hash === '/shell') return ; if (user.owner && hash === '/admin') return ; return ; } @@ -72,7 +72,7 @@ export function App() { const pages: ShellTab[] = [ { href: '#/', label: 'Overview', icon: 'home', active: !/^\/(images|volumes|alerts|logs|shell|admin|docs|secrets|backups)/.test(hash) }, - { href: '#/alerts', label: alerts ? `Alerts · ${alerts}` : 'Alerts', icon: 'bell', active: hash === '/alerts' }, + { href: '#/alerts', label: alerts ? `Alerts · ${alerts}` : 'Alerts', icon: 'bell', active: hash === '/alerts' }, { href: '#/logs', label: 'Logs', icon: 'search', active: hash === '/logs' }, { href: '#/images', label: 'Images', icon: 'library', active: hash === '/images' }, { href: '#/volumes', label: 'Volumes', icon: 'folder', active: hash === '/volumes' }, @@ -94,7 +94,7 @@ export function App() { user && ( - {user.login} · {user.owner ? 'owner' : 'member'} + {user.login} · {user.owner ? 'owner' : 'member'} sign out diff --git a/app/src/web/api.ts b/app/src/web/api.ts index 667e69b..b095a17 100644 --- a/app/src/web/api.ts +++ b/app/src/web/api.ts @@ -100,8 +100,17 @@ export interface BackupRequest { at?: string; } +export interface BackupSchedule { + enabled: boolean; + everyHours: number; + hour: number; + keepDays: number; +} + export interface BackupsData { projects: { project: string; archives: BackupSummary[] }[]; + schedule: BackupSchedule; + next: number | null; requests: BackupRequest[]; } @@ -124,7 +133,6 @@ export interface Secret { export interface SecretProject { project: string; - needsRedeploy: boolean; missing: string[]; } @@ -229,13 +237,6 @@ export interface AlertsData { rules: AlertRule[]; } -export interface LogMatch { - container: string; - stream: 'stdout' | 'stderr'; - time: string; - text: string; -} - export interface DocPage { path: string; title: string; diff --git a/app/src/web/components/ActionButton.tsx b/app/src/web/components/ActionButton.tsx new file mode 100644 index 0000000..922a867 --- /dev/null +++ b/app/src/web/components/ActionButton.tsx @@ -0,0 +1,62 @@ +import Tooltip from '@mui/material/Tooltip'; +import { Button, type ButtonVariant } from '@project-graphite/ui'; +import { useEffect, useState } from 'react'; + +export interface ActionLabels { + idle: string; + busy: string; + done: string; + failed: string; +} + +export function ActionButton({ + labels, + run, + disabled = false, + variant = 'secondary', +}: { + labels: ActionLabels; + run: () => Promise; + disabled?: boolean; + variant?: ButtonVariant; +}) { + const [state, setState] = useState<'idle' | 'busy' | 'done' | 'failed'>('idle'); + const [error, setError] = useState(''); + + useEffect(() => { + if (state !== 'done') { + return; + } + const timer = setTimeout(() => setState('idle'), 2000); + return () => clearTimeout(timer); + }, [state]); + + const start = () => { + setState('busy'); + run().then( + () => setState('done'), + (reason: Error) => { + setError(reason.message); + setState('failed'); + }, + ); + }; + + return ( + {error} : ''}> + + + + + ); +} diff --git a/app/src/web/components/Chart.tsx b/app/src/web/components/Chart.tsx index e18efba..4b10160 100644 --- a/app/src/web/components/Chart.tsx +++ b/app/src/web/components/Chart.tsx @@ -45,9 +45,19 @@ function useWidth() { return { ref, width }; } -function Breakdown({ lines, at, metric, total, format }: { lines: Series[]; at: number; metric: Metric; total: number; format: (value: number) => string }) { +function near(points: Series['points'], at: number, step: number) { + let best: Series['points'][number] | undefined; + for (const point of points) { + if (Math.abs(point.at - at) <= step / 2 && (!best || Math.abs(point.at - at) < Math.abs(best.at - at))) { + best = point; + } + } + return best; +} + +function Breakdown({ lines, at, step, metric, total, format }: { lines: Series[]; at: number; step: number; metric: Metric; total: number; format: (value: number) => string }) { const parts = lines - .map((line) => ({ name: line.name, value: line.points.find((point) => point.at === at)?.[metric] ?? 0 })) + .map((line) => ({ name: line.name, value: near(line.points, at, step)?.[metric] ?? 0 })) .filter((part) => part.value > 0) .sort((a, b) => b.value - a.value); const rest = total - parts.reduce((sum, part) => sum + part.value, 0); @@ -94,7 +104,7 @@ export function Chart({ label, series, metric, span, step, now, format, max, bre const values = hover ? plotted - .map((line) => ({ name: line.name, point: line.points.find((point) => point.at === hover.at) })) + .map((line) => ({ name: line.name, point: near(line.points, hover.at, step) })) .filter((entry): entry is { name: string; point: Series['points'][number] } => entry.point !== undefined) .sort((a, b) => b.point[metric] - a.point[metric]) : []; @@ -105,10 +115,10 @@ export function Chart({ label, series, metric, span, step, now, format, max, bre const pointerY = event.clientY - bounds.top; const at = Math.round((start + ((pointerX - pad.left) / Math.max(1, width - pad.left - pad.right)) * span) / step) * step; const nearest = plotted - .map((line) => ({ name: line.name, point: line.points.find((point) => point.at === at) })) + .map((line) => ({ name: line.name, point: near(line.points, at, step) })) .filter((entry) => entry.point) .sort((a, b) => Math.abs(y(a.point![metric]) - pointerY) - Math.abs(y(b.point![metric]) - pointerY))[0]; - setHover(at >= start && at <= now ? { at, name: nearest?.name, x: x(at) } : undefined); + setHover(at >= start - step / 2 && at <= now + step / 2 ? { at, name: nearest?.name, x: x(at) } : undefined); }; const ticks = (width < 480 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1]).map((fraction) => start + fraction * span); @@ -185,7 +195,7 @@ export function Chart({ label, series, metric, span, step, now, format, max, bre {format(point[metric])}

))} - {breakdown && } + {breakdown && } )} {series.length > 1 && ( diff --git a/app/src/web/components/Rail.tsx b/app/src/web/components/Rail.tsx new file mode 100644 index 0000000..cc24c15 --- /dev/null +++ b/app/src/web/components/Rail.tsx @@ -0,0 +1,36 @@ +import { TagChip, type TagColor } from '@project-graphite/ui'; +import type { ReactNode } from 'react'; + +export interface Stat { + label: string; + value: ReactNode; + detail?: ReactNode; + pill?: TagColor; +} + +export function Rail({ title, stats, children }: { title: string; stats: Stat[]; children?: ReactNode }) { + return ( + + ); +} + +export function WithRail({ rail, children }: { rail: ReactNode; children: ReactNode }) { + return ( +
+
{children}
+ {rail} +
+ ); +} diff --git a/app/src/web/components/Redeploy.tsx b/app/src/web/components/Redeploy.tsx index 00d0403..25e4911 100644 --- a/app/src/web/components/Redeploy.tsx +++ b/app/src/web/components/Redeploy.tsx @@ -1,61 +1,33 @@ -import { Button } from '@project-graphite/ui'; -import { useEffect, useState } from 'react'; import { get, post } from '../api'; +import { ActionButton } from './ActionButton'; interface Status { state: 'none' | 'running' | 'done' | 'failed'; log: string; } -export function Redeploy({ project, onDone }: { project: string; onDone: () => void }) { - const [watching, setWatching] = useState(false); - const [status, setStatus] = useState(); - const [message, setMessage] = useState(); +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - useEffect(() => { - if (!watching) { +async function redeploy(project: string) { + await post(`/api/projects/${project}/redeploy`); + for (const deadline = Date.now() + 16 * 60_000; Date.now() < deadline; ) { + await pause(3000); + const status = await get(`/api/projects/${project}/redeploy`).catch(() => undefined); + if (status?.state === 'done') { return; } - const timer = setInterval(() => { - get(`/api/projects/${project}/redeploy`).then( - (next) => { - setStatus(next); - setMessage(undefined); - if (next.state === 'done' || next.state === 'failed') { - setWatching(false); - onDone(); - } - }, - () => setMessage('Waiting for the console to answer…'), - ); - }, 3000); - return () => clearInterval(timer); - }, [watching, project, onDone]); - - const start = () => { - setStatus(undefined); - setMessage('Starting…'); - post(`/api/projects/${project}/redeploy`).then( - () => { - setMessage(undefined); - setStatus({ state: 'running', log: '' }); - setWatching(true); - }, - (error: Error) => setMessage(error.message), - ); - }; + if (status?.state === 'failed') { + throw new Error(status.log.split('\n').slice(-8).join('\n') || 'The redeploy failed.'); + } + } + throw new Error('The redeploy did not finish within 16 minutes.'); +} +export function Redeploy({ project, onDone }: { project: string; onDone: () => void }) { return ( - - - - - {message ?? (status?.state === 'running' ? 'Redeploying…' : status?.state === 'done' ? 'Redeployed.' : status?.state === 'failed' ? 'The redeploy failed:' : '')} - - - {status?.state === 'failed' && status.log &&
{status.log}
} -
+ redeploy(project).finally(onDone)} + /> ); } diff --git a/app/src/web/pages/Admin.tsx b/app/src/web/pages/Admin.tsx index f350b28..76b56fd 100644 --- a/app/src/web/pages/Admin.tsx +++ b/app/src/web/pages/Admin.tsx @@ -1,86 +1,185 @@ -import { Button, Checkbox, ErrorState, PageHeader, TagChip, timeAgo } from '@project-graphite/ui'; +import MuiTextField from '@mui/material/TextField'; +import { Button, Dialog, ErrorState, PageHeader, TagChip, timeAgo, Toggle } from '@project-graphite/ui'; import { useState } from 'react'; import { post, type Member, type Overview } from '../api'; +import { ActionButton } from '../components/ActionButton'; +import { Rail, WithRail } from '../components/Rail'; import { usePoll } from '../usePoll'; const areas = [ - { scope: '*', label: 'everything' }, - { scope: 'area:shell', label: 'host shell' }, - { scope: 'area:images', label: 'images' }, + { scope: '*', label: 'Everything', detail: 'every project, the host shell and images; secrets still need their own grant' }, + { scope: 'area:shell', label: 'Host shell', detail: 'a root shell on the VPS' }, + { scope: 'area:images', label: 'Images', detail: 'remove unused images' }, ]; -function Access({ member, projects, onSaved }: { member: Member; projects: string[]; onSaved: () => void }) { +function summary(scopes: string[]) { + const projects = scopes.filter((scope) => scope.startsWith('project:')).length; + const secrets = scopes.filter((scope) => scope.startsWith('secrets:')).length; + return [ + ...(scopes.includes('*') ? ['everything'] : []), + ...(scopes.includes('area:shell') ? ['host shell'] : []), + ...(scopes.includes('area:images') ? ['images'] : []), + ...(projects && !scopes.includes('*') ? [`${projects} project${projects > 1 ? 's' : ''}`] : []), + ...(secrets ? [`secrets of ${secrets}`] : []), + ]; +} + +function AccessDialog({ member, projects, onClose, onSaved }: { member: Member; projects: string[]; onClose: () => void; onSaved: () => void }) { const [scopes, setScopes] = useState(member.scopes); - const [message, setMessage] = useState(); + const [filter, setFilter] = useState(''); const changed = [...scopes].sort().join() !== [...member.scopes].sort().join(); const everything = scopes.includes('*'); - const toggle = (scope: string) => setScopes(scopes.includes(scope) ? scopes.filter((entry) => entry !== scope) : [...scopes, scope]); - const save = () => { - setMessage('Saving…'); - post(`/api/admin/members/${member.login}`, { scopes }).then( - () => { - setMessage('Saved.'); - onSaved(); - }, - (error: Error) => setMessage(error.message), - ); - }; - const option = (scope: string, label: string, disabled = false) => ( - toggle(scope)} /> - ); + const set = (scope: string, on: boolean) => setScopes(on ? [...new Set([...scopes, scope])] : scopes.filter((entry) => entry !== scope)); + const shown = projects.filter((slug) => slug.includes(filter.trim().toLowerCase())); return ( -
-
{areas.map((area) => option(area.scope, area.label, area.scope !== '*' && everything))}
-
{projects.map((slug) => option(`project:${slug}`, slug, everything))}
-
- secrets - {projects.map((slug) => ( - toggle(`secrets:${slug}`)} /> - ))} + +
+
+

Areas

+ {areas.map((area) => ( + set(area.scope, on)} + /> + ))} +
+
+
+

Projects

+ setFilter(event.target.value)} size="small" type="search" value={filter} /> +
+
+ + + + + + + + + + + + + + + {shown.map((slug) => ( + + + + + + ))} + +
projectoperatesecrets
{slug} + set(`project:${slug}`, on)} /> + + set(`secrets:${slug}`, on)} /> +
+ {shown.length === 0 &&

No project matches.

} +
+
+
+ + post(`/api/admin/members/${member.login}`, { scopes }).then(onSaved)} + variant="primary" + /> +
-
- - {message && {message}} -
-
+ ); } export function Admin() { const members = usePoll('/api/admin/members', 60_000); const overview = usePoll('/api/overview', 60_000); + const [editing, setEditing] = useState(); if (members.error && !members.data) { return void members.reload()} title="The console could not list the members">{members.error.message}; } const projects = (overview.data?.projects ?? []).map((project) => project.slug); + const list = members.data ?? []; + const member = list.find((entry) => entry.login === editing); + const week = Date.now() - 7 * 86_400_000; return (
- - - Every organisation member can read the console. Write access starts, stops and restarts a project's containers, wakes it, - opens its terminals and ports, and takes effect at once. - - -
- {(members.data ?? []).map((member) => ( -
-
- {member.login} - {member.owner && } - - {member.seen ? `last seen ${timeAgo(new Date(member.seen).toISOString())}` : 'never signed in'} - -
- {member.owner ? ( -

Everything, as an owner.

- ) : ( - void members.reload()} projects={projects} /> - )} -
- ))} -
+ + entry.owner).length }, + { label: 'With write access', value: list.filter((entry) => !entry.owner && entry.scopes.length).length }, + { label: 'Seen this week', value: list.filter((entry) => (entry.seen ?? 0) > week).length }, + { label: 'Never signed in', value: list.filter((entry) => !entry.seen).length }, + ]} + title="Members" + /> + } + > +
+
+ + + + + + + + + + + + + + + + + + {list.map((entry) => ( + + + + + + + + ))} + +
memberrolelast seenaccess +
{entry.login} + + {entry.seen ? timeAgo(new Date(entry.seen).toISOString()) : 'never'} + + {entry.owner ? ( + + ) : summary(entry.scopes).length ? ( + summary(entry.scopes).map((label) => ) + ) : ( + + )} + + + {!entry.owner && ( + + )} +
+
+
+
+ {member && setEditing(undefined)} onSaved={() => void members.reload()} projects={projects} />}
); } diff --git a/app/src/web/pages/Alerts.tsx b/app/src/web/pages/Alerts.tsx index 6c4adfc..30f8ac8 100644 --- a/app/src/web/pages/Alerts.tsx +++ b/app/src/web/pages/Alerts.tsx @@ -1,30 +1,23 @@ import MuiLink from '@mui/material/Link'; import MuiTextField from '@mui/material/TextField'; -import { Button, EmptyState, ErrorState, PageHeader, timeAgo, Toggle } from '@project-graphite/ui'; +import { EmptyState, ErrorState, PageHeader, timeAgo, Toggle } from '@project-graphite/ui'; import { useState } from 'react'; import { post, type Alert, type AlertRule, type AlertsData } from '../api'; +import { ActionButton } from '../components/ActionButton'; +import { Rail, WithRail } from '../components/Rail'; import { usePoll } from '../usePoll'; function RuleRow({ rule, owner, onSaved }: { rule: AlertRule; owner: boolean; onSaved: () => void }) { const [threshold, setThreshold] = useState(String(rule.threshold)); const [minutes, setMinutes] = useState(String(rule.minutes)); const [enabled, setEnabled] = useState(rule.enabled); - const [message, setMessage] = useState(); const changed = Number(threshold) !== rule.threshold || Number(minutes) !== rule.minutes || enabled !== rule.enabled; - const save = () => - post(`/api/alerts/rules/${rule.kind}`, { threshold: Number(threshold), minutes: Number(minutes), enabled }).then( - () => { - setMessage('Saved.'); - onSaved(); - }, - (error: Error) => setMessage(error.message), - ); return ( - + {rule.unit ? ( — )} - + setMinutes(event.target.value)} size="small" slotProps={{ htmlInput: { min: 0, step: 'any' } }} @@ -52,14 +45,13 @@ function RuleRow({ rule, owner, onSaved }: { rule: AlertRule; owner: boolean; on value={minutes} /> - + {owner && ( - - - {message && {message}} - + post(`/api/alerts/rules/${rule.kind}`, { threshold: Number(threshold), minutes: Number(minutes), enabled }).then(onSaved)} + /> )} @@ -67,29 +59,26 @@ function RuleRow({ rule, owner, onSaved }: { rule: AlertRule; owner: boolean; on } function Rules({ data, owner, onSaved }: { data: AlertsData; owner: boolean; onSaved: () => void }) { - const [message, setMessage] = useState(); - const test = () => { - setMessage('Sending…'); - post('/api/alerts/test').then( - () => setMessage('Sent; check the inbox.'), - (error: Error) => setMessage(error.message), - ); - }; return (
-
+

Rules

{owner && ( - - {message && {message}} - - + post('/api/alerts/test')} + /> )}
- +
+ + + + + + @@ -100,12 +89,11 @@ function Rules({ data, owner, onSaved }: { data: AlertsData; owner: boolean; onS {data.rules.map((rule) => ( - + ))}
alert when
-

An alert resolves once its condition has been clear for a minute. CPU and memory of a container are measured against its own limits.

); } @@ -131,60 +119,72 @@ export function Alerts({ owner }: { owner: boolean }) { if (!data) { return null; } + const on = data.rules.filter((rule) => rule.enabled).length; return (
- - - Checked every 10 seconds against the rules below. {data.emailing ? 'Each alert and its recovery are emailed.' : 'Email is off until SMTP_URL and ALERTS_EMAIL are set.'}{' '} - {data.notifying ? `Each alert also opens an issue in ${data.repo}.` : 'GitHub issues are off until ALERTS_TOKEN is set.'} - - - {data.active.length === 0 ? ( - Nothing needs attention. - ) : ( -
- {data.active.map((alert) => ( -
-

{alert.title}

-

{alert.detail}

-

- since {timeAgo(new Date(alert.since).toISOString())} - -

-
- ))} -
- )} - {data.recent.length > 0 && ( -
-

Resolved in the last 7 days

-
- - - - - - - - - - {data.recent.map((alert) => ( - - - - - + + + } + > + {data.active.length === 0 ? ( + Nothing needs attention. + ) : ( +
+ {data.active.map((alert) => ( +
+

{alert.title}

+

{alert.detail}

+

+ since {timeAgo(new Date(alert.since).toISOString())} + +

+
+ ))} +
+ )} + void reload()} owner={owner} /> + {data.recent.length > 0 && ( +
+

Resolved in the last 7 days

+
+
alertresolvedlasted -
{alert.title}{timeAgo(new Date(alert.resolved!).toISOString())}{duration(alert.since, alert.resolved!)} - -
+ + + + + + - ))} - -
alertresolvedlasted
-
-
- )} - void reload()} owner={owner} /> + + + {data.recent.map((alert) => ( + + {alert.title} + {timeAgo(new Date(alert.resolved!).toISOString())} + {duration(alert.since, alert.resolved!)} + + + + + ))} + + +
+ + )} +
); } diff --git a/app/src/web/pages/Backups.tsx b/app/src/web/pages/Backups.tsx index 5a84489..872181e 100644 --- a/app/src/web/pages/Backups.tsx +++ b/app/src/web/pages/Backups.tsx @@ -1,10 +1,16 @@ import MuiLink from '@mui/material/Link'; -import { Button, ConfirmDialog, ErrorState, PageHeader, Select, TagChip, timeAgo } from '@project-graphite/ui'; +import MuiTextField from '@mui/material/TextField'; +import { Button, ConfirmDialog, ErrorState, PageHeader, Select, TagChip, timeAgo, Toggle } from '@project-graphite/ui'; import { useRef, useState } from 'react'; -import { bytes, post, stampDate, type BackupsData, type BackupSummary, type Overview } from '../api'; +import { bytes, post, stampDate, type BackupSchedule, type BackupsData, type BackupSummary, type Overview } from '../api'; +import { ActionButton } from '../components/ActionButton'; +import { Rail, WithRail } from '../components/Rail'; import { usePoll } from '../usePoll'; const requestColors = { waiting: 'gray', running: 'blue', done: 'green', failed: 'red' } as const; +const periods = [1, 2, 3, 4, 6, 8, 12, 24, 48, 72, 168]; +const every = (hours: number) => (hours < 24 ? `every ${hours} h` : hours === 24 ? 'daily' : hours === 168 ? 'weekly' : `every ${hours / 24} days`); +const clock = (at: number) => new Date(at).toISOString().slice(0, 16).replace('T', ' '); function Archive({ archive, onRestore }: { archive: BackupSummary; onRestore: () => void }) { const volumes = archive.items.filter((item) => item.kind === 'volume'); @@ -13,13 +19,13 @@ function Archive({ archive, onRestore }: { archive: BackupSummary; onRestore: () return ( - {stampDate(archive.stamp).toISOString().slice(0, 16).replace('T', ' ')} + {clock(stampDate(archive.stamp).getTime())} · {timeAgo(stampDate(archive.stamp).toISOString())} - {archive.imported && } + {archive.imported && } @@ -31,7 +37,7 @@ function Archive({ archive, onRestore }: { archive: BackupSummary; onRestore: () {bytes(archive.bytes)} {archive.bytes !== undefined && ( - + download @@ -47,40 +53,80 @@ function Archive({ archive, onRestore }: { archive: BackupSummary; onRestore: () ); } +function ScheduleEditor({ schedule, onSaved }: { schedule: BackupSchedule; onSaved: () => void }) { + const [enabled, setEnabled] = useState(schedule.enabled); + const [everyHours, setEveryHours] = useState(schedule.everyHours); + const [hour, setHour] = useState(schedule.hour); + const [keepDays, setKeepDays] = useState(String(schedule.keepDays)); + const changed = enabled !== schedule.enabled || everyHours !== schedule.everyHours || hour !== schedule.hour || Number(keepDays) !== schedule.keepDays; + return ( +
+

Schedule

+ + setHour(Number(value))} + options={Array.from({ length: 24 }, (_, value) => ({ value: String(value), label: `${String(value).padStart(2, '0')}:00` }))} + value={String(hour)} + /> + setKeepDays(event.target.value)} + size="small" + slotProps={{ htmlInput: { min: 1, max: 90, step: 1 } }} + type="number" + value={keepDays} + /> +
+ post('/api/backups/schedule', { enabled, everyHours, hour, keepDays: Number(keepDays) }).then(onSaved)} + /> +
+
+ ); +} + function Import({ projects, onDone }: { projects: string[]; onDone: () => void }) { const [project, setProject] = useState(''); - const [message, setMessage] = useState(); + const [name, setName] = useState(''); const file = useRef(null); - const send = () => { + const send = async () => { const chosen = file.current?.files?.[0]; if (!project || !chosen) { - setMessage('Choose a project and an archive.'); - return; + throw new Error('Choose a project and an archive.'); } - setMessage('Uploading…'); - fetch(`/api/backups/${project}/import`, { method: 'POST', headers: { 'Content-Type': 'application/x-tar' }, body: chosen }) - .then(async (response) => { - if (!response.ok) { - throw new Error((await response.text()) || `The console answered ${response.status}.`); - } - setMessage('Uploaded; the backup service is verifying it. Restore it from the list once it shows verified.'); - onDone(); - }) - .catch((error: Error) => setMessage(error.message)); + const response = await fetch(`/api/backups/${project}/import`, { method: 'POST', headers: { 'Content-Type': 'application/x-tar' }, body: chosen }); + if (!response.ok) { + throw new Error((await response.text()) || `The console answered ${response.status}.`); + } + onDone(); }; return (

Import an archive

-
+
- + setName(event.target.files?.[0]?.name ?? '')} ref={file} type="file" /> + + +
- {message &&

{message}

}
); } @@ -89,95 +135,118 @@ export function Backups() { const { data, error, reload } = usePoll('/api/backups', 10_000); const overview = usePoll('/api/overview', 60_000); const [restoring, setRestoring] = useState(); - const [message, setMessage] = useState(); if (error && !data) { return void reload()} title="The console could not list the backups">{error.message}; } if (!data) { return null; } - const run = (project: string) => - post(`/api/backups/${project}/run`).then( - () => { - setMessage(`Asked the backup service to back up ${project}.`); - void reload(); - }, - (reason: Error) => setMessage(reason.message), - ); const projects = [...new Set([...data.projects.map((entry) => entry.project), ...(overview.data?.projects ?? []).map((project) => project.slug)])].sort(); + const latest = data.projects.flatMap(({ archives }) => archives.filter((archive) => !archive.imported).slice(0, 1)); + const verified = latest.filter((archive) => archive.ok).length; + const archives = data.projects.flatMap((entry) => entry.archives); + const last = latest.map((archive) => archive.stamp).sort().at(-1); return (
- - - Every night at 03:00 UTC the backup service dumps each database labelled graphite.backup.postgres and archives each volume labelled graphite.backup, - with consistent copies of the SQLite databases inside, then reopens and verifies every archive. Archives are kept for 14 days. - - - {message &&

{message}

} - {data.requests.length > 0 && ( -
-

Recent requests

- - - {data.requests.map((entry) => ( - - - - - - - - ))} - -
- - - {entry.action} {entry.project} - {entry.archive && {entry.archive}} - {entry.by}{entry.output}{entry.at && timeAgo(entry.at)}
-
- )} - {data.projects.map(({ project, archives }) => ( -
-
-

{project}

- -
-
- - - - - - - - - - - {archives.map((archive) => ( - setRestoring(archive)} /> - ))} - -
takencheckcontentssize -
-
-
- ))} - {data.projects.length === 0 &&

No backup has run yet.

} - void reload()} projects={projects} /> + + sum + (archive.bytes ?? 0), 0)) }, + { label: 'Kept', value: `${data.schedule.keepDays} days` }, + ]} + title="Status" + > + void reload()} schedule={data.schedule} /> + + } + > + {data.requests.length > 0 && ( +
+

Recent requests

+
+ + + + + + + + + + {data.requests.map((entry) => ( + + + + + + + + ))} + +
+ + + {entry.action} {entry.project} + {entry.archive && {entry.archive}} + {entry.by}{entry.output}{entry.at && timeAgo(entry.at)}
+
+
+ )} + {data.projects.map(({ project, archives: list }) => ( +
+
+

+ {project} + {list[0] && } +

+ post(`/api/backups/${project}/run`).then(() => reload())} /> +
+
+ + + + + + + + + + + + + + + + + + {list.map((archive) => ( + setRestoring(archive)} /> + ))} + +
takencheckcontentssize +
+
+
+ ))} + {data.projects.length === 0 &&

No backup has run yet.

} + void reload()} projects={projects} /> +
{restoring && ( setRestoring(undefined)} - onConfirm={() => - post(`/api/backups/${restoring.project}/${restoring.stamp}/restore`).then(() => { - setMessage(`Asked the backup service to restore ${restoring.project}.`); - void reload(); - }) - } - title={`Restore ${restoring.project} from ${stampDate(restoring.stamp).toISOString().slice(0, 16).replace('T', ' ')}?`} + onConfirm={() => post(`/api/backups/${restoring.project}/${restoring.stamp}/restore`).then(() => void reload())} + title={`Restore ${restoring.project} from ${clock(stampDate(restoring.stamp).getTime())}?`} tone="danger" > The project stops, its databases and volumes are replaced by this archive, and it starts again. Everything written since the archive was taken is lost. diff --git a/app/src/web/pages/HostShell.tsx b/app/src/web/pages/HostShell.tsx index a127642..7e1c019 100644 --- a/app/src/web/pages/HostShell.tsx +++ b/app/src/web/pages/HostShell.tsx @@ -1,13 +1,36 @@ -import { PageHeader } from '@project-graphite/ui'; +import { ConfirmDialog, PageHeader, TagChip, Toggle } from '@project-graphite/ui'; +import { useState } from 'react'; import { LazyTerminal } from '../components/LazyTerminal'; -export function HostShell() { +export function HostShell({ owner }: { owner: boolean }) { + const [root, setRoot] = useState(false); + const [asking, setAsking] = useState(false); return (
- - a root shell on the host; every command runs on production + (on ? setAsking(true) : setRoot(false))} />} + title="VPS shell" + > + + + + - + + {asking && ( + setAsking(false)} + onConfirm={async () => { + setRoot(true); + setAsking(false); + }} + title="Open a root shell on the VPS?" + tone="danger" + > + Every command runs as root on production. The guard blocks commands that would take down the VPS or the console and asks before destructive ones, but it is a guard rail, not a sandbox. Every command is logged with your login. + + )}
); } diff --git a/app/src/web/pages/LogBrowser.tsx b/app/src/web/pages/LogBrowser.tsx new file mode 100644 index 0000000..3f6acaa --- /dev/null +++ b/app/src/web/pages/LogBrowser.tsx @@ -0,0 +1,278 @@ +import MuiTextField from '@mui/material/TextField'; +import { Button, Checkbox, Icon, PageHeader, Popover, TagChip, timeAgo, Toggle, type TagColor } from '@project-graphite/ui'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { bytes, get } from '../api'; +import { Rail, WithRail } from '../components/Rail'; +import { Segmented } from '../components/Segmented'; +import { formatTime } from '../logs'; +import { usePoll } from '../usePoll'; + +interface StoredLine { + id: number; + time: string; + project: string; + service: string; + container: string; + stream: 'stdout' | 'stderr'; + level: 'error' | 'warning' | 'info' | 'debug'; + text: string; +} + +interface Sources { + sources: { project: string; service: string; container: string; last: number }[]; + lines: number; + oldest: number | null; + errors: number; + bytes: number; + keepDays: number; +} + +interface Filters { + containers: string[]; + levels: string[]; + streams: string[]; + since: string; +} + +const windows = ['15m', '1h', '6h', '24h', '7d'] as const; +const levelColors: Record = { error: 'red', warning: 'yellow', info: 'blue', debug: 'gray' }; +const empty: Filters = { containers: [], levels: [], streams: [], since: '24h' }; +const flip = (list: string[], value: string) => (list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]); + +function FilterMenu({ filters, sources, onChange }: { filters: Filters; sources: Sources['sources']; onChange: (next: Filters) => void }) { + const [open, setOpen] = useState([]); + const projects = [...new Set(sources.map((source) => source.project))]; + const count = filters.containers.length + filters.levels.length + filters.streams.length; + return ( + + + {count ? `Filters · ${count}` : 'Filters'} + + } + triggerLabel="Filters" + triggerVariant="secondary" + > + {() => ( + <> +
+ Time + onChange({ ...filters, since })} options={windows} value={filters.since as (typeof windows)[number]} /> +
+
+ Source + {projects.map((project) => { + const containers = sources.filter((source) => source.project === project).map((source) => source.container); + const chosen = containers.filter((name) => filters.containers.includes(name)); + const expanded = open.includes(project); + return ( +
+
+ + + onChange({ + ...filters, + containers: chosen.length === containers.length ? filters.containers.filter((name) => !containers.includes(name)) : [...new Set([...filters.containers, ...containers])], + }) + } + /> +
+ {expanded && ( +
+ {sources + .filter((source) => source.project === project) + .map((source) => ( + onChange({ ...filters, containers: flip(filters.containers, source.container) })} + /> + ))} +
+ )} +
+ ); + })} +
+
+ Level + {(['error', 'warning', 'info', 'debug'] as const).map((level) => ( + onChange({ ...filters, levels: flip(filters.levels, level) })} /> + ))} +
+
+ Stream + {['stdout', 'stderr'].map((stream) => ( + onChange({ ...filters, streams: flip(filters.streams, stream) })} /> + ))} +
+ {count > 0 && ( + + )} + + )} +
+ ); +} + +export function LogBrowser() { + const info = usePoll('/api/logs/sources', 30_000).data; + const [filters, setFilters] = useState(empty); + const [text, setText] = useState(''); + const [search, setSearch] = useState(''); + const [live, setLive] = useState(false); + const [lines, setLines] = useState([]); + const [more, setMore] = useState(false); + const [error, setError] = useState(); + const newest = useRef(0); + + const query = useCallback( + (extra: Record) => + `/api/logs?${new URLSearchParams({ + since: filters.since, + ...(filters.containers.length ? { containers: filters.containers.join(',') } : {}), + ...(filters.levels.length ? { levels: filters.levels.join(',') } : {}), + ...(filters.streams.length ? { streams: filters.streams.join(',') } : {}), + ...(search ? { q: search } : {}), + ...extra, + })}`, + [filters, search], + ); + + useEffect(() => { + const timer = setTimeout(() => setSearch(text.trim()), 300); + return () => clearTimeout(timer); + }, [text]); + + useEffect(() => { + get<{ lines: StoredLine[]; more: boolean }>(query({ limit: '200' })).then( + (page) => { + setLines(page.lines); + setMore(page.more); + setError(undefined); + }, + (reason: Error) => setError(reason.message), + ); + }, [query]); + + useEffect(() => { + newest.current = lines[0]?.id ?? 0; + }, [lines]); + + useEffect(() => { + if (!live) { + return; + } + const timer = setInterval(() => { + get<{ lines: StoredLine[] }>(query({ after: String(newest.current), limit: '500' })).then( + (page) => setLines((latest) => [...page.lines.filter((line) => line.id > (latest[0]?.id ?? 0)), ...latest].slice(0, 2000)), + (reason: Error) => setError(reason.message), + ); + }, 2000); + return () => clearInterval(timer); + }, [live, query]); + + const older = () => + get<{ lines: StoredLine[]; more: boolean }>(query({ before: String(lines.at(-1)!.id), limit: '200' })).then( + (page) => { + setLines([...lines, ...page.lines]); + setMore(page.more); + }, + (reason: Error) => setError(reason.message), + ); + + return ( +
+ + + } + > +
+
+ + setText(event.target.value)} size="small" type="search" value={text} /> + + + +
+
+ + {filters.containers.map((name) => ( + setFilters({ ...filters, containers: flip(filters.containers, name) })} /> + ))} + {filters.levels.map((level) => ( + setFilters({ ...filters, levels: flip(filters.levels, level) })} /> + ))} + {filters.streams.map((stream) => ( + setFilters({ ...filters, streams: flip(filters.streams, stream) })} /> + ))} +
+ {error &&

{error}

} +
+ + + + + + + + + + + + + + + + + {lines.map((line) => ( + + + + + + + ))} + +
timesourcelevelmessage
{formatTime(line.time)} + {line.project}/{line.service} + + + {line.text}
+
+ {lines.length === 0 && !error &&

No line matches.

} + {more && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/app/src/web/pages/LogSearch.tsx b/app/src/web/pages/LogSearch.tsx deleted file mode 100644 index 604a6fa..0000000 --- a/app/src/web/pages/LogSearch.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import MuiTextField from '@mui/material/TextField'; -import { Button, PageHeader, Select } from '@project-graphite/ui'; -import { useState, type FormEvent } from 'react'; -import { get, type LogMatch, type Overview } from '../api'; -import { Segmented } from '../components/Segmented'; -import { usePoll } from '../usePoll'; - -const windows = ['15m', '1h', '6h', '24h', '7d'] as const; - -export function LogSearch() { - const projects = (usePoll('/api/overview', 60_000).data?.projects ?? []).map((project) => project.slug); - const [project, setProject] = useState(''); - const [since, setSince] = useState<(typeof windows)[number]>('1h'); - const [text, setText] = useState(''); - const [result, setResult] = useState<{ lines: LogMatch[]; more: boolean }>(); - const [message, setMessage] = useState(); - - const search = (event: FormEvent) => { - event.preventDefault(); - setMessage('Searching…'); - get<{ lines: LogMatch[]; more: boolean }>(`/api/logs/search?${new URLSearchParams({ q: text, since, ...(project ? { project } : {}) })}`).then( - (found) => { - setResult(found); - setMessage(undefined); - }, - (error: Error) => setMessage(error.message), - ); - }; - - return ( -
- - Search every container's logs, newest first, case-insensitively. - -
- setText(event.target.value)} size="small" type="search" value={text} /> -
-