1.0.38
This commit is contained in:
177 files changed
+21182
-1042
No files matched your search
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Pure decision for the "Backup helper image" setting in Settings → Backups.
|
||||
*
|
||||
* The field is pre-filled with the value the API returned — either the user's
|
||||
* saved override, or (when nothing is saved) the engine's real version-pinned
|
||||
* default (fnsys/dockhand-backup:<version>). We must persist ONLY when the user
|
||||
* actually changed it: saving the pre-filled versioned default would pin the DB to
|
||||
* that exact version, and since the DB setting wins over the computed default, a
|
||||
* later app upgrade would keep using the OLD helper (a stale helper — see
|
||||
* src/lib/server/backups/restic.ts). Not saving an unchanged value keeps the DB row
|
||||
* empty so the engine tracks the app version automatically.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decide whether to persist the backup helper image on Save.
|
||||
*
|
||||
* @param current the current field value (trimmed comparison; leading/trailing
|
||||
* whitespace never counts as a change)
|
||||
* @param initial the value the field was loaded with (API's saved-or-default)
|
||||
* @returns true only when the user meaningfully changed the value
|
||||
*/
|
||||
export function shouldSaveBackupImage(current: string, initial: string): boolean {
|
||||
return (current ?? '').trim() !== (initial ?? '').trim();
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Shared backup utilities — used by BackupPanel, BackupsTab, RestoreModal, backups page.
|
||||
*/
|
||||
|
||||
import { HardDrive, Globe } from 'lucide-svelte';
|
||||
import { AmazonS3Icon, BackblazeIcon, AzureBlobIcon, GoogleCloudIcon, RestServerIcon } from '$lib/components/cloud-icons';
|
||||
import cronstrue from 'cronstrue';
|
||||
import type { Component } from 'svelte';
|
||||
import { computeExecutionTally, type Execution, type ExecutionTally } from '$lib/utils/execution-tally';
|
||||
export { computeExecutionTally, type Execution, type ExecutionTally };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job-result classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Classify a backup/restore job result. The engine now returns a discriminated
|
||||
* union keyed on `status` ('success' | 'warning' | 'skipped' | 'error') — it no
|
||||
* longer carries the old `{ success: boolean }` field. A 'skipped' result (a
|
||||
* concurrent-run rejection, or a retention "would wipe everything" guard) is
|
||||
* NOT a success and must not be reported as one; it is also not a hard error.
|
||||
*
|
||||
* `error` covers both the current shape and any legacy `{ success:false }` /
|
||||
* bare `{ error }` payload, so this is safe for callers that may still receive
|
||||
* an old-shaped result from a non-migrated route.
|
||||
*/
|
||||
export type BackupOutcome = 'success' | 'warning' | 'skipped' | 'error';
|
||||
|
||||
export function classifyJobResult(result: any): { outcome: BackupOutcome; message?: string } {
|
||||
if (!result || typeof result !== 'object') return { outcome: 'error', message: 'No result' };
|
||||
const status = result.status as string | undefined;
|
||||
if (status === 'error' || result.success === false || (result.error && !status)) {
|
||||
return { outcome: 'error', message: result.error || 'Operation failed' };
|
||||
}
|
||||
if (status === 'skipped') return { outcome: 'skipped', message: result.reason };
|
||||
if (status === 'warning') return { outcome: 'warning', message: result.warning };
|
||||
return { outcome: 'success' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository type helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getRepoTypeIcon(repository: string): Component {
|
||||
if (repository.startsWith('/') || repository.startsWith('./')) return HardDrive;
|
||||
if (repository.startsWith('s3:')) return AmazonS3Icon;
|
||||
if (repository.startsWith('b2:')) return BackblazeIcon;
|
||||
if (repository.startsWith('azure:')) return AzureBlobIcon;
|
||||
if (repository.startsWith('gs:')) return GoogleCloudIcon;
|
||||
if (repository.startsWith('rest:')) return RestServerIcon;
|
||||
return Globe;
|
||||
}
|
||||
|
||||
export function getRepoTypeLabel(repository: string): string {
|
||||
if (repository.startsWith('/') || repository.startsWith('./')) return 'Local';
|
||||
if (repository.startsWith('s3:')) return 'S3';
|
||||
if (repository.startsWith('b2:')) return 'Backblaze B2';
|
||||
if (repository.startsWith('azure:')) return 'Azure Blob';
|
||||
if (repository.startsWith('gs:')) return 'Google Cloud';
|
||||
if (repository.startsWith('rest:')) return 'REST';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
// Repository/environment predicates live in the lucide-free shared module so
|
||||
// server routes can import them without pulling in this file's icon deps (#37).
|
||||
export { isLocalRepo, isRemoteEnvironment } from '$lib/shared/repo-predicates';
|
||||
export {
|
||||
normalizeMounts,
|
||||
normalizeStackMounts,
|
||||
mountTypeFromHostPath,
|
||||
groupContainersForBackup,
|
||||
volumesForStack,
|
||||
type VolumeInfo,
|
||||
type BackupItem,
|
||||
type RawContainer
|
||||
} from '$lib/utils/mounts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retention helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RetentionPolicy {
|
||||
keepLast?: number;
|
||||
keepDaily?: number;
|
||||
keepWeekly?: number;
|
||||
keepMonthly?: number;
|
||||
keepYearly?: number;
|
||||
}
|
||||
|
||||
export interface BackupOptions {
|
||||
compression?: string;
|
||||
limitUpload?: string;
|
||||
limitDownload?: string;
|
||||
excludePatterns?: string;
|
||||
excludeCaches?: boolean;
|
||||
webhookSuccess?: string;
|
||||
webhookFailure?: string;
|
||||
}
|
||||
|
||||
export function parseRetention(retention: string | RetentionPolicy | null | undefined): RetentionPolicy {
|
||||
if (!retention) return {};
|
||||
if (typeof retention === 'object') return retention;
|
||||
try {
|
||||
const parsed = JSON.parse(retention);
|
||||
// Extract only retention fields (ignore any extra properties)
|
||||
const { keepLast, keepDaily, keepWeekly, keepMonthly, keepYearly } = parsed;
|
||||
return { keepLast, keepDaily, keepWeekly, keepMonthly, keepYearly };
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
export function parseOptions(options: string | BackupOptions | null | undefined): BackupOptions {
|
||||
if (!options) return {};
|
||||
if (typeof options === 'object') return options;
|
||||
try { return JSON.parse(options); } catch { return {}; }
|
||||
}
|
||||
|
||||
export function retentionSummary(retention: string | RetentionPolicy | null | undefined): string {
|
||||
const r = parseRetention(retention);
|
||||
const parts = [
|
||||
r.keepLast && `${r.keepLast} last`,
|
||||
r.keepDaily && `${r.keepDaily}d`,
|
||||
r.keepWeekly && `${r.keepWeekly}w`,
|
||||
r.keepMonthly && `${r.keepMonthly}m`,
|
||||
r.keepYearly && `${r.keepYearly}y`
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? `Keep ${parts.join('/')}` : '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cron helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatCron(cron: string | null): string {
|
||||
if (!cron) return '—';
|
||||
try { return cronstrue.toString(cron, { use24HourTimeFormat: true }); } catch { return cron; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config body assembly + action orchestration
|
||||
//
|
||||
// Shared between BackupPanel (the in-modal Backups tab) and CreateBackupModal
|
||||
// (the standalone wizard) so neither can silently drift on what gets POSTed.
|
||||
//
|
||||
// The historical bug this exists to prevent: BackupPanel's old "Run once"
|
||||
// path silently omitted selectedVolumes, letting the backend default to
|
||||
// allVolumes=true. With buildBackupConfigBody as the single source of truth,
|
||||
// any field added below is automatically sent by every action.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Form state required to construct a /api/backup/configs body. `retention`
|
||||
* and `options` are optional — the wizard (CreateBackupModal) doesn't expose
|
||||
* them, the in-modal form (BackupPanel) does.
|
||||
*/
|
||||
export interface BackupFormState {
|
||||
targetName: string;
|
||||
type: 'container' | 'stack';
|
||||
environmentId: number | null | undefined;
|
||||
destinationId: number;
|
||||
stopBeforeBackup: boolean;
|
||||
allVolumes: boolean;
|
||||
selectedVolumes: string[];
|
||||
retention?: RetentionPolicy;
|
||||
options?: BackupOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides what happens after the config exists:
|
||||
* save — POST/PUT and stop. Schedule field is honored, enabled flag honored.
|
||||
* save-run — same as save, then immediately fires /run on the saved config.
|
||||
* run-once — POSTs a disabled config with no schedule, runs it, then deletes
|
||||
* it. Snapshot remains in the repo (browseable, restorable as an
|
||||
* orphan) but no zombie config row is left behind.
|
||||
*/
|
||||
export type BackupAction = 'save' | 'save-run' | 'run-once';
|
||||
|
||||
export interface BuildBodyOptions {
|
||||
enabled: boolean;
|
||||
/** Set to null to explicitly clear the schedule (run-once needs this). */
|
||||
schedule: string | null;
|
||||
}
|
||||
|
||||
/** Pure: assemble the POST/PUT body. Single source of truth. */
|
||||
export function buildBackupConfigBody(
|
||||
form: BackupFormState,
|
||||
opts: BuildBodyOptions
|
||||
): Record<string, any> {
|
||||
return {
|
||||
targetName: form.targetName,
|
||||
type: form.type,
|
||||
destinationId: form.destinationId,
|
||||
environmentId: form.environmentId,
|
||||
enabled: opts.enabled,
|
||||
schedule: opts.schedule,
|
||||
retention: form.retention,
|
||||
options: form.options,
|
||||
stopBeforeBackup: form.stopBeforeBackup,
|
||||
// Persist allVolumes explicitly (audit #38) — the server's selection filter
|
||||
// keys on `!config.allVolumes`, so omitting it here left the DB column stale
|
||||
// on update and could apply/skip the filter incorrectly.
|
||||
allVolumes: form.allVolumes,
|
||||
selectedVolumes: form.allVolumes ? null : form.selectedVolumes
|
||||
};
|
||||
}
|
||||
|
||||
export interface RunBackupActionInput {
|
||||
form: BackupFormState;
|
||||
action: BackupAction;
|
||||
/** Existing config to update (PUT). Omit for new-config flow (POST). */
|
||||
editingConfigId?: number | null;
|
||||
/** Cron expression to persist when action !== 'run-once'. Ignored for run-once. */
|
||||
schedule: string;
|
||||
enabled: boolean;
|
||||
/** Streams restic progress lines from the /run job. No-op by default. */
|
||||
onProgress?: (line: { event?: string; data: unknown }) => void;
|
||||
/** Called with the config id once the backup is about to run, so the caller can
|
||||
* offer a Cancel button while it's in flight. */
|
||||
onStarted?: (configId: number) => void;
|
||||
}
|
||||
|
||||
export interface RunBackupActionResult {
|
||||
ok: boolean;
|
||||
configId?: number;
|
||||
snapshotId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrate the full action sequence: POST/PUT the config, optionally /run
|
||||
* it, optionally delete it. Returns a tagged result so callers can decide what
|
||||
* toast to fire and what to do with the result.
|
||||
*
|
||||
* Errors are returned in the result rather than thrown — the caller has more
|
||||
* context (which target, which environment) to render a useful error toast.
|
||||
*/
|
||||
export async function runBackupAction(input: RunBackupActionInput): Promise<RunBackupActionResult> {
|
||||
const { form, action, editingConfigId, onProgress } = input;
|
||||
|
||||
// Build the body that matches the requested action. Run-once forces
|
||||
// enabled=false + schedule=null regardless of what the user typed.
|
||||
const persistedBody = buildBackupConfigBody(form, {
|
||||
enabled: action === 'run-once' ? false : input.enabled,
|
||||
schedule: action === 'run-once' ? null : (input.schedule || null)
|
||||
});
|
||||
|
||||
// A run-once now KEEPS its config, so running one twice for the same target
|
||||
// must not pile up duplicate configs. Reuse an existing config for the same
|
||||
// (target, type, env, destination) if one is already there — PUT it instead of
|
||||
// POSTing a new one. (Edits already carry editingConfigId and skip this.)
|
||||
let reuseId = editingConfigId ?? null;
|
||||
if (action === 'run-once' && reuseId == null) {
|
||||
try {
|
||||
const params = new URLSearchParams({ type: form.type, target: form.targetName });
|
||||
if (form.environmentId != null) params.set('env', String(form.environmentId));
|
||||
const res = await fetch(`/api/backup/configs?${params}`);
|
||||
if (res.ok) {
|
||||
const list = await res.json();
|
||||
const arr = Array.isArray(list) ? list : (list?.id ? [list] : []);
|
||||
const match = arr.find((c: any) => c.destinationId === form.destinationId);
|
||||
if (match?.id) reuseId = match.id;
|
||||
}
|
||||
} catch { /* fall through to POST a fresh config */ }
|
||||
}
|
||||
|
||||
// Step 1 — persist. PUT when we have a config to reuse (edit or a matching
|
||||
// run-once target), POST otherwise.
|
||||
let configId: number;
|
||||
try {
|
||||
const isPut = reuseId != null;
|
||||
const url = isPut ? `/api/backup/configs/${reuseId}` : '/api/backup/configs';
|
||||
const res = await fetch(url, {
|
||||
method: isPut ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(persistedBody)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: data.error || `Failed to ${isPut ? 'update' : 'create'} backup config` };
|
||||
}
|
||||
const body = await res.json();
|
||||
configId = isPut ? (reuseId as number) : body.id;
|
||||
} catch (err: any) {
|
||||
return { ok: false, error: err?.message || 'Failed to save config' };
|
||||
}
|
||||
|
||||
// Step 2 — if just saving, we're done.
|
||||
if (action === 'save') return { ok: true, configId };
|
||||
|
||||
// Step 3 — run the backup. Both 'save-run' and 'run-once' land here.
|
||||
let snapshotId: string | undefined;
|
||||
let runError: string | undefined;
|
||||
// Report the config id now so the caller can offer a Cancel while it runs.
|
||||
input.onStarted?.(configId);
|
||||
try {
|
||||
// When the caller wants live output (onProgress), ask for the JOB-POLLING path
|
||||
// (returns { jobId } → we stream via watchJob). `Accept: application/json` takes
|
||||
// the SYNCHRONOUS server path instead, which produces NO progress lines — that's
|
||||
// only for tests/CLI that don't stream. Requesting the wrong one is why the log
|
||||
// modal was empty ("Backup completed" but 0 lines).
|
||||
const wantsStream = typeof onProgress === 'function';
|
||||
const runRes = await fetch(`/api/backup/configs/${configId}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': wantsStream ? 'text/event-stream' : 'application/json' }
|
||||
});
|
||||
const runData = await runRes.json().catch(() => ({}));
|
||||
|
||||
if (runData.jobId) {
|
||||
// SSE-style job — poll until done. onProgress streams progress lines.
|
||||
// Imported lazily so this module stays Svelte-runtime-free at the
|
||||
// top level (callers in non-browser contexts can still buildBody).
|
||||
const { watchJob } = await import('$lib/utils/sse-fetch');
|
||||
const result: any = await watchJob(runData.jobId, (line) => { onProgress?.(line); });
|
||||
const { outcome, message } = classifyJobResult(result);
|
||||
// 'skipped' (concurrent overlap / prune guard) is neither success nor a
|
||||
// hard failure — surface the reason so the caller isn't told it succeeded.
|
||||
if (outcome === 'error' || outcome === 'skipped') {
|
||||
runError = message || 'Backup failed';
|
||||
} else {
|
||||
snapshotId = result?.snapshotId;
|
||||
}
|
||||
} else {
|
||||
// Synchronous JSON path (Accept: application/json). Same union shape.
|
||||
const { outcome, message } = classifyJobResult(runData);
|
||||
if (outcome === 'error' || outcome === 'skipped') {
|
||||
runError = message || 'Backup failed';
|
||||
} else {
|
||||
snapshotId = runData.snapshotId;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
runError = err?.message || 'Backup failed';
|
||||
}
|
||||
|
||||
// A 'run-once' backup KEEPS its config (enabled=false, schedule=null) so the
|
||||
// target stays on the backups list with a working "Run backup now" button and a
|
||||
// delete action. Previously the config was deleted here, which turned every
|
||||
// one-off backup into an orphan (no re-run possible). The user removes it
|
||||
// explicitly via the delete action when they no longer want it.
|
||||
if (runError) return { ok: false, configId, error: runError };
|
||||
return { ok: true, configId, snapshotId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag a progress line for LogConsole's source pills. Dockhand's own status lines
|
||||
* carry no `[source]` prefix, so tag them `[dockhand]`; restic/FS-op lines already
|
||||
* arrive tagged (`[restic]`/`[dockhand]`) and pass through unchanged. Shared by
|
||||
* every place that streams backup/restore progress into LogConsole.
|
||||
*/
|
||||
export function tagLogLine(msg: string): string {
|
||||
return /^\[[a-z0-9_-]+\]/i.test(msg) ? msg : `[dockhand] ${msg}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backup execution history + ok/fail tally
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch backup executions for a target's config ids and compute an ok/fail tally.
|
||||
* Used by the container/stack modal (tab counters + History tab) and the main
|
||||
* Backups page. Merges across multiple configs (a target can back up to more than
|
||||
* one repo). Network shell around {@link computeExecutionTally}.
|
||||
*
|
||||
* Pass `repoByConfig` (configId → {repository, name}) to stamp each run with which
|
||||
* repo it wrote to — the History table shows a Repo column when this is present.
|
||||
*/
|
||||
export async function fetchBackupExecutions(
|
||||
configIds: number[],
|
||||
limit = 50,
|
||||
repoByConfig?: Map<number, { repository?: string; name?: string }>
|
||||
): Promise<ExecutionTally> {
|
||||
if (!configIds.length) return { executions: [], ok: 0, failed: 0 };
|
||||
const all: Execution[] = [];
|
||||
await Promise.all(configIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/schedules/executions?scheduleType=backup&scheduleId=${id}&limit=${limit}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const dest = repoByConfig?.get(id);
|
||||
for (const e of data.executions || []) {
|
||||
const exec = e as Execution;
|
||||
if (dest) { exec._repository = dest.repository; exec._destinationName = dest.name; }
|
||||
all.push(exec);
|
||||
}
|
||||
} catch { /* one config's history is best-effort */ }
|
||||
}));
|
||||
return computeExecutionTally(all);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Pure, UI-free backup execution-history logic. Kept out of utils/backup.ts (which
|
||||
* pulls in lucide/cloud-icons) so it can be unit-tested under `bun test` — matches
|
||||
* the repo's convention of isolating logic from Svelte/icon imports.
|
||||
*/
|
||||
|
||||
export interface Execution {
|
||||
id: number;
|
||||
scheduleId?: number;
|
||||
triggeredAt: string | null;
|
||||
triggeredBy: string;
|
||||
status: string;
|
||||
duration: number | null;
|
||||
errorMessage: string | null;
|
||||
logs?: string | null;
|
||||
details?: { filesNew?: number; filesChanged?: number; dataAdded?: number } | null;
|
||||
/** Stamped by fetchBackupExecutions when a config→destination map is passed, so
|
||||
* the history table can show which repo each run wrote to. */
|
||||
_repository?: string;
|
||||
_destinationName?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionTally {
|
||||
executions: Execution[];
|
||||
ok: number; // success + warning + skipped — produced a snapshot or was a no-op
|
||||
failed: number; // status === 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort executions newest-first and count the ok/fail tally. `warning` and `skipped`
|
||||
* count as ok — only a hard `failed` is red. `running`/`queued` count as neither
|
||||
* (in-flight, no outcome yet). A null `triggeredAt` sorts oldest.
|
||||
*/
|
||||
export function computeExecutionTally(executions: Execution[]): ExecutionTally {
|
||||
const sorted = [...executions].sort(
|
||||
(a, b) => new Date(b.triggeredAt ?? 0).getTime() - new Date(a.triggeredAt ?? 0).getTime()
|
||||
);
|
||||
let ok = 0, failed = 0;
|
||||
for (const e of sorted) {
|
||||
if (e.status === 'failed') failed++;
|
||||
else if (e.status === 'success' || e.status === 'warning' || e.status === 'skipped') ok++;
|
||||
}
|
||||
return { executions: sorted, ok, failed };
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
/**
|
||||
* Format a byte count into a human-readable string.
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals = 1): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : decimals)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact byte format for grids: 1.2K, 500M, 2.1G
|
||||
*/
|
||||
export function formatBytesCompact(bytes: number, decimals = 1): string {
|
||||
if (bytes === 0) return '0B';
|
||||
const units = ['B', 'K', 'M', 'G', 'T'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : decimals)}${units[i]}`;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Canonical volume/bind normalization for the backup UI.
|
||||
*
|
||||
* Three surfaces (CreateBackupModal, BackupPanel via EditContainerModal, and
|
||||
* EnvironmentBackupsTab) previously each open-coded "Docker mounts -> pickable
|
||||
* volume list" and drifted: one dropped bind mounts entirely, one never set a
|
||||
* mount type (so every row rendered as a named volume). Both symptoms trace to
|
||||
* duplicated logic. This module is the single source of truth.
|
||||
*
|
||||
* Kept dependency-free (no Svelte/lucide/db imports) so it is unit-testable
|
||||
* under `bun test`.
|
||||
*/
|
||||
|
||||
export interface VolumeInfo {
|
||||
name: string;
|
||||
mountPoint: string;
|
||||
mountType: 'volume' | 'bind';
|
||||
}
|
||||
|
||||
/** A raw Docker mount as returned by the containers API (list or inspect).
|
||||
* Both lowercase (our normalized) and PascalCase (raw Docker) keys are
|
||||
* tolerated because callers pass either shape. */
|
||||
interface RawMount {
|
||||
type?: string;
|
||||
Type?: string;
|
||||
name?: string;
|
||||
Name?: string;
|
||||
source?: string;
|
||||
Source?: string;
|
||||
destination?: string;
|
||||
Destination?: string;
|
||||
}
|
||||
|
||||
/** Docker's Binds are `hostPath:containerPath[:mode]`; a named volume's
|
||||
* "hostPath" is the volume NAME (no leading slash), a bind mount's is an
|
||||
* absolute host path. This is the standard heuristic Docker itself uses. */
|
||||
export function mountTypeFromHostPath(hostPath: string): 'volume' | 'bind' {
|
||||
return hostPath.startsWith('/') ? 'bind' : 'volume';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a container's raw Docker mounts into the pickable volume list.
|
||||
* Keeps only `volume` and `bind` mounts (skips tmpfs/npipe/etc.), and derives
|
||||
* a stable display `name` (volume name, else source, else destination).
|
||||
*/
|
||||
export function normalizeMounts(mounts: RawMount[] | null | undefined): VolumeInfo[] {
|
||||
if (!Array.isArray(mounts)) return [];
|
||||
const out: VolumeInfo[] = [];
|
||||
for (const m of mounts) {
|
||||
const type = m.type || m.Type;
|
||||
if (type !== 'volume' && type !== 'bind') continue;
|
||||
const name = m.name || m.Name || m.source || m.Source || m.destination || m.Destination || '';
|
||||
out.push({
|
||||
name,
|
||||
mountPoint: m.destination || m.Destination || '',
|
||||
mountType: type === 'bind' ? 'bind' : 'volume'
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the mounts of every container in a compose project into one deduped
|
||||
* volume list (by display name). Used for stack-level backup pickers.
|
||||
*/
|
||||
export function normalizeStackMounts(
|
||||
containers: Array<{ mounts?: RawMount[]; Mounts?: RawMount[] }>
|
||||
): VolumeInfo[] {
|
||||
const seen = new Set<string>();
|
||||
const out: VolumeInfo[] = [];
|
||||
for (const c of containers) {
|
||||
for (const v of normalizeMounts(c.mounts || c.Mounts)) {
|
||||
if (v.name && !seen.has(v.name)) {
|
||||
seen.add(v.name);
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The compose-project label that groups containers into a stack. */
|
||||
const COMPOSE_PROJECT_LABEL = 'com.docker.compose.project';
|
||||
|
||||
/** A raw container as returned by the /api/containers list endpoint. Tolerates
|
||||
* the Docker-raw fallback keys (`Mounts`, `Names`) the same way the mount
|
||||
* normalizers do, so callers can pass either shape. */
|
||||
export interface RawContainer {
|
||||
name?: string;
|
||||
Names?: string[];
|
||||
labels?: Record<string, string | undefined>;
|
||||
Labels?: Record<string, string | undefined>;
|
||||
mounts?: RawMount[];
|
||||
Mounts?: RawMount[];
|
||||
}
|
||||
|
||||
/** A pickable backup target: a stack (mounts of all its containers, merged) or
|
||||
* a standalone container. */
|
||||
export interface BackupItem {
|
||||
name: string;
|
||||
type: 'stack' | 'container';
|
||||
volumes: VolumeInfo[];
|
||||
}
|
||||
|
||||
function containerName(c: RawContainer): string {
|
||||
return c.name || c.Names?.[0]?.replace(/^\//, '') || '';
|
||||
}
|
||||
|
||||
function composeProject(c: RawContainer): string | undefined {
|
||||
const labels = c.labels || c.Labels;
|
||||
return labels?.[COMPOSE_PROJECT_LABEL];
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition a raw /api/containers list into stack items (grouped by the compose
|
||||
* project label, mounts merged+deduped across the stack's containers) and
|
||||
* standalone container items. Sorted: stacks first, then containers, each
|
||||
* alphabetical.
|
||||
*
|
||||
* NOTE: deriving stacks from container labels can't see a stack's sourceType and
|
||||
* misses stopped stacks (no live containers). Backup-discovery surfaces now take
|
||||
* stacks from /api/stacks and standalone containers from {@link standaloneContainers}.
|
||||
* This whole-list grouping is kept for any caller that only has a container list.
|
||||
*/
|
||||
export function groupContainersForBackup(containers: RawContainer[] | null | undefined): BackupItem[] {
|
||||
if (!Array.isArray(containers)) return [];
|
||||
const stackContainers = new Map<string, RawContainer[]>();
|
||||
const standalone: BackupItem[] = [];
|
||||
for (const c of containers) {
|
||||
const project = composeProject(c);
|
||||
if (project) {
|
||||
const existing = stackContainers.get(project) || [];
|
||||
existing.push(c);
|
||||
stackContainers.set(project, existing);
|
||||
} else {
|
||||
standalone.push({ name: containerName(c), type: 'container', volumes: normalizeMounts(c.mounts || c.Mounts) });
|
||||
}
|
||||
}
|
||||
return [
|
||||
...[...stackContainers.entries()].map(([name, cs]): BackupItem => ({
|
||||
name,
|
||||
type: 'stack',
|
||||
volumes: normalizeStackMounts(cs)
|
||||
})),
|
||||
...standalone
|
||||
].sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'stack' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Volumes for ONE stack: the stack slice of {@link groupContainersForBackup}.
|
||||
* Returns [] if the stack has no containers (stopped / not-yet-deployed) — that
|
||||
* is now the ONLY path that can legitimately produce an empty picker.
|
||||
*/
|
||||
export function volumesForStack(
|
||||
containers: RawContainer[] | null | undefined,
|
||||
stackName: string
|
||||
): VolumeInfo[] {
|
||||
if (!Array.isArray(containers)) return [];
|
||||
const mine = containers.filter((c) => composeProject(c) === stackName);
|
||||
return normalizeStackMounts(mine);
|
||||
}
|
||||
|
||||
/**
|
||||
* The STANDALONE-container slice of a /api/containers list: every container
|
||||
* WITHOUT a compose-project label, as a backup item with its mounts normalized.
|
||||
* Sorted alphabetically.
|
||||
*
|
||||
* This exists because the stack slice of a backup list must now come from
|
||||
* /api/stacks (the single source that knows a stack's sourceType and includes
|
||||
* stopped stacks) — reconstructing stacks from container labels missed both. So
|
||||
* the caller takes stacks from /api/stacks and standalone containers from here,
|
||||
* instead of {@link groupContainersForBackup} (which does both from labels).
|
||||
*/
|
||||
export function standaloneContainers(
|
||||
containers: RawContainer[] | null | undefined
|
||||
): BackupItem[] {
|
||||
if (!Array.isArray(containers)) return [];
|
||||
return containers
|
||||
.filter((c) => !composeProject(c))
|
||||
.map((c): BackupItem => ({
|
||||
name: containerName(c),
|
||||
type: 'container',
|
||||
volumes: normalizeMounts(c.mounts || c.Mounts)
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Parse a shell command string into an array of arguments,
|
||||
* respecting single and double quotes.
|
||||
*
|
||||
* Examples:
|
||||
* "sleep 3600" → ["sleep", "3600"]
|
||||
* 'sh -c "echo hello world"' → ["sh", "-c", "echo hello world"]
|
||||
* "python -c 'print(1)'" → ["python", "-c", "print(1)"]
|
||||
*/
|
||||
export function parseShellCommand(cmd: string): string[] {
|
||||
const args: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
|
||||
for (let i = 0; i < cmd.length; i++) {
|
||||
const char = cmd[i];
|
||||
|
||||
if ((char === '"' || char === "'") && !inQuotes) {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
} else if (char === quoteChar && inQuotes) {
|
||||
inQuotes = false;
|
||||
quoteChar = '';
|
||||
} else if (char === ' ' && !inQuotes) {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
args.push(current);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
Reference in new issue
Block a user