Build backup manager MVP

This commit is contained in:
2026-09-04 23:59:11 +02:00
commit 6178508970
31 changed files with 6890 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git
node_modules
dist
data
backups
.env
*.log

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
ADMIN_PASSWORD=replace-with-a-long-random-password
MASTER_KEY=replace-with-output-of-openssl-rand-base64-32
APP_PORT=3000
BIND_ADDRESS=127.0.0.1
SECURE_COOKIE=false

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
data/
backups/
.env
*.log

11
AGENTS.md Normal file
View File

@@ -0,0 +1,11 @@
# Repository Guide
- Requires Node.js 22+. Use `npm run check` for the required `lint -> typecheck -> test -> build` verification order; `npm test -- <file>` runs one Vitest file.
- `src/server.ts` owns HTTP/authentication, `src/service.ts` owns the single-worker queue and cron polling, and `src/executor.ts` owns ordered step execution, SFTP collection, staging, and retention. The React UI is intentionally a single entrypoint at `web/src/main.tsx`.
- Runtime configuration is mandatory: `ADMIN_PASSWORD` and a base64-encoded 32-byte `MASTER_KEY`. Tests import modules without either; do not load environment configuration at module scope outside `src/server.ts`.
- SQLite state lives under `DATA_DIR`; artifacts live under `BACKUP_DIR`. Store artifact paths relative to `BACKUP_DIR` and download to `.staging` before atomically moving completed runs.
- SSH host-key pinning is independent of password/private-key user authentication. Never weaken `hostVerifier`, log decrypted values, or store credentials outside AES-256-GCM envelopes from `src/crypto.ts`.
- Jobs contain ordered mixed steps but use one SSH host/connection. `continueOnError` yields `succeeded_with_warnings`; required failures publish no artifacts. Keep legacy single-operation normalization in `src/schemas.ts` while persisted old jobs may exist.
- Remote commands must quote every configured value with `shellQuote`. Docker outputs are container paths copied with `docker cp`; remote command outputs are staged immediately; database and directory operations run tools on the SSH host.
- Scheduling is single-instance and non-overlapping per job. On startup, `src/db.ts` marks queued/running work failed; do not imply distributed-worker safety without replacing this design.
- `npm run dev` starts Fastify on `3000` and Vite on `5173`; production serves `dist/public` from the Fastify process. `docker compose up --build` runs as UID/GID `10001`, so mounted data/backup paths must be writable by it.

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.server.json tsconfig.web.json vite.config.ts vitest.config.ts eslint.config.js ./
COPY src ./src
COPY web ./web
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim
ENV NODE_ENV=production DATA_DIR=/data BACKUP_DIR=/backups PORT=3000
WORKDIR /app
RUN groupadd --gid 10001 backup-manager && useradd --uid 10001 --gid backup-manager --no-create-home backup-manager \
&& mkdir /data /backups && chown backup-manager:backup-manager /data /backups
COPY --from=build --chown=backup-manager:backup-manager /app/package.json /app/package-lock.json ./
COPY --from=build --chown=backup-manager:backup-manager /app/node_modules ./node_modules
COPY --from=build --chown=backup-manager:backup-manager /app/dist ./dist
USER backup-manager
EXPOSE 3000
CMD ["node", "dist/server/server.js"]

89
README.md Normal file
View File

@@ -0,0 +1,89 @@
# Backup Script Manager
A single-administrator web application that runs ordered, typed backup steps on home-lab hosts over SSH and retrieves artifacts over SFTP.
Supported operations:
- Combine up to 50 Docker commands, remote host commands, database dumps, and directory archives in one job.
- Execute commands with zero or more declared outputs, allowing preparation and cleanup steps.
- Create compressed PostgreSQL or MySQL dumps using tools installed on the remote host.
- Archive a remote directory as `tar.gz`.
- Run jobs manually or with timezone-aware cron schedules.
- Retain a configured number of successful runs and notify by webhook or SMTP.
## Run With Docker Compose
Create the deployment configuration and a permanent encryption key:
```bash
cp .env.example .env
openssl rand -base64 32
```
Set the generated value as `MASTER_KEY` and choose a strong `ADMIN_PASSWORD` in `.env`, then start the application:
```bash
docker compose up --build -d
```
Open `http://localhost:3000`. The default binds only to loopback. For network access, put the application behind an HTTPS reverse proxy, set `BIND_ADDRESS` as needed, and set `SECURE_COOKIE=true`; do not expose the HTTP login directly to the LAN.
Compose uses managed volumes by default. To use bind mounts, set `DATA_PATH` and `BACKUP_PATH` in `.env`; the process runs as UID/GID `10001`, so those host directories must already exist and be writable by that identity. Keep `MASTER_KEY` in a password manager: losing or changing it makes stored SSH and database credentials unreadable.
## Remote Host Requirements
- SSH must allow password or private-key authentication and the same account must support SFTP.
- Verify a probed SSH fingerprint against the host itself, for example with `ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub -E sha256`, before saving it.
- The account needs write access to `/tmp` and read access to backup sources.
- Docker jobs require `docker`, `tar`, and `gzip`; the SSH account must be permitted to run Docker without an interactive prompt.
- Directory jobs require `tar` and `gzip`.
- PostgreSQL jobs require `pg_dump` and `gzip` on the SSH host. MySQL jobs require `mysqldump` and `gzip`.
Docker jobs execute in an existing container. Each configured output is a path inside that container. The manager copies it to a permission-restricted remote staging directory, downloads it, and deletes only that staging directory; it does not delete the original container output.
## Job Steps
Every job targets one SSH host and runs its steps sequentially over one pinned connection. Use the step arrows in the job builder to control order. Available steps are:
- **Docker command:** runs an executable with explicit arguments inside an existing container. Declared container outputs are copied with `docker cp`.
- **Remote command:** runs an executable with explicit arguments on the SSH host. It can collect generated host files or archives.
- **PostgreSQL/MySQL dump:** creates a compressed dump using an encrypted password associated with that step.
- **Directory archive:** archives an absolute host path.
Command arguments are entered one per line and are passed as distinct shell-quoted values; shell pipelines and redirection are not interpreted. Add an explicit script on the remote system when more complex command logic is required.
By default, a failed step stops the job and no artifacts are published. Enable **Continue if this step fails** only for non-critical steps. The run then continues, skips outputs from the failed step, and finishes as `succeeded_with_warnings` if all required steps complete. Artifact names must be unique across the job, and every job must contain at least one artifact-producing step.
Schedules use five-field cron syntax. Jobs do not overlap: a scheduled occurrence is skipped if that job is already queued or running. Runs from different jobs are processed serially in this initial single-instance release.
## Local Development
Node.js 22 or later is required.
```bash
npm install
export ADMIN_PASSWORD=development-only
export MASTER_KEY="$(openssl rand -base64 32)"
npm run dev
```
Vite serves the UI on `http://localhost:5173` and proxies API requests to port `3000`.
Verification commands:
```bash
npm run lint
npm run typecheck
npm test
npm run build
```
Run all checks in that order with `npm run check`.
## Storage
- `data/app.db` contains hosts, encrypted secrets, job definitions, schedules, and run history.
- `backups/<job-id>/<run-id>/` contains completed artifacts.
- `backups/.staging/` contains in-progress downloads and is cleaned when a run fails.
Back up both the data and artifact volumes together. Restore workflows are not included in this release.

24
compose.yaml Normal file
View File

@@ -0,0 +1,24 @@
services:
backup-manager:
build: .
container_name: backup-manager
restart: unless-stopped
environment:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
MASTER_KEY: ${MASTER_KEY:?Set MASTER_KEY in .env}
SECURE_COOKIE: ${SECURE_COOKIE:-false}
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-3000}:3000"
volumes:
- ${DATA_PATH:-backup-manager-data}:/data
- ${BACKUP_PATH:-backup-manager-backups}:/backups
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/session').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
backup-manager-data:
backup-manager-backups:

17
eslint.config.js Normal file
View File

@@ -0,0 +1,17 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'data', 'backups'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.ts'],
languageOptions: { globals: globals.node },
},
{
files: ['web/**/*.{ts,tsx}'],
languageOptions: { globals: globals.browser },
},
);

5165
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "backup-script-manager",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently -k \"tsx watch src/server.ts\" \"vite\"",
"build": "tsc -p tsconfig.server.json && vite build",
"start": "node dist/server/server.js",
"typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web.json --noEmit",
"test": "vitest run",
"lint": "eslint .",
"check": "npm run lint && npm run typecheck && npm test && npm run build"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^10.1.3",
"better-sqlite3": "^12.2.0",
"cron-parser": "^5.3.1",
"fastify": "^5.5.0",
"nodemailer": "^10.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"ssh2": "^1.17.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/ssh2": "^1.15.5",
"@vitejs/plugin-react": "^5.0.2",
"concurrently": "^9.2.1",
"eslint": "^9.34.0",
"globals": "^16.3.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"vite": "^7.1.4",
"vitest": "^3.2.4"
}
}

29
src/config.ts Normal file
View File

@@ -0,0 +1,29 @@
import path from 'node:path';
export interface AppConfig {
port: number;
dataDir: string;
backupDir: string;
adminPassword: string;
masterKey: Buffer;
secureCookie: boolean;
}
export function loadConfig(env = process.env): AppConfig {
const adminPassword = env.ADMIN_PASSWORD;
const encodedKey = env.MASTER_KEY;
if (!adminPassword) throw new Error('ADMIN_PASSWORD is required');
if (!encodedKey) throw new Error('MASTER_KEY is required');
const masterKey = Buffer.from(encodedKey, 'base64');
if (masterKey.length !== 32) throw new Error('MASTER_KEY must be a base64-encoded 32-byte key');
return {
port: Number(env.PORT ?? 3000),
dataDir: path.resolve(env.DATA_DIR ?? 'data'),
backupDir: path.resolve(env.BACKUP_DIR ?? 'backups'),
adminPassword,
masterKey,
secureCookie: env.SECURE_COOKIE === 'true',
};
}

