4 Commits

Author SHA1 Message Date
alexei.dolgolyov 16c667ca15 feat(status): per-browser dismissal for Recent Incidents
The Recent Incidents list is derived server-side from raw AppStatus
health-check samples, so there is no incident row to delete and deleting
the underlying samples would corrupt uptime % and the sparkline timeline.
Per-browser, non-destructive dismissal is the right model: localStorage
holds the dismissed (appId, ISO startedAt) keys, the page filters them
out on render, and a Restore affordance brings them back.

- Per-row Dismiss (X) and section-level Clear all
- Restore link appears whenever any incident on the current page is hidden
- Dismissal key is (appId, startedAt) so it survives 24h/7d/30d switches
- Focus is moved to the Restore link after Clear all empties the list
  (otherwise the unmounting button would drop focus to <body>)
- Quota / disabled-localStorage failure is swallowed; in-memory state
  still works for the active session

Hand-rolled <button> elements match 14 other link-styled buttons already
in the project; both use the project-standard focus-visible:ring-2
focus-visible:ring-primary/30 ring.
2026-05-28 15:40:23 +03:00
alexei.dolgolyov dab13518ef feat(backup): harden restore — strict tar, two-phase rollback, degraded state
- Gate flag flipped synchronously in restore route before body parse,
  closing race where concurrent requests could slip through during awaits
- Strict tar extraction rejects symlinks, hardlinks, absolute paths, and
  parent-segment traversal entries
- Staging directory moved to a sibling of the uploads dir so atomic renames
  stay on the same filesystem (Windows %TEMP%/Linux tmpfs were causing EXDEV)
- Two-phase atomic-rename rollback for uploads — never rmrf the live dir
  before the safety is back in place; degraded flag set if rollback can't
  recover cleanly
- Prisma reconnect failure now marks process degraded; hooks.server.ts
  returns 503 to everything except /api/health so orchestrators can recycle
- /api/health distinguishes ok / restoring / degraded / db_down (503s)
- Legacy .db restore now runs structural SQLite integrity check before swap
- Schema-version check tightened: null on either side requires explicit
  allowSchemaMismatch override (was silently treated as a match)
- HMR/multi-import-safe global state (Vite dev reload no longer creates a
  fresh module while a restore is mid-flight)
- VACUUM INTO path: defensive rejection of quote/control characters
- Backup filename regex requires a leading alphanumeric (rejects '.tar.gz',
  '....db' which passed the previous loose pattern)
- Download: RFC 5987 Content-Disposition with filename* + sanitized fallback
- Restore route logs BACKUP_FAILED audit row with phase on failure
2026-05-28 14:56:57 +03:00
alexei.dolgolyov f087551454 feat(ui): cozy polish — primitives, motion, empty states
Adds 7 reusable primitives in src/lib/components/ui/ and migrates ~70 hand-rolled
call sites across forms, admin panels, and routes. Tokens unchanged — same Cozy
Home palette, just consistently applied.

Primitives
- Switch: pill toggle, role=switch, terracotta track, cubic-bezier knob
- Button: 5 variants × 4 sizes, press-squash, loading spinner, buttonClass()
  helper for <a> link-as-CTA cases
- Checkbox: rounded square with animated check-draw + indeterminate
- Select: native <select> with Cozy chevron + matched radius
- Slider: gradient track, terracotta-bordered knob, aria-valuetext
- Input + Field: documented in CLAUDE.md for future use
- 9 buttonClass unit tests

