Build backup manager MVP
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
backups
|
||||
.env
|
||||
*.log
|
||||
5
.env.example
Normal file
5
.env.example
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
backups/
|
||||
.env
|
||||
*.log
|
||||
11
AGENTS.md
Normal file
11
AGENTS.md
Normal 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
20
Dockerfile
Normal 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
89
README.md
Normal 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
24
compose.yaml
Normal 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
17
eslint.config.js
Normal 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
5165
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
package.json
Normal file
44
package.json
Normal 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
29
src/config.ts
Normal 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
26
src/crypto.test.ts
Normal 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
38
src/crypto.ts
Normal 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
74
src/db.ts
Normal 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
25
src/executor.test.ts
Normal 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
245
src/executor.ts
Normal 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
52
src/notifications.ts
Normal 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
62
src/schemas.test.ts
Normal 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
149
src/schemas.ts
Normal 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
206
src/server.ts
Normal 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
13
src/service.test.ts
Normal 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
68
src/service.ts
Normal 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
12
src/ssh.test.ts
Normal 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
117
src/ssh.ts
Normal 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
16
tsconfig.server.json
Normal 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
13
tsconfig.web.json
Normal 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
15
vite.config.ts
Normal 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
9
vitest.config.ts
Normal 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
13
web/index.html
Normal 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
265
web/src/main.tsx
Normal 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
55
web/src/styles.css
Normal 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; } }
|
||||
Reference in New Issue
Block a user