26
src/crypto.test.ts Normal file
View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { createSession, decryptJson, encryptJson, verifySession } from './crypto.js';
const key = Buffer.alloc(32, 7);
describe('encrypted values', () => {
it('round trips without exposing plaintext', () => {
const encrypted = encryptJson({ password: 'home-lab-secret' }, key);
expect(encrypted).not.toContain('home-lab-secret');
expect(decryptJson(encrypted, key)).toEqual({ password: 'home-lab-secret' });
});
it('rejects a modified envelope', () => {
const encrypted = encryptJson({ password: 'secret' }, key);
expect(() => decryptJson(`${encrypted.slice(0, -1)}x`, key)).toThrow();
});
});
describe('sessions', () => {
it('expires after 24 hours', () => {
const now = Date.parse('2026-01-01T00:00:00Z');
const session = createSession(key, now);
expect(verifySession(session, key, now + 1_000)).toBe(true);
expect(verifySession(session, key, now + 24 * 60 * 60 * 1000 + 1)).toBe(false);
});
});

38
src/crypto.ts Normal file
View File

@@ -0,0 +1,38 @@
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
export function encryptJson(value: unknown, key: Buffer): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
return [iv, cipher.getAuthTag(), encrypted].map((part) => part.toString('base64url')).join('.');
}
export function decryptJson<T>(envelope: string, key: Buffer): T {
const parts = envelope.split('.').map((part) => Buffer.from(part, 'base64url'));
const [iv, tag, encrypted] = parts;
if (!iv || !tag || !encrypted || parts.length !== 3) throw new Error('Invalid encrypted value');
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return JSON.parse(Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')) as T;
}
export function createSession(key: Buffer, now = Date.now()): string {
const issuedAt = String(now);
const signature = createHmac('sha256', key).update(issuedAt).digest('base64url');
return `${issuedAt}.${signature}`;
}
export function verifySession(token: string | undefined, key: Buffer, now = Date.now()): boolean {
if (!token) return false;
const [issuedAt, provided] = token.split('.');
if (!issuedAt || !provided || !/^\d+$/.test(issuedAt) || now - Number(issuedAt) > 24 * 60 * 60 * 1000) return false;
const expected = createHmac('sha256', key).update(issuedAt).digest();
const actual = Buffer.from(provided, 'base64url');
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
export function secureEqual(left: string, right: string): boolean {
const a = createHmac('sha256', 'password-compare').update(left).digest();
const b = createHmac('sha256', 'password-compare').update(right).digest();
return timingSafeEqual(a, b);
}

74
src/db.ts Normal file
View File

@@ -0,0 +1,74 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
export interface HostRecord {
id: number;
name: string;
hostname: string;
port: number;
username: string;
fingerprint: string;
auth_type: 'password' | 'privateKey';
secret: string;
created_at: string;
}
export interface JobRecord {
id: number;
name: string;
host_id: number;
config: string;
secret: string | null;
schedule: string | null;
timezone: string;
enabled: number;
retention_count: number;
next_run_at: string | null;
created_at: string;
}
export interface RunRecord {
id: number;
job_id: number;
status: 'queued' | 'running' | 'succeeded' | 'succeeded_with_warnings' | 'failed';
trigger: 'manual' | 'scheduled';
started_at: string | null;
finished_at: string | null;
log: string;
error: string | null;
created_at: string;
}
export function openDatabase(dataDir: string): Database.Database {
fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
const db = new Database(path.join(dataDir, 'app.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS hosts (
id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, hostname TEXT NOT NULL, port INTEGER NOT NULL,
username TEXT NOT NULL, fingerprint TEXT NOT NULL, auth_type TEXT NOT NULL, secret TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE RESTRICT,
config TEXT NOT NULL, secret TEXT, schedule TEXT, timezone TEXT NOT NULL DEFAULT 'UTC', enabled INTEGER NOT NULL DEFAULT 1,
retention_count INTEGER NOT NULL DEFAULT 10, next_run_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY, job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
status TEXT NOT NULL, trigger TEXT NOT NULL, started_at TEXT, finished_at TEXT, log TEXT NOT NULL DEFAULT '',
error TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS artifacts (
id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
name TEXT NOT NULL, path TEXT NOT NULL, size INTEGER NOT NULL, checksum TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS runs_job_id ON runs(job_id, id DESC);
`);
db.prepare("UPDATE runs SET status = 'failed', finished_at = CURRENT_TIMESTAMP, error = 'Interrupted by application restart' WHERE status IN ('queued', 'running')").run();
return db;
}

25
src/executor.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { executeSequentialSteps } from './executor.js';
describe('step execution policy', () => {
it('continues after tolerated failures and records a warning', async () => {
const warning = vi.fn();
const result = await executeSequentialSteps(
[{ continueOnError: true, value: 'first' }, { continueOnError: false, value: 'second' }],
async (step) => { if (step.value === 'first') throw new Error('optional failed'); return step.value; },
warning,
);
expect(result).toEqual({ values: ['second'], warningCount: 1 });
expect(warning).toHaveBeenCalledOnce();
});
it('stops after a required failure', async () => {
const visited: string[] = [];
await expect(executeSequentialSteps(
[{ continueOnError: false, value: 'first' }, { continueOnError: false, value: 'second' }],
async (step) => { visited.push(step.value); throw new Error('failed'); },
() => undefined,
)).rejects.toThrow('failed');
expect(visited).toEqual(['first']);
});
});

245
src/executor.ts Normal file
View File

@@ -0,0 +1,245 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type Database from 'better-sqlite3';
import type { Client } from 'ssh2';
import type { AppConfig } from './config.js';
import { decryptJson } from './crypto.js';
import type { HostRecord, JobRecord, RunRecord } from './db.js';
import { normalizeJobConfig, type JobConfig, type JobStep } from './schemas.js';
import { connect, download, exec, getSftp, shellQuote, uploadText, type HostSecret } from './ssh.js';
interface RemoteArtifact {
remotePath: string;
name: string;
}
export class BackupExecutor {
constructor(private db: Database.Database, private appConfig: AppConfig) {}
async execute(run: RunRecord): Promise<void> {
const job = this.db.prepare('SELECT * FROM jobs WHERE id = ?').get(run.job_id) as JobRecord | undefined;
if (!job) throw new Error('Job no longer exists');
const host = this.db.prepare('SELECT * FROM hosts WHERE id = ?').get(job.host_id) as HostRecord | undefined;
if (!host) throw new Error('Host no longer exists');
const jobConfig = normalizeJobConfig(JSON.parse(job.config), String(job.id));
const hostSecret = decryptJson<HostSecret>(host.secret, this.appConfig.masterKey);
const stepSecrets = this.readStepSecrets(job, jobConfig);
const redactions = [hostSecret.password, hostSecret.passphrase, ...Object.values(stepSecrets)].filter((value): value is string => Boolean(value));
this.updateRun(run.id, "status = 'running', started_at = CURRENT_TIMESTAMP");
this.log(run.id, `Connecting to ${host.name} (${host.hostname}:${host.port})`);
let client: Client | undefined;
let remoteDir: string | undefined;
try {
client = await connect(host, hostSecret);
const created = await exec(client, `umask 077 && mktemp -d ${shellQuote(`/tmp/backup-manager-${run.id}-XXXXXX`)}`, 30);
remoteDir = created.stdout.trim();
if (!new RegExp(`^/tmp/backup-manager-${run.id}-[A-Za-z0-9]+$`).test(remoteDir)) throw new Error('Remote host returned an invalid staging path');
const { artifacts, warningCount } = await this.runSteps(client, jobConfig, stepSecrets, remoteDir, (message) => this.log(run.id, redact(message, redactions)));
await this.collectArtifacts(client, job, run, artifacts);
const status = warningCount ? 'succeeded_with_warnings' : 'succeeded';
this.updateRun(run.id, 'status = ?, finished_at = CURRENT_TIMESTAMP, error = NULL', status);
this.log(run.id, `Completed with ${artifacts.length} artifact(s)${warningCount ? ` and ${warningCount} warning(s)` : ''}`);
this.applyRetention(job);
} catch (error) {
fs.rmSync(path.join(this.appConfig.backupDir, '.staging', String(run.id)), { recursive: true, force: true });
const message = redact(error instanceof Error ? error.message : String(error), redactions);
this.updateRun(run.id, "status = 'failed', finished_at = CURRENT_TIMESTAMP, error = ?", message);
this.log(run.id, `Failed: ${message}`);
throw error;
} finally {
if (client && remoteDir) {
try { await exec(client, `rm -rf -- ${shellQuote(remoteDir)}`, 30); } catch { /* Best-effort cleanup after preserving the primary result. */ }
}
client?.end();
}
}
private async runSteps(client: Client, config: JobConfig, secrets: Record<string, string>, remoteDir: string, log: (message: string) => void): Promise<{ artifacts: RemoteArtifact[]; warningCount: number }> {
const result = await executeSequentialSteps(config.steps, async (step, index) => {
log(`Step ${index + 1}/${config.steps.length}: ${step.name}`);
return this.runStep(client, step, secrets[step.id], remoteDir, index, log);
}, (error) => log(`Warning: ${error instanceof Error ? error.message : String(error)}`));
return { artifacts: result.values.flat(), warningCount: result.warningCount };
}
private async runStep(client: Client, config: JobStep, password: string | undefined, remoteDir: string, stepIndex: number, log: (message: string) => void): Promise<RemoteArtifact[]> {
switch (config.type) {
case 'dockerCommand': {
log(`Running Docker command in ${config.container}`);
const flags = [config.user ? `--user ${shellQuote(config.user)}` : '', config.workingDirectory ? `--workdir ${shellQuote(config.workingDirectory)}` : ''].filter(Boolean).join(' ');
const command = ['docker exec', flags, shellQuote(config.container), shellQuote(config.executable), ...config.arguments.map(shellQuote)].filter(Boolean).join(' ');
const result = await exec(client, command, config.timeoutSeconds);
if (result.stdout.trim()) log(result.stdout.trim());
if (result.stderr.trim()) log(result.stderr.trim());
const artifacts: RemoteArtifact[] = [];
for (const [index, output] of config.outputs.entries()) {
const copiedPath = `${remoteDir}/step-${stepIndex}-output-${index}`;
await exec(client, `docker cp ${shellQuote(`${config.container}:${output.path}`)} ${shellQuote(copiedPath)}`, config.timeoutSeconds);
if (output.archive) {
const archiveName = output.name.endsWith('.tar.gz') ? output.name : `${output.name}.tar.gz`;
const archivePath = `${remoteDir}/${archiveName}`;
await exec(client, `tar -czf ${shellQuote(archivePath)} -C ${shellQuote(remoteDir)} -- ${shellQuote(`step-${stepIndex}-output-${index}`)}`, config.timeoutSeconds);
artifacts.push({ remotePath: archivePath, name: archiveName });
} else {
await exec(client, `test -f ${shellQuote(copiedPath)}`, 30);
artifacts.push({ remotePath: copiedPath, name: output.name });
}
}
return artifacts;
}
case 'remoteCommand': {
log(`Running remote command ${config.executable}`);
const prefix = config.workingDirectory ? `cd ${shellQuote(config.workingDirectory)} && ` : '';
const command = `${prefix}${[shellQuote(config.executable), ...config.arguments.map(shellQuote)].join(' ')}`;
const result = await exec(client, command, config.timeoutSeconds);
if (result.stdout.trim()) log(result.stdout.trim());
if (result.stderr.trim()) log(result.stderr.trim());
const artifacts: RemoteArtifact[] = [];
for (const [index, output] of config.outputs.entries()) {
if (output.archive) {
const archiveName = output.name.endsWith('.tar.gz') ? output.name : `${output.name}.tar.gz`;
const archivePath = `${remoteDir}/${archiveName}`;
await exec(client, `tar -czf ${shellQuote(archivePath)} -C ${shellQuote(path.posix.dirname(output.path))} -- ${shellQuote(path.posix.basename(output.path))}`, config.timeoutSeconds);
artifacts.push({ remotePath: archivePath, name: archiveName });
} else {
const copiedPath = `${remoteDir}/step-${stepIndex}-output-${index}`;
await exec(client, `test -f ${shellQuote(output.path)} && cp -- ${shellQuote(output.path)} ${shellQuote(copiedPath)}`, config.timeoutSeconds);
artifacts.push({ remotePath: copiedPath, name: output.name });
}
}
return artifacts;
}
case 'directory': {
log(`Archiving ${config.path}`);
const name = config.outputName.endsWith('.tar.gz') ? config.outputName : `${config.outputName}.tar.gz`;
const target = `${remoteDir}/${name}`;
await exec(client, `tar -czf ${shellQuote(target)} -C ${shellQuote(path.posix.dirname(config.path))} -- ${shellQuote(path.posix.basename(config.path))}`, config.timeoutSeconds);
return [{ remotePath: target, name }];
}
case 'postgres': {
if (!password) throw new Error('PostgreSQL password is missing');
log(`Dumping PostgreSQL database ${config.database}`);
const name = config.outputName.endsWith('.sql.gz') ? config.outputName : `${config.outputName}.sql.gz`;
const target = `${remoteDir}/${name}`;
await this.writeRemoteSecret(client, `${remoteDir}/pgpass`, `${pgPassValue(config.databaseHost)}:${config.databasePort}:${pgPassValue(config.database)}:${pgPassValue(config.username)}:${pgPassValue(password)}\n`);
const raw = `${remoteDir}/dump.sql`;
const command = `PGPASSFILE=${shellQuote(`${remoteDir}/pgpass`)} pg_dump --host=${shellQuote(config.databaseHost)} --port=${shellQuote(String(config.databasePort))} --username=${shellQuote(config.username)} --no-password --dbname=${shellQuote(config.database)} > ${shellQuote(raw)} && gzip -c ${shellQuote(raw)} > ${shellQuote(target)} && rm ${shellQuote(raw)}`;
await exec(client, command, config.timeoutSeconds);
return [{ remotePath: target, name }];
}
case 'mysql': {
if (!password) throw new Error('MySQL password is missing');
log(`Dumping MySQL database ${config.database}`);
const name = config.outputName.endsWith('.sql.gz') ? config.outputName : `${config.outputName}.sql.gz`;
const target = `${remoteDir}/${name}`;
await this.writeRemoteSecret(client, `${remoteDir}/my.cnf`, `[client]\npassword='${mysqlOptionValue(password)}'\n`);
const raw = `${remoteDir}/dump.sql`;
const command = `mysqldump --defaults-extra-file=${shellQuote(`${remoteDir}/my.cnf`)} --host=${shellQuote(config.databaseHost)} --port=${shellQuote(String(config.databasePort))} --user=${shellQuote(config.username)} --databases ${shellQuote(config.database)} > ${shellQuote(raw)} && gzip -c ${shellQuote(raw)} > ${shellQuote(target)} && rm ${shellQuote(raw)}`;
await exec(client, command, config.timeoutSeconds);
return [{ remotePath: target, name }];
}
}
}
private async collectArtifacts(client: Client, job: JobRecord, run: RunRecord, artifacts: RemoteArtifact[]): Promise<void> {
const staging = path.join(this.appConfig.backupDir, '.staging', String(run.id));
const finalDir = path.join(this.appConfig.backupDir, String(job.id), String(run.id));
fs.rmSync(staging, { recursive: true, force: true });
fs.mkdirSync(staging, { recursive: true, mode: 0o700 });
const sftp = await getSftp(client);
const records: Array<{ name: string; relativePath: string; size: number; checksum: string }> = [];
try {
for (const artifact of artifacts) {
this.log(run.id, `Downloading ${artifact.name}`);
const localPath = path.join(staging, artifact.name);
await download(sftp, artifact.remotePath, localPath);
const { size, checksum } = await hashFile(localPath);
records.push({
name: artifact.name,
relativePath: path.relative(this.appConfig.backupDir, path.join(finalDir, artifact.name)),
size,
checksum,
});
}
} finally {
sftp.end();
}
fs.mkdirSync(path.dirname(finalDir), { recursive: true, mode: 0o700 });
fs.renameSync(staging, finalDir);
const insert = this.db.prepare('INSERT INTO artifacts (run_id, name, path, size, checksum) VALUES (?, ?, ?, ?, ?)');
this.db.transaction(() => records.forEach((record) => insert.run(run.id, record.name, record.relativePath, record.size, record.checksum)))();
}
private applyRetention(job: JobRecord): void {
const expired = this.db.prepare("SELECT id FROM runs WHERE job_id = ? AND status IN ('succeeded', 'succeeded_with_warnings') ORDER BY id DESC LIMIT -1 OFFSET ?").all(job.id, job.retention_count) as Array<{ id: number }>;
for (const run of expired) {
fs.rmSync(path.join(this.appConfig.backupDir, String(job.id), String(run.id)), { recursive: true, force: true });
this.db.prepare('DELETE FROM runs WHERE id = ?').run(run.id);
}
}
private async writeRemoteSecret(client: Client, remotePath: string, contents: string): Promise<void> {
const sftp = await getSftp(client);
try { await uploadText(sftp, remotePath, contents); } finally { sftp.end(); }
}
private readStepSecrets(job: JobRecord, config: JobConfig): Record<string, string> {
if (!job.secret) return {};
const decrypted = decryptJson<Record<string, string>>(job.secret, this.appConfig.masterKey);
if (typeof decrypted.password === 'string') {
const databaseStep = config.steps.find((step) => step.type === 'postgres' || step.type === 'mysql');
return databaseStep ? { [databaseStep.id]: decrypted.password } : {};
}
return decrypted;
}
private log(runId: number, message: string): void {
const line = `[${new Date().toISOString()}] ${message}\n`;
this.db.prepare("UPDATE runs SET log = substr(log || ?, -1000000) WHERE id = ?").run(line, runId);
}
private updateRun(runId: number, clause: string, ...values: unknown[]): void {
this.db.prepare(`UPDATE runs SET ${clause} WHERE id = ?`).run(...values, runId);
}
}
async function hashFile(filePath: string): Promise<{ size: number; checksum: string }> {
const hash = createHash('sha256');
let size = 0;
for await (const chunk of fs.createReadStream(filePath)) {
const buffer = chunk as Buffer;
size += buffer.length;
hash.update(buffer);
}
return { size, checksum: hash.digest('hex') };
}
function pgPassValue(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll(':', '\\:').replaceAll('\n', '\\n');
}
function mysqlOptionValue(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r');
}
function redact(value: string, secrets: string[]): string {
return secrets.reduce((result, secret) => secret ? result.replaceAll(secret, '[REDACTED]') : result, value);
}
export async function executeSequentialSteps<T extends { continueOnError: boolean }, R>(steps: T[], executeStep: (step: T, index: number) => Promise<R>, onWarning: (error: unknown) => void): Promise<{ values: R[]; warningCount: number }> {
const values: R[] = [];
let warningCount = 0;
for (const [index, step] of steps.entries()) {
try {
values.push(await executeStep(step, index));
} catch (error) {
if (!step.continueOnError) throw error;
warningCount += 1;
onWarning(error);
}
}
return { values, warningCount };
}

52
src/notifications.ts Normal file
View File

@@ -0,0 +1,52 @@
import type Database from 'better-sqlite3';
import nodemailer from 'nodemailer';
import type { AppConfig } from './config.js';
import { decryptJson } from './crypto.js';
import type { JobRecord, RunRecord } from './db.js';
export interface NotificationSettings {
webhookUrl?: string;
smtp?: {
host: string;
port: number;
secure: boolean;
username?: string;
password?: string;
from: string;
to: string;
};
notifySuccess: boolean;
notifyFailure: boolean;
}
export async function notify(db: Database.Database, config: AppConfig, run: RunRecord): Promise<void> {
const row = db.prepare("SELECT value FROM settings WHERE key = 'notifications'").get() as { value: string } | undefined;
if (!row) return;
const settings = decryptJson<NotificationSettings>(row.value, config.masterKey);
if ((run.status === 'succeeded' || run.status === 'succeeded_with_warnings') && !settings.notifySuccess) return;
if (run.status === 'failed' && !settings.notifyFailure) return;
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(run.job_id) as JobRecord;
const subject = `[Backup Manager] ${job.name}: ${run.status}`;
const text = `${subject}\nRun #${run.id}\nStarted: ${run.started_at ?? 'not started'}\nFinished: ${run.finished_at ?? 'unknown'}\n${run.error ? `Error: ${run.error}` : ''}`;
const requests: Promise<unknown>[] = [];
if (settings.webhookUrl) {
requests.push(fetch(settings.webhookUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ subject, text, runId: run.id, job: job.name, status: run.status }),
signal: AbortSignal.timeout(10_000),
}).then((response) => { if (!response.ok) throw new Error(`Webhook returned HTTP ${response.status}`); }));
}
if (settings.smtp) {
const smtp = settings.smtp;
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
});
requests.push(transporter.sendMail({ from: smtp.from, to: smtp.to, subject, text }));
}
await Promise.allSettled(requests);
}

