Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions app/src/server/volumes.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}

Expand Down
42 changes: 42 additions & 0 deletions app/src/web/components/Switch.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<MuiSwitch
checked={checked}
disabled={disabled}
id={id}
onChange={(event) => onChange(event.target.checked)}
size="small"
slotProps={{ input: { 'aria-label': hideLabel ? label : undefined } }}
/>
);
if (hideLabel) {
return control;
}
return (
<label className={`flex items-start gap-2 ${disabled ? 'opacity-60' : 'cursor-pointer'}`} htmlFor={id}>
{control}
<span className="pt-1">
<span className="block text-sm text-ink">{label}</span>
{description && <span className="mono-sm block text-faint">{description}</span>}
</span>
</label>
);
}
11 changes: 6 additions & 5 deletions app/src/web/pages/Admin.tsx
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -37,7 +38,7 @@ function AccessDialog({ member, projects, onClose, onSaved }: { member: Member;
<section className="space-y-2">
<h3 className="mono-sm text-muted">Areas</h3>
{areas.map((area) => (
<Toggle
<Switch
checked={everything || scopes.includes(area.scope)}
description={area.detail}
disabled={area.scope !== '*' && everything}
Expand Down Expand Up @@ -71,10 +72,10 @@ function AccessDialog({ member, projects, onClose, onSaved }: { member: Member;
<tr key={slug}>
<td className="mono-sm truncate">{slug}</td>
<td>
<Toggle checked={everything || scopes.includes(`project:${slug}`)} disabled={everything} label={`Operate ${slug}`} onChange={(on) => set(`project:${slug}`, on)} />
<Switch checked={everything || scopes.includes(`project:${slug}`)} disabled={everything} hideLabel label={`Operate ${slug}`} onChange={(on) => set(`project:${slug}`, on)} />
</td>
<td>
<Toggle checked={scopes.includes(`secrets:${slug}`)} label={`Secrets of ${slug}`} onChange={(on) => set(`secrets:${slug}`, on)} />
<Switch checked={scopes.includes(`secrets:${slug}`)} hideLabel label={`Secrets of ${slug}`} onChange={(on) => set(`secrets:${slug}`, on)} />
</td>
</tr>
))}
Expand All @@ -89,7 +90,7 @@ function AccessDialog({ member, projects, onClose, onSaved }: { member: Member;
</Button>
<ActionButton
disabled={!changed}
labels={{ idle: 'Save', busy: 'Saving…', done: 'Saved', failed: 'Retry' }}
labels={{ idle: 'Save', busy: 'Saving…', done: 'Saved', failed: 'Retry' }}
run={() => post(`/api/admin/members/${member.login}`, { scopes }).then(onSaved)}
variant="primary"
/>
Expand Down
11 changes: 6 additions & 5 deletions app/src/web/pages/Alerts.tsx
Original file line number Diff line number Diff line change
@@ -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 }) {
Expand All @@ -15,7 +16,7 @@ function RuleRow({ rule, owner, onSaved }: { rule: AlertRule; owner: boolean; on
return (
<tr>
<td>
<Toggle checked={enabled} disabled={!owner} label={rule.label} onChange={setEnabled} />
<Switch checked={enabled} disabled={!owner} label={rule.label} onChange={setEnabled} />
</td>
<td>
{rule.unit ? (
Expand All @@ -30,7 +31,7 @@ function RuleRow({ rule, owner, onSaved }: { rule: AlertRule; owner: boolean; on
value={threshold}
/>
) : (
<span className="mono-sm text-faint">—</span>
<span className="mono-sm text-faint">—</span>
)}
</td>
<td>
Expand All @@ -49,7 +50,7 @@ function RuleRow({ rule, owner, onSaved }: { rule: AlertRule; owner: boolean; on
{owner && (
<ActionButton
disabled={!changed}
labels={{ idle: 'Save', busy: 'Saving…', done: 'Saved', failed: 'Retry' }}
labels={{ idle: 'Save', busy: 'Saving…', done: 'Saved', failed: 'Retry' }}
run={() => post(`/api/alerts/rules/${rule.kind}`, { threshold: Number(threshold), minutes: Number(minutes), enabled }).then(onSaved)}
/>
)}
Expand All @@ -66,7 +67,7 @@ function Rules({ data, owner, onSaved }: { data: AlertsData; owner: boolean; onS
{owner && (
<ActionButton
disabled={!data.emailing}
labels={{ idle: 'Send a test email', busy: 'Sending…', done: 'Sent', failed: 'Not sent' }}
labels={{ idle: 'Send a test email', busy: 'Sending…', done: 'Sent', failed: 'Not sent' }}
run={() => post('/api/alerts/test')}
/>
)}
Expand Down
17 changes: 9 additions & 8 deletions app/src/web/pages/Backups.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,7 +21,7 @@ function Archive({ archive, onRestore }: { archive: BackupSummary; onRestore: ()
<tr>
<td className="mono-sm whitespace-nowrap">
{clock(stampDate(archive.stamp).getTime())}
<span className="text-faint"> · {timeAgo(stampDate(archive.stamp).toISOString())}</span>
<span className="text-faint"> · {timeAgo(stampDate(archive.stamp).toISOString())}</span>
</td>
<td className="whitespace-nowrap">
<span className="flex items-center gap-1">
Expand All @@ -31,7 +32,7 @@ function Archive({ archive, onRestore }: { archive: BackupSummary; onRestore: ()
<td className="mono-sm text-muted">
{[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 && <div className="break-all text-faint">{archive.errors.join('; ')}</div>}
</td>
<td className="mono-sm whitespace-nowrap">{bytes(archive.bytes)}</td>
Expand Down Expand Up @@ -62,7 +63,7 @@ function ScheduleEditor({ schedule, onSaved }: { schedule: BackupSchedule; onSav
return (
<div className="space-y-3 border-t border-line-soft pt-4">
<h3 className="mono-sm text-muted">Schedule</h3>
<Toggle checked={enabled} label="Back up on a schedule" onChange={setEnabled} />
<Switch checked={enabled} label="Back up on a schedule" onChange={setEnabled} />
<Select
disabled={!enabled}
label="Frequency"
Expand All @@ -89,7 +90,7 @@ function ScheduleEditor({ schedule, onSaved }: { schedule: BackupSchedule; onSav
<div className="flex justify-end">
<ActionButton
disabled={!changed}
labels={{ idle: 'Save', busy: 'Saving…', done: 'Saved', failed: 'Retry' }}
labels={{ idle: 'Save', busy: 'Saving…', done: 'Saved', failed: 'Retry' }}
run={() => post('/api/backups/schedule', { enabled, everyHours, hour, keepDays: Number(keepDays) }).then(onSaved)}
/>
</div>
Expand Down Expand Up @@ -124,7 +125,7 @@ function Import({ projects, onDone }: { projects: string[]; onDone: () => void }
</Button>
<input accept=".tar,application/x-tar" className="hidden" onChange={(event) => setName(event.target.files?.[0]?.name ?? '')} ref={file} type="file" />
<span className="ml-auto">
<ActionButton disabled={!project || !name} labels={{ idle: 'Upload', busy: 'Uploading…', done: 'Uploaded', failed: 'Retry' }} run={send} variant="primary" />
<ActionButton disabled={!project || !name} labels={{ idle: 'Upload', busy: 'Uploading…', done: 'Uploaded', failed: 'Retry' }} run={send} variant="primary" />
</span>
</div>
</section>
Expand Down Expand Up @@ -154,7 +155,7 @@ export function Backups() {
<Rail
stats={[
{ label: 'Schedule', value: data.schedule.enabled ? `${every(data.schedule.everyHours)} from ${String(data.schedule.hour).padStart(2, '0')}:00 UTC` : 'off', pill: data.schedule.enabled ? 'green' : 'gray' },
{ label: 'Next run', value: data.next ? `${clock(data.next)} UTC` : '—' },
{ label: 'Next run', value: data.next ? `${clock(data.next)} UTC` : '—' },
{ label: 'Last run', value: last ? timeAgo(stampDate(last).toISOString()) : 'never' },
{
label: 'Latest per project',
Expand Down Expand Up @@ -209,7 +210,7 @@ export function Backups() {
{project}
{list[0] && <TagChip color={list[0].ok ? 'green' : 'red'} label={list[0].ok ? 'latest verified' : 'latest failed'} />}
</h2>
<ActionButton labels={{ idle: 'Back up now', busy: 'Requesting…', done: 'Requested', failed: 'Retry' }} run={() => post(`/api/backups/${project}/run`).then(() => reload())} />
<ActionButton labels={{ idle: 'Back up now', busy: 'Requesting…', done: 'Requested', failed: 'Retry' }} run={() => post(`/api/backups/${project}/run`).then(() => reload())} />
</header>
<div className="overflow-x-auto">
<table className="data-table min-w-180 table-fixed">
Expand Down
5 changes: 3 additions & 2 deletions app/src/web/pages/HostShell.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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);
const [asking, setAsking] = useState(false);
return (
<div className="space-y-4">
<PageHeader
actions={owner && <Toggle checked={root} label="Root shell" onChange={(on) => (on ? setAsking(true) : setRoot(false))} />}
actions={owner && <Switch checked={root} label="Root shell" onChange={(on) => (on ? setAsking(true) : setRoot(false))} />}
title="VPS shell"
>
<span className="flex flex-wrap items-center gap-2">
Expand Down
Loading
Loading