{ "$schema": "homarr-widget-package-v3", "manifest": { "id": "homarr.reference.backup", "version": "1.1.0", "sdkVersion": "1", "name": "Filesystem backup monitor", "description": "Read backup files and a JSON status/history report without an API, with an optional owner-configured backup executable.", "entrypoints": { "tile": "widget.tsx", "advanced": "widget.tsx", "server": "server.ts" }, "handlers": { "files": { "kind": "query", "permission": "view" }, "runBackup": { "kind": "action", "permission": "full", "timeoutMs": 300000, "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false } } } }, "files": { "widget.tsx": "import type { BackupOptions, BackupSnapshot, BackupRunResult } from \"./types\";\nimport { Alert, Badge, Button, Group, Loader, Stack, Table, Text } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetClock, useWidgetHost, useWidgetOptions, useWidgetQuery, useWidgetServices } from \"@homarr/widget-sdk\";\nexport default function Backups() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const options = useWidgetOptions();\n const now = useWidgetClock(60000);\n const query = useWidgetQuery(\"files\", {}, {refetchInterval:60000});\n const run = useWidgetAction>(\"runBackup\");\n const files = query.data?.files ?? [];\n const latest = files[0];\n const status = query.data?.status;\n const finishedAt = status?.finishedAt ?? latest?.modifiedAt;\n const overdue = finishedAt && now !== null && now - finishedAt > options.maximumAgeHours * 3600000;\n let label = \"No backup report\";\n let color = \"gray\";\n if (finishedAt) { label = \"Recent backup activity\"; color = \"teal\"; }\n if (status?.status === \"success\") label = \"Backup succeeded\";\n if (overdue) { label = \"Backup overdue\"; color = \"orange\"; }\n if (status?.status === \"failure\") { label = \"Backup failed\"; color = \"red\"; }\n if (query.data?.running || run.isPending || status?.status === \"running\") { label = \"Backup running\"; color = \"blue\"; }\n const execute = async () => {\n if (await services.confirm({title:\"Run backup now?\",message:\"Run the owner-configured backup executable on the Homarr server?\"})) run.mutate({});\n };\n return \n {options.title}{label}\n {query.isPending && }\n {query.error && {query.error.message}}\n {(query.data?.errors ?? []).map((error,index) => {error})}\n {status?.message && {status.message}}\n {finishedAt && {new Date(finishedAt).toLocaleString(host.locale)}}\n {latest && {latest.name} · {(latest.bytes/1048576).toFixed(1)} MiB}\n {status?.durationSeconds != null && Last duration: {status.durationSeconds}s}\n \n {host.displayMode === \"compact\" && }\n {query.data?.commandConfigured && }\n \n {run.error && {run.error.message}}\n {run.data?.completed && Command completed in {run.data.durationSeconds}s. Refresh to read its status report.}\n {host.displayMode === \"advanced\" && <>\n Run history\n {!(query.data?.history?.length) && Add history entries to the configured JSON status report.}\n FinishedStatusDuration{(query.data?.history ?? []).map((entry,index) => {entry.finishedAt ? new Date(entry.finishedAt).toLocaleString(host.locale) : \"Unknown\"}{entry.status}{entry.durationSeconds ?? \"—\"}s)}
\n Backup files\n FileModifiedMiB{files.map((file) => {file.name}{new Date(file.modifiedAt).toLocaleString(host.locale)}{(file.bytes/1048576).toFixed(1)})}
\n }\n Activity reports do not verify restore integrity. Test restores separately.\n
;\n}\n", "server.ts": "import type { BackupOptions, BackupFile, BackupRecord } from \"./types\";\nimport { readdir, readFile, stat } from \"node:fs/promises\";\nimport { join, isAbsolute } from \"node:path\";\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { defineWidgetServer, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\nimport { backupCommand } from \"./command\";\nconst execute = promisify(execFile);\nlet running = false;\nconst timestamp = (value:unknown):number|null => { let next=NaN; if(typeof value===\"number\") next=value; else if(typeof value===\"string\") next=Date.parse(value); return Number.isFinite(next) ? next : null; };\nconst record = (input:unknown):BackupRecord => {\n const value = input && typeof input === \"object\" ? input as Record : {};\n const status = value.status;\n return {\n status: status === \"success\" || status === \"failure\" || status === \"running\" ? status : \"unknown\",\n finishedAt: timestamp(value.finishedAt),\n durationSeconds: typeof value.durationSeconds === \"number\" ? value.durationSeconds : null,\n bytes: typeof value.bytes === \"number\" ? value.bytes : null,\n message: typeof value.message === \"string\" ? value.message.slice(0,500) : \"\",\n };\n};\nexport default defineWidgetServer({\n files: async (_input, context) => {\n const { options } = await getWidgetServerContext(context);\n const errors = [];\n const files:BackupFile[] = [];\n let status:BackupRecord|null = null;\n let history:BackupRecord[] = [];\n try {\n if (typeof options.directory !== \"string\" || !isAbsolute(options.directory)) throw new Error(\"Choose an absolute backup directory inside Homarr\");\n const entries = await readdir(options.directory, {withFileTypes:true});\n const matches = entries.filter((entry) => entry.isFile() && (!options.suffix || entry.name.endsWith(options.suffix)));\n if (matches.length > 1000) errors.push(\"The directory has over 1,000 matching files; narrow the suffix to inspect the full set.\");\n for (const entry of matches.slice(0, 1000)) {\n context.signal.throwIfAborted();\n const info = await stat(join(options.directory, entry.name));\n files.push({name:entry.name, bytes:info.size, modifiedAt:info.mtimeMs});\n }\n files.sort((a,b) => b.modifiedAt - a.modifiedAt);\n } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); }\n if (options.statusFile) {\n try {\n if (typeof options.statusFile !== \"string\" || !isAbsolute(options.statusFile)) throw new Error(\"Status file must use an absolute container path\");\n if ((await stat(options.statusFile)).size > 1048576) throw new Error(\"Status report exceeds 1 MiB\");\n const report:unknown = JSON.parse(await readFile(options.statusFile, \"utf8\"));\n status = record(report);\n if (report && typeof report === \"object\" && \"history\" in report && Array.isArray(report.history)) history = report.history.slice(-100).map(record).sort((a,b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0));\n } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); }\n }\n return {files:files.slice(0,100),status,history,errors,running,commandConfigured:Boolean(backupCommand.executable)};\n },\n runBackup: async (_input, context) => {\n const {isPreview} = await getWidgetServerContext(context);\n if (isPreview) return {simulated:true};\n if (!backupCommand.executable || !isAbsolute(backupCommand.executable)) throw new Error(\"Configure an absolute executable in command.ts and activate the package first\");\n if (running) throw new Error(\"A backup command is already running\");\n running = true;\n const started = Date.now();\n try {\n await execute(backupCommand.executable, backupCommand.args, {shell:false,timeout:backupCommand.timeoutMs,maxBuffer:524288,signal:context.signal});\n return {completed:true,durationSeconds:Math.round((Date.now()-started)/1000)};\n } catch (error) {\n const code = error && typeof error === \"object\" && \"code\" in error ? error.code : \"unknown\";\n throw new Error(`The configured backup command failed (${code}). Check the backup service logs.`);\n } finally { running = false; }\n },\n});\n", "command.ts": "// Owner configuration: edit this source file deliberately, then preview and activate.\n// The dashboard cannot supply executable paths or arguments. No shell is used.\nexport const backupCommand = {\n executable: \"\", // Example: \"/usr/local/bin/run-backup\"\n args: [] as string[],\n timeoutMs: 240000,\n};\n", "README.md": "# Filesystem backup monitor\nMount your backup directory and optional status JSON into Homarr. No API connection is required.\n\nThe optional status file is written by your backup system. Example:\n```json\n{\"status\":\"success\",\"finishedAt\":\"2026-09-12T01:00:00Z\",\"durationSeconds\":92,\"bytes\":1048576,\"message\":\"Nightly backup completed\",\"history\":[{\"status\":\"success\",\"finishedAt\":\"2026-09-11T01:00:00Z\",\"durationSeconds\":88}]}\n```\n`status` is success, failure, running, or unknown. `finishedAt` accepts ISO timestamps or epoch milliseconds. The widget reads at most 1 MiB and 100 history records. File timestamps alone report activity, not backup success or restore integrity.\n\nTo enable Run backup, deliberately edit `command.ts` with an absolute executable and fixed argument array. The executable must exist inside Homarr. Activate the reviewed change. The browser supplies no command text; the named full-permission action uses execFile without a shell, bounded output and a timeout. It never runs from a query or in preview. stdout/stderr are not returned to dashboard viewers. The configured program should update the status report itself.\n", "types.ts": "export type BackupOptions = {title:string;directory:string;suffix:string;maximumAgeHours:number;statusFile:string};\nexport interface BackupRecord {status:\"success\"|\"failure\"|\"running\"|\"unknown\";finishedAt:number|null;durationSeconds:number|null;bytes:number|null;message:string;}\nexport interface BackupFile {name:string;bytes:number;modifiedAt:number;}\nexport interface BackupSnapshot {files:BackupFile[];status:BackupRecord|null;history:BackupRecord[];errors:string[];running:boolean;commandConfigured:boolean;}\nexport interface BackupRunResult {simulated?:boolean;completed?:boolean;durationSeconds?:number;}\n" }, "dependencies": {}, "connections": {}, "options": { "title": { "control": "text", "label": "Title", "default": "Backups" }, "directory": { "control": "text", "label": "Directory in the Homarr container", "description": "Mount the backup directory into Homarr and enter its absolute container path.", "default": "/backups" }, "suffix": { "control": "text", "label": "Filename suffix", "default": ".gz" }, "maximumAgeHours": { "control": "number", "label": "Maximum backup age (hours)", "default": 24, "min": 1, "max": 8760 }, "statusFile": { "control": "text", "label": "Status report JSON file", "description": "Optional absolute path to a backup status report inside Homarr. See README.md for the format.", "default": "" } } }