62
src/schemas.test.ts Normal file
View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { hostInputSchema, jobInputSchema, normalizeJobConfig } from './schemas.js';
describe('host validation', () => {
it('requires the selected authentication credential', () => {
const result = hostInputSchema.safeParse({
name: 'node', hostname: 'node.local', port: 22, username: 'backup',
fingerprint: `SHA256:${'A'.repeat(43)}`, authType: 'privateKey',
});
expect(result.success).toBe(false);
});
});
describe('job validation', () => {
const dockerStep = {
id: 'docker-1', name: 'Generate export', continueOnError: false,
type: 'dockerCommand', container: 'app', executable: '/app/backup', arguments: [], timeoutSeconds: 300,
outputs: [{ path: '/tmp/export', name: 'export', archive: true }],
} as const;
const dockerJob = {
name: 'container backup', hostId: 1, timezone: 'UTC', enabled: true, retentionCount: 10,
config: { steps: [dockerStep] }, stepSecrets: {},
};
it('accepts ordered mixed steps', () => {
const input = { ...dockerJob, config: { steps: [
{ id: 'prepare', name: 'Prepare', continueOnError: true, type: 'remoteCommand', executable: '/bin/prepare', arguments: [], timeoutSeconds: 30, outputs: [] },
dockerStep,
] } };
expect(jobInputSchema.parse(input).config.steps).toHaveLength(2);
});
it('rejects invalid cron expressions', () => {
expect(jobInputSchema.safeParse({ ...dockerJob, schedule: 'not a cron' }).success).toBe(false);
});
it('rejects Docker outputs that resolve to the same artifact name', () => {
const result = jobInputSchema.safeParse({
...dockerJob,
config: { steps: [dockerStep, { id: 'directory-1', name: 'Archive', continueOnError: false, type: 'directory', path: '/srv/data', outputName: 'export.tar.gz', timeoutSeconds: 300 }] },
});
expect(result.success).toBe(false);
});
it('requires database passwords', () => {
const result = jobInputSchema.safeParse({
...dockerJob,
config: { steps: [{ id: 'database-1', name: 'Database', continueOnError: false, type: 'postgres', database: 'app', username: 'postgres', databaseHost: 'localhost', databasePort: 5432, outputName: 'app', timeoutSeconds: 300 }] },
});
expect(result.success).toBe(false);
});
it('rejects jobs with only commands that produce no artifacts', () => {
const result = jobInputSchema.safeParse({ ...dockerJob, config: { steps: [{ ...dockerStep, outputs: [] }] } });
expect(result.success).toBe(false);
});
it('normalizes persisted single-operation jobs', () => {
const legacy = { type: 'directory', path: '/srv/data', outputName: 'data', timeoutSeconds: 300 };
expect(normalizeJobConfig(legacy, '42').steps[0]).toMatchObject({ id: 'legacy-42', type: 'directory' });
});
});