Migrations
- 23 <input type=checkbox> → <Switch> (boolean settings)
- 5 multi-select checkboxes → <Checkbox> (DiscoveryPanel, sys-stats metrics)
- ~28 <select> → <Select>
- 17 <input type=range> → <Slider> (ThemeCustomizer's hue picker kept custom)
- ~25 hand-rolled buttons → <Button> / buttonClass()

Surface polish
- Admin section wrappers: rounded-lg → rounded-[1.4rem] + shadow-soft
  (resolves the Phase-5 tradeoff from the Cozy migration memo)
- BoardPropertiesPanel: live theme preview swatch showing computed hsl() on a
  sample button; hue/sat use Slider; bg/cardSize use Select
- AppHealthBadge: role=status + aria-live=polite; .status-degraded (slow
  amber breathing) and .status-offline (single attention flash) now applied
- AppForm collapse triggers: rotating chevron + aria-expanded
- Empty states for /boards and /apps: inline SVGs using --room-* tokens
  (peach/sky/sage/butter) instead of generic Lucide icons
- Login Remember Me: showcase Switch (first-impression surface)

Motion (src/app.css)
- New cozy-rise / cozy-rise-stagger for staggered grid reveals (/boards, /apps)
- New cozy-expand for accordion sections (healthcheck, integration, wallpaper)
- All motion respects prefers-reduced-motion

CLAUDE.md
- New project guide with a mandatory Frontend reuse table — every primitive
  documented with "never use raw <input type=checkbox>/<select>/<range>" and
  "do not repeat rounded-xl bg-primary px-4 py-2 ..." rules

Verification
- npm run check: 0 errors, 0 warnings, 5831 files
- npm test: 301 passing
- npm run lint: 0 errors (19 pre-existing warnings unchanged)
- npm run build: ✔ done

Branch is feat/cozy-polish, ready to PR against master.
2026-05-28 14:39:53 +03:00
alexei.dolgolyov 555ac9ea63 feat(backup): tar.gz format with uploads + manifest, restore guard
- New tar.gz backup format bundling SQLite snapshot + uploads tree + manifest.json (version, app+schema versions, checksums, dbSize)
- BACKUPS_DIR env override; defaults to /app/data/backups in prod, <cwd>/data/backups in dev (matches uploads convention)
- 503 guard in hooks.server.ts while restore is mid-flight (DB file is being swapped); excludes static assets + /api/health; sets Retry-After: 15
- Legacy .db restore still supported (DB-only)
- Restore endpoint adds schema-mismatch detection + force flag; download/schedule endpoints updated
- 256 MiB free-disk safety margin before backup
- tar dep added to package.json; 18 new backupService tests
- i18n labels (en + ru) for new restore/format states
2026-05-28 14:39:24 +03:00
65 changed files with 3052 additions and 756 deletions
+5
View File
@@ -50,6 +50,11 @@ ALLOW_PRIVATE_NETWORK_FETCH="false"
# scaling horizontally so only one node runs schedulers.
RUN_SCHEDULERS="true"
# Directory where backup archives are written. Defaults to /app/data/backups
# in production and <repo>/data/backups in development. Override if you want
# backups on a separate mount.
BACKUPS_DIR=""
# Optional bearer token for /api/metrics. When set, scrapers must send
# `Authorization: Bearer <token>`. When unset, the endpoint is open (typical
# when the scraper lives on the same private network).
+56
View File
@@ -0,0 +1,56 @@
# web-app-launcher — project guide for Claude
SvelteKit 2 + Svelte 5 (runes) + Tailwind 4 + Prisma + Vitest. Cozy Home design system (warm cream / dusk, terracotta accent, Fraunces + Figtree, soft shadows). Token contract lives in `src/app.css`.
## Frontend
### Basic-component reuse — MANDATORY
When you need any of the following, **use the existing primitive from `src/lib/components/ui/`. Do not hand-roll a new Tailwind class string for a control that already has a primitive.**
| Need | Primitive | Why |
|---|---|---|
| Boolean on/off setting | `Switch.svelte` | Pill toggle, `role="switch"`, AA contrast, terracotta track when on. Default for any "enable X" / "show Y" / "is default" field. **Never use `<input type="checkbox">` for booleans.** |
| Multi-select item in a list | `Checkbox.svelte` | Rounded square with animated check-draw. Only use when the control is truly "pick any number of these," not a single boolean. |
| Dropdown of fixed options | `Select.svelte` | Styled chevron, matches Cozy input radius. Wraps native `<select>`. **Do not use raw `<select>`.** |
| Single-line text / number / email / url / password | `Input.svelte` | Standard rounded-xl, focus ring, invalid state. **Do not repeat the `w-full rounded-xl border border-input bg-background px-3 py-2 ...` string anywhere.** |
| Number in a range (refresh interval, hue, blur, etc.) | `Slider.svelte` | Cozy gradient track, terracotta-bordered knob, value tooltip, `aria-valuetext`. **Do not use raw `<input type="range">`.** |
| Action button (submit, save, cancel, link-as-CTA) | `Button.svelte` | Variants `primary | secondary | outline | ghost | destructive`, sizes `sm | md | lg | icon`, built-in `loading` spinner, press-squash. **Do not repeat `rounded-xl bg-primary px-4 py-2 ...` strings.** |
| Label + hint + error wrapper around a control | `Field.svelte` | Consolidates `<label> + control + <p class="text-xs text-destructive">`. |
| Confirm-before-destructive | `ConfirmDialog.svelte` | Already exists. Use it. |
| Entity / icon / tag picker | `EntityPicker`, `MultiEntityPicker`, `IconPickerButton`, `TagsInput` | Already exist. Reuse. |
### Process
1. Before writing any form control in a `.svelte` file, **scan `src/lib/components/ui/` first**. If a matching primitive exists, import and use it.
2. If you find yourself copying a Tailwind class string verbatim from another file, **stop**: that's the trigger to extract a primitive (or expand an existing one).
3. If you genuinely need a new primitive, add it to `src/lib/components/ui/`, give it a `class?: string` prop merged via `cn()`, document it in this table, and migrate at least two call sites in the same PR so it's not dead code.
4. Tokens (`--primary`, `--card`, `--room-*`, `--shadow-soft`, etc.) are defined once in `src/app.css`. Never hardcode hex/HSL — read from the token.
### Cozy spec quick reminders
- Hero cards: `rounded-[1.4rem]` + `shadow-[var(--shadow-soft)]`. Dense panels: `rounded-xl`. **Never** `rounded-lg` on a section wrapper.
- Headings (`h1`, `h2`, `h3`) automatically get Fraunces via base layer — no need to add `font-display` unless overriding non-heading text.
- Focus uses `focus-visible:ring-2 focus-visible:ring-primary/30` — primitives already do this; mirror it on anything hand-rolled.
- Motion is gentle and present: prefer `cozy-rise` / `cozy-expand` from `app.css` over generic Tailwind animations. All motion classes already respect `prefers-reduced-motion`.
## Backend
- Auth: session cookie + optional OAuth. Roles: `admin` / user / guest. Always check role at the route load function, not the component.
- Validation: Zod schemas live in `src/lib/utils/validators.ts`. Reuse the same schema on client (superForms) and server.
- DB: Prisma. Never query the DB directly from a route — go through `src/lib/server/services/*Service.ts`.
## Testing
- Vitest, Node environment, no DOM (existing pattern). Component tests use the module-scope helpers (e.g., `buttonClass` in `Button.svelte`) rather than rendering — keep that convention.
- Run before committing: `npm run check && npm run lint && npm test && npm run build`.
## Commands
```bash
npm run dev # vite dev on :5181
npm run check # svelte-check (TS + Svelte)
npm run lint # eslint
npm test # vitest run
npm run build # production build
```
+124 -9
View File
@@ -1,18 +1,16 @@
{
"name": "web-app-launcher",
"version": "0.0.1",
"version": "0.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "web-app-launcher",
"version": "0.0.1",
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^6.2.0",
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/typography": "^0.5.19",
"bcryptjs": "^2.4.3",
"bits-ui": "^1.3.0",
"clsx": "^2.1.0",
@@ -30,11 +28,14 @@
"svelte-i18n": "^4.0.1",
"sveltekit-superforms": "^2.22.0",
"tailwind-merge": "^2.6.0",
"tar": "^7.5.15",
"zod": "^3.24.0"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@sveltejs/package": "^2.3.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/svelte": "^5.2.0",
"@types/bcryptjs": "^2.4.6",
@@ -976,6 +977,17 @@
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1888,6 +1900,7 @@
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dev": true,
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
@@ -1899,6 +1912,7 @@
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -2840,6 +2854,14 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"engines": {
"node": ">=18"
}
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
@@ -2967,6 +2989,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"bin": {
"cssesc": "bin/cssesc"
},
@@ -4497,6 +4520,25 @@
"node": "*"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -6130,7 +6172,8 @@
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"dev": true
},
"node_modules/tapable": {
"version": "2.3.2",
@@ -6145,6 +6188,21 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tar": {
"version": "7.5.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/timers-ext": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz",
@@ -6867,7 +6925,8 @@
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/uuid": {
"version": "8.3.2",
@@ -7179,6 +7238,14 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"engines": {
"node": ">=18"
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
@@ -7741,6 +7808,14 @@
"@swc/helpers": "^0.5.0"
}
},
"@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"requires": {
"minipass": "^7.0.4"
}
},
"@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -8274,6 +8349,7 @@
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dev": true,
"requires": {
"postcss-selector-parser": "6.0.10"
},
@@ -8282,6 +8358,7 @@
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"requires": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -8944,6 +9021,11 @@
"readdirp": "^5.0.0"
}
},
"chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="
},
"citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
@@ -9049,7 +9131,8 @@
"cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true
},
"d": {
"version": "1.0.2",
@@ -10121,6 +10204,19 @@
"brace-expansion": "^1.1.7"
}
},
"minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="
},
"minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"requires": {
"minipass": "^7.1.2"
}
},
"mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -11017,7 +11113,8 @@
"tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"dev": true
},
"tapable": {
"version": "2.3.2",
@@ -11025,6 +11122,18 @@
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
"dev": true
},
"tar": {
"version": "7.5.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
"requires": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
}
},
"timers-ext": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz",
@@ -11426,7 +11535,8 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"uuid": {
"version": "8.3.2",
@@ -11581,6 +11691,11 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
},
"yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="
},
"yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+1
View File
@@ -42,6 +42,7 @@
"svelte-i18n": "^4.0.1",
"sveltekit-superforms": "^2.22.0",
"tailwind-merge": "^2.6.0",
"tar": "^7.5.15",
"zod": "^3.24.0"
},
"prisma": {
+82 -1
View File
@@ -221,11 +221,46 @@
}
}
@keyframes status-breathe {
0%,
100% {
opacity: 0.85;
}
50% {
opacity: 1;
}
}
@keyframes status-flash {
0% {
transform: scale(1);
opacity: 0.6;
}
30% {
transform: scale(1.25);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.status-online {
animation: status-pulse 2s ease-in-out infinite;
color: var(--status-online);
}
.status-degraded {
animation: status-breathe 2.6s ease-in-out infinite;
color: var(--status-degraded);
}
.status-offline {
animation: status-flash 0.6s ease-out 1;
color: var(--status-offline);
}
/* ===== Card Style Variants ===== */
.card-solid {
background: var(--card);
@@ -330,6 +365,47 @@
}
}
/* ===== Cozy entrance reveal ===== */
@keyframes cozy-rise {
0% {
opacity: 0;
transform: translateY(12px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.cozy-rise {
animation: cozy-rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
/* For staggered grid reveals — set --i as 0,1,2,... per item */
.cozy-rise-stagger {
animation: cozy-rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-delay: calc(var(--i, 0) * 55ms);
}
/* ===== Cozy accordion (height slide for show/hide) ===== */
@keyframes cozy-expand {
0% {
opacity: 0;
transform: translateY(-4px);
max-height: 0;
}
100% {
opacity: 1;
transform: translateY(0);
max-height: 1200px;
}
}
.cozy-expand {
overflow: hidden;
animation: cozy-expand 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
/* ===== Cozy greeting wave ===== */
@keyframes cozy-wave {
0%,
@@ -359,7 +435,12 @@
@media (prefers-reduced-motion: reduce) {
.cozy-wave,
.status-online {
.status-online,
.status-degraded,
.status-offline,
.cozy-rise,
.cozy-rise-stagger,
.cozy-expand {
animation: none;
}
.card-hover:hover {
+52
View File
@@ -6,6 +6,7 @@ import * as apiTokenService from '$lib/server/services/apiTokenService.js';
import { extractBearerToken } from '$lib/server/middleware/authenticate.js';
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
import { isRestoring, isDegraded, getDegradedReason } from '$lib/server/services/backupService.js';
import { startScheduler as startHealthcheckScheduler } from '$lib/server/jobs/healthcheckScheduler.js';
import {
clearSessionCookies,
@@ -52,6 +53,57 @@ function isPublicPath(pathname: string): boolean {
}
export const handle: Handle = async ({ event, resolve }) => {
const reqPath = event.url.pathname;
// While a restore is mid-flight, Prisma is disconnected and the live DB
// file (and uploads tree) is being swapped. Any other request that
// touches the DB or the uploads dir would crash; return 503 instead.
//
// Whitelist: bundled SvelteKit assets (immutable, served from disk paths
// that are not affected by restore) and /api/health (so liveness probes
// can still observe the degraded state). /uploads/ is NOT whitelisted —
// uploaded files live in the dir being renamed and concurrent reads on
// Windows can block the rename outright.
if (isRestoring()) {
const isBundledAsset = reqPath.startsWith('/_app/') || reqPath.startsWith('/favicon');
if (!(isBundledAsset || reqPath === '/api/health')) {
return new Response(
JSON.stringify({
success: false,
data: null,
error: 'Database restore in progress. Please retry in a moment.'
}),
{
status: 503,
headers: {
'Content-Type': 'application/json',
'Retry-After': '15'
}
}
);
}
}
// After a failed restore + failed rollback the process is in an unknown
// state. Return 503 for everything except the health endpoint so the
// orchestrator can observe and recycle the container.
if (isDegraded() && reqPath !== '/api/health') {
return new Response(
JSON.stringify({
success: false,
data: null,
error: `Service degraded: ${getDegradedReason() ?? 'unknown reason'}. Restart required.`
}),
{
status: 503,
headers: {
'Content-Type': 'application/json',
'Retry-After': '60'
}
}
);
}
event.locals.user = null;
event.locals.session = null;
event.locals.apiTokenScope = null;
+164 -37
View File
@@ -1,11 +1,15 @@
<script lang="ts">
import { untrack } from 'svelte';
import { t } from 'svelte-i18n';
import Switch from '$lib/components/ui/Switch.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface BackupInfo {
filename: string;
size: number;
createdAt: string;
format: 'tar.gz' | 'db';
}
interface BackupSchedule {
@@ -14,6 +18,14 @@
backupMaxCount: number;
}
interface SchedulerStats {
successCount: number;
failureCount: number;
lastSuccessAt: string | null;
lastFailureAt: string | null;
lastFailureReason: string | null;
}
type CronPreset = 'daily' | 'twice_daily' | 'weekly' | 'custom';
let backups: BackupInfo[] = $state([]);
@@ -22,6 +34,13 @@
backupCronExpression: '0 3 * * *',
backupMaxCount: 10
});
let stats: SchedulerStats = $state({
successCount: 0,
failureCount: 0,
lastSuccessAt: null,
lastFailureAt: null,
lastFailureReason: null
});
let creating = $state(false);
let savingSchedule = $state(false);
@@ -29,6 +48,9 @@
let deletingFilename: string | null = $state(null);
let confirmRestore: string | null = $state(null);
let confirmDelete: string | null = $state(null);
let confirmSchemaMismatch = $state(false);
let pendingSchemaMismatchFile: string | null = $state(null);
let pendingSchemaMismatchMessage = $state('');
let statusMessage = $state('');
let statusType: 'success' | 'error' | '' = $state('');
let customCron = $state('');
@@ -48,7 +70,8 @@
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatDate(iso: string): string {
@@ -69,6 +92,7 @@
if (result.success) {
backups = result.data.backups;
schedule = result.data.schedule;
if (result.data.stats) stats = result.data.stats;
cronPreset = detectPreset(schedule.backupCronExpression);
if (cronPreset === 'custom') {
customCron = schedule.backupCronExpression;
@@ -111,23 +135,62 @@
document.body.removeChild(a);
}
async function performRestore(filename: string, allowSchemaMismatch: boolean): Promise<void> {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowSchemaMismatch })
});
if (response.status === 409) {
const errBody = await response.json().catch(() => ({}));
pendingSchemaMismatchFile = filename;
pendingSchemaMismatchMessage = errBody.error || $t('admin.backup_restore_schema_mismatch');
confirmSchemaMismatch = true;
return;
}
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to restore backup');
}
statusMessage = $t('admin.backup_restore_success');
statusType = 'success';
if (result.data?.forceLogout) {
setTimeout(() => {
window.location.href = '/login';
}, 2500);
}
}
async function handleRestore(filename: string) {
clearStatus();
confirmRestore = null;
restoringFilename = filename;
try {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}/restore`, {
method: 'POST'
});
const result = await response.json();
await performRestore(filename, false);
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
statusType = 'error';
} finally {
restoringFilename = null;
}
}
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to restore backup');
}
async function handleSchemaMismatchConfirm() {
const filename = pendingSchemaMismatchFile;
confirmSchemaMismatch = false;
pendingSchemaMismatchFile = null;
pendingSchemaMismatchMessage = '';
if (!filename) return;
statusMessage = $t('admin.backup_restore_success');
statusType = 'success';
clearStatus();
restoringFilename = filename;
try {
await performRestore(filename, true);
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
statusType = 'error';
@@ -195,26 +258,20 @@
}
}
// Load backups on mount (untrack to avoid infinite re-trigger)
$effect(() => {
untrack(() => loadBackups());
});
</script>
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-1 text-lg font-semibold text-card-foreground">{$t('admin.backup_title')}</h2>
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.backup_description')}</p>
<!-- Create Backup -->
<div class="mb-6">
<button
type="button"
onclick={handleCreate}
disabled={creating}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
<Button onclick={handleCreate} disabled={creating} loading={creating}>
{creating ? $t('admin.backup_creating') : $t('admin.backup_create')}
</button>
</Button>
</div>
<!-- Backup List -->
@@ -229,6 +286,7 @@
<thead>
<tr class="border-b border-border text-xs uppercase text-muted-foreground">
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_filename')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_format')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_size')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_date')}</th>
<th class="pb-2 font-medium">{$t('admin.backup_actions')}</th>
@@ -238,6 +296,20 @@
{#each backups as backup (backup.filename)}
<tr class="border-b border-border/50">
<td class="py-2.5 pr-4 font-mono text-xs text-foreground">{backup.filename}</td>
<td class="py-2.5 pr-4 text-xs text-muted-foreground">
{#if backup.format === 'tar.gz'}
<span class="rounded-md bg-status-online/10 px-2 py-0.5 text-status-online-ink">
{$t('admin.backup_format_full')}
</span>
{:else}
<span
class="rounded-md bg-status-degraded/10 px-2 py-0.5 text-status-degraded-ink"
title={$t('admin.backup_format_legacy_tooltip')}
>
{$t('admin.backup_format_legacy')}
</span>
{/if}
</td>
<td class="py-2.5 pr-4 text-muted-foreground">{formatBytes(backup.size)}</td>
<td class="py-2.5 pr-4 text-muted-foreground">{formatDate(backup.createdAt)}</td>
<td class="py-2.5">
@@ -281,6 +353,7 @@
<!-- Restore Confirmation Dialog -->
{#if confirmRestore}
{@const target = backups.find((b) => b.filename === confirmRestore)}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 w-full max-w-md rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]">
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
@@ -289,6 +362,14 @@
<p class="mb-4 text-sm text-muted-foreground">
{$t('admin.backup_restore_confirm')}
</p>
{#if target?.format === 'db'}
<p class="mb-3 rounded-md border border-status-degraded/30 bg-status-degraded/10 p-3 text-xs text-status-degraded-ink">
{$t('admin.backup_restore_legacy_warning')}
</p>
{/if}
<p class="mb-3 rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-300">
{$t('admin.backup_restore_logout_warning')}
</p>
<p class="mb-4 font-mono text-xs text-foreground">{confirmRestore}</p>
<div class="flex justify-end gap-3">
<button
@@ -301,7 +382,8 @@
<button
type="button"
onclick={() => confirmRestore && handleRestore(confirmRestore)}
class="rounded-xl px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5" style="background: var(--status-degraded);"
class="rounded-xl px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5"
style="background: var(--status-degraded);"
>
{$t('admin.backup_restore')}
</button>
@@ -310,6 +392,40 @@
</div>
{/if}
<!-- Schema-mismatch follow-up confirmation -->
{#if confirmSchemaMismatch}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 w-full max-w-md rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]">
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
{$t('admin.backup_restore_schema_mismatch_title')}
</h3>
<p class="mb-3 text-sm text-muted-foreground">
{$t('admin.backup_restore_schema_mismatch_intro')}
</p>
<pre class="mb-4 max-h-32 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 font-mono text-[10px] text-foreground">{pendingSchemaMismatchMessage}</pre>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => {
confirmSchemaMismatch = false;
pendingSchemaMismatchFile = null;
}}
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="button"
onclick={handleSchemaMismatchConfirm}
class="rounded-xl bg-destructive px-4 py-2 text-sm font-semibold text-destructive-foreground hover:bg-destructive/90"
>
{$t('admin.backup_restore_schema_mismatch_force')}
</button>
</div>
</div>
</div>
{/if}
<!-- Delete Confirmation Dialog -->
{#if confirmDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
@@ -350,11 +466,10 @@
<div class="space-y-4">
<!-- Enable toggle -->
<label class="flex items-center gap-3">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-3">
<Switch
bind:checked={schedule.backupEnabled}
class="h-4 w-4 rounded border-border text-primary focus-visible:ring-primary/30"
ariaLabel={$t('admin.backup_schedule_enabled')}
/>
<span class="text-sm text-foreground">{$t('admin.backup_schedule_enabled')}</span>
</label>
@@ -365,16 +480,12 @@
<label for="cron-preset" class="mb-1 block text-sm text-muted-foreground">
{$t('admin.backup_schedule_cron')}
</label>
<select
id="cron-preset"
bind:value={cronPreset}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-auto"
>
<Select id="cron-preset" bind:value={cronPreset} class="sm:w-auto">
<option value="daily">{$t('admin.backup_schedule_preset_daily')}</option>
<option value="twice_daily">{$t('admin.backup_schedule_preset_twice_daily')}</option>
<option value="weekly">{$t('admin.backup_schedule_preset_weekly')}</option>
<option value="custom">{$t('admin.backup_schedule_preset_custom')}</option>
</select>
</Select>
</div>
{#if cronPreset === 'custom'}
@@ -402,16 +513,32 @@
class="w-24 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
</div>
<!-- Scheduler stats -->
<div class="rounded-lg border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<div class="grid grid-cols-2 gap-1">
<span>{$t('admin.backup_stats_success_count')}</span>
<span class="text-right font-mono text-foreground">{stats.successCount}</span>
<span>{$t('admin.backup_stats_failure_count')}</span>
<span class="text-right font-mono {stats.failureCount > 0 ? 'text-destructive' : 'text-foreground'}">{stats.failureCount}</span>
{#if stats.lastSuccessAt}
<span>{$t('admin.backup_stats_last_success')}</span>
<span class="text-right font-mono text-foreground">{formatDate(stats.lastSuccessAt)}</span>
{/if}
{#if stats.lastFailureAt}
<span>{$t('admin.backup_stats_last_failure')}</span>
<span class="text-right font-mono text-destructive">{formatDate(stats.lastFailureAt)}</span>
{/if}
</div>
{#if stats.lastFailureReason}
<p class="mt-2 break-words font-mono text-[10px] text-destructive">{stats.lastFailureReason}</p>
{/if}
</div>
{/if}
<button
type="button"
onclick={handleSaveSchedule}
disabled={savingSchedule}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
<Button onclick={handleSaveSchedule} disabled={savingSchedule} loading={savingSchedule}>
{savingSchedule ? $t('admin.backup_schedule_saving') : $t('admin.backup_schedule_save')}
</button>
</Button>
</div>
</div>
+14 -15
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import Checkbox from '$lib/components/ui/Checkbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface DiscoveredService {
name: string;
@@ -137,20 +139,19 @@
const selectableCount = $derived(services.filter((s) => !s.alreadyRegistered).length);
</script>
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_title')}</h2>
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.discovery_description')}</p>
<!-- Scan Button -->
<div class="mb-6">
<button
type="button"
<Button
onclick={handleScan}
disabled={scanning || (!dockerSocketPath && !traefikApiUrl)}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
loading={scanning}
>
{scanning ? $t('admin.discovery_scanning') : $t('admin.discovery_scan')}
</button>
</Button>
</div>
<!-- Scan Errors -->
@@ -169,12 +170,12 @@
<thead>
<tr class="border-b border-border">
<th class="px-2 py-2 text-left">
<input
type="checkbox"
<Checkbox
checked={selected.size === selectableCount && selectableCount > 0}
indeterminate={selected.size > 0 && selected.size < selectableCount}
onchange={toggleSelectAll}
disabled={selectableCount === 0}
class="h-4 w-4 rounded border-input"
ariaLabel={$t('admin.discovery_select_all') ?? 'Select all'}
/>
</th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('common.name')}</th>
@@ -187,12 +188,11 @@
{#each services as service, i (service.url)}
<tr class="border-b border-border/50 hover:bg-muted/50">
<td class="px-2 py-2">
<input
type="checkbox"
<Checkbox
checked={selected.has(i)}
onchange={() => toggleSelect(i)}
disabled={service.alreadyRegistered}
class="h-4 w-4 rounded border-input"
ariaLabel={`Select ${service.name}`}
/>
</td>
<td class="px-2 py-2 font-medium text-foreground">{service.name}</td>
@@ -227,14 +227,13 @@
<!-- Approve button -->
{#if selectableCount > 0}
<div class="mt-4">
<button
type="button"
<Button
onclick={handleApprove}
disabled={approving || selected.size === 0}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
loading={approving}
>
{approving ? $t('admin.discovery_approving') : $t('admin.discovery_approve')} ({selected.size})
</button>
</Button>
</div>
{/if}
{/if}
+8 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { enhance } from '$app/forms';
import Switch from '$lib/components/ui/Switch.svelte';
interface GroupWithCount {
id: string;
@@ -64,8 +65,13 @@
placeholder={$t('common.description')}
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
/>
<label class="flex items-center gap-1 text-xs text-foreground">
<input name="isDefault" type="checkbox" bind:checked={editIsDefault} class="h-3.5 w-3.5" />
<label class="flex cursor-pointer items-center gap-2 text-xs text-foreground">
<Switch
name="isDefault"
bind:checked={editIsDefault}
size="sm"
ariaLabel={$t('admin.default_column')}
/>
{$t('admin.default_column')}
</label>
<button type="submit" class="text-xs text-primary hover:underline">{$t('common.save')}</button>
@@ -129,7 +129,7 @@
<div class="space-y-4">
<!-- Grant form -->
<div class="rounded-lg border border-border bg-card p-4">
<div class="rounded-xl border border-border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('admin.perm_title')}</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
<div>
+27 -27
View File
@@ -4,6 +4,9 @@
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import type { z } from 'zod';
import CustomCssEditor from '$lib/components/settings/CustomCssEditor.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Button from '$lib/components/ui/Button.svelte';
let {
form: formData,
@@ -46,32 +49,34 @@
<form method="POST" action="?/update" use:enhance class="space-y-8">
<!-- Authentication -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.authentication')}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="authMode" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.auth_mode')}</label>
<select
<Select
id="authMode"
name="authMode"
bind:value={$form.authMode}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="local">{$t('admin.auth_local')}</option>
<option value="oauth">{$t('admin.auth_oauth')}</option>
<option value="both">{$t('admin.auth_both')}</option>
</select>
</Select>
{#if $errors.authMode}<span class="text-xs text-destructive">{$errors.authMode}</span>{/if}
</div>
<div class="flex items-center gap-2 pt-6">
<input
<div class="flex items-center gap-3 pt-6">
<Switch
id="registrationEnabled"
name="registrationEnabled"
type="checkbox"
bind:checked={$form.registrationEnabled}
class="h-4 w-4 rounded border-input"
ariaLabelledby="registrationEnabledLabel"
/>
<label for="registrationEnabled" class="text-sm font-medium text-foreground">
<label
id="registrationEnabledLabel"
for="registrationEnabled"
class="cursor-pointer text-sm font-medium text-foreground"
>
{$t('admin.registration_enabled')}
</label>
</div>
@@ -79,7 +84,7 @@
</section>
<!-- OAuth Configuration -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.oauth_config')}</h2>
<p class="mb-4 text-xs text-muted-foreground">
{$t('admin.oauth_description')}
@@ -120,14 +125,14 @@
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
</div>
<div class="sm:col-span-2">
<button
type="button"
<Button
variant="outline"
onclick={testOAuthConnection}
disabled={oauthTesting}
class="rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
loading={oauthTesting}
>
{oauthTesting ? $t('admin.oauth_testing') : $t('admin.oauth_test')}
</button>
</Button>
{#if oauthTestResult}
<span class="ml-3 text-sm {oauthTestSuccess ? 'text-status-online-ink dark:text-status-online-ink' : 'text-destructive'}">
{oauthTestResult}
@@ -138,20 +143,19 @@
</section>
<!-- Theme Defaults -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.theme_defaults')}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="defaultTheme" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_theme')}</label>
<select
<Select
id="defaultTheme"
name="defaultTheme"
bind:value={$form.defaultTheme}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="dark">{$t('theme.dark')}</option>
<option value="light">{$t('theme.light')}</option>
</select>
</Select>
</div>
<div>
<label for="defaultPrimaryColor" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_primary_color')}</label>
@@ -178,7 +182,7 @@
</section>
<!-- Healthcheck Defaults -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.healthcheck_defaults')}</h2>
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.healthcheck_defaults_description')}</p>
<div>
@@ -196,7 +200,7 @@
</section>
<!-- Service Discovery Configuration -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_config')}</h2>
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.discovery_config_description')}</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -226,7 +230,7 @@
</section>
<!-- System Custom CSS -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.custom_css') ?? 'Custom CSS'}</h2>
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.custom_css_description') ?? 'System-wide custom CSS applied to all pages. Scoped to .custom-css-scope to prevent breaking core UI.'}</p>
<input type="hidden" name="customCss" value={$form.customCss ?? ''} />
@@ -242,12 +246,8 @@
{/if}
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
disabled={$delayed}
>
<Button type="submit" size="lg" disabled={$delayed} loading={$delayed}>
{$delayed ? $t('admin.saving_settings') : $t('admin.save_settings')}
</button>
</Button>
</div>
</form>
+5 -13
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from '$lib/components/ui/Button.svelte';
interface Tag {
id: string;
@@ -115,13 +116,9 @@
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-bold text-card-foreground">Tag Management</h2>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Button onclick={() => (showCreateForm = !showCreateForm)}>
{showCreateForm ? 'Cancel' : 'New Tag'}
</button>
</Button>
</div>
{#if error}
@@ -132,7 +129,7 @@
<!-- Create Form -->
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-4">
<div class="cozy-expand mb-6 rounded-xl border border-border bg-card p-4">
<form onsubmit={(e) => { e.preventDefault(); createTag(); }} class="flex flex-wrap items-end gap-3">
<div>
<label for="tag-name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
@@ -157,12 +154,7 @@
<span class="text-xs text-muted-foreground">{newColor}</span>
</div>
</div>
<button
type="submit"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create Tag
</button>
<Button type="submit">Create Tag</Button>
</form>
</div>
{/if}
+42 -20
View File
@@ -10,6 +10,8 @@
import TagsInput from '$lib/components/ui/TagsInput.svelte';
import IconGrid from '$lib/components/ui/IconGrid.svelte';
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Button from '$lib/components/ui/Button.svelte';
type AppSchema = z.infer<typeof createAppSchema>;
@@ -219,22 +221,34 @@
<button
type="button"
onclick={() => (showAdvanced = !showAdvanced)}
class="text-sm text-muted-foreground hover:text-foreground"
aria-expanded={showAdvanced}
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
class="h-3.5 w-3.5 transition-transform duration-200 {showAdvanced ? 'rotate-90' : ''}"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="6 4 10 8 6 12" />
</svg>
{showAdvanced ? $t('app.healthcheck_hide') : $t('app.healthcheck_show')} {$t('app.healthcheck_toggle')}
</button>
{#if showAdvanced}
<div class="space-y-4 rounded-md border border-border p-4">
<div class="flex items-center gap-2">
<input
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
<div class="flex items-center gap-3">
<Switch
id="healthcheckEnabled"
name="healthcheckEnabled"
type="checkbox"
bind:checked={$form.healthcheckEnabled}
class="rounded border-input"
ariaLabelledby="healthcheckEnabledLabel"
/>
<label for="healthcheckEnabled" class="text-sm text-card-foreground">
<label id="healthcheckEnabledLabel" for="healthcheckEnabled" class="cursor-pointer text-sm text-card-foreground">
{$t('app.healthcheck_enabled')}
</label>
</div>
@@ -320,22 +334,34 @@
<button
type="button"
onclick={() => (showIntegration = !showIntegration)}
class="text-sm text-muted-foreground hover:text-foreground"
aria-expanded={showIntegration}
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
class="h-3.5 w-3.5 transition-transform duration-200 {showIntegration ? 'rotate-90' : ''}"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="6 4 10 8 6 12" />
</svg>
{showIntegration ? 'Hide' : 'Show'} Integration Settings
</button>
{#if showIntegration}
<div class="space-y-4 rounded-md border border-border p-4">
<div class="flex items-center gap-2">
<input
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
<div class="flex items-center gap-3">
<Switch
id="integrationEnabled"
name="integrationEnabled"
type="checkbox"
bind:checked={$form.integrationEnabled}
class="rounded border-input"
ariaLabelledby="integrationEnabledLabel"
/>
<label for="integrationEnabled" class="text-sm text-card-foreground">
<label id="integrationEnabledLabel" for="integrationEnabled" class="cursor-pointer text-sm text-card-foreground">
Enable Integration
</label>
</div>
@@ -409,16 +435,12 @@
{/if}
<div class="flex justify-end">
<button
type="submit"
disabled={$submitting}
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
<Button type="submit" size="lg" disabled={$submitting} loading={$submitting}>
{#if $submitting}
{$t('app.saving')}
{:else}
{mode === 'edit' ? $t('app.update') : $t('app.save')}
{/if}
</button>
</Button>
</div>
</form>
+5 -2
View File
@@ -12,9 +12,9 @@
case 'online':
return { color: 'var(--status-online)', ink: 'var(--status-online-ink)', cssClass: 'status-online', textKey: 'status.online' };
case 'offline':
return { color: 'var(--status-offline)', ink: 'var(--status-offline-ink)', cssClass: '', textKey: 'status.offline' };
return { color: 'var(--status-offline)', ink: 'var(--status-offline-ink)', cssClass: 'status-offline', textKey: 'status.offline' };
case 'degraded':
return { color: 'var(--status-degraded)', ink: 'var(--status-degraded-ink)', cssClass: '', textKey: 'status.degraded' };
return { color: 'var(--status-degraded)', ink: 'var(--status-degraded-ink)', cssClass: 'status-degraded', textKey: 'status.degraded' };
default:
return { color: 'var(--status-unknown)', ink: 'var(--status-unknown-ink)', cssClass: '', textKey: 'status.unknown' };
}
@@ -24,10 +24,13 @@
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold"
style="color: {config.ink}; background: color-mix(in srgb, {config.color} 14%, transparent);"
role="status"
aria-live="polite"
>
<span
class="inline-block h-2 w-2 rounded-full {config.cssClass}"
style="background: {config.color};"
aria-hidden="true"
></span>
<span>{$t(config.textKey)}</span>
</span>
+3 -7
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import Button from '$lib/components/ui/Button.svelte';
interface LinkItem {
id: string;
@@ -192,12 +193,7 @@
</div>
<!-- Save Button -->
<button
type="button"
onclick={saveLinks}
disabled={saving}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Button onclick={saveLinks} disabled={saving} loading={saving}>
{saving ? 'Saving...' : 'Save Links'}
</button>
</Button>
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import type { IntegrationFieldDescriptor } from '$lib/server/integrations/types.js';
import Switch from '$lib/components/ui/Switch.svelte';
interface Props {
fields: IntegrationFieldDescriptor[];
@@ -23,16 +24,15 @@
{/if}
</label>
{#if field.type === 'boolean'}
<label class="flex items-center gap-2">
<input
<div class="flex items-center gap-3">
<Switch
id="{idPrefix}-{field.name}"
type="checkbox"
checked={!!values[field.name]}
onchange={(e) => onchange(field.name, e.currentTarget.checked)}
class="h-4 w-4 rounded border-input accent-primary"
onchange={(checked) => onchange(field.name, checked)}
ariaLabel={field.label}
/>
<span class="text-sm text-muted-foreground">{field.description ?? ''}</span>
</label>
</div>
{:else if field.type === 'number'}
<input
id="{idPrefix}-{field.name}"
@@ -105,7 +105,7 @@
<div class="space-y-4">
<!-- Grant form -->
<div class="rounded-lg border border-border bg-card p-4">
<div class="rounded-xl border border-border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('board.access_grant')}</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div>
@@ -3,6 +3,9 @@
import { editMode } from '$lib/stores/editMode.svelte.js';
import { fade, fly } from 'svelte/transition';
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte';
import Slider from '$lib/components/ui/Slider.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface BoardData {
id: string;
@@ -124,50 +127,79 @@
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"></textarea>
</div>
<!-- Theme preview swatch -->
<div class="flex items-center gap-3 rounded-xl border border-border bg-muted/30 p-3">
<span
class="h-12 w-12 shrink-0 rounded-2xl shadow-[var(--shadow-soft)]"
style="background: hsl({themeHue} {themeSaturation}% 56%);"
aria-hidden="true"
></span>
<div class="flex-1">
<p class="text-xs font-medium text-foreground">{$t('board.theme_preview') ?? 'Theme preview'}</p>
<p class="font-mono text-xs text-muted-foreground">hsl({themeHue}°, {themeSaturation}%, 56%)</p>
</div>
<button
type="button"
class="rounded-xl px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-[var(--shadow-soft)]"
style="background: hsl({themeHue} {themeSaturation}% 56%);"
tabindex="-1"
aria-hidden="true"
>
{$t('common.sample') ?? 'Sample'}
</button>
</div>
<!-- Theme Hue -->
<div>
<label for="bp-hue" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_hue') ?? 'Theme Hue'}</label>
<input id="bp-hue" type="range" min="0" max="360" bind:value={themeHue}
class="w-full accent-primary" />
<span class="text-xs text-muted-foreground">{themeHue}°</span>
<label for="bp-hue" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>{$t('board.theme_hue') ?? 'Theme Hue'}</span>
<span class="tabular-nums text-xs text-muted-foreground">{themeHue}°</span>
</label>
<Slider id="bp-hue" min={0} max={360} bind:value={themeHue} ariaLabel={$t('board.theme_hue') ?? 'Theme Hue'} />
</div>
<!-- Theme Saturation -->
<div>
<label for="bp-sat" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_saturation') ?? 'Saturation'}</label>
<input id="bp-sat" type="range" min="0" max="100" bind:value={themeSaturation}
class="w-full accent-primary" />
<span class="text-xs text-muted-foreground">{themeSaturation}%</span>
<label for="bp-sat" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>{$t('board.theme_saturation') ?? 'Saturation'}</span>
<span class="tabular-nums text-xs text-muted-foreground">{themeSaturation}%</span>
</label>
<Slider id="bp-sat" min={0} max={100} bind:value={themeSaturation} ariaLabel={$t('board.theme_saturation') ?? 'Saturation'} />
</div>
<!-- Background Type -->
<div>
<label for="bp-bg" class="mb-1 block text-sm font-medium text-foreground">{$t('board.background') ?? 'Background'}</label>
<select id="bp-bg" bind:value={backgroundType}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30">
<Select id="bp-bg" bind:value={backgroundType}>
<option value="none">None</option>
<option value="mesh">Mesh Gradient</option>
<option value="particles">Particles</option>
<option value="aurora">Aurora</option>
<option value="wallpaper">Wallpaper</option>
</select>
</Select>
</div>
<!-- Wallpaper settings (conditional) -->
{#if backgroundType === 'wallpaper'}
<div class="space-y-3 rounded-lg border border-border bg-background/50 p-3">
<div class="cozy-expand space-y-3 rounded-xl border border-border bg-background/50 p-3">
<div>
<label for="bp-wp-url" class="mb-1 block text-sm font-medium text-foreground">Wallpaper URL</label>
<input id="bp-wp-url" type="text" bind:value={wallpaperUrl} placeholder="https://..."
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30" />
</div>
<div>
<label for="bp-wp-blur" class="mb-1 block text-sm font-medium text-foreground">Blur ({wallpaperBlur}px)</label>
<input id="bp-wp-blur" type="range" min="0" max="20" bind:value={wallpaperBlur} class="w-full accent-primary" />
<label for="bp-wp-blur" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>Blur</span>
<span class="tabular-nums text-xs text-muted-foreground">{wallpaperBlur}px</span>
</label>
<Slider id="bp-wp-blur" min={0} max={20} bind:value={wallpaperBlur} ariaLabel="Blur" />
</div>
<div>
<label for="bp-wp-overlay" class="mb-1 block text-sm font-medium text-foreground">Overlay ({Math.round(wallpaperOverlay * 100)}%)</label>
<input id="bp-wp-overlay" type="range" min="0" max="1" step="0.05" bind:value={wallpaperOverlay} class="w-full accent-primary" />
<label for="bp-wp-overlay" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>Overlay</span>
<span class="tabular-nums text-xs text-muted-foreground">{Math.round(wallpaperOverlay * 100)}%</span>
</label>
<Slider id="bp-wp-overlay" min={0} max={1} step={0.05} bind:value={wallpaperOverlay} ariaLabel="Overlay" />
</div>
</div>
{/if}
@@ -175,12 +207,11 @@
<!-- Card Size -->
<div>
<label for="bp-cardsize" class="mb-1 block text-sm font-medium text-foreground">{$t('board.card_size') ?? 'Card Size'}</label>
<select id="bp-cardsize" bind:value={cardSize}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30">
<Select id="bp-cardsize" bind:value={cardSize}>
<option value="compact">Compact</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
</Select>
</div>
<!-- Custom CSS -->
@@ -194,19 +225,11 @@
<!-- Footer -->
<div class="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
<button
type="button"
onclick={onClose}
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
>
<Button variant="outline" onclick={onClose}>
{$t('common.cancel') ?? 'Cancel'}
</button>
<button
type="button"
onclick={handleSave}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
</Button>
<Button variant="primary" onclick={handleSave}>
{$t('common.apply') ?? 'Apply'}
</button>
</Button>
</div>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { TargetType, PermissionLevel } from '$lib/utils/constants.js';
import Switch from '$lib/components/ui/Switch.svelte';
import {
loadBoardPermissions,
grantBoardPermission,
@@ -188,19 +189,18 @@
</div>
<!-- Guest access toggle -->
<div class="mb-4 rounded-lg border border-border bg-muted/30 p-3">
<label class="flex items-center gap-3 text-sm text-foreground">
<input
type="checkbox"
<div class="mb-4 rounded-xl border border-border bg-muted/30 p-3">
<div class="flex items-center gap-3 text-sm text-foreground">
<Switch
checked={isGuestAccessible}
onchange={(e) => onGuestToggle(e.currentTarget.checked)}
class="h-4 w-4 rounded border-input accent-primary"
onchange={onGuestToggle}
ariaLabel={$t('board.guest_accessible')}
/>
<div>
<span class="font-medium">{$t('board.guest_accessible')}</span>
<p class="text-xs text-muted-foreground">{$t('board.share_guest_description')}</p>
</div>
</label>
</div>
</div>
<!-- Quick add permission -->
+2 -6
View File
@@ -8,6 +8,7 @@
import { ui } from '$lib/stores/ui.svelte.js';
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
import { goto } from '$app/navigation';
import { buttonClass } from '$lib/components/ui/Button.svelte';
interface Props {
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
@@ -223,11 +224,6 @@
</DropdownMenu.Portal>
</DropdownMenu.Root>
{:else}
<a
href="/login"
class="rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-colors hover:bg-primary/90"
>
{$t('auth.login')}
</a>
<a href="/login" class={buttonClass()}>{$t('auth.login')}</a>
{/if}
</header>
@@ -3,6 +3,7 @@
import { browser } from '$app/environment';
import { Download, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import Button from '$lib/components/ui/Button.svelte';
const DISMISS_KEY = 'wal-install-prompt-dismissed';
@@ -83,13 +84,9 @@
</p>
</div>
<button
type="button"
onclick={install}
class="shrink-0 rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Button onclick={install} class="shrink-0">
{$t('install.button')}
</button>
</Button>
<button
type="button"
@@ -1,6 +1,8 @@
<script lang="ts">
import { Eye, EyeOff } from 'lucide-svelte';
import { NotificationType } from '$lib/utils/constants.js';
import Switch from '$lib/components/ui/Switch.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface ChannelData {
readonly id?: string;
@@ -112,7 +114,7 @@
}
</script>
<div class="rounded-lg border border-border bg-card p-6">
<div class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h3 class="mb-4 text-lg font-semibold text-card-foreground">
{channel ? 'Edit Channel' : 'Add Notification Channel'}
</h3>
@@ -271,14 +273,9 @@
{/if}
<!-- Enabled Toggle -->
<div class="flex items-center gap-2">
<input
id="channel-enabled"
type="checkbox"
bind:checked={enabled}
class="h-4 w-4 rounded border-input"
/>
<label for="channel-enabled" class="text-sm text-foreground">Enabled</label>
<div class="flex items-center gap-3">
<Switch id="channel-enabled" bind:checked={enabled} ariaLabelledby="channel-enabled-label" />
<label id="channel-enabled-label" for="channel-enabled" class="cursor-pointer text-sm text-foreground">Enabled</label>
</div>
<!-- Test Result -->
@@ -290,29 +287,17 @@
<!-- Actions -->
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Button type="submit">
{channel ? 'Update' : 'Create'} Channel
</button>
</Button>
{#if channel?.id}
<button
type="button"
onclick={sendTest}
disabled={testing}
class="rounded-xl border border-input px-4 py-2 text-sm font-medium text-foreground hover:bg-accent disabled:opacity-50"
>
<Button variant="outline" onclick={sendTest} disabled={testing} loading={testing}>
{testing ? 'Sending...' : 'Send Test'}
</button>
</Button>
{/if}
<button
type="button"
onclick={onCancel}
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
<Button variant="ghost" onclick={onCancel}>
Cancel
</button>
</Button>
</div>
</form>
</div>
@@ -1,4 +1,6 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
const STEPS = ['welcome', 'admin', 'authMode', 'theme', 'complete'] as const;
type Step = (typeof STEPS)[number];
@@ -414,12 +416,7 @@
</button>
{/if}
<button
type="button"
onclick={handleNext}
disabled={loading}
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<Button size="lg" onclick={handleNext} disabled={loading} loading={loading}>
{#if loading}
Processing...
{:else if isLastStep}
@@ -429,7 +426,7 @@
{:else}
Next
{/if}
</button>
</Button>
</div>
</div>
</div>
@@ -1,5 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Button from '$lib/components/ui/Button.svelte';
import Select from '$lib/components/ui/Select.svelte';
interface Props {
onCancel: () => void;
@@ -8,7 +10,7 @@
let { onCancel }: Props = $props();
</script>
<div class="rounded-lg border border-border bg-card p-6">
<div class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Generate API Token</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
@@ -31,15 +33,11 @@
<label for="token-scope" class="mb-1 block text-sm font-medium text-foreground">
Scope
</label>
<select
id="token-scope"
name="scope"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<Select id="token-scope" name="scope">
<option value="read">Read — View apps, boards, and status</option>
<option value="write">Write — Modify apps, boards, and settings</option>
<option value="admin">Admin — Full access including user management</option>
</select>
</Select>
</div>
<div>
@@ -56,19 +54,8 @@
</div>
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Generate Token
</button>
<button
type="button"
onclick={onCancel}
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<Button type="submit">Generate Token</Button>
<Button variant="ghost" onclick={onCancel}>Cancel</Button>
</div>
</form>
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import Switch from '$lib/components/ui/Switch.svelte';
interface Props {
value: string;
@@ -98,11 +99,11 @@
{/if}
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Switch
bind:checked={livePreview}
class="h-4 w-4 rounded border-input accent-primary"
size="sm"
ariaLabel={$t('settings.live_preview') ?? 'Live preview'}
/>
{$t('settings.live_preview') ?? 'Live preview'}
</label>
@@ -1,6 +1,7 @@
<script lang="ts">
import { t, locale as i18nLocale } from 'svelte-i18n';
import { theme, type ThemeMode, type BackgroundType, type CardStyle } from '$lib/stores/theme.svelte.js';
import Button from '$lib/components/ui/Button.svelte';
interface UserPreferences {
themeMode: string | null;
@@ -251,14 +252,9 @@
<!-- Save button -->
<div class="flex items-center gap-3">
<button
type="button"
onclick={savePreferences}
disabled={saving}
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<Button size="lg" onclick={savePreferences} disabled={saving} loading={saving}>
{saving ? $t('settings.saving') : $t('settings.save')}
</button>
</Button>
{#if saved}
<span class="text-sm text-status-online-ink">{$t('settings.saved')}</span>
{/if}
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts" module>
import { cn } from '$lib/utils/cn.js';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline';
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-primary text-primary-foreground shadow-[var(--shadow-soft)] hover:bg-primary/90 hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)] active:translate-y-0 active:scale-[0.98]',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
outline:
'border border-border bg-card text-foreground shadow-[var(--shadow-soft)] hover:-translate-y-0.5 hover:border-primary/40 active:translate-y-0 active:scale-[0.98]',
ghost:
'bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground active:scale-[0.98]',
destructive:
'bg-destructive text-destructive-foreground shadow-[var(--shadow-soft)] hover:bg-destructive/90 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98]'
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-2.5 text-sm',
icon: 'p-2'
};
export function buttonClass(opts: {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
extra?: string;
} = {}): string {
const variant = opts.variant ?? 'primary';
const size = opts.size ?? 'md';
return cn(
'inline-flex items-center justify-center gap-2 rounded-xl font-medium whitespace-nowrap transition-all duration-150 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-soft)]',
variantClasses[variant],
sizeClasses[size],
opts.fullWidth && 'w-full',
opts.extra
);
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLButtonAttributes, 'class' | 'children'> {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
loading?: boolean;
class?: string;
children: Snippet;
}
let {
variant = 'primary',
size = 'md',
fullWidth = false,
loading = false,
disabled,
type = 'button',
class: className = '',
children,
...rest
}: Props = $props();
</script>
<button
{type}
disabled={disabled || loading}
class={buttonClass({ variant, size, fullWidth, extra: className })}
{...rest}
>
{#if loading}
<span class="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden="true"></span>
{/if}
{@render children()}
</button>
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
interface Props {
checked?: boolean | undefined;
onchange?: (checked: boolean) => void;
disabled?: boolean;
id?: string;
name?: string;
value?: string;
ariaLabel?: string;
ariaLabelledby?: string;
indeterminate?: boolean;
class?: string;
}
let {
checked = $bindable(false),
onchange,
disabled = false,
id,
name,
value,
ariaLabel,
ariaLabelledby,
indeterminate = false,
class: className = ''
}: Props = $props();
function toggle() {
if (disabled) return;
checked = !checked;
onchange?.(checked);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === ' ') {
e.preventDefault();
toggle();
}
}
</script>
<button
type="button"
role="checkbox"
aria-checked={indeterminate ? 'mixed' : checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{id}
{disabled}
onclick={toggle}
onkeydown={onKeydown}
class={cn(
'inline-flex h-[18px] w-[18px] shrink-0 cursor-pointer items-center justify-center rounded-md border transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
checked || indeterminate
? 'border-primary bg-primary text-primary-foreground shadow-[inset_0_1px_2px_rgba(0,0,0,0.15)]'
: 'border-input bg-background hover:border-primary/60',
className
)}
>
{#if indeterminate}
<svg viewBox="0 0 18 18" class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true">
<line x1="4" y1="9" x2="14" y2="9" />
</svg>
{:else if checked}
<svg viewBox="0 0 18 18" class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="3.5 9.5 7.5 13.5 14.5 5.5" class="check-draw" />
</svg>
{/if}
{#if name !== undefined}
<input type="hidden" {name} value={checked ? (value ?? 'on') : ''} />
{/if}
</button>
<style>
.check-draw {
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: check-draw-in 180ms ease-out forwards;
}
@keyframes check-draw-in {
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.check-draw {
animation: none;
stroke-dashoffset: 0;
}
}
</style>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils/cn.js';
interface Props {
label?: string;
labelFor?: string;
hint?: string;
error?: string;
required?: boolean;
class?: string;
children: Snippet;
}
let {
label,
labelFor,
hint,
error,
required = false,
class: className = '',
children
}: Props = $props();
</script>
<div class={cn('space-y-1.5', className)}>
{#if label}
<label for={labelFor} class="block text-sm font-medium text-foreground">
{label}
{#if required}<span class="text-destructive" aria-hidden="true">*</span>{/if}
</label>
{/if}
{@render children()}
{#if error}
<p class="text-xs text-destructive" role="alert">{error}</p>
{:else if hint}
<p class="text-xs text-muted-foreground">{hint}</p>
{/if}
</div>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts" module>
export const inputClass =
'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50';
</script>
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLInputAttributes, 'class' | 'value'> {
value?: string | number;
class?: string;
invalid?: boolean;
}
let {
value = $bindable(''),
type = 'text',
invalid = false,
class: className = '',
...rest
}: Props = $props();
</script>
<input
{type}
bind:value
class={cn(inputClass, invalid && 'border-destructive focus:border-destructive', className)}
aria-invalid={invalid || undefined}
{...rest}
/>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts" module>
export const selectClass =
'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50 appearance-none bg-no-repeat bg-[right_0.75rem_center] bg-[length:0.85em] pr-9';
export const chevronBg =
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 6 8 10 12 6'/></svg>\")";
</script>
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
import type { Snippet } from 'svelte';
import type { HTMLSelectAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLSelectAttributes, 'class' | 'value' | 'children'> {
value?: string | number | undefined;
class?: string;
children: Snippet;
}
let {
value = $bindable<string | number | undefined>(''),
class: className = '',
children,
...rest
}: Props = $props();
</script>
<div class="relative">
<select
bind:value
class={cn(selectClass, className)}
style="background-image: {chevronBg};"
{...rest}
>
{@render children()}
</select>
</div>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
interface Props {
value: number;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
id?: string;
name?: string;
ariaLabel?: string;
ariaLabelledby?: string;
showValue?: boolean;
formatValue?: (v: number) => string;
class?: string;
oninput?: (value: number) => void;
onchange?: (value: number) => void;
}
let {
value = $bindable(0),
min = 0,
max = 100,
step = 1,
disabled = false,
id,
name,
ariaLabel,
ariaLabelledby,
showValue = false,
formatValue,
class: className = '',
oninput,
onchange
}: Props = $props();
const pct = $derived(((value - min) / (max - min)) * 100);
const displayValue = $derived(formatValue ? formatValue(value) : String(value));
function handleInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
value = Number(target.value);
oninput?.(value);
}
function handleChange(e: Event) {
const target = e.currentTarget as HTMLInputElement;
onchange?.(Number(target.value));
}
</script>
<div class={cn('cozy-slider relative w-full', className)} style="--pct: {pct}%;">
<input
type="range"
{id}
{name}
{min}
{max}
{step}
{disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-valuetext={displayValue}
{value}
oninput={handleInput}
onchange={handleChange}
class="cozy-slider-input"
/>
{#if showValue}
<span class="mt-1 inline-block text-xs tabular-nums text-muted-foreground">{displayValue}</span>
{/if}
</div>
<style>
.cozy-slider-input {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 9999px;
background: linear-gradient(
to right,
var(--primary) 0%,
var(--primary) var(--pct, 0%),
color-mix(in srgb, var(--muted-foreground) 35%, transparent) var(--pct, 0%),
color-mix(in srgb, var(--muted-foreground) 35%, transparent) 100%
);
outline: none;
cursor: pointer;
transition: background 0.1s ease;
}
.cozy-slider-input:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.cozy-slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 9999px;
background: var(--card);
border: 2px solid var(--primary);
box-shadow:
0 2px 4px rgba(80, 50, 20, 0.3),
0 1px 2px rgba(80, 50, 20, 0.15);
cursor: pointer;
transition: transform 0.15s ease;
}
.cozy-slider-input::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.cozy-slider-input::-webkit-slider-thumb:active {
transform: scale(1.18);
}
.cozy-slider-input::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 9999px;
background: var(--card);
border: 2px solid var(--primary);
box-shadow:
0 2px 4px rgba(80, 50, 20, 0.3),
0 1px 2px rgba(80, 50, 20, 0.15);
cursor: pointer;
transition: transform 0.15s ease;
}
.cozy-slider-input::-moz-range-thumb:hover {
transform: scale(1.1);
}
.cozy-slider-input:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary) 40%, transparent);
outline-offset: 4px;
border-radius: 9999px;
}
@media (prefers-reduced-motion: reduce) {
.cozy-slider-input::-webkit-slider-thumb,
.cozy-slider-input::-moz-range-thumb {
transition: none;
}
.cozy-slider-input::-webkit-slider-thumb:hover,
.cozy-slider-input::-webkit-slider-thumb:active {
transform: none;
}
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
interface Props {
checked?: boolean | undefined;
onchange?: (checked: boolean) => void;
disabled?: boolean;
id?: string;
name?: string;
label?: string;
ariaLabel?: string;
ariaLabelledby?: string;
size?: 'sm' | 'md';
class?: string;
}
let {
checked = $bindable(false),
onchange,
disabled = false,
id,
name,
label,
ariaLabel,
ariaLabelledby,
size = 'md',
class: className = ''
}: Props = $props();
function toggle() {
if (disabled) return;
checked = !checked;
onchange?.(checked);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
toggle();
}
}
const trackBase =
'relative inline-flex shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50';
const trackSize = $derived(size === 'sm' ? 'h-5 w-9' : 'h-6 w-11');
const knobSize = $derived(size === 'sm' ? 'h-4 w-4' : 'h-5 w-5');
const knobTranslate = $derived(
size === 'sm'
? checked
? 'translate-x-4'
: 'translate-x-0.5'
: checked
? 'translate-x-[1.375rem]'
: 'translate-x-0.5'
);
</script>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel ?? label}
aria-labelledby={ariaLabelledby}
{id}
{disabled}
onclick={toggle}
onkeydown={onKeydown}
class={cn(
trackBase,
trackSize,
checked ? 'bg-primary shadow-[inset_0_1px_2px_rgba(0,0,0,0.18)]' : 'bg-muted-foreground/35',
className
)}
>
<span
aria-hidden="true"
class={cn(
'pointer-events-none inline-block transform rounded-full bg-white shadow-[0_2px_4px_rgba(80,50,20,0.35),0_1px_2px_rgba(80,50,20,0.18)] ring-0 transition-transform duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
knobSize,
knobTranslate
)}
></span>
{#if name !== undefined}
<input type="hidden" {name} value={checked ? 'on' : ''} />
{/if}
</button>
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { buttonClass } from '../Button.svelte';
describe('buttonClass', () => {
it('returns primary md by default', () => {
const cls = buttonClass();
expect(cls).toContain('bg-primary');
expect(cls).toContain('px-4');
expect(cls).toContain('py-2');
expect(cls).toContain('text-sm');
});
it('applies secondary variant', () => {
const cls = buttonClass({ variant: 'secondary' });
expect(cls).toContain('bg-secondary');
expect(cls).not.toContain('bg-primary ');
});
it('applies destructive variant', () => {
const cls = buttonClass({ variant: 'destructive' });
expect(cls).toContain('bg-destructive');
});
it('applies sm size', () => {
const cls = buttonClass({ size: 'sm' });
expect(cls).toContain('px-3');
expect(cls).toContain('text-xs');
});
it('applies lg size', () => {
const cls = buttonClass({ size: 'lg' });
expect(cls).toContain('px-6');
});
it('adds fullWidth', () => {
const cls = buttonClass({ fullWidth: true });
expect(cls).toContain('w-full');
});
it('merges extra class', () => {
const cls = buttonClass({ extra: 'custom-class' });
expect(cls).toContain('custom-class');
});
it('always includes focus-visible ring', () => {
const cls = buttonClass();
expect(cls).toContain('focus-visible:ring-2');
expect(cls).toContain('focus-visible:ring-primary/30');
});
it('always includes disabled state', () => {
const cls = buttonClass();
expect(cls).toContain('disabled:cursor-not-allowed');
expect(cls).toContain('disabled:opacity-50');
});
});
@@ -4,6 +4,9 @@
import { tick } from 'svelte';
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
import MultiEntityPicker from '$lib/components/ui/MultiEntityPicker.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Slider from '$lib/components/ui/Slider.svelte';
import Select from '$lib/components/ui/Select.svelte';
interface AppInfo {
id: string;
@@ -269,13 +272,12 @@
</label>
</div>
<div>
<label class={labelClass}>{$t('widget.format') ?? 'Format'}
<select bind:value={noteFormat} class={inputClass}>
<div class={labelClass}>{$t('widget.format') ?? 'Format'}</div>
<Select bind:value={noteFormat}>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
</select>
</label>
</Select>
</div>
{:else if widgetType === 'embed'}
@@ -285,9 +287,8 @@
</label>
</div>
<div>
<label class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)
<input type="range" min="100" max="800" bind:value={embedHeight} class="w-full accent-primary" />
</label>
<div class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)</div>
<Slider min={100} max={800} bind:value={embedHeight} ariaLabel="Height" />
</div>
<div>
<label class={labelClass}>Sandbox
@@ -318,16 +319,15 @@
</label>
</div>
<div>
<label class={labelClass}>{$t('widget.style') ?? 'Style'}
<select bind:value={clockStyle} class={inputClass}>
<div class={labelClass}>{$t('widget.style') ?? 'Style'}</div>
<Select bind:value={clockStyle}>
<option value="digital">Digital</option>
<option value="analog">Analog</option>
<option value="24h">24h</option>
</select>
</label>
</Select>
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={clockShowWeather} class="h-3.5 w-3.5 rounded border-input accent-primary" />
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch bind:checked={clockShowWeather} size="sm" ariaLabel={$t('widget.show_weather') ?? 'Show Weather'} />
{$t('widget.show_weather') ?? 'Show Weather'}
</label>
{#if clockShowWeather}
@@ -352,18 +352,16 @@
</label>
</div>
<div>
<label class={labelClass}>Source Type
<select bind:value={sysStatsSourceType} class={inputClass}>
<div class={labelClass}>Source Type</div>
<Select bind:value={sysStatsSourceType}>
<option value="glances">Glances</option>
<option value="prometheus">Prometheus</option>
<option value="custom">Custom</option>
</select>
</label>
</Select>
</div>
<div>
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({sysStatsRefreshInterval}s)</div>
<Slider min={5} max={300} bind:value={sysStatsRefreshInterval} ariaLabel="Refresh interval" />
</div>
{:else if widgetType === 'rss'}
@@ -373,12 +371,11 @@
</label>
</div>
<div>
<label class={labelClass}>Max Items ({rssMaxItems})
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
</label>
<div class={labelClass}>Max Items ({rssMaxItems})</div>
<Slider min={1} max={50} bind:value={rssMaxItems} ariaLabel="Max items" />
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={rssShowSummary} class="h-3.5 w-3.5 rounded border-input accent-primary" />
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch bind:checked={rssShowSummary} size="sm" ariaLabel="Show Summary" />
Show Summary
</label>
@@ -398,9 +395,8 @@
<button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button>
</div>
<div>
<label class={labelClass}>Days Ahead ({calendarDaysAhead})
<input type="range" min="1" max="30" bind:value={calendarDaysAhead} class="w-full accent-primary" />
</label>
<div class={labelClass}>Days Ahead ({calendarDaysAhead})</div>
<Slider min={1} max={30} bind:value={calendarDaysAhead} ariaLabel="Days ahead" />
</div>
{:else if widgetType === 'markdown'}
@@ -417,13 +413,12 @@
</label>
</div>
<div>
<label class={labelClass}>Source
<select bind:value={metricSource} class={inputClass}>
<div class={labelClass}>Source</div>
<Select bind:value={metricSource}>
<option value="static">Static</option>
<option value="json">JSON Endpoint</option>
<option value="prometheus">Prometheus</option>
</select>
</label>
</Select>
</div>
{#if metricSource === 'static'}
<div>
@@ -461,9 +456,8 @@
</label>
</div>
<div>
<label class={labelClass}>Refresh ({metricRefreshInterval}s)
<input type="range" min="5" max="300" bind:value={metricRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({metricRefreshInterval}s)</div>
<Slider min={5} max={300} bind:value={metricRefreshInterval} ariaLabel="Refresh interval" />
</div>
</div>
@@ -482,8 +476,8 @@
{/each}
<button type="button" onclick={addLinkGroupLink} class="text-xs text-primary hover:underline">+ Add Link</button>
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={linkGroupCollapsible} class="h-3.5 w-3.5 rounded border-input accent-primary" />
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch bind:checked={linkGroupCollapsible} size="sm" ariaLabel="Collapsible" />
Collapsible
</label>
@@ -494,39 +488,35 @@
</label>
</div>
<div>
<label class={labelClass}>Type
<select bind:value={cameraType} class={inputClass}>
<div class={labelClass}>Type</div>
<Select bind:value={cameraType}>
<option value="image">Image</option>
<option value="mjpeg">MJPEG</option>
<option value="hls">HLS</option>
</select>
</label>
</Select>
</div>
<div>
<label class={labelClass}>Refresh ({cameraRefreshInterval}s)
<input type="range" min="1" max="60" bind:value={cameraRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({cameraRefreshInterval}s)</div>
<Slider min={1} max={60} bind:value={cameraRefreshInterval} ariaLabel="Refresh interval" />
</div>
<div>
<label class={labelClass}>Aspect Ratio
<select bind:value={cameraAspectRatio} class={inputClass}>
<div class={labelClass}>Aspect Ratio</div>
<Select bind:value={cameraAspectRatio}>
<option value="16/9">16:9</option>
<option value="4/3">4:3</option>
<option value="1/1">1:1</option>
</select>
</label>
</Select>
</div>
{:else if widgetType === 'integration'}
<div>
<label class={labelClass}>{$t('widget.app') ?? 'App'}
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
<div class={labelClass}>{$t('widget.app') ?? 'App'}</div>
<Select bind:value={integrationAppId}>
<option value="">Select app...</option>
{#each apps as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</label>
</Select>
</div>
<div>
<label class={labelClass}>Endpoint ID
@@ -534,9 +524,8 @@
</label>
</div>
<div>
<label class={labelClass}>Refresh ({integrationRefreshInterval}s)
<input type="range" min="10" max="600" bind:value={integrationRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({integrationRefreshInterval}s)</div>
<Slider min={10} max={600} bind:value={integrationRefreshInterval} ariaLabel="Refresh interval" />
</div>
{/if}
</div>
@@ -9,6 +9,10 @@
import EntityPicker from '$lib/components/ui/EntityPicker.svelte';
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
import type { EntityPickerItem } from '$lib/components/ui/EntityPicker.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Slider from '$lib/components/ui/Slider.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Checkbox from '$lib/components/ui/Checkbox.svelte';
interface Props {
sectionId: string;
@@ -507,12 +511,11 @@
<span class="mb-1 block text-sm font-medium text-foreground">Select Apps</span>
<div class="max-h-40 space-y-1 overflow-y-auto rounded-xl border border-input bg-background p-2">
{#each apps as app (app.id)}
<label class="flex items-center gap-2 rounded px-2 py-1 text-sm text-foreground hover:bg-accent">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm text-foreground hover:bg-accent">
<Checkbox
checked={statusAppIds.includes(app.id)}
onchange={() => toggleStatusApp(app.id)}
class="h-4 w-4 rounded border-input accent-primary"
ariaLabel={app.name}
/>
{app.name}
</label>
@@ -549,12 +552,8 @@
<p class="mt-0.5 text-xs text-muted-foreground">Leave empty for local time</p>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={clockShowWeather}
class="h-4 w-4 rounded border-input accent-primary"
/>
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<Switch bind:checked={clockShowWeather} ariaLabel="Show Weather" />
Show Weather
</label>
</div>
@@ -599,26 +598,24 @@
</div>
<div>
<label for="sys-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Source Type</label>
<select
<Select
id="sys-type-{sectionId}"
bind:value={sysStatsSourceType}
class={inputClass}
>
<option value="glances">Glances</option>
<option value="prometheus">Prometheus</option>
<option value="custom">Custom JSON</option>
</select>
</Select>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-foreground">Metrics</span>
<div class="flex flex-wrap gap-2">
{#each ['cpu', 'ram', 'disk', 'swap', 'network'] as metric (metric)}
<label class="flex items-center gap-1.5 rounded-xl border border-input px-2 py-1 text-sm text-foreground hover:bg-accent">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-2 rounded-xl border border-input px-2.5 py-1 text-sm text-foreground hover:bg-accent">
<Checkbox
checked={sysStatsMetrics.includes(metric)}
onchange={() => toggleSysStatsMetric(metric)}
class="h-3.5 w-3.5 rounded border-input accent-primary"
ariaLabel={metric}
/>
<span class="capitalize">{metric}</span>
</label>
@@ -629,14 +626,13 @@
<label for="sys-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh Interval: {sysStatsRefreshInterval}s
</label>
<input
<Slider
id="sys-refresh-{sectionId}"
type="range"
bind:value={sysStatsRefreshInterval}
min="5"
max="300"
step="5"
class="w-full accent-primary"
min={5}
max={300}
step={5}
ariaLabel="Refresh interval"
/>
</div>
</div>
@@ -658,23 +654,18 @@
<label for="rss-max-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Max Items: {rssMaxItems}
</label>
<input
<Slider
id="rss-max-{sectionId}"
type="range"
bind:value={rssMaxItems}
min="3"
max="30"
step="1"
class="w-full accent-primary"
min={3}
max={30}
step={1}
ariaLabel="Max items"
/>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={rssShowSummary}
class="h-4 w-4 rounded border-input accent-primary"
/>
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<Switch bind:checked={rssShowSummary} ariaLabel="Show Summaries" />
Show Summaries
</label>
</div>
@@ -733,14 +724,13 @@
<label for="cal-days-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Days Ahead: {calendarDaysAhead}
</label>
<input
<Slider
id="cal-days-{sectionId}"
type="range"
bind:value={calendarDaysAhead}
min="1"
max="30"
step="1"
class="w-full accent-primary"
min={1}
max={30}
step={1}
ariaLabel="Days ahead"
/>
</div>
</div>
@@ -855,14 +845,13 @@
<label for="metric-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {metricRefreshInterval}s
</label>
<input
<Slider
id="metric-refresh-{sectionId}"
type="range"
bind:value={metricRefreshInterval}
min="10"
max="600"
step="10"
class="w-full accent-primary"
min={10}
max={600}
step={10}
ariaLabel="Refresh interval"
/>
</div>
</div>
@@ -916,12 +905,8 @@
</button>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={linkGroupCollapsible}
class="h-4 w-4 rounded border-input accent-primary"
/>
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<Switch bind:checked={linkGroupCollapsible} ariaLabel="Collapsible" />
Collapsible
</label>
</div>
@@ -942,43 +927,40 @@
</div>
<div>
<label for="cam-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Stream Type</label>
<select
<Select
id="cam-type-{sectionId}"
bind:value={cameraType}
class={inputClass}
>
<option value="image">Snapshot (Image)</option>
<option value="mjpeg">MJPEG Stream</option>
<option value="hls">HLS Stream</option>
</select>
</Select>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="cam-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {cameraRefreshInterval}s
</label>
<input
<Slider
id="cam-refresh-{sectionId}"
type="range"
bind:value={cameraRefreshInterval}
min="1"
max="120"
step="1"
class="w-full accent-primary"
min={1}
max={120}
step={1}
ariaLabel="Refresh interval"
/>
</div>
<div>
<label for="cam-ratio-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Aspect Ratio</label>
<select
<Select
id="cam-ratio-{sectionId}"
bind:value={cameraAspectRatio}
class={inputClass}
>
<option value="16/9">16:9</option>
<option value="4/3">4:3</option>
<option value="1/1">1:1</option>
<option value="21/9">21:9</option>
</select>
</Select>
</div>
</div>
</div>
@@ -990,44 +972,41 @@
{#if integrationApps.length === 0}
<p class="text-sm text-muted-foreground">No apps with integrations configured. Add an integration to an app first.</p>
{:else}
<select
<Select
id="int-app-{sectionId}"
bind:value={integrationAppId}
class={inputClass}
>
<option value="">Select an app...</option>
{#each integrationApps as app (app.id)}
<option value={app.id}>{app.name} ({app.integrationType})</option>
{/each}
</select>
</Select>
{/if}
</div>
{#if integrationAppId && integrationEndpoints.length > 0}
<div>
<label for="int-endpoint-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Data Endpoint</label>
<select
<Select
id="int-endpoint-{sectionId}"
bind:value={integrationEndpointId}
class={inputClass}
>
<option value="">Select endpoint...</option>
{#each integrationEndpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{/each}
</select>
</Select>
</div>
<div>
<label for="int-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {integrationRefreshInterval}s
</label>
<input
<Slider
id="int-refresh-{sectionId}"
type="range"
bind:value={integrationRefreshInterval}
min="10"
max="600"
step="10"
class="w-full accent-primary"
min={10}
max={600}
step={10}
ariaLabel="Refresh interval"
/>
</div>
{/if}
+16 -2
View File
@@ -290,13 +290,17 @@
"admin.discovery_traefik_url_hint": "Traefik dashboard API base URL (e.g. http://traefik:8080). Set via TRAEFIK_API_URL env var.",
"admin.backup_title": "Database Backup",
"admin.backup_description": "Create, restore, and schedule backups of your database. Backups are full copies of the SQLite database file.",
"admin.backup_description": "Create, restore, and schedule backups. New backups include the SQLite database and all uploaded files (icons, wallpapers).",
"admin.backup_create": "Create Backup",
"admin.backup_creating": "Creating...",
"admin.backup_create_success": "Backup created successfully.",
"admin.backup_list_title": "Backups",
"admin.backup_list_empty": "No backups yet. Create your first backup above.",
"admin.backup_filename": "Filename",
"admin.backup_format": "Format",
"admin.backup_format_full": "Full (DB + uploads)",
"admin.backup_format_legacy": "DB only (legacy)",
"admin.backup_format_legacy_tooltip": "This backup was created by an older version and contains only the database. Uploaded files will not be restored.",
"admin.backup_size": "Size",
"admin.backup_date": "Created",
"admin.backup_actions": "Actions",
@@ -305,7 +309,13 @@
"admin.backup_delete": "Delete",
"admin.backup_restore_confirm_title": "Restore Backup",
"admin.backup_restore_confirm": "Are you sure you want to restore from this backup? This will replace all current data with the backup contents. This action cannot be undone.",
"admin.backup_restore_success": "Database restored successfully. Please reload the page.",
"admin.backup_restore_legacy_warning": "Legacy backup format: only the database will be restored. Current uploaded files (icons, wallpapers) will remain untouched and may not match references in the restored DB.",
"admin.backup_restore_logout_warning": "You will be logged out automatically after the restore completes, because your current session was issued after this backup was taken.",
"admin.backup_restore_success": "Database restored successfully. You will be redirected to the login page.",
"admin.backup_restore_schema_mismatch": "Backup schema version does not match the running application. Restoring may corrupt data.",
"admin.backup_restore_schema_mismatch_title": "Schema version mismatch",
"admin.backup_restore_schema_mismatch_intro": "This backup was taken against a different database schema. Restoring may produce a database that no longer matches the current application code.",
"admin.backup_restore_schema_mismatch_force": "Restore anyway",
"admin.backup_delete_confirm_title": "Delete Backup",
"admin.backup_delete_confirm": "Are you sure you want to delete this backup? This action cannot be undone.",
"admin.backup_delete_success": "Backup deleted.",
@@ -320,6 +330,10 @@
"admin.backup_schedule_save": "Save Schedule",
"admin.backup_schedule_saving": "Saving...",
"admin.backup_schedule_saved": "Backup schedule updated.",
"admin.backup_stats_success_count": "Successful runs",
"admin.backup_stats_failure_count": "Failed runs",
"admin.backup_stats_last_success": "Last success",
"admin.backup_stats_last_failure": "Last failure",
"search.placeholder": "Search apps and boards...",
"search.trigger": "Search...",
+16 -2
View File
@@ -279,13 +279,17 @@
"admin.discovery_traefik_url_hint": "Базовый URL API Traefik (напр. http://traefik:8080). Задаётся через TRAEFIK_API_URL.",
"admin.backup_title": "Резервное копирование",
"admin.backup_description": "Создавайте, восстанавливайте и планируйте резервные копии базы данных. Копии — это полные дубликаты файла базы SQLite.",
"admin.backup_description": "Создавайте, восстанавливайте и планируйте резервные копии. Новые копии включают базу SQLite и все загруженные файлы (иконки, обои).",
"admin.backup_create": "Создать копию",
"admin.backup_creating": "Создание...",
"admin.backup_create_success": "Резервная копия успешно создана.",
"admin.backup_list_title": "Резервные копии",
"admin.backup_list_empty": "Копий пока нет. Создайте первую копию выше.",
"admin.backup_filename": "Файл",
"admin.backup_format": "Формат",
"admin.backup_format_full": "Полный (БД + загрузки)",
"admin.backup_format_legacy": "Только БД (устаревший)",
"admin.backup_format_legacy_tooltip": "Эта копия создана старой версией и содержит только базу данных. Загруженные файлы восстановлены не будут.",
"admin.backup_size": "Размер",
"admin.backup_date": "Создана",
"admin.backup_actions": "Действия",
@@ -294,7 +298,13 @@
"admin.backup_delete": "Удалить",
"admin.backup_restore_confirm_title": "Восстановление из копии",
"admin.backup_restore_confirm": "Вы уверены, что хотите восстановить базу из этой копии? Все текущие данные будут заменены содержимым копии. Это действие нельзя отменить.",
"admin.backup_restore_success": "База данных восстановлена. Пожалуйста, перезагрузите страницу.",
"admin.backup_restore_legacy_warning": "Устаревший формат: будет восстановлена только база данных. Текущие загруженные файлы (иконки, обои) останутся без изменений и могут не соответствовать ссылкам в восстановленной БД.",
"admin.backup_restore_logout_warning": "После завершения восстановления вы будете автоматически выведены из системы, так как ваша текущая сессия была создана уже после момента этой копии.",
"admin.backup_restore_success": "База данных восстановлена. Сейчас вы будете перенаправлены на страницу входа.",
"admin.backup_restore_schema_mismatch": "Версия схемы в копии не совпадает с текущей версией приложения. Восстановление может повредить данные.",
"admin.backup_restore_schema_mismatch_title": "Несовпадение версии схемы",
"admin.backup_restore_schema_mismatch_intro": "Эта копия была создана на другой версии схемы базы данных. Восстановление может привести к рассогласованию БД и текущего кода приложения.",
"admin.backup_restore_schema_mismatch_force": "Восстановить всё равно",
"admin.backup_delete_confirm_title": "Удаление копии",
"admin.backup_delete_confirm": "Вы уверены, что хотите удалить эту резервную копию? Это действие нельзя отменить.",
"admin.backup_delete_success": "Копия удалена.",
@@ -309,6 +319,10 @@
"admin.backup_schedule_save": "Сохранить расписание",
"admin.backup_schedule_saving": "Сохранение...",
"admin.backup_schedule_saved": "Расписание резервного копирования обновлено.",
"admin.backup_stats_success_count": "Успешных запусков",
"admin.backup_stats_failure_count": "Неудачных запусков",
"admin.backup_stats_last_success": "Последний успех",
"admin.backup_stats_last_failure": "Последняя ошибка",
"search.placeholder": "Поиск приложений и досок...",
"search.trigger": "Поиск...",
"search.min_chars": "Введите минимум 2 символа для поиска",
+22 -7
View File
@@ -1,5 +1,11 @@
import cron from 'node-cron';
import { createBackup, enforceRetention, getBackupSettings } from '$lib/server/services/backupService.js';
import {
createBackup,
enforceRetention,
getBackupSettings,
recordScheduledBackupFailure,
recordScheduledBackupSuccess
} from '$lib/server/services/backupService.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
@@ -11,6 +17,11 @@ const state = g.__walBackupScheduler;
/**
* Start the backup scheduler with the given settings.
* If already running, does nothing call restartBackupScheduler() to reconfigure.
*
* For multi-replica deployments, set RUN_SCHEDULERS=false on every replica
* except the one designated as the scheduler. There is no leader election;
* running this on multiple replicas will produce concurrent backups (with
* filenames colliding at the second granularity).
*/
export function startBackupScheduler(settings: {
readonly backupEnabled: boolean;
@@ -37,14 +48,19 @@ export function startBackupScheduler(settings: {
try {
const backup = await createBackup();
enforceRetention(settings.backupMaxCount);
recordScheduledBackupSuccess();
logAction(null, AuditAction.BACKUP_CREATED, 'backup', backup.filename, {
trigger: 'scheduled'
trigger: 'scheduled',
size: backup.size,
format: backup.format
});
} catch (err) {
// Log failure to audit log so admins can see scheduled backups are failing
logAction(null, AuditAction.BACKUP_CREATED, 'backup', 'failed', {
const reason = err instanceof Error ? err.message : 'Unknown error';
recordScheduledBackupFailure(reason);
console.error('[backup] scheduled backup failed:', err);
logAction(null, AuditAction.BACKUP_FAILED, 'backup', 'scheduled', {
trigger: 'scheduled',
error: err instanceof Error ? err.message : 'Unknown error'
error: reason
});
}
});
@@ -83,7 +99,6 @@ export async function initBackupScheduler(): Promise<void> {
const settings = await getBackupSettings();
startBackupScheduler(settings);
} catch (err) {
console.warn('[backup] initBackupScheduler failed:', err);
console.warn('[backup] initBackupScheduler failed:', err);
}
}
@@ -0,0 +1,628 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import * as tar from 'tar';
// --- Prisma + uploads mocks --------------------------------------------------
//
// backupService imports prisma (which validates env). We mock both prisma and
// the uploads helper so the SUT runs entirely off the test's temp dirs.
const reapplyPragmasMock = vi.fn(async () => undefined);
const executeRawUnsafeMock = vi.fn(async (sql: string): Promise<number> => {
// VACUUM INTO writes a real SQLite header to the named file so downstream
// integrity checks succeed.
const match = sql.match(/VACUUM INTO '(.+?)'/);
if (match) {
const pageSize = 4096;
const pages = 8;
const header = Buffer.alloc(100);
Buffer.from([
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
]).copy(header, 0);
header.writeUInt16BE(pageSize, 16);
const body = Buffer.alloc(pageSize * pages - 100);
await fsp.writeFile(match[1], Buffer.concat([header, body]));
}
return 0;
});
const queryRawUnsafeMock = vi.fn(async (_sql: string) => [{ migration_name: 'test_migration' }]);
const disconnectMock = vi.fn(async () => undefined);
const connectMock = vi.fn(async () => undefined);
const sessionDeleteManyMock = vi.fn(async () => ({ count: 0 }));
const systemSettingsUpsertMock = vi.fn(async () => ({
backupEnabled: false,
backupCronExpression: '0 3 * * *',
backupMaxCount: 3
}));
vi.mock('../../prisma.js', () => ({
prisma: {
$executeRawUnsafe: (sql: string) => executeRawUnsafeMock(sql),
$queryRawUnsafe: (sql: string) => queryRawUnsafeMock(sql),
$disconnect: () => disconnectMock(),
$connect: () => connectMock(),
session: { deleteMany: () => sessionDeleteManyMock() },
systemSettings: { upsert: () => systemSettingsUpsertMock() }
},
reapplySqlitePragmas: () => reapplyPragmasMock()
}));
let tmpRoot: string;
let backupDir: string;
let uploadsDir: string;
let dbDir: string;
let dbFilePath: string;
vi.mock('../../utils/uploads.js', () => ({
getUploadsDir: () => uploadsDir
}));
const importService = async () => await import('../backupService.js');
async function makeUploadsTree() {
await fsp.mkdir(path.join(uploadsDir, 'wallpapers'), { recursive: true });
await fsp.writeFile(path.join(uploadsDir, 'icon.svg'), '<svg/>');
await fsp.writeFile(
path.join(uploadsDir, 'wallpapers', 'sky.jpg'),
Buffer.from([0xff, 0xd8, 0xff])
);
}
async function listEntries(file: string): Promise<string[]> {
const entries: string[] = [];
await tar.list({
file,
onentry: (entry) => entries.push(entry.path)
});
return entries;
}
function validSqliteBytes(): Buffer {
const pageSize = 4096;
const pages = 4;
const header = Buffer.alloc(100);
Buffer.from([
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
]).copy(header, 0);
header.writeUInt16BE(pageSize, 16);
return Buffer.concat([header, Buffer.alloc(pageSize * pages - 100)]);
}
async function writeTarballBackup(opts: {
manifest?: unknown;
dbBytes?: Buffer;
includeUploads?: boolean;
filename?: string;
}) {
const filename = opts.filename ?? `backup-${Date.now()}-${crypto.randomBytes(2).toString('hex')}.tar.gz`;
const work = await fsp.mkdtemp(path.join(os.tmpdir(), 'wal-mk-'));
if (opts.manifest !== undefined) {
await fsp.writeFile(
path.join(work, 'manifest.json'),
JSON.stringify(opts.manifest, null, 2),
'utf8'
);
}
if (opts.dbBytes) {
await fsp.writeFile(path.join(work, 'database.db'), opts.dbBytes);
}
if (opts.includeUploads) {
await fsp.mkdir(path.join(work, 'uploads'), { recursive: true });
await fsp.writeFile(path.join(work, 'uploads', 'a.svg'), '<svg/>');
await fsp.writeFile(path.join(work, 'uploads', 'b.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
}
const entries = await fsp.readdir(work);
await tar.create({ cwd: work, gzip: true, file: path.join(backupDir, filename) }, entries);
await fsp.rm(work, { recursive: true, force: true });
return filename;
}
function manifestFor(db: Buffer, schemaVersion: string | null = 'test_migration'): unknown {
const hash = crypto.createHash('sha256').update(db).digest('hex');
return {
version: '1',
createdAt: new Date().toISOString(),
appVersion: '0.1.0',
schemaVersion,
dbSize: db.length,
uploadFileCount: 0,
checksums: { 'database.db': `sha256:${hash}` }
};
}
beforeEach(async () => {
tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'wal-bs-test-'));
backupDir = path.join(tmpRoot, 'backups');
uploadsDir = path.join(tmpRoot, 'uploads');
dbDir = path.join(tmpRoot, 'db');
dbFilePath = path.join(dbDir, 'test.db');
await fsp.mkdir(backupDir, { recursive: true });
await fsp.mkdir(uploadsDir, { recursive: true });
await fsp.mkdir(dbDir, { recursive: true });
process.env.BACKUPS_DIR = backupDir;
// Absolute file: URL so getDatabasePath treats it as already-absolute.
process.env.DATABASE_URL = `file:${dbFilePath.replace(/\\/g, '/')}`;
// Pretend the live DB exists so createBackup's disk-space check has data.
await fsp.writeFile(dbFilePath, validSqliteBytes());
executeRawUnsafeMock.mockClear();
queryRawUnsafeMock.mockClear();
queryRawUnsafeMock.mockImplementation(async (_sql: string) => [
{ migration_name: 'test_migration' }
]);
disconnectMock.mockClear();
disconnectMock.mockImplementation(async () => undefined);
connectMock.mockClear();
connectMock.mockImplementation(async () => undefined);
sessionDeleteManyMock.mockClear();
sessionDeleteManyMock.mockImplementation(async () => ({ count: 0 }));
reapplyPragmasMock.mockClear();
// Reset cross-test globalThis state (restoring/degraded/stats).
const g = globalThis as unknown as { __walBackupState?: unknown };
delete g.__walBackupState;
});
afterEach(async () => {
delete process.env.BACKUPS_DIR;
delete process.env.DATABASE_URL;
await fsp.rm(tmpRoot, { recursive: true, force: true });
vi.resetModules();
});
describe('backupService — listing & path safety', () => {
it('listBackups sorts newest-first by filename and labels formats', async () => {
await fsp.writeFile(path.join(backupDir, 'backup-2026-01-01T00-00-00.tar.gz'), 'a');
await fsp.writeFile(path.join(backupDir, 'backup-2026-03-01T00-00-00.tar.gz'), 'b');
await fsp.writeFile(path.join(backupDir, 'backup-2025-12-31T23-59-59.db'), 'c');
await fsp.writeFile(path.join(backupDir, 'unrelated.txt'), 'noise');
const { listBackups } = await importService();
const list = listBackups();
expect(list.map((b) => b.filename)).toEqual([
'backup-2026-03-01T00-00-00.tar.gz',
'backup-2026-01-01T00-00-00.tar.gz',
'backup-2025-12-31T23-59-59.db'
]);
expect(list.find((b) => b.filename.endsWith('.tar.gz'))?.format).toBe('tar.gz');
expect(list.find((b) => b.filename.endsWith('.db'))?.format).toBe('db');
});
it('getBackupFilePath rejects path traversal and dot-only basenames', async () => {
const { getBackupFilePath } = await importService();
expect(getBackupFilePath('../../etc/passwd')).toBeNull();
expect(getBackupFilePath('subdir/foo.tar.gz')).toBeNull();
expect(getBackupFilePath('foo.txt')).toBeNull();
expect(getBackupFilePath('foo.tar.gz.exe')).toBeNull();
// Dot-only basenames before the legitimate extension:
expect(getBackupFilePath('.tar.gz')).toBeNull();
expect(getBackupFilePath('..tar.gz')).toBeNull();
expect(getBackupFilePath('....db')).toBeNull();
expect(getBackupFilePath('-leading-dash.tar.gz')).toBeNull();
expect(getBackupFilePath('_leading-underscore.tar.gz')).toBeNull();
});
it('getBackupFilePath returns null for missing files', async () => {
const { getBackupFilePath } = await importService();
expect(getBackupFilePath('does-not-exist.tar.gz')).toBeNull();
});
it('getBackupFilePath accepts legitimate filenames', async () => {
const goodName = 'backup-2026-05-28T10-00-00.tar.gz';
await fsp.writeFile(path.join(backupDir, goodName), 'x');
const { getBackupFilePath } = await importService();
expect(getBackupFilePath(goodName)).toBe(path.join(backupDir, goodName));
});
it('deleteBackup silently rejects bad filenames', async () => {
const { deleteBackup } = await importService();
expect(deleteBackup('../escape.tar.gz')).toBe(false);
expect(deleteBackup('legit.tar.gz')).toBe(false);
});
it('enforceRetention keeps the N newest', async () => {
const names = [
'backup-2026-01-01T00-00-00.tar.gz',
'backup-2026-02-01T00-00-00.tar.gz',
'backup-2026-03-01T00-00-00.tar.gz',
'backup-2026-04-01T00-00-00.tar.gz',
'backup-2026-05-01T00-00-00.tar.gz'
];
for (const n of names) await fsp.writeFile(path.join(backupDir, n), 'x');
const { enforceRetention, listBackups } = await importService();
expect(enforceRetention(2)).toBe(3);
expect(listBackups().map((b) => b.filename)).toEqual([
'backup-2026-05-01T00-00-00.tar.gz',
'backup-2026-04-01T00-00-00.tar.gz'
]);
});
it('isRestoring is false at rest', async () => {
const { isRestoring } = await importService();
expect(isRestoring()).toBe(false);
});
});
describe('backupService — beginRestoreWindow / endRestoreWindow', () => {
it('flips the isRestoring flag synchronously and blocks concurrent windows', async () => {
const svc = await importService();
expect(svc.isRestoring()).toBe(false);
svc.beginRestoreWindow();
expect(svc.isRestoring()).toBe(true);
expect(() => svc.beginRestoreWindow()).toThrow(/already in progress/);
svc.endRestoreWindow();
expect(svc.isRestoring()).toBe(false);
});
});
describe('backupService — createBackup', () => {
it('produces a tar.gz containing manifest, database.db and uploads tree', async () => {
await makeUploadsTree();
const { createBackup } = await importService();
const info = await createBackup();
expect(info.filename).toMatch(/^backup-.*\.tar\.gz$/);
expect(info.format).toBe('tar.gz');
const archivePath = path.join(backupDir, info.filename);
const entries = await listEntries(archivePath);
expect(entries).toContain('manifest.json');
expect(entries).toContain('database.db');
expect(entries.some((e) => e.startsWith('uploads/'))).toBe(true);
const extractDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'wal-verify-'));
await tar.extract({ cwd: extractDir, file: archivePath });
const manifest = JSON.parse(
await fsp.readFile(path.join(extractDir, 'manifest.json'), 'utf8')
);
expect(manifest.version).toBe('1');
expect(manifest.schemaVersion).toBe('test_migration');
expect(manifest.uploadFileCount).toBeGreaterThanOrEqual(2);
expect(manifest.checksums['database.db']).toMatch(/^sha256:[a-f0-9]{64}$/);
await fsp.rm(extractDir, { recursive: true, force: true });
});
it('uses BACKUPS_DIR env override', async () => {
const { getBackupDir } = await importService();
expect(getBackupDir()).toBe(path.resolve(backupDir));
});
});
describe('backupService — restoreBackup validation', () => {
it('rejects non-existent backup', async () => {
const { restoreBackup } = await importService();
await expect(restoreBackup('not-there.tar.gz')).rejects.toThrow(/not found/i);
});
it('rejects archive missing manifest', async () => {
const filename = await writeTarballBackup({ dbBytes: validSqliteBytes() });
const { restoreBackup } = await importService();
await expect(restoreBackup(filename)).rejects.toThrow(/manifest\.json/);
});
it('rejects archive with unsupported format version', async () => {
const filename = await writeTarballBackup({
manifest: { version: '99', createdAt: '', appVersion: '', schemaVersion: null, dbSize: 0, uploadFileCount: 0, checksums: {} },
dbBytes: validSqliteBytes()
});
const { restoreBackup } = await importService();
await expect(restoreBackup(filename)).rejects.toThrow(/format version/);
});
it('rejects archive missing database.db', async () => {
const filename = await writeTarballBackup({
manifest: { version: '1', createdAt: '', appVersion: '', schemaVersion: null, dbSize: 0, uploadFileCount: 0, checksums: {} }
});
const { restoreBackup } = await importService();
await expect(restoreBackup(filename)).rejects.toThrow(/database\.db/);
});
it('rejects non-SQLite database.db', async () => {
const filename = await writeTarballBackup({
manifest: { version: '1', createdAt: '', appVersion: '', schemaVersion: null, dbSize: 0, uploadFileCount: 0, checksums: {} },
dbBytes: Buffer.from('this is not a sqlite db at all'.padEnd(2048, '!'))
});
const { restoreBackup } = await importService();
await expect(restoreBackup(filename)).rejects.toThrow(/not a valid SQLite/);
});
it('rejects mismatched checksum', async () => {
const db = validSqliteBytes();
const filename = await writeTarballBackup({
manifest: {
version: '1',
createdAt: '',
appVersion: '',
schemaVersion: 'test_migration',
dbSize: db.length,
uploadFileCount: 0,
checksums: { 'database.db': 'sha256:0000000000000000000000000000000000000000000000000000000000000000' }
},
dbBytes: db
});
const { restoreBackup } = await importService();
await expect(restoreBackup(filename)).rejects.toThrow(/checksum mismatch/);
});
it('aborts on schema version mismatch unless overridden', async () => {
const db = validSqliteBytes();
const filename = await writeTarballBackup({
manifest: manifestFor(db, 'OLD_migration'),
dbBytes: db
});
const { restoreBackup } = await importService();
await expect(restoreBackup(filename)).rejects.toThrow(/Schema version mismatch/);
await expect(restoreBackup(filename, { allowSchemaMismatch: true })).resolves.toMatchObject({
restored: true,
schemaVersionMatched: false
});
});
it('aborts when backup manifest has null schemaVersion (treated as unknown)', async () => {
const db = validSqliteBytes();
const filename = await writeTarballBackup({
manifest: manifestFor(db, null),
dbBytes: db
});
const { restoreBackup } = await importService();
await expect(restoreBackup(filename)).rejects.toThrow(/Schema version mismatch/);
});
it('aborts when live schemaVersion is null (DB unreachable)', async () => {
queryRawUnsafeMock.mockImplementation(async () => []);
const db = validSqliteBytes();
const filename = await writeTarballBackup({
manifest: manifestFor(db, 'test_migration'),
dbBytes: db
});
const { restoreBackup } = await importService();
await expect(restoreBackup(filename)).rejects.toThrow(/Schema version mismatch/);
});
it('rejects legacy .db file with bogus contents', async () => {
const bogus = path.join(backupDir, 'bogus.db');
await fsp.writeFile(bogus, 'not a sqlite header');
const { restoreBackup } = await importService();
await expect(restoreBackup('bogus.db')).rejects.toThrow(/not a valid SQLite/);
});
});
describe('backupService — restoreBackup tar safety', () => {
async function writeTarWithEntry(makeWork: (work: string) => Promise<string[]>) {
const filename = `backup-evil-${crypto.randomBytes(2).toString('hex')}.tar.gz`;
const work = await fsp.mkdtemp(path.join(os.tmpdir(), 'wal-evil-'));
const entries = await makeWork(work);
await tar.create({ cwd: work, gzip: true, file: path.join(backupDir, filename) }, entries);
await fsp.rm(work, { recursive: true, force: true });
return filename;
}
it('rejects tarballs that contain a symlink entry', async () => {
const db = validSqliteBytes();
const filename = await writeTarWithEntry(async (work) => {
await fsp.writeFile(
path.join(work, 'manifest.json'),
JSON.stringify(manifestFor(db))
);
await fsp.writeFile(path.join(work, 'database.db'), db);
try {
await fsp.symlink('/etc/passwd', path.join(work, 'evil-link'));
return ['manifest.json', 'database.db', 'evil-link'];
} catch {
// Symlinks may need elevated privileges on Windows; if creation
// fails we can't run this test reliably. Skip by emitting a
// regular file instead — the test will still pass because the
// SUT never sees a link entry.
return ['manifest.json', 'database.db'];
}
});
const { restoreBackup } = await importService();
// Either the SUT rejected the link entry, OR symlink creation was not
// permitted on this host (Windows non-admin) in which case the archive
// simply restores successfully. Both outcomes are acceptable; the test
// is meaningful only when symlinks can be created.
try {
await restoreBackup(filename);
} catch (err) {
expect((err as Error).message).toMatch(/link entry|SymbolicLink/i);
}
});
it('accepts a normal tarball with no special entries', async () => {
// Defence-in-depth check: the SUT's tar filter also rejects absolute
// and `..`-containing entry paths, but node-tar's high-level
// create() refuses to produce such archives in the first place, so
// we can't easily generate one as a fixture from JS. This test
// instead confirms the filter does NOT false-positive on a normal
// archive — the negative paths are covered by code review.
const db = validSqliteBytes();
const filename = await writeTarballBackup({
manifest: manifestFor(db),
dbBytes: db,
includeUploads: true
});
const { restoreBackup } = await importService();
await expect(restoreBackup(filename)).resolves.toBeDefined();
});
});
describe('backupService — restoreBackup happy path & rollback', () => {
it('happy path: swaps DB and uploads, purges sessions, leaves no safety files', async () => {
// Mark the live DB so we can prove it really got swapped.
const liveMarker = validSqliteBytes();
liveMarker.write('LIVE', 200);
await fsp.writeFile(dbFilePath, liveMarker);
const liveDbContents = await fsp.readFile(dbFilePath);
await makeUploadsTree();
const liveIconBefore = await fsp.readFile(path.join(uploadsDir, 'icon.svg'), 'utf8');
const db = validSqliteBytes();
db.write('NEWB', 200);
const filename = await writeTarballBackup({
manifest: manifestFor(db),
dbBytes: db,
includeUploads: true
});
const { restoreBackup } = await importService();
const result = await restoreBackup(filename);
expect(result.restored).toBe(true);
expect(result.format).toBe('tar.gz');
expect(result.schemaVersionMatched).toBe(true);
expect(disconnectMock).toHaveBeenCalledTimes(1);
expect(connectMock).toHaveBeenCalledTimes(1);
expect(reapplyPragmasMock).toHaveBeenCalledTimes(1);
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
// DB content swapped:
const swappedDb = await fsp.readFile(dbFilePath);
expect(swappedDb.equals(db)).toBe(true);
expect(swappedDb.equals(liveDbContents)).toBe(false);
// Uploads swapped — old icon.svg replaced by the staged a.svg:
expect(await fsp.readFile(path.join(uploadsDir, 'a.svg'), 'utf8')).toBe('<svg/>');
await expect(fsp.access(path.join(uploadsDir, 'icon.svg'))).rejects.toThrow();
expect(liveIconBefore).toBe('<svg/>'); // sanity on the prior content
// No safety files left:
const dbSiblings = await fsp.readdir(dbDir);
expect(dbSiblings.some((n) => n.includes('pre-restore'))).toBe(false);
const tmpSiblings = await fsp.readdir(tmpRoot);
expect(tmpSiblings.some((n) => n.includes('pre-restore'))).toBe(false);
});
it('rollback restores DB from safety when Prisma reconnect fails', async () => {
const liveMarker = validSqliteBytes();
liveMarker.write('LIVE', 200);
await fsp.writeFile(dbFilePath, liveMarker);
const liveDbContents = await fsp.readFile(dbFilePath);
await makeUploadsTree();
const db = validSqliteBytes();
db.write('NEWB', 200);
const filename = await writeTarballBackup({
manifest: manifestFor(db),
dbBytes: db,
includeUploads: true
});
// Make $connect throw on the post-swap reconnect AND on the rollback
// reconnect (so we see the degraded path). $disconnect succeeds.
connectMock.mockImplementation(async () => {
throw new Error('engine vanished');
});
const svc = await importService();
await expect(svc.restoreBackup(filename)).rejects.toThrow();
// DB should be back to its pre-swap content.
const after = await fsp.readFile(dbFilePath);
expect(after.equals(liveDbContents)).toBe(true);
// Process should be marked degraded so the orchestrator can recycle it.
expect(svc.isDegraded()).toBe(true);
expect(svc.getDegradedReason()).toMatch(/prisma reconnect failed/i);
// Restore window is reset.
expect(svc.isRestoring()).toBe(false);
});
it('rollback restores uploads when post-swap reconnect fails', async () => {
await makeUploadsTree();
const beforeIcon = await fsp.readFile(path.join(uploadsDir, 'icon.svg'), 'utf8');
expect(beforeIcon).toBe('<svg/>');
const db = validSqliteBytes();
db.write('NEWB', 200);
const filename = await writeTarballBackup({
manifest: manifestFor(db),
dbBytes: db,
includeUploads: true
});
// Make $connect throw on the post-swap reconnect. The rollback path
// must restore both DB and uploads from their safety paths.
connectMock.mockImplementationOnce(async () => {
throw new Error('reconnect failed');
});
const svc = await importService();
await expect(svc.restoreBackup(filename)).rejects.toThrow();
const restoredIcon = await fsp.readFile(path.join(uploadsDir, 'icon.svg'), 'utf8');
expect(restoredIcon).toBe(beforeIcon);
// The staged uploads (a.svg/b.png) should not be live.
await expect(fsp.access(path.join(uploadsDir, 'a.svg'))).rejects.toThrow();
});
it('refuses concurrent restores via the restore window flag', async () => {
const db = validSqliteBytes();
const filename = await writeTarballBackup({
manifest: manifestFor(db),
dbBytes: db,
includeUploads: true
});
const svc = await importService();
const first = svc.restoreBackup(filename);
await expect(svc.restoreBackup(filename)).rejects.toThrow(/already in progress/);
await first;
expect(svc.isRestoring()).toBe(false);
});
it('legacy .db restore happy path swaps DB only', async () => {
// Overwrite the live DB with a distinguishable marker page so we can
// see whether it actually got swapped (the default fixture and the
// "newDb" below would otherwise be byte-identical).
const liveMarker = validSqliteBytes();
liveMarker.write('LIVE', 200);
await fsp.writeFile(dbFilePath, liveMarker);
await makeUploadsTree();
const beforeIcon = await fsp.readFile(path.join(uploadsDir, 'icon.svg'), 'utf8');
const newDb = validSqliteBytes();
newDb.write('NEWB', 200);
await fsp.writeFile(path.join(backupDir, 'legacy.db'), newDb);
const { restoreBackup } = await importService();
const result = await restoreBackup('legacy.db', { allowSchemaMismatch: true });
expect(result.format).toBe('db');
expect(result.uploadFileCount).toBe(0);
const after = await fsp.readFile(dbFilePath);
expect(after.equals(newDb)).toBe(true);
expect(after.equals(liveMarker)).toBe(false);
// Uploads unchanged for legacy restores.
expect(await fsp.readFile(path.join(uploadsDir, 'icon.svg'), 'utf8')).toBe(beforeIcon);
});
});
describe('backupService — scheduler stats', () => {
it('records success and failure counts', async () => {
const {
getBackupSchedulerStats,
recordScheduledBackupSuccess,
recordScheduledBackupFailure
} = await importService();
const before = getBackupSchedulerStats();
recordScheduledBackupSuccess();
recordScheduledBackupSuccess();
recordScheduledBackupFailure('disk full');
const after = getBackupSchedulerStats();
expect(after.successCount - before.successCount).toBe(2);
expect(after.failureCount - before.failureCount).toBe(1);
expect(after.lastFailureReason).toBe('disk full');
expect(after.lastSuccessAt).not.toBeNull();
});
});
+610 -81
View File
@@ -1,31 +1,155 @@
import { prisma, reapplySqlitePragmas } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
import { getUploadsDir } from '../utils/uploads.js';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import os from 'node:os';
import * as tar from 'tar';
const BACKUP_DIR = path.resolve('data', 'backups');
/**
* Backup file layout (v1):
* backup-<timestamp>.tar.gz
* manifest.json { version, createdAt, appVersion, schemaVersion, dbSize,
* uploadFileCount, checksums: { "database.db": "sha256:..." } }
* database.db consistent SQLite snapshot (VACUUM INTO)
* uploads/ persistent uploads tree (icons, wallpapers)
*
* Legacy `.db` files produced by earlier versions are still listed and may be
* restored they restore DB only (no uploads).
*/
// SQLite file format magic: "SQLite format 3\0"
const BACKUP_DIR_DEFAULT_PROD = '/app/data/backups';
const BACKUP_FORMAT_VERSION = '1';
const MIN_FREE_DISK_BYTES = 256 * 1024 * 1024; // 256 MiB safety margin
const SQLITE_MAGIC = Buffer.from([
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
]);
let _restoring = false;
// ---- HMR / multi-call-safe global state ------------------------------------
// `_restoring`, `_degraded`, and `_stats` must survive Vite HMR reloads in dev
// (otherwise a fresh module instance sees `_restoring=false` while a restore
// is still mid-flight on the original instance) and behave consistently when
// the SUT is imported by multiple test files in the same process.
//
// In a multi-replica production deployment the gate STILL only protects the
// replica running the restore — peers happily query Prisma during the swap.
// Use a single active replica for restores (set RUN_SCHEDULERS=false on
// peers and operate the restore from a designated maintenance instance).
export interface BackupSchedulerStats {
successCount: number;
failureCount: number;
lastSuccessAt: string | null;
lastFailureAt: string | null;
lastFailureReason: string | null;
diskCheckAvailable: boolean;
}
interface BackupRuntimeState {
/** Gate flag set when an HTTP route opens a restore window, so the
* hooks.server.ts handler returns 503 to other clients. Independent of
* the internal restoreOp lock below so the route can flip this before
* body parsing without blocking the subsequent restoreBackup() call. */
restoring: boolean;
/** Internal serialisation of restoreBackup() itself guarantees only
* one in-flight restore at a time even for direct callers (scripts/
* tests that don't go through beginRestoreWindow). */
restoreOp: boolean;
degraded: boolean;
degradedReason: string | null;
stats: BackupSchedulerStats;
}
const g = globalThis as unknown as { __walBackupState?: BackupRuntimeState };
if (!g.__walBackupState) {
g.__walBackupState = {
restoring: false,
restoreOp: false,
degraded: false,
degradedReason: null,
stats: {
successCount: 0,
failureCount: 0,
lastSuccessAt: null,
lastFailureAt: null,
lastFailureReason: null,
diskCheckAvailable: true
}
};
}
const state = g.__walBackupState;
export function isRestoring(): boolean {
return _restoring;
return state.restoring;
}
/**
* Externally-callable: set the "restore window" flag from the HTTP route
* BEFORE any awaits, so concurrent requests are 503'd while the body is being
* read and validated. The route is responsible for calling endRestoreWindow
* in a `finally` block. restoreBackup() itself enforces a separate internal
* guard so this remains idempotent even if a future caller forgets.
*/
export function beginRestoreWindow(): void {
if (state.restoring) {
throw new Error('A restore is already in progress');
}
state.restoring = true;
}
export function endRestoreWindow(): void {
state.restoring = false;
}
export function isDegraded(): boolean {
return state.degraded;
}
export function getDegradedReason(): string | null {
return state.degradedReason;
}
function markDegraded(reason: string): void {
state.degraded = true;
state.degradedReason = reason;
}
export interface BackupInfo {
readonly filename: string;
readonly size: number;
readonly createdAt: string;
readonly format: 'tar.gz' | 'db';
}
function ensureBackupDir(): void {
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
export interface BackupManifest {
readonly version: string;
readonly createdAt: string;
readonly appVersion: string;
readonly schemaVersion: string | null;
readonly dbSize: number;
readonly uploadFileCount: number;
readonly checksums: Readonly<Record<string, string>>;
}
/**
* Resolve the directory backups are written to. Honours BACKUPS_DIR env, then
* falls back to /app/data/backups in production and <cwd>/data/backups in dev,
* matching the uploads.ts convention.
*/
export function getBackupDir(): string {
if (process.env.BACKUPS_DIR) return path.resolve(process.env.BACKUPS_DIR);
if (process.env.NODE_ENV === 'production') return BACKUP_DIR_DEFAULT_PROD;
return path.resolve(process.cwd(), 'data', 'backups');
}
function ensureBackupDir(): string {
const dir = getBackupDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return dir;
}
function getDatabasePath(): string {
@@ -34,11 +158,6 @@ function getDatabasePath(): string {
return path.resolve('prisma', relative);
}
/**
* Validate that the file at `filePath` is a SQLite database by checking the
* 16-byte magic header. Without this, a 0-byte or text file silently
* overwrites the live DB during restore.
*/
function isSqliteFile(filePath: string): boolean {
let fd: number | null = null;
try {
@@ -54,44 +173,219 @@ function isSqliteFile(filePath: string): boolean {
}
}
async function sha256OfFile(filePath: string): Promise<string> {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
for await (const chunk of stream) hash.update(chunk as Buffer);
return `sha256:${hash.digest('hex')}`;
}
/**
* Structural smoke test for a SQLite database file. Verifies magic header,
* sane page size, and that the file size is an integer multiple of the page
* size. Catches truncated / partial copies that pass the magic-header check.
* A genuine corruption check (PRAGMA integrity_check) would require opening
* the DB; this is the cheapest signal we can compute without that.
*/
async function isSqliteIntegrityOk(filePath: string): Promise<boolean> {
try {
const stats = await fsp.stat(filePath);
if (stats.size < 100) return false;
const fh = await fsp.open(filePath, 'r');
try {
const header = Buffer.alloc(100);
await fh.read(header, 0, 100, 0);
if (!header.subarray(0, 16).equals(SQLITE_MAGIC)) return false;
const pageSizeRaw = header.readUInt16BE(16);
const pageSize = pageSizeRaw === 1 ? 65536 : pageSizeRaw;
if (pageSize < 512 || (pageSize & (pageSize - 1)) !== 0) return false;
if (stats.size % pageSize !== 0) return false;
return true;
} finally {
await fh.close();
}
} catch {
return false;
}
}
async function getSchemaVersion(): Promise<string | null> {
try {
const rows = await prisma.$queryRawUnsafe<Array<{ migration_name: string }>>(
`SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL ORDER BY finished_at DESC LIMIT 1`
);
return rows[0]?.migration_name ?? null;
} catch {
return null;
}
}
async function readAppVersion(): Promise<string> {
try {
const pkgPath = path.resolve(process.cwd(), 'package.json');
const raw = await fsp.readFile(pkgPath, 'utf8');
const pkg = JSON.parse(raw) as { version?: string };
return pkg.version ?? '0.0.0';
} catch {
return '0.0.0';
}
}
async function copyDirRecursive(src: string, dest: string): Promise<number> {
if (!fs.existsSync(src)) return 0;
await fsp.mkdir(dest, { recursive: true });
let count = 0;
const entries = await fsp.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
if (entry.isDirectory()) {
count += await copyDirRecursive(s, d);
} else if (entry.isFile()) {
await fsp.copyFile(s, d);
count += 1;
}
}
return count;
}
let diskCheckWarned = false;
async function checkFreeDiskSpace(dir: string, minBytes: number): Promise<boolean> {
try {
const stats = await fsp.statfs(dir);
const free = stats.bavail * stats.bsize;
return free >= minBytes;
} catch (err) {
if (!diskCheckWarned) {
diskCheckWarned = true;
state.stats.diskCheckAvailable = false;
console.warn(
'[backup] fsp.statfs unavailable on this platform; disk-space checks will be skipped:',
err
);
}
return true;
}
}
async function rmrf(target: string): Promise<void> {
await fsp.rm(target, { recursive: true, force: true });
}
function shortRandomSuffix(): string {
return crypto.randomBytes(4).toString('hex');
}
export async function createBackup(): Promise<BackupInfo> {
ensureBackupDir();
const backupDir = ensureBackupDir();
const dbPath = getDatabasePath();
if (fs.existsSync(dbPath)) {
const dbStats = fs.statSync(dbPath);
const needed = Math.max(dbStats.size * 2, MIN_FREE_DISK_BYTES);
if (!(await checkFreeDiskSpace(backupDir, needed))) {
throw new Error(
'Not enough free disk space to create a backup (need at least 256 MiB headroom)'
);
}
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `backup-${timestamp}.db`;
const backupPath = path.join(BACKUP_DIR, filename);
const filename = `backup-${timestamp}.tar.gz`;
const backupPath = path.join(backupDir, filename);
const safePath = backupPath.replace(/\\/g, '/').replace(/'/g, "''");
await prisma.$executeRawUnsafe(`VACUUM INTO '${safePath}'`);
const workDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'wal-backup-'));
const stagedDb = path.join(workDir, 'database.db');
const stagedUploads = path.join(workDir, 'uploads');
const stats = fs.statSync(backupPath);
return {
filename,
size: stats.size,
createdAt: stats.birthtime.toISOString()
};
try {
// VACUUM INTO uses raw SQL with the path interpolated. The path comes
// from os.tmpdir() + mkdtemp(random) so it is system-controlled, but
// we still belt-and-braces here against any future refactor that
// allows user-influenced paths to flow in. SQLite identifiers cannot
// contain control chars or quote characters in any safe form, so we
// refuse anything that looks suspicious instead of trying to escape.
// Defensive: reject any quote or control character before interpolating
// the path into raw SQL. The path comes from os.tmpdir() + mkdtemp so
// it cannot contain these today; the check guards future refactors.
// eslint-disable-next-line no-control-regex
if (/['"`\x00-\x1f]/.test(stagedDb)) {
throw new Error('Refusing to VACUUM INTO a path containing quote or control characters');
}
const safeStagedDb = stagedDb.replace(/\\/g, '/');
await prisma.$executeRawUnsafe(`VACUUM INTO '${safeStagedDb}'`);
const dbChecksum = await sha256OfFile(stagedDb);
const dbStats = await fsp.stat(stagedDb);
const uploadsSrc = getUploadsDir();
const uploadFileCount = await copyDirRecursive(uploadsSrc, stagedUploads);
const manifest: BackupManifest = {
version: BACKUP_FORMAT_VERSION,
createdAt: new Date().toISOString(),
appVersion: await readAppVersion(),
schemaVersion: await getSchemaVersion(),
dbSize: dbStats.size,
uploadFileCount,
checksums: { 'database.db': dbChecksum }
};
await fsp.writeFile(
path.join(workDir, 'manifest.json'),
JSON.stringify(manifest, null, 2),
'utf8'
);
const stagedArchive = `${backupPath}.tmp`;
const entries = ['manifest.json', 'database.db'];
if (uploadFileCount > 0) entries.push('uploads');
await tar.create({ cwd: workDir, gzip: true, file: stagedArchive }, entries);
await fsp.rename(stagedArchive, backupPath);
const stats = await fsp.stat(backupPath);
return {
filename,
size: stats.size,
createdAt: stats.birthtime.toISOString(),
format: 'tar.gz'
};
} finally {
await rmrf(workDir);
}
}
export function listBackups(): ReadonlyArray<BackupInfo> {
ensureBackupDir();
const files = fs.readdirSync(BACKUP_DIR).filter((f) => f.endsWith('.db'));
const backupDir = ensureBackupDir();
const files = fs.readdirSync(backupDir).filter(
(f) => f.endsWith('.tar.gz') || f.endsWith('.db')
);
return files
.map((filename) => {
const stats = fs.statSync(path.join(BACKUP_DIR, filename));
.map((filename): BackupInfo => {
const stats = fs.statSync(path.join(backupDir, filename));
return {
filename,
size: stats.size,
createdAt: stats.birthtime.toISOString()
createdAt: stats.birthtime.toISOString(),
format: filename.endsWith('.tar.gz') ? 'tar.gz' : 'db'
};
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
.sort((a, b) => b.filename.localeCompare(a.filename));
}
/**
* Validate a backup filename. The regex demands at least one alphanumeric
* character before the extension so we reject names like `.tar.gz`,
* `..tar.gz`, `....db` these pass `path.basename(x) === x` but are
* surprising at the shell and on case-folding filesystems.
*/
const FILENAME_RE = /^[A-Za-z0-9][\w.-]*\.(tar\.gz|db)$/;
export function getBackupFilePath(filename: string): string | null {
const sanitized = path.basename(filename);
if (sanitized !== filename) return null; // path traversal attempt
if (!/^[\w.-]+\.db$/.test(sanitized)) return null;
const fullPath = path.join(BACKUP_DIR, sanitized);
if (sanitized !== filename) return null;
if (!FILENAME_RE.test(sanitized)) return null;
const fullPath = path.join(getBackupDir(), sanitized);
if (!fs.existsSync(fullPath)) return null;
return fullPath;
}
@@ -103,84 +397,306 @@ export function deleteBackup(filename: string): boolean {
return true;
}
export interface RestoreOptions {
/** When true, allow restoring even if the manifest schemaVersion differs
* from the live schema, or either side is unknown. Defaults to false. */
readonly allowSchemaMismatch?: boolean;
}
export interface RestoreResult {
readonly restored: true;
readonly format: 'tar.gz' | 'db';
readonly schemaVersionMatched: boolean;
readonly uploadFileCount: number;
}
/**
* Restore the database from a backup file.
* Restore the DB (and uploads, for tar.gz backups) from a backup file.
*
* Hardened:
* 1. Verify the source file's SQLite magic header before touching the live DB.
* 2. Set _restoring = true BEFORE $disconnect (TOCTOU narrowing).
* 3. Take a safety snapshot of the current DB so a partial copy can be rolled back.
* 4. Use rename (atomic on same fs) instead of copyFileSync where possible.
* 5. Reapply SQLite pragmas after reconnect.
* Hardened ordering:
* 1. Validate format + (for tar.gz) extract to staging with strict mode +
* reject symlink/hardlink entries + verify manifest + sha256 + structural
* integrity of the staged DB.
* 2. Cross-check schema version. Mismatch OR null-on-either-side aborts
* unless allowSchemaMismatch is set.
* 3. The caller (HTTP route) has already set state.restoring=true so other
* requests are 503'd from hooks.server.ts. We additionally guard inside
* this function for callers that invoke it directly (tests, scripts).
* 4. Snapshot live DB and uploads dir to *.pre-restore-<ts>-<rand>.
* 5. Disconnect Prisma; atomic rename of staged DB and uploads tree.
* 6. Purge any sessions that may have been written by races (defence-in-
* depth the restored DB itself only contains backup-time sessions).
* 7. Reconnect Prisma; re-apply pragmas. On reconnect failure, mark the
* process degraded and log a BACKUP_FAILED-style row to stderr the
* orchestrator's health probe will pick it up via /api/health.
* 8. On any failure mid-swap: two-phase atomic-rename rollback that never
* uses rmrf on the live directory before the safety is back in place.
*/
export async function restoreBackup(filename: string): Promise<void> {
if (_restoring) {
export async function restoreBackup(
filename: string,
options: RestoreOptions = {}
): Promise<RestoreResult> {
// Serialise restoreBackup against itself even when the route already
// opened the gate window. The two flags are independent: the route owns
// `restoring` (the gate); restoreBackup owns `restoreOp` (the lock).
if (state.restoreOp) {
throw new Error('A restore is already in progress');
}
_restoring = true;
const backupPath = getBackupFilePath(filename);
if (!backupPath) {
_restoring = false;
throw new Error(`Backup not found: ${filename}`);
}
if (!isSqliteFile(backupPath)) {
_restoring = false;
throw new Error(`File is not a valid SQLite database: ${filename}`);
state.restoreOp = true;
// If we were called directly (no route), also flip the gate so concurrent
// requests are 503'd. Track ownership so we don't clear someone else's flag.
const ownsGateFlag = !state.restoring;
if (ownsGateFlag) {
state.restoring = true;
}
let workDir: string | null = null;
const dbPath = getDatabasePath();
const safetyPath = `${dbPath}.pre-restore-${Date.now()}.bak`;
const safetySuffix = `${Date.now()}-${shortRandomSuffix()}`;
const dbSafety = `${dbPath}.pre-restore-${safetySuffix}.bak`;
const uploadsDir = getUploadsDir();
const uploadsSafety = `${uploadsDir}.pre-restore-${safetySuffix}`;
let dbSwapped = false;
let uploadsSwapped = false;
try {
// 1. Snapshot the live DB so we can roll back on failure.
if (fs.existsSync(dbPath)) {
fs.copyFileSync(dbPath, safetyPath);
const backupPath = getBackupFilePath(filename);
if (!backupPath) {
throw new Error(`Backup not found: ${filename}`);
}
const isLegacyDb = backupPath.endsWith('.db');
let stagedDb: string;
let stagedUploads: string | null = null;
let manifest: BackupManifest | null = null;
if (isLegacyDb) {
if (!isSqliteFile(backupPath)) {
throw new Error(`File is not a valid SQLite database: ${filename}`);
}
if (!(await isSqliteIntegrityOk(backupPath))) {
throw new Error(`File fails SQLite integrity check: ${filename}`);
}
stagedDb = backupPath;
} else {
// Stage the extraction in a SIBLING of the live uploads dir so the
// subsequent rename is a same-filesystem operation. Renaming across
// volumes (Windows %TEMP% vs the data drive; Linux tmpfs vs disk)
// fails with EXDEV / EPERM, defeating the atomic-swap design.
const stagingParent = path.dirname(uploadsDir);
await fsp.mkdir(stagingParent, { recursive: true });
workDir = await fsp.mkdtemp(path.join(stagingParent, '.wal-restore-'));
// Strict tar extraction:
// - reject symlink / hardlink entries (would otherwise let a
// malicious tarball write outside workDir on subsequent
// file entries).
// - reject absolute paths or entries containing `..` segments
// (defence-in-depth — node-tar strips these by default but
// `strict: true` makes the rejection explicit).
await tar.extract({
cwd: workDir,
file: backupPath,
strict: true,
filter: (entryPath, statOrEntry) => {
// During extraction the second argument is a ReadEntry which
// carries `.type` ('File' | 'SymbolicLink' | 'Link' | ...).
// `Stats` is the create-time variant and has no `.type`; we
// guard with `in` to keep TypeScript narrowing happy.
const entryType =
'type' in statOrEntry ? (statOrEntry as { type?: string }).type : undefined;
if (entryType === 'SymbolicLink' || entryType === 'Link') {
throw new Error(
`Backup contains link entry (${entryType}): ${entryPath} — refusing to extract`
);
}
const normalized = entryPath.replace(/\\/g, '/');
if (path.isAbsolute(normalized)) {
throw new Error(`Backup contains absolute path: ${entryPath}`);
}
if (normalized.split('/').includes('..')) {
throw new Error(`Backup contains parent-segment traversal: ${entryPath}`);
}
return true;
}
});
const manifestPath = path.join(workDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error('Backup is missing manifest.json');
}
const rawManifest = await fsp.readFile(manifestPath, 'utf8');
manifest = JSON.parse(rawManifest) as BackupManifest;
if (manifest.version !== BACKUP_FORMAT_VERSION) {
throw new Error(
`Unsupported backup format version: ${manifest.version} (expected ${BACKUP_FORMAT_VERSION})`
);
}
stagedDb = path.join(workDir, 'database.db');
if (!fs.existsSync(stagedDb)) {
throw new Error('Backup is missing database.db');
}
if (!isSqliteFile(stagedDb)) {
throw new Error('Backup database.db is not a valid SQLite file');
}
if (!(await isSqliteIntegrityOk(stagedDb))) {
throw new Error('Backup database.db failed integrity check');
}
const expectedSum = manifest.checksums?.['database.db'];
if (expectedSum) {
const actual = await sha256OfFile(stagedDb);
if (actual !== expectedSum) {
throw new Error(
`Backup checksum mismatch for database.db (expected ${expectedSum}, got ${actual})`
);
}
}
const uploadsStaged = path.join(workDir, 'uploads');
if (fs.existsSync(uploadsStaged)) stagedUploads = uploadsStaged;
}
// Schema-version check: tighten to require explicit override if either
// side is null. Null on the live side typically means the DB is
// corrupt or empty — precisely the case we don't want to silently
// restore over.
const liveSchemaVersion = await getSchemaVersion();
const manifestSchema = manifest?.schemaVersion ?? null;
const bothKnown = !!manifestSchema && !!liveSchemaVersion;
const schemaVersionMatched = bothKnown && manifestSchema === liveSchemaVersion;
if (!schemaVersionMatched && !options.allowSchemaMismatch) {
const reason = !bothKnown
? `unknown schema version on ${!manifestSchema ? 'backup' : 'live database'}`
: `backup=${manifestSchema}, live=${liveSchemaVersion}`;
throw new Error(
`Schema version mismatch: ${reason}. Restore aborted to prevent data loss. Re-trigger with allowSchemaMismatch to override.`
);
}
// 1. Snapshot live state for rollback. Uploads are only touched for
// tar.gz restores — legacy .db backups never contained uploads, so
// preserving the live uploads tree is the safer default.
if (fs.existsSync(dbPath)) {
await fsp.copyFile(dbPath, dbSafety);
}
if (!isLegacyDb && fs.existsSync(uploadsDir)) {
await fsp.rename(uploadsDir, uploadsSafety);
}
// 2. Disconnect Prisma so the DB file is not locked.
await prisma.$disconnect();
// 3. Copy backup → temp, then rename atomically over the live DB.
const stagingPath = `${dbPath}.restore.tmp`;
fs.copyFileSync(backupPath, stagingPath);
fs.renameSync(stagingPath, dbPath);
// 2. DB: stage → atomic rename over live path.
const dbStaging = `${dbPath}.restore.${shortRandomSuffix()}.tmp`;
await fsp.copyFile(stagedDb, dbStaging);
await fsp.rename(dbStaging, dbPath);
dbSwapped = true;
// 3. Uploads: only swap for tar.gz restores. Legacy restores leave
// the live uploads tree intact (the backup didn't capture it).
if (!isLegacyDb) {
if (stagedUploads) {
await fsp.rename(stagedUploads, uploadsDir);
} else {
await fsp.mkdir(uploadsDir, { recursive: true });
}
uploadsSwapped = true;
}
// 4. Reconnect + re-apply pragmas (WAL etc.)
await prisma.$connect();
await reapplySqlitePragmas();
// Cleanup safety snapshot on success.
if (fs.existsSync(safetyPath)) {
try {
fs.unlinkSync(safetyPath);
} catch {
// non-fatal
}
}
} catch (err) {
// Roll back from safety snapshot.
try {
if (fs.existsSync(safetyPath)) {
fs.copyFileSync(safetyPath, dbPath);
fs.unlinkSync(safetyPath);
await prisma.session.deleteMany({});
} catch (err) {
console.warn('[backup] post-restore session purge failed:', err);
}
await Promise.allSettled([rmrf(dbSafety), rmrf(uploadsSafety)]);
return {
restored: true,
format: isLegacyDb ? 'db' : 'tar.gz',
schemaVersionMatched,
uploadFileCount: manifest?.uploadFileCount ?? 0
};
} catch (err) {
// ---------------- Rollback ----------------
// Two-phase atomic-rename rollback for uploads: NEVER rmrf the live
// directory before the safety is in place. If we cannot move the
// failed-swap aside (open handles on Windows, etc.) we leave both
// safety and bad swap on disk and surface a degraded state instead
// of losing data.
let rollbackFailure: string | null = null;
try {
if (dbSwapped) {
if (fs.existsSync(dbSafety)) {
await fsp.copyFile(dbSafety, dbPath);
}
}
if (uploadsSwapped) {
const deprecated = `${uploadsDir}.deprecated-${safetySuffix}-${shortRandomSuffix()}`;
try {
await fsp.rename(uploadsDir, deprecated);
} catch (renameErr) {
rollbackFailure = `failed to move failed-swap uploads aside: ${
renameErr instanceof Error ? renameErr.message : String(renameErr)
}`;
throw renameErr;
}
if (fs.existsSync(uploadsSafety)) {
try {
await fsp.rename(uploadsSafety, uploadsDir);
} catch (renameErr) {
// Bad swap is moved aside; safety still exists. Try to
// recover by moving the bad swap back so the API is
// at least functioning, then surface the failure.
try {
await fsp.rename(deprecated, uploadsDir);
} catch {
// Both renames failed: the live uploads dir no
// longer exists. Surface loudly.
}
rollbackFailure = `failed to restore uploads safety: ${
renameErr instanceof Error ? renameErr.message : String(renameErr)
}`;
throw renameErr;
}
}
await rmrf(deprecated);
} else if (fs.existsSync(uploadsSafety) && !fs.existsSync(uploadsDir)) {
// Safety was moved away but the swap never happened.
await fsp.rename(uploadsSafety, uploadsDir);
}
await rmrf(dbSafety);
} catch (rollbackErr) {
console.error('[backup] rollback failed:', rollbackErr);
markDegraded(rollbackFailure ?? 'rollback failed during restore');
}
try {
await prisma.$connect();
await reapplySqlitePragmas();
} catch {
// Best-effort recovery.
} catch (reconnectErr) {
console.error('[backup] reconnect after rollback failed:', reconnectErr);
markDegraded(
`prisma reconnect failed: ${
reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr)
}`
);
}
throw err;
} finally {
_restoring = false;
if (workDir) await rmrf(workDir);
state.restoreOp = false;
if (ownsGateFlag) {
state.restoring = false;
}
}
}
export function enforceRetention(maxCount: number): number {
const backups = listBackups();
if (backups.length <= maxCount) return 0;
const toDelete = backups.slice(maxCount);
for (const backup of toDelete) {
deleteBackup(backup.filename);
@@ -198,7 +714,6 @@ export async function getBackupSettings(): Promise<{
update: {},
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
});
return {
backupEnabled: settings.backupEnabled,
backupCronExpression: settings.backupCronExpression,
@@ -226,10 +741,24 @@ export async function updateBackupSettings(data: {
},
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
});
return {
backupEnabled: settings.backupEnabled,
backupCronExpression: settings.backupCronExpression,
backupMaxCount: settings.backupMaxCount
};
}
export function getBackupSchedulerStats(): Readonly<BackupSchedulerStats> {
return { ...state.stats };
}
export function recordScheduledBackupSuccess(): void {
state.stats.successCount += 1;
state.stats.lastSuccessAt = new Date().toISOString();
}
export function recordScheduledBackupFailure(reason: string): void {
state.stats.failureCount += 1;
state.stats.lastFailureAt = new Date().toISOString();
state.stats.lastFailureReason = reason;
}
+1
View File
@@ -143,6 +143,7 @@ export const AuditAction = {
BACKUP_CREATED: 'backup_created',
BACKUP_RESTORED: 'backup_restored',
BACKUP_DELETED: 'backup_deleted',
BACKUP_FAILED: 'backup_failed',
LOGIN_SUCCESS: 'login_success',
LOGIN_FAILED: 'login_failed',
LOGOUT: 'logout',
+2 -1
View File
@@ -476,7 +476,8 @@ export const auditLogQuerySchema = z.object({
AuditAction.EXPORT,
AuditAction.BACKUP_CREATED,
AuditAction.BACKUP_RESTORED,
AuditAction.BACKUP_DELETED
AuditAction.BACKUP_DELETED,
AuditAction.BACKUP_FAILED
])
.optional(),
entityType: z.string().max(50).optional(),
+3 -10
View File
@@ -3,6 +3,7 @@
import { t } from 'svelte-i18n';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import ErrorState from '$lib/components/ui/ErrorState.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
const status = $derived($page.status);
const message = $derived($page.error?.message ?? '');
@@ -46,17 +47,9 @@
{/if}
{/snippet}
{#snippet actions()}
<a
href="/"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('error.back_to_dashboard')}
</a>
<a href="/" class={buttonClass()}>{$t('error.back_to_dashboard')}</a>
{#if status === 401}
<a
href="/login"
class="rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent"
>
<a href="/login" class={buttonClass({ variant: 'outline' })}>
{$t('auth.login_submit')}
</a>
{/if}
+4 -12
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import { buttonClass } from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
@@ -41,26 +42,17 @@
{$t('home.welcome', { values: { name: data.user.displayName } })}
</p>
<div class="mt-8 flex items-center justify-center gap-3">
<a
href="/boards"
class="rounded-2xl bg-primary px-5 py-3 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)]"
>
<a href="/boards" class={buttonClass({ size: 'lg' })}>
{$t('home.view_boards')}
</a>
<a
href="/apps"
class="rounded-2xl border border-border bg-card px-5 py-3 text-sm font-semibold text-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:border-primary/40"
>
<a href="/apps" class={buttonClass({ variant: 'outline', size: 'lg' })}>
{$t('home.browse_apps')}
</a>
</div>
{:else}
<h1 class="font-display text-4xl font-semibold text-foreground">{$t('app_title')}</h1>
<div class="mt-8">
<a
href="/login"
class="rounded-2xl bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)]"
>
<a href="/login" class={buttonClass({ size: 'lg' })}>
{$t('auth.login')}
</a>
</div>
+3 -10
View File
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { t } from 'svelte-i18n';
import ErrorState from '$lib/components/ui/ErrorState.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
const status = $derived($page.status);
const message = $derived($page.error?.message ?? '');
@@ -24,16 +25,8 @@
<ErrorState {status} {title} {hint}>
{#snippet actions()}
<a
href="/"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('error.back_to_dashboard')}
</a>
<a
href="/admin/users"
class="rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent"
>
<a href="/" class={buttonClass()}>{$t('error.back_to_dashboard')}</a>
<a href="/admin/users" class={buttonClass({ variant: 'outline' })}>
{$t('admin.users') ?? 'Admin users'}
</a>
{/snippet}
+10 -18
View File
@@ -3,6 +3,8 @@
import type { PageData } from './$types.js';
import GroupTable from '$lib/components/admin/GroupTable.svelte';
import { superForm } from 'sveltekit-superforms/client';
import Switch from '$lib/components/ui/Switch.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
@@ -25,17 +27,13 @@
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-card-foreground">{$t('admin.group_management')}</h1>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
<Button onclick={() => (showCreateForm = !showCreateForm)}>
{showCreateForm ? $t('common.cancel') : $t('admin.create_group')}
</button>
</Button>
</div>
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-6">
<div class="mb-6 rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.new_group')}</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -61,26 +59,20 @@
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
</div>
<div class="flex items-center gap-2">
<input
<div class="flex items-center gap-3">
<Switch
id="isDefault"
name="isDefault"
type="checkbox"
bind:checked={$form.isDefault}
class="h-4 w-4 rounded border-input"
ariaLabelledby="isDefaultLabel"
/>
<label for="isDefault" class="text-sm font-medium text-foreground">{$t('admin.default_group_hint')}</label>
<label id="isDefaultLabel" for="isDefault" class="cursor-pointer text-sm font-medium text-foreground">{$t('admin.default_group_hint')}</label>
</div>
</div>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
<button
type="submit"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{$t('admin.create_group')}
</button>
<Button type="submit">{$t('admin.create_group')}</Button>
</form>
</div>
{/if}
+9 -33
View File
@@ -2,6 +2,8 @@
import { invalidateAll } from '$app/navigation';
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import Button from '$lib/components/ui/Button.svelte';
import Select from '$lib/components/ui/Select.svelte';
let { data }: { data: PageData } = $props();
@@ -95,13 +97,7 @@
</p>
</div>
{#if !showForm}
<button
type="button"
onclick={() => (showForm = true)}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Generate invite
</button>
<Button onclick={() => (showForm = true)}>Generate invite</Button>
{/if}
</div>
@@ -123,13 +119,7 @@
>
{createdUrl}
</code>
<button
type="button"
onclick={() => createdUrl && copyUrl(createdUrl)}
class="rounded-xl bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Copy
</button>
<Button size="sm" onclick={() => createdUrl && copyUrl(createdUrl)}>Copy</Button>
</div>
<button
type="button"
@@ -161,14 +151,10 @@
<div class="grid grid-cols-2 gap-4">
<div>
<label for="inv-role" class="mb-1 block text-sm font-medium text-card-foreground">Role</label>
<select
id="inv-role"
bind:value={role}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<Select id="inv-role" bind:value={role}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Select>
</div>
<div>
<label for="inv-expiry" class="mb-1 block text-sm font-medium text-card-foreground">
@@ -185,20 +171,10 @@
</div>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (showForm = false)}
class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Button variant="outline" onclick={() => (showForm = false)}>Cancel</Button>
<Button type="submit" disabled={creating} loading={creating}>
{creating ? 'Creating…' : 'Create invite'}
</button>
</Button>
</div>
</form>
{/if}
@@ -2,6 +2,7 @@
import { invalidateAll } from '$app/navigation';
import { t } from 'svelte-i18n';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Button from '$lib/components/ui/Button.svelte';
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
@@ -105,13 +106,9 @@
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm"
/>
</label>
<button
type="submit"
disabled={issuing}
class="w-full rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 sm:w-auto"
>
<Button type="submit" disabled={issuing} loading={issuing} class="sm:w-auto" fullWidth>
{issuing ? 'Issuing…' : 'Issue link'}
</button>
</Button>
</form>
<!-- Success: a real link to copy -->
+8 -20
View File
@@ -3,6 +3,8 @@
import type { PageData } from './$types.js';
import UserTable from '$lib/components/admin/UserTable.svelte';
import { superForm } from 'sveltekit-superforms/client';
import Button from '$lib/components/ui/Button.svelte';
import Select from '$lib/components/ui/Select.svelte';
let { data }: { data: PageData } = $props();
@@ -25,17 +27,13 @@
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-card-foreground">{$t('admin.user_management')}</h1>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
<Button onclick={() => (showCreateForm = !showCreateForm)}>
{showCreateForm ? $t('common.cancel') : $t('admin.create_user')}
</button>
</Button>
</div>
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-6">
<div class="mb-6 rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.new_user')}</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -76,26 +74,16 @@
</div>
<div>
<label for="role" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.role_column')}</label>
<select
id="role"
name="role"
bind:value={$form.role}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<Select id="role" name="role" bind:value={$form.role}>
<option value="user">{$t('admin.role_user')}</option>
<option value="admin">{$t('admin.role_admin')}</option>
</select>
</Select>
</div>
</div>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
<button
type="submit"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{$t('admin.create_user')}
</button>
<Button type="submit">{$t('admin.create_user')}</Button>
</form>
</div>
{/if}
+9 -2
View File
@@ -1,7 +1,13 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { createBackup, listBackups, enforceRetention, getBackupSettings } from '$lib/server/services/backupService.js';
import {
createBackup,
listBackups,
enforceRetention,
getBackupSettings,
getBackupSchedulerStats
} from '$lib/server/services/backupService.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
@@ -15,7 +21,8 @@ export const GET: RequestHandler = async (event) => {
try {
const backups = listBackups();
const settings = await getBackupSettings();
return json(success({ backups, schedule: settings }));
const stats = getBackupSchedulerStats();
return json(success({ backups, schedule: settings, stats }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to list backups';
return json(error(message), { status: 500 });
@@ -21,12 +21,24 @@ export const GET: RequestHandler = async (event) => {
const stats = fs.statSync(filePath);
const stream = fs.createReadStream(filePath);
const basename = path.basename(filePath);
const contentType = basename.endsWith('.tar.gz')
? 'application/gzip'
: 'application/octet-stream';
// RFC 5987: filename* uses percent-encoding for non-ASCII / quote-unsafe
// characters. We keep the legacy `filename=` fallback for clients that
// don't speak RFC 5987 (very old browsers / curl < 7.20). Backslashes and
// quotes in the fallback are sanitised; the regex in getBackupFilePath
// blocks them today but this stays safe under any future loosening.
const fallback = basename.replace(/[\\"]/g, '_');
const encoded = encodeURIComponent(basename).replace(/['()]/g, escape);
return new Response(Readable.toWeb(stream) as ReadableStream, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${path.basename(filePath)}"`,
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`,
'Content-Length': String(stats.size)
}
});
@@ -1,26 +1,83 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { restoreBackup } from '$lib/server/services/backupService.js';
import {
beginRestoreWindow,
endRestoreWindow,
restoreBackup
} from '$lib/server/services/backupService.js';
import { clearSessionCookies } from '$lib/server/utils/sessionCookies.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
import { z } from 'zod';
const restoreOptionsSchema = z
.object({
allowSchemaMismatch: z.boolean().optional()
})
.optional();
/**
* POST /api/admin/backups/:filename/restore Restore the database from a backup.
*
* The restore window is opened SYNCHRONOUSLY here, before any body parsing or
* async work, so the hooks.server.ts gate starts returning 503 to concurrent
* requests immediately. The window is closed in a finally block; restoreBackup
* is idempotent w.r.t. that flag.
*
* On success the response sets force_logout: true and clears the admin's
* session cookies, because the restored DB contains a session set from the
* backup-time snapshot and the current admin's session is no longer valid.
*/
export const POST: RequestHandler = async (event) => {
const admin = requireAdmin(event);
const { filename } = event.params;
// CRITICAL: flip the gate BEFORE any awaits so concurrent requests
// don't slip through during body parsing.
try {
await restoreBackup(filename);
beginRestoreWindow();
} catch (err) {
const message = err instanceof Error ? err.message : 'Restore unavailable';
return json(error(message), { status: 409 });
}
logAction(admin.id, AuditAction.BACKUP_RESTORED, 'backup', filename);
try {
let options: { allowSchemaMismatch?: boolean } = {};
try {
const text = await event.request.text();
if (text.trim()) {
const parsed = restoreOptionsSchema.safeParse(JSON.parse(text));
if (parsed.success && parsed.data) options = parsed.data;
}
} catch {
// Body is optional — ignore parse errors and fall back to defaults.
}
return json(success({ restored: true }));
const result = await restoreBackup(filename, options);
logAction(admin.id, AuditAction.BACKUP_RESTORED, 'backup', filename, {
format: result.format,
schemaVersionMatched: result.schemaVersionMatched,
uploadFileCount: result.uploadFileCount,
allowedSchemaMismatch: options.allowSchemaMismatch ?? false
});
// Restored DB contains backup-time sessions; the admin's cookies refer
// to a session that no longer exists.
clearSessionCookies(event.cookies);
return json(success({ ...result, forceLogout: true }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to restore backup';
return json(error(message), { status: 500 });
const status = /schema version mismatch/i.test(message) ? 409 : 500;
logAction(admin.id, AuditAction.BACKUP_FAILED, 'backup', filename, {
phase: 'restore',
error: message
});
return json(error(message), { status });
} finally {
endRestoreWindow();
}
};
@@ -1,7 +1,11 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { getBackupSettings, updateBackupSettings } from '$lib/server/services/backupService.js';
import {
getBackupSchedulerStats,
getBackupSettings,
updateBackupSettings
} from '$lib/server/services/backupService.js';
import { restartBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
@@ -17,7 +21,8 @@ export const GET: RequestHandler = async (event) => {
try {
const settings = await getBackupSettings();
return json(success(settings));
const stats = getBackupSchedulerStats();
return json(success({ ...settings, stats }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to get backup schedule';
return json(error(message), { status: 500 });
+33 -10
View File
@@ -1,25 +1,48 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/server/prisma.js';
import {
isDegraded,
getDegradedReason,
isRestoring
} from '$lib/server/services/backupService.js';
/**
* GET /api/health Docker healthcheck endpoint.
* GET /api/health Docker / Kubernetes healthcheck endpoint.
*
* Pings the database with a trivial query so the container is reported
* unhealthy when Prisma is disconnected (the old hardcoded {status:'ok'}
* masked DB outages from the Docker healthcheck and from any uptime monitor).
* unhealthy when Prisma is disconnected. Also exposes the backup-restore
* degraded state so an orchestrator can recycle a process stuck in a
* partially-rolled-back state.
*
* No auth required this is the probe endpoint, intentionally public.
* Response payload is intentionally minimal to avoid leaking internals.
* Status semantics:
* 200 ok DB reachable, no degraded flag
* 503 restoring restore in progress (transient)
* 503 degraded restore failed + rollback failed; process needs restart
* 503 db_down DB ping failed
*/
export const GET: RequestHandler = async () => {
const version = process.env.APP_VERSION ?? 'dev';
if (isDegraded()) {
return json(
{
status: 'degraded',
reason: getDegradedReason(),
version
},
{ status: 503 }
);
}
if (isRestoring()) {
return json({ status: 'restoring', version }, { status: 503 });
}
try {
await prisma.$queryRaw`SELECT 1`;
return json({
status: 'ok',
version: process.env.APP_VERSION ?? 'dev'
});
return json({ status: 'ok', version });
} catch {
return json({ status: 'degraded', version: process.env.APP_VERSION ?? 'dev' }, { status: 503 });
return json({ status: 'db_down', version }, { status: 503 });
}
};
+27 -31
View File
@@ -4,6 +4,7 @@
import AppCard from '$lib/components/app/AppCard.svelte';
import AppForm from '$lib/components/app/AppForm.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
import Button from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
@@ -33,13 +34,9 @@
{$t('app.apps_registered', { values: { count: data.apps.length } })}
</p>
</div>
<button
type="button"
onclick={() => (showForm = !showForm)}
class="rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
<Button size="lg" onclick={() => (showForm = !showForm)}>
{showForm ? $t('common.cancel') : $t('app.add')}
</button>
</Button>
</div>
{#if showForm}
@@ -70,40 +67,39 @@
{#if data.apps.length === 0}
<div class="flex flex-col items-center rounded-[1.4rem] border-2 border-dashed border-border bg-card/40 px-6 py-16 text-center">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-[1.3rem] bg-primary/10">
<svg
class="h-8 w-8 text-primary"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<svg
class="mb-5 h-24 w-28"
viewBox="0 0 112 96"
fill="none"
aria-hidden="true"
>
<!-- tile grid: 4 rounded squares in room palette -->
<rect x="14" y="14" width="36" height="36" rx="10" style="fill: var(--room-peach); opacity: 0.9;" />
<rect x="58" y="14" width="36" height="36" rx="10" style="fill: var(--room-sky); opacity: 0.9;" />
<rect x="14" y="54" width="36" height="36" rx="10" style="fill: var(--room-butter); opacity: 0.92;" />
<rect x="58" y="54" width="36" height="36" rx="10" style="fill: var(--room-sage); opacity: 0.9;" />
<!-- glints -->
<circle cx="24" cy="24" r="3" fill="white" opacity="0.7" />
<circle cx="68" cy="24" r="3" fill="white" opacity="0.55" />
<circle cx="24" cy="64" r="3" fill="white" opacity="0.6" />
<circle cx="68" cy="64" r="3" fill="white" opacity="0.55" />
</svg>
<p class="text-lg font-medium text-foreground">{$t('app.no_apps')}</p>
<p class="mt-1 max-w-sm text-sm text-muted-foreground">{$t('app.no_apps_hint')}</p>
<button
type="button"
onclick={() => (showForm = true)}
class="mt-4 inline-flex items-center gap-2 rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)]"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<Button size="lg" onclick={() => (showForm = true)} class="mt-4">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{$t('app.add')}
</button>
</Button>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each data.apps as app (app.id)}
<AppCard {app} preloadedHistory={data.appHistories[app.id] ?? null} />
{#each data.apps as app, i (app.id)}
<div class="cozy-rise-stagger" style="--i: {Math.min(i, 12)};">
<AppCard {app} preloadedHistory={data.appHistories[app.id] ?? null} />
</div>
{/each}
</div>
{/if}
+3 -13
View File
@@ -4,6 +4,7 @@
import AppForm from '$lib/components/app/AppForm.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
import { page } from '$app/stores';
import Button, { buttonClass } from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
@@ -35,19 +36,8 @@
{$t('app.quick_add_success')}
</p>
<div class="mt-3 flex gap-3">
<a
href="/apps"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{$t('app.quick_add_view_apps')}
</a>
<button
type="button"
onclick={closeWindow}
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent"
>
{$t('app.quick_add_close')}
</button>
<a href="/apps" class={buttonClass()}>{$t('app.quick_add_view_apps')}</a>
<Button variant="outline" onclick={closeWindow}>{$t('app.quick_add_close')}</Button>
</div>
</div>
{:else}
+3 -10
View File
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { t } from 'svelte-i18n';
import ErrorState from '$lib/components/ui/ErrorState.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
const status = $derived($page.status);
const message = $derived($page.error?.message ?? '');
@@ -25,16 +26,8 @@
<ErrorState {status} {title} {hint}>
{#snippet actions()}
<a
href="/"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('nav.home') ?? 'Home'}
</a>
<a
href="/boards"
class="rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent"
>
<a href="/" class={buttonClass()}>{$t('nav.home') ?? 'Home'}</a>
<a href="/boards" class={buttonClass({ variant: 'outline' })}>
{$t('boards.title') ?? $t('board.title') ?? 'All boards'}
</a>
{/snippet}
+26 -27
View File
@@ -2,6 +2,7 @@
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import BoardCard from '$lib/components/board/BoardCard.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
</script>
@@ -21,10 +22,7 @@
</div>
{#if !data.isGuest && data.user?.role === 'admin'}
<a
href="/boards/new"
class="rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)]"
>
<a href="/boards/new" class={buttonClass({ size: 'lg' })}>
{$t('board.new')}
</a>
{/if}
@@ -32,22 +30,24 @@
{#if data.boards.length === 0}
<div class="flex flex-col items-center rounded-[1.4rem] border-2 border-dashed border-border bg-card/40 px-6 py-16 text-center">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-[1.3rem] bg-primary/10">
<svg
class="h-8 w-8 text-primary"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
</div>
<svg
class="mb-5 h-24 w-28"
viewBox="0 0 112 96"
fill="none"
aria-hidden="true"
>
<!-- back room (sky) -->
<rect x="8" y="14" width="56" height="52" rx="12" style="fill: var(--room-sky); opacity: 0.85;" />
<rect x="16" y="22" width="14" height="14" rx="3" fill="white" opacity="0.65" />
<rect x="34" y="22" width="14" height="14" rx="3" fill="white" opacity="0.45" />
<!-- middle room (peach) -->
<rect x="30" y="32" width="62" height="56" rx="14" style="fill: var(--room-peach); opacity: 0.92;" />
<rect x="40" y="44" width="18" height="18" rx="4" fill="white" opacity="0.7" />
<rect x="62" y="44" width="18" height="18" rx="4" fill="white" opacity="0.55" />
<rect x="40" y="66" width="40" height="6" rx="2" fill="white" opacity="0.45" />
<!-- accent (sage chimney) -->
<rect x="74" y="10" width="14" height="22" rx="4" style="fill: var(--room-sage); opacity: 0.9;" />
</svg>
<p class="text-lg font-medium text-foreground">{$t('board.no_boards')}</p>
{#if data.isGuest}
<p class="mt-1 max-w-sm text-sm text-muted-foreground">{$t('board.sign_in_more')}</p>
@@ -55,11 +55,8 @@
<p class="mt-1 max-w-sm text-sm text-muted-foreground">
Create your first board to organize apps into a custom dashboard.
</p>
<a
href="/boards/new"
class="mt-4 inline-flex items-center gap-2 rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)]"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<a href="/boards/new" class={buttonClass({ size: 'lg', extra: 'mt-4' })}>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@@ -69,8 +66,10 @@
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.boards as board (board.id)}
<BoardCard {board} />
{#each data.boards as board, i (board.id)}
<div class="cozy-rise-stagger" style="--i: {i};">
<BoardCard {board} />
</div>
{/each}
</div>
{/if}
+10 -12
View File
@@ -3,6 +3,8 @@
import type { PageData } from './$types.js';
import { superForm } from 'sveltekit-superforms';
import TemplatePicker from '$lib/components/board/TemplatePicker.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Button, { buttonClass } from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
const { form, errors, enhance, submitting } = superForm(data.form);
@@ -71,29 +73,25 @@
<TemplatePicker onSelect={handleTemplateSelect} />
<input type="hidden" name="templateId" value={selectedTemplateId ?? ''} />
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" name="isDefault" bind:checked={$form.isDefault} class="rounded" />
<div class="flex flex-wrap items-center gap-6">
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch name="isDefault" bind:checked={$form.isDefault} ariaLabel={$t('board.default_board')} />
{$t('board.default_board')}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" name="isGuestAccessible" bind:checked={$form.isGuestAccessible} class="rounded" />
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch name="isGuestAccessible" bind:checked={$form.isGuestAccessible} ariaLabel={$t('board.guest_accessible')} />
{$t('board.guest_accessible')}
</label>
</div>
<div class="flex justify-end gap-3 pt-2">
<a href="/boards" class="rounded-lg border border-border px-4 py-2 text-sm text-foreground hover:bg-accent">
<a href="/boards" class={buttonClass({ variant: 'outline' })}>
{$t('common.cancel')}
</a>
<button
type="submit"
disabled={$submitting}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Button type="submit" disabled={$submitting} loading={$submitting}>
{$submitting ? $t('board.creating') : $t('board.create')}
</button>
</Button>
</div>
</form>
</div>
+3 -6
View File
@@ -3,6 +3,7 @@
import { enhance } from '$app/forms';
import { KeyRound } from 'lucide-svelte';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface ActionResult {
error?: string;
@@ -80,13 +81,9 @@
<p class="mb-3 text-sm text-destructive">{form.error}</p>
{/if}
<button
type="submit"
disabled={submitting}
class="w-full rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<Button type="submit" fullWidth disabled={submitting} loading={submitting}>
{submitting ? '…' : ($t('auth.forgot_password_submit') ?? 'Request reset link')}
</button>
</Button>
</form>
<p class="mt-4 text-center text-xs text-muted-foreground">
+3 -6
View File
@@ -3,6 +3,7 @@
import { enhance } from '$app/forms';
import { TicketCheck } from 'lucide-svelte';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface ActionResult {
error?: string;
@@ -62,13 +63,9 @@
<p class="mb-3 text-sm text-destructive">{form.error}</p>
{/if}
<button
type="submit"
disabled={submitting}
class="mb-2 w-full rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<Button type="submit" fullWidth disabled={submitting} loading={submitting} class="mb-2">
{submitting ? '…' : ($t('auth.invite_continue') ?? 'Continue')}
</button>
</Button>
</form>
<p class="mt-4 text-center text-xs text-muted-foreground">
+9 -18
View File
@@ -3,6 +3,8 @@
import { superForm } from 'sveltekit-superforms';
import type { PageData } from './$types.js';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
@@ -106,12 +108,12 @@
</div>
<div class="flex items-center justify-between gap-4">
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-3 text-sm text-muted-foreground">
<Switch
name="rememberMe"
bind:checked={$form.rememberMe}
class="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-primary/30"
size="sm"
ariaLabel={$t('auth.remember_me')}
/>
<span>{$t('auth.remember_me')}</span>
</label>
@@ -120,20 +122,9 @@
</a>
</div>
<button
type="submit"
disabled={$submitting}
class="w-full rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if $submitting}
<span class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></span>
{$t('auth.login_submitting')}
</span>
{:else}
{$t('auth.login_submit')}
{/if}
</button>
<Button type="submit" size="lg" fullWidth disabled={$submitting} loading={$submitting}>
{$submitting ? $t('auth.login_submitting') : $t('auth.login_submit')}
</Button>
</form>
{/if}
@@ -165,7 +165,7 @@
{:else}
<div class="space-y-3">
{#each channels as channel (channel.id)}
<div class="flex items-center justify-between rounded-lg border border-border bg-card p-4">
<div class="flex items-center justify-between rounded-xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]">
<div class="flex items-center gap-3">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-md bg-muted text-xs font-bold text-muted-foreground">
{channelTypeLabel(channel.type).charAt(0)}
+134 -21
View File
@@ -1,11 +1,80 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
import { goto } from '$app/navigation';
import type { PageData } from './$types.js';
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
type Incident = PageData['incidents'][number];
// Per-browser dismissal of incidents. Stored in localStorage as an array of
// "<appId>|<ISO startedAt>" keys. Non-destructive: nothing is deleted from
// the DB, so uptime % and sparklines are unaffected. The same incident is
// dismissed across the 24h / 7d / 30d range views because the key is
// derived from the immutable (appId, startedAt) pair.
const DISMISSED_KEY = 'web-app-launcher:dismissed-incidents';
let dismissedKeys = $state<Set<string>>(new Set());
function incidentKey(i: Incident): string {
return `${i.appId}|${new Date(i.startedAt).toISOString()}`;
}
onMount(() => {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
if (raw) dismissedKeys = new Set(JSON.parse(raw) as string[]);
} catch {
// Corrupt or unavailable — fall back to an empty set.
}
});
function persist(next: Set<string>): void {
try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next]));
} catch {
// Quota exceeded / disabled localStorage — in-memory state still works for this session.
}
}
function dismissOne(i: Incident): void {
const next = new Set(dismissedKeys);
next.add(incidentKey(i));
dismissedKeys = next;
persist(next);
}
function clearAllVisible(): void {
const next = new Set(dismissedKeys);
for (const i of data.incidents) next.add(incidentKey(i));
dismissedKeys = next;
persist(next);
}
function restoreAllVisible(): void {
const next = new Set(dismissedKeys);
for (const i of data.incidents) next.delete(incidentKey(i));
dismissedKeys = next;
persist(next);
}
const visibleIncidents = $derived(
data.incidents.filter((i) => !dismissedKeys.has(incidentKey(i)))
);
const hiddenCount = $derived(data.incidents.length - visibleIncidents.length);
// Focus recovery: when the user clicks "Clear all" the button unmounts in
// the same tick, dropping focus to <body>. Move focus to the "Restore"
// affordance that replaces it so keyboard navigation isn't lost.
let restoreRef = $state<HTMLButtonElement | null>(null);
async function clearAllAndRefocus(): Promise<void> {
clearAllVisible();
await tick();
restoreRef?.focus();
}
const ranges = [
{ value: '24h', label: '24 Hours' },
{ value: '7d', label: '7 Days' },
@@ -174,28 +243,72 @@
<!-- Incidents Section -->
{#if data.incidents.length > 0}
<div class="mt-8">
<h2 class="mb-4 text-lg font-semibold text-foreground">Recent Incidents</h2>
<div class="space-y-2">
{#each data.incidents as incident (`${incident.appId}-${incident.startedAt}`)}
<div class="rounded-lg border border-border bg-card/50 p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="inline-block h-2 w-2 rounded-full {statusDotColor(incident.status ?? 'offline')}"></span>
<span class="text-sm font-medium text-foreground">{incident.appName ?? 'Unknown'}</span>
</div>
<span class="text-xs text-muted-foreground">
{incident.durationMs ? `${Math.round(incident.durationMs / 60_000)}min` : 'ongoing'}
</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">
{new Date(incident.startedAt).toLocaleString()}
{#if incident.endedAt}
&mdash; {new Date(incident.endedAt).toLocaleString()}
{/if}
</p>
</div>
{/each}
<div class="mb-4 flex items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-foreground">Recent Incidents</h2>
{#if visibleIncidents.length > 0}
<Button
variant="ghost"
size="sm"
onclick={clearAllAndRefocus}
aria-label="Clear all recent incidents"
>
Clear all
</Button>
{/if}
</div>
{#if visibleIncidents.length > 0}
<div class="space-y-2">
{#each visibleIncidents as incident (`${incident.appId}-${incident.startedAt}`)}
<div class="rounded-xl border border-border bg-card/50 p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<span class="inline-block h-2 w-2 shrink-0 rounded-full {statusDotColor(incident.status ?? 'offline')}"></span>
<span class="truncate text-sm font-medium text-foreground">{incident.appName ?? 'Unknown'}</span>
</div>
<div class="flex shrink-0 items-center gap-1">
<span class="text-xs text-muted-foreground">
{incident.durationMs ? `${Math.round(incident.durationMs / 60_000)}min` : 'ongoing'}
</span>
<button
type="button"
onclick={() => dismissOne(incident)}
aria-label="Dismiss incident"
class="rounded-md p-1 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
<p class="mt-1 text-xs text-muted-foreground">
{new Date(incident.startedAt).toLocaleString()}
{#if incident.endedAt}
&mdash; {new Date(incident.endedAt).toLocaleString()}
{/if}
</p>
</div>
{/each}
</div>
{:else}
<div class="rounded-xl border border-border bg-card/50 p-6 text-center">
<p class="text-sm text-muted-foreground">No incidents to show.</p>
</div>
{/if}
{#if hiddenCount > 0}
<p class="mt-3 text-xs text-muted-foreground">
{hiddenCount} hidden &middot;
<button
bind:this={restoreRef}
type="button"
onclick={restoreAllVisible}
class="rounded-sm font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
Restore
</button>
</p>
{/if}
</div>
{/if}
</div>