Add host editing and explicit job steps

This commit is contained in:
2026-09-05 00:11:10 +02:00
parent f878a663bd
commit 3983adbdd7
7 changed files with 108 additions and 18 deletions

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import type { HostUpdateInput } from './schemas.js';
import { resolveUpdatedHostSecret } from './host-credentials.js';
const base: HostUpdateInput = {
name: 'server', hostname: 'server.local', port: 22, username: 'backup',
fingerprint: `SHA256:${'A'.repeat(43)}`, authType: 'password',
};
describe('host credential updates', () => {
it('keeps the encrypted credential payload when no replacement is supplied', () => {
const current = { password: 'existing' };
expect(resolveUpdatedHostSecret('password', current, base)).toBe(current);
});
it('replaces a credential when a new one is supplied', () => {
expect(resolveUpdatedHostSecret('password', { password: 'existing' }, { ...base, password: 'replacement' })).toEqual({ password: 'replacement' });
});
it('requires a credential when changing authentication type', () => {
expect(() => resolveUpdatedHostSecret('password', { password: 'existing' }, { ...base, authType: 'privateKey' })).toThrow('Private key is required');
});
});

14
src/host-credentials.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { HostUpdateInput } from './schemas.js';
import type { HostSecret } from './ssh.js';
export function resolveUpdatedHostSecret(currentAuthType: 'password' | 'privateKey', currentSecret: HostSecret, input: HostUpdateInput): HostSecret {
if (input.authType === 'password') {
if (input.password) return { password: input.password };
if (currentAuthType === 'password') return currentSecret;
throw new Error('Password is required when changing authentication type');
}
if (input.privateKey) return { privateKey: input.privateKey, passphrase: input.passphrase };
if (currentAuthType === 'privateKey') return currentSecret;
throw new Error('Private key is required when changing authentication type');
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { hostInputSchema, jobInputSchema, normalizeJobConfig } from './schemas.js';
import { hostInputSchema, hostUpdateInputSchema, jobInputSchema, normalizeJobConfig } from './schemas.js';
describe('host validation', () => {
it('requires the selected authentication credential', () => {
@@ -9,6 +9,14 @@ describe('host validation', () => {
});
expect(result.success).toBe(false);
});
it('allows an update without returning or replacing credentials', () => {
const result = hostUpdateInputSchema.safeParse({
name: 'node', hostname: 'new-node.local', port: 2222, username: 'backup',
fingerprint: `SHA256:${'A'.repeat(43)}`, authType: 'password',
});
expect(result.success).toBe(true);
});
});
describe('job validation', () => {

View File

@@ -20,6 +20,18 @@ export const hostInputSchema = z.object({
if (value.authType === 'privateKey' && !value.privateKey) context.addIssue({ code: 'custom', path: ['privateKey'], message: 'Private key is required' });
});
export const hostUpdateInputSchema = z.object({
name: z.string().trim().min(1).max(100),
hostname: z.string().trim().min(1).max(253),
port: z.number().int().min(1).max(65535),
username: z.string().trim().min(1).max(100),
fingerprint: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}=?$/, 'Expected an SHA256 SSH fingerprint'),
authType: z.enum(['password', 'privateKey']),
password: z.string().min(1).optional(),
privateKey: z.string().min(1).optional(),
passphrase: z.string().optional(),
});
const outputSchema = z.object({
path: remoteAbsolutePath,
name: safeName,
@@ -136,6 +148,7 @@ export const jobInputSchema = z.object({
});
export type HostInput = z.infer<typeof hostInputSchema>;
export type HostUpdateInput = z.infer<typeof hostUpdateInputSchema>;
export type JobInput = z.infer<typeof jobInputSchema>;
function finalArtifactNames(step: JobStep): string[] {

View File

@@ -7,8 +7,9 @@ import { z } from 'zod';
import { loadConfig } from './config.js';
import { createSession, decryptJson, encryptJson, secureEqual, verifySession } from './crypto.js';
import { openDatabase, type HostRecord, type JobRecord } from './db.js';
import { resolveUpdatedHostSecret } from './host-credentials.js';
import { type NotificationSettings } from './notifications.js';
import { hostInputSchema, jobInputSchema, normalizeJobConfig } from './schemas.js';
import { hostInputSchema, hostUpdateInputSchema, jobInputSchema, normalizeJobConfig } from './schemas.js';
import { BackupService, nextRun } from './service.js';
import { connect, probeFingerprint, type HostSecret } from './ssh.js';
@@ -76,6 +77,23 @@ app.post('/api/hosts', async (request, reply) => {
return reply.code(201).send({ id: Number(result.lastInsertRowid) });
});
app.put('/api/hosts/:id', async (request, reply) => {
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(request.params);
const input = hostUpdateInputSchema.parse(request.body);
const host = db.prepare('SELECT * FROM hosts WHERE id = ?').get(id) as HostRecord | undefined;
if (!host) return reply.code(404).send({ error: 'Host not found' });
const currentSecret = decryptJson<HostSecret>(host.secret, config.masterKey);
let secret: HostSecret;
try {
secret = resolveUpdatedHostSecret(host.auth_type, currentSecret, input);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
db.prepare('UPDATE hosts SET name = ?, hostname = ?, port = ?, username = ?, fingerprint = ?, auth_type = ?, secret = ? WHERE id = ?')
.run(input.name, input.hostname, input.port, input.username, input.fingerprint, input.authType, encryptJson(secret, config.masterKey), id);
return { updated: true };
});
app.post('/api/hosts/:id/test', async (request, reply) => {
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(request.params);
const host = db.prepare('SELECT * FROM hosts WHERE id = ?').get(id) as HostRecord | undefined;

View File

@@ -4,7 +4,7 @@ import { api } from './api';
import './styles.css';
type Tab = 'overview' | 'hosts' | 'jobs' | 'settings';
type Host = { id: number; name: string; hostname: string; port: number; username: string; fingerprint: string; authType: string };
type Host = { id: number; name: string; hostname: string; port: number; username: string; fingerprint: string; authType: 'password' | 'privateKey' };
type StepType = 'dockerCommand' | 'remoteCommand' | 'directory' | 'postgres' | 'mysql';
type StepDraft = {
id: string; name: string; type: StepType; continueOnError: boolean; timeoutSeconds: string;
@@ -106,7 +106,8 @@ function Status({ value }: { value: string }) { return <span className={`status
function Hosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [authType, setAuthType] = useState('password');
const [authType, setAuthType] = useState<'password' | 'privateKey'>('password');
const [editingHost, setEditingHost] = useState<Host>();
const [message, setMessage] = useState('');
async function load() { setHosts(await api<Host[]>('/api/hosts')); }
useEffect(() => { void load(); }, []);
@@ -125,25 +126,35 @@ function Hosts() {
const form = event.currentTarget;
const data = new FormData(form);
try {
await api('/api/hosts', { method: 'POST', body: JSON.stringify({
await api(editingHost ? `/api/hosts/${editingHost.id}` : '/api/hosts', { method: editingHost ? 'PUT' : 'POST', body: JSON.stringify({
name: data.get('name'), hostname: data.get('hostname'), port: Number(data.get('port')), username: data.get('username'),
fingerprint: data.get('fingerprint'), authType, password: data.get('password') || undefined,
privateKey: data.get('privateKey') || undefined, passphrase: data.get('passphrase') || undefined,
}) });
form.reset(); setMessage('Host saved.'); await load();
form.reset(); setEditingHost(undefined); setAuthType('password'); setMessage(editingHost ? 'Host updated.' : 'Host saved.'); await load();
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
}
function edit(host: Host) {
setEditingHost(host);
setAuthType(host.authType);
setMessage(`Editing ${host.name}. Stored credentials remain hidden.`);
}
function cancelEdit() {
setEditingHost(undefined);
setAuthType('password');
setMessage('');
}
return <>
<PageTitle eyebrow="INVENTORY / SSH" title="Remote hosts" detail="Pinned connections used to execute and retrieve backups." />
<div className="split"><section className="panel form-panel"><div className="panel-head"><h2>Add host</h2><span>encrypted at rest</span></div><form onSubmit={submit}>
<div className="field-row"><label>Display name<input name="name" required placeholder="media-node" /></label><label>SSH username<input name="username" required placeholder="backup" /></label></div>
<div className="field-row wide"><label>Hostname or IP<input name="hostname" required placeholder="192.168.1.20" /></label><label className="port">Port<input name="port" type="number" defaultValue="22" required /></label></div>
<label>Host-key fingerprint<div className="input-action"><input name="fingerprint" required placeholder="SHA256:..." /><button type="button" onClick={(event) => void probe(event.currentTarget.form!)}>Probe</button></div></label>
<div className="split"><section className="panel form-panel"><div className="panel-head"><h2>{editingHost ? `Edit ${editingHost.name}` : 'Add host'}</h2><span>{editingHost ? 'credentials stay hidden' : 'encrypted at rest'}</span></div><form key={editingHost?.id ?? 'new'} onSubmit={submit}>
<div className="field-row"><label>Display name<input name="name" required placeholder="media-node" defaultValue={editingHost?.name} /></label><label>SSH username<input name="username" required placeholder="backup" defaultValue={editingHost?.username} /></label></div>
<div className="field-row wide"><label>Hostname or IP<input name="hostname" required placeholder="192.168.1.20" defaultValue={editingHost?.hostname} /></label><label className="port">Port<input name="port" type="number" defaultValue={editingHost?.port ?? 22} required /></label></div>
<label>Host-key fingerprint<div className="input-action"><input name="fingerprint" required placeholder="SHA256:..." defaultValue={editingHost?.fingerprint} /><button type="button" onClick={(event) => void probe(event.currentTarget.form!)}>Probe</button></div></label>
<div className="segmented"><button type="button" className={authType === 'password' ? 'selected' : ''} onClick={() => setAuthType('password')}>Password</button><button type="button" className={authType === 'privateKey' ? 'selected' : ''} onClick={() => setAuthType('privateKey')}>Private key</button></div>
{authType === 'password' ? <label>Password<input name="password" type="password" required /></label> : <><label>PEM private key<textarea name="privateKey" rows={7} required placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /></label><label>Key passphrase <small>optional</small><input name="passphrase" type="password" /></label></>}
{message && <p className="form-message">{message}</p>}<button type="submit">Save encrypted host</button>
{authType === 'password' ? <label>Password {editingHost?.authType === 'password' && <small>leave blank to keep current password</small>}<input name="password" type="password" required={!editingHost || editingHost.authType !== 'password'} /></label> : <><label>PEM private key {editingHost?.authType === 'privateKey' && <small>leave blank to keep current key</small>}<textarea name="privateKey" rows={7} required={!editingHost || editingHost.authType !== 'privateKey'} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /></label><label>Key passphrase <small>{editingHost?.authType === 'privateKey' ? 'used only when replacing the key' : 'optional'}</small><input name="passphrase" type="password" /></label></>}
{message && <p className="form-message">{message}</p>}<div className="form-actions"><button type="submit">{editingHost ? 'Update host' : 'Save encrypted host'}</button>{editingHost && <button type="button" className="secondary" onClick={cancelEdit}>Cancel editing</button>}</div>
</form></section>
<section className="panel"><div className="panel-head"><h2>Known hosts</h2><span>{hosts.length} configured</span></div>{hosts.map((host) => <article className="host-card" key={host.id}><div className="host-icon">{host.name.slice(0, 2).toUpperCase()}</div><div><h3>{host.name}</h3><p className="mono">{host.username}@{host.hostname}:{host.port}</p><small>{host.authType === 'privateKey' ? 'PRIVATE KEY' : 'PASSWORD'} · {host.fingerprint}</small></div><div className="card-actions"><button onClick={() => void api(`/api/hosts/${host.id}/test`, { method: 'POST' }).then(() => setMessage(`${host.name}: connection successful`)).catch((error) => setMessage(error.message))}>Test</button><button className="danger" onClick={() => confirm(`Delete ${host.name}?`) && void api(`/api/hosts/${host.id}`, { method: 'DELETE' }).then(load).catch((error) => setMessage(error.message))}>Delete</button></div></article>)}{hosts.length === 0 && <Empty text="No remote hosts configured." />}</section></div>
<section className="panel"><div className="panel-head"><h2>Known hosts</h2><span>{hosts.length} configured</span></div>{hosts.map((host) => <article className="host-card" key={host.id}><div className="host-icon">{host.name.slice(0, 2).toUpperCase()}</div><div><h3>{host.name}</h3><p className="mono">{host.username}@{host.hostname}:{host.port}</p><small>{host.authType === 'privateKey' ? 'PRIVATE KEY' : 'PASSWORD'} · {host.fingerprint}</small></div><div className="card-actions"><button onClick={() => edit(host)}>Edit</button><button onClick={() => void api(`/api/hosts/${host.id}/test`, { method: 'POST' }).then(() => setMessage(`${host.name}: connection successful`)).catch((error) => setMessage(error.message))}>Test</button><button className="danger" onClick={() => confirm(`Delete ${host.name}?`) && void api(`/api/hosts/${host.id}`, { method: 'DELETE' }).then(load).catch((error) => setMessage(error.message))}>Delete</button></div></article>)}{hosts.length === 0 && <Empty text="No remote hosts configured." />}</section></div>
</>;
}
@@ -151,7 +162,7 @@ function Jobs() {
const [jobs, setJobs] = useState<Job[]>([]);
const [hosts, setHosts] = useState<Host[]>([]);
const [addType, setAddType] = useState<StepType>('dockerCommand');
const [steps, setSteps] = useState<StepDraft[]>([createStep('dockerCommand')]);
const [steps, setSteps] = useState<StepDraft[]>([]);
const [message, setMessage] = useState('');
async function load() { const [jobData, hostData] = await Promise.all([api<Job[]>('/api/jobs'), api<Host[]>('/api/hosts')]); setJobs(jobData); setHosts(hostData); }
useEffect(() => { void load(); }, []);
@@ -181,7 +192,7 @@ function Jobs() {
schedule: data.get('schedule') || undefined,
timezone: data.get('timezone'), enabled: true, retentionCount: Number(data.get('retentionCount')),
}) });
form.reset(); setSteps([createStep('dockerCommand')]); setMessage('Job created.'); await load();
form.reset(); setSteps([]); setMessage('Job created.'); await load();
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
}
return <>
@@ -189,10 +200,10 @@ function Jobs() {
<div className="split jobs-split"><section className="panel form-panel"><div className="panel-head"><h2>New job</h2><span>runs remotely</span></div>
{hosts.length === 0 ? <Empty text="Add an SSH host before creating a job." /> : <form onSubmit={submit}>
<div className="field-row"><label>Job name<input name="name" required placeholder="immich-db" /></label><label>Remote host<select name="hostId" required>{hosts.map((host) => <option value={host.id} key={host.id}>{host.name}</option>)}</select></label></div>
<div className="step-list">{steps.map((step, index) => <StepEditor step={step} index={index} count={steps.length} update={updateStep} move={moveStep} remove={(id) => setSteps((current) => current.filter((item) => item.id !== id))} key={step.id} />)}</div>
<div className="step-list">{steps.map((step, index) => <StepEditor step={step} index={index} count={steps.length} update={updateStep} move={moveStep} remove={(id) => setSteps((current) => current.filter((item) => item.id !== id))} key={step.id} />)}{steps.length === 0 && <div className="empty step-empty">Select a type below to add the first step.</div>}</div>
<div className="add-step"><select value={addType} onChange={(event) => setAddType(event.target.value as StepType)}><option value="dockerCommand">Docker command</option><option value="remoteCommand">Remote command</option><option value="directory">Directory archive</option><option value="postgres">PostgreSQL dump</option><option value="mysql">MySQL dump</option></select><button type="button" onClick={() => setSteps((current) => [...current, createStep(addType)])}>+ Add step</button></div>
<fieldset><legend>Job policy</legend><div className="field-row"><label>Cron schedule <small>optional</small><input name="schedule" placeholder="0 2 * * *" /></label><label>Timezone<input name="timezone" required defaultValue="UTC" /></label></div><label>Successful runs to retain<input name="retentionCount" type="number" min="1" defaultValue="10" required /></label></fieldset>
{message && <p className="form-message">{message}</p>}<button type="submit">Create backup job</button>
{message && <p className="form-message">{message}</p>}<button type="submit" disabled={steps.length === 0}>Create backup job</button>
</form>}
</section><section className="panel"><div className="panel-head"><h2>Configured jobs</h2><span>{jobs.length} total</span></div>{jobs.map((job) => <article className="job-card" key={job.id}><div><span className="job-type">{job.config.steps.length} step{job.config.steps.length === 1 ? '' : 's'}</span><h3>{job.name}</h3><p>{job.hostName} · keep {job.retentionCount}</p><small>{job.schedule ? `${job.schedule} · next ${formatDate(job.nextRunAt)}` : 'MANUAL ONLY'}</small></div><div className="card-actions"><button className="run" onClick={() => void api<{ runId: number }>(`/api/jobs/${job.id}/run`, { method: 'POST' }).then((value) => setMessage(`Run #${value.runId} queued`)).catch((error) => setMessage(error.message))}>Run now</button><button className="danger" onClick={() => confirm(`Delete ${job.name} and its run history?`) && void api(`/api/jobs/${job.id}`, { method: 'DELETE' }).then(load).catch((error) => setMessage(error.message))}>Delete</button></div></article>)}{jobs.length === 0 && <Empty text="No backup jobs defined." />}</section></div>
</>;
@@ -201,7 +212,7 @@ function Jobs() {
function StepEditor({ step, index, count, update, move, remove }: { step: StepDraft; index: number; count: number; update: (id: string, values: Partial<StepDraft>) => void; move: (index: number, direction: number) => void; remove: (id: string) => void }) {
const set = (values: Partial<StepDraft>) => update(step.id, values);
const command = step.type === 'dockerCommand' || step.type === 'remoteCommand';
return <fieldset className="step-card"><legend>Step {index + 1}</legend><div className="step-head"><input aria-label="Step name" value={step.name} onChange={(event) => set({ name: event.target.value })} required /><span className="job-type">{stepTypeLabel(step.type)}</span><div className="step-actions"><button type="button" disabled={index === 0} onClick={() => move(index, -1)}></button><button type="button" disabled={index === count - 1} onClick={() => move(index, 1)}></button><button type="button" disabled={count === 1} onClick={() => remove(step.id)}>×</button></div></div>
return <fieldset className="step-card"><legend>Step {index + 1}</legend><div className="step-head"><input aria-label="Step name" value={step.name} onChange={(event) => set({ name: event.target.value })} required /><span className="job-type">{stepTypeLabel(step.type)}</span><div className="step-actions"><button type="button" disabled={index === 0} onClick={() => move(index, -1)}></button><button type="button" disabled={index === count - 1} onClick={() => move(index, 1)}></button><button type="button" onClick={() => remove(step.id)}>×</button></div></div>
{step.type === 'dockerCommand' && <><div className="field-row"><label>Container<input value={step.container} onChange={(event) => set({ container: event.target.value })} required placeholder="immich_server" /></label><label>Executable<input value={step.executable} onChange={(event) => set({ executable: event.target.value })} required placeholder="/app/backup" /></label></div><div className="field-row"><label>Container user <small>optional</small><input value={step.dockerUser} onChange={(event) => set({ dockerUser: event.target.value })} /></label><label>Working directory <small>optional</small><input value={step.workingDirectory} onChange={(event) => set({ workingDirectory: event.target.value })} placeholder="/app" /></label></div></>}
{step.type === 'remoteCommand' && <><label>Executable<input value={step.executable} onChange={(event) => set({ executable: event.target.value })} required placeholder="/usr/local/bin/prepare-backup" /></label><label>Working directory <small>optional</small><input value={step.workingDirectory} onChange={(event) => set({ workingDirectory: event.target.value })} placeholder="/srv/app" /></label></>}
{command && <><label>Arguments <small>one argument per line</small><textarea value={step.arguments} onChange={(event) => set({ arguments: event.target.value })} rows={3} placeholder={'--output\n/tmp/export'} /></label><label className="check"><input type="checkbox" checked={step.collectOutput} onChange={(event) => set({ collectOutput: event.target.checked })} /> Collect an output from this command</label>{step.collectOutput && <><div className="field-row"><label>{step.type === 'dockerCommand' ? 'Container' : 'Remote'} output path<input value={step.outputPath} onChange={(event) => set({ outputPath: event.target.value })} required placeholder="/tmp/export" /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="export" /></label></div><label className="check"><input type="checkbox" checked={step.archive} onChange={(event) => set({ archive: event.target.checked })} /> Archive output as tar.gz</label></>}</>}

View File

@@ -5,6 +5,7 @@
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 80% 0, #1c2921 0, transparent 32%), #101512; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: default; opacity: .4; }
.loading { display: grid; place-items: center; min-height: 100vh; color: var(--muted); }
.login-shell { min-height: 100vh; display: grid; place-items: center; padding: 24px; background-image: linear-gradient(#ffffff06 1px, transparent 1px), linear-gradient(90deg, #ffffff06 1px, transparent 1px); background-size: 48px 48px; }
.login-card { width: min(440px, 100%); background: #171e1af2; border: 1px solid var(--line); padding: 48px; box-shadow: 18px 18px 0 #0a0d0b; }
@@ -47,9 +48,11 @@ nav button.active span { color: var(--acid); }
.status.succeeded_with_warnings { color: #ffe08a; }.status.succeeded_with_warnings i { background: #ffe06a; }
.split { display: grid; grid-template-columns: minmax(380px, .9fr) minmax(440px, 1.1fr); gap: 20px; align-items: start; }.jobs-split { grid-template-columns: minmax(440px, 1fr) minmax(400px, .9fr); }.form-panel form { padding: 22px; }.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }.field-row.wide { grid-template-columns: 1fr 100px; }.input-action { display: flex; }.input-action button { white-space: nowrap; }.segmented { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--line); border: 1px solid var(--line); }.segmented button { background: #121814; color: var(--muted); }.segmented button.selected { background: #263126; color: var(--acid); }
.form-message { color: #cfdbc9; border-left: 2px solid var(--acid); padding-left: 10px; font-size: 13px; }.empty { color: var(--muted); padding: 50px 25px; text-align: center; font-size: 13px; }
.form-actions { display: flex; gap: 8px; }.form-actions .secondary { background: transparent; border: 1px solid var(--line); color: var(--muted); }
.host-card, .job-card { padding: 19px 21px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 15px; }.host-card:last-child, .job-card:last-child { border: 0; }.host-card p, .job-card p { color: var(--muted); margin-bottom: 5px; font-size: 12px; }.host-card small, .job-card small { color: #718078; font: 9px DM Mono; overflow-wrap: anywhere; }.host-icon { width: 43px; height: 43px; flex: 0 0 43px; border: 1px solid #435047; display: grid; place-items: center; color: var(--acid); font: 11px DM Mono; }.card-actions { display: flex; gap: 6px; margin-left: auto; }.card-actions button { padding: 8px 10px; font-size: 11px; background: #2b382f; color: #dbe4de; }.card-actions .run { background: var(--acid); color: #111713; }.card-actions .danger { background: transparent; color: #c6866b; border: 1px solid #513429; }
fieldset { border: 1px solid var(--line); padding: 18px; display: grid; gap: 16px; } legend { color: var(--acid); padding: 0 8px; font: 10px DM Mono; letter-spacing: .08em; text-transform: uppercase; }.check { display: flex; grid-template-columns: 18px 1fr; align-items: center; }.check input { width: 16px; height: 16px; accent-color: var(--acid); }.job-card { align-items: flex-start; }.job-type { display: inline-block; color: var(--acid); border: 1px solid #3d4d3f; padding: 3px 6px; margin-bottom: 10px; font: 9px DM Mono; text-transform: uppercase; }.settings-panel { max-width: 700px; }.settings-panel form { padding: 24px; }
.step-list { display: grid; gap: 14px; }.step-card { background: #121814; border-color: #354139; }.step-head { display: grid; grid-template-columns: minmax(120px, 1fr) auto auto; gap: 10px; align-items: center; }.step-head input { font-weight: 700; }.step-head .job-type { margin: 0; }.step-actions { display: flex; gap: 3px; }.step-actions button { padding: 8px 10px; background: #28332c; color: var(--ink); }.step-actions button:disabled { opacity: .25; cursor: default; }.add-step { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 14px; border: 1px dashed #435047; }.step-policy { align-items: end; padding-top: 4px; border-top: 1px solid var(--line); }
.step-empty { border: 1px dashed #435047; padding: 28px 20px; }
.modal-backdrop { position: fixed; inset: 0; background: #050806cf; display: grid; place-items: center; padding: 22px; z-index: 10; }.modal { width: min(800px, 100%); max-height: 88vh; overflow: auto; background: #171e1a; border: 1px solid #4a584f; box-shadow: 22px 22px 0 #050705; padding-bottom: 22px; }.modal > :not(.panel-head) { margin-left: 22px; margin-right: 22px; }.modal .close { color: var(--muted); background: transparent; border: 1px solid var(--line); padding: 8px 12px; }.modal > .status { margin-top: 18px; }.modal pre { padding: 17px; max-height: 330px; overflow: auto; background: #0b100d; border: 1px solid #29342d; color: #bdc9c1; font: 11px/1.7 DM Mono; white-space: pre-wrap; }.modal h3 { font-size: 13px; margin-top: 24px; }.artifact { display: flex; align-items: center; justify-content: space-between; color: var(--ink); text-decoration: none; border: 1px solid var(--line); padding: 12px; margin-top: 7px; font-size: 12px; }.artifact:hover { border-color: var(--acid); }.artifact small { display: block; color: #627069; font: 8px DM Mono; margin-top: 5px; }.artifact strong { color: var(--acid); font: 10px DM Mono; white-space: nowrap; }
@media (max-width: 1000px) { .split, .jobs-split { grid-template-columns: 1fr; }.metrics { grid-template-columns: 1fr 1fr; } }
@media (max-width: 680px) { .app-shell { display: block; }.content { padding: 30px 16px 90px; } aside { height: auto; position: fixed; inset: auto 0 0; z-index: 5; padding: 8px; border: 1px solid var(--line); }.brand, .logout { display: none; } nav { grid-template-columns: repeat(4, 1fr); } nav button { justify-content: center; font-size: 10px; padding: 11px 4px; } nav button span { display: none; }.metrics { gap: 7px; }.metric { min-height: 105px; }.metric strong { font-size: 32px; }.field-row, .field-row.wide, .step-head, .add-step { grid-template-columns: 1fr; }.split, .jobs-split { display: block; }.split > .panel { margin-bottom: 16px; }.host-card, .job-card { flex-wrap: wrap; }.card-actions { width: 100%; margin-left: 58px; }.job-card .card-actions { margin-left: 0; }.login-card { padding: 32px 24px; } }