149
src/schemas.ts Normal file
View File

@@ -0,0 +1,149 @@
import { CronExpressionParser } from 'cron-parser';
import { z } from 'zod';
const safeName = z.string().min(1).max(100).regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, 'Start with a letter or number; then use letters, numbers, dot, dash, or underscore');
const commandValue = z.string().min(1).max(253).refine((value) => !value.startsWith('-'), 'Must not begin with a dash');
const remoteAbsolutePath = z.string().startsWith('/').max(1000).refine((value) => !value.includes('\0'), 'Invalid path');
export const hostInputSchema = 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).default(22),
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(),
}).superRefine((value, context) => {
if (value.authType === 'password' && !value.password) context.addIssue({ code: 'custom', path: ['password'], message: 'Password is required' });
if (value.authType === 'privateKey' && !value.privateKey) context.addIssue({ code: 'custom', path: ['privateKey'], message: 'Private key is required' });
});
const outputSchema = z.object({
path: remoteAbsolutePath,
name: safeName,
archive: z.boolean().default(true),
});
const stepFields = {
id: z.string().min(1).max(100).regex(/^[a-zA-Z0-9_-]+$/, 'Use letters, numbers, dash, or underscore'),
name: z.string().trim().min(1).max(100),
continueOnError: z.boolean().default(false),
};
export const dockerStepSchema = z.object({
...stepFields,
type: z.literal('dockerCommand'),
container: safeName,
executable: z.string().min(1).max(500),
arguments: z.array(z.string().max(2000)).max(100).default([]),
user: z.string().max(100).optional(),
workingDirectory: remoteAbsolutePath.optional(),
timeoutSeconds: z.number().int().min(1).max(86400).default(3600),
outputs: z.array(outputSchema).max(20).default([]),
});
export const remoteCommandStepSchema = z.object({
...stepFields,
type: z.literal('remoteCommand'),
executable: z.string().min(1).max(500),
arguments: z.array(z.string().max(2000)).max(100).default([]),
workingDirectory: remoteAbsolutePath.optional(),
timeoutSeconds: z.number().int().min(1).max(86400).default(3600),
outputs: z.array(outputSchema).max(20).default([]),
});
export const directoryStepSchema = z.object({
...stepFields,
type: z.literal('directory'),
path: remoteAbsolutePath,
outputName: safeName,
timeoutSeconds: z.number().int().min(1).max(86400).default(3600),
});
export const postgresStepSchema = z.object({
...stepFields,
type: z.literal('postgres'),
database: commandValue,
username: commandValue,
databaseHost: commandValue.default('localhost'),
databasePort: z.number().int().min(1).max(65535).default(5432),
outputName: safeName,
timeoutSeconds: z.number().int().min(1).max(86400).default(3600),
});
export const mysqlStepSchema = z.object({
...stepFields,
type: z.literal('mysql'),
database: commandValue,
username: commandValue,
databaseHost: commandValue.default('localhost'),
databasePort: z.number().int().min(1).max(65535).default(3306),
outputName: safeName,
timeoutSeconds: z.number().int().min(1).max(86400).default(3600),
});
export const jobStepSchema = z.discriminatedUnion('type', [dockerStepSchema, remoteCommandStepSchema, directoryStepSchema, postgresStepSchema, mysqlStepSchema]);
export type JobStep = z.infer<typeof jobStepSchema>;
export const jobConfigSchema = z.object({
steps: z.array(jobStepSchema).min(1).max(50),
}).superRefine((value, context) => {
const ids = value.steps.map((step) => step.id);
if (new Set(ids).size !== ids.length) context.addIssue({ code: 'custom', path: ['steps'], message: 'Step IDs must be unique' });
const artifactNames = value.steps.flatMap(finalArtifactNames);
if (new Set(artifactNames).size !== artifactNames.length) context.addIssue({ code: 'custom', path: ['steps'], message: 'Artifact names must be unique across the job' });
if (artifactNames.length === 0) context.addIssue({ code: 'custom', path: ['steps'], message: 'At least one step must produce an artifact' });
});
export type JobConfig = z.infer<typeof jobConfigSchema>;
const legacyDockerSchema = dockerStepSchema.omit({ id: true, name: true, continueOnError: true }).extend({ outputs: z.array(outputSchema).min(1).max(20) });
const legacyJobSchema = z.discriminatedUnion('type', [
legacyDockerSchema,
directoryStepSchema.omit({ id: true, name: true, continueOnError: true }),
postgresStepSchema.omit({ id: true, name: true, continueOnError: true }),
mysqlStepSchema.omit({ id: true, name: true, continueOnError: true }),
]);
export function normalizeJobConfig(value: unknown, jobId = 'job'): JobConfig {
const current = jobConfigSchema.safeParse(value);
if (current.success) return current.data;
const legacy = legacyJobSchema.parse(value);
return jobConfigSchema.parse({ steps: [{ ...legacy, id: `legacy-${jobId}`, name: defaultStepName(legacy.type), continueOnError: false }] });
}
export const jobInputSchema = z.object({
name: z.string().trim().min(1).max(100),
hostId: z.number().int().positive(),
config: jobConfigSchema,
stepSecrets: z.record(z.string(), z.string()).default({}),
schedule: z.string().trim().optional(),
timezone: z.string().trim().default('UTC'),
enabled: z.boolean().default(true),
retentionCount: z.number().int().min(1).max(1000).default(10),
}).superRefine((value, context) => {
for (const [index, step] of value.config.steps.entries()) {
if ((step.type === 'postgres' || step.type === 'mysql') && !value.stepSecrets[step.id]) context.addIssue({ code: 'custom', path: ['stepSecrets', step.id], message: `Password is required for step ${index + 1}` });
}
if (value.schedule) {
try {
CronExpressionParser.parse(value.schedule, { tz: value.timezone });
} catch {
context.addIssue({ code: 'custom', path: ['schedule'], message: 'Invalid cron expression or timezone' });
}
}
});
export type HostInput = z.infer<typeof hostInputSchema>;
export type JobInput = z.infer<typeof jobInputSchema>;
function finalArtifactNames(step: JobStep): string[] {
if (step.type === 'directory') return [step.outputName.endsWith('.tar.gz') ? step.outputName : `${step.outputName}.tar.gz`];
if (step.type === 'postgres' || step.type === 'mysql') return [step.outputName.endsWith('.sql.gz') ? step.outputName : `${step.outputName}.sql.gz`];
return step.outputs.map((output) => output.archive && !output.name.endsWith('.tar.gz') ? `${output.name}.tar.gz` : output.name);
}
function defaultStepName(type: string): string {
return { dockerCommand: 'Docker command', directory: 'Directory archive', postgres: 'PostgreSQL dump', mysql: 'MySQL dump' }[type] ?? 'Backup step';
}

