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/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/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 && ( +
+
+ post('/api/backups/schedule', { enabled, everyHours, hour, keepDays: Number(keepDays) }).then(onSaved)} />
@@ -124,7 +125,7 @@ function Import({ projects, onDone }: { projects: string[]; onDone: () => void } 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/Overview.tsx b/app/src/web/pages/Overview.tsx index 62e9eca..763a13e 100644 --- a/app/src/web/pages/Overview.tsx +++ b/app/src/web/pages/Overview.tsx @@ -1,9 +1,10 @@ import IconButton from '@mui/material/IconButton'; import MuiLink from '@mui/material/Link'; import MuiTextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; import { EmptyState, ErrorState, Meter, PageHeader, TagChip, timeAgo } from '@project-graphite/ui'; import { useState } from 'react'; -import { bytes, completed, percent, rate, stampDate, type AlertsData, type Host, type Overview as OverviewData, type Project, type Traffic } from '../api'; +import { bytes, completed, describeChange, percent, rate, stampDate, type AlertsData, type Host, type Overview as OverviewData, type Project, type Traffic } from '../api'; import { Metrics } from '../components/Metrics'; import { projectTotals, useLive } from '../live'; import { load, save } from '../stored'; @@ -126,7 +127,13 @@ function ProjectList({ projects, live }: { projects: OverviewData['projects']; l {status(project)} - {project.needsRedeploy && } + {project.redeploy.length > 0 && ( + {project.redeploy.map(describeChange).join('\n')}}> + + + + + )} {percent(live.get(project.slug)?.cpu ?? project.cpu)} 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 ec13955..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}} @@ -251,7 +251,16 @@ export function SecretList({ project }: { project?: string }) {
- +
+ + + + + + + + + - + - + ); })} 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(); 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');
@@ -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} +
{owner && ( - {!volume.inUse && ( )} + )}