From 2665ebcc18c18b31bcfc3bab560a6e3feef62072 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:10:47 +0200 Subject: [PATCH 1/2] fix: volume browsing through /proc/1/root, plain switches, steady secret columns and roomier logs Browse said "There is no volume" for every volume: realpath resolved the /proc/1/root link to the console's own root. Paths are now joined under the volume, kept inside it, and refused when any part is a link, without realpath. graphite-ui's Toggle draws a card around its label, which broke the access table; a small Switch (MUI's, with an inline or hidden label and an optional description) replaces it everywhere. The Secrets table has fixed column widths, so collapsing a project no longer moves the columns. Log lines drop their time, source and level columns: the level is a coloured edge, time and source a faint line above, and the message has the full width. --- app/src/server/volumes.ts | 21 +++++++--- app/src/web/components/Switch.tsx | 42 +++++++++++++++++++ app/src/web/pages/Admin.tsx | 11 ++--- app/src/web/pages/Alerts.tsx | 11 ++--- app/src/web/pages/Backups.tsx | 17 ++++---- app/src/web/pages/HostShell.tsx | 5 ++- app/src/web/pages/LogBrowser.tsx | 67 ++++++++++++------------------- app/src/web/pages/Secrets.tsx | 21 ++++++++-- app/test/volumes.spec.ts | 12 ++++++ 9 files changed, 136 insertions(+), 71 deletions(-) create mode 100644 app/src/web/components/Switch.tsx diff --git a/app/src/server/volumes.ts b/app/src/server/volumes.ts index 325dbca..784f644 100644 --- a/app/src/server/volumes.ts +++ b/app/src/server/volumes.ts @@ -1,5 +1,5 @@ import { createReadStream } from 'node:fs'; -import { lstat, readdir, realpath, stat } from 'node:fs/promises'; +import { lstat, readdir, stat } from 'node:fs/promises'; import type { ServerResponse } from 'node:http'; import path from 'node:path'; import { pipeline } from 'node:stream/promises'; @@ -17,15 +17,24 @@ function check(name: string) { 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(() => { + const root = path.join(volumes, name, '_data'); + if (!(await lstat(root).catch(() => undefined))?.isDirectory()) { 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}.`); - }); + } + const target = path.resolve(root, relative); if (target !== root && !target.startsWith(root + path.sep)) { throw new Refusal(403, 'That path leads out of the volume.'); } + let current = root; + for (const part of path.relative(root, target).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + const info = await lstat(current).catch(() => { + throw new Refusal(404, `There is no ${relative} in ${name}.`); + }); + if (info.isSymbolicLink()) { + throw new Refusal(403, 'That path leads out of the volume.'); + } + } return target; } diff --git a/app/src/web/components/Switch.tsx b/app/src/web/components/Switch.tsx new file mode 100644 index 0000000..8b7b3c3 --- /dev/null +++ b/app/src/web/components/Switch.tsx @@ -0,0 +1,42 @@ +import MuiSwitch from '@mui/material/Switch'; +import { useId } from 'react'; + +export function Switch({ + checked, + onChange, + label, + hideLabel = false, + description, + disabled = false, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + label: string; + hideLabel?: boolean; + description?: string; + disabled?: boolean; +}) { + const id = useId(); + const control = ( + onChange(event.target.checked)} + size="small" + slotProps={{ input: { 'aria-label': hideLabel ? label : undefined } }} + /> + ); + if (hideLabel) { + return control; + } + return ( + + ); +} diff --git a/app/src/web/pages/Admin.tsx b/app/src/web/pages/Admin.tsx index 76b56fd..bf26b69 100644 --- a/app/src/web/pages/Admin.tsx +++ b/app/src/web/pages/Admin.tsx @@ -1,9 +1,10 @@ import MuiTextField from '@mui/material/TextField'; -import { Button, Dialog, ErrorState, PageHeader, TagChip, timeAgo, Toggle } from '@project-graphite/ui'; +import { Button, Dialog, ErrorState, PageHeader, TagChip, timeAgo } 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 { Switch } from '../components/Switch'; import { usePoll } from '../usePoll'; const areas = [ @@ -37,7 +38,7 @@ function AccessDialog({ member, projects, onClose, onSaved }: { member: Member;

Areas

{areas.map((area) => ( - {slug} - set(`project:${slug}`, on)} /> + set(`project:${slug}`, on)} /> - set(`secrets:${slug}`, on)} /> + set(`secrets:${slug}`, on)} /> ))} @@ -89,7 +90,7 @@ function AccessDialog({ member, projects, onClose, onSaved }: { member: Member; post(`/api/admin/members/${member.login}`, { scopes }).then(onSaved)} variant="primary" /> diff --git a/app/src/web/pages/Alerts.tsx b/app/src/web/pages/Alerts.tsx index 30f8ac8..331a189 100644 --- a/app/src/web/pages/Alerts.tsx +++ b/app/src/web/pages/Alerts.tsx @@ -1,10 +1,11 @@ import MuiLink from '@mui/material/Link'; import MuiTextField from '@mui/material/TextField'; -import { EmptyState, ErrorState, PageHeader, timeAgo, Toggle } from '@project-graphite/ui'; +import { EmptyState, ErrorState, PageHeader, timeAgo } 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 { Switch } from '../components/Switch'; import { usePoll } from '../usePoll'; function RuleRow({ rule, owner, onSaved }: { rule: AlertRule; owner: boolean; onSaved: () => void }) { @@ -15,7 +16,7 @@ function RuleRow({ rule, owner, onSaved }: { rule: AlertRule; owner: boolean; on return ( - + {rule.unit ? ( @@ -30,7 +31,7 @@ function RuleRow({ rule, owner, onSaved }: { rule: AlertRule; owner: boolean; on value={threshold} /> ) : ( - — + — )} @@ -49,7 +50,7 @@ function RuleRow({ rule, owner, onSaved }: { rule: AlertRule; owner: boolean; on {owner && ( post(`/api/alerts/rules/${rule.kind}`, { threshold: Number(threshold), minutes: Number(minutes), enabled }).then(onSaved)} /> )} @@ -66,7 +67,7 @@ function Rules({ data, owner, onSaved }: { data: AlertsData; owner: boolean; onS {owner && ( post('/api/alerts/test')} /> )} diff --git a/app/src/web/pages/Backups.tsx b/app/src/web/pages/Backups.tsx index 872181e..460b492 100644 --- a/app/src/web/pages/Backups.tsx +++ b/app/src/web/pages/Backups.tsx @@ -1,10 +1,11 @@ import MuiLink from '@mui/material/Link'; import MuiTextField from '@mui/material/TextField'; -import { Button, ConfirmDialog, ErrorState, PageHeader, Select, TagChip, timeAgo, Toggle } from '@project-graphite/ui'; +import { Button, ConfirmDialog, ErrorState, PageHeader, Select, TagChip, timeAgo } from '@project-graphite/ui'; import { useRef, useState } from 'react'; 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 { Switch } from '../components/Switch'; import { usePoll } from '../usePoll'; const requestColors = { waiting: 'gray', running: 'blue', done: 'green', failed: 'red' } as const; @@ -20,7 +21,7 @@ function Archive({ archive, onRestore }: { archive: BackupSummary; onRestore: () {clock(stampDate(archive.stamp).getTime())} - · {timeAgo(stampDate(archive.stamp).toISOString())} + · {timeAgo(stampDate(archive.stamp).toISOString())} @@ -31,7 +32,7 @@ function Archive({ archive, onRestore }: { archive: BackupSummary; onRestore: () {[databases.length && `${databases.length} database dump${databases.length > 1 ? 's' : ''}`, volumes.length && `${volumes.length} volume${volumes.length > 1 ? 's' : ''}`, sqlite && `${sqlite} SQLite`] .filter(Boolean) - .join(' · ')} + .join(' · ')} {archive.errors.length > 0 &&
{archive.errors.join('; ')}
} {bytes(archive.bytes)} @@ -62,7 +63,7 @@ function ScheduleEditor({ schedule, onSaved }: { schedule: BackupSchedule; onSav return (

Schedule

- + setName(event.target.files?.[0]?.name ?? '')} ref={file} type="file" /> - +
@@ -154,7 +155,7 @@ export function Backups() { } - post(`/api/backups/${project}/run`).then(() => reload())} /> + post(`/api/backups/${project}/run`).then(() => reload())} />
diff --git a/app/src/web/pages/HostShell.tsx b/app/src/web/pages/HostShell.tsx index 7e1c019..fe90bce 100644 --- a/app/src/web/pages/HostShell.tsx +++ b/app/src/web/pages/HostShell.tsx @@ -1,6 +1,7 @@ -import { ConfirmDialog, PageHeader, TagChip, Toggle } from '@project-graphite/ui'; +import { ConfirmDialog, PageHeader, TagChip } from '@project-graphite/ui'; import { useState } from 'react'; import { LazyTerminal } from '../components/LazyTerminal'; +import { Switch } from '../components/Switch'; export function HostShell({ owner }: { owner: boolean }) { const [root, setRoot] = useState(false); @@ -8,7 +9,7 @@ export function HostShell({ owner }: { owner: boolean }) { return (
(on ? setAsking(true) : setRoot(false))} />} + actions={owner && (on ? setAsking(true) : setRoot(false))} />} title="VPS shell" > diff --git a/app/src/web/pages/LogBrowser.tsx b/app/src/web/pages/LogBrowser.tsx index 3f6acaa..a1cfb0a 100644 --- a/app/src/web/pages/LogBrowser.tsx +++ b/app/src/web/pages/LogBrowser.tsx @@ -1,9 +1,10 @@ import MuiTextField from '@mui/material/TextField'; -import { Button, Checkbox, Icon, PageHeader, Popover, TagChip, timeAgo, Toggle, type TagColor } from '@project-graphite/ui'; +import { Button, Checkbox, Icon, PageHeader, Popover, TagChip, timeAgo, 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 { Switch } from '../components/Switch'; import { formatTime } from '../logs'; import { usePoll } from '../usePoll'; @@ -36,6 +37,7 @@ interface Filters { const windows = ['15m', '1h', '6h', '24h', '7d'] as const; const levelColors: Record = { error: 'red', warning: 'yellow', info: 'blue', debug: 'gray' }; +const levelBars: Record = { error: '#e5736b', warning: '#d7b24a', info: '#6a9eea', debug: '#8b8b95' }; const empty: Filters = { containers: [], levels: [], streams: [], since: '24h' }; const flip = (list: string[], value: string) => (list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]); @@ -50,7 +52,7 @@ function FilterMenu({ filters, sources, onChange }: { filters: Filters; sources: trigger={ - {count ? `Filters · ${count}` : 'Filters'} + {count ? `Filters · ${count}` : 'Filters'} } triggerLabel="Filters" @@ -76,7 +78,7 @@ function FilterMenu({ filters, sources, onChange }: { filters: Filters; sources: onChange({ ...filters, @@ -199,11 +201,11 @@ export function LogBrowser() { rail={ setText(event.target.value)} size="small" type="search" value={text} /> - +
@@ -231,38 +233,21 @@ export function LogBrowser() { ))}
{error &&

{error}

} -
-
- - - - - - - - - - - - - - - - {lines.map((line) => ( - - - - - - - ))} - -
timesourcelevelmessage
{formatTime(line.time)} - {line.project}/{line.service} - - - {line.text}
-
+
    + {lines.map((line) => ( +
  1. +

    + + + {line.project}/{line.service} + + {line.level !== 'info' && {line.level}} + {line.stream === 'stderr' && stderr} +

    +

    {line.text}

    +
  2. + ))} +
{lines.length === 0 && !error &&

No line matches.

} {more && (
diff --git a/app/src/web/pages/Secrets.tsx b/app/src/web/pages/Secrets.tsx index ec13955..0a00972 100644 --- a/app/src/web/pages/Secrets.tsx +++ b/app/src/web/pages/Secrets.tsx @@ -251,7 +251,16 @@ export function SecretList({ project }: { project?: string }) {
- +
+ + + + + + + + + - + - + ); })} diff --git a/app/test/volumes.spec.ts b/app/test/volumes.spec.ts index aff2a6e..f99e884 100644 --- a/app/test/volumes.spec.ts +++ b/app/test/volumes.spec.ts @@ -33,6 +33,18 @@ describe('volumes', () => { expect((await listFiles('strata_files-data', 'uploads', base)).map((entry) => [entry.name, entry.size])).toEqual([['a.png', 3]]); }); + it('resolves inside a volume reached through a linked root, as /proc/1/root is', async () => { + const base = volumes(); + const linked = `${base}-linked`; + directories.push(linked); + try { + symlinkSync(base, linked, 'junction'); + } catch { + return; + } + expect((await listFiles('strata_files-data', 'uploads', linked)).map((entry) => entry.name)).toEqual(['a.png']); + }); + it('refuses paths and links that lead out of the volume, and names that are not volumes', async () => { const base = volumes(); await expect(inside('strata_files-data', '../../secret.txt', base)).rejects.toThrow('leads out of the volume'); From 5bb2f31609a4afa72b5350ff513a5399f10e2519 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:20:48 +0200 Subject: [PATCH 2/2] feat: release, disable and enable a project, redeploy reasons, live per-project breakdown A Release card on each Project page (owners) shows the running version and whether the project is enabled, lists every version the project has deployed (from the history of platform's deploy//values) and deploys a chosen one, and disables or enables the project. Each is a commit to that file through the platform GitHub App, so git stays the record and Deploy applies it. "Needs redeploy" now says why: each secret added, changed, restored, removed or edited on the host since the last rollout, with who and when, on the Project page and in the Overview pill's tooltip. Description-only edits no longer count. The Overview's live chart breaks the hovered second down by project. The project's Secrets section uses the same collapsible card as the log panels, and Browse is the right-most button on every volume. --- app/src/server/config.ts | 5 + app/src/server/main.ts | 35 ++++++- app/src/server/platform.ts | 128 +++++++++++++++++++++++++ app/src/server/secrets.ts | 12 ++- app/src/web/api.ts | 24 ++++- app/src/web/components/Collapsible.tsx | 18 ++++ app/src/web/components/Logs.tsx | 20 ++-- app/src/web/components/Metrics.tsx | 4 +- app/src/web/components/Release.tsx | 71 ++++++++++++++ app/src/web/live.ts | 21 ++++ app/src/web/pages/Overview.tsx | 11 ++- app/src/web/pages/ProjectPage.tsx | 34 ++++--- app/src/web/pages/Secrets.tsx | 4 +- app/src/web/pages/Volumes.tsx | 6 +- app/test/platform.spec.ts | 34 +++++++ app/test/secrets.spec.ts | 17 ++++ 16 files changed, 405 insertions(+), 39 deletions(-) create mode 100644 app/src/server/platform.ts create mode 100644 app/src/web/components/Collapsible.tsx create mode 100644 app/src/web/components/Release.tsx create mode 100644 app/test/platform.spec.ts diff --git a/app/src/server/config.ts b/app/src/server/config.ts index ba832cf..2ff125a 100644 --- a/app/src/server/config.ts +++ b/app/src/server/config.ts @@ -34,5 +34,10 @@ export const config = { sshTarget: process.env.SSH_TARGET ?? 'ubuntu@localhost', secretsKey: process.env.SECRETS_KEY || undefined, graphiteHome: process.env.GRAPHITE_HOME ?? '/srv/graphite', + platformRepo: process.env.PLATFORM_REPO ?? 'project-graphite/platform', + platformApp: + process.env.PLATFORM_APP_CLIENT_ID && process.env.PLATFORM_APP_PRIVATE_KEY + ? { clientId: process.env.PLATFORM_APP_CLIENT_ID, privateKey: process.env.PLATFORM_APP_PRIVATE_KEY.replaceAll('\\n', '\n') } + : undefined, webRoot: new URL('../web/', import.meta.url), }; diff --git a/app/src/server/main.ts b/app/src/server/main.ts index 0ca8daf..9d8bac0 100644 --- a/app/src/server/main.ts +++ b/app/src/server/main.ts @@ -18,6 +18,7 @@ import { Refusal } from './refusal.ts'; import { appliedAt, appliedCompose, appliedValueNames, redeployStatus, startRedeploy } from './rollout.ts'; import { composeReferences, parseKeys, projectPattern, SecretStore } from './secrets.ts'; import { LogStore } from './logstore.ts'; +import { release, setEnabled, setVersion } from './platform.ts'; import { hostShell, type ShellMode } from './shell.ts'; import { createVolume, downloadFile, listFiles, removeVolume } from './volumes.ts'; import { downloadLogs, logs, terminal } from './terminal.ts'; @@ -150,10 +151,18 @@ function secretStore() { return store; } -async function needsRedeploy(project: string, views: ContainerView[]) { +async function redeployReasons(project: string, views: ContainerView[]) { const changed = store?.changedAt(project) ?? null; const created = views.filter((view) => view.project === project).map((view) => view.created); - return changed !== null && created.length > 0 && changed > Math.max(...created, (await appliedAt(project)) ?? 0); + if (changed === null || !created.length) { + return []; + } + const rollout = Math.max(...created, (await appliedAt(project)) ?? 0); + if (changed <= rollout) { + return []; + } + const changes = store!.changesSince(project, rollout); + return changes.length ? changes : [{ name: `${project}.env`, action: 'edited on the host', login: 'host', at: changed }]; } async function secretProject(project: string) { @@ -316,7 +325,7 @@ async function api(user: User, request: http.IncomingMessage, url: URL) { buildCache: usage.buildCache, volumes: usage.volumes.reduce((sum, volume) => sum + Math.max(0, volume.size), 0), }, - projects: await Promise.all(list.map(async (project) => ({ ...project, needsRedeploy: await needsRedeploy(project.slug, project.containers) }))), + projects: await Promise.all(list.map(async (project) => ({ ...project, redeploy: await redeployReasons(project.slug, project.containers) }))), }; } const project = /^GET \/api\/projects\/([a-z0-9-]+)$/.exec(route); @@ -328,7 +337,12 @@ async function api(user: User, request: http.IncomingMessage, url: URL) { image.usedBy.push(container.service); images.set(container.imageId, image); } - return { ...found, images: [...images.values()], volumes: usage.volumes.filter((volume) => volume.project === found.slug) }; + return { + ...found, + images: [...images.values()], + volumes: usage.volumes.filter((volume) => volume.project === found.slug), + redeploy: await redeployReasons(found.slug, found.containers), + }; } if (route === 'GET /api/history') { const range = rangeOf(url); @@ -416,6 +430,19 @@ async function api(user: User, request: http.IncomingMessage, url: URL) { await refresh(); return { ok: true }; } + const releasing = /^(GET|POST) \/api\/projects\/([a-z0-9-]+)\/(release|enabled)$/.exec(route); + if (releasing) { + await requireOwnerNow(request); + if (releasing[1] === 'GET' && releasing[3] === 'release') { + return release(releasing[2]); + } + if (releasing[1] === 'POST') { + const input = (await body(request)) as { tag?: unknown; enabled?: unknown }; + const url = releasing[3] === 'release' ? await setVersion(releasing[2], input.tag, user.login) : await setEnabled(releasing[2], input.enabled, user.login); + store?.audit(user.login, releasing[3] === 'release' ? `release ${String(input.tag)}` : input.enabled ? 'enable' : 'disable', releasing[2], null); + return { commit: url }; + } + } const redeploy = /^(GET|POST) \/api\/projects\/([a-z0-9-]+)\/redeploy$/.exec(route); if (redeploy) { if (redeploy[1] === 'GET') { diff --git a/app/src/server/platform.ts b/app/src/server/platform.ts new file mode 100644 index 0000000..05d9321 --- /dev/null +++ b/app/src/server/platform.ts @@ -0,0 +1,128 @@ +import { createSign } from 'node:crypto'; +import { config } from './config.ts'; +import { Refusal } from './refusal.ts'; + +const tagPattern = /^sha-[0-9a-f]{7,40}$/; +const slugPattern = /^[a-z0-9-]+$/; +let cached: { token: string; until: number } | undefined; + +function credentials() { + if (!config.platformApp) { + throw new Refusal(503, 'Releases need PLATFORM_APP_CLIENT_ID and PLATFORM_APP_PRIVATE_KEY in the console env file.'); + } + return config.platformApp; +} + +function appToken() { + const { clientId, privateKey } = credentials(); + const now = Math.floor(Date.now() / 1000); + const part = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const unsigned = `${part({ alg: 'RS256', typ: 'JWT' })}.${part({ iat: now - 60, exp: now + 540, iss: clientId })}`; + return `${unsigned}.${createSign('RSA-SHA256').update(unsigned).sign(privateKey, 'base64url')}`; +} + +async function github(path: string, token: string, method = 'GET', body?: unknown) { + const response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'User-Agent': 'project-graphite-console', + 'X-GitHub-Api-Version': '2022-11-28', + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Refusal(502, `GitHub answered ${response.status} for ${path.split('?')[0]}: ${(await response.text()).slice(0, 200)}`); + } + return (await response.json()) as T; +} + +async function token() { + if (cached && cached.until > Date.now() + 60_000) { + return cached.token; + } + const jwt = appToken(); + const { id } = await github<{ id: number }>(`/repos/${config.platformRepo}/installation`, jwt); + const created = await github<{ token: string; expires_at: string }>(`/app/installations/${id}/access_tokens`, jwt, 'POST', { + repositories: [config.platformRepo.split('/')[1]], + permissions: { contents: 'write' }, + }); + cached = { token: created.token, until: Date.parse(created.expires_at) }; + return created.token; +} + +export function parseValues(text: string) { + return new Map( + text.split('\n').flatMap((line) => { + const match = /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line); + return match ? [[match[1], match[2]] as [string, string]] : []; + }), + ); +} + +export function withValue(text: string, name: string, value: string | null) { + const lines = text.split('\n').filter((line) => line && !line.startsWith(`${name}=`)); + return `${[...(value === null ? [] : [`${name}=${value}`]), ...lines].join('\n')}\n`; +} + +export function deployedTags(commits: { commit: { message: string; author: { name: string; date: string } } }[]) { + const seen = new Set(); + return commits.flatMap(({ commit }) => { + const tag = /\bdeploy (sha-[0-9a-f]{7,40})\b/.exec(commit.message)?.[1]; + if (!tag || seen.has(tag)) { + return []; + } + seen.add(tag); + return [{ tag, at: commit.author.date, by: /from the console by ([\w-]+)/.exec(commit.message)?.[1] ?? commit.author.name }]; + }); +} + +async function valuesFile(slug: string, auth: string) { + if (!slugPattern.test(slug)) { + throw new Refusal(400, `${slug} is not a project.`); + } + const file = await github<{ content: string; sha: string }>(`/repos/${config.platformRepo}/contents/deploy/${slug}/values?ref=main`, auth); + return { text: Buffer.from(file.content, 'base64').toString('utf8'), sha: file.sha }; +} + +export async function release(slug: string) { + const auth = await token(); + const [{ text }, commits] = await Promise.all([ + valuesFile(slug, auth), + github<{ commit: { message: string; author: { name: string; date: string } } }[]>(`/repos/${config.platformRepo}/commits?path=deploy/${slug}/values&per_page=40`, auth), + ]); + const values = parseValues(text); + return { current: values.get('IMAGE_TAG') ?? null, enabled: values.get('ENABLED') !== 'false', history: deployedTags(commits) }; +} + +async function commit(slug: string, name: string, value: string | null, message: string) { + const auth = await token(); + const { text, sha } = await valuesFile(slug, auth); + const next = withValue(text, name, value); + if (next === text) { + return null; + } + const result = await github<{ commit: { html_url: string } }>(`/repos/${config.platformRepo}/contents/deploy/${slug}/values`, auth, 'PUT', { + message, + content: Buffer.from(next).toString('base64'), + sha, + branch: 'main', + }); + return result.commit.html_url; +} + +export function setVersion(slug: string, tag: unknown, login: string) { + if (typeof tag !== 'string' || !tagPattern.test(tag)) { + throw new Refusal(400, 'A version is a sha- tag.'); + } + return commit(slug, 'IMAGE_TAG', tag, `chore(${slug}): deploy ${tag} from the console by ${login}`); +} + +export function setEnabled(slug: string, enabled: unknown, login: string) { + if (typeof enabled !== 'boolean') { + throw new Refusal(400, 'Say whether the project is enabled.'); + } + return commit(slug, 'ENABLED', enabled ? null : 'false', `chore(${slug}): ${enabled ? 'enable' : 'disable'} from the console by ${login}`); +} diff --git a/app/src/server/secrets.ts b/app/src/server/secrets.ts index e3e3384..6fbe62a 100644 --- a/app/src/server/secrets.ts +++ b/app/src/server/secrets.ts @@ -343,6 +343,13 @@ export class SecretStore { }; } + changesSince(project: string, since: number) { + const rows = this.db + .prepare("SELECT name, action, login, at FROM secret_audit WHERE project = ? AND at > ? AND name IS NOT NULL AND action IN ('create', 'update', 'restore', 'delete', 'reconcile') ORDER BY id") + .all(project, since) as { name: string; action: string; login: string; at: number }[]; + return [...new Map(rows.map((row) => [row.name, row])).values()]; + } + changedAt(project: string) { return statSync(path.join(this.hostDirectory, `${project}.env`), { throwIfNoEntry: false })?.mtimeMs ?? null; } @@ -402,10 +409,11 @@ export class SecretStore { this.db .prepare('UPDATE secrets SET description = ?, type = ?, updated_by = ?, updated_at = ? WHERE project = ? AND name = ?') .run(description, type, login, at, project, name); - if (text !== undefined && text !== this.value(project, name)) { + const changed = text !== undefined && text !== this.value(project, name); + if (changed) { this.addVersion(project, name, text, login, at); } - this.audit(login, 'update', project, name, at); + this.audit(login, changed ? 'update' : 'describe', project, name, at); }); } diff --git a/app/src/web/api.ts b/app/src/web/api.ts index b095a17..9e879f3 100644 --- a/app/src/web/api.ts +++ b/app/src/web/api.ts @@ -55,6 +55,7 @@ export interface Volume { export interface ProjectDetail extends Project { images: { id: string; name: string; size: number | null; usedBy: string[] }[]; volumes: Volume[]; + redeploy: RedeployReason[]; } export interface Usage { @@ -72,10 +73,31 @@ export interface Host { swap: Usage; } +export interface RedeployReason { + name: string; + action: string; + login: string; + at: number; +} + +const changeWords: Record = { create: 'added', update: 'changed', restore: 'restored', delete: 'removed', reconcile: 'edited on the host' }; + +export function describeChange(reason: RedeployReason) { + const minutes = Math.max(1, Math.round((Date.now() - reason.at) / 60_000)); + const ago = minutes < 60 ? `${minutes} min ago` : minutes < 2880 ? `${Math.round(minutes / 60)} h ago` : `${Math.round(minutes / 1440)} days ago`; + return `${reason.name} ${changeWords[reason.action] ?? reason.action}${reason.login === 'host' ? '' : ` by ${reason.login}`}, ${ago}`; +} + +export interface Release { + current: string | null; + enabled: boolean; + history: { tag: string; at: string; by: string }[]; +} + export interface Overview { host: Host | null; docker: { images: number; containers: number; buildCache: number; volumes: number } | null; - projects: (Project & { needsRedeploy: boolean })[]; + projects: (Project & { redeploy: RedeployReason[] })[]; backups: { projects: number; verified: number; latest: string | null }; } diff --git a/app/src/web/components/Collapsible.tsx b/app/src/web/components/Collapsible.tsx new file mode 100644 index 0000000..1e87970 --- /dev/null +++ b/app/src/web/components/Collapsible.tsx @@ -0,0 +1,18 @@ +import ButtonBase from '@mui/material/ButtonBase'; +import { Icon } from '@project-graphite/ui'; +import type { ReactNode } from 'react'; + +export function Collapsible({ title, open, onToggle, adornment, children }: { title: ReactNode; open: boolean; onToggle: (open: boolean) => void; adornment?: ReactNode; children: ReactNode }) { + return ( +
+ onToggle(!open)}> + + + + {title} + {adornment} + + {open &&
{children}
} +
+ ); +} diff --git a/app/src/web/components/Logs.tsx b/app/src/web/components/Logs.tsx index 2b02b58..8be47e0 100644 --- a/app/src/web/components/Logs.tsx +++ b/app/src/web/components/Logs.tsx @@ -1,4 +1,3 @@ -import ButtonBase from '@mui/material/ButtonBase'; import IconButton from '@mui/material/IconButton'; import InputAdornment from '@mui/material/InputAdornment'; import ListSubheader from '@mui/material/ListSubheader'; @@ -9,6 +8,7 @@ import ToggleButton from '@mui/material/ToggleButton'; import { Icon, Popover, TagChip } from '@project-graphite/ui'; import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { socketUrl } from '../api'; +import { Collapsible } from './Collapsible'; import { entryDetail, formatTime, levelOf, levels, segments, type Level } from '../logs'; import { usePreference } from '../stored'; @@ -513,16 +513,14 @@ export function Logs({ collapsible = false, container, name, open = true, runnin return (
{collapsible ? ( -
- setExpanded(!expanded)}> - - - - {name} - {expanded && status === 'live' && } - - {expanded &&
{panel}
} -
+ } + onToggle={setExpanded} + open={expanded} + title={name} + > + {panel} + ) : ( panel )} diff --git a/app/src/web/components/Metrics.tsx b/app/src/web/components/Metrics.tsx index 73c6e82..8e9633f 100644 --- a/app/src/web/components/Metrics.tsx +++ b/app/src/web/components/Metrics.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { bytes, percent, rate, ranges, type HistoryData, type Metric, type Series } from '../api'; -import { containerPoint, hostPoint, liveSeconds, useLive } from '../live'; +import { containerPoint, hostPoint, liveSeconds, projectSeries, useLive } from '../live'; import { load, save } from '../stored'; import { usePoll } from '../usePoll'; import { Chart } from './Chart'; @@ -26,7 +26,7 @@ export function Metrics({ query, live, cpuMax, memoryMax }: { query: string; liv : live.map(({ name, label }) => ({ name: label, points: frames.flatMap((frame) => containerPoint(frame, name) ?? []) })); const chart = range === 'live' - ? { series: liveSeries, span: liveSeconds * 1000, step: 1000, now: frames.at(-1)?.at ?? Date.now() } + ? { series: liveSeries, breakdown: live === 'host' ? projectSeries(frames) : undefined, span: liveSeconds * 1000, step: 1000, now: frames.at(-1)?.at ?? Date.now() } : data && { series: data.series, breakdown: data.breakdown, span: data.span, step: data.step, now: Date.now() }; const peak = (metric: Metric): Metric => (peaks && range !== 'live' ? (metric === 'cpu' ? 'cpuPeak' : 'memoryPeak') : metric); diff --git a/app/src/web/components/Release.tsx b/app/src/web/components/Release.tsx new file mode 100644 index 0000000..2347d5c --- /dev/null +++ b/app/src/web/components/Release.tsx @@ -0,0 +1,71 @@ +import { ConfirmDialog, Select, TagChip } from '@project-graphite/ui'; +import { useState } from 'react'; +import { post, type Release as ReleaseData } from '../api'; +import { usePoll } from '../usePoll'; +import { ActionButton } from './ActionButton'; +import { Switch } from './Switch'; + +const day = (at: string) => new Date(at).toISOString().slice(0, 16).replace('T', ' '); + +export function Release({ slug, onChanged }: { slug: string; onChanged: () => void }) { + const { data, error, reload } = usePoll(`/api/projects/${slug}/release`, 60_000); + const [tag, setTag] = useState(''); + const [disabling, setDisabling] = useState(false); + const chosen = tag || data?.current || ''; + const refresh = () => { + void reload(); + onChanged(); + }; + const enable = (on: boolean) => post(`/api/projects/${slug}/enabled`, { enabled: on }).then(refresh); + return ( +
+
+

Release

+ {data && } + {data?.current && } + {data && ( + + (on ? void enable(true) : setDisabling(true))} /> + + )} +
+ {error &&

{error.message}

} + {data && ( +
+
+
diff --git a/app/src/web/pages/ProjectPage.tsx b/app/src/web/pages/ProjectPage.tsx index 9760cf5..9584e8b 100644 --- a/app/src/web/pages/ProjectPage.tsx +++ b/app/src/web/pages/ProjectPage.tsx @@ -1,10 +1,12 @@ import MuiLink from '@mui/material/Link'; -import { Button, ErrorState, Icon, PageHeader, TagChip } from '@project-graphite/ui'; +import { Button, ErrorState, PageHeader, TagChip } from '@project-graphite/ui'; import { useState } from 'react'; -import { bytes, can, percent, post, shortImage, stateColor, stateOf, type ProjectDetail, type User } from '../api'; +import { bytes, can, describeChange, percent, post, shortImage, stateColor, stateOf, type ProjectDetail, type User } from '../api'; +import { Collapsible } from '../components/Collapsible'; import { Logs } from '../components/Logs'; -import { Redeploy } from '../components/Redeploy'; import { Metrics } from '../components/Metrics'; +import { Redeploy } from '../components/Redeploy'; +import { Release } from '../components/Release'; import { usePreference } from '../stored'; import { usePoll } from '../usePoll'; import { SecretList } from './Secrets'; @@ -55,12 +57,24 @@ export function ProjectPage({ slug, user }: { slug: string; user: User }) { > {project.sleeps && } + {project.redeploy.length > 0 && } cpu {percent(project.cpu)} · memory {bytes(project.memory)} · storage {bytes(storage)} + {project.redeploy.length > 0 && ( +
+

Changed since the last rollout; redeploy to apply

+
    + {project.redeploy.map((reason) => ( +
  • {describeChange(reason)}
  • + ))} +
+
+ )} + ({ name: container.name, label: container.service }))} query={`project=${slug}`} />
@@ -100,16 +114,12 @@ export function ProjectPage({ slug, user }: { slug: string; user: User }) {
+ {user.owner && void reload()} slug={slug} />} + {can(user, `secrets:${slug}`) && ( -
-

- -

- {secretsOpen && } -
+ + + )}
diff --git a/app/src/web/pages/Secrets.tsx b/app/src/web/pages/Secrets.tsx index 0a00972..7984c5f 100644 --- a/app/src/web/pages/Secrets.tsx +++ b/app/src/web/pages/Secrets.tsx @@ -187,7 +187,7 @@ function SecretSheet({ prefix, secret, onClose, onSaved }: { prefix: string; sec ); } -export function SecretList({ project }: { project?: string }) { +export function SecretList({ project, framed = true }: { project?: string; framed?: boolean }) { const { data, error, reload } = usePoll<{ projects?: SecretProject[]; project?: SecretProject; secrets: Secret[] }>( project ? `/api/secrets/${project}` : '/api/secrets', 30_000, @@ -230,7 +230,7 @@ export function SecretList({ project }: { project?: string }) { }; return ( -
+
entry.project)} /> {message && {message}} diff --git a/app/src/web/pages/Volumes.tsx b/app/src/web/pages/Volumes.tsx index 085a1fb..1468612 100644 --- a/app/src/web/pages/Volumes.tsx +++ b/app/src/web/pages/Volumes.tsx @@ -189,14 +189,14 @@ export function Volumes({ owner }: { owner: boolean }) {
diff --git a/app/test/platform.spec.ts b/app/test/platform.spec.ts new file mode 100644 index 0000000..06c84d0 --- /dev/null +++ b/app/test/platform.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { deployedTags, parseValues, withValue } from '../src/server/platform.ts'; + +describe('values', () => { + const text = 'IMAGE_TAG=sha-1111111\nAPP_MEMORY=512M\n'; + + it('sets a value as the first line, replaces it, and removes it', () => { + expect(withValue(text, 'IMAGE_TAG', 'sha-2222222')).toBe('IMAGE_TAG=sha-2222222\nAPP_MEMORY=512M\n'); + expect(withValue(text, 'ENABLED', 'false')).toBe('ENABLED=false\nIMAGE_TAG=sha-1111111\nAPP_MEMORY=512M\n'); + expect(withValue('ENABLED=false\nIMAGE_TAG=sha-1111111\n', 'ENABLED', null)).toBe('IMAGE_TAG=sha-1111111\n'); + }); + + it('reads NAME=value lines', () => { + expect(Object.fromEntries(parseValues('IMAGE_TAG=sha-1\nURL=https://a=b\n\n'))).toEqual({ IMAGE_TAG: 'sha-1', URL: 'https://a=b' }); + }); +}); + +describe('deployedTags', () => { + it('lists each deployed tag once, newest first, with who deployed it', () => { + const commit = (message: string, name: string, date: string) => ({ commit: { message, author: { name, date } } }); + expect( + deployedTags([ + commit('chore(strata): deploy sha-3333333 from the console by AmrMsCLL', 'graphite-deploy', '2026-11-05T12:00:00Z'), + commit('chore(strata): disable from the console by AmrMsCLL', 'graphite-deploy', '2026-11-05T11:00:00Z'), + commit('chore(strata): deploy sha-2222222', 'graphite-deploy', '2026-11-05T10:00:00Z'), + commit('chore(strata): deploy sha-3333333', 'graphite-deploy', '2026-11-05T09:00:00Z'), + commit('feat: plain compose', 'Amr', '2026-11-04T09:00:00Z'), + ]), + ).toEqual([ + { tag: 'sha-3333333', at: '2026-11-05T12:00:00Z', by: 'AmrMsCLL' }, + { tag: 'sha-2222222', at: '2026-11-05T10:00:00Z', by: 'graphite-deploy' }, + ]); + }); +}); diff --git a/app/test/secrets.spec.ts b/app/test/secrets.spec.ts index d5fecef..0fcca81 100644 --- a/app/test/secrets.spec.ts +++ b/app/test/secrets.spec.ts @@ -139,6 +139,23 @@ describe('SecretStore', () => { ]); }); + it('lists what changed after a rollout, each secret once, ignoring description edits', () => { + const paths = setup(); + const store = open(paths, key()); + store.create('strata', { name: 'OLD', value: 'before' }, 'amr', 100); + store.create('strata', { name: 'NEW', value: 'x' }, 'omar', 200); + store.update('strata', 'OLD', { value: 'after' }, 'amr', 300); + store.update('strata', 'OLD', { description: 'only words' }, 'amr', 400); + store.create('strata', { name: 'GONE', value: 'x' }, 'amr', 500); + store.remove('strata', ['GONE'], 'amr', 600); + expect(store.changesSince('strata', 150).map(({ name, action, login, at }) => [name, action, login, at])).toEqual([ + ['NEW', 'create', 'omar', 200], + ['OLD', 'update', 'amr', 300], + ['GONE', 'delete', 'amr', 600], + ]); + expect(store.changesSince('strata', 600)).toEqual([]); + }); + it('reconciles a hand-edited env file into the store on start', () => { const paths = setup(); const secret = key();
@@ -291,7 +300,7 @@ export function SecretList({ project }: { project?: string }) { toggle(path)} /> - + {path} {secret.value !== null &&
{secret.value}
} @@ -299,10 +308,14 @@ export function SecretList({ project }: { project?: string }) {
{secret.description} + {secret.description} + v{secret.version} {when(secret.updatedAt)}{secret.updatedBy} + {secret.updatedBy} +
{status(project)} - {project.needsRedeploy && } + {project.redeploy.length > 0 && ( + {project.redeploy.map(describeChange).join('\n')}}> + + + + + )} {percent(live.get(project.slug)?.cpu ?? project.cpu)} {owner && ( - {!volume.inUse && ( )} + )}