b170c2b792
- Auto-refresh ticker is now silent: skips ``eventsLoading`` so the
loading placeholder no longer flashes, uses ``(event.id)`` key on
the events ``{#each}`` so unchanged rows reuse their DOM nodes, and
short-circuits the array reassignment when the visible page is
identical to what we already rendered. No-op refreshes leave the
list completely untouched.
- ``PageHeader`` crumbs (Routing · Notification, Operators · Bots, …)
were hard-coded literals. Moved to a new ``crumbs`` i18n namespace
with 9 keys; updated all 15 call sites to ``t('crumbs.*')`` so they
switch with the language.
- Tracker form's Immich feature-discovery banner now exposes both
``Open Tracking Config`` and ``Open Template Config``. Added the
``?edit=<id>`` auto-open hook to ``/template-configs`` (mirrors the
existing one on ``/tracking-configs``) so the new link lands users
directly on the editor.
256 lines
10 KiB
Svelte
256 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { api } 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';
|
|
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 { externalUrlCache } from '$lib/stores/caches.svelte';
|
|
|
|
interface CacheBucketStats {
|
|
count: number;
|
|
total_size_bytes: number;
|
|
oldest: string | null;
|
|
newest: string | null;
|
|
}
|
|
interface CacheStats {
|
|
url: CacheBucketStats;
|
|
asset: CacheBucketStats;
|
|
}
|
|
|
|
let loaded = $state(false);
|
|
let saving = $state(false);
|
|
let clearingCache = $state(false);
|
|
let confirmClearCache = $state(false);
|
|
let error = $state('');
|
|
let settings = $state({
|
|
external_url: '',
|
|
telegram_webhook_secret: '',
|
|
telegram_cache_ttl_hours: '720',
|
|
telegram_asset_cache_max_entries: '5000',
|
|
supported_locales: 'en,ru',
|
|
timezone: 'UTC',
|
|
log_level: 'INFO',
|
|
log_format: 'text',
|
|
log_levels: '',
|
|
});
|
|
let cacheStats = $state<CacheStats | null>(null);
|
|
|
|
async function loadCacheStats() {
|
|
try {
|
|
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
|
|
} catch { cacheStats = null; }
|
|
}
|
|
|
|
onMount(async () => {
|
|
try {
|
|
settings = await api('/settings');
|
|
await loadCacheStats();
|
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
|
finally { loaded = true; }
|
|
});
|
|
|
|
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 formatTs(iso: string | null): string {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
|
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
|
}
|
|
|
|
async function save() {
|
|
saving = true; error = '';
|
|
try {
|
|
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
|
|
externalUrlCache.invalidate();
|
|
snackSuccess(t('settings.saved'));
|
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
|
saving = false;
|
|
}
|
|
|
|
async function clearTelegramCache() {
|
|
confirmClearCache = false;
|
|
clearingCache = true;
|
|
try {
|
|
await api('/settings/telegram-cache/clear', { method: 'POST' });
|
|
snackSuccess(t('settings.clearCacheDone'));
|
|
await loadCacheStats();
|
|
} catch (err: any) { snackError(err.message); }
|
|
clearingCache = false;
|
|
}
|
|
</script>
|
|
|
|
<PageHeader
|
|
title={t('settings.title')}
|
|
emphasis={t('settings.titleEmphasis')}
|
|
description={t('settings.description')}
|
|
crumb={t('crumbs.systemConfiguration')}
|
|
/>
|
|
|
|
{#if !loaded}
|
|
<Loading />
|
|
{:else}
|
|
<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 -->
|
|
<Card>
|
|
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
<MdiIcon name="mdiSend" size={18} />
|
|
{t('settings.telegram')}
|
|
</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 -->
|
|
<Card>
|
|
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
<MdiIcon name="mdiTranslate" size={18} />
|
|
{t('settings.locales')}
|
|
</h3>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
|
<LocaleSelector bind:value={settings.supported_locales} />
|
|
</div>
|
|
</div>
|
|
</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>
|
|
|
|
<ConfirmModal open={confirmClearCache}
|
|
title={t('settings.clearCacheConfirmTitle')}
|
|
message={t('settings.clearCacheConfirm')}
|
|
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
|
confirmIcon="mdiDeleteSweep"
|
|
onconfirm={clearTelegramCache}
|
|
oncancel={() => confirmClearCache = false} />
|
|
{/if}
|