Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16c667ca15 | |||
| dab13518ef | |||
| f087551454 | |||
| 555ac9ea63 |
@@ -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).
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Generated
+124
-9
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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 символа для поиска",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 +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 +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">
|
||||
|
||||
@@ -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
@@ -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}
|
||||
— {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}
|
||||
— {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 ·
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user