Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d41a39406 | |||
| 6229bf9b74 | |||
| a666bad0c4 | |||
| bede928a3f | |||
| 87cb33cffe |
+10
-27
@@ -1,32 +1,14 @@
|
|||||||
# v0.7.1 (2026-05-07)
|
# v0.7.2 (2026-05-11)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Bot command invocations now appear in the dashboard event stream with `command_handled`, `command_rate_limited`, and `command_failed` rows — closing the last user-initiated path that was invisible to the dashboard ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
- Redesign settings/common with Aurora cassettes — refreshed identity, logging, Telegram, and cache-ledger sections with the new glass/cassette UI ([6229bf9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6229bf9))
|
||||||
- Click any event row to open a detail modal with full provenance (bot → chat → issuer → provider), raw `details` JSON, and per-entity action buttons that deep-link into the relevant list page with the card scrolled into view and pulsing ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
- Group targets by bot in the targets view and redesign backup settings ([a666bad](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a666bad))
|
||||||
- Configurable event auto-refresh dropdown (Off / 10s / 30s / 1m / 5m), persisted in `localStorage`; ticker pauses while the tab is hidden ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
- Add `/status` command handler for webhook providers ([bede928](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bede928))
|
||||||
- Smoother event refresh — no more loading-placeholder flicker on auto-refresh; unchanged rows reuse their DOM nodes and identical pages skip re-rendering entirely ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
|
|
||||||
- Page header breadcrumbs are now translated (new `crumbs.*` i18n namespace covering all 15 call sites), so `Routing · Notification`, `Operators · Bots`, etc. switch with the active language ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
|
|
||||||
- Tracker form's Immich feature-discovery banner now offers an `Open Template Config` shortcut alongside `Open Tracking Config`, and `/template-configs?edit=<id>` auto-opens the editor on landing ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
|
|
||||||
- Event-type filter, dashboard verb labels, and gradients extended for the three new `command_*` types; filled in previously missing i18n keys (`common.hide`, `common.show`, `commandConfig.noCommandsForProvider`) ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
|
||||||
- Telegram issuer info (`from`) captured in both poller and webhook paths and persisted under `details.issuer`, whitelisted to identity fields only by `_normalize_issuer` so `language_code` and any future PII fields are dropped ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
|
||||||
|
|
||||||
## Bug Fixes
|
## Bug Fixes
|
||||||
|
|
||||||
- Cyrillic glyphs in sidebar nav links, section labels, and monospace badges now render in Geist instead of falling back to Segoe UI / Cascadia / Consolas. Switched to `@fontsource-variable/geist` (latin + latin-ext + cyrillic) and added `@fontsource/geist-mono` cyrillic subsets for weights 400/500/600 ([73b046f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/73b046f))
|
- Stop event-log flicker on pagination ([87cb33c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/87cb33c))
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development / Internal
|
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
- `EventLog` gains nullable `command_tracker_id` / `telegram_bot_id` FKs plus deletion-snapshot name columns; idempotent migration ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
|
||||||
- `/api/status` resolves live `CommandTracker` / `TelegramBot` names (mirroring the action pattern) and exposes `tracker_id`, `command_tracker_id`, `telegram_bot_id` so the frontend can deep-link ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- New `test_command_event_logging.py` covers subject formatting, issuer normalization, the three event branches, and graceful failure when the DB is unreachable; full server suite passing 96/96 ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,9 +16,10 @@
|
|||||||
<summary>All Commits</summary>
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
| Hash | Message | Author |
|
||||||
| ---- | ------- | ------ |
|
|------|---------|--------|
|
||||||
| [73b046f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/73b046f) | fix(frontend): cyrillic glyphs for nav and section labels | alexei.dolgolyov |
|
| [6229bf9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6229bf9) | feat(frontend): redesign settings/common with Aurora cassettes | alexei.dolgolyov |
|
||||||
| [b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b) | feat(frontend): smoother event refresh, localized crumbs, template config deep-link | alexei.dolgolyov |
|
| [a666bad](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a666bad) | feat(frontend): group targets by bot, redesign backup settings | alexei.dolgolyov |
|
||||||
| [35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008) | feat: log bot command invocations to the event stream | alexei.dolgolyov |
|
| [bede928](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bede928) | feat(server): add /status command handler for webhook providers | alexei.dolgolyov |
|
||||||
|
| [87cb33c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/87cb33c) | fix(frontend): stop event-log flicker on pagination | alexei.dolgolyov |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.1",
|
"version": "0.7.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
noneLabel = '—',
|
noneLabel = '—',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
size = 'default',
|
size = 'default',
|
||||||
|
open = $bindable(false),
|
||||||
|
showTrigger = true,
|
||||||
onselect,
|
onselect,
|
||||||
|
onclose,
|
||||||
}: {
|
}: {
|
||||||
items: EntityItem[];
|
items: EntityItem[];
|
||||||
value: string | number | null;
|
value: string | number | null;
|
||||||
@@ -29,10 +32,12 @@
|
|||||||
noneLabel?: string;
|
noneLabel?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'sm' | 'default';
|
size?: 'sm' | 'default';
|
||||||
|
open?: boolean;
|
||||||
|
showTrigger?: boolean;
|
||||||
onselect?: (value: string | number | null) => void;
|
onselect?: (value: string | number | null) => void;
|
||||||
|
onclose?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
let highlightIdx = $state(0);
|
let highlightIdx = $state(0);
|
||||||
let inputEl = $state<HTMLInputElement | undefined>();
|
let inputEl = $state<HTMLInputElement | undefined>();
|
||||||
@@ -52,24 +57,37 @@
|
|||||||
return [...result, ...matching];
|
return [...result, ...matching];
|
||||||
});
|
});
|
||||||
|
|
||||||
function openPalette() {
|
// Focus input whenever the palette transitions to open (covers both internal
|
||||||
if (disabled) return;
|
// trigger clicks and external programmatic opening via bind:open).
|
||||||
open = true;
|
let wasOpen = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (open && !wasOpen) {
|
||||||
query = '';
|
query = '';
|
||||||
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
|
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
|
||||||
requestAnimationFrame(() => inputEl?.focus());
|
requestAnimationFrame(() => inputEl?.focus());
|
||||||
}
|
}
|
||||||
|
wasOpen = open;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openPalette() {
|
||||||
|
if (disabled) return;
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when the user dismisses the palette (overlay click or ESC).
|
||||||
|
// Selection uses its own quiet-close path so onclose stays a true "cancel" signal.
|
||||||
function closePalette() {
|
function closePalette() {
|
||||||
open = false;
|
open = false;
|
||||||
query = '';
|
query = '';
|
||||||
|
onclose?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectItem(item: EntityItem) {
|
function selectItem(item: EntityItem) {
|
||||||
if (item.disabled) return;
|
if (item.disabled) return;
|
||||||
value = item.value || null;
|
value = item.value || null;
|
||||||
onselect?.(value);
|
onselect?.(value);
|
||||||
closePalette();
|
open = false;
|
||||||
|
query = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
@@ -106,7 +124,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Trigger button -->
|
<!-- Trigger button (hidden when the parent drives `open` via bind:open) -->
|
||||||
|
{#if showTrigger}
|
||||||
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
|
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
@@ -121,6 +140,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
|
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
|
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
|
||||||
{#if open}
|
{#if open}
|
||||||
|
|||||||
@@ -461,7 +461,13 @@
|
|||||||
"receiverUpdated": "Receiver updated",
|
"receiverUpdated": "Receiver updated",
|
||||||
"confirmDeleteReceiver": "Delete this receiver?",
|
"confirmDeleteReceiver": "Delete this receiver?",
|
||||||
"receiverEnabled": "Receiver enabled",
|
"receiverEnabled": "Receiver enabled",
|
||||||
"receiverDisabled": "Receiver disabled"
|
"receiverDisabled": "Receiver disabled",
|
||||||
|
"groupNoBot": "No bot linked",
|
||||||
|
"groupDirect": "Direct delivery",
|
||||||
|
"groupBotMissing": "Unknown bot",
|
||||||
|
"target": "target",
|
||||||
|
"targetsLower": "targets",
|
||||||
|
"openBot": "Open bot"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"titleEmphasis": "& access",
|
"titleEmphasis": "& access",
|
||||||
@@ -830,7 +836,41 @@
|
|||||||
"logFormatHint": "Output format. 'text' is human-readable; 'json' emits one object per line for log aggregators (Loki, ELK). Changing this requires a server restart.",
|
"logFormatHint": "Output format. 'text' is human-readable; 'json' emits one object per line for log aggregators (Loki, ELK). Changing this requires a server restart.",
|
||||||
"logLevels": "Per-Module Overrides",
|
"logLevels": "Per-Module Overrides",
|
||||||
"logLevelsHint": "Comma-separated 'module=LEVEL' pairs to silence noisy modules or drill into one area. Example: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
"logLevelsHint": "Comma-separated 'module=LEVEL' pairs to silence noisy modules or drill into one area. Example: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
||||||
"saved": "Settings saved"
|
"saved": "Settings saved",
|
||||||
|
"identity": "Identity",
|
||||||
|
"identityHeadline": "How this instance presents itself to bots, webhooks, and recipients",
|
||||||
|
"telegramHeadline": "Webhook authentication and media cache tuning",
|
||||||
|
"loggingHeadline": "Verbosity, output format, and per-module overrides",
|
||||||
|
"heroNoUrl": "External URL not set",
|
||||||
|
"heroNoLocales": "no locales",
|
||||||
|
"copy": "Copy",
|
||||||
|
"urlCopied": "URL copied",
|
||||||
|
"openExternal": "Open",
|
||||||
|
"show": "Show",
|
||||||
|
"hide": "Hide",
|
||||||
|
"secretSet": "Verified",
|
||||||
|
"secretUnset": "Not configured",
|
||||||
|
"cacheConfig": "Cache",
|
||||||
|
"cacheTtlShort": "TTL",
|
||||||
|
"cacheMaxShort": "Max entries",
|
||||||
|
"cacheMaxFootnote": "per bucket (LRU)",
|
||||||
|
"hoursShort": "hrs",
|
||||||
|
"entriesShort": "max",
|
||||||
|
"ttlNoExpiry": "no expiry",
|
||||||
|
"cacheCapacity": "Cache capacity",
|
||||||
|
"cacheCapacityCap": "of {n} cap",
|
||||||
|
"logModulePlaceholder": "module.path",
|
||||||
|
"addOverride": "Add override",
|
||||||
|
"removeOverride": "Remove",
|
||||||
|
"editAsText": "Edit as text",
|
||||||
|
"editAsChips": "Edit as chips",
|
||||||
|
"logPreviewLabel": "ACTIVE",
|
||||||
|
"unsavedChanges": "Unsaved changes",
|
||||||
|
"unsaved": "UNSAVED",
|
||||||
|
"changedOne": "1 setting changed",
|
||||||
|
"changedMany": "{n} settings changed",
|
||||||
|
"discard": "Discard",
|
||||||
|
"saveChanges": "Save changes"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
|
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
|
||||||
@@ -1383,6 +1423,30 @@
|
|||||||
"applyLater": "Apply later",
|
"applyLater": "Apply later",
|
||||||
"restartNow": "Restart now",
|
"restartNow": "Restart now",
|
||||||
"restartingTitle": "Restarting backend…",
|
"restartingTitle": "Restarting backend…",
|
||||||
"restartingDescription": "The page will reload once the server is back online."
|
"restartingDescription": "The page will reload once the server is back online.",
|
||||||
|
"countLabel": "backups",
|
||||||
|
"scheduleOn": "Auto · every {h}h",
|
||||||
|
"scheduleOff": "Auto backup off",
|
||||||
|
"lastBackup": "Last {ago}",
|
||||||
|
"never": "no backups yet",
|
||||||
|
"totalSize": "{size} total",
|
||||||
|
"dropZone": "Drop a JSON backup here, or click to choose",
|
||||||
|
"dropZoneActive": "Release to load",
|
||||||
|
"changeFile": "Change file",
|
||||||
|
"catGroupIdentity": "Identity & Routing",
|
||||||
|
"catGroupNotif": "Notifications",
|
||||||
|
"catGroupCmd": "Commands",
|
||||||
|
"catGroupSystem": "System",
|
||||||
|
"stepCategories": "What to include",
|
||||||
|
"stepSecrets": "Secrets handling",
|
||||||
|
"stepDownload": "Download",
|
||||||
|
"stepFile": "Choose a file",
|
||||||
|
"stepValidate": "Validate contents",
|
||||||
|
"stepConflict": "On conflict",
|
||||||
|
"stepApply": "Apply",
|
||||||
|
"tagScheduled": "scheduled",
|
||||||
|
"tagManual": "manual",
|
||||||
|
"tagSecrets": "with secrets",
|
||||||
|
"validateFirst": "Validate the file first to enable import"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -461,7 +461,13 @@
|
|||||||
"receiverUpdated": "Получатель обновлён",
|
"receiverUpdated": "Получатель обновлён",
|
||||||
"confirmDeleteReceiver": "Удалить этого получателя?",
|
"confirmDeleteReceiver": "Удалить этого получателя?",
|
||||||
"receiverEnabled": "Получатель включён",
|
"receiverEnabled": "Получатель включён",
|
||||||
"receiverDisabled": "Получатель отключён"
|
"receiverDisabled": "Получатель отключён",
|
||||||
|
"groupNoBot": "Без привязки к боту",
|
||||||
|
"groupDirect": "Прямая доставка",
|
||||||
|
"groupBotMissing": "Неизвестный бот",
|
||||||
|
"target": "получатель",
|
||||||
|
"targetsLower": "получателей",
|
||||||
|
"openBot": "Открыть бота"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"titleEmphasis": "и доступ",
|
"titleEmphasis": "и доступ",
|
||||||
@@ -830,7 +836,41 @@
|
|||||||
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
|
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
|
||||||
"logLevels": "Переопределения по модулям",
|
"logLevels": "Переопределения по модулям",
|
||||||
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
||||||
"saved": "Настройки сохранены"
|
"saved": "Настройки сохранены",
|
||||||
|
"identity": "Идентификация",
|
||||||
|
"identityHeadline": "Как этот сервер представляется ботам, вебхукам и получателям",
|
||||||
|
"telegramHeadline": "Аутентификация вебхуков и настройка медиакэша",
|
||||||
|
"loggingHeadline": "Подробность, формат вывода и переопределения по модулям",
|
||||||
|
"heroNoUrl": "Внешний URL не задан",
|
||||||
|
"heroNoLocales": "нет локалей",
|
||||||
|
"copy": "Копировать",
|
||||||
|
"urlCopied": "URL скопирован",
|
||||||
|
"openExternal": "Открыть",
|
||||||
|
"show": "Показать",
|
||||||
|
"hide": "Скрыть",
|
||||||
|
"secretSet": "Задан",
|
||||||
|
"secretUnset": "Не настроен",
|
||||||
|
"cacheConfig": "Кэш",
|
||||||
|
"cacheTtlShort": "TTL",
|
||||||
|
"cacheMaxShort": "Макс. записей",
|
||||||
|
"cacheMaxFootnote": "на корзину (LRU)",
|
||||||
|
"hoursShort": "ч",
|
||||||
|
"entriesShort": "макс",
|
||||||
|
"ttlNoExpiry": "без срока",
|
||||||
|
"cacheCapacity": "Заполненность кэша",
|
||||||
|
"cacheCapacityCap": "из {n}",
|
||||||
|
"logModulePlaceholder": "путь.модуля",
|
||||||
|
"addOverride": "Добавить",
|
||||||
|
"removeOverride": "Удалить",
|
||||||
|
"editAsText": "Редактировать как текст",
|
||||||
|
"editAsChips": "Редактировать как чипы",
|
||||||
|
"logPreviewLabel": "АКТИВНО",
|
||||||
|
"unsavedChanges": "Несохранённые изменения",
|
||||||
|
"unsaved": "НЕ СОХРАНЕНО",
|
||||||
|
"changedOne": "Изменена 1 настройка",
|
||||||
|
"changedMany": "Изменено настроек: {n}",
|
||||||
|
"discard": "Отменить",
|
||||||
|
"saveChanges": "Сохранить"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
|
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
|
||||||
@@ -1383,6 +1423,30 @@
|
|||||||
"applyLater": "Применить позже",
|
"applyLater": "Применить позже",
|
||||||
"restartNow": "Перезапустить сейчас",
|
"restartNow": "Перезапустить сейчас",
|
||||||
"restartingTitle": "Перезапуск бэкенда…",
|
"restartingTitle": "Перезапуск бэкенда…",
|
||||||
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
|
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен.",
|
||||||
|
"countLabel": "бэкапов",
|
||||||
|
"scheduleOn": "Авто · каждые {h}ч",
|
||||||
|
"scheduleOff": "Авто-бэкап выключен",
|
||||||
|
"lastBackup": "Последний {ago}",
|
||||||
|
"never": "ещё нет бэкапов",
|
||||||
|
"totalSize": "всего {size}",
|
||||||
|
"dropZone": "Перетащите JSON-бэкап сюда или нажмите для выбора",
|
||||||
|
"dropZoneActive": "Отпустите для загрузки",
|
||||||
|
"changeFile": "Сменить файл",
|
||||||
|
"catGroupIdentity": "Идентичность и маршрутизация",
|
||||||
|
"catGroupNotif": "Уведомления",
|
||||||
|
"catGroupCmd": "Команды",
|
||||||
|
"catGroupSystem": "Система",
|
||||||
|
"stepCategories": "Что включить",
|
||||||
|
"stepSecrets": "Обработка секретов",
|
||||||
|
"stepDownload": "Скачать",
|
||||||
|
"stepFile": "Выберите файл",
|
||||||
|
"stepValidate": "Проверить содержимое",
|
||||||
|
"stepConflict": "При конфликте",
|
||||||
|
"stepApply": "Применить",
|
||||||
|
"tagScheduled": "по расписанию",
|
||||||
|
"tagManual": "вручную",
|
||||||
|
"tagSecrets": "с секретами",
|
||||||
|
"validateFirst": "Сначала проверьте файл, чтобы включить импорт"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,6 +96,10 @@
|
|||||||
let confirmClearEvents = $state(false);
|
let confirmClearEvents = $state(false);
|
||||||
let refreshSeconds = $state(loadRefreshSeconds());
|
let refreshSeconds = $state(loadRefreshSeconds());
|
||||||
let selectedEvent = $state<EventLog | null>(null);
|
let selectedEvent = $state<EventLog | null>(null);
|
||||||
|
// Stagger entry animation should play once on initial load only —
|
||||||
|
// without this, every pagination/filter change re-runs the cascade
|
||||||
|
// (~600ms of fade-up per row) which reads as the panel "reconstructing".
|
||||||
|
let eventsAnimated = $state(false);
|
||||||
|
|
||||||
// Auto-refresh ticker — re-creates the interval whenever the user
|
// Auto-refresh ticker — re-creates the interval whenever the user
|
||||||
// changes the cadence. ``$effect`` returns a cleanup that fires on
|
// changes the cadence. ``$effect`` returns a cleanup that fires on
|
||||||
@@ -279,6 +283,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable stagger entry animation once the first non-empty list has
|
||||||
|
// rendered + had time to play. Subsequent pagination/filter reloads
|
||||||
|
// then settle in place instead of re-running the cascade.
|
||||||
|
$effect(() => {
|
||||||
|
if (eventsAnimated) return;
|
||||||
|
if (!status?.recent_events?.length) return;
|
||||||
|
const handle = setTimeout(() => { eventsAnimated = true; }, 700);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
});
|
||||||
|
|
||||||
const filteredProviderCount = $derived(globalProviderFilter.providerType
|
const filteredProviderCount = $derived(globalProviderFilter.providerType
|
||||||
? providers.filter(p => p.type === globalProviderFilter.providerType).length
|
? providers.filter(p => p.type === globalProviderFilter.providerType).length
|
||||||
: displayProviders);
|
: displayProviders);
|
||||||
@@ -675,18 +689,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#if status.recent_events.length === 0}
|
||||||
{#if eventsLoading}
|
{#if eventsLoading}
|
||||||
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
|
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
|
||||||
{:else if status.recent_events.length === 0}
|
{:else}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<MdiIcon name="mdiCalendarBlank" size={36} />
|
<MdiIcon name="mdiCalendarBlank" size={36} />
|
||||||
<p>{t('dashboard.noEvents')}</p>
|
<p>{t('dashboard.noEvents')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="signal-list stagger-children">
|
<div class="signal-list"
|
||||||
|
class:stagger-children={!eventsAnimated}
|
||||||
|
class:signal-list--reloading={eventsLoading}
|
||||||
|
aria-busy={eventsLoading}>
|
||||||
{#each status.recent_events as event, i (event.id)}
|
{#each status.recent_events as event, i (event.id)}
|
||||||
<button type="button" class="signal-row signal-row--clickable"
|
<button type="button" class="signal-row signal-row--clickable"
|
||||||
style="animation-delay: {i * 60}ms;"
|
style={eventsAnimated ? '' : `animation-delay: ${i * 60}ms;`}
|
||||||
onclick={() => selectedEvent = event}
|
onclick={() => selectedEvent = event}
|
||||||
aria-label={t('events.detailTitle')}>
|
aria-label={t('events.detailTitle')}>
|
||||||
<div class="signal-avatar"
|
<div class="signal-avatar"
|
||||||
@@ -1232,6 +1251,11 @@
|
|||||||
SIGNAL STREAM — events with routing trail
|
SIGNAL STREAM — events with routing trail
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.signal-list { position: relative; z-index: 1; padding-bottom: 0.25rem; }
|
.signal-list { position: relative; z-index: 1; padding-bottom: 0.25rem; }
|
||||||
|
/* Soft dim while a page change / filter reload is in flight. We keep
|
||||||
|
the previous rows mounted (avoids the layout collapsing to a tiny
|
||||||
|
"Loading…" placeholder) and just nudge opacity so the swap feels
|
||||||
|
like a refresh rather than a teardown. */
|
||||||
|
.signal-list--reloading { opacity: 0.55; pointer-events: none; transition: opacity 0.15s ease; }
|
||||||
.signal-row {
|
.signal-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px 1fr auto;
|
grid-template-columns: 40px 1fr auto;
|
||||||
|
|||||||
@@ -2,21 +2,19 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
||||||
import Card from '$lib/components/Card.svelte';
|
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
||||||
import Hint from '$lib/components/Hint.svelte';
|
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
|
|
||||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
|
||||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
|
||||||
import { logLevelItems, logFormatItems } from '$lib/grid-items';
|
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import { externalUrlCache } from '$lib/stores/caches.svelte';
|
import { externalUrlCache } from '$lib/stores/caches.svelte';
|
||||||
|
|
||||||
|
import SettingsHero from './SettingsHero.svelte';
|
||||||
|
import IdentityCassette from './IdentityCassette.svelte';
|
||||||
|
import TelegramCassette from './TelegramCassette.svelte';
|
||||||
|
import CacheLedger from './CacheLedger.svelte';
|
||||||
|
import LoggingCassette from './LoggingCassette.svelte';
|
||||||
|
import SaveBar from './SaveBar.svelte';
|
||||||
|
|
||||||
interface CacheBucketStats {
|
interface CacheBucketStats {
|
||||||
count: number;
|
count: number;
|
||||||
total_size_bytes: number;
|
total_size_bytes: number;
|
||||||
@@ -28,12 +26,19 @@
|
|||||||
asset: CacheBucketStats;
|
asset: CacheBucketStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
let loaded = $state(false);
|
interface Settings {
|
||||||
let saving = $state(false);
|
external_url: string;
|
||||||
let clearingCache = $state(false);
|
telegram_webhook_secret: string;
|
||||||
let confirmClearCache = $state(false);
|
telegram_cache_ttl_hours: string;
|
||||||
let error = $state('');
|
telegram_asset_cache_max_entries: string;
|
||||||
let settings = $state({
|
supported_locales: string;
|
||||||
|
timezone: string;
|
||||||
|
log_level: string;
|
||||||
|
log_format: string;
|
||||||
|
log_levels: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: Settings = {
|
||||||
external_url: '',
|
external_url: '',
|
||||||
telegram_webhook_secret: '',
|
telegram_webhook_secret: '',
|
||||||
telegram_cache_ttl_hours: '720',
|
telegram_cache_ttl_hours: '720',
|
||||||
@@ -43,10 +48,33 @@
|
|||||||
log_level: 'INFO',
|
log_level: 'INFO',
|
||||||
log_format: 'text',
|
log_format: 'text',
|
||||||
log_levels: '',
|
log_levels: '',
|
||||||
});
|
};
|
||||||
|
|
||||||
|
let loaded = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let clearingCache = $state(false);
|
||||||
|
let confirmClearCache = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
let settings = $state<Settings>({ ...EMPTY });
|
||||||
|
// Snapshot of the last server-known state, used for dirty tracking.
|
||||||
|
let baseline = $state<Settings>({ ...EMPTY });
|
||||||
let cacheStats = $state<CacheStats | null>(null);
|
let cacheStats = $state<CacheStats | null>(null);
|
||||||
|
|
||||||
async function loadCacheStats() {
|
// --- Dirty tracking -----------------------------------------------------
|
||||||
|
|
||||||
|
const dirtyKeys = $derived.by<Array<keyof Settings>>(() => {
|
||||||
|
const out: Array<keyof Settings> = [];
|
||||||
|
for (const key of Object.keys(settings) as Array<keyof Settings>) {
|
||||||
|
if (settings[key] !== baseline[key]) out.push(key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
const dirty = $derived(dirtyKeys.length > 0);
|
||||||
|
|
||||||
|
// --- Data loading -------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadCacheStats(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
|
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
|
||||||
} catch { cacheStats = null; }
|
} catch { cacheStats = null; }
|
||||||
@@ -54,202 +82,135 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
settings = await api('/settings');
|
const fetched = await api<Settings>('/settings');
|
||||||
|
settings = { ...EMPTY, ...fetched };
|
||||||
|
baseline = { ...settings };
|
||||||
await loadCacheStats();
|
await loadCacheStats();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) {
|
||||||
finally { loaded = true; }
|
const msg = err instanceof Error ? err.message : 'Failed to load settings';
|
||||||
|
error = msg;
|
||||||
|
snackError(msg);
|
||||||
|
} finally {
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
// --- Actions ------------------------------------------------------------
|
||||||
if (!bytes) return '0 B';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
let i = 0;
|
|
||||||
let v = bytes;
|
|
||||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
||||||
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTs(iso: string | null): string {
|
async function save(): Promise<void> {
|
||||||
if (!iso) return '—';
|
saving = true;
|
||||||
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
error = '';
|
||||||
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
saving = true; error = '';
|
|
||||||
try {
|
try {
|
||||||
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
|
const next = await api<Settings>('/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
settings = { ...EMPTY, ...next };
|
||||||
|
baseline = { ...settings };
|
||||||
externalUrlCache.invalidate();
|
externalUrlCache.invalidate();
|
||||||
snackSuccess(t('settings.saved'));
|
snackSuccess(t('settings.saved'));
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Save failed';
|
||||||
|
error = msg;
|
||||||
|
snackError(msg);
|
||||||
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function clearTelegramCache() {
|
function discard(): void {
|
||||||
|
settings = { ...baseline };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearTelegramCache(): Promise<void> {
|
||||||
confirmClearCache = false;
|
confirmClearCache = false;
|
||||||
clearingCache = true;
|
clearingCache = true;
|
||||||
try {
|
try {
|
||||||
await api('/settings/telegram-cache/clear', { method: 'POST' });
|
await api('/settings/telegram-cache/clear', { method: 'POST' });
|
||||||
snackSuccess(t('settings.clearCacheDone'));
|
snackSuccess(t('settings.clearCacheDone'));
|
||||||
await loadCacheStats();
|
await loadCacheStats();
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Clear cache failed';
|
||||||
|
snackError(msg);
|
||||||
|
} finally {
|
||||||
clearingCache = false;
|
clearingCache = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheMaxEntriesNum = $derived(
|
||||||
|
Math.max(0, Number(settings.telegram_asset_cache_max_entries || '0')),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader
|
<SettingsHero {settings} />
|
||||||
title={t('settings.title')}
|
|
||||||
emphasis={t('settings.titleEmphasis')}
|
|
||||||
description={t('settings.description')}
|
|
||||||
crumb={t('crumbs.systemConfiguration')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if !loaded}
|
{#if !loaded}
|
||||||
<Loading />
|
<Loading />
|
||||||
{:else}
|
{:else}
|
||||||
<ErrorBanner message={error} />
|
<ErrorBanner message={error} />
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- General section -->
|
|
||||||
<Card>
|
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<MdiIcon name="mdiCog" size={18} />
|
|
||||||
{t('settings.general')}
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.externalUrl')}<Hint text={t('settings.externalUrlHint')} /></label>
|
|
||||||
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
|
|
||||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
|
||||||
<TimezoneSelector bind:value={settings.timezone} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Telegram section -->
|
<div class="settings-page stagger-children">
|
||||||
<Card>
|
<IdentityCassette
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
bind:externalUrl={settings.external_url}
|
||||||
<MdiIcon name="mdiSend" size={18} />
|
bind:timezone={settings.timezone}
|
||||||
{t('settings.telegram')}
|
bind:supportedLocales={settings.supported_locales}
|
||||||
</h3>
|
/>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
|
||||||
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
|
||||||
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
|
|
||||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
|
|
||||||
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
|
|
||||||
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
|
|
||||||
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
|
|
||||||
{#each [
|
|
||||||
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
|
|
||||||
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
|
|
||||||
] as bucket}
|
|
||||||
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
|
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
|
||||||
<span class="font-medium">{bucket.label}</span>
|
|
||||||
{#if bucket.data && bucket.data.count > 0}
|
|
||||||
<span>
|
|
||||||
<span class="font-mono">{bucket.data.count}</span>
|
|
||||||
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
|
|
||||||
{#if bucket.data.total_size_bytes > 0}
|
|
||||||
<span style="color: var(--color-muted-foreground);"> · </span>
|
|
||||||
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
|
|
||||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
|
|
||||||
{#if bucket.data.oldest}
|
|
||||||
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
|
|
||||||
{/if}
|
|
||||||
{#if bucket.data.newest}
|
|
||||||
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
|
||||||
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
|
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
|
|
||||||
<MdiIcon name="mdiDeleteSweep" size={16} />
|
|
||||||
{clearingCache ? t('common.loading') : t('settings.clearCache')}
|
|
||||||
</button>
|
|
||||||
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Locales section -->
|
<div class="telegram-deck">
|
||||||
<Card>
|
<TelegramCassette
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
bind:webhookSecret={settings.telegram_webhook_secret}
|
||||||
<MdiIcon name="mdiTranslate" size={18} />
|
bind:cacheTtlHours={settings.telegram_cache_ttl_hours}
|
||||||
{t('settings.locales')}
|
bind:cacheMaxEntries={settings.telegram_asset_cache_max_entries}
|
||||||
</h3>
|
/>
|
||||||
<div class="space-y-3">
|
<CacheLedger
|
||||||
<div>
|
stats={cacheStats}
|
||||||
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
clearing={clearingCache}
|
||||||
<LocaleSelector bind:value={settings.supported_locales} />
|
maxEntries={cacheMaxEntriesNum}
|
||||||
</div>
|
onRefresh={loadCacheStats}
|
||||||
</div>
|
onClear={() => (confirmClearCache = true)}
|
||||||
</Card>
|
/>
|
||||||
|
|
||||||
<!-- Logging section -->
|
|
||||||
<Card>
|
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<MdiIcon name="mdiTextBoxOutline" size={18} />
|
|
||||||
{t('settings.logging')}
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.logLevel')}<Hint text={t('settings.logLevelHint')} /></label>
|
|
||||||
<IconGridSelect items={logLevelItems()} bind:value={settings.log_level} columns={2} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.logFormat')}<Hint text={t('settings.logFormatHint')} /></label>
|
|
||||||
<IconGridSelect items={logFormatItems()} bind:value={settings.log_format} columns={2} />
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-2">
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.logLevels')}<Hint text={t('settings.logLevelsHint')} /></label>
|
|
||||||
<input bind:value={settings.log_levels}
|
|
||||||
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button onclick={save} disabled={saving}>
|
|
||||||
{saving ? t('common.loading') : t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmModal open={confirmClearCache}
|
<LoggingCassette
|
||||||
|
bind:logLevel={settings.log_level}
|
||||||
|
bind:logFormat={settings.log_format}
|
||||||
|
bind:logLevels={settings.log_levels}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SaveBar
|
||||||
|
{dirty}
|
||||||
|
{saving}
|
||||||
|
changedCount={dirtyKeys.length}
|
||||||
|
onSave={save}
|
||||||
|
onDiscard={discard}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmClearCache}
|
||||||
title={t('settings.clearCacheConfirmTitle')}
|
title={t('settings.clearCacheConfirmTitle')}
|
||||||
message={t('settings.clearCacheConfirm')}
|
message={t('settings.clearCacheConfirm')}
|
||||||
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
||||||
confirmIcon="mdiDeleteSweep"
|
confirmIcon="mdiDeleteSweep"
|
||||||
onconfirm={clearTelegramCache}
|
onconfirm={clearTelegramCache}
|
||||||
oncancel={() => confirmClearCache = false} />
|
oncancel={() => (confirmClearCache = false)}
|
||||||
{/if}
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.settings-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-deck {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.telegram-deck { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
|
||||||
|
|
||||||
|
interface CacheBucketStats {
|
||||||
|
count: number;
|
||||||
|
total_size_bytes: number;
|
||||||
|
oldest: string | null;
|
||||||
|
newest: string | null;
|
||||||
|
}
|
||||||
|
interface CacheStats {
|
||||||
|
url: CacheBucketStats;
|
||||||
|
asset: CacheBucketStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stats: CacheStats | null;
|
||||||
|
clearing: boolean;
|
||||||
|
maxEntries: number;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { stats, clearing, maxEntries, onRefresh, onClear }: Props = $props();
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let i = 0;
|
||||||
|
let v = bytes;
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||||
|
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(iso: string | null): Date | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso: string | null): string {
|
||||||
|
const date = parseDate(iso);
|
||||||
|
if (!date) return '';
|
||||||
|
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||||
|
if (diffSec < 60) return t('dashboard.justNow');
|
||||||
|
const min = Math.floor(diffSec / 60);
|
||||||
|
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||||
|
const day = Math.floor(hr / 24);
|
||||||
|
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageTone(iso: string | null): Tone {
|
||||||
|
const date = parseDate(iso);
|
||||||
|
if (!date) return 'mint';
|
||||||
|
const hours = (Date.now() - date.getTime()) / 3_600_000;
|
||||||
|
if (hours < 48) return 'mint';
|
||||||
|
if (hours < 24 * 7) return 'sky';
|
||||||
|
if (hours < 24 * 30) return 'citrus';
|
||||||
|
return 'coral';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BucketRow {
|
||||||
|
key: 'url' | 'asset';
|
||||||
|
labelKey: string;
|
||||||
|
icon: string;
|
||||||
|
data: CacheBucketStats | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = $derived<BucketRow[]>([
|
||||||
|
{ key: 'url', labelKey: 'settings.cacheStatsUrl', icon: 'mdiLinkVariant', data: stats?.url ?? null },
|
||||||
|
{ key: 'asset', labelKey: 'settings.cacheStatsAsset', icon: 'mdiImageMultipleOutline', data: stats?.asset ?? null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCount = $derived(
|
||||||
|
(stats?.url.count ?? 0) + (stats?.asset.count ?? 0),
|
||||||
|
);
|
||||||
|
const totalBytes = $derived(
|
||||||
|
(stats?.url.total_size_bytes ?? 0) + (stats?.asset.total_size_bytes ?? 0),
|
||||||
|
);
|
||||||
|
const fillPct = $derived.by(() => {
|
||||||
|
const max = Math.max(1, maxEntries);
|
||||||
|
const each = totalCount / 2; // two buckets share the cap conceptually; use whichever is fuller
|
||||||
|
const top = Math.max(stats?.url.count ?? 0, stats?.asset.count ?? 0);
|
||||||
|
void each; // explicit ack we considered both
|
||||||
|
return Math.min(100, Math.round((top / max) * 100));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="ledger glass">
|
||||||
|
<header class="ledger-head">
|
||||||
|
<div class="ledger-summary">
|
||||||
|
<div class="ledger-eyebrow">
|
||||||
|
<MdiIcon name="mdiDatabaseClockOutline" size={12} />
|
||||||
|
<span>{t('settings.cacheStats')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-numbers">
|
||||||
|
<span class="ledger-count font-mono">{totalCount.toLocaleString()}</span>
|
||||||
|
<span class="ledger-count-label">{t('settings.cacheStatsEntries')}</span>
|
||||||
|
{#if totalBytes > 0}
|
||||||
|
<span class="ledger-sep">·</span>
|
||||||
|
<span class="ledger-bytes font-mono">{formatBytes(totalBytes)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn"
|
||||||
|
onclick={onRefresh}
|
||||||
|
aria-label={t('common.refresh', 'Refresh')}
|
||||||
|
title={t('common.refresh', 'Refresh')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiRefresh" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Capacity meter (peak bucket vs configured cap) -->
|
||||||
|
{#if maxEntries > 0}
|
||||||
|
<div class="meter" aria-label={t('settings.cacheCapacity')}>
|
||||||
|
<div class="meter-track">
|
||||||
|
<div class="meter-fill" style="width: {fillPct}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="meter-text font-mono">
|
||||||
|
{fillPct}% · {t('settings.cacheCapacityCap').replace('{n}', maxEntries.toLocaleString())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Bucket rows -->
|
||||||
|
<ol class="ledger-list">
|
||||||
|
{#each buckets as bucket (bucket.key)}
|
||||||
|
{@const data = bucket.data}
|
||||||
|
{@const empty = !data || data.count === 0}
|
||||||
|
{@const tone = empty ? 'mint' : ageTone(data?.oldest ?? null)}
|
||||||
|
<li class="row" data-tone={tone} class:row-empty={empty}>
|
||||||
|
<span class="row-edge" aria-hidden="true"></span>
|
||||||
|
<span class="row-icon" aria-hidden="true">
|
||||||
|
<MdiIcon name={bucket.icon} size={16} />
|
||||||
|
</span>
|
||||||
|
<div class="row-text">
|
||||||
|
<span class="row-name">{t(bucket.labelKey)}</span>
|
||||||
|
{#if empty}
|
||||||
|
<span class="row-meta">{t('settings.cacheStatsEmpty')}</span>
|
||||||
|
{:else if data}
|
||||||
|
<span class="row-meta">
|
||||||
|
<span>
|
||||||
|
<span class="font-mono">{data.count.toLocaleString()}</span>
|
||||||
|
{t('settings.cacheStatsEntries')}
|
||||||
|
</span>
|
||||||
|
{#if data.total_size_bytes > 0}
|
||||||
|
<span class="row-sep">·</span>
|
||||||
|
<span class="font-mono">{formatBytes(data.total_size_bytes)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if data.oldest}
|
||||||
|
<span class="row-sep">·</span>
|
||||||
|
<span>{t('settings.cacheStatsOldest')} {relativeTime(data.oldest)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="row-dot" aria-hidden="true"></span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<footer class="ledger-foot">
|
||||||
|
<Button size="sm" variant="secondary" onclick={onClear} disabled={clearing || totalCount === 0}>
|
||||||
|
{#if clearing}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiDeleteSweep" size={14} />
|
||||||
|
{/if}
|
||||||
|
{clearing ? t('common.loading') : t('settings.clearCache')}
|
||||||
|
</Button>
|
||||||
|
<span class="foot-hint">{t('settings.clearCacheHint')}</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ledger {
|
||||||
|
padding: 1.4rem 1.5rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.ledger-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ledger-summary { min-width: 0; }
|
||||||
|
.ledger-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.ledger-numbers {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ledger-count {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ledger-count-label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
|
||||||
|
.ledger-bytes {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ledger-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.icon-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Capacity meter --- */
|
||||||
|
.meter {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
.meter-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.meter-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--color-mint), var(--color-sky));
|
||||||
|
border-radius: inherit;
|
||||||
|
transition: width 0.4s cubic-bezier(.2,.7,.2,1);
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 40%, transparent);
|
||||||
|
}
|
||||||
|
.meter-text {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bucket rows --- */
|
||||||
|
.ledger-list {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.7rem 0.95rem 0.7rem 1.1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
transition: transform 0.18s, border-color 0.18s, background 0.18s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.row:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
background: var(--color-glass-elev);
|
||||||
|
}
|
||||||
|
.row.row-empty { opacity: 0.78; }
|
||||||
|
|
||||||
|
.row-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
|
||||||
|
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
|
||||||
|
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
|
||||||
|
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
|
||||||
|
|
||||||
|
.row-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--color-glass);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.row[data-tone="mint"] .row-icon { color: var(--color-mint); }
|
||||||
|
.row[data-tone="sky"] .row-icon { color: var(--color-sky); }
|
||||||
|
.row[data-tone="citrus"] .row-icon { color: var(--color-citrus); }
|
||||||
|
.row[data-tone="coral"] .row-icon { color: var(--color-coral); }
|
||||||
|
|
||||||
|
.row-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.row-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.row-meta {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.row-sep { opacity: 0.45; }
|
||||||
|
|
||||||
|
.row-dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
|
||||||
|
.row[data-tone="sky"] .row-dot { background: var(--color-sky); box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 50%, transparent); }
|
||||||
|
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); box-shadow: 0 0 8px color-mix(in srgb, var(--color-citrus) 50%, transparent); }
|
||||||
|
.row[data-tone="coral"] .row-dot { background: var(--color-coral); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral) 50%, transparent); }
|
||||||
|
|
||||||
|
/* --- Footer --- */
|
||||||
|
.ledger-foot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.foot-hint {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.row, .meter-fill { transition: none !important; }
|
||||||
|
.row:hover { transform: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
|
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
|
||||||
|
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||||
|
import { snackSuccess } from '$lib/stores/snackbar.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
externalUrl: string;
|
||||||
|
timezone: string;
|
||||||
|
supportedLocales: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
externalUrl = $bindable(),
|
||||||
|
timezone = $bindable(),
|
||||||
|
supportedLocales = $bindable(),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function copyUrl(): void {
|
||||||
|
if (!externalUrl) return;
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(externalUrl);
|
||||||
|
copied = true;
|
||||||
|
snackSuccess(t('settings.urlCopied'));
|
||||||
|
if (copyTimer) clearTimeout(copyTimer);
|
||||||
|
copyTimer = setTimeout(() => { copied = false; }, 1600);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReachable(url: string): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
try { new URL(url); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlValid = $derived(isReachable(externalUrl));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="identity glass">
|
||||||
|
<header class="identity-head">
|
||||||
|
<div class="identity-eyebrow">
|
||||||
|
<MdiIcon name="mdiAccountNetworkOutline" size={12} />
|
||||||
|
<span>{t('settings.identity')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="identity-title">{t('settings.identityHeadline')}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="identity-body">
|
||||||
|
<!-- External URL row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="row-label">
|
||||||
|
<span class="row-num">01</span>
|
||||||
|
<label for="settings-external-url" class="row-name">
|
||||||
|
{t('settings.externalUrl')}
|
||||||
|
<Hint text={t('settings.externalUrlHint')} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row-control">
|
||||||
|
<div class="url-field" class:url-field-valid={urlValid && !!externalUrl}>
|
||||||
|
<span class="url-leading" aria-hidden="true">
|
||||||
|
<MdiIcon name={urlValid ? 'mdiEarth' : 'mdiEarthOff'} size={14} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id="settings-external-url"
|
||||||
|
bind:value={externalUrl}
|
||||||
|
placeholder="https://notify.example.com"
|
||||||
|
class="url-input"
|
||||||
|
type="url"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
{#if externalUrl}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="url-action"
|
||||||
|
onclick={copyUrl}
|
||||||
|
aria-label={t('settings.copy')}
|
||||||
|
title={t('settings.copy')}
|
||||||
|
>
|
||||||
|
<MdiIcon name={copied ? 'mdiCheck' : 'mdiContentCopy'} size={13} />
|
||||||
|
</button>
|
||||||
|
{#if urlValid}
|
||||||
|
<a
|
||||||
|
href={externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="url-action"
|
||||||
|
aria-label={t('settings.openExternal')}
|
||||||
|
title={t('settings.openExternal')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiOpenInNew" size={13} />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timezone row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="row-label">
|
||||||
|
<span class="row-num">02</span>
|
||||||
|
<span class="row-name">
|
||||||
|
{t('settings.timezone')}
|
||||||
|
<Hint text={t('settings.timezoneHint')} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-control">
|
||||||
|
<TimezoneSelector bind:value={timezone} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Locales row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="row-label">
|
||||||
|
<span class="row-num">03</span>
|
||||||
|
<span class="row-name">
|
||||||
|
{t('settings.supportedLocales')}
|
||||||
|
<Hint text={t('settings.supportedLocalesHint')} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-control">
|
||||||
|
<LocaleSelector bind:value={supportedLocales} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.identity {
|
||||||
|
padding: 1.5rem 1.6rem 1.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.identity-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.identity-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
max-width: 42ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 11rem 1fr;
|
||||||
|
gap: 1.4rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.row:first-child { border-top: 0; padding-top: 0.4rem; }
|
||||||
|
.row:last-child { padding-bottom: 0.1rem; }
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.row-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.row-name {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.row-control {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- URL field with leading icon and trailing actions --- */
|
||||||
|
.url-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-width: 34rem;
|
||||||
|
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
|
||||||
|
}
|
||||||
|
.url-field:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-glow);
|
||||||
|
}
|
||||||
|
.url-field-valid {
|
||||||
|
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-rule-strong));
|
||||||
|
}
|
||||||
|
.url-leading {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.url-field-valid .url-leading { color: var(--color-mint); }
|
||||||
|
.url-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
padding: 0.5rem 0.4rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.url-input::placeholder { color: var(--color-muted-foreground); }
|
||||||
|
.url-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.url-action:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.95rem 0;
|
||||||
|
}
|
||||||
|
.row-label { padding-top: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.url-field, .url-action { transition: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||||
|
import { logLevelItems, logFormatItems } from '$lib/grid-items';
|
||||||
|
|
||||||
|
type Level = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR';
|
||||||
|
|
||||||
|
interface Override {
|
||||||
|
module: string;
|
||||||
|
level: Level;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logLevel: string;
|
||||||
|
logFormat: string;
|
||||||
|
logLevels: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
logLevel = $bindable(),
|
||||||
|
logFormat = $bindable(),
|
||||||
|
logLevels = $bindable(),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const LEVELS: Level[] = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
|
||||||
|
const LEVEL_TONE: Record<Level, string> = {
|
||||||
|
DEBUG: 'sky',
|
||||||
|
INFO: 'mint',
|
||||||
|
WARNING: 'citrus',
|
||||||
|
ERROR: 'coral',
|
||||||
|
};
|
||||||
|
|
||||||
|
let rawMode = $state(false);
|
||||||
|
|
||||||
|
// Parse the comma-separated `module=LEVEL,...` string into structured rows.
|
||||||
|
function parse(csv: string): Override[] {
|
||||||
|
if (!csv) return [];
|
||||||
|
const out: Override[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const raw of csv.split(',')) {
|
||||||
|
const piece = raw.trim();
|
||||||
|
if (!piece) continue;
|
||||||
|
const eq = piece.indexOf('=');
|
||||||
|
if (eq < 0) continue;
|
||||||
|
const module = piece.slice(0, eq).trim();
|
||||||
|
const lvlRaw = piece.slice(eq + 1).trim().toUpperCase();
|
||||||
|
if (!module || seen.has(module)) continue;
|
||||||
|
const level = (LEVELS.includes(lvlRaw as Level) ? lvlRaw : 'INFO') as Level;
|
||||||
|
seen.add(module);
|
||||||
|
out.push({ module, level });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(rows: Override[]): string {
|
||||||
|
return rows
|
||||||
|
.filter(r => r.module.trim().length > 0)
|
||||||
|
.map(r => `${r.module.trim()}=${r.level}`)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = $state<Override[]>(parse(logLevels));
|
||||||
|
let lastEmitted = $state(logLevels);
|
||||||
|
|
||||||
|
// Re-parse when the upstream string changes from outside (e.g. fetch / reset).
|
||||||
|
$effect(() => {
|
||||||
|
if (logLevels !== lastEmitted) {
|
||||||
|
rows = parse(logLevels);
|
||||||
|
lastEmitted = logLevels;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function commit(next: Override[]): void {
|
||||||
|
rows = next;
|
||||||
|
const serialized = serialize(next);
|
||||||
|
lastEmitted = serialized;
|
||||||
|
logLevels = serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow(): void {
|
||||||
|
commit([...rows, { module: '', level: 'INFO' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(i: number): void {
|
||||||
|
commit(rows.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModule(i: number, value: string): void {
|
||||||
|
const next = rows.map((r, idx) => (idx === i ? { ...r, module: value } : r));
|
||||||
|
commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLevel(i: number, level: Level): void {
|
||||||
|
const next = rows.map((r, idx) => (idx === i ? { ...r, level } : r));
|
||||||
|
commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewLine = $derived.by(() => {
|
||||||
|
const root = (logLevel || 'INFO').toUpperCase();
|
||||||
|
if (rows.length === 0) return `root=${root}`;
|
||||||
|
return `root=${root}, ${rows.map(r => `${r.module || '?'}=${r.level}`).join(', ')}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="logging glass">
|
||||||
|
<header class="log-head">
|
||||||
|
<div class="log-eyebrow">
|
||||||
|
<MdiIcon name="mdiTextBoxOutline" size={12} />
|
||||||
|
<span>{t('settings.logging')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="log-title">{t('settings.loggingHeadline')}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Level + format -->
|
||||||
|
<div class="log-row">
|
||||||
|
<div class="log-cell">
|
||||||
|
<span class="log-label">
|
||||||
|
{t('settings.logLevel')}
|
||||||
|
<Hint text={t('settings.logLevelHint')} />
|
||||||
|
</span>
|
||||||
|
<IconGridSelect items={logLevelItems()} bind:value={logLevel} columns={2} />
|
||||||
|
</div>
|
||||||
|
<div class="log-cell">
|
||||||
|
<span class="log-label">
|
||||||
|
{t('settings.logFormat')}
|
||||||
|
<Hint text={t('settings.logFormatHint')} />
|
||||||
|
</span>
|
||||||
|
<IconGridSelect items={logFormatItems()} bind:value={logFormat} columns={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-module overrides -->
|
||||||
|
<div class="overrides">
|
||||||
|
<div class="overrides-head">
|
||||||
|
<span class="log-label">
|
||||||
|
{t('settings.logLevels')}
|
||||||
|
<Hint text={t('settings.logLevelsHint')} />
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mode-toggle"
|
||||||
|
onclick={() => (rawMode = !rawMode)}
|
||||||
|
title={rawMode ? t('settings.editAsChips') : t('settings.editAsText')}
|
||||||
|
>
|
||||||
|
<MdiIcon name={rawMode ? 'mdiViewList' : 'mdiCodeBraces'} size={12} />
|
||||||
|
<span>{rawMode ? t('settings.editAsChips') : t('settings.editAsText')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if rawMode}
|
||||||
|
<input
|
||||||
|
bind:value={logLevels}
|
||||||
|
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
|
||||||
|
class="raw-input"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="chip-stack">
|
||||||
|
{#each rows as row, i (i)}
|
||||||
|
{@const tone = LEVEL_TONE[row.level]}
|
||||||
|
<div class="chip" data-tone={tone}>
|
||||||
|
<span class="chip-edge" aria-hidden="true"></span>
|
||||||
|
<input
|
||||||
|
value={row.module}
|
||||||
|
oninput={(e) => updateModule(i, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
placeholder={t('settings.logModulePlaceholder')}
|
||||||
|
class="chip-input"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<span class="chip-sep" aria-hidden="true">=</span>
|
||||||
|
<select
|
||||||
|
value={row.level}
|
||||||
|
onchange={(e) => updateLevel(i, (e.currentTarget as HTMLSelectElement).value as Level)}
|
||||||
|
class="chip-level"
|
||||||
|
aria-label={t('settings.logLevel')}
|
||||||
|
>
|
||||||
|
{#each LEVELS as lvl}
|
||||||
|
<option value={lvl}>{lvl}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip-remove"
|
||||||
|
onclick={() => removeRow(i)}
|
||||||
|
aria-label={t('settings.removeOverride')}
|
||||||
|
title={t('settings.removeOverride')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiClose" size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button type="button" class="chip-add" onclick={addRow}>
|
||||||
|
<MdiIcon name="mdiPlus" size={13} />
|
||||||
|
<span>{t('settings.addOverride')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Live preview -->
|
||||||
|
<div class="preview" role="status">
|
||||||
|
<span class="preview-eyebrow">{t('settings.logPreviewLabel')}</span>
|
||||||
|
<code class="preview-text">{previewLine}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logging {
|
||||||
|
padding: 1.5rem 1.6rem 1.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.15rem;
|
||||||
|
}
|
||||||
|
.log-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.log-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.log-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
max-width: 38ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.log-row { grid-template-columns: 1fr 1fr; gap: 1.4rem; }
|
||||||
|
}
|
||||||
|
.log-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.log-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Overrides editor --- */
|
||||||
|
.overrides {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.overrides-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.mode-toggle:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-input {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.35rem 0.4rem 0.35rem 0.95rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.18s, background 0.18s;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
background: var(--color-glass-elev);
|
||||||
|
}
|
||||||
|
.chip-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.chip[data-tone="sky"] .chip-edge { background: var(--color-sky); }
|
||||||
|
.chip[data-tone="mint"] .chip-edge { background: var(--color-mint); }
|
||||||
|
.chip[data-tone="citrus"] .chip-edge { background: var(--color-citrus); }
|
||||||
|
.chip[data-tone="coral"] .chip-edge { background: var(--color-coral); }
|
||||||
|
|
||||||
|
.chip-input {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.chip-input::placeholder { color: var(--color-muted-foreground); opacity: 0.7; }
|
||||||
|
|
||||||
|
.chip-sep {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
opacity: 0.5;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-level {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.3rem 1.6rem 0.3rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
min-width: 7.2rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.chip[data-tone="sky"] .chip-level { color: var(--color-sky); border-color: color-mix(in srgb, var(--color-sky) 35%, var(--color-border)); }
|
||||||
|
.chip[data-tone="mint"] .chip-level { color: var(--color-mint); border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
|
||||||
|
.chip[data-tone="citrus"] .chip-level { color: var(--color-citrus); border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
|
||||||
|
.chip[data-tone="coral"] .chip-level { color: var(--color-coral); border-color: color-mix(in srgb, var(--color-coral) 35%, var(--color-border)); }
|
||||||
|
|
||||||
|
.chip-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.chip-remove:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-error-fg) 12%, var(--color-glass-strong));
|
||||||
|
color: var(--color-error-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px dashed var(--color-rule-strong);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.chip-add:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-style: solid;
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-rule-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Live preview --- */
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--color-background-deep, #02030a) 30%, var(--color-glass-strong));
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.preview-eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.preview-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dirty: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
changedCount: number;
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { dirty, saving, changedCount, onSave, onDiscard }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if dirty || saving}
|
||||||
|
<div class="save-bar" role="region" aria-label={t('settings.unsavedChanges')}>
|
||||||
|
<div class="save-bar-inner glass">
|
||||||
|
<span class="save-edge" aria-hidden="true"></span>
|
||||||
|
<span class="save-pulse" aria-hidden="true"></span>
|
||||||
|
<div class="save-text">
|
||||||
|
<span class="save-eyebrow">{t('settings.unsaved')}</span>
|
||||||
|
<span class="save-message">
|
||||||
|
{#if changedCount === 1}
|
||||||
|
{t('settings.changedOne')}
|
||||||
|
{:else}
|
||||||
|
{t('settings.changedMany').replace('{n}', String(changedCount))}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="save-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="discard"
|
||||||
|
onclick={onDiscard}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('settings.discard')}
|
||||||
|
</button>
|
||||||
|
<Button size="sm" onclick={onSave} disabled={saving}>
|
||||||
|
{#if saving}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiContentSave" size={14} />
|
||||||
|
{/if}
|
||||||
|
{saving ? t('common.loading') : t('settings.saveChanges')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.save-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 40;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: save-rise 0.3s cubic-bezier(.2,.7,.2,1) both;
|
||||||
|
}
|
||||||
|
.save-bar-inner {
|
||||||
|
pointer-events: auto;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.7rem 1rem 0.7rem 1.25rem;
|
||||||
|
max-width: min(640px, calc(100% - 1rem));
|
||||||
|
width: 100%;
|
||||||
|
border-color: color-mix(in srgb, var(--color-citrus) 40%, var(--color-border));
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow-card),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--color-citrus) 22%, transparent) inset;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.save-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: linear-gradient(180deg, var(--color-citrus), color-mix(in srgb, var(--color-citrus) 50%, transparent));
|
||||||
|
}
|
||||||
|
.save-pulse {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-citrus);
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent);
|
||||||
|
animation: save-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-text {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 1rem; /* clear room for the pulse dot */
|
||||||
|
}
|
||||||
|
.save-eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-citrus);
|
||||||
|
}
|
||||||
|
.save-message {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.discard {
|
||||||
|
padding: 0 0.95rem;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.discard:hover:not(:disabled) {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
.discard:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
@keyframes save-rise {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes save-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent); }
|
||||||
|
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-citrus) 0%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.save-bar, .save-pulse { animation: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import PageHeader, { type HeaderPill } from '$lib/components/PageHeader.svelte';
|
||||||
|
|
||||||
|
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
external_url: string;
|
||||||
|
timezone: string;
|
||||||
|
supported_locales: string;
|
||||||
|
log_level: string;
|
||||||
|
log_format: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { settings }: Props = $props();
|
||||||
|
|
||||||
|
// Live tick so the timezone pill shows the current local HH:MM.
|
||||||
|
let now = $state(new Date());
|
||||||
|
let tick: ReturnType<typeof setInterval> | null = null;
|
||||||
|
onMount(() => { tick = setInterval(() => { now = new Date(); }, 30_000); });
|
||||||
|
onDestroy(() => { if (tick) clearInterval(tick); });
|
||||||
|
|
||||||
|
function fmtClock(tz: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: tz || 'UTC',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(now);
|
||||||
|
} catch { return '--:--'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostFromUrl(url: string): string {
|
||||||
|
if (!url) return '';
|
||||||
|
try { return new URL(url).host; }
|
||||||
|
catch { return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function localeCount(csv: string): number {
|
||||||
|
if (!csv) return 0;
|
||||||
|
return csv.split(',').map(s => s.trim()).filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_TONE: Record<string, Tone> = {
|
||||||
|
DEBUG: 'sky',
|
||||||
|
INFO: 'mint',
|
||||||
|
WARNING: 'citrus',
|
||||||
|
ERROR: 'coral',
|
||||||
|
};
|
||||||
|
|
||||||
|
const pills = $derived.by<HeaderPill[]>(() => {
|
||||||
|
const out: HeaderPill[] = [];
|
||||||
|
|
||||||
|
const host = hostFromUrl(settings.external_url);
|
||||||
|
out.push(host
|
||||||
|
? { label: host, tone: 'sky' }
|
||||||
|
: { label: t('settings.heroNoUrl') }
|
||||||
|
);
|
||||||
|
|
||||||
|
const tz = settings.timezone || 'UTC';
|
||||||
|
out.push({ label: `${tz} · ${fmtClock(tz)}`, tone: 'primary' });
|
||||||
|
|
||||||
|
const locales = settings.supported_locales || '';
|
||||||
|
const count = localeCount(locales);
|
||||||
|
out.push({
|
||||||
|
label: count > 0
|
||||||
|
? locales.split(',').map(s => s.trim()).filter(Boolean).map(s => s.toUpperCase()).join(' · ')
|
||||||
|
: t('settings.heroNoLocales'),
|
||||||
|
tone: 'orchid',
|
||||||
|
});
|
||||||
|
|
||||||
|
const lvl = (settings.log_level || 'INFO').toUpperCase();
|
||||||
|
out.push({
|
||||||
|
label: `${lvl} · ${settings.log_format || 'text'}`,
|
||||||
|
tone: SEVERITY_TONE[lvl] ?? 'mint',
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={t('settings.title')}
|
||||||
|
emphasis={t('settings.titleEmphasis')}
|
||||||
|
description={t('settings.description')}
|
||||||
|
crumb={t('crumbs.systemConfiguration')}
|
||||||
|
{pills}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
webhookSecret: string;
|
||||||
|
cacheTtlHours: string;
|
||||||
|
cacheMaxEntries: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
webhookSecret = $bindable(),
|
||||||
|
cacheTtlHours = $bindable(),
|
||||||
|
cacheMaxEntries = $bindable(),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let showSecret = $state(false);
|
||||||
|
|
||||||
|
const secretSet = $derived(!!webhookSecret && webhookSecret.length > 0);
|
||||||
|
const ttlHours = $derived(Number(cacheTtlHours || '0'));
|
||||||
|
const ttlIsOff = $derived(ttlHours <= 0);
|
||||||
|
|
||||||
|
function ttlHumanized(h: number): string {
|
||||||
|
if (h <= 0) return t('settings.ttlNoExpiry');
|
||||||
|
if (h < 24) return `${h}h`;
|
||||||
|
const d = Math.round(h / 24);
|
||||||
|
if (d < 7) return `${d}d`;
|
||||||
|
const w = Math.round(d / 7);
|
||||||
|
if (w < 8) return `${w}w`;
|
||||||
|
const mo = Math.round(d / 30);
|
||||||
|
return `${mo}mo`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="tg glass">
|
||||||
|
<header class="tg-head">
|
||||||
|
<div class="tg-eyebrow">
|
||||||
|
<MdiIcon name="mdiSend" size={12} />
|
||||||
|
<span>{t('settings.telegram')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="tg-title">{t('settings.telegramHeadline')}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tg-grid">
|
||||||
|
<!-- Webhook secret column -->
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-head">
|
||||||
|
<span class="col-num">A</span>
|
||||||
|
<span class="col-name">
|
||||||
|
{t('settings.webhookSecret')}
|
||||||
|
<Hint text={t('settings.webhookSecretHint')} />
|
||||||
|
</span>
|
||||||
|
<span class="col-status" data-state={secretSet ? 'set' : 'unset'}>
|
||||||
|
<span class="dot"></span>
|
||||||
|
{secretSet ? t('settings.secretSet') : t('settings.secretUnset')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form class="secret-field" onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
||||||
|
<input
|
||||||
|
bind:value={webhookSecret}
|
||||||
|
type={showSecret ? 'text' : 'password'}
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder={t('providers.optional')}
|
||||||
|
class="secret-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secret-toggle"
|
||||||
|
onclick={() => (showSecret = !showSecret)}
|
||||||
|
aria-label={showSecret ? t('settings.hide') : t('settings.show')}
|
||||||
|
title={showSecret ? t('settings.hide') : t('settings.show')}
|
||||||
|
>
|
||||||
|
<MdiIcon name={showSecret ? 'mdiEyeOff' : 'mdiEye'} size={14} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cache config column -->
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-head">
|
||||||
|
<span class="col-num">B</span>
|
||||||
|
<span class="col-name">{t('settings.cacheConfig')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cache-grid">
|
||||||
|
<label class="num-field">
|
||||||
|
<span class="num-label">
|
||||||
|
{t('settings.cacheTtlShort')}
|
||||||
|
<Hint text={t('settings.cacheTtlHint')} />
|
||||||
|
</span>
|
||||||
|
<div class="num-row">
|
||||||
|
<input
|
||||||
|
bind:value={cacheTtlHours}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="8760"
|
||||||
|
class="num-input"
|
||||||
|
/>
|
||||||
|
<span class="num-suffix">{t('settings.hoursShort')}</span>
|
||||||
|
</div>
|
||||||
|
<span class="num-meta" class:num-meta-off={ttlIsOff}>
|
||||||
|
{ttlHumanized(ttlHours)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="num-field">
|
||||||
|
<span class="num-label">
|
||||||
|
{t('settings.cacheMaxShort')}
|
||||||
|
<Hint text={t('settings.cacheMaxEntriesHint')} />
|
||||||
|
</span>
|
||||||
|
<div class="num-row">
|
||||||
|
<input
|
||||||
|
bind:value={cacheMaxEntries}
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
max="100000"
|
||||||
|
class="num-input"
|
||||||
|
/>
|
||||||
|
<span class="num-suffix">{t('settings.entriesShort')}</span>
|
||||||
|
</div>
|
||||||
|
<span class="num-meta">
|
||||||
|
{t('settings.cacheMaxFootnote')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tg {
|
||||||
|
padding: 1.5rem 1.6rem 1.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.15rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.tg-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.tg-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.tg-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
max-width: 36ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-grid {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.tg-grid { grid-template-columns: 1fr 1fr; gap: 1.6rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.col-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.col-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.col-name {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.col-status {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.col-status .dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.col-status[data-state="set"] {
|
||||||
|
color: var(--color-mint);
|
||||||
|
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-mint) 8%, var(--color-glass-strong));
|
||||||
|
}
|
||||||
|
.col-status[data-state="set"] .dot {
|
||||||
|
background: var(--color-mint);
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Secret field --- */
|
||||||
|
.secret-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
transition: border-color 0.18s, box-shadow 0.18s;
|
||||||
|
}
|
||||||
|
.secret-field:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-glow);
|
||||||
|
}
|
||||||
|
.secret-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
padding: 0.5rem 0.4rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
min-width: 0;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.secret-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.secret-toggle:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Cache config grid --- */
|
||||||
|
.cache-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.num-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.7rem 0.85rem 0.65rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
}
|
||||||
|
.num-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.num-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.num-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.1rem 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
line-height: 1.1;
|
||||||
|
outline: none;
|
||||||
|
appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
.num-input::-webkit-outer-spin-button,
|
||||||
|
.num-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.num-suffix {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
.num-meta {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-mint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.num-meta-off {
|
||||||
|
color: var(--color-citrus);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.cache-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api, fetchAuth } from '$lib/api';
|
import { api, fetchAuth } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
||||||
import Card from '$lib/components/Card.svelte';
|
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
@@ -11,32 +9,55 @@
|
|||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
|
|
||||||
// --- Export state ---
|
import BackupHero from './BackupHero.svelte';
|
||||||
let exportSecrets = $state('exclude');
|
import PendingStrip from './PendingStrip.svelte';
|
||||||
let exporting = $state(false);
|
import ExportPanel from './ExportPanel.svelte';
|
||||||
|
import ImportPanel from './ImportPanel.svelte';
|
||||||
|
import ScheduleCassette from './ScheduleCassette.svelte';
|
||||||
|
import BackupLedger from './BackupLedger.svelte';
|
||||||
|
|
||||||
const categories = [
|
type SecretsMode = 'exclude' | 'masked' | 'include';
|
||||||
{ key: 'providers', label: 'backup.catProviders' },
|
type ConflictMode = 'skip' | 'rename' | 'overwrite';
|
||||||
{ key: 'telegram_bots', label: 'backup.catTelegramBots' },
|
|
||||||
{ key: 'matrix_bots', label: 'backup.catMatrixBots' },
|
interface BackupFile {
|
||||||
{ key: 'email_bots', label: 'backup.catEmailBots' },
|
filename: string;
|
||||||
{ key: 'targets', label: 'backup.catTargets' },
|
size: number;
|
||||||
{ key: 'tracking_configs', label: 'backup.catTrackingConfigs' },
|
created_at?: string | null;
|
||||||
{ key: 'template_configs', label: 'backup.catTemplateConfigs' },
|
}
|
||||||
{ key: 'command_configs', label: 'backup.catCommandConfigs' },
|
|
||||||
{ key: 'command_template_configs', label: 'backup.catCommandTemplateConfigs' },
|
interface ScheduledSettings {
|
||||||
{ key: 'notification_trackers', label: 'backup.catNotificationTrackers' },
|
backup_scheduled_enabled: string;
|
||||||
{ key: 'command_trackers', label: 'backup.catCommandTrackers' },
|
backup_scheduled_interval_hours: string;
|
||||||
{ key: 'actions', label: 'backup.catActions' },
|
backup_secrets_mode: string;
|
||||||
{ key: 'app_settings', label: 'backup.catAppSettings' },
|
backup_retention_count: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingState {
|
||||||
|
pending: boolean;
|
||||||
|
uploaded_at?: string | null;
|
||||||
|
uploaded_by?: string | null;
|
||||||
|
conflict_mode?: string;
|
||||||
|
supervised?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCategories = [
|
||||||
|
'providers', 'telegram_bots', 'matrix_bots', 'email_bots', 'targets',
|
||||||
|
'tracking_configs', 'template_configs',
|
||||||
|
'command_configs', 'command_template_configs',
|
||||||
|
'notification_trackers', 'command_trackers',
|
||||||
|
'actions', 'app_settings',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// --- Export state ---
|
||||||
|
let exportSecrets = $state<SecretsMode>('exclude');
|
||||||
|
let exporting = $state(false);
|
||||||
let selectedCategories = $state<Record<string, boolean>>(
|
let selectedCategories = $state<Record<string, boolean>>(
|
||||||
Object.fromEntries(categories.map(c => [c.key, true]))
|
Object.fromEntries(allCategories.map(k => [k, true]))
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Import state ---
|
// --- Import state ---
|
||||||
let importFile: File | null = $state(null);
|
let importFile: File | null = $state(null);
|
||||||
let importConflict = $state('skip');
|
let importConflict = $state<ConflictMode>('skip');
|
||||||
let importing = $state(false);
|
let importing = $state(false);
|
||||||
let validating = $state(false);
|
let validating = $state(false);
|
||||||
let validationResult: any = $state(null);
|
let validationResult: any = $state(null);
|
||||||
@@ -47,7 +68,7 @@
|
|||||||
// --- Scheduled backup state ---
|
// --- Scheduled backup state ---
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let scheduledSettings = $state({
|
let scheduledSettings = $state<ScheduledSettings>({
|
||||||
backup_scheduled_enabled: 'false',
|
backup_scheduled_enabled: 'false',
|
||||||
backup_scheduled_interval_hours: '24',
|
backup_scheduled_interval_hours: '24',
|
||||||
backup_secrets_mode: 'exclude',
|
backup_secrets_mode: 'exclude',
|
||||||
@@ -56,22 +77,22 @@
|
|||||||
let savingSchedule = $state(false);
|
let savingSchedule = $state(false);
|
||||||
|
|
||||||
// --- Backup files ---
|
// --- Backup files ---
|
||||||
let backupFiles = $state<any[]>([]);
|
let backupFiles = $state<BackupFile[]>([]);
|
||||||
let loadingFiles = $state(false);
|
let loadingFiles = $state(false);
|
||||||
let confirmDeleteFile = $state('');
|
let confirmDeleteFile = $state('');
|
||||||
let creatingBackup = $state(false);
|
let creatingBackup = $state(false);
|
||||||
|
|
||||||
// --- Pending restore state ---
|
// --- Pending restore state ---
|
||||||
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
|
let pending = $state<PendingState | null>(null);
|
||||||
let postRestoreModalOpen = $state(false);
|
let postRestoreModalOpen = $state(false);
|
||||||
let restartingOverlay = $state(false);
|
let restartingOverlay = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const [settings, files, p] = await Promise.all([
|
const [settings, files, p] = await Promise.all([
|
||||||
api('/backup/scheduled'),
|
api<ScheduledSettings>('/backup/scheduled'),
|
||||||
api('/backup/files'),
|
api<BackupFile[]>('/backup/files'),
|
||||||
api('/backup/pending-restore'),
|
api<PendingState>('/backup/pending-restore'),
|
||||||
]);
|
]);
|
||||||
scheduledSettings = settings;
|
scheduledSettings = settings;
|
||||||
backupFiles = files;
|
backupFiles = files;
|
||||||
@@ -84,7 +105,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function cancelPending() {
|
async function cancelPending(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api('/backup/pending-restore', { method: 'DELETE' });
|
await api('/backup/pending-restore', { method: 'DELETE' });
|
||||||
snackSuccess(t('backup.pendingCancelled'));
|
snackSuccess(t('backup.pendingCancelled'));
|
||||||
@@ -92,14 +113,13 @@
|
|||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: any) { snackError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyAndRestart() {
|
async function applyAndRestart(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api('/backup/apply-restart', { method: 'POST' });
|
await api('/backup/apply-restart', { method: 'POST' });
|
||||||
restartingOverlay = true;
|
restartingOverlay = true;
|
||||||
// Poll /health until the new instance is up
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const poll = async () => {
|
const poll = async (): Promise<void> => {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/health');
|
const res = await fetch('/api/health');
|
||||||
@@ -117,7 +137,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createManualBackup() {
|
async function createManualBackup(): Promise<void> {
|
||||||
creatingBackup = true;
|
creatingBackup = true;
|
||||||
try {
|
try {
|
||||||
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
|
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
|
||||||
@@ -132,7 +152,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Export ---
|
// --- Export ---
|
||||||
async function doExport() {
|
async function doExport(): Promise<void> {
|
||||||
if (exportSecrets === 'include') {
|
if (exportSecrets === 'include') {
|
||||||
confirmExportOpen = true;
|
confirmExportOpen = true;
|
||||||
return;
|
return;
|
||||||
@@ -140,7 +160,7 @@
|
|||||||
await performExport();
|
await performExport();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performExport() {
|
async function performExport(): Promise<void> {
|
||||||
confirmExportOpen = false;
|
confirmExportOpen = false;
|
||||||
exporting = true;
|
exporting = true;
|
||||||
try {
|
try {
|
||||||
@@ -165,8 +185,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Validate ---
|
// --- Validate / Import ---
|
||||||
async function validateFile() {
|
function handleFileSelect(file: File | null): void {
|
||||||
|
importFile = file;
|
||||||
|
validationResult = null;
|
||||||
|
importResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateFile(): Promise<void> {
|
||||||
if (!importFile) return;
|
if (!importFile) return;
|
||||||
validating = true;
|
validating = true;
|
||||||
validationResult = null;
|
validationResult = null;
|
||||||
@@ -183,12 +209,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Import ---
|
function doImport(): void {
|
||||||
async function doImport() {
|
|
||||||
confirmImportOpen = true;
|
confirmImportOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performImport() {
|
async function performImport(): Promise<void> {
|
||||||
confirmImportOpen = false;
|
confirmImportOpen = false;
|
||||||
if (!importFile) return;
|
if (!importFile) return;
|
||||||
importing = true;
|
importing = true;
|
||||||
@@ -213,10 +238,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Scheduled settings ---
|
// --- Scheduled settings ---
|
||||||
async function saveSchedule() {
|
async function saveSchedule(): Promise<void> {
|
||||||
savingSchedule = true;
|
savingSchedule = true;
|
||||||
try {
|
try {
|
||||||
scheduledSettings = await api('/backup/scheduled', {
|
scheduledSettings = await api<ScheduledSettings>('/backup/scheduled', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(scheduledSettings),
|
body: JSON.stringify(scheduledSettings),
|
||||||
});
|
});
|
||||||
@@ -229,10 +254,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- File management ---
|
// --- File management ---
|
||||||
async function refreshFiles() {
|
async function refreshFiles(): Promise<void> {
|
||||||
loadingFiles = true;
|
loadingFiles = true;
|
||||||
try {
|
try {
|
||||||
backupFiles = await api('/backup/files');
|
backupFiles = await api<BackupFile[]>('/backup/files');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
snackError(err.message);
|
snackError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -240,7 +265,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFile(filename: string) {
|
async function downloadFile(filename: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const data = await api(`/backup/files/${filename}`);
|
const data = await api(`/backup/files/${filename}`);
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
@@ -255,7 +280,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFile(filename: string) {
|
async function deleteFile(filename: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api(`/backup/files/${filename}`, { method: 'DELETE' });
|
await api(`/backup/files/${filename}`, { method: 'DELETE' });
|
||||||
snackSuccess(t('backup.fileDeleted'));
|
snackSuccess(t('backup.fileDeleted'));
|
||||||
@@ -265,355 +290,61 @@
|
|||||||
snackError(err.message);
|
snackError(err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileSelect(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement;
|
|
||||||
if (input.files?.length) {
|
|
||||||
importFile = input.files[0];
|
|
||||||
validationResult = null;
|
|
||||||
importResult = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let allSelected = $derived(Object.values(selectedCategories).every(v => v));
|
|
||||||
let noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
|
|
||||||
|
|
||||||
function toggleAll() {
|
|
||||||
const newVal = !allSelected;
|
|
||||||
for (const key of Object.keys(selectedCategories)) {
|
|
||||||
selectedCategories[key] = newVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader
|
<BackupHero files={backupFiles} scheduled={scheduledSettings} {pending} />
|
||||||
title={t('backup.title')}
|
|
||||||
emphasis={t('backup.titleEmphasis')}
|
|
||||||
description={t('backup.description')}
|
|
||||||
crumb={t('crumbs.systemMaintenance')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if !loaded}
|
{#if !loaded}
|
||||||
<Loading />
|
<Loading />
|
||||||
{:else}
|
{:else}
|
||||||
<ErrorBanner message={error} />
|
<ErrorBanner message={error} />
|
||||||
|
|
||||||
{#if pending?.pending}
|
<PendingStrip {pending} onApply={applyAndRestart} onCancel={cancelPending} />
|
||||||
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
|
|
||||||
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
|
|
||||||
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
|
|
||||||
<MdiIcon name="mdiClockAlert" size={20} />
|
|
||||||
</span>
|
|
||||||
<div class="flex-1 min-w-[12rem] text-sm">
|
|
||||||
<div class="font-medium">{t('backup.pendingTitle')}</div>
|
|
||||||
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
|
|
||||||
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
{#if pending.supervised}
|
|
||||||
<Button size="sm" onclick={applyAndRestart}>
|
|
||||||
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
<button onclick={cancelPending}
|
|
||||||
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="backup-page stagger-children">
|
||||||
|
<div class="action-deck">
|
||||||
<!-- Export Section -->
|
<ExportPanel
|
||||||
<Card>
|
{selectedCategories}
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
{exportSecrets}
|
||||||
<MdiIcon name="mdiDatabaseExport" size={18} />
|
{exporting}
|
||||||
{t('backup.export')}
|
onCategoriesChange={(next) => selectedCategories = next}
|
||||||
</h3>
|
onSecretsChange={(next) => exportSecrets = next}
|
||||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.exportDescription')}</p>
|
onExport={doExport}
|
||||||
|
/>
|
||||||
<!-- Categories -->
|
<ImportPanel
|
||||||
<div class="mb-4">
|
{importFile}
|
||||||
<div class="flex items-center gap-2 mb-2">
|
{importConflict}
|
||||||
<span class="text-xs font-medium">{t('backup.categories')}</span>
|
{validating}
|
||||||
<button class="text-xs underline" style="color: var(--color-primary);" onclick={toggleAll}>
|
{validationResult}
|
||||||
{allSelected ? t('backup.deselectAll') : t('backup.selectAll')}
|
{importing}
|
||||||
</button>
|
{importResult}
|
||||||
</div>
|
onFileSelect={handleFileSelect}
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
onConflictChange={(mode) => importConflict = mode}
|
||||||
{#each categories as cat}
|
onValidate={validateFile}
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
onImport={doImport}
|
||||||
<input type="checkbox" bind:checked={selectedCategories[cat.key]} />
|
/>
|
||||||
{t(cat.label)}
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Secrets mode -->
|
<ScheduleCassette
|
||||||
<div class="mb-4">
|
enabled={scheduledSettings.backup_scheduled_enabled === 'true'}
|
||||||
<div class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</div>
|
bind:intervalHours={scheduledSettings.backup_scheduled_interval_hours}
|
||||||
<div class="flex flex-col gap-1.5">
|
bind:secretsMode={scheduledSettings.backup_secrets_mode}
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
bind:retentionCount={scheduledSettings.backup_retention_count}
|
||||||
<input type="radio" bind:group={exportSecrets} value="exclude" />
|
saving={savingSchedule}
|
||||||
{t('backup.secretsExclude')}
|
onToggle={() => scheduledSettings.backup_scheduled_enabled =
|
||||||
</label>
|
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'}
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
onSave={saveSchedule}
|
||||||
<input type="radio" bind:group={exportSecrets} value="masked" />
|
/>
|
||||||
{t('backup.secretsMasked')}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
|
||||||
<input type="radio" bind:group={exportSecrets} value="include" />
|
|
||||||
{t('backup.secretsInclude')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{#if exportSecrets === 'include'}
|
|
||||||
<div class="mt-2 p-2 rounded-md text-xs flex items-center gap-2"
|
|
||||||
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
|
||||||
<MdiIcon name="mdiAlert" size={14} />
|
|
||||||
{t('backup.secretsWarningExport')}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onclick={doExport} disabled={exporting || noneSelected}>
|
<BackupLedger
|
||||||
{#if exporting}
|
files={backupFiles}
|
||||||
<MdiIcon name="mdiLoading" size={14} />
|
loading={loadingFiles}
|
||||||
{:else}
|
creating={creatingBackup}
|
||||||
<MdiIcon name="mdiDownload" size={14} />
|
onCreate={createManualBackup}
|
||||||
{/if}
|
onRefresh={refreshFiles}
|
||||||
{exporting ? t('common.loading') : t('backup.exportBtn')}
|
onDownload={downloadFile}
|
||||||
</Button>
|
onDelete={(filename) => confirmDeleteFile = filename}
|
||||||
</Card>
|
/>
|
||||||
|
|
||||||
<!-- Import Section -->
|
|
||||||
<Card>
|
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<MdiIcon name="mdiDatabaseImport" size={18} />
|
|
||||||
{t('backup.import')}
|
|
||||||
</h3>
|
|
||||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.importDescription')}</p>
|
|
||||||
|
|
||||||
<!-- File picker -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<input type="file" accept=".json" onchange={handleFileSelect}
|
|
||||||
class="text-xs file:mr-2 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:cursor-pointer"
|
|
||||||
style="file:background: var(--color-muted); file:color: var(--color-foreground);" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if importFile}
|
|
||||||
<!-- Validate -->
|
|
||||||
<div class="mb-4 flex items-center gap-2">
|
|
||||||
<Button variant="secondary" onclick={validateFile} disabled={validating}>
|
|
||||||
{#if validating}
|
|
||||||
<MdiIcon name="mdiLoading" size={14} />
|
|
||||||
{:else}
|
|
||||||
<MdiIcon name="mdiCheckCircleOutline" size={14} />
|
|
||||||
{/if}
|
|
||||||
{validating ? t('backup.validating') : t('backup.validateBtn')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if validationResult}
|
|
||||||
<div class="mb-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
|
||||||
<div class="flex items-center gap-2 mb-2 font-medium">
|
|
||||||
{#if validationResult.valid}
|
|
||||||
<span style="color: var(--color-success-fg, green);"><MdiIcon name="mdiCheckCircle" size={14} /></span>
|
|
||||||
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
|
|
||||||
{:else}
|
|
||||||
<MdiIcon name="mdiCloseCircle" size={14} />
|
|
||||||
<span style="color: var(--color-error-fg);">{t('backup.validationFailed')}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if Object.keys(validationResult.entity_counts || {}).length}
|
|
||||||
<div class="mb-2">
|
|
||||||
<span class="font-medium">{t('backup.entities')}:</span>
|
|
||||||
{#each Object.entries(validationResult.entity_counts) as [cat, count]}
|
|
||||||
<span class="inline-block mr-2">{cat}: {count}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#each validationResult.warnings || [] as w}
|
|
||||||
<div class="flex items-start gap-1 mt-1" style="color: var(--color-warning-fg, orange);">
|
|
||||||
<MdiIcon name="mdiAlert" size={12} />
|
|
||||||
<span>{w}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{#each validationResult.errors || [] as e}
|
|
||||||
<div class="flex items-start gap-1 mt-1" style="color: var(--color-error-fg);">
|
|
||||||
<MdiIcon name="mdiAlertCircle" size={12} />
|
|
||||||
<span>{e}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Conflict mode -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</div>
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
|
||||||
<input type="radio" bind:group={importConflict} value="skip" />
|
|
||||||
{t('backup.conflictSkip')}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
|
||||||
<input type="radio" bind:group={importConflict} value="rename" />
|
|
||||||
{t('backup.conflictRename')}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
|
||||||
<input type="radio" bind:group={importConflict} value="overwrite" />
|
|
||||||
{t('backup.conflictOverwrite')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onclick={doImport}
|
|
||||||
disabled={importing || !validationResult?.valid}>
|
|
||||||
{#if importing}
|
|
||||||
<MdiIcon name="mdiLoading" size={14} />
|
|
||||||
{:else}
|
|
||||||
<MdiIcon name="mdiUpload" size={14} />
|
|
||||||
{/if}
|
|
||||||
{importing ? t('backup.importing') : t('backup.importBtn')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{#if importResult}
|
|
||||||
<div class="mt-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
|
||||||
<div class="font-medium mb-1">{t('backup.importResults')}</div>
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
<div>{t('backup.resultCreated')}: {importResult.created}</div>
|
|
||||||
<div>{t('backup.resultSkipped')}: {importResult.skipped}</div>
|
|
||||||
<div>{t('backup.resultOverwritten')}: {importResult.overwritten}</div>
|
|
||||||
{#if importResult.errors?.length}
|
|
||||||
<div style="color: var(--color-error-fg);">{t('backup.resultErrors')}: {importResult.errors.length}</div>
|
|
||||||
{#each importResult.errors as e}
|
|
||||||
<div class="ml-2" style="color: var(--color-error-fg);">{e}</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{#if importResult.warnings?.length}
|
|
||||||
{#each importResult.warnings as w}
|
|
||||||
<div class="ml-2" style="color: var(--color-warning-fg, orange);">{w}</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Scheduled Backups Section -->
|
|
||||||
<Card>
|
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<MdiIcon name="mdiClockOutline" size={18} />
|
|
||||||
{t('backup.scheduled')}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<label class="flex items-center gap-2 text-xs">
|
|
||||||
<input type="checkbox"
|
|
||||||
checked={scheduledSettings.backup_scheduled_enabled === 'true'}
|
|
||||||
onchange={() => scheduledSettings.backup_scheduled_enabled =
|
|
||||||
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'} />
|
|
||||||
<span class="font-medium">{t('backup.enableScheduled')}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{#if scheduledSettings.backup_scheduled_enabled === 'true'}
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label for="backup-interval" class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
|
|
||||||
<select id="backup-interval" bind:value={scheduledSettings.backup_scheduled_interval_hours}
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
|
||||||
<option value="6">6 {t('backup.hours')}</option>
|
|
||||||
<option value="12">12 {t('backup.hours')}</option>
|
|
||||||
<option value="24">24 {t('backup.hours')}</option>
|
|
||||||
<option value="48">48 {t('backup.hours')}</option>
|
|
||||||
<option value="72">72 {t('backup.hours')}</option>
|
|
||||||
<option value="168">168 {t('backup.hours')} (7d)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="backup-secrets-mode" class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
|
|
||||||
<select id="backup-secrets-mode" bind:value={scheduledSettings.backup_secrets_mode}
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
|
||||||
<option value="exclude">{t('backup.secretsExclude')}</option>
|
|
||||||
<option value="masked">{t('backup.secretsMasked')}</option>
|
|
||||||
<option value="include">{t('backup.secretsInclude')}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="backup-retention" class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
|
|
||||||
<select id="backup-retention" bind:value={scheduledSettings.backup_retention_count}
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
|
||||||
<option value="3">3</option>
|
|
||||||
<option value="5">5</option>
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="20">20</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<Button onclick={saveSchedule} disabled={savingSchedule}>
|
|
||||||
{savingSchedule ? t('common.loading') : t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Saved Backup Files -->
|
|
||||||
<Card>
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
|
||||||
<MdiIcon name="mdiFolder" size={18} />
|
|
||||||
{t('backup.savedFiles')}
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
|
|
||||||
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
|
|
||||||
</Button>
|
|
||||||
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
|
|
||||||
<MdiIcon name="mdiRefresh" size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if backupFiles.length === 0}
|
|
||||||
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('backup.noFiles')}</p>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-2">
|
|
||||||
{#each backupFiles as file}
|
|
||||||
<div class="flex items-center justify-between p-2 rounded-md border text-xs"
|
|
||||||
style="border-color: var(--color-border);">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<MdiIcon name="mdiFileDocument" size={14} />
|
|
||||||
<span class="font-mono">{file.filename}</span>
|
|
||||||
<span style="color: var(--color-muted-foreground);">({formatSize(file.size)})</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button onclick={() => downloadFile(file.filename)}
|
|
||||||
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('backup.download')}>
|
|
||||||
<MdiIcon name="mdiDownload" size={14} />
|
|
||||||
</button>
|
|
||||||
<button onclick={() => confirmDeleteFile = file.filename}
|
|
||||||
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('common.delete')}
|
|
||||||
style="color: var(--color-error-fg);">
|
|
||||||
<MdiIcon name="mdiDelete" size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -652,27 +383,25 @@
|
|||||||
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
|
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
|
||||||
{#if postRestoreModalOpen && pending?.pending}
|
{#if postRestoreModalOpen && pending?.pending}
|
||||||
<div class="post-restore-backdrop"
|
<div class="post-restore-backdrop"
|
||||||
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
|
|
||||||
onclick={() => postRestoreModalOpen = false}
|
onclick={() => postRestoreModalOpen = false}
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
|
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
|
||||||
role="presentation">
|
role="presentation">
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
|
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
|
||||||
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
|
class="post-restore-card"
|
||||||
onclick={(e) => e.stopPropagation()}>
|
onclick={(e) => e.stopPropagation()}>
|
||||||
<div class="flex items-start gap-3 mb-4">
|
<div class="post-restore-head">
|
||||||
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
|
<div class="post-restore-icon">
|
||||||
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
|
|
||||||
<MdiIcon name="mdiClockAlert" size={22} />
|
<MdiIcon name="mdiClockAlert" size={22} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="post-restore-text">
|
||||||
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
|
<h3 id="post-restore-title">{t('backup.restorePrepared')}</h3>
|
||||||
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
|
<p>{t('backup.restoreApplyPrompt')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 justify-end flex-wrap">
|
<div class="post-restore-actions">
|
||||||
<button onclick={() => postRestoreModalOpen = false}
|
<button class="post-restore-later" type="button"
|
||||||
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
onclick={() => postRestoreModalOpen = false}>
|
||||||
{t('backup.applyLater')}
|
{t('backup.applyLater')}
|
||||||
</button>
|
</button>
|
||||||
{#if pending.supervised}
|
{#if pending.supervised}
|
||||||
@@ -687,30 +416,162 @@
|
|||||||
|
|
||||||
<!-- Restarting overlay -->
|
<!-- Restarting overlay -->
|
||||||
{#if restartingOverlay}
|
{#if restartingOverlay}
|
||||||
<div role="alert" aria-live="assertive"
|
<div class="restart-overlay" role="alert" aria-live="assertive">
|
||||||
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
|
<div class="restart-card">
|
||||||
<div class="text-center p-6" style="color: var(--color-foreground);">
|
<div class="restart-spinner">
|
||||||
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
|
|
||||||
<MdiIcon name="mdiRestart" size={40} />
|
<MdiIcon name="mdiRestart" size={40} />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
|
<p class="restart-title">{t('backup.restartingTitle')}</p>
|
||||||
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
|
<p class="restart-sub">{t('backup.restartingDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.backup-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-deck {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.action-deck { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post-restore modal */
|
||||||
|
.post-restore-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.post-restore-card {
|
||||||
|
background: var(--color-glass-elev);
|
||||||
|
backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 30px 70px -16px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.post-restore-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
.post-restore-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px; height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
color: var(--color-warning-fg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.post-restore-text { min-width: 0; }
|
||||||
|
.post-restore-text h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
.post-restore-text p {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.post-restore-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.post-restore-later {
|
||||||
|
padding: 0 0.95rem;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.post-restore-later:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restarting overlay */
|
||||||
|
.restart-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.restart-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.6rem 2rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
.restart-spinner {
|
.restart-spinner {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
color: var(--color-primary);
|
||||||
animation: restart-spin 1.2s linear infinite;
|
animation: restart-spin 1.2s linear infinite;
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
.restart-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
.restart-sub {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
}
|
||||||
@keyframes restart-spin {
|
@keyframes restart-spin {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.restart-spinner { animation: none !important; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
|
||||||
|
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
|
||||||
|
|
||||||
|
interface BackupFile {
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
created_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduledSettings {
|
||||||
|
backup_scheduled_enabled: string;
|
||||||
|
backup_scheduled_interval_hours: string;
|
||||||
|
backup_secrets_mode: string;
|
||||||
|
backup_retention_count: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
files: BackupFile[];
|
||||||
|
scheduled: ScheduledSettings;
|
||||||
|
pending: { pending: boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { files, scheduled, pending }: Props = $props();
|
||||||
|
|
||||||
|
function relativeTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||||
|
if (isNaN(date.getTime())) return '';
|
||||||
|
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||||
|
if (diffSec < 60) return t('dashboard.justNow');
|
||||||
|
const min = Math.floor(diffSec / 60);
|
||||||
|
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||||
|
const day = Math.floor(hr / 24);
|
||||||
|
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestCreatedAt(list: BackupFile[]): string | null {
|
||||||
|
const stamps = list
|
||||||
|
.map(f => f.created_at)
|
||||||
|
.filter((s): s is string => !!s)
|
||||||
|
.sort();
|
||||||
|
return stamps.length ? stamps[stamps.length - 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageHours(iso: string | null): number {
|
||||||
|
if (!iso) return Infinity;
|
||||||
|
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||||
|
if (isNaN(date.getTime())) return Infinity;
|
||||||
|
return (Date.now() - date.getTime()) / 3_600_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pills = $derived.by<Array<{ label: string; tone?: Tone }>>(() => {
|
||||||
|
const out: Array<{ label: string; tone?: Tone }> = [];
|
||||||
|
if (pending?.pending) {
|
||||||
|
out.push({ label: t('backup.restorePrepared'), tone: 'coral' });
|
||||||
|
}
|
||||||
|
if (scheduled.backup_scheduled_enabled === 'true') {
|
||||||
|
out.push({
|
||||||
|
label: t('backup.scheduleOn').replace('{h}', scheduled.backup_scheduled_interval_hours || '24'),
|
||||||
|
tone: 'mint',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
out.push({ label: t('backup.scheduleOff') });
|
||||||
|
}
|
||||||
|
const latest = latestCreatedAt(files);
|
||||||
|
if (latest) {
|
||||||
|
const hours = ageHours(latest);
|
||||||
|
const tone: Tone = hours < 48 ? 'mint' : hours < 24 * 7 ? 'citrus' : 'coral';
|
||||||
|
out.push({ label: t('backup.lastBackup').replace('{ago}', relativeTime(latest)), tone });
|
||||||
|
} else {
|
||||||
|
out.push({ label: t('backup.never'), tone: 'citrus' });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={t('backup.title')}
|
||||||
|
emphasis={t('backup.titleEmphasis')}
|
||||||
|
description={t('backup.description')}
|
||||||
|
crumb={t('crumbs.systemMaintenance')}
|
||||||
|
count={files.length}
|
||||||
|
countLabel={t('backup.countLabel')}
|
||||||
|
{pills}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
interface BackupFile {
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
created_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
files: BackupFile[];
|
||||||
|
loading: boolean;
|
||||||
|
creating: boolean;
|
||||||
|
onCreate: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onDownload: (filename: string) => void;
|
||||||
|
onDelete: (filename: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { files, loading, creating, onCreate, onRefresh, onDownload, onDelete }: Props = $props();
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(iso: string | null | undefined): Date | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso: string | null | undefined): string {
|
||||||
|
const date = parseDate(iso);
|
||||||
|
if (!date) return '';
|
||||||
|
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||||
|
if (diffSec < 60) return t('dashboard.justNow');
|
||||||
|
const min = Math.floor(diffSec / 60);
|
||||||
|
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||||
|
const day = Math.floor(hr / 24);
|
||||||
|
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteTime(iso: string | null | undefined): string {
|
||||||
|
const date = parseDate(iso);
|
||||||
|
return date ? date.toLocaleString() : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageTone(iso: string | null | undefined): Tone {
|
||||||
|
const date = parseDate(iso);
|
||||||
|
if (!date) return 'coral';
|
||||||
|
const hours = (Date.now() - date.getTime()) / 3_600_000;
|
||||||
|
if (hours < 48) return 'mint';
|
||||||
|
if (hours < 24 * 7) return 'sky';
|
||||||
|
if (hours < 24 * 30) return 'citrus';
|
||||||
|
return 'coral';
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSize = $derived(files.reduce((sum, f) => sum + (f.size || 0), 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="ledger glass">
|
||||||
|
<header class="ledger-head">
|
||||||
|
<div>
|
||||||
|
<div class="ledger-eyebrow">
|
||||||
|
<MdiIcon name="mdiArchiveOutline" size={12} />
|
||||||
|
<span>{t('backup.savedFiles')}</span>
|
||||||
|
</div>
|
||||||
|
{#if files.length > 0}
|
||||||
|
<div class="ledger-summary">
|
||||||
|
<span class="ledger-count font-mono">{files.length}</span>
|
||||||
|
<span class="ledger-count-label">{t('backup.countLabel')}</span>
|
||||||
|
<span class="ledger-sep">·</span>
|
||||||
|
<span class="ledger-total">{t('backup.totalSize').replace('{size}', formatBytes(totalSize))}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="ledger-actions">
|
||||||
|
<Button size="sm" variant="secondary" onclick={onCreate} disabled={creating}>
|
||||||
|
{#if creating}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiPlus" size={14} />
|
||||||
|
{/if}
|
||||||
|
{creating ? t('common.loading') : t('backup.createManual')}
|
||||||
|
</Button>
|
||||||
|
<button class="icon-btn" type="button" onclick={onRefresh} disabled={loading}
|
||||||
|
aria-label={t('common.refresh', 'Refresh')} title={t('common.refresh', 'Refresh')}>
|
||||||
|
<span class:spinning={loading}><MdiIcon name="mdiRefresh" size={16} /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if files.length === 0}
|
||||||
|
<div class="ledger-empty">
|
||||||
|
<MdiIcon name="mdiCloudOffOutline" size={28} />
|
||||||
|
<p>{t('backup.noFiles')}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ol class="ledger-list">
|
||||||
|
{#each files as file (file.filename)}
|
||||||
|
{@const tone = ageTone(file.created_at)}
|
||||||
|
<li class="row" data-tone={tone}>
|
||||||
|
<span class="row-edge" aria-hidden="true"></span>
|
||||||
|
<span class="row-dot" aria-hidden="true"></span>
|
||||||
|
<div class="row-time">
|
||||||
|
<span class="row-rel">{relativeTime(file.created_at) || '—'}</span>
|
||||||
|
<span class="row-abs" title={absoluteTime(file.created_at)}>
|
||||||
|
{absoluteTime(file.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-name">
|
||||||
|
<span class="row-filename" title={file.filename}>{file.filename}</span>
|
||||||
|
</div>
|
||||||
|
<span class="row-size font-mono">{formatBytes(file.size)}</span>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="icon-btn" type="button"
|
||||||
|
onclick={() => onDownload(file.filename)}
|
||||||
|
aria-label={t('backup.download')}
|
||||||
|
title={t('backup.download')}>
|
||||||
|
<MdiIcon name="mdiDownload" size={14} />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn icon-btn-danger" type="button"
|
||||||
|
onclick={() => onDelete(file.filename)}
|
||||||
|
aria-label={t('common.delete')}
|
||||||
|
title={t('common.delete')}>
|
||||||
|
<MdiIcon name="mdiTrashCanOutline" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ledger {
|
||||||
|
padding: 1.4rem 1.5rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.95rem;
|
||||||
|
}
|
||||||
|
.ledger-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ledger-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.ledger-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.ledger-count {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ledger-count-label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
|
||||||
|
.ledger-total {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ledger-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.icon-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
.icon-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.icon-btn-danger:hover:not(:disabled) {
|
||||||
|
color: var(--color-error-fg);
|
||||||
|
border-color: color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-error-fg) 8%, var(--color-glass-strong));
|
||||||
|
}
|
||||||
|
.spinning {
|
||||||
|
display: inline-flex;
|
||||||
|
animation: ledger-spin 1.1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes ledger-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-empty {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.6rem 1rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ledger-empty p { margin: 0; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.ledger-list {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.55rem 0.75rem 0.55rem 1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
transition: transform 0.18s, border-color 0.18s, background 0.18s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.row:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
background: var(--color-glass-elev);
|
||||||
|
}
|
||||||
|
.row-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
|
||||||
|
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
|
||||||
|
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
|
||||||
|
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
|
||||||
|
.row-dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
|
||||||
|
.row[data-tone="sky"] .row-dot { background: var(--color-sky); }
|
||||||
|
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); }
|
||||||
|
.row[data-tone="coral"] .row-dot { background: var(--color-coral); }
|
||||||
|
|
||||||
|
.row-time {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.05rem;
|
||||||
|
min-width: 6.5rem;
|
||||||
|
}
|
||||||
|
.row-rel {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.row-abs {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 14rem;
|
||||||
|
}
|
||||||
|
.row-name {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.row-filename {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.row:hover .row-filename { color: var(--color-foreground); }
|
||||||
|
|
||||||
|
.row-size {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.15rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.18s;
|
||||||
|
}
|
||||||
|
.row:hover .row-actions,
|
||||||
|
.row:focus-within .row-actions { opacity: 1; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.row { grid-template-columns: auto 1fr auto; row-gap: 0.25rem; }
|
||||||
|
.row-time { grid-column: 2; min-width: 0; }
|
||||||
|
.row-name { grid-column: 1 / -1; }
|
||||||
|
.row-size { grid-column: 3; grid-row: 1; }
|
||||||
|
.row-actions { grid-column: 1 / -1; opacity: 1; justify-content: flex-end; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.row { transition: none !important; }
|
||||||
|
.row:hover { transform: none !important; }
|
||||||
|
.spinning { animation: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
type SecretsMode = 'exclude' | 'masked' | 'include';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedCategories: Record<string, boolean>;
|
||||||
|
exportSecrets: SecretsMode;
|
||||||
|
exporting: boolean;
|
||||||
|
onCategoriesChange: (next: Record<string, boolean>) => void;
|
||||||
|
onSecretsChange: (next: SecretsMode) => void;
|
||||||
|
onExport: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
selectedCategories,
|
||||||
|
exportSecrets,
|
||||||
|
exporting,
|
||||||
|
onCategoriesChange,
|
||||||
|
onSecretsChange,
|
||||||
|
onExport,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const categoryGroups: Array<{ key: string; labelKey: string; icon: string; cats: Array<{ key: string; labelKey: string }> }> = [
|
||||||
|
{
|
||||||
|
key: 'identity',
|
||||||
|
labelKey: 'backup.catGroupIdentity',
|
||||||
|
icon: 'mdiAccountNetwork',
|
||||||
|
cats: [
|
||||||
|
{ key: 'providers', labelKey: 'backup.catProviders' },
|
||||||
|
{ key: 'telegram_bots', labelKey: 'backup.catTelegramBots' },
|
||||||
|
{ key: 'matrix_bots', labelKey: 'backup.catMatrixBots' },
|
||||||
|
{ key: 'email_bots', labelKey: 'backup.catEmailBots' },
|
||||||
|
{ key: 'targets', labelKey: 'backup.catTargets' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notif',
|
||||||
|
labelKey: 'backup.catGroupNotif',
|
||||||
|
icon: 'mdiBellOutline',
|
||||||
|
cats: [
|
||||||
|
{ key: 'tracking_configs', labelKey: 'backup.catTrackingConfigs' },
|
||||||
|
{ key: 'template_configs', labelKey: 'backup.catTemplateConfigs' },
|
||||||
|
{ key: 'notification_trackers', labelKey: 'backup.catNotificationTrackers' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cmd',
|
||||||
|
labelKey: 'backup.catGroupCmd',
|
||||||
|
icon: 'mdiConsoleLine',
|
||||||
|
cats: [
|
||||||
|
{ key: 'command_configs', labelKey: 'backup.catCommandConfigs' },
|
||||||
|
{ key: 'command_template_configs', labelKey: 'backup.catCommandTemplateConfigs' },
|
||||||
|
{ key: 'command_trackers', labelKey: 'backup.catCommandTrackers' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
labelKey: 'backup.catGroupSystem',
|
||||||
|
icon: 'mdiCog',
|
||||||
|
cats: [
|
||||||
|
{ key: 'actions', labelKey: 'backup.catActions' },
|
||||||
|
{ key: 'app_settings', labelKey: 'backup.catAppSettings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function toggleCat(key: string): void {
|
||||||
|
onCategoriesChange({ ...selectedCategories, [key]: !selectedCategories[key] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupState(groupKey: string): 'all' | 'none' | 'some' {
|
||||||
|
const group = categoryGroups.find(g => g.key === groupKey);
|
||||||
|
if (!group) return 'none';
|
||||||
|
const flags = group.cats.map(c => !!selectedCategories[c.key]);
|
||||||
|
if (flags.every(v => v)) return 'all';
|
||||||
|
if (flags.every(v => !v)) return 'none';
|
||||||
|
return 'some';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(groupKey: string): void {
|
||||||
|
const group = categoryGroups.find(g => g.key === groupKey);
|
||||||
|
if (!group) return;
|
||||||
|
const target = groupState(groupKey) !== 'all';
|
||||||
|
const next = { ...selectedCategories };
|
||||||
|
for (const c of group.cats) next[c.key] = target;
|
||||||
|
onCategoriesChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
|
||||||
|
const totalSelected = $derived(Object.values(selectedCategories).filter(v => v).length);
|
||||||
|
|
||||||
|
const secretsModes: Array<{ value: SecretsMode; icon: string; labelKey: string }> = [
|
||||||
|
{ value: 'exclude', icon: 'mdiShieldCheckOutline', labelKey: 'backup.secretsExclude' },
|
||||||
|
{ value: 'masked', icon: 'mdiEyeOffOutline', labelKey: 'backup.secretsMasked' },
|
||||||
|
{ value: 'include', icon: 'mdiKeyVariant', labelKey: 'backup.secretsInclude' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="export-panel glass">
|
||||||
|
<header class="panel-head">
|
||||||
|
<div class="panel-eyebrow">
|
||||||
|
<MdiIcon name="mdiDatabaseExport" size={14} />
|
||||||
|
<span>{t('backup.export')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="panel-title">{t('backup.exportDescription')}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
<!-- Step 1: categories -->
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-num">01</span>
|
||||||
|
<span class="step-label">{t('backup.stepCategories')}</span>
|
||||||
|
<span class="step-count">{totalSelected}</span>
|
||||||
|
</div>
|
||||||
|
<div class="group-grid">
|
||||||
|
{#each categoryGroups as group}
|
||||||
|
{@const state = groupState(group.key)}
|
||||||
|
<div class="group" class:group-all={state === 'all'} class:group-some={state === 'some'}>
|
||||||
|
<button class="group-head" type="button" onclick={() => toggleGroup(group.key)}>
|
||||||
|
<span class="group-icon"><MdiIcon name={group.icon} size={14} /></span>
|
||||||
|
<span class="group-title">{t(group.labelKey)}</span>
|
||||||
|
<span class="group-state">
|
||||||
|
{#if state === 'all'}<MdiIcon name="mdiCheckboxMarked" size={14} />
|
||||||
|
{:else if state === 'some'}<MdiIcon name="mdiMinusBoxOutline" size={14} />
|
||||||
|
{:else}<MdiIcon name="mdiCheckboxBlankOutline" size={14} />{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="chip-row">
|
||||||
|
{#each group.cats as cat}
|
||||||
|
<button class="chip" type="button"
|
||||||
|
class:chip-on={selectedCategories[cat.key]}
|
||||||
|
onclick={() => toggleCat(cat.key)}>
|
||||||
|
{t(cat.labelKey)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: secrets -->
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-num">02</span>
|
||||||
|
<span class="step-label">{t('backup.stepSecrets')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="segmented" role="radiogroup" aria-label={t('backup.secretsMode')}>
|
||||||
|
{#each secretsModes as mode}
|
||||||
|
<button type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={exportSecrets === mode.value}
|
||||||
|
class="seg"
|
||||||
|
class:seg-on={exportSecrets === mode.value}
|
||||||
|
onclick={() => onSecretsChange(mode.value)}>
|
||||||
|
<MdiIcon name={mode.icon} size={14} />
|
||||||
|
<span>{t(mode.labelKey)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if exportSecrets === 'include'}
|
||||||
|
<div class="warn-strip" role="status">
|
||||||
|
<span class="warn-edge" aria-hidden="true"></span>
|
||||||
|
<MdiIcon name="mdiAlertOctagonOutline" size={14} />
|
||||||
|
<span>{t('backup.secretsWarningExport')}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: CTA -->
|
||||||
|
<div class="step step-cta">
|
||||||
|
<Button onclick={onExport} disabled={exporting || noneSelected}>
|
||||||
|
{#if exporting}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiDownload" size={14} />
|
||||||
|
{/if}
|
||||||
|
{exporting ? t('common.loading') : t('backup.exportBtn')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.export-panel {
|
||||||
|
padding: 1.5rem 1.5rem 1.35rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.panel-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
max-width: 36ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.step-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.step-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.step-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.step-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.group-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
.group {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
padding: 0.55rem 0.65rem 0.7rem;
|
||||||
|
transition: border-color 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
.group-all { border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border)); background: color-mix(in srgb, var(--color-primary) 6%, var(--color-glass-strong)); }
|
||||||
|
.group-some { border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border)); }
|
||||||
|
.group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.15rem 0.1rem 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.group-icon { color: var(--color-primary); display: inline-flex; }
|
||||||
|
.group-title {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.group-state {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.group-all .group-state { color: var(--color-primary); }
|
||||||
|
.group-some .group-state { color: var(--color-citrus); }
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.chip:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); }
|
||||||
|
.chip-on {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
|
||||||
|
color: var(--color-foreground);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.segmented { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
.seg {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.25;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
|
||||||
|
}
|
||||||
|
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||||
|
.seg-on {
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 var(--color-highlight),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-strip {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.55rem 0.75rem 0.55rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--color-error-fg);
|
||||||
|
background: color-mix(in srgb, var(--color-error-fg) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, var(--color-border));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.warn-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--color-coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-cta {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
type ConflictMode = 'skip' | 'rename' | 'overwrite';
|
||||||
|
|
||||||
|
interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
entity_counts?: Record<string, number>;
|
||||||
|
warnings?: string[];
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
created?: number;
|
||||||
|
skipped?: number;
|
||||||
|
overwritten?: number;
|
||||||
|
errors?: string[];
|
||||||
|
warnings?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
importFile: File | null;
|
||||||
|
importConflict: ConflictMode;
|
||||||
|
validating: boolean;
|
||||||
|
validationResult: ValidationResult | null;
|
||||||
|
importing: boolean;
|
||||||
|
importResult: ImportResult | null;
|
||||||
|
onFileSelect: (file: File | null) => void;
|
||||||
|
onConflictChange: (mode: ConflictMode) => void;
|
||||||
|
onValidate: () => void;
|
||||||
|
onImport: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
importFile,
|
||||||
|
importConflict,
|
||||||
|
validating,
|
||||||
|
validationResult,
|
||||||
|
importing,
|
||||||
|
importResult,
|
||||||
|
onFileSelect,
|
||||||
|
onConflictChange,
|
||||||
|
onValidate,
|
||||||
|
onImport,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let dragging = $state(false);
|
||||||
|
let inputEl = $state<HTMLInputElement | undefined>();
|
||||||
|
|
||||||
|
const conflictOptions: Array<{ value: ConflictMode; icon: string; labelKey: string }> = [
|
||||||
|
{ value: 'skip', icon: 'mdiSkipNext', labelKey: 'backup.conflictSkip' },
|
||||||
|
{ value: 'rename', icon: 'mdiRename', labelKey: 'backup.conflictRename' },
|
||||||
|
{ value: 'overwrite', icon: 'mdiSync', labelKey: 'backup.conflictOverwrite' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickFile(): void {
|
||||||
|
inputEl?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e: Event): void {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0] ?? null;
|
||||||
|
onFileSelect(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent): void {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
const file = e.dataTransfer?.files?.[0];
|
||||||
|
if (file && (file.name.endsWith('.json') || file.type === 'application/json')) {
|
||||||
|
onFileSelect(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent): void {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(): void {
|
||||||
|
dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityCount = $derived(
|
||||||
|
validationResult?.entity_counts
|
||||||
|
? Object.values(validationResult.entity_counts).reduce<number>((a, b) => a + (b as number), 0)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="import-panel glass">
|
||||||
|
<header class="panel-head">
|
||||||
|
<div class="panel-eyebrow">
|
||||||
|
<MdiIcon name="mdiDatabaseImport" size={14} />
|
||||||
|
<span>{t('backup.import')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="panel-title">{t('backup.importDescription')}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
<!-- Step 1: file -->
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-num">01</span>
|
||||||
|
<span class="step-label">{t('backup.stepFile')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if importFile}
|
||||||
|
<div class="file-pill">
|
||||||
|
<span class="file-icon"><MdiIcon name="mdiCodeJson" size={18} /></span>
|
||||||
|
<div class="file-meta">
|
||||||
|
<div class="file-name" title={importFile.name}>{importFile.name}</div>
|
||||||
|
<div class="file-size">{formatBytes(importFile.size)}</div>
|
||||||
|
</div>
|
||||||
|
<button class="file-change" type="button" onclick={pickFile}>
|
||||||
|
<MdiIcon name="mdiSwapHorizontal" size={14} />
|
||||||
|
<span>{t('backup.changeFile')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button type="button"
|
||||||
|
class="dropzone"
|
||||||
|
class:dropzone-active={dragging}
|
||||||
|
onclick={pickFile}
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={handleDrop}>
|
||||||
|
<span class="dropzone-icon"><MdiIcon name="mdiCloudUploadOutline" size={28} /></span>
|
||||||
|
<span class="dropzone-text">
|
||||||
|
{dragging ? t('backup.dropZoneActive') : t('backup.dropZone')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<input bind:this={inputEl} type="file" accept=".json,application/json"
|
||||||
|
class="visually-hidden" onchange={handleInput} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: validate -->
|
||||||
|
{#if importFile}
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-num">02</span>
|
||||||
|
<span class="step-label">{t('backup.stepValidate')}</span>
|
||||||
|
{#if validationResult}
|
||||||
|
<span class="validate-pill"
|
||||||
|
class:validate-ok={validationResult.valid}
|
||||||
|
class:validate-bad={!validationResult.valid}>
|
||||||
|
<MdiIcon name={validationResult.valid ? 'mdiCheckCircle' : 'mdiCloseCircle'} size={12} />
|
||||||
|
{validationResult.valid ? t('backup.validationPassed') : t('backup.validationFailed')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if !validationResult}
|
||||||
|
<Button variant="secondary" size="sm" onclick={onValidate} disabled={validating}>
|
||||||
|
{#if validating}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiCheckDecagramOutline" size={14} />
|
||||||
|
{/if}
|
||||||
|
{validating ? t('backup.validating') : t('backup.validateBtn')}
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<div class="validate-card" class:validate-card-bad={!validationResult.valid}>
|
||||||
|
{#if entityCount > 0}
|
||||||
|
<div class="validate-summary">
|
||||||
|
<span class="validate-count font-mono">{entityCount}</span>
|
||||||
|
<span class="validate-count-label">{t('backup.entities')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="validate-categories">
|
||||||
|
{#each Object.entries(validationResult.entity_counts ?? {}) as [cat, count]}
|
||||||
|
<span class="validate-cat">
|
||||||
|
<span class="validate-cat-num font-mono">{count}</span>
|
||||||
|
<span class="validate-cat-name">{cat}</span>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if validationResult.warnings?.length}
|
||||||
|
<ul class="validate-list validate-warn">
|
||||||
|
{#each validationResult.warnings as w}
|
||||||
|
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if validationResult.errors?.length}
|
||||||
|
<ul class="validate-list validate-err">
|
||||||
|
{#each validationResult.errors as e}
|
||||||
|
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Step 3: conflict mode -->
|
||||||
|
{#if importFile && validationResult?.valid}
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-num">03</span>
|
||||||
|
<span class="step-label">{t('backup.stepConflict')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="segmented" role="radiogroup" aria-label={t('backup.conflictMode')}>
|
||||||
|
{#each conflictOptions as opt}
|
||||||
|
<button type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={importConflict === opt.value}
|
||||||
|
class="seg"
|
||||||
|
class:seg-on={importConflict === opt.value}
|
||||||
|
onclick={() => onConflictChange(opt.value)}>
|
||||||
|
<MdiIcon name={opt.icon} size={14} />
|
||||||
|
<span>{t(opt.labelKey)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Step 4: CTA + results -->
|
||||||
|
<div class="step step-cta">
|
||||||
|
{#if importFile && !validationResult?.valid && !validating}
|
||||||
|
<div class="cta-hint">
|
||||||
|
<MdiIcon name="mdiInformationOutline" size={12} />
|
||||||
|
<span>{t('backup.validateFirst')}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<Button onclick={onImport} disabled={importing || !importFile || !validationResult?.valid}>
|
||||||
|
{#if importing}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiUpload" size={14} />
|
||||||
|
{/if}
|
||||||
|
{importing ? t('backup.importing') : t('backup.importBtn')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if importResult}
|
||||||
|
<div class="import-results">
|
||||||
|
<div class="result-tiles">
|
||||||
|
<div class="result-tile tile-created">
|
||||||
|
<span class="result-num font-mono">{importResult.created ?? 0}</span>
|
||||||
|
<span class="result-label">{t('backup.resultCreated')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-tile tile-skipped">
|
||||||
|
<span class="result-num font-mono">{importResult.skipped ?? 0}</span>
|
||||||
|
<span class="result-label">{t('backup.resultSkipped')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-tile tile-overwritten">
|
||||||
|
<span class="result-num font-mono">{importResult.overwritten ?? 0}</span>
|
||||||
|
<span class="result-label">{t('backup.resultOverwritten')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if importResult.errors?.length}
|
||||||
|
<ul class="validate-list validate-err">
|
||||||
|
{#each importResult.errors as e}
|
||||||
|
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if importResult.warnings?.length}
|
||||||
|
<ul class="validate-list validate-warn">
|
||||||
|
{#each importResult.warnings as w}
|
||||||
|
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.import-panel {
|
||||||
|
padding: 1.5rem 1.5rem 1.35rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.panel-head { position: relative; z-index: 1; }
|
||||||
|
.panel-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
max-width: 36ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
.step-head { display: flex; align-items: baseline; gap: 0.6rem; }
|
||||||
|
.step-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.step-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop zone */
|
||||||
|
.dropzone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 1.65rem 1.1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1.5px dashed var(--color-rule-strong);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-glass-strong));
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.18s, border-color 0.18s, color 0.18s, transform 0.18s;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
.dropzone:hover {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
|
||||||
|
}
|
||||||
|
.dropzone-active {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-glass-strong));
|
||||||
|
transform: scale(1.005);
|
||||||
|
}
|
||||||
|
.dropzone-icon { color: var(--color-primary); display: inline-flex; }
|
||||||
|
.dropzone-text { line-height: 1.4; max-width: 28ch; }
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
padding: 0; margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0,0,0,0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File pill */
|
||||||
|
.file-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
|
||||||
|
}
|
||||||
|
.file-icon { color: var(--color-primary); flex-shrink: 0; }
|
||||||
|
.file-meta { flex: 1; min-width: 0; }
|
||||||
|
.file-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.file-size {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.file-change {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.32rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.file-change:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||||
|
|
||||||
|
/* Validation */
|
||||||
|
.validate-pill {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.validate-ok {
|
||||||
|
color: var(--color-success-fg);
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-success-fg) 30%, transparent);
|
||||||
|
}
|
||||||
|
.validate-bad {
|
||||||
|
color: var(--color-error-fg);
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validate-card {
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.validate-card-bad {
|
||||||
|
border-color: color-mix(in srgb, var(--color-error-fg) 28%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-error-fg) 6%, var(--color-glass-strong));
|
||||||
|
}
|
||||||
|
.validate-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.validate-count {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.validate-count-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.validate-categories {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.validate-cat {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.18rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
}
|
||||||
|
.validate-cat-num {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.validate-cat-name {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validate-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0; margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.validate-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.validate-warn li { color: var(--color-warning-fg); }
|
||||||
|
.validate-err li { color: var(--color-error-fg); }
|
||||||
|
|
||||||
|
/* Segmented (same vocabulary as ExportPanel) */
|
||||||
|
.segmented {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.segmented { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
.seg {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.25;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
|
||||||
|
}
|
||||||
|
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||||
|
.seg-on {
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 var(--color-highlight),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-cta {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-results {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.result-tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.result-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
}
|
||||||
|
.result-num {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.result-label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.tile-created { border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
|
||||||
|
.tile-created .result-num { color: var(--color-mint); }
|
||||||
|
.tile-skipped { border-color: color-mix(in srgb, var(--color-sky) 30%, var(--color-border)); }
|
||||||
|
.tile-skipped .result-num { color: var(--color-sky); }
|
||||||
|
.tile-overwritten { border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
|
||||||
|
.tile-overwritten .result-num { color: var(--color-citrus); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
interface PendingState {
|
||||||
|
pending: boolean;
|
||||||
|
uploaded_at?: string | null;
|
||||||
|
uploaded_by?: string | null;
|
||||||
|
conflict_mode?: string;
|
||||||
|
supervised?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pending: PendingState | null;
|
||||||
|
onApply: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { pending, onApply, onCancel }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pending?.pending}
|
||||||
|
<div class="pending-strip animate-rise" role="alert">
|
||||||
|
<span class="pending-edge" aria-hidden="true"></span>
|
||||||
|
<span class="aurora-pulse error" aria-hidden="true"></span>
|
||||||
|
<div class="pending-body">
|
||||||
|
<div class="pending-title">
|
||||||
|
<MdiIcon name="mdiShieldAlertOutline" size={16} />
|
||||||
|
<span>{t('backup.pendingTitle')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pending-meta">
|
||||||
|
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '—')}
|
||||||
|
<span class="pending-dot">·</span>
|
||||||
|
{t('backup.pendingAt').replace('{at}', pending.uploaded_at || '—')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pending-actions">
|
||||||
|
{#if pending.supervised}
|
||||||
|
<Button size="sm" onclick={onApply}>
|
||||||
|
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<button class="pending-cancel" onclick={onCancel} type="button">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pending-strip {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.85rem 1.1rem 0.85rem 1.35rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--color-glass);
|
||||||
|
backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow-card),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--color-error-fg) 18%, transparent) inset;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pending-strip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
.pending-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: linear-gradient(180deg, var(--color-coral), color-mix(in srgb, var(--color-coral) 50%, transparent));
|
||||||
|
}
|
||||||
|
.pending-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
.pending-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.pending-meta {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-top: 0.18rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.pending-dot {
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
.pending-actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pending-cancel {
|
||||||
|
padding: 0 0.95rem;
|
||||||
|
height: 34px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.pending-cancel:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||||
|
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalHours: string;
|
||||||
|
secretsMode: string;
|
||||||
|
retentionCount: string;
|
||||||
|
saving: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
enabled,
|
||||||
|
intervalHours = $bindable(),
|
||||||
|
secretsMode = $bindable(),
|
||||||
|
retentionCount = $bindable(),
|
||||||
|
saving,
|
||||||
|
onToggle,
|
||||||
|
onSave,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const intervalItems: GridItem[] = $derived([
|
||||||
|
{ value: '6', icon: 'mdiTimerSand', label: `6 ${t('backup.hours')}` },
|
||||||
|
{ value: '12', icon: 'mdiClockOutline', label: `12 ${t('backup.hours')}` },
|
||||||
|
{ value: '24', icon: 'mdiCalendarToday', label: `24 ${t('backup.hours')}` },
|
||||||
|
{ value: '48', icon: 'mdiCalendarRange', label: `48 ${t('backup.hours')}` },
|
||||||
|
{ value: '72', icon: 'mdiCalendarWeek', label: `72 ${t('backup.hours')}` },
|
||||||
|
{ value: '168', icon: 'mdiCalendarMonth', label: `7d` },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const secretsItems: GridItem[] = $derived([
|
||||||
|
{ value: 'exclude', icon: 'mdiShieldCheckOutline', label: t('backup.secretsExclude') },
|
||||||
|
{ value: 'masked', icon: 'mdiEyeOffOutline', label: t('backup.secretsMasked') },
|
||||||
|
{ value: 'include', icon: 'mdiKeyVariant', label: t('backup.secretsInclude') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const retentionItems: GridItem[] = $derived([
|
||||||
|
{ value: '3', icon: 'mdiNumeric3BoxOutline', label: `3` },
|
||||||
|
{ value: '5', icon: 'mdiNumeric5BoxOutline', label: `5` },
|
||||||
|
{ value: '10', icon: 'mdiLayersTripleOutline', label: `10` },
|
||||||
|
{ value: '20', icon: 'mdiNumeric9PlusBoxOutline', label: `20` },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="cassette glass" class:cassette-on={enabled}>
|
||||||
|
<button class="cassette-toggle" type="button" onclick={onToggle} aria-pressed={enabled}>
|
||||||
|
<span class="toggle-track" class:toggle-on={enabled}>
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</span>
|
||||||
|
<span class="toggle-label">
|
||||||
|
<span class="cassette-eyebrow">
|
||||||
|
<MdiIcon name="mdiClockOutline" size={12} />
|
||||||
|
<span>{t('backup.scheduled')}</span>
|
||||||
|
</span>
|
||||||
|
<span class="cassette-title">{t('backup.enableScheduled')}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if enabled}
|
||||||
|
<div class="cassette-controls">
|
||||||
|
<div class="ctl">
|
||||||
|
<span class="ctl-label">{t('backup.interval')}</span>
|
||||||
|
<IconGridSelect items={intervalItems} bind:value={intervalHours} columns={2} />
|
||||||
|
</div>
|
||||||
|
<div class="ctl">
|
||||||
|
<span class="ctl-label">{t('backup.secretsMode')}</span>
|
||||||
|
<IconGridSelect items={secretsItems} bind:value={secretsMode} columns={1} />
|
||||||
|
</div>
|
||||||
|
<div class="ctl">
|
||||||
|
<span class="ctl-label">{t('backup.retention')}</span>
|
||||||
|
<IconGridSelect items={retentionItems} bind:value={retentionCount} columns={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="cassette-off">{t('backup.scheduleOff')}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="cassette-save">
|
||||||
|
<Button size="sm" variant="secondary" onclick={onSave} disabled={saving}>
|
||||||
|
<MdiIcon name="mdiContentSave" size={14} />
|
||||||
|
{saving ? t('common.loading') : t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cassette {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding: 0.95rem 1.15rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cassette-on { border-color: color-mix(in srgb, var(--color-mint) 30%, var(--color-border)); }
|
||||||
|
|
||||||
|
.cassette-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.2rem 0.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-muted-foreground);
|
||||||
|
transition: transform 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-on {
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
|
||||||
|
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
|
||||||
|
}
|
||||||
|
.toggle-on .toggle-thumb {
|
||||||
|
background: white;
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
|
.cassette-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.cassette-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cassette-controls {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.7rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.cassette-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
.ctl { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
|
||||||
|
.ctl-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cassette-off {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cassette-save {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.cassette-save { width: 100%; }
|
||||||
|
.cassette-save > :global(*) { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
@@ -13,7 +15,6 @@
|
|||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import CrossLink from '$lib/components/CrossLink.svelte';
|
|
||||||
import { chatActionItems } from '$lib/grid-items';
|
import { chatActionItems } from '$lib/grid-items';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import { highlightFromUrl } from '$lib/highlight';
|
import { highlightFromUrl } from '$lib/highlight';
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
|
|
||||||
import TargetForm from './TargetForm.svelte';
|
import TargetForm from './TargetForm.svelte';
|
||||||
import ReceiverSection from './ReceiverSection.svelte';
|
import ReceiverSection from './ReceiverSection.svelte';
|
||||||
|
import BotGroupHeader from './BotGroupHeader.svelte';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
@@ -164,6 +166,20 @@
|
|||||||
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
|
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
|
||||||
let receiverTesting = $state<Record<number, boolean>>({});
|
let receiverTesting = $state<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// Per-target expansion state for the receivers section. Hidden by default.
|
||||||
|
let expandedTargets = $state<Set<number>>(new SvelteSet());
|
||||||
|
|
||||||
|
function isExpanded(id: number): boolean {
|
||||||
|
return expandedTargets.has(id);
|
||||||
|
}
|
||||||
|
function toggleExpanded(id: number) {
|
||||||
|
if (expandedTargets.has(id)) expandedTargets.delete(id);
|
||||||
|
else expandedTargets.add(id);
|
||||||
|
}
|
||||||
|
function expandTarget(id: number) {
|
||||||
|
if (!expandedTargets.has(id)) expandedTargets.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Effects ──
|
// ── Effects ──
|
||||||
|
|
||||||
// Reset form when switching target type tabs
|
// Reset form when switching target type tabs
|
||||||
@@ -179,6 +195,98 @@
|
|||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
|
|
||||||
|
// ── Bot grouping ──
|
||||||
|
|
||||||
|
type TargetGroup = {
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
subtitle: string | null;
|
||||||
|
icon: string;
|
||||||
|
typeBadge: string | null;
|
||||||
|
botHref: string | null;
|
||||||
|
botEntityId: number | null;
|
||||||
|
muted: boolean;
|
||||||
|
targets: NotificationTarget[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOT_TYPES = new Set<string>(['telegram', 'email', 'matrix']);
|
||||||
|
|
||||||
|
const groupedTargets = $derived.by<TargetGroup[]>(() => {
|
||||||
|
const groups = new Map<string, TargetGroup>();
|
||||||
|
for (const tgt of targets) {
|
||||||
|
const isBotType = BOT_TYPES.has(tgt.type);
|
||||||
|
const botId = isBotType ? getBotEntityId(tgt) : null;
|
||||||
|
const key = isBotType
|
||||||
|
? (botId ? `${tgt.type}:${botId}` : `${tgt.type}:nobot`)
|
||||||
|
: `${tgt.type}:direct`;
|
||||||
|
|
||||||
|
let group = groups.get(key);
|
||||||
|
if (!group) {
|
||||||
|
const typeBadge = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
|
||||||
|
let icon = TYPE_ICONS[tgt.type] || 'mdiTarget';
|
||||||
|
let name = '';
|
||||||
|
let subtitle: string | null = null;
|
||||||
|
let muted = false;
|
||||||
|
|
||||||
|
if (isBotType && botId) {
|
||||||
|
if (tgt.type === 'telegram') {
|
||||||
|
const bot = telegramBots.find(b => b.id === botId);
|
||||||
|
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
|
||||||
|
subtitle = bot?.bot_username ? `@${bot.bot_username}` : null;
|
||||||
|
icon = bot?.icon || 'mdiSend';
|
||||||
|
} else if (tgt.type === 'email') {
|
||||||
|
const bot = emailBots.find(b => b.id === botId);
|
||||||
|
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
|
||||||
|
subtitle = bot?.email || null;
|
||||||
|
icon = bot?.icon || 'mdiEmailOutline';
|
||||||
|
} else if (tgt.type === 'matrix') {
|
||||||
|
const bot = matrixBots.find(b => b.id === botId);
|
||||||
|
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
|
||||||
|
subtitle = bot?.display_name || bot?.homeserver_url || null;
|
||||||
|
icon = bot?.icon || 'mdiMatrix';
|
||||||
|
}
|
||||||
|
} else if (isBotType) {
|
||||||
|
name = t('targets.groupNoBot');
|
||||||
|
subtitle = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
|
||||||
|
muted = true;
|
||||||
|
} else {
|
||||||
|
name = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
|
||||||
|
subtitle = t('targets.groupDirect');
|
||||||
|
muted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
group = {
|
||||||
|
key,
|
||||||
|
type: tgt.type,
|
||||||
|
name,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
typeBadge,
|
||||||
|
botHref: isBotType && botId ? getBotHref(tgt) : null,
|
||||||
|
botEntityId: isBotType ? botId : null,
|
||||||
|
muted,
|
||||||
|
targets: [],
|
||||||
|
};
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
group.targets.push(tgt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = (g: TargetGroup) => {
|
||||||
|
if (g.type === 'broadcast') return 4;
|
||||||
|
if (g.muted && BOT_TYPES.has(g.type)) return 2; // bot-type without bot
|
||||||
|
if (g.muted) return 3; // direct delivery (webhook/discord/slack/ntfy)
|
||||||
|
return 1; // bot-linked
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...groups.values()].sort((a, b) => {
|
||||||
|
const ra = rank(a), rb = rank(b);
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const headerPills = $derived.by(() => {
|
const headerPills = $derived.by(() => {
|
||||||
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = [];
|
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = [];
|
||||||
if (activeType) {
|
if (activeType) {
|
||||||
@@ -216,6 +324,16 @@
|
|||||||
} catch (e) { console.warn('Failed to load bot chats:', e); }
|
} catch (e) { console.warn('Failed to load bot chats:', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
|
||||||
|
// Fired when the chat picker opens so the user sees the freshest list without a manual click.
|
||||||
|
async function discoverReceiverBotChats(botId: number) {
|
||||||
|
if (!botId) return;
|
||||||
|
try {
|
||||||
|
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' });
|
||||||
|
receiverBotChats = { ...receiverBotChats, [botId]: data };
|
||||||
|
} catch (e) { console.warn('Failed to discover bot chats:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Target CRUD ──
|
// ── Target CRUD ──
|
||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
@@ -341,15 +459,27 @@
|
|||||||
|
|
||||||
// ── Receiver CRUD ──
|
// ── Receiver CRUD ──
|
||||||
|
|
||||||
function openReceiverForm(targetId: number, targetType: string) {
|
async function openReceiverForm(targetId: number, targetType: string) {
|
||||||
|
// Force a remount of any picker palette when the same target is reopened
|
||||||
|
// after a prior attempt left addingReceiverForTarget unchanged (e.g. save failure).
|
||||||
|
if (addingReceiverForTarget === targetId) {
|
||||||
|
addingReceiverForTarget = null;
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
addingReceiverForTarget = targetId;
|
addingReceiverForTarget = targetId;
|
||||||
|
expandTarget(targetId);
|
||||||
receiverHeadersError = '';
|
receiverHeadersError = '';
|
||||||
if (targetType === 'telegram') {
|
if (targetType === 'telegram') {
|
||||||
receiverForm = { chat_id: '' };
|
receiverForm = { chat_id: '' };
|
||||||
// Load bot chats for the target's bot
|
// Show what we have immediately (cached list), then actively discover in the
|
||||||
|
// background so any newly-added chats appear in the palette as soon as
|
||||||
|
// Telegram returns them.
|
||||||
const tgt = allTargets.find(t => t.id === targetId);
|
const tgt = allTargets.find(t => t.id === targetId);
|
||||||
const botId = tgt?.config?.bot_id;
|
const botId = tgt?.config?.bot_id;
|
||||||
if (botId && !receiverBotChats[botId]) loadReceiverBotChats(botId);
|
if (botId) {
|
||||||
|
if (!receiverBotChats[botId]) loadReceiverBotChats(botId);
|
||||||
|
discoverReceiverBotChats(botId);
|
||||||
|
}
|
||||||
} else if (targetType === 'email') {
|
} else if (targetType === 'email') {
|
||||||
receiverForm = { email: '' };
|
receiverForm = { email: '' };
|
||||||
} else if (targetType === 'webhook') {
|
} else if (targetType === 'webhook') {
|
||||||
@@ -510,32 +640,58 @@
|
|||||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="targets-list">
|
||||||
{#each targets as target (target.id)}
|
{#each groupedTargets as group (group.key)}
|
||||||
|
<section class="target-group">
|
||||||
|
<BotGroupHeader
|
||||||
|
icon={group.icon}
|
||||||
|
name={group.name}
|
||||||
|
subtitle={group.subtitle}
|
||||||
|
targetCount={group.targets.length}
|
||||||
|
typeBadge={!activeType ? group.typeBadge : null}
|
||||||
|
botHref={group.botHref}
|
||||||
|
botEntityId={group.botEntityId}
|
||||||
|
muted={group.muted}
|
||||||
|
/>
|
||||||
|
<div class="target-group__items stagger-children">
|
||||||
|
{#each group.targets as target (target.id)}
|
||||||
|
{@const expanded = isExpanded(target.id)}
|
||||||
|
{@const childCount = target.type === 'broadcast' ? (target.child_targets?.length || 0) : (target.receivers || []).length}
|
||||||
|
{@const childLabel = target.type === 'broadcast' ? t('targets.childTargets') : t('targets.receivers')}
|
||||||
<Card hover entityId={target.id}>
|
<Card hover entityId={target.id}>
|
||||||
<!-- Target header -->
|
<!-- Target header (clickable to toggle receiver visibility) -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div>
|
<button
|
||||||
<div class="flex items-center gap-2">
|
type="button"
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
class="target-summary"
|
||||||
<p class="font-medium">{target.name}</p>
|
aria-expanded={expanded}
|
||||||
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
|
aria-controls={`target-body-${target.id}`}
|
||||||
{#if target.type === 'broadcast' && target.child_targets?.length}
|
onclick={() => toggleExpanded(target.id)}
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.child_targets.length} {t('targets.childTargets')}</span>
|
>
|
||||||
{:else if target.type !== 'broadcast' && (target.receivers || []).length > 0}
|
<span class="target-summary__chevron" class:open={expanded} aria-hidden="true">
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} {t('targets.receivers')}</span>
|
<MdiIcon name="mdiChevronRight" size={16} />
|
||||||
|
</span>
|
||||||
|
<span class="target-summary__icon"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
||||||
|
<span class="target-summary__name">{target.name}</span>
|
||||||
|
{#if childCount > 0}
|
||||||
|
<span class="target-summary__count">
|
||||||
|
<span class="target-summary__count-num">{childCount}</span>
|
||||||
|
<span class="target-summary__count-label">{childLabel}</span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="target-summary__count target-summary__count--empty">{t('targets.noReceivers')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
|
</button>
|
||||||
</div>
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
|
||||||
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
|
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Receivers list -->
|
<!-- Receivers list (collapsible) -->
|
||||||
|
{#if expanded}
|
||||||
|
<div id={`target-body-${target.id}`} transition:slide={{ duration: 180 }}>
|
||||||
<ReceiverSection
|
<ReceiverSection
|
||||||
{target}
|
{target}
|
||||||
typeIcons={TYPE_ICONS}
|
typeIcons={TYPE_ICONS}
|
||||||
@@ -556,9 +712,14 @@
|
|||||||
onchangeReceiverForm={(f) => receiverForm = f}
|
onchangeReceiverForm={(f) => receiverForm = f}
|
||||||
ontoggleBroadcastChild={toggleBroadcastChild}
|
ontoggleBroadcastChild={toggleBroadcastChild}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
@@ -578,3 +739,105 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.targets-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.target-group {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.target-group__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding-left: 0.85rem;
|
||||||
|
border-left: 1px dashed color-mix(in srgb, var(--color-rule-strong) 70%, transparent);
|
||||||
|
margin-left: 0.55rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.target-group__items {
|
||||||
|
padding-left: 0.4rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-summary {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.1rem 0.25rem 0.1rem 0;
|
||||||
|
margin: -0.1rem 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.target-summary:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
}
|
||||||
|
.target-summary:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.target-summary__chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s ease;
|
||||||
|
}
|
||||||
|
.target-summary__chevron.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.target-summary__icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.target-summary__name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.target-summary__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.target-summary__count-num {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.target-summary__count-label {
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.target-summary__count--empty {
|
||||||
|
font-style: italic;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.12rem 0.2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import CrossLink from '$lib/components/CrossLink.svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: string;
|
||||||
|
name: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
targetCount: number;
|
||||||
|
typeBadge?: string | null;
|
||||||
|
botHref?: string | null;
|
||||||
|
botEntityId?: number | null;
|
||||||
|
muted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
icon,
|
||||||
|
name,
|
||||||
|
subtitle = null,
|
||||||
|
targetCount,
|
||||||
|
typeBadge = null,
|
||||||
|
botHref = null,
|
||||||
|
botEntityId = null,
|
||||||
|
muted = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const countLabel = $derived(targetCount === 1 ? t('targets.target') : t('targets.targetsLower'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bot-group-header" class:muted>
|
||||||
|
<div class="bot-avatar">
|
||||||
|
<MdiIcon name={icon} size={18} />
|
||||||
|
</div>
|
||||||
|
<div class="bot-meta">
|
||||||
|
<div class="bot-title-row">
|
||||||
|
<span class="bot-name">{name}</span>
|
||||||
|
{#if typeBadge}
|
||||||
|
<span class="type-badge">{typeBadge}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if subtitle}
|
||||||
|
<span class="bot-sub">{subtitle}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="bot-actions">
|
||||||
|
<span class="count-chip">
|
||||||
|
<span class="count-num">{targetCount}</span>
|
||||||
|
<span class="count-label">{countLabel}</span>
|
||||||
|
</span>
|
||||||
|
{#if botHref}
|
||||||
|
<CrossLink href={botHref} icon="mdiArrowTopRight" label={t('targets.openBot')} entityId={botEntityId ?? undefined} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bot-group-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.6rem 0.95rem 0.6rem 0.75rem;
|
||||||
|
margin: 1.4rem 0 0.55rem 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(
|
||||||
|
95deg,
|
||||||
|
color-mix(in srgb, var(--color-primary) 14%, var(--color-glass)),
|
||||||
|
var(--color-glass) 75%
|
||||||
|
);
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
backdrop-filter: blur(18px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(150%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bot-group-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 12%;
|
||||||
|
bottom: 12%;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--color-primary),
|
||||||
|
color-mix(in srgb, var(--color-primary) 35%, transparent)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.bot-group-header.muted {
|
||||||
|
background: var(--color-glass);
|
||||||
|
}
|
||||||
|
.bot-group-header.muted::before {
|
||||||
|
background: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
.muted .bot-avatar {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.05rem;
|
||||||
|
}
|
||||||
|
.bot-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.bot-name {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.type-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.bot-sub {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.count-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
.count-num {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.count-label {
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-group-header:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -114,9 +114,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Inline add-receiver form -->
|
<!-- Telegram: chat picker palette opens directly from the "Add receiver" button — no inline section. -->
|
||||||
{#if addingReceiverForTarget === target.id}
|
|
||||||
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
|
|
||||||
{#if target.type === 'telegram'}
|
{#if target.type === 'telegram'}
|
||||||
{@const botId = target.config?.bot_id}
|
{@const botId = target.config?.bot_id}
|
||||||
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
|
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
|
||||||
@@ -128,20 +126,25 @@
|
|||||||
disabled: existingKeys.has(c.chat_id),
|
disabled: existingKeys.has(c.chat_id),
|
||||||
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
|
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
|
||||||
}))}
|
}))}
|
||||||
{#if chatItems.length > 0}
|
{#if addingReceiverForTarget === target.id}
|
||||||
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
|
<EntitySelect
|
||||||
{:else}
|
items={chatItems}
|
||||||
<input bind:value={receiverForm.chat_id} placeholder="Chat ID"
|
bind:value={receiverForm.chat_id}
|
||||||
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
open={true}
|
||||||
|
showTrigger={false}
|
||||||
|
placeholder={t('telegramBot.selectChat')}
|
||||||
|
onselect={(v) => { if (v != null && v !== '') onsaveReceiver(target.id); }}
|
||||||
|
onclose={oncancelReceiver}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if botId}
|
<button type="button" onclick={() => onopenReceiverForm(target.id, target.type)}
|
||||||
<button type="button" onclick={() => onloadBotChats(botId)}
|
class="mt-1 flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline cursor-pointer">
|
||||||
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
|
<MdiIcon name="mdiPlus" size={14} />
|
||||||
<MdiIcon name="mdiSync" size={14} />
|
{t('targets.addReceiver')}
|
||||||
{t('telegramBot.discoverChats')}
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{:else if addingReceiverForTarget === target.id}
|
||||||
{:else if target.type === 'email'}
|
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
|
||||||
|
{#if target.type === 'email'}
|
||||||
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
|
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
|
||||||
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
{:else if target.type === 'webhook'}
|
{:else if target.type === 'webhook'}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-core"
|
name = "notify-bridge-core"
|
||||||
version = "0.7.1"
|
version = "0.7.2"
|
||||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-server"
|
name = "notify-bridge-server"
|
||||||
version = "0.7.1"
|
version = "0.7.2"
|
||||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -403,7 +403,13 @@ async def get_command_variables(
|
|||||||
webhook = {
|
webhook = {
|
||||||
"status": {
|
"status": {
|
||||||
"description": "/status webhook provider summary",
|
"description": "/status webhook provider summary",
|
||||||
"variables": {**common_vars, "provider_name": "Webhook provider name", "last_event": "Last event timestamp"},
|
"variables": {
|
||||||
|
**common_vars,
|
||||||
|
"trackers_active": "Number of enabled trackers attached to the webhook provider",
|
||||||
|
"trackers_total": "Total number of trackers attached to the webhook provider",
|
||||||
|
"provider_name": "Webhook provider name",
|
||||||
|
"last_event": "Last event timestamp ('YYYY-MM-DD HH:MM' or '-')",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ def _auto_register() -> None:
|
|||||||
from .gitea_handler import GiteaCommandHandler
|
from .gitea_handler import GiteaCommandHandler
|
||||||
from .planka_handler import PlankaCommandHandler
|
from .planka_handler import PlankaCommandHandler
|
||||||
from .nut_handler import NutCommandHandler
|
from .nut_handler import NutCommandHandler
|
||||||
|
from .webhook_handler import WebhookCommandHandler
|
||||||
|
|
||||||
register_handler(ImmichCommandHandler())
|
register_handler(ImmichCommandHandler())
|
||||||
register_handler(GiteaCommandHandler())
|
register_handler(GiteaCommandHandler())
|
||||||
register_handler(PlankaCommandHandler())
|
register_handler(PlankaCommandHandler())
|
||||||
register_handler(NutCommandHandler())
|
register_handler(NutCommandHandler())
|
||||||
|
register_handler(WebhookCommandHandler())
|
||||||
|
|
||||||
|
|
||||||
# Auto-register on import
|
# Auto-register on import
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Generic webhook provider bot command handler.
|
||||||
|
|
||||||
|
The generic webhook provider has no upstream API to query — its only
|
||||||
|
runtime signal is the stream of incoming webhook payloads recorded as
|
||||||
|
``EventLog`` rows. ``/status`` therefore reports DB-derived stats:
|
||||||
|
|
||||||
|
* ``trackers_active`` — enabled ``NotificationTracker`` rows for the provider
|
||||||
|
* ``trackers_total`` — all ``NotificationTracker`` rows for the provider
|
||||||
|
* ``provider_name`` — the provider's display name
|
||||||
|
* ``last_event`` — formatted timestamp of the most recent received event,
|
||||||
|
or ``-`` if nothing has been received yet
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..database.engine import get_engine
|
||||||
|
from ..database.models import (
|
||||||
|
CommandConfig,
|
||||||
|
CommandTracker,
|
||||||
|
CommandTrackerListener,
|
||||||
|
NotificationTracker,
|
||||||
|
ServiceProvider,
|
||||||
|
TelegramBot,
|
||||||
|
)
|
||||||
|
from .base import CommandResponse, ProviderCommandHandler
|
||||||
|
from .command_utils import get_last_event_str
|
||||||
|
from .handler import _render_cmd_template
|
||||||
|
|
||||||
|
_WEBHOOK_COMMANDS = {"status"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmd_status(provider: ServiceProvider) -> dict[str, Any]:
|
||||||
|
"""Build the context for ``/status`` on a webhook provider."""
|
||||||
|
engine = get_engine()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
result = await session.exec(
|
||||||
|
select(NotificationTracker).where(
|
||||||
|
NotificationTracker.provider_id == provider.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
trackers = list(result.all())
|
||||||
|
|
||||||
|
active = [t for t in trackers if t.enabled]
|
||||||
|
last_str = await get_last_event_str([t.id for t in active])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trackers_active": len(active),
|
||||||
|
"trackers_total": len(trackers),
|
||||||
|
"provider_name": provider.name or "",
|
||||||
|
"last_event": last_str,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookCommandHandler(ProviderCommandHandler):
|
||||||
|
"""Handles ``/status`` for generic-webhook providers."""
|
||||||
|
|
||||||
|
provider_type = "webhook"
|
||||||
|
|
||||||
|
def get_provider_commands(self) -> set[str]:
|
||||||
|
return _WEBHOOK_COMMANDS
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
args: str,
|
||||||
|
count: int,
|
||||||
|
locale: str,
|
||||||
|
response_mode: str,
|
||||||
|
provider: ServiceProvider,
|
||||||
|
cmd_templates: dict[str, dict[str, str]],
|
||||||
|
bot: TelegramBot,
|
||||||
|
tracker: CommandTracker,
|
||||||
|
config: CommandConfig,
|
||||||
|
*,
|
||||||
|
listener: CommandTrackerListener | None = None,
|
||||||
|
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — webhook has no album scope
|
||||||
|
page: int = 1,
|
||||||
|
) -> CommandResponse | None:
|
||||||
|
if cmd != "status":
|
||||||
|
return None
|
||||||
|
ctx = await _cmd_status(provider)
|
||||||
|
return CommandResponse(
|
||||||
|
text=_render_cmd_template(cmd_templates, "status", locale, ctx),
|
||||||
|
)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Tests for the generic-webhook ``/status`` command handler.
|
||||||
|
|
||||||
|
The webhook provider has no upstream API, so ``/status`` is built from
|
||||||
|
local DB stats:
|
||||||
|
|
||||||
|
* ``trackers_active`` — count of enabled ``NotificationTracker`` rows
|
||||||
|
* ``trackers_total`` — count of all ``NotificationTracker`` rows
|
||||||
|
* ``last_event`` — formatted timestamp of the most recent ``EventLog`` row
|
||||||
|
tied to one of the provider's trackers, or ``-`` when there are none
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
_STATUS_TEMPLATE_EN = (
|
||||||
|
"Webhook Status\n"
|
||||||
|
"Trackers active: {{ trackers_active }}/{{ trackers_total }}\n"
|
||||||
|
"Provider: {{ provider_name }}\n"
|
||||||
|
"Last event: {{ last_event }}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap_app():
|
||||||
|
"""Bring up the app once so migrations run against the temp DB."""
|
||||||
|
from notify_bridge_server.main import app
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_user() -> int:
|
||||||
|
from notify_bridge_server.database.engine import get_engine
|
||||||
|
from notify_bridge_server.database.models import User
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
user = User(username=f"u_{datetime.now(timezone.utc).timestamp()}", hashed_password="x")
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_provider(user_id: int, name: str = "WH") -> int:
|
||||||
|
from notify_bridge_server.database.engine import get_engine
|
||||||
|
from notify_bridge_server.database.models import ServiceProvider
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
prov = ServiceProvider(user_id=user_id, type="webhook", name=name, config={})
|
||||||
|
session.add(prov)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(prov)
|
||||||
|
return prov.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_tracker(user_id: int, provider_id: int, name: str, *, enabled: bool) -> int:
|
||||||
|
from notify_bridge_server.database.engine import get_engine
|
||||||
|
from notify_bridge_server.database.models import NotificationTracker
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
tr = NotificationTracker(
|
||||||
|
user_id=user_id, provider_id=provider_id, name=name, enabled=enabled,
|
||||||
|
)
|
||||||
|
session.add(tr)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(tr)
|
||||||
|
return tr.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_event(tracker_id: int, when: datetime) -> None:
|
||||||
|
from notify_bridge_server.database.engine import get_engine
|
||||||
|
from notify_bridge_server.database.models import EventLog
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
session.add(EventLog(
|
||||||
|
tracker_id=tracker_id,
|
||||||
|
tracker_name="webhook-tr",
|
||||||
|
event_type="webhook_received",
|
||||||
|
collection_id="",
|
||||||
|
collection_name="",
|
||||||
|
created_at=when,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_provider(provider_id: int):
|
||||||
|
from notify_bridge_server.database.engine import get_engine
|
||||||
|
from notify_bridge_server.database.models import ServiceProvider
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
return await session.get(ServiceProvider, provider_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_registers_webhook_handler(tmp_data_dir) -> None: # noqa: ARG001
|
||||||
|
"""Auto-registration must wire the webhook handler to provider type 'webhook'."""
|
||||||
|
from notify_bridge_server.commands.dispatch import get_handler
|
||||||
|
|
||||||
|
handler = get_handler("webhook")
|
||||||
|
assert handler is not None, "WebhookCommandHandler must be registered"
|
||||||
|
assert handler.provider_type == "webhook"
|
||||||
|
assert "status" in handler.get_provider_commands()
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_renders_active_total_and_last_event(tmp_data_dir) -> None: # noqa: ARG001
|
||||||
|
"""``/status`` returns active/total counts and formatted last-event timestamp."""
|
||||||
|
from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler
|
||||||
|
|
||||||
|
app = _bootstrap_app()
|
||||||
|
with TestClient(app):
|
||||||
|
async def run() -> None:
|
||||||
|
user_id = await _seed_user()
|
||||||
|
provider_id = await _seed_provider(user_id, "WH1")
|
||||||
|
enabled_id = await _seed_tracker(user_id, provider_id, "tr-on", enabled=True)
|
||||||
|
await _seed_tracker(user_id, provider_id, "tr-off", enabled=False)
|
||||||
|
event_at = datetime(2026, 5, 1, 12, 34, tzinfo=timezone.utc)
|
||||||
|
await _seed_event(enabled_id, event_at)
|
||||||
|
|
||||||
|
provider = await _load_provider(provider_id)
|
||||||
|
handler = WebhookCommandHandler()
|
||||||
|
templates = {"status": {"en": _STATUS_TEMPLATE_EN}}
|
||||||
|
response = await handler.handle(
|
||||||
|
cmd="status", args="", count=5, locale="en",
|
||||||
|
response_mode="text", provider=provider,
|
||||||
|
cmd_templates=templates,
|
||||||
|
bot=None, tracker=None, config=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.text is not None
|
||||||
|
text = response.text
|
||||||
|
assert "Trackers active: 1/2" in text
|
||||||
|
assert "Provider: WH1" in text
|
||||||
|
assert "2026-05-01 12:34" in text
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_with_no_events_shows_dash(tmp_data_dir) -> None: # noqa: ARG001
|
||||||
|
"""Zero events → ``last_event`` renders as '-' (the get_last_event_str sentinel)."""
|
||||||
|
from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler
|
||||||
|
|
||||||
|
app = _bootstrap_app()
|
||||||
|
with TestClient(app):
|
||||||
|
async def run() -> None:
|
||||||
|
user_id = await _seed_user()
|
||||||
|
provider_id = await _seed_provider(user_id, "WH-empty")
|
||||||
|
provider = await _load_provider(provider_id)
|
||||||
|
|
||||||
|
handler = WebhookCommandHandler()
|
||||||
|
templates = {"status": {"en": _STATUS_TEMPLATE_EN}}
|
||||||
|
response = await handler.handle(
|
||||||
|
cmd="status", args="", count=5, locale="en",
|
||||||
|
response_mode="text", provider=provider,
|
||||||
|
cmd_templates=templates,
|
||||||
|
bot=None, tracker=None, config=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert "Trackers active: 0/0" in response.text
|
||||||
|
assert "Last event: -" in response.text
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_returns_none_for_unknown_command(tmp_data_dir) -> None: # noqa: ARG001
|
||||||
|
"""Commands the webhook handler doesn't own must return None (lets dispatch fall through)."""
|
||||||
|
from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler
|
||||||
|
|
||||||
|
app = _bootstrap_app()
|
||||||
|
with TestClient(app):
|
||||||
|
async def run() -> None:
|
||||||
|
user_id = await _seed_user()
|
||||||
|
provider_id = await _seed_provider(user_id, "WH-noop")
|
||||||
|
provider = await _load_provider(provider_id)
|
||||||
|
|
||||||
|
handler = WebhookCommandHandler()
|
||||||
|
response = await handler.handle(
|
||||||
|
cmd="albums", args="", count=5, locale="en",
|
||||||
|
response_mode="text", provider=provider,
|
||||||
|
cmd_templates={},
|
||||||
|
bot=None, tracker=None, config=None,
|
||||||
|
)
|
||||||
|
assert response is None
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
Reference in New Issue
Block a user