206
src/server.ts Normal file
View File

@@ -0,0 +1,206 @@
import fs from 'node:fs';
import path from 'node:path';
import cookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import Fastify from 'fastify';
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 { type NotificationSettings } from './notifications.js';
import { hostInputSchema, jobInputSchema, normalizeJobConfig } from './schemas.js';
import { BackupService, nextRun } from './service.js';
import { connect, probeFingerprint, type HostSecret } from './ssh.js';
const config = loadConfig();
fs.mkdirSync(config.backupDir, { recursive: true, mode: 0o700 });
const db = openDatabase(config.dataDir);
const service = new BackupService(db, config);
const app = Fastify({ logger: true, bodyLimit: 2 * 1024 * 1024 });
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
await app.register(cookie);
app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/') || request.url === '/api/login' || request.url === '/api/session') return;
if (!verifySession(request.cookies.session, config.masterKey)) return reply.code(401).send({ error: 'Authentication required' });
});
app.post('/api/login', async (request, reply) => {
const now = Date.now();
if (loginAttempts.size > 1_000) {
for (const [ip, entry] of loginAttempts) if (entry.resetAt <= now) loginAttempts.delete(ip);
if (loginAttempts.size > 1_000) loginAttempts.delete(loginAttempts.keys().next().value!);
}
const attempts = loginAttempts.get(request.ip);
if (attempts && attempts.resetAt > now && attempts.count >= 5) return reply.code(429).send({ error: 'Too many login attempts; retry in one minute' });
if (attempts?.resetAt && attempts.resetAt <= now) loginAttempts.delete(request.ip);
const body = z.object({ password: z.string() }).parse(request.body);
if (!secureEqual(body.password, config.adminPassword)) {
const current = loginAttempts.get(request.ip);
loginAttempts.set(request.ip, { count: (current?.count ?? 0) + 1, resetAt: current?.resetAt ?? now + 60_000 });
return reply.code(401).send({ error: 'Invalid password' });
}
loginAttempts.delete(request.ip);
reply.setCookie('session', createSession(config.masterKey), {
httpOnly: true,
sameSite: 'strict',
secure: config.secureCookie,
path: '/',
maxAge: 24 * 60 * 60,
});
return { authenticated: true };
});
app.post('/api/logout', async (_request, reply) => {
reply.clearCookie('session', { path: '/' });
return { authenticated: false };
});
app.get('/api/session', async (request) => ({ authenticated: verifySession(request.cookies.session, config.masterKey) }));
app.post('/api/hosts/probe', async (request) => {
const body = z.object({ hostname: z.string().min(1), port: z.number().int().min(1).max(65535).default(22) }).parse(request.body);
return { fingerprint: await probeFingerprint(body.hostname, body.port) };
});
app.get('/api/hosts', async () => db.prepare('SELECT id, name, hostname, port, username, fingerprint, auth_type AS authType, created_at AS createdAt FROM hosts ORDER BY name').all());
app.post('/api/hosts', async (request, reply) => {
const input = hostInputSchema.parse(request.body);
const secret: HostSecret = input.authType === 'password'
? { password: input.password }
: { privateKey: input.privateKey, passphrase: input.passphrase };
const result = db.prepare('INSERT INTO hosts (name, hostname, port, username, fingerprint, auth_type, secret) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(input.name, input.hostname, input.port, input.username, input.fingerprint, input.authType, encryptJson(secret, config.masterKey));
return reply.code(201).send({ id: Number(result.lastInsertRowid) });
});
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;
if (!host) return reply.code(404).send({ error: 'Host not found' });
const client = await connect(host, decryptJson<HostSecret>(host.secret, config.masterKey));
client.end();
return { connected: true };
});
app.delete('/api/hosts/:id', async (request, reply) => {
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(request.params);
const result = db.prepare('DELETE FROM hosts WHERE id = ?').run(id);
if (!result.changes) return reply.code(404).send({ error: 'Host not found' });
return reply.code(204).send();
});
app.get('/api/jobs', async () => {
const rows = db.prepare('SELECT jobs.*, hosts.name AS host_name FROM jobs JOIN hosts ON hosts.id = jobs.host_id ORDER BY jobs.name').all() as Array<JobRecord & { host_name: string }>;
return rows.map((row) => ({
id: row.id, name: row.name, hostId: row.host_id, hostName: row.host_name, config: normalizeJobConfig(JSON.parse(row.config), String(row.id)),
schedule: row.schedule, timezone: row.timezone, enabled: Boolean(row.enabled), retentionCount: row.retention_count,
nextRunAt: row.next_run_at, hasDatabasePassword: Boolean(row.secret), createdAt: row.created_at,
}));
});
app.post('/api/jobs', async (request, reply) => {
const input = jobInputSchema.parse(request.body);
const secret = Object.keys(input.stepSecrets).length ? encryptJson(input.stepSecrets, config.masterKey) : null;
const result = db.prepare('INSERT INTO jobs (name, host_id, config, secret, schedule, timezone, enabled, retention_count, next_run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run(input.name, input.hostId, JSON.stringify(input.config), secret, input.schedule || null, input.timezone, Number(input.enabled), input.retentionCount, nextRun(input.schedule || null, input.timezone));
return reply.code(201).send({ id: Number(result.lastInsertRowid) });
});
app.post('/api/jobs/:id/run', async (request, reply) => {
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(request.params);
if (!db.prepare('SELECT id FROM jobs WHERE id = ?').get(id)) return reply.code(404).send({ error: 'Job not found' });
try {
return reply.code(202).send({ runId: service.enqueue(id, 'manual') });
} catch (error) {
return reply.code(409).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.delete('/api/jobs/:id', async (request, reply) => {
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(request.params);
const active = db.prepare("SELECT id FROM runs WHERE job_id = ? AND status IN ('queued', 'running')").get(id);
if (active) return reply.code(409).send({ error: 'Cannot delete an active job' });
const result = db.prepare('DELETE FROM jobs WHERE id = ?').run(id);
if (!result.changes) return reply.code(404).send({ error: 'Job not found' });
fs.rmSync(path.join(config.backupDir, String(id)), { recursive: true, force: true });
return reply.code(204).send();
});
app.get('/api/runs', async () => db.prepare(`
SELECT runs.id, runs.job_id AS jobId, jobs.name AS jobName, runs.status, runs.trigger,
runs.started_at AS startedAt, runs.finished_at AS finishedAt, runs.error, runs.created_at AS createdAt,
(SELECT COUNT(*) FROM artifacts WHERE artifacts.run_id = runs.id) AS artifactCount
FROM runs JOIN jobs ON jobs.id = runs.job_id ORDER BY runs.id DESC LIMIT 100
`).all());
app.get('/api/runs/:id', async (request, reply) => {
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(request.params);
const run = db.prepare('SELECT * FROM runs WHERE id = ?').get(id);
if (!run) return reply.code(404).send({ error: 'Run not found' });
const artifacts = db.prepare('SELECT id, name, size, checksum, created_at AS createdAt FROM artifacts WHERE run_id = ?').all(id);
return { run, artifacts };
});
app.get('/api/artifacts/:id/download', async (request, reply) => {
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(request.params);
const artifact = db.prepare('SELECT * FROM artifacts WHERE id = ?').get(id) as { path: string; name: string } | undefined;
if (!artifact) return reply.code(404).send({ error: 'Artifact not found' });
const filePath = path.resolve(config.backupDir, artifact.path);
if (!filePath.startsWith(`${config.backupDir}${path.sep}`)) return reply.code(400).send({ error: 'Invalid artifact path' });
return reply.header('Content-Disposition', `attachment; filename="${artifact.name.replaceAll('"', '')}"`).send(fs.createReadStream(filePath));
});
const notificationSchema = z.object({
webhookUrl: z.url().optional().or(z.literal('')),
notifySuccess: z.boolean().default(false),
notifyFailure: z.boolean().default(true),
smtp: z.object({
host: z.string().min(1), port: z.number().int().min(1).max(65535), secure: z.boolean(),
username: z.string().optional(), password: z.string().optional(), from: z.email(), to: z.email(),
}).optional(),
});
app.get('/api/settings/notifications', async () => {
const row = db.prepare("SELECT value FROM settings WHERE key = 'notifications'").get() as { value: string } | undefined;
if (!row) return { notifySuccess: false, notifyFailure: true };
const settings = decryptJson<NotificationSettings>(row.value, config.masterKey);
return { ...settings, smtp: settings.smtp ? { ...settings.smtp, password: settings.smtp.password ? '********' : undefined } : undefined };
});
app.put('/api/settings/notifications', async (request) => {
const input = notificationSchema.parse(request.body);
const settings: NotificationSettings = { ...input, webhookUrl: input.webhookUrl || undefined };
if (settings.smtp?.password === '********') {
const row = db.prepare("SELECT value FROM settings WHERE key = 'notifications'").get() as { value: string } | undefined;
const existing = row ? decryptJson<NotificationSettings>(row.value, config.masterKey) : undefined;
settings.smtp.password = existing?.smtp?.password;
}
db.prepare("INSERT INTO settings (key, value) VALUES ('notifications', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run(encryptJson(settings, config.masterKey));
return { saved: true };
});
app.setErrorHandler((error: Error & { code?: string; statusCode?: number }, _request, reply) => {
if (error instanceof z.ZodError) return reply.code(400).send({ error: 'Validation failed', details: z.flattenError(error).fieldErrors });
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') return reply.code(409).send({ error: 'A record with that name already exists' });
if (error.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') return reply.code(409).send({ error: 'This record is still in use' });
app.log.error(error);
return reply.code(typeof error.statusCode === 'number' ? error.statusCode : 500).send({ error: error.message });
});
const publicDir = path.resolve('dist/public');
if (fs.existsSync(publicDir)) {
await app.register(fastifyStatic, { root: publicDir, wildcard: false });
app.get('/*', async (_request, reply) => reply.sendFile('index.html'));
}
app.addHook('onClose', async () => {
service.stop();
db.close();
});
await app.listen({ port: config.port, host: '0.0.0.0' });
service.start();

13
src/service.test.ts Normal file
View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { nextRun } from './service.js';
describe('scheduling', () => {
it('calculates cron times in the configured timezone', () => {
const result = nextRun('0 2 * * *', 'Europe/Paris', new Date('2026-01-01T00:00:00Z'));
expect(result).toBe('2026-01-01T01:00:00.000Z');
});
it('returns null for manual-only jobs', () => {
expect(nextRun(null, 'UTC')).toBeNull();
});
});

68
src/service.ts Normal file
View File

@@ -0,0 +1,68 @@
import type Database from 'better-sqlite3';
import { CronExpressionParser } from 'cron-parser';
import type { AppConfig } from './config.js';
import type { JobRecord, RunRecord } from './db.js';
import { BackupExecutor } from './executor.js';
import { notify } from './notifications.js';
export function nextRun(schedule: string | null, timezone: string, currentDate = new Date()): string | null {
if (!schedule) return null;
return CronExpressionParser.parse(schedule, { currentDate, tz: timezone }).next().toISOString();
}
export class BackupService {
private executor: BackupExecutor;
private workerTimer?: NodeJS.Timeout;
private schedulerTimer?: NodeJS.Timeout;
private working = false;
constructor(private db: Database.Database, private config: AppConfig) {
this.executor = new BackupExecutor(db, config);
}
start(): void {
this.workerTimer = setInterval(() => void this.work(), 1_000);
this.schedulerTimer = setInterval(() => this.scheduleDueJobs(), 15_000);
this.scheduleDueJobs();
void this.work();
}
stop(): void {
if (this.workerTimer) clearInterval(this.workerTimer);
if (this.schedulerTimer) clearInterval(this.schedulerTimer);
}
enqueue(jobId: number, trigger: 'manual' | 'scheduled'): number {
const active = this.db.prepare("SELECT id FROM runs WHERE job_id = ? AND status IN ('queued', 'running')").get(jobId);
if (active) throw new Error('This job is already queued or running');
return Number(this.db.prepare("INSERT INTO runs (job_id, status, trigger) VALUES (?, 'queued', ?)").run(jobId, trigger).lastInsertRowid);
}
private scheduleDueJobs(): void {
const due = this.db.prepare("SELECT * FROM jobs WHERE enabled = 1 AND schedule IS NOT NULL AND next_run_at <= ?").all(new Date().toISOString()) as JobRecord[];
for (const job of due) {
const following = nextRun(job.schedule, job.timezone);
this.db.transaction(() => {
const active = this.db.prepare("SELECT id FROM runs WHERE job_id = ? AND status IN ('queued', 'running')").get(job.id);
if (!active) this.db.prepare("INSERT INTO runs (job_id, status, trigger) VALUES (?, 'queued', 'scheduled')").run(job.id);
this.db.prepare('UPDATE jobs SET next_run_at = ? WHERE id = ?').run(following, job.id);
})();
}
}
private async work(): Promise<void> {
if (this.working) return;
const run = this.db.prepare("SELECT * FROM runs WHERE status = 'queued' ORDER BY id LIMIT 1").get() as RunRecord | undefined;
if (!run) return;
this.working = true;
try {
await this.executor.execute(run);
} catch {
// The executor persists the failure and its redacted error.
} finally {
const completed = this.db.prepare('SELECT * FROM runs WHERE id = ?').get(run.id) as RunRecord;
await notify(this.db, this.config, completed).catch(() => undefined);
this.working = false;
}
}
}

12
src/ssh.test.ts Normal file
View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { fingerprint, shellQuote } from './ssh.js';
describe('SSH helpers', () => {
it('quotes shell metacharacters as one argument', () => {
expect(shellQuote("a'; rm -rf / #")).toBe("'a'\"'\"'; rm -rf / #'");
});
it('formats SHA256 host fingerprints', () => {
expect(fingerprint(Buffer.from('host-key'))).toMatch(/^SHA256:[A-Za-z0-9+/]{43}$/);
});
});

117
src/ssh.ts Normal file
View File

@@ -0,0 +1,117 @@
import { createHash } from 'node:crypto';
import { Client, type ConnectConfig, type SFTPWrapper } from 'ssh2';
import type { HostRecord } from './db.js';
export interface HostSecret {
password?: string;
privateKey?: string;
passphrase?: string;
}
export function fingerprint(key: Buffer): string {
return `SHA256:${createHash('sha256').update(key).digest('base64').replace(/=$/, '')}`;
}
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
export function connect(host: HostRecord, secret: HostSecret): Promise<Client> {
return new Promise((resolve, reject) => {
const client = new Client();
const config: ConnectConfig = {
host: host.hostname,
port: host.port,
username: host.username,
readyTimeout: 15_000,
keepaliveInterval: 10_000,
hostVerifier: (key: Buffer) => fingerprint(key) === host.fingerprint,
};
if (host.auth_type === 'password') config.password = secret.password;
else {
config.privateKey = secret.privateKey;
config.passphrase = secret.passphrase || undefined;
}
client.once('ready', () => resolve(client));
client.once('error', reject);
client.connect(config);
});
}
export function probeFingerprint(hostname: string, port: number): Promise<string> {
return new Promise((resolve, reject) => {
const client = new Client();
let settled = false;
const timer = setTimeout(() => {
if (!settled) reject(new Error('SSH fingerprint probe timed out'));
client.end();
}, 15_000);
client.once('error', (error) => {
clearTimeout(timer);
if (!settled) reject(error);
});
client.connect({
host: hostname,
port,
username: 'fingerprint-probe',
readyTimeout: 15_000,
hostVerifier: (key: Buffer) => {
settled = true;
clearTimeout(timer);
resolve(fingerprint(key));
setImmediate(() => client.end());
return false;
},
});
});
}
export interface CommandResult {
stdout: string;
stderr: string;
}
export function exec(client: Client, command: string, timeoutSeconds: number): Promise<CommandResult> {
return new Promise((resolve, reject) => {
client.exec(command, (error, stream) => {
if (error) return reject(error);
let stdout = '';
let stderr = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
stream.close();
}, timeoutSeconds * 1000);
stream.on('data', (chunk: Buffer) => { stdout = boundedAppend(stdout, chunk.toString()); });
stream.stderr.on('data', (chunk: Buffer) => { stderr = boundedAppend(stderr, chunk.toString()); });
stream.once('close', (code: number | null) => {
clearTimeout(timer);
if (timedOut) return reject(new Error(`Remote command timed out after ${timeoutSeconds}s`));
if (code !== 0) return reject(new Error(`Remote command exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
resolve({ stdout, stderr });
});
});
});
}
function boundedAppend(current: string, addition: string): string {
const next = current + addition;
return next.length <= 1_000_000 ? next : `${next.slice(0, 1_000_000)}\n[output truncated]`;
}
export function getSftp(client: Client): Promise<SFTPWrapper> {
return new Promise((resolve, reject) => client.sftp((error, sftp) => error ? reject(error) : resolve(sftp)));
}
export function download(sftp: SFTPWrapper, remotePath: string, localPath: string): Promise<void> {
return new Promise((resolve, reject) => sftp.fastGet(remotePath, localPath, (error) => error ? reject(error) : resolve()));
}
export function uploadText(sftp: SFTPWrapper, remotePath: string, contents: string): Promise<void> {
return new Promise((resolve, reject) => {
const stream = sftp.createWriteStream(remotePath, { mode: 0o600, encoding: 'utf8' });
stream.once('error', reject);
stream.once('close', resolve);
stream.end(contents);
});
}

16
tsconfig.server.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist/server",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}

13
tsconfig.web.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["web/**/*"]
}

15
vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
root: 'web',
build: {
outDir: '../dist/public',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: { '/api': 'http://localhost:3000' },
},
});

9
vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
root: '.',
environment: 'node',
include: ['src/**/*.test.ts'],
},
});

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#17211c" />
<title>Backup Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

265
web/src/main.tsx Normal file
View File

@@ -0,0 +1,265 @@
import { FormEvent, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
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 StepType = 'dockerCommand' | 'remoteCommand' | 'directory' | 'postgres' | 'mysql';
type StepDraft = {
id: string; name: string; type: StepType; continueOnError: boolean; timeoutSeconds: string;
executable: string; arguments: string; workingDirectory: string; container: string; dockerUser: string;
collectOutput: boolean; outputPath: string; outputName: string; archive: boolean; directoryPath: string;
database: string; databaseUsername: string; databaseHost: string; databasePort: string; databasePassword: string;
};
type Job = { id: number; name: string; hostName: string; config: { steps: Array<{ type: string }> }; schedule?: string; nextRunAt?: string; retentionCount: number };
type Run = { id: number; jobName: string; status: string; trigger: string; createdAt: string; startedAt?: string; finishedAt?: string; error?: string; artifactCount: number };
type RunDetail = { run: { id: number; status: string; log: string; error?: string }; artifacts: Array<{ id: number; name: string; size: number; checksum: string }> };
async function api<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
headers: { 'content-type': 'application/json', ...options?.headers },
});
if (response.status === 204) return undefined as T;
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Request failed');
return body as T;
}
function Login({ onLogin }: { onLogin: () => void }) {
const [error, setError] = useState('');
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError('');
const password = String(new FormData(event.currentTarget).get('password'));
try {
await api('/api/login', { method: 'POST', body: JSON.stringify({ password }) });
onLogin();
} catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)); }
}
return <main className="login-shell">
<section className="login-card">
<div className="mark">BM</div>
<p className="eyebrow">HOME LAB OPERATIONS</p>
<h1>Backup Manager</h1>
<p className="muted">Secure remote jobs. Local, verifiable artifacts.</p>
<form onSubmit={submit}>
<label>Administrator password<input name="password" type="password" autoFocus required /></label>
{error && <p className="error">{error}</p>}
<button type="submit">Unlock console</button>
</form>
</section>
</main>;
}
function App() {
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [tab, setTab] = useState<Tab>('overview');
useEffect(() => { void api<{ authenticated: boolean }>('/api/session').then((value) => setAuthenticated(value.authenticated)); }, []);
if (authenticated === null) return <div className="loading">Starting console...</div>;
if (!authenticated) return <Login onLogin={() => setAuthenticated(true)} />;
return <div className="app-shell">
<aside>
<div className="brand"><span className="mark small">BM</span><div><strong>Backup</strong><small>MANAGER</small></div></div>
<nav>
{(['overview', 'hosts', 'jobs', 'settings'] as Tab[]).map((item) => <button className={tab === item ? 'active' : ''} onClick={() => setTab(item)} key={item}><span>{item === 'overview' ? '01' : item === 'hosts' ? '02' : item === 'jobs' ? '03' : '04'}</span>{item}</button>)}
</nav>
<button className="logout" onClick={() => void api('/api/logout', { method: 'POST' }).then(() => setAuthenticated(false))}>Lock console</button>
</aside>
<main className="content">
{tab === 'overview' && <Overview />}
{tab === 'hosts' && <Hosts />}
{tab === 'jobs' && <Jobs />}
{tab === 'settings' && <Settings />}
</main>
</div>;
}
function PageTitle({ eyebrow, title, detail }: { eyebrow: string; title: string; detail: string }) {
return <header className="page-title"><p className="eyebrow">{eyebrow}</p><h1>{title}</h1><p>{detail}</p></header>;
}
function Overview() {
const [runs, setRuns] = useState<Run[]>([]);
const [detail, setDetail] = useState<RunDetail>();
async function load() { setRuns(await api<Run[]>('/api/runs')); }
useEffect(() => {
void load();
const timer = setInterval(() => void load(), 5_000);
return () => clearInterval(timer);
}, []);
const active = runs.filter((run) => run.status === 'running' || run.status === 'queued').length;
const failed = runs.filter((run) => run.status === 'failed').length;
const successful = runs.filter((run) => run.status === 'succeeded' || run.status === 'succeeded_with_warnings').length;
return <>
<PageTitle eyebrow="OPERATIONS / LIVE" title="Run overview" detail="Recent backup activity across every remote system." />
<section className="metrics">
<Metric label="Active" value={active} accent />
<Metric label="Successful" value={successful} />
<Metric label="Failed" value={failed} warning={failed > 0} />
<Metric label="Artifacts" value={runs.reduce((sum, run) => sum + run.artifactCount, 0)} />
</section>
<section className="panel"><div className="panel-head"><h2>Run history</h2><span>latest 100</span></div>
<div className="table-wrap"><table><thead><tr><th>Run</th><th>Job</th><th>Trigger</th><th>Status</th><th>Started</th><th /></tr></thead>
<tbody>{runs.map((run) => <tr key={run.id}><td className="mono">#{run.id}</td><td>{run.jobName}</td><td>{run.trigger}</td><td><Status value={run.status} /></td><td>{formatDate(run.startedAt ?? run.createdAt)}</td><td><button className="text-button" onClick={() => void api<RunDetail>(`/api/runs/${run.id}`).then(setDetail)}>inspect</button></td></tr>)}</tbody>
</table>{runs.length === 0 && <Empty text="No runs yet. Create a job and trigger the first backup." />}</div>
</section>
{detail && <div className="modal-backdrop" onMouseDown={() => setDetail(undefined)}><section className="modal" onMouseDown={(event) => event.stopPropagation()}><div className="panel-head"><div><p className="eyebrow">RUN #{detail.run.id}</p><h2>Execution detail</h2></div><button className="close" onClick={() => setDetail(undefined)}>Close</button></div><Status value={detail.run.status} />{detail.run.error && <p className="error">{detail.run.error}</p>}<pre>{detail.run.log || 'Waiting for worker...'}</pre><h3>Artifacts</h3>{detail.artifacts.map((artifact) => <a className="artifact" href={`/api/artifacts/${artifact.id}/download`} key={artifact.id}><span>{artifact.name}<small>{artifact.checksum}</small></span><strong>{formatBytes(artifact.size)} </strong></a>)}</section></div>}
</>;
}
function Metric({ label, value, accent, warning }: { label: string; value: number; accent?: boolean; warning?: boolean }) {
return <div className={`metric ${accent ? 'accent' : ''} ${warning ? 'warning' : ''}`}><span>{label}</span><strong>{String(value).padStart(2, '0')}</strong></div>;
}
function Status({ value }: { value: string }) { return <span className={`status ${value}`}>{value}<i /></span>; }
function Hosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [authType, setAuthType] = useState('password');
const [message, setMessage] = useState('');
async function load() { setHosts(await api<Host[]>('/api/hosts')); }
useEffect(() => { void load(); }, []);
async function probe(form: HTMLFormElement) {
const data = new FormData(form);
setMessage('Reading remote fingerprint...');
try {
const result = await api<{ fingerprint: string }>('/api/hosts/probe', { method: 'POST', body: JSON.stringify({ hostname: data.get('hostname'), port: Number(data.get('port')) }) });
const input = form.elements.namedItem('fingerprint') as HTMLInputElement;
input.value = result.fingerprint;
setMessage('Fingerprint received. Verify it against the server before saving.');
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
try {
await api('/api/hosts', { method: '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();
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
}
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="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>
</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>
</>;
}
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 [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(); }, []);
function updateStep(id: string, values: Partial<StepDraft>) { setSteps((current) => current.map((step) => step.id === id ? { ...step, ...values } : step)); }
function moveStep(index: number, direction: number) {
const target = index + direction;
if (target < 0 || target >= steps.length) return;
setSteps((current) => { const next = [...current]; [next[index], next[target]] = [next[target]!, next[index]!]; return next; });
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const stepSecrets: Record<string, string> = {};
const configuredSteps = steps.map((step) => {
const base = { id: step.id, name: step.name, type: step.type, continueOnError: step.continueOnError, timeoutSeconds: Number(step.timeoutSeconds) };
const outputs = step.collectOutput ? [{ path: step.outputPath, name: step.outputName, archive: step.archive }] : [];
if (step.type === 'dockerCommand') return { ...base, container: step.container, executable: step.executable, arguments: parseArguments(step.arguments), user: step.dockerUser || undefined, workingDirectory: step.workingDirectory || undefined, outputs };
if (step.type === 'remoteCommand') return { ...base, executable: step.executable, arguments: parseArguments(step.arguments), workingDirectory: step.workingDirectory || undefined, outputs };
if (step.type === 'directory') return { ...base, path: step.directoryPath, outputName: step.outputName };
if (step.databasePassword) stepSecrets[step.id] = step.databasePassword;
return { ...base, database: step.database, username: step.databaseUsername, databaseHost: step.databaseHost, databasePort: Number(step.databasePort), outputName: step.outputName };
});
try {
await api('/api/jobs', { method: 'POST', body: JSON.stringify({
name: data.get('name'), hostId: Number(data.get('hostId')), config: { steps: configuredSteps }, stepSecrets,
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();
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
}
return <>
<PageTitle eyebrow="AUTOMATION / JOBS" title="Backup definitions" detail="Ordered remote operations with explicit outputs and failure policy." />
<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="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>
</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>
</>;
}
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>
{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></>}</>}
{step.type === 'directory' && <div className="field-row"><label>Absolute remote path<input value={step.directoryPath} onChange={(event) => set({ directoryPath: event.target.value })} required placeholder="/srv/documents" /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="documents" /></label></div>}
{(step.type === 'postgres' || step.type === 'mysql') && <><div className="field-row"><label>Database host<input value={step.databaseHost} onChange={(event) => set({ databaseHost: event.target.value })} required /></label><label>Port<input value={step.databasePort} onChange={(event) => set({ databasePort: event.target.value })} type="number" required /></label></div><div className="field-row"><label>Database<input value={step.database} onChange={(event) => set({ database: event.target.value })} required /></label><label>Username<input value={step.databaseUsername} onChange={(event) => set({ databaseUsername: event.target.value })} required /></label></div><div className="field-row"><label>Password<input value={step.databasePassword} onChange={(event) => set({ databasePassword: event.target.value })} type="password" required /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="database" /></label></div></>}
<div className="field-row step-policy"><label>Timeout (seconds)<input value={step.timeoutSeconds} onChange={(event) => set({ timeoutSeconds: event.target.value })} type="number" min="1" required /></label><label className="check"><input type="checkbox" checked={step.continueOnError} onChange={(event) => set({ continueOnError: event.target.checked })} /> Continue if this step fails</label></div>
</fieldset>;
}
function createStep(type: StepType): StepDraft {
const id = crypto.randomUUID();
return { id, type, name: stepTypeLabel(type), continueOnError: false, timeoutSeconds: '3600', executable: '', arguments: '', workingDirectory: '', container: '', dockerUser: '', collectOutput: type === 'dockerCommand', outputPath: '', outputName: '', archive: true, directoryPath: '', database: '', databaseUsername: '', databaseHost: 'localhost', databasePort: type === 'mysql' ? '3306' : '5432', databasePassword: '' };
}
function stepTypeLabel(type: StepType): string { return { dockerCommand: 'Docker command', remoteCommand: 'Remote command', directory: 'Directory archive', postgres: 'PostgreSQL dump', mysql: 'MySQL dump' }[type]; }
function parseArguments(value: string): string[] { return value.split('\n').map((argument) => argument.trim()).filter(Boolean); }
function Settings() {
const [message, setMessage] = useState('');
useEffect(() => { void api<Record<string, unknown>>('/api/settings/notifications').then((data) => {
const form = document.querySelector<HTMLFormElement>('#notification-form');
if (!form) return;
const setField = (name: string, value: unknown) => {
const element = form.elements.namedItem(name) as HTMLInputElement | null;
if (!element) return;
if (element.type === 'checkbox') element.checked = Boolean(value);
else element.value = String(value ?? '');
};
setField('webhookUrl', data.webhookUrl);
setField('notifySuccess', data.notifySuccess);
setField('notifyFailure', data.notifyFailure);
const smtp = data.smtp as Record<string, unknown> | undefined;
setField('smtpEnabled', Boolean(smtp));
if (smtp) for (const [key, value] of Object.entries(smtp)) setField(`smtp${key[0]!.toUpperCase()}${key.slice(1)}`, value);
}); }, []);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const data = new FormData(event.currentTarget);
const smtpEnabled = data.get('smtpEnabled') === 'on';
try { await api('/api/settings/notifications', { method: 'PUT', body: JSON.stringify({
webhookUrl: data.get('webhookUrl') || '', notifySuccess: data.get('notifySuccess') === 'on', notifyFailure: data.get('notifyFailure') === 'on',
smtp: smtpEnabled ? { host: data.get('smtpHost'), port: Number(data.get('smtpPort')), secure: data.get('smtpSecure') === 'on', username: data.get('smtpUsername') || undefined, password: data.get('smtpPassword') || undefined, from: data.get('smtpFrom'), to: data.get('smtpTo') } : undefined,
}) }); setMessage('Notification settings saved.'); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
}
return <><PageTitle eyebrow="SYSTEM / DELIVERY" title="Notifications" detail="Send a concise result after remote jobs complete." /><section className="panel form-panel settings-panel"><form id="notification-form" onSubmit={submit}><fieldset><legend>Events</legend><label className="check"><input name="notifyFailure" type="checkbox" defaultChecked /> Notify when a backup fails</label><label className="check"><input name="notifySuccess" type="checkbox" /> Notify when a backup succeeds</label></fieldset><fieldset><legend>Webhook</legend><label>Endpoint URL<input name="webhookUrl" type="url" placeholder="https://hooks.example/backup" /></label></fieldset><fieldset><legend>SMTP email</legend><label className="check"><input name="smtpEnabled" type="checkbox" /> Enable SMTP delivery</label><div className="field-row wide"><label>SMTP host<input name="smtpHost" /></label><label className="port">Port<input name="smtpPort" type="number" defaultValue="587" /></label></div><label className="check"><input name="smtpSecure" type="checkbox" /> Use implicit TLS</label><div className="field-row"><label>Username<input name="smtpUsername" /></label><label>Password<input name="smtpPassword" type="password" /></label></div><div className="field-row"><label>From<input name="smtpFrom" type="email" /></label><label>To<input name="smtpTo" type="email" /></label></div></fieldset>{message && <p className="form-message">{message}</p>}<button type="submit">Save settings</button></form></section></>;
}
function Empty({ text }: { text: string }) { return <div className="empty">{text}</div>; }
function formatDate(value?: string) { return value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) : '—'; }
function formatBytes(value: number) { return value < 1024 * 1024 ? `${(value / 1024).toFixed(1)} KB` : `${(value / 1024 / 1024).toFixed(1)} MB`; }
createRoot(document.getElementById('root')!).render(<App />);

55
web/src/styles.css Normal file
View File

@@ -0,0 +1,55 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap');
:root { color: #e9eee9; background: #101512; font-family: Manrope, sans-serif; font-synthesis: none; --ink: #e9eee9; --muted: #8f9b94; --panel: #171e1a; --line: #2b352f; --acid: #b8f34a; --orange: #ff8a52; }
* { box-sizing: border-box; }
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; }
.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; }
.mark { width: 58px; height: 58px; display: grid; place-items: center; background: var(--acid); color: #101512; font: 700 18px DM Mono; clip-path: polygon(0 0, 84% 0, 100% 16%, 100% 100%, 16% 100%, 0 84%); margin-bottom: 34px; }
.mark.small { width: 42px; height: 42px; font-size: 14px; margin: 0; }
.eyebrow { color: var(--acid) !important; font: 500 11px DM Mono; letter-spacing: .14em; margin: 0 0 10px; }
h1, h2, h3, p { margin-top: 0; }
h1 { font-size: clamp(32px, 5vw, 52px); letter-spacing: -.05em; line-height: 1; margin-bottom: 14px; }
h2 { font-size: 18px; letter-spacing: -.02em; margin-bottom: 0; }
h3 { margin-bottom: 4px; }
.muted, .page-title > p { color: var(--muted); }
form { display: grid; gap: 18px; }
label { display: grid; gap: 8px; color: #c4cec8; font-size: 13px; font-weight: 600; }
label small { color: var(--muted); font-weight: 400; }
input, textarea, select { width: 100%; color: var(--ink); background: #0d120f; border: 1px solid #354139; border-radius: 0; padding: 12px 13px; outline: none; }
input:focus, textarea:focus, select:focus { border-color: var(--acid); box-shadow: 0 0 0 1px var(--acid); }
textarea { resize: vertical; font-family: DM Mono, monospace; font-size: 12px; }
button { color: #111713; background: var(--acid); border: 0; padding: 12px 17px; font-weight: 700; }
.error { color: #ffc0a5; background: #351e16; border-left: 3px solid var(--orange); padding: 10px; font-size: 13px; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 230px 1fr; }
aside { border-right: 1px solid var(--line); padding: 28px 20px; display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; background: #101512dd; backdrop-filter: blur(12px); }
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 54px; }
.brand strong, .brand small { display: block; }.brand small { color: var(--muted); font: 10px DM Mono; letter-spacing: .18em; margin-top: 3px; }
nav { display: grid; gap: 5px; }
nav button { background: transparent; color: var(--muted); text-align: left; text-transform: capitalize; display: flex; gap: 14px; align-items: center; padding: 12px; }
nav button span { font: 10px DM Mono; color: #536158; }
nav button.active { color: var(--ink); background: #1c251f; box-shadow: inset 3px 0 var(--acid); }
nav button.active span { color: var(--acid); }
.logout { margin-top: auto; background: transparent; border: 1px solid var(--line); color: var(--muted); }
.content { padding: 52px clamp(24px, 5vw, 76px) 80px; min-width: 0; }
.page-title { margin-bottom: 42px; max-width: 680px; }.page-title h1 { margin-bottom: 12px; }.page-title > p:last-child { line-height: 1.6; }
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 22px; }
.metric { min-height: 130px; padding: 19px; background: var(--panel); border: 1px solid var(--line); display: flex; flex-direction: column; justify-content: space-between; }
.metric span { color: var(--muted); font: 11px DM Mono; text-transform: uppercase; letter-spacing: .1em; }.metric strong { font: 500 42px DM Mono; }.metric.accent { background: var(--acid); color: #101512; border-color: var(--acid); }.metric.accent span { color: #394c17; }.metric.warning strong { color: var(--orange); }
.panel { background: var(--panel); border: 1px solid var(--line); min-width: 0; }
.panel-head { padding: 19px 21px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; }.panel-head > span { color: var(--muted); font: 10px DM Mono; text-transform: uppercase; }.panel-head .eyebrow { margin-bottom: 7px; }
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; font-size: 13px; } th { color: #6f7d74; font: 10px DM Mono; letter-spacing: .1em; text-transform: uppercase; text-align: left; } th, td { padding: 15px 20px; border-bottom: 1px solid #252e29; } tbody tr:hover { background: #1c251f; }
.mono { font-family: DM Mono, monospace; font-size: 12px; }.text-button { padding: 0; background: transparent; color: var(--acid); font: 12px DM Mono; }
.status { display: inline-flex; align-items: center; gap: 7px; font: 10px DM Mono; text-transform: uppercase; color: #b1bbb5; }.status i { width: 7px; height: 7px; border-radius: 50%; background: #738078; }.status.succeeded i { background: var(--acid); box-shadow: 0 0 10px #b8f34a88; }.status.failed { color: #ffad87; }.status.failed i { background: var(--orange); }.status.running i, .status.queued i { background: #ffe06a; animation: pulse 1s infinite alternate; } @keyframes pulse { to { opacity: .25; } }
.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; }
.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); }
.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; } }