feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events

Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
  allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
  through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
  / search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
  to POST /api/search/metadata with personIds (fixes /person command and
  auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
  image when missing (falls back to any asset type); failures do not fail the
  rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
  + backfill. Status query filters by user_id directly; Immich/webhook paths
  emit user_id explicitly. action_runner writes an action_success/partial/
  failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
  tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
  (ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
  pending_restore.json; lifespan hook applies on next startup and archives
  under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
  shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
  Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
  (limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
  TelegramChat.language_override per chat instead of applying the first
  receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
  and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
  save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
  deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
  track_assets_removed default False.

Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
  labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
  create forms (trackers, command-trackers, targets, template/command
  configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
  multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
  restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
  inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.

Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
  notification_tracker).
- command_tracker_listener: + allowed_album_ids.
This commit is contained in:
2026-04-22 01:13:11 +03:00
parent b5ffab7ece
commit a7a2b4efa4
57 changed files with 2452 additions and 335 deletions
+96 -1
View File
@@ -11,6 +11,37 @@ export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
return fallback;
}
/** Structured 409 blocked-by payload attached to ApiError.blockedBy. */
export interface BlockedByDetail {
message: string;
entity: string;
blocked_by: string[];
}
export class ApiError extends Error {
status: number;
blockedBy?: BlockedByDetail;
constructor(message: string, status: number, blockedBy?: BlockedByDetail) {
super(message);
this.name = 'ApiError';
this.status = status;
this.blockedBy = blockedBy;
}
}
/** Parse a server-issued datetime string as UTC (appends Z if no timezone info present). */
export function parseDate(dateStr: string): Date {
if (!dateStr) return new Date(NaN);
if (!/Z$|[+-]\d{2}:?\d{2}$/.test(dateStr)) return new Date(dateStr + 'Z');
return new Date(dateStr);
}
/** If the thrown error was a structured 409 from delete_protection, return its payload. */
export function getBlockedBy(err: unknown): BlockedByDetail | null {
if (err instanceof ApiError && err.blockedBy) return err.blockedBy;
return null;
}
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token');
@@ -106,7 +137,17 @@ export async function api<T = any>(
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
// Structured blocked-by detail (from delete_protection.raise_if_used)
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
const bb: BlockedByDetail = {
message: err.detail.message || `HTTP ${res.status}`,
entity: err.detail.entity || '',
blocked_by: err.detail.blocked_by,
};
throw new ApiError(bb.message, res.status, bb);
}
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
throw new ApiError(msg, res.status);
}
return res.json();
@@ -114,3 +155,57 @@ export async function api<T = any>(
clearTimeout(timeout);
}
}
/**
* Auth-aware ``fetch`` wrapper for calls that can't go through ``api()`` —
* typically multipart/form-data uploads or binary downloads where we need the
* raw ``Response`` object rather than parsed JSON.
*
* - Injects the Bearer token automatically.
* - Does NOT set ``Content-Type`` (the caller's body — e.g. ``FormData`` —
* decides the encoding; browsers add the boundary).
* - Attempts a one-shot token refresh on 401, matching ``api()``.
* - Translates non-OK responses to ``ApiError`` so callers can use the same
* ``getBlockedBy`` / ``err.message`` handling pattern.
*/
export async function fetchAuth(
path: string,
options: RequestInit = {},
): Promise<Response> {
const token = getToken();
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
let res = await fetch(url, { ...options, headers });
if (res.status === 401 && token) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${getToken()}`;
res = await fetch(url, { ...options, headers });
}
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') window.location.href = '/login';
throw new ApiError('Unauthorized', 401);
}
if (!res.ok) {
const err = await res.clone().json().catch(() => ({ detail: res.statusText }));
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
const bb: BlockedByDetail = {
message: err.detail.message || `HTTP ${res.status}`,
entity: err.detail.entity || '',
blocked_by: err.detail.blocked_by,
};
throw new ApiError(bb.message, res.status, bb);
}
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
throw new ApiError(msg, res.status);
}
return res;
}
@@ -0,0 +1,74 @@
<script lang="ts">
import Modal from './Modal.svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import type { BlockedByDetail } from '$lib/api';
let { open = false, detail = null, onclose } = $props<{
open: boolean;
detail: BlockedByDetail | null;
onclose: () => void;
}>();
const blockedCount = $derived(detail?.blocked_by?.length ?? 0);
</script>
<Modal {open} title={t('common.cannotDelete')} onclose={onclose}>
{#if detail}
<div class="flex items-start gap-3 mb-4">
<div class="flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0"
style="background: var(--color-error-bg); color: var(--color-error-fg);">
<MdiIcon name="mdiLinkVariant" size={20} />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium mb-1 break-words">{detail.message}</p>
{#if detail.entity}
<p class="text-xs break-all" style="color: var(--color-muted-foreground);">{detail.entity}</p>
{/if}
</div>
</div>
<div class="flex items-center justify-between mb-2">
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('common.blockedByIntro')}</p>
{#if blockedCount > 0}
<span class="text-[0.65rem] font-mono px-1.5 py-0.5 rounded"
style="background: var(--color-muted); color: var(--color-muted-foreground);">
{blockedCount}
</span>
{/if}
</div>
{#if blockedCount > 0}
<ul class="space-y-1.5 max-h-64 overflow-y-auto pr-1 mb-5">
{#each detail.blocked_by as consumer}
<li class="flex items-start gap-2 text-sm px-3 py-2 rounded-md"
style="background: var(--color-muted); border: 1px solid var(--color-border);">
<span class="flex-shrink-0 mt-0.5" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiChevronRight" size={14} />
</span>
<span class="font-mono text-xs break-all min-w-0 flex-1">{consumer}</span>
</li>
{/each}
</ul>
{/if}
{/if}
<div class="flex justify-end">
<button onclick={onclose} class="blocked-by-close-btn">
{t('common.close')}
</button>
</div>
</Modal>
<style>
.blocked-by-close-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-foreground);
cursor: pointer;
transition: all 0.2s ease;
}
.blocked-by-close-btn:hover {
background: var(--color-muted);
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { parseDate } from '$lib/api';
import MdiIcon from './MdiIcon.svelte';
interface DayData {
@@ -47,7 +48,7 @@
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
const d = parseDate(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
+3
View File
@@ -89,6 +89,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
{ value: 'collection_renamed', icon: 'mdiRename', label: t('dashboard.filterRenamed'), desc: t('gridDesc.renamed') },
{ value: 'collection_deleted', icon: 'mdiDeleteAlert', label: t('dashboard.filterDeleted'), desc: t('gridDesc.deleted') },
{ value: 'sharing_changed', icon: 'mdiShareVariant', label: t('dashboard.filterSharingChanged'), desc: t('gridDesc.sharingChanged') },
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
];
// --- Sort filter (dashboard) ---
+39 -2
View File
@@ -64,6 +64,8 @@
"activeTrackers": "Active Trackers",
"targets": "Targets",
"recentEvents": "Events",
"clearEvents": "Clear",
"confirmClearEvents": "Delete all event log entries? This cannot be undone.",
"chart": "Event chart",
"noEvents": "No events yet. Create a tracker to start monitoring.",
"loading": "Loading...",
@@ -76,6 +78,9 @@
"collectionRenamed": "collection renamed",
"collectionDeleted": "collection deleted",
"sharingChanged": "sharing changed",
"actionSuccess": "action run",
"actionPartial": "action partial",
"actionFailed": "action failed",
"searchEvents": "Search events...",
"allEvents": "All Events",
"filterAssetsAdded": "Assets Added",
@@ -83,6 +88,9 @@
"filterRenamed": "Renamed",
"filterDeleted": "Deleted",
"filterSharingChanged": "Sharing Changed",
"filterActionSuccess": "Action Success",
"filterActionPartial": "Action Partial",
"filterActionFailed": "Action Failed",
"allProviders": "All Providers",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
@@ -365,6 +373,7 @@
"roleAdmin": "Admin",
"create": "Create User",
"delete": "Delete",
"edit": "Edit user",
"confirmDelete": "Delete this user?",
"joined": "joined",
"noUsers": "No users found"
@@ -785,13 +794,21 @@
"disabled": "Disabled",
"noListeners": "No listeners attached.",
"selectBot": "Select bot...",
"listenerType": "telegram_bot"
"listenerType": "telegram_bot",
"editScope": "Edit album scope",
"scopeAll": "all albums",
"albumsShort": "albums",
"scopeTitle": "Album Scope for This Chat",
"scopeDescription": "Restrict which tracked albums this chat can query via commands. Leave on \"inherit\" to allow all albums from the tracker.",
"scopeInherit": "Inherit: allow all tracked albums",
"noCollections": "No albums available."
},
"snackbar": {
"showDetails": "Show details",
"hideDetails": "Hide details"
},
"snack": {
"eventsCleared": "{count} event(s) cleared",
"providerSaved": "Provider saved",
"providerDeleted": "Provider deleted",
"trackerCreated": "Tracker created",
@@ -810,6 +827,7 @@
"botDeleted": "Bot deleted",
"userCreated": "User created",
"userDeleted": "User deleted",
"userUpdated": "User updated",
"passwordChanged": "Password changed",
"copied": "Copied to clipboard",
"genericError": "Something went wrong",
@@ -827,6 +845,7 @@
"commandTrackerDisabled": "Command tracker disabled",
"listenerAdded": "Listener added",
"listenerRemoved": "Listener removed",
"listenerScopeSaved": "Scope updated",
"cmdTemplateSaved": "Command template saved",
"cmdTemplateDeleted": "Command template deleted",
"emailBotCreated": "Email bot created",
@@ -848,6 +867,8 @@
"description": "Description",
"close": "Close",
"confirm": "Confirm",
"cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:",
"error": "Error",
"success": "Success",
"none": "None",
@@ -960,6 +981,9 @@
"renamed": "Album was renamed",
"deleted": "Album was deleted",
"sharingChanged": "Album sharing toggled",
"actionSuccess": "Scheduled action completed",
"actionPartial": "Scheduled action partially succeeded",
"actionFailed": "Scheduled action failed",
"newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown",
@@ -1021,6 +1045,7 @@
"name": "Name",
"schedule": "Schedule",
"interval": "Interval",
"cronMode": "Cron expression",
"seconds": "seconds",
"cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)",
"enabled": "Enabled",
@@ -1126,6 +1151,18 @@
"savedFiles": "Saved Backups",
"noFiles": "No backup files yet.",
"download": "Download",
"fileDeleted": "Backup file deleted"
"fileDeleted": "Backup file deleted",
"createManual": "Create backup",
"manualCreated": "Backup created",
"pendingTitle": "Restore pending — restart to apply",
"pendingBy": "Uploaded by {by}",
"pendingAt": "at {at}",
"pendingCancelled": "Pending restore cancelled",
"restorePrepared": "Restore prepared",
"restoreApplyPrompt": "Apply the restore now (the backend will restart) or later on the next natural restart?",
"applyLater": "Apply later",
"restartNow": "Restart now",
"restartingTitle": "Restarting backend…",
"restartingDescription": "The page will reload once the server is back online."
}
}
+39 -2
View File
@@ -64,6 +64,8 @@
"activeTrackers": "Активные трекеры",
"targets": "Получатели",
"recentEvents": "События",
"clearEvents": "Очистить",
"confirmClearEvents": "Удалить все записи журнала событий? Это действие нельзя отменить.",
"chart": "График событий",
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
"loading": "Загрузка...",
@@ -76,6 +78,9 @@
"collectionRenamed": "альбом переименован",
"collectionDeleted": "альбом удалён",
"sharingChanged": "изменение доступа",
"actionSuccess": "действие выполнено",
"actionPartial": "действие частично",
"actionFailed": "действие провалено",
"searchEvents": "Поиск событий...",
"allEvents": "Все события",
"filterAssetsAdded": "Добавление файлов",
@@ -83,6 +88,9 @@
"filterRenamed": "Переименование",
"filterDeleted": "Удаление",
"filterSharingChanged": "Изменение доступа",
"filterActionSuccess": "Действие выполнено",
"filterActionPartial": "Действие частично",
"filterActionFailed": "Действие провалено",
"allProviders": "Все провайдеры",
"newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые",
@@ -365,6 +373,7 @@
"roleAdmin": "Администратор",
"create": "Создать",
"delete": "Удалить",
"edit": "Редактировать пользователя",
"confirmDelete": "Удалить этого пользователя?",
"joined": "зарегистрирован",
"noUsers": "Пользователи не найдены"
@@ -785,13 +794,21 @@
"disabled": "Отключён",
"noListeners": "Нет подключённых слушателей.",
"selectBot": "Выберите бота...",
"listenerType": "telegram_bot"
"listenerType": "telegram_bot",
"editScope": "Изменить область альбомов",
"scopeAll": "все альбомы",
"albumsShort": "альбомов",
"scopeTitle": "Область альбомов для этого чата",
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.",
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы",
"noCollections": "Нет доступных альбомов."
},
"snackbar": {
"showDetails": "Показать детали",
"hideDetails": "Скрыть детали"
},
"snack": {
"eventsCleared": "Очищено событий: {count}",
"providerSaved": "Провайдер сохранён",
"providerDeleted": "Провайдер удалён",
"trackerCreated": "Трекер создан",
@@ -810,6 +827,7 @@
"botDeleted": "Бот удалён",
"userCreated": "Пользователь создан",
"userDeleted": "Пользователь удалён",
"userUpdated": "Пользователь обновлён",
"passwordChanged": "Пароль изменён",
"copied": "Скопировано",
"genericError": "Что-то пошло не так",
@@ -827,6 +845,7 @@
"commandTrackerDisabled": "Трекер команд отключён",
"listenerAdded": "Слушатель добавлен",
"listenerRemoved": "Слушатель удалён",
"listenerScopeSaved": "Область обновлена",
"cmdTemplateSaved": "Шаблон команд сохранён",
"cmdTemplateDeleted": "Шаблон команд удалён",
"emailBotCreated": "Email бот создан",
@@ -848,6 +867,8 @@
"description": "Описание",
"close": "Закрыть",
"confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:",
"error": "Ошибка",
"success": "Успешно",
"none": "Нет",
@@ -960,6 +981,9 @@
"renamed": "Альбом переименован",
"deleted": "Альбом удалён",
"sharingChanged": "Изменён доступ к альбому",
"actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено",
"newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается",
@@ -1021,6 +1045,7 @@
"name": "Название",
"schedule": "Расписание",
"interval": "Интервал",
"cronMode": "Cron выражение",
"seconds": "секунд",
"cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)",
"enabled": "Включено",
@@ -1126,6 +1151,18 @@
"savedFiles": "Сохранённые бэкапы",
"noFiles": "Файлов бэкапа пока нет.",
"download": "Скачать",
"fileDeleted": "Файл бэкапа удалён"
"fileDeleted": "Файл бэкапа удалён",
"createManual": "Создать бэкап",
"manualCreated": "Бэкап создан",
"pendingTitle": "Восстановление ожидает — перезапустите для применения",
"pendingBy": "Загружено пользователем {by}",
"pendingAt": "в {at}",
"pendingCancelled": "Ожидающее восстановление отменено",
"restorePrepared": "Восстановление подготовлено",
"restoreApplyPrompt": "Применить восстановление сейчас (бэкенд перезапустится) или позже при следующем штатном перезапуске?",
"applyLater": "Применить позже",
"restartNow": "Перезапустить сейчас",
"restartingTitle": "Перезапуск бэкенда…",
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
}
}
+17 -1
View File
@@ -105,7 +105,10 @@ export const immichDescriptor: ProviderDescriptor = {
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
const warnings: { id: string; name: string; issue: string }[] = [];
for (const albumId of newIds) {
// Run shared-link checks in parallel with a concurrency cap so a large
// album set doesn't stall the save button for seconds.
const CONCURRENCY = 6;
async function checkOne(albumId: string): Promise<void> {
try {
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
@@ -123,6 +126,19 @@ export const immichDescriptor: ProviderDescriptor = {
} catch { /* shared-link check failed, proceed */ }
}
const queue = [...newIds];
const workers: Promise<void>[] = [];
for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) {
workers.push((async () => {
while (queue.length > 0) {
const next = queue.shift();
if (next === undefined) return;
await checkOne(next);
}
})());
}
await Promise.all(workers);
if (warnings.length > 0) return { warnings, proceed: false };
return { proceed: true };
},
+2 -2
View File
@@ -384,7 +384,7 @@
</div>
<!-- Global provider filter -->
{#if allProviders.length > 1}
{#if allProviders.length >= 1}
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
{#if collapsed}
<button onclick={() => {
@@ -564,7 +564,7 @@
onclick={() => mobileMoreOpen = false} role="presentation"></div>
<div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;"
transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length > 1}
{#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
</div>
+44 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -11,6 +11,8 @@
import EventChart from '$lib/components/EventChart.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
@@ -56,6 +58,21 @@
let eventsLimit = $state(loadEventsPerPage());
let eventsOffset = $state(0);
let eventsLoading = $state(false);
let confirmClearEvents = $state(false);
async function clearEvents() {
try {
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
snackSuccess(t('snack.eventsCleared').replace('{count}', String(res.deleted)));
eventsOffset = 0;
await loadEvents();
await loadChart();
} catch (err: any) {
snackError(err.message || t('common.error'));
} finally {
confirmClearEvents = false;
}
}
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
@@ -191,7 +208,7 @@
] : []);
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const diff = Date.now() - parseDate(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return t('dashboard.justNow');
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
@@ -206,15 +223,20 @@
collection_renamed: 'dashboard.collectionRenamed',
collection_deleted: 'dashboard.collectionDeleted',
sharing_changed: 'dashboard.sharingChanged',
action_success: 'dashboard.actionSuccess',
action_partial: 'dashboard.actionPartial',
action_failed: 'dashboard.actionFailed',
};
const eventIcons: Record<string, string> = {
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
};
const eventColors: Record<string, string> = {
assets_added: '#059669', assets_removed: '#ef4444',
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
};
</script>
@@ -252,13 +274,23 @@
</div>
<!-- Events section -->
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
<MdiIcon name="mdiPulse" size={18} />
{t('dashboard.recentEvents')}
<div class="flex items-center justify-between mb-3">
<h3 class="text-base font-semibold flex items-center gap-2">
<MdiIcon name="mdiPulse" size={18} />
{t('dashboard.recentEvents')}
{#if status.total_events > 0}
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
{/if}
</h3>
{#if status.total_events > 0}
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
<button type="button" onclick={() => confirmClearEvents = true}
class="clear-events-btn flex items-center gap-1.5 px-2.5 py-1 text-xs border border-[var(--color-border)] rounded-md transition-colors"
title={t('dashboard.clearEvents')}>
<MdiIcon name="mdiTrashCanOutline" size={14} />
<span class="hidden sm:inline">{t('dashboard.clearEvents')}</span>
</button>
{/if}
</h3>
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-2 mb-4">
@@ -370,6 +402,9 @@
{/if}
{/if}
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
<style>
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
@@ -385,4 +420,6 @@
.event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
.clear-events-btn { color: var(--color-muted-foreground); background: transparent; }
.clear-events-btn:hover { background: color-mix(in srgb, var(--color-error-fg) 10%, transparent); border-color: color-mix(in srgb, var(--color-error-fg) 40%, var(--color-border)); color: var(--color-error-fg); }
</style>
@@ -1,5 +1,5 @@
<script lang="ts">
import { api } from '$lib/api';
import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import type { ActionExecution } from '$lib/types';
@@ -47,14 +47,14 @@
function formatDate(iso: string | null): string {
if (!iso) return '-';
try {
return new Date(iso).toLocaleString();
return parseDate(iso).toLocaleString();
} catch { return iso; }
}
function formatDuration(start: string, end: string | null): string {
if (!end) return '-';
try {
const ms = new Date(end).getTime() - new Date(start).getTime();
const ms = parseDate(end).getTime() - parseDate(start).getTime();
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
} catch { return '-'; }
+10 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { emailBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -58,12 +59,17 @@
finally { emailSubmitting = false; }
}
let blockedBy = $state<BlockedByDetail | null>(null);
function removeEmail(id: number) {
confirmDeleteEmail = {
id,
onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDeleteEmail = null; }
}
};
@@ -173,3 +179,5 @@
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
+10 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -56,12 +57,17 @@
finally { matrixSubmitting = false; }
}
let blockedBy = $state<BlockedByDetail | null>(null);
function removeMatrix(id: number) {
confirmDeleteMatrix = {
id,
onconfirm: async () => {
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDeleteMatrix = null; }
}
};
@@ -155,3 +161,5 @@
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
+10 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { telegramBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -64,12 +65,17 @@
finally { submitting = false; }
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDelete = null; }
}
};
@@ -518,3 +524,5 @@
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -92,7 +93,10 @@
function openNew() {
form = defaultForm();
// Auto-select first matching template for the default provider_type
// Auto-select first provider type with commands
const types = Object.keys(allCapabilities).filter(t => (allCapabilities[t]?.commands?.length || 0) > 0);
if (types.length > 0) form.provider_type = types[0];
// Auto-select first matching template for the chosen provider_type
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
if (match) form.command_template_config_id = match.id;
editing = null;
@@ -137,6 +141,7 @@
finally { submitting = false; }
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(cfg: CommandConfig) {
confirmDelete = {
id: cfg.id,
@@ -145,7 +150,11 @@
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandConfigDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
snackError(err.message);
}
finally { confirmDelete = null; }
}
};
@@ -296,3 +305,5 @@
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize';
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
@@ -189,6 +190,8 @@
function openNew() {
form = defaultForm();
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
editing = null;
showForm = true;
activeLocale = 'en';
@@ -265,6 +268,7 @@
setTimeout(() => refreshAllPreviews(), 100);
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
id,
@@ -274,6 +278,8 @@
await load();
snackSuccess(t('snack.cmdTemplateDeleted'));
} catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
} finally {
@@ -458,6 +464,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
@@ -11,6 +11,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Modal from '$lib/components/Modal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
@@ -83,7 +84,17 @@
finally { loaded = true; highlightFromUrl(); }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; }
function openNew() {
form = defaultForm();
if (providers.length > 0) form.provider_id = providers[0].id;
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
if (ptype) {
const firstCfg = commandConfigs.find(c => c.provider_type === ptype);
if (firstCfg) form.command_config_id = firstCfg.id;
}
editing = null;
showForm = true;
}
function editTracker(trk: any) {
form = {
name: trk.name,
@@ -178,6 +189,35 @@
} catch (err: any) { snackError(err.message); }
}
// Per-listener album scope editing
let scopeEditor = $state<{ trkId: number; listener: any; providerId: number; collections: any[]; selectedIds: string[]; inherit: boolean } | null>(null);
async function openScopeEditor(trkId: number, listener: any) {
const trk = allCmdTrackers.find((t: any) => t.id === trkId);
if (!trk) return;
let collections: any[] = [];
try { collections = await api(`/providers/${trk.provider_id}/collections`); } catch { /* ignore */ }
scopeEditor = {
trkId,
listener,
providerId: trk.provider_id,
collections,
selectedIds: [...(listener.allowed_album_ids || [])],
inherit: listener.allowed_album_ids === null || listener.allowed_album_ids === undefined,
};
}
async function saveScope() {
if (!scopeEditor) return;
const body = { allowed_album_ids: scopeEditor.inherit ? null : scopeEditor.selectedIds };
try {
await api(`/command-trackers/${scopeEditor.trkId}/listeners/${scopeEditor.listener.id}`, {
method: 'PATCH', body: JSON.stringify(body),
});
snackSuccess(t('snack.listenerScopeSaved'));
await loadListeners(scopeEditor.trkId);
scopeEditor = null;
} catch (err: any) { snackError(err.message); }
}
function providerName(id: number): string {
return providers.find(p => p.id === id)?.name || '?';
}
@@ -289,10 +329,18 @@
<div class="space-y-1">
{#each listeners[trk.id] as listener}
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 min-w-0">
<MdiIcon name="mdiRobot" size={14} />
<CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-mono">{listener.listener_type}</span>
<button type="button" onclick={() => openScopeEditor(trk.id, listener)}
class="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]"
title={t('commandTracker.editScope')}>
<MdiIcon name="mdiImageMultiple" size={12} />
{listener.allowed_album_ids === null || listener.allowed_album_ids === undefined
? t('commandTracker.scopeAll')
: `${(listener.allowed_album_ids || []).length} ${t('commandTracker.albumsShort')}`}
</button>
</div>
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
@@ -321,3 +369,59 @@
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<!-- Per-listener album scope editor -->
<Modal open={scopeEditor !== null} title={t('commandTracker.scopeTitle')} onclose={() => scopeEditor = null}>
{#if scopeEditor}
<p class="text-xs text-[var(--color-muted-foreground)] mb-3">{t('commandTracker.scopeDescription')}</p>
<label class="flex items-center gap-2 text-sm mb-3">
<input type="checkbox" bind:checked={scopeEditor.inherit} />
{t('commandTracker.scopeInherit')}
</label>
{#if !scopeEditor.inherit}
{#if scopeEditor.collections.length > 0}
<div class="flex items-center justify-between mb-1.5 text-xs" style="color: var(--color-muted-foreground);">
<span>{scopeEditor.selectedIds.length} / {scopeEditor.collections.length}</span>
<div class="flex items-center gap-2">
<button type="button" class="underline hover:text-[var(--color-primary)]"
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
{t('backup.selectAll')}
</button>
<span aria-hidden="true">·</span>
<button type="button" class="underline hover:text-[var(--color-primary)]"
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
{t('backup.deselectAll')}
</button>
</div>
</div>
{/if}
<div class="space-y-1 max-h-72 overflow-y-auto border border-[var(--color-border)] rounded-md p-2">
{#if scopeEditor.collections.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] py-3 text-center">{t('commandTracker.noCollections')}</p>
{:else}
{#each scopeEditor.collections as col}
{@const cid = col.id}
<label class="flex items-center gap-2 text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer">
<input type="checkbox" checked={scopeEditor.selectedIds.includes(cid)}
onchange={(e) => {
if (!scopeEditor) return;
const target = e.target as HTMLInputElement;
scopeEditor.selectedIds = target.checked
? [...scopeEditor.selectedIds, cid]
: scopeEditor.selectedIds.filter((i) => i !== cid);
}} />
<span class="truncate min-w-0 flex-1" title={col.albumName || col.name || cid}>{col.albumName || col.name || cid}</span>
</label>
{/each}
{/if}
</div>
{/if}
<div class="flex gap-2 justify-end mt-4">
<button onclick={() => scopeEditor = null}
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>
<Button size="sm" onclick={saveScope}>{t('common.save')}</Button>
</div>
{/if}
</Modal>
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api, parseDate } from '$lib/api';
import { t, getLocale } from '$lib/i18n';
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -136,10 +136,29 @@
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
_prevProviderId = form.provider_id;
loadCollections();
// Auto-select first available tracking/template config for this provider when creating
if (editing === null) {
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
if (ptype) {
if (!form.default_tracking_config_id) {
const first = trackingConfigs.find(c => c.provider_type === ptype);
if (first) form.default_tracking_config_id = first.id;
}
if (!form.default_template_config_id) {
const first = templateConfigs.find(c => c.provider_type === ptype);
if (first) form.default_template_config_id = first.id;
}
}
}
}
});
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
function openNew() {
form = defaultForm();
// Auto-select first provider if any
if (providers.length > 0) form.provider_id = providers[0].id;
editing = null; showForm = true; collections = []; previousCollectionIds = [];
}
async function edit(trk: Tracker) {
form = {
@@ -256,7 +275,7 @@
function formatDate(dateStr: string): string {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
const d = parseDate(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch (e) { console.warn('Date format error:', e); return ''; }
}
+10 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -11,6 +11,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
@@ -131,12 +132,17 @@
}
function startDelete(provider: any) { confirmDelete = provider; }
let blockedBy = $state<BlockedByDetail | null>(null);
async function doDelete() {
if (!confirmDelete) return;
const id = confirmDelete.id;
confirmDelete = null;
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
}
</script>
@@ -280,6 +286,8 @@
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
.health-dot {
width: 10px;
@@ -1,6 +1,6 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { api } from '$lib/api';
import { api, parseDate } from '$lib/api';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import type { WebhookPayloadLog } from '$lib/types';
@@ -65,7 +65,7 @@
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleString();
return parseDate(iso).toLocaleString();
}
</script>
+164 -24
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api, fetchAuth } from '$lib/api';
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 MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
@@ -60,15 +59,23 @@
let backupFiles = $state<any[]>([]);
let loadingFiles = $state(false);
let confirmDeleteFile = $state('');
let creatingBackup = $state(false);
// --- Pending restore state ---
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
let postRestoreModalOpen = $state(false);
let restartingOverlay = $state(false);
onMount(async () => {
try {
const [settings, files] = await Promise.all([
const [settings, files, p] = await Promise.all([
api('/backup/scheduled'),
api('/backup/files'),
api('/backup/pending-restore'),
]);
scheduledSettings = settings;
backupFiles = files;
pending = p;
} catch (err: any) {
error = err.message;
snackError(err.message);
@@ -77,6 +84,53 @@
}
});
async function cancelPending() {
try {
await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled'));
pending = null;
} catch (err: any) { snackError(err.message); }
}
async function applyAndRestart() {
try {
await api('/backup/apply-restart', { method: 'POST' });
restartingOverlay = true;
// Poll /health until the new instance is up
const startedAt = Date.now();
let attempts = 0;
const poll = async () => {
attempts += 1;
try {
const res = await fetch('/api/health');
if (res.ok && Date.now() - startedAt > 2000) {
window.location.reload();
return;
}
} catch { /* still down */ }
if (attempts < 120) setTimeout(poll, 1000);
};
setTimeout(poll, 1500);
} catch (err: any) {
restartingOverlay = false;
snackError(err.message);
}
}
async function createManualBackup() {
creatingBackup = true;
try {
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
snackSuccess(t('backup.manualCreated'));
await refreshFiles();
} catch (err: any) {
snackError(err.message);
} finally {
creatingBackup = false;
}
}
// --- Export ---
async function doExport() {
if (exportSecrets === 'include') {
@@ -120,16 +174,7 @@
try {
const formData = new FormData();
formData.append('file', importFile);
const token = localStorage.getItem('access_token');
const res = await fetch('/api/backup/validate', {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
validationResult = await res.json();
} catch (err: any) {
snackError(err.message);
@@ -151,18 +196,15 @@
try {
const formData = new FormData();
formData.append('file', importFile);
const token = localStorage.getItem('access_token');
const res = await fetch(`/api/backup/import?conflict_mode=${importConflict}`, {
const res = await fetchAuth(`/backup/prepare-restore?conflict_mode=${importConflict}`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
importResult = await res.json();
snackSuccess(t('backup.importSuccess'));
pending = importResult;
snackSuccess(t('backup.restorePrepared'));
postRestoreModalOpen = true;
importFile = null;
} catch (err: any) {
snackError(err.message);
} finally {
@@ -256,6 +298,33 @@
<Loading />
{:else}
<ErrorBanner message={error} />
{#if pending?.pending}
<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">
<!-- Export Section -->
@@ -502,9 +571,14 @@
<MdiIcon name="mdiFolder" size={18} />
{t('backup.savedFiles')}
</h3>
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
<MdiIcon name="mdiRefresh" size={14} />
</button>
<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}
@@ -568,3 +642,69 @@
onconfirm={() => deleteFile(confirmDeleteFile)}
oncancel={() => confirmDeleteFile = ''}
/>
<!-- Post-restore modal: Apply now or later -->
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
{#if postRestoreModalOpen && pending?.pending}
<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}
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
role="presentation">
<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);"
onclick={(e) => e.stopPropagation()}>
<div class="flex items-start gap-3 mb-4">
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
<MdiIcon name="mdiClockAlert" size={22} />
</div>
<div class="min-w-0">
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
</div>
</div>
<div class="flex gap-2 justify-end flex-wrap">
<button onclick={() => postRestoreModalOpen = false}
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('backup.applyLater')}
</button>
{#if pending.supervised}
<Button size="sm" onclick={() => { postRestoreModalOpen = false; applyAndRestart(); }}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
</div>
</div>
</div>
{/if}
<!-- Restarting overlay -->
{#if restartingOverlay}
<div 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="text-center p-6" style="color: var(--color-foreground);">
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
<MdiIcon name="mdiRestart" size={40} />
</div>
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
</div>
</div>
{/if}
<style>
.restart-spinner {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
animation: restart-spin 1.2s linear infinite;
transform-origin: center center;
}
@keyframes restart-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
+12 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/state';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -114,7 +115,7 @@
const defaultForm = () => ({
name: '', icon: '', bot_id: 0, bot_token: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
disable_url_preview: true, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
// Discord/Slack shared settings
username: '',
// ntfy shared settings
@@ -193,6 +194,10 @@
function openNew() {
form = defaultForm();
formType = activeType || 'telegram';
// Auto-select first available bot of the chosen type
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
editing = null;
showTelegramSettings = false;
showForm = true;
@@ -289,12 +294,15 @@
} catch (err: any) { snackError(err.message); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
async function remove(id: number) {
try {
await api(`/targets/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetDeleted'));
} catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
}
@@ -529,3 +537,5 @@
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
oncancel={() => confirmDeleteReceiver = null}
/>
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize';
import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
@@ -208,7 +209,12 @@
finally { loaded = true; highlightFromUrl(); }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; refreshDateFormatPreview(); }
function openNew() {
form = defaultForm();
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
refreshDateFormatPreview();
}
function edit(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
@@ -253,12 +259,17 @@
setTimeout(() => refreshAllPreviews(), 100);
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDelete = null; }
}
};
@@ -455,6 +466,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { trackingConfigsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -72,12 +73,17 @@
} catch (err: any) { error = err.message; snackError(err.message); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDelete = null; }
}
};
@@ -257,6 +263,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
.toggle-switch {
position: relative;
+47 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -31,6 +31,13 @@
let resetMsg = $state('');
let resetSuccess = $state(false);
// Admin edit username/role
let editUserId = $state<number | null>(null);
let editUsername = $state('');
let editRole = $state('user');
let editMsg = $state('');
let editSuccess = $state(false);
onMount(load);
async function load() {
try { users = await api('/users'); }
@@ -56,6 +63,20 @@
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
function openEditUser(user: any) {
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
}
async function saveUserEdit(e: SubmitEvent) {
e.preventDefault(); editMsg = ''; editSuccess = false;
try {
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
editMsg = t('snack.userUpdated');
editSuccess = true;
snackSuccess(editMsg);
await load();
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
@@ -111,9 +132,10 @@
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
@@ -144,5 +166,28 @@
</form>
</Modal>
<!-- Admin edit username/role modal -->
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
<form onsubmit={saveUserEdit} class="space-y-3">
<div>
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="edit-username" bind:value={editUsername} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="edit-role" bind:value={editRole}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
{#if editMsg}
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
{/if}
<Button type="submit" class="w-full">{t('common.save')}</Button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />