From 05b20e1e920ae0cbb5e64ff690db24541b000a58 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:27:28 +0200 Subject: [PATCH 1/4] feat: secret filters in a menu, collapsible projects, and a redeploy that clears its hint The Secrets list drops its path and text fields for a Filters menu (project, type, missing a required secret) with removable chips; Create sits at the far right, Delete beside it and copying the selected names is an icon. Each project header collapses its secrets with a chevron. Redeploy and its hint leave the Secrets list for the Project page and the Overview. A redeploy that finishes marks .applied with its exit time, so the needs-redeploy hint clears even when nothing had to be recreated. --- app/src/server/main.ts | 7 +- app/src/server/rollout.ts | 12 ++- app/src/web/App.tsx | 2 +- app/src/web/api.ts | 1 - app/src/web/pages/ProjectPage.tsx | 2 +- app/src/web/pages/Secrets.tsx | 144 +++++++++++++++++++++++------- 6 files changed, 127 insertions(+), 41 deletions(-) diff --git a/app/src/server/main.ts b/app/src/server/main.ts index dd01b10..02e225a 100644 --- a/app/src/server/main.ts +++ b/app/src/server/main.ts @@ -151,12 +151,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 +166,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 +175,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 ?? [] }; 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/web/App.tsx b/app/src/web/App.tsx index 49f3ad3..a113ee1 100644 --- a/app/src/web/App.tsx +++ b/app/src/web/App.tsx @@ -45,7 +45,7 @@ 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 ; diff --git a/app/src/web/api.ts b/app/src/web/api.ts index 667e69b..98442f9 100644 --- a/app/src/web/api.ts +++ b/app/src/web/api.ts @@ -124,7 +124,6 @@ export interface Secret { export interface SecretProject { project: string; - needsRedeploy: boolean; missing: string[]; } diff --git a/app/src/web/pages/ProjectPage.tsx b/app/src/web/pages/ProjectPage.tsx index 3a8ceba..ecec042 100644 --- a/app/src/web/pages/ProjectPage.tsx +++ b/app/src/web/pages/ProjectPage.tsx @@ -101,7 +101,7 @@ export function ProjectPage({ slug, user }: { slug: string; user: User }) { {can(user, `secrets:${slug}`) && (

Secrets

- +
)} diff --git a/app/src/web/pages/Secrets.tsx b/app/src/web/pages/Secrets.tsx index aa6bec4..ec13955 100644 --- a/app/src/web/pages/Secrets.tsx +++ b/app/src/web/pages/Secrets.tsx @@ -1,13 +1,16 @@ import Alert from '@mui/material/Alert'; +import MuiIconButton from '@mui/material/IconButton'; import MuiLink from '@mui/material/Link'; -import MuiTextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; import { Button, Checkbox, ConfirmDialog, Dialog, ErrorState, + Icon, PageHeader, + Popover, Select, TagChip, Tabs, @@ -17,8 +20,7 @@ import { type SelectOption, } from '@project-graphite/ui'; import { useEffect, useState } from 'react'; -import { can, post, type Secret, type SecretDetail, type SecretProject, type SecretType, type User } from '../api'; -import { Redeploy } from '../components/Redeploy'; +import { post, type Secret, type SecretDetail, type SecretProject, type SecretType, type User } from '../api'; import { Segmented } from '../components/Segmented'; import { usePoll } from '../usePoll'; @@ -29,6 +31,73 @@ const typeOptions: SelectOption[] = [ const when = (at: number) => timeAgo(new Date(at).toISOString()); +interface Filters { + projects: string[]; + types: SecretType[]; + attention: boolean; +} + +const active = (filters: Filters) => filters.projects.length + filters.types.length + Number(filters.attention); + +function CopyGlyph() { + return ( + + ); +} + +function SecretFilters({ filters, projects, onChange }: { filters: Filters; projects: string[]; onChange: (next: Filters) => void }) { + const flip = (list: T[], value: T) => (list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]); + const count = active(filters); + return ( + + + {() => ( + <> + {projects.length > 0 && ( +
+ Project + {projects.map((slug) => ( + onChange({ ...filters, projects: flip(filters.projects, slug) })} /> + ))} +
+ )} +
+ Type + {typeOptions.map((option) => ( + onChange({ ...filters, types: flip(filters.types, option.value) })} + /> + ))} +
+
+ Status + onChange({ ...filters, attention: !filters.attention })} /> +
+ {count > 0 && ( + + )} + + )} +
+ {filters.projects.map((slug) => ( + onChange({ ...filters, projects: flip(filters.projects, slug) })} /> + ))} + {filters.types.map((type) => ( + onChange({ ...filters, types: flip(filters.types, type) })} /> + ))} + {filters.attention && onChange({ ...filters, attention: false })} />} +
+ ); +} + function SecretSheet({ prefix, secret, onClose, onSaved }: { prefix: string; secret?: Secret; onClose: () => void; onSaved: () => void }) { const [mode, setMode] = useState<'Create' | 'Import'>('Create'); const [path, setPath] = useState(secret ? `/${secret.project}/${secret.name}` : prefix); @@ -118,13 +187,13 @@ function SecretSheet({ prefix, secret, onClose, onSaved }: { prefix: string; sec ); } -export function SecretList({ project, user }: { project?: string; user: User }) { +export function SecretList({ project }: { project?: string }) { const { data, error, reload } = usePoll<{ projects?: SecretProject[]; project?: SecretProject; secrets: Secret[] }>( project ? `/api/secrets/${project}` : '/api/secrets', 30_000, ); - const [prefix, setPrefix] = useState(project ? `/${project}/` : '/'); - const [filter, setFilter] = useState(''); + const [filters, setFilters] = useState({ projects: [], types: [], attention: false }); + const [collapsed, setCollapsed] = useState([]); const [selected, setSelected] = useState([]); const [creating, setCreating] = useState(false); const [deleting, setDeleting] = useState(false); @@ -135,13 +204,17 @@ export function SecretList({ project, user }: { project?: string; user: User }) if (!data) { return null; } - const query = filter.trim().toLowerCase(); - const rows = data.secrets.filter( - (secret) => `/${secret.project}/${secret.name}`.startsWith(prefix) && `${secret.name} ${secret.description}`.toLowerCase().includes(query), + const all = data.projects ?? [data.project!]; + const groups = all.filter( + (entry) => + (!filters.projects.length || filters.projects.includes(entry.project)) && + (!filters.attention || entry.missing.length > 0) && + (!filters.types.length || data.secrets.some((secret) => secret.project === entry.project && filters.types.includes(secret.type))), ); - const groups = (data.projects ?? [data.project!]).filter( - (entry) => rows.some((secret) => secret.project === entry.project) || (!query && `/${entry.project}/`.startsWith(prefix)), + const rows = data.secrets.filter( + (secret) => groups.some((entry) => entry.project === secret.project) && (!filters.types.length || filters.types.includes(secret.type)), ); + const prefix = project ? `/${project}/` : filters.projects.length === 1 ? `/${filters.projects[0]}/` : '/'; const toggle = (path: string) => setSelected(selected.includes(path) ? selected.filter((entry) => entry !== path) : [...selected, path]); const copy = () => navigator.clipboard.writeText(selected.join('\n')).then( @@ -159,20 +232,23 @@ export function SecretList({ project, user }: { project?: string; user: User }) return (
- {!project && ( - setPrefix(event.target.value || '/')} size="small" value={prefix} /> - )} - setFilter(event.target.value)} size="small" type="search" value={filter} /> - - - + entry.project)} /> {message && {message}} + + + + void copy()} size="small"> + + + + + + +
@@ -192,15 +268,21 @@ export function SecretList({ project, user }: { project?: string; user: User }) {rows - .filter((secret) => secret.project === entry.project) + .filter((secret) => secret.project === entry.project && !collapsed.includes(entry.project)) .map((secret) => { const path = `/${secret.project}/${secret.name}`; return ( @@ -228,7 +310,7 @@ export function SecretList({ project, user }: { project?: string; user: User }) ))}
- /{entry.project}/ - {entry.needsRedeploy && } - {entry.needsRedeploy && can(user, `project:${entry.project}`) && void reload()} project={entry.project} />} + {entry.missing.length > 0 && }
- {rows.length === 0 &&

No secret under {prefix}{query && ` matches “${filter}”`}.

} + {rows.length === 0 &&

{active(filters) ? 'No secret matches these filters.' : 'No secret yet.'}

} {creating && ( setCreating(false)} @@ -248,7 +330,7 @@ export function SecretList({ project, user }: { project?: string; user: User }) ); } -export function Secrets({ user }: { user: User }) { +export function Secrets() { return (
@@ -257,7 +339,7 @@ export function Secrets({ user }: { user: User }) { deploys. - +
); } From 1e2361d5f33e8a71536b8397a3c341fd45be7e25 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:48:17 +0200 Subject: [PATCH 2/4] feat: stored logs, volume management, a guarded shell, backup schedule and a reworked UI Logs: the console follows every running container and keeps its lines for seven days in logs.db on its own volume, with level and stream; the Logs page browses them with nested filters (project and container, level, stream, time), search, live tail and paging back. Volumes: create, browse files (paths and links cannot leave the volume), download a file and delete an unused volume, owners only, with a size bar per volume. Shell: the host shell runs as the unprivileged graphite-ops user, whose sudo allows read-only docker commands only; owners can open a root shell after a confirmation. Both run a bash guard that blocks commands that would take down the VPS or the console or leave the guard, asks before destructive ones, and logs every command to the journal with the login. Backups: the schedule (on or off, every 1 to 168 hours from an hour in UTC, days kept) is set on the page; the late-backup alert follows it. UI: a stats rail with status pills on Alerts, Logs, Backups, Volumes and Admin; action buttons that show saving, saved and failed on themselves; Admin as a member table with an access dialog; collapsible secrets on a project page; descriptions removed; a wider page. --- app/src/server/backups.ts | 46 +++- app/src/server/config.ts | 2 + app/src/server/logstore.ts | 224 +++++++++++++++++++ app/src/server/main.ts | 78 +++++-- app/src/server/shell.ts | 133 ++++++++++++ app/src/server/terminal.ts | 31 +-- app/src/server/volumes.ts | 73 +++++++ app/src/web/App.tsx | 12 +- app/src/web/api.ts | 16 +- app/src/web/components/ActionButton.tsx | 62 ++++++ app/src/web/components/Rail.tsx | 36 +++ app/src/web/components/Redeploy.tsx | 66 ++---- app/src/web/pages/Admin.tsx | 217 +++++++++++++----- app/src/web/pages/Alerts.tsx | 178 +++++++-------- app/src/web/pages/Backups.tsx | 269 ++++++++++++++--------- app/src/web/pages/HostShell.tsx | 33 ++- app/src/web/pages/LogBrowser.tsx | 278 ++++++++++++++++++++++++ app/src/web/pages/LogSearch.tsx | 73 ------- app/src/web/pages/ProjectPage.tsx | 13 +- app/src/web/pages/Volumes.tsx | 251 ++++++++++++++++++--- app/src/web/styles.css | 4 + app/test/backups.spec.ts | 33 ++- app/test/logstore.spec.ts | 83 +++++++ app/test/shell.spec.ts | 99 +++++++++ app/test/volumes.spec.ts | 48 ++++ 25 files changed, 1896 insertions(+), 462 deletions(-) create mode 100644 app/src/server/logstore.ts create mode 100644 app/src/server/shell.ts create mode 100644 app/src/server/volumes.ts create mode 100644 app/src/web/components/ActionButton.tsx create mode 100644 app/src/web/components/Rail.tsx create mode 100644 app/src/web/pages/LogBrowser.tsx delete mode 100644 app/src/web/pages/LogSearch.tsx create mode 100644 app/test/logstore.spec.ts create mode 100644 app/test/shell.spec.ts create mode 100644 app/test/volumes.spec.ts 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 02e225a..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', @@ -231,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) { @@ -271,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(); @@ -361,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 }>( @@ -473,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); @@ -526,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()); @@ -537,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/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 a113ee1..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'; @@ -48,10 +48,10 @@ function Page({ hash, user }: { hash: string; user: User }) { 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 98442f9..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[]; } @@ -228,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/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} /> -
-