From e193428f71f59bd24a31b453283300816bfc077a 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] 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');
@@ -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} +