refactor: comprehensive consistency review — UI/UX, code quality, functional parity
Fix 19 issues across 3 priority tiers found during full-codebase review: CRITICAL: - Fix undefined --color-secondary CSS variable causing invisible UI elements - Fix Google Photos command templates using nonexistent asset.originalFileName - Fix scheduler template variable docs (tracker_name → schedule_name) - Add missing admin guard on notification template update endpoint HIGH: - Fix 5 hardcoded English strings missing i18n (MultiEntitySelect, actions, settings, TelegramBotTab, users) - Replace 17 raw <button> elements with shared <Button> component - Replace 5 raw error divs with shared <ErrorBanner> component - Refactor webhook handler duplication into shared _dispatch_webhook_event() - Add 30+ provider-specific fields to TrackingConfig TypeScript type - Add default TrackingConfig seeds for immich and google_photos - Add provider-specific command variable docs (Gitea, Planka, NUT, GP, Webhook) MEDIUM: - Replace hardcoded hex colors and Tailwind classes with CSS variable tokens - Remove dead code (unused imports, orphaned check_notification_tracker) - Fix Svelte 5 patterns ($state for _prevProviderId, remove unnecessary as any) - Fix inconsistent POST response shape (targets now returns full response) - Fix Google Photos template dead asset.year branches, clarify album_url docs
This commit is contained in:
@@ -105,7 +105,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button type="button" class="mes-trigger" onclick={openPalette}>
|
<button type="button" class="mes-trigger" onclick={openPalette}>
|
||||||
<MdiIcon name="mdiPlus" size={14} />
|
<MdiIcon name="mdiPlus" size={14} />
|
||||||
<span>{(values || []).length === 0 ? placeholder : `${(values || []).length} selected`}</span>
|
<span>{(values || []).length === 0 ? placeholder : t('common.nSelected').replace('{0}', String((values || []).length))}</span>
|
||||||
<span class="mes-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
|
<span class="mes-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
placeholder="Search..."
|
placeholder={t('common.search')}
|
||||||
class="mes-input"
|
class="mes-input"
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
|||||||
@@ -876,7 +876,10 @@
|
|||||||
"saveFailed": "Save failed",
|
"saveFailed": "Save failed",
|
||||||
"loadFailed": "Failed to load data",
|
"loadFailed": "Failed to load data",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"systemSuffix": " (System)"
|
"systemSuffix": " (System)",
|
||||||
|
"search": "Search...",
|
||||||
|
"nSelected": "{0} selected",
|
||||||
|
"unknown": "Unknown"
|
||||||
},
|
},
|
||||||
"templateSlot": {
|
"templateSlot": {
|
||||||
"message_assets_added": "New assets added to album",
|
"message_assets_added": "New assets added to album",
|
||||||
|
|||||||
@@ -876,7 +876,10 @@
|
|||||||
"saveFailed": "Не удалось сохранить",
|
"saveFailed": "Не удалось сохранить",
|
||||||
"loadFailed": "Не удалось загрузить данные",
|
"loadFailed": "Не удалось загрузить данные",
|
||||||
"dismiss": "Закрыть",
|
"dismiss": "Закрыть",
|
||||||
"systemSuffix": " (Системный)"
|
"systemSuffix": " (Системный)",
|
||||||
|
"search": "Поиск...",
|
||||||
|
"nSelected": "{0} выбрано",
|
||||||
|
"unknown": "Неизвестно"
|
||||||
},
|
},
|
||||||
"templateSlot": {
|
"templateSlot": {
|
||||||
"message_assets_added": "Новые файлы добавлены в альбом",
|
"message_assets_added": "Новые файлы добавлены в альбом",
|
||||||
|
|||||||
@@ -125,6 +125,45 @@ export interface TrackingConfig {
|
|||||||
track_sharing_changed: boolean;
|
track_sharing_changed: boolean;
|
||||||
track_images: boolean;
|
track_images: boolean;
|
||||||
track_videos: boolean;
|
track_videos: boolean;
|
||||||
|
// Gitea-specific
|
||||||
|
track_push?: boolean;
|
||||||
|
track_issue_opened?: boolean;
|
||||||
|
track_issue_closed?: boolean;
|
||||||
|
track_issue_commented?: boolean;
|
||||||
|
track_pr_opened?: boolean;
|
||||||
|
track_pr_closed?: boolean;
|
||||||
|
track_pr_merged?: boolean;
|
||||||
|
track_pr_commented?: boolean;
|
||||||
|
track_release_published?: boolean;
|
||||||
|
// Planka-specific
|
||||||
|
track_card_created?: boolean;
|
||||||
|
track_card_updated?: boolean;
|
||||||
|
track_card_moved?: boolean;
|
||||||
|
track_card_deleted?: boolean;
|
||||||
|
track_card_commented?: boolean;
|
||||||
|
track_comment_updated?: boolean;
|
||||||
|
track_board_created?: boolean;
|
||||||
|
track_board_updated?: boolean;
|
||||||
|
track_board_deleted?: boolean;
|
||||||
|
track_list_created?: boolean;
|
||||||
|
track_list_updated?: boolean;
|
||||||
|
track_list_deleted?: boolean;
|
||||||
|
track_attachment_created?: boolean;
|
||||||
|
track_card_label_added?: boolean;
|
||||||
|
track_task_completed?: boolean;
|
||||||
|
// Scheduler-specific
|
||||||
|
track_scheduled_message?: boolean;
|
||||||
|
// NUT-specific
|
||||||
|
track_ups_online?: boolean;
|
||||||
|
track_ups_on_battery?: boolean;
|
||||||
|
track_ups_low_battery?: boolean;
|
||||||
|
track_ups_battery_restored?: boolean;
|
||||||
|
track_ups_comms_lost?: boolean;
|
||||||
|
track_ups_comms_restored?: boolean;
|
||||||
|
track_ups_replace_battery?: boolean;
|
||||||
|
track_ups_overload?: boolean;
|
||||||
|
// Webhook-specific
|
||||||
|
track_webhook_received?: boolean;
|
||||||
notify_favorites_only: boolean;
|
notify_favorites_only: boolean;
|
||||||
include_tags: boolean;
|
include_tags: boolean;
|
||||||
include_asset_details: boolean;
|
include_asset_details: boolean;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
import { providerDefaultIcon } from '$lib/grid-items';
|
import { providerDefaultIcon } from '$lib/grid-items';
|
||||||
import RuleEditor from './RuleEditor.svelte';
|
import RuleEditor from './RuleEditor.svelte';
|
||||||
import ExecutionHistory from './ExecutionHistory.svelte';
|
import ExecutionHistory from './ExecutionHistory.svelte';
|
||||||
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
import type { Action, ActionRule } from '$lib/types';
|
import type { Action, ActionRule } from '$lib/types';
|
||||||
|
|
||||||
let allActions = $derived(actionsCache.items);
|
let allActions = $derived(actionsCache.items);
|
||||||
@@ -162,18 +164,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
function statusColor(status: string): string {
|
||||||
if (status === 'success') return '#059669';
|
if (status === 'success') return 'var(--color-success-fg)';
|
||||||
if (status === 'partial') return '#f59e0b';
|
if (status === 'partial') return 'var(--color-warning-fg)';
|
||||||
if (status === 'failed') return '#ef4444';
|
if (status === 'failed') return 'var(--color-error-fg)';
|
||||||
return 'var(--color-muted-foreground)';
|
return 'var(--color-muted-foreground)';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('actions.title')} description={t('actions.description')}>
|
<PageHeader title={t('actions.title')} description={t('actions.description')}>
|
||||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
|
||||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
|
||||||
{showForm ? t('common.cancel') : t('actions.addAction')}
|
{showForm ? t('common.cancel') : t('actions.addAction')}
|
||||||
</button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if !loaded}
|
{#if !loaded}
|
||||||
@@ -192,9 +193,7 @@
|
|||||||
{#if showForm}
|
{#if showForm}
|
||||||
<div in:slide={{ duration: 200 }}>
|
<div in:slide={{ duration: 200 }}>
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}
|
{#if error}<ErrorBanner message={error} />{/if}
|
||||||
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>
|
|
||||||
{/if}
|
|
||||||
<form onsubmit={save} class="space-y-3">
|
<form onsubmit={save} class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">{t('actions.provider')}</label>
|
<label class="block text-sm font-medium mb-1">{t('actions.provider')}</label>
|
||||||
@@ -242,7 +241,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1 text-sm">
|
<label class="flex items-center gap-1 text-sm">
|
||||||
<input type="radio" name="schedule_type" value="cron" bind:group={form.schedule_type} class="accent-[var(--color-primary)]" />
|
<input type="radio" name="schedule_type" value="cron" bind:group={form.schedule_type} class="accent-[var(--color-primary)]" />
|
||||||
Cron
|
{t('actions.cronMode')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{#if form.schedule_type === 'interval'}
|
{#if form.schedule_type === 'interval'}
|
||||||
@@ -263,10 +262,9 @@
|
|||||||
<label for="act-enabled" class="text-sm">{t('actions.enabled')}</label>
|
<label for="act-enabled" class="text-sm">{t('actions.enabled')}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={submitting}
|
<Button type="submit" disabled={submitting}>
|
||||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
|
||||||
{submitting ? t('common.loading') : (editing ? t('common.save') : t('actions.addAction'))}
|
{submitting ? t('common.loading') : (editing ? t('common.save') : t('actions.addAction'))}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if editing}
|
{#if editing}
|
||||||
|
|||||||
@@ -131,10 +131,9 @@
|
|||||||
<input type="checkbox" bind:checked={emailForm.smtp_use_tls} />
|
<input type="checkbox" bind:checked={emailForm.smtp_use_tls} />
|
||||||
{t('emailBot.useTls')}
|
{t('emailBot.useTls')}
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={emailSubmitting}
|
<Button type="submit" disabled={emailSubmitting}>
|
||||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
|
||||||
{emailSubmitting ? t('common.loading') : (editingEmail ? t('common.save') : t('emailBot.addBot'))}
|
{emailSubmitting ? t('common.loading') : (editingEmail ? t('common.save') : t('emailBot.addBot'))}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -157,7 +156,7 @@
|
|||||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
|
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
|
||||||
{#if bot.smtp_use_tls}
|
{#if bot.smtp_use_tls}
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500">TLS</span>
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-success-bg)] text-[var(--color-success-fg)]">TLS</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -303,10 +303,9 @@
|
|||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="submit" disabled={submitting}
|
<Button type="submit" disabled={submitting}>
|
||||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
|
||||||
{submitting ? t('common.loading') : (editing ? t('common.save') : t('telegramBot.addBot'))}
|
{submitting ? t('common.loading') : (editing ? t('common.save') : t('telegramBot.addBot'))}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -329,8 +328,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<!-- Mode badge -->
|
<!-- Mode badge -->
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook'
|
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook'
|
||||||
? 'bg-blue-500/10 text-blue-500'
|
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
|
||||||
: 'bg-emerald-500/10 text-emerald-500'}">
|
: 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'}">
|
||||||
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')}
|
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,7 +377,7 @@
|
|||||||
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
||||||
title={t('telegramBot.clickToCopy')}
|
title={t('telegramBot.clickToCopy')}
|
||||||
role="button" tabindex="0">
|
role="button" tabindex="0">
|
||||||
<span class="font-medium truncate">{chat.title || chat.username || 'Unknown'}</span>
|
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
|
||||||
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
||||||
<div style="justify-self:center" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
<div style="justify-self:center" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
||||||
@@ -399,7 +398,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||||
<div style="justify-self:end" class="flex items-center gap-1">
|
<div style="justify-self:end" class="flex items-center gap-1">
|
||||||
<IconButton icon="mdiSend" title="Test message" size={14}
|
<IconButton icon="mdiSend" title={t('common.test')} size={14}
|
||||||
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
||||||
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||||
@@ -465,7 +464,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if bot.update_mode === 'polling'}
|
{#if bot.update_mode === 'polling'}
|
||||||
<span class="text-xs text-emerald-500 flex items-center gap-1">
|
<span class="text-xs text-[var(--color-success-fg)] flex items-center gap-1">
|
||||||
<MdiIcon name="mdiCheckCircle" size={14} />
|
<MdiIcon name="mdiCheckCircle" size={14} />
|
||||||
{t('telegramBot.pollingActive')}
|
{t('telegramBot.pollingActive')}
|
||||||
</span>
|
</span>
|
||||||
@@ -489,7 +488,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if ws.last_error_message}
|
{#if ws.last_error_message}
|
||||||
<span class="text-xs text-red-500">{t('telegramBot.webhookError')}: {ws.last_error_message}</span>
|
<span class="text-xs text-[var(--color-error-fg)]">{t('telegramBot.webhookError')}: {ws.last_error_message}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<button onclick={() => loadWebhookStatus(bot.id)}
|
<button onclick={() => loadWebhookStatus(bot.id)}
|
||||||
@@ -500,7 +499,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !settings.external_url && bot.update_mode === 'webhook'}
|
{#if !settings.external_url && bot.update_mode === 'webhook'}
|
||||||
<span class="text-xs text-amber-500 flex items-center gap-1">
|
<span class="text-xs text-[var(--color-warning-fg)] flex items-center gap-1">
|
||||||
<MdiIcon name="mdiAlert" size={14} />
|
<MdiIcon name="mdiAlert" size={14} />
|
||||||
{t('telegramBot.noExternalDomain')}
|
{t('telegramBot.noExternalDomain')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
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';
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import type { CommandConfig } from '$lib/types';
|
import type { CommandConfig } from '$lib/types';
|
||||||
|
|
||||||
function templateName(id: number | null): string {
|
function templateName(id: number | null): string {
|
||||||
@@ -161,7 +162,7 @@
|
|||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}<ErrorBanner message={error} />{/if}
|
||||||
<form onsubmit={saveConfig} class="space-y-4">
|
<form onsubmit={saveConfig} class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label>
|
<label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label>
|
||||||
@@ -230,10 +231,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit" disabled={submitting}
|
<Button type="submit" disabled={submitting}>
|
||||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
|
||||||
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
|
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
<span style="color: var(--color-primary);"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
|
<span style="color: var(--color-primary);"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||||
<p class="font-medium">{cfg.name}</p>
|
<p class="font-medium">{cfg.name}</p>
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{cfg.provider_type}</span>
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{cfg.provider_type}</span>
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500 font-mono">
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-success-bg)] text-[var(--color-success-fg)] font-mono">
|
||||||
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
|
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { highlightFromUrl } from '$lib/highlight';
|
import { highlightFromUrl } from '$lib/highlight';
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
import { providerDefaultIcon } from '$lib/grid-items';
|
import { providerDefaultIcon } from '$lib/grid-items';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
import type { ServiceProvider, TelegramBot } from '$lib/types';
|
import type { ServiceProvider, TelegramBot } from '$lib/types';
|
||||||
|
|
||||||
let allCmdTrackers = $state<any[]>([]);
|
let allCmdTrackers = $state<any[]>([]);
|
||||||
@@ -186,10 +187,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('commandTracker.title')} description={t('commandTracker.description')}>
|
<PageHeader title={t('commandTracker.title')} description={t('commandTracker.description')}>
|
||||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
|
||||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
|
||||||
{showForm ? t('common.cancel') : t('commandTracker.newTracker')}
|
{showForm ? t('common.cancel') : t('commandTracker.newTracker')}
|
||||||
</button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if !loaded}<Loading />{:else}
|
{#if !loaded}<Loading />{:else}
|
||||||
@@ -217,10 +217,9 @@
|
|||||||
<EntitySelect items={configItems} bind:value={form.command_config_id} placeholder={t('commandTracker.selectCommandConfig')} />
|
<EntitySelect items={configItems} bind:value={form.command_config_id} placeholder={t('commandTracker.selectCommandConfig')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={submitting}
|
<Button type="submit" disabled={submitting}>
|
||||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
|
||||||
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
|
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -257,8 +256,8 @@
|
|||||||
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
|
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
|
||||||
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
|
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
|
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
|
||||||
? 'bg-emerald-500/10 text-emerald-500'
|
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
|
||||||
: 'bg-red-500/10 text-red-500'}">
|
: 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]'}">
|
||||||
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,7 +292,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<MdiIcon name="mdiRobot" size={14} />
|
<MdiIcon name="mdiRobot" size={14} />
|
||||||
<CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
|
<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-blue-500/10 text-blue-500 font-mono">{listener.listener_type}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
|
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
|
||||||
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
|
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
|
||||||
@@ -307,10 +306,9 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<EntitySelect items={botItems} bind:value={newListenerBotId[trk.id]} placeholder={t('commandTracker.selectBot')} />
|
<EntitySelect items={botItems} bind:value={newListenerBotId[trk.id]} placeholder={t('commandTracker.selectBot')} />
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => addListener(trk.id)} disabled={!newListenerBotId[trk.id] || addingListener[trk.id]}
|
<Button size="sm" onclick={() => addListener(trk.id)} disabled={!newListenerBotId[trk.id] || addingListener[trk.id]}>
|
||||||
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
|
|
||||||
{addingListener[trk.id] ? t('common.loading') : t('commandTracker.addListener')}
|
{addingListener[trk.id] ? t('common.loading') : t('commandTracker.addListener')}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
let _prevProviderId = 0;
|
let _prevProviderId = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||||
_prevProviderId = form.provider_id;
|
_prevProviderId = form.provider_id;
|
||||||
@@ -146,8 +146,8 @@
|
|||||||
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
||||||
collection_ids: [...(trk.collection_ids || [])],
|
collection_ids: [...(trk.collection_ids || [])],
|
||||||
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
|
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
|
||||||
default_tracking_config_id: (trk as any).default_tracking_config_id || 0,
|
default_tracking_config_id: trk.default_tracking_config_id ?? 0,
|
||||||
default_template_config_id: (trk as any).default_template_config_id || 0,
|
default_template_config_id: trk.default_template_config_id ?? 0,
|
||||||
filters: trk.filters || {},
|
filters: trk.filters || {},
|
||||||
};
|
};
|
||||||
previousCollectionIds = [...(trk.collection_ids || [])];
|
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||||
|
|||||||
@@ -285,19 +285,19 @@
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.health-dot.online {
|
.health-dot.online {
|
||||||
background: #059669;
|
background: var(--color-success-fg);
|
||||||
box-shadow: 0 0 8px rgba(5, 150, 105, 0.4);
|
box-shadow: 0 0 8px color-mix(in srgb, var(--color-success-fg) 40%, transparent);
|
||||||
}
|
}
|
||||||
.health-dot.offline {
|
.health-dot.offline {
|
||||||
background: #ef4444;
|
background: var(--color-error-fg);
|
||||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.3);
|
box-shadow: 0 0 8px color-mix(in srgb, var(--color-error-fg) 30%, transparent);
|
||||||
}
|
}
|
||||||
.health-dot.checking {
|
.health-dot.checking {
|
||||||
background: #f59e0b;
|
background: var(--color-warning-fg);
|
||||||
animation: pulseCheck 1.5s ease-in-out infinite;
|
animation: pulseCheck 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@keyframes pulseCheck {
|
@keyframes pulseCheck {
|
||||||
0%, 100% { box-shadow: 0 0 4px rgba(245, 158, 11, 0.3); }
|
0%, 100% { box-shadow: 0 0 4px color-mix(in srgb, var(--color-warning-fg) 30%, transparent); }
|
||||||
50% { box-shadow: 0 0 12px rgba(245, 158, 11, 0.6); }
|
50% { box-shadow: 0 0 12px color-mix(in srgb, var(--color-warning-fg) 60%, transparent); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
logs = await api(`/providers/${providerId}/webhook-logs`);
|
logs = await api(`/providers/${providerId}/webhook-logs`);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error('Failed to load webhook logs', e);
|
||||||
logs = [];
|
logs = [];
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
try {
|
try {
|
||||||
await api(`/providers/${providerId}/webhook-logs`, { method: 'DELETE' });
|
await api(`/providers/${providerId}/webhook-logs`, { method: 'DELETE' });
|
||||||
logs = [];
|
logs = [];
|
||||||
} catch { /* ignore */ }
|
} catch (e) { console.error('Failed to clear webhook logs', e); }
|
||||||
showClearConfirm = false;
|
showClearConfirm = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,9 +40,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
function statusColor(status: string): string {
|
||||||
if (status === 'matched') return '#059669';
|
if (status === 'matched') return 'var(--color-success-fg)';
|
||||||
if (status === 'unmatched') return '#f59e0b';
|
if (status === 'unmatched') return 'var(--color-warning-fg)';
|
||||||
return '#ef4444';
|
return 'var(--color-error-fg)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusIcon(status: string): string {
|
function statusIcon(status: string): string {
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h3 class="text-sm font-semibold">{t('webhookLogs.title')}</h3>
|
<h3 class="text-sm font-semibold">{t('webhookLogs.title')}</h3>
|
||||||
{#if logs.length > 0}
|
{#if logs.length > 0}
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-secondary)] text-[var(--color-secondary-foreground)]">{logs.length}</span>
|
<span class="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{logs.length}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if logs.length > 0}
|
{#if logs.length > 0}
|
||||||
@@ -99,7 +100,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<MdiIcon name={expandedId === log.id ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
|
<MdiIcon name={expandedId === log.id ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
|
||||||
<span class="text-xs text-[var(--color-muted-foreground)] tabular-nums">{formatTime(log.created_at)}</span>
|
<span class="text-xs text-[var(--color-muted-foreground)] tabular-nums">{formatTime(log.created_at)}</span>
|
||||||
<span class="text-xs font-mono px-1 py-0.5 rounded bg-[var(--color-secondary)]">{log.method}</span>
|
<span class="text-xs font-mono px-1 py-0.5 rounded bg-[var(--color-muted)]">{log.method}</span>
|
||||||
<span class="flex items-center gap-1" style="color: {statusColor(log.status)};">
|
<span class="flex items-center gap-1" style="color: {statusColor(log.status)};">
|
||||||
<MdiIcon name={statusIcon(log.status)} size={14} />
|
<MdiIcon name={statusIcon(log.status)} size={14} />
|
||||||
<span class="text-xs font-medium">{statusLabel(log.status)}</span>
|
<span class="text-xs font-medium">{statusLabel(log.status)}</span>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import Hint from '$lib/components/Hint.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 { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
|
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
||||||
<input bind:value={settings.telegram_webhook_secret} type="password" placeholder="optional"
|
<input bind:value={settings.telegram_webhook_secret} type="password" 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" />
|
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>
|
||||||
<div>
|
<div>
|
||||||
@@ -78,9 +79,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<button onclick={save} disabled={saving}
|
<Button onclick={save} disabled={saving}>
|
||||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
|
||||||
{saving ? t('common.loading') : t('common.save')}
|
{saving ? t('common.loading') : t('common.save')}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||||
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
|
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
|
||||||
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
|
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
form: {
|
form: {
|
||||||
@@ -184,7 +185,7 @@
|
|||||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">{submitting ? t('common.loading') : (editing ? t('common.save') : t('targets.create'))}</button>
|
<Button type="submit" disabled={submitting}>{submitting ? t('common.loading') : (editing ? t('common.save') : t('targets.create'))}</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
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';
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
import type { TemplateConfig } from '$lib/types';
|
import type { TemplateConfig } from '$lib/types';
|
||||||
|
|
||||||
let allTemplateConfigs = $derived(templateConfigsCache.items);
|
let allTemplateConfigs = $derived(templateConfigsCache.items);
|
||||||
@@ -263,10 +265,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')}>
|
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')}>
|
||||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
|
||||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
|
||||||
{showForm ? t('common.cancel') : t('templateConfig.newConfig')}
|
{showForm ? t('common.cancel') : t('templateConfig.newConfig')}
|
||||||
</button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if !loaded}<Loading />{:else}
|
{#if !loaded}<Loading />{:else}
|
||||||
@@ -274,7 +275,7 @@
|
|||||||
{#if showForm}
|
{#if showForm}
|
||||||
<div in:slide={{ duration: 200 }}>
|
<div in:slide={{ duration: 200 }}>
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}<ErrorBanner message={error} />{/if}
|
||||||
<form onsubmit={save} class="space-y-5">
|
<form onsubmit={save} class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
|
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
|
||||||
@@ -391,9 +392,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
<Button type="submit">
|
||||||
{editing ? t('common.save') : t('common.create')}
|
{editing ? t('common.save') : t('common.create')}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
import { highlightFromUrl } from '$lib/highlight';
|
import { highlightFromUrl } from '$lib/highlight';
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
|
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
|
||||||
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
import type { TrackingConfig } from '$lib/types';
|
import type { TrackingConfig } from '$lib/types';
|
||||||
|
|
||||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||||
@@ -83,10 +85,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')}>
|
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')}>
|
||||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
|
||||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
|
||||||
{showForm ? t('common.cancel') : t('trackingConfig.newConfig')}
|
{showForm ? t('common.cancel') : t('trackingConfig.newConfig')}
|
||||||
</button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if !loaded}<Loading />{:else}
|
{#if !loaded}<Loading />{:else}
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
{#if showForm}
|
{#if showForm}
|
||||||
<div in:slide={{ duration: 200 }}>
|
<div in:slide={{ duration: 200 }}>
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}<ErrorBanner message={error} />{/if}
|
||||||
<form onsubmit={save} class="space-y-5">
|
<form onsubmit={save} class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
|
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
|
||||||
@@ -194,9 +195,9 @@
|
|||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
<Button type="submit">
|
||||||
{editing ? t('common.save') : t('common.create')}
|
{editing ? t('common.save') : t('common.create')}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
import type { User } from '$lib/types';
|
import type { User } from '$lib/types';
|
||||||
|
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
@@ -67,17 +69,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('users.title')} description={t('users.description')}>
|
<PageHeader title={t('users.title')} description={t('users.description')}>
|
||||||
<button onclick={() => showForm = !showForm}
|
<Button size="sm" onclick={() => showForm = !showForm}>
|
||||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
|
||||||
{showForm ? t('users.cancel') : t('users.addUser')}
|
{showForm ? t('users.cancel') : t('users.addUser')}
|
||||||
</button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if !loaded}<Loading />{:else}
|
{#if !loaded}<Loading />{:else}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}<ErrorBanner message={error} />{/if}
|
||||||
<form onsubmit={create} class="space-y-3">
|
<form onsubmit={create} class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
<option value="admin">{t('users.roleAdmin')}</option>
|
<option value="admin">{t('users.roleAdmin')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">{t('users.create')}</button>
|
<Button type="submit">{t('users.create')}</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -110,7 +111,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{user.username}</p>
|
<p class="font-medium">{user.username}</p>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role} · {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')} {new Date(user.created_at).toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
{#if user.id !== auth.user?.id}
|
{#if user.id !== auth.user?.id}
|
||||||
@@ -137,9 +138,9 @@
|
|||||||
{#if resetMsg}
|
{#if resetMsg}
|
||||||
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
<Button type="submit" class="w-full">
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
📸 Latest:
|
📸 Latest:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %}
|
• {{ asset.filename }}{% if asset.type == 'VIDEO' %} 🎬{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
🎲 Random:
|
🎲 Random:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {{ asset.originalFileName }}
|
• {{ asset.filename }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
🔍 Results for "{{ query }}":
|
🔍 Results for "{{ query }}":
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %}
|
• {{ asset.filename }}{% if asset.type == 'VIDEO' %} 🎬{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
📸 Последние:
|
📸 Последние:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %}
|
• {{ asset.filename }}{% if asset.type == 'VIDEO' %} 🎬{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
🎲 Случайные:
|
🎲 Случайные:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {{ asset.originalFileName }}
|
• {{ asset.filename }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
🔍 Результаты по запросу "{{ query }}":
|
🔍 Результаты по запросу "{{ query }}":
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %}
|
• {{ asset.filename }}{% if asset.type == 'VIDEO' %} 🎬{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -112,7 +112,8 @@ async def get_command_variables(
|
|||||||
"asset_fields": asset_fields,
|
"asset_fields": asset_fields,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
# --- Shared slots (all providers) ---
|
||||||
|
shared = {
|
||||||
"start": {
|
"start": {
|
||||||
"description": "/start greeting message",
|
"description": "/start greeting message",
|
||||||
"variables": {**common_vars, "bot_name": "Bot display name"},
|
"variables": {**common_vars, "bot_name": "Bot display name"},
|
||||||
@@ -122,6 +123,20 @@ async def get_command_variables(
|
|||||||
"variables": {**common_vars, "commands": "List of command dicts (use {% for cmd in commands %})"},
|
"variables": {**common_vars, "commands": "List of command dicts (use {% for cmd in commands %})"},
|
||||||
"command_fields": command_fields,
|
"command_fields": command_fields,
|
||||||
},
|
},
|
||||||
|
"rate_limited": {
|
||||||
|
"description": "Rate limit warning message",
|
||||||
|
"variables": {**common_vars, "wait": "Seconds to wait before retry"},
|
||||||
|
},
|
||||||
|
"no_results": {
|
||||||
|
"description": "Empty results fallback",
|
||||||
|
"variables": {**common_vars, "command": "Command name", "query": "Search query (empty for non-search commands)"},
|
||||||
|
},
|
||||||
|
"desc_help": {"description": "Description for /help command", "variables": common_vars},
|
||||||
|
"desc_status": {"description": "Description for /status command", "variables": common_vars},
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Immich-specific ---
|
||||||
|
immich = {
|
||||||
"status": {
|
"status": {
|
||||||
"description": "/status tracker summary",
|
"description": "/status tracker summary",
|
||||||
"variables": {
|
"variables": {
|
||||||
@@ -163,17 +178,6 @@ async def get_command_variables(
|
|||||||
"variables": {**common_vars, "assets": "List of asset dicts with year field (use {% for asset in assets %})", "count": "Number of results"},
|
"variables": {**common_vars, "assets": "List of asset dicts with year field (use {% for asset in assets %})", "count": "Number of results"},
|
||||||
"asset_fields": asset_fields,
|
"asset_fields": asset_fields,
|
||||||
},
|
},
|
||||||
"rate_limited": {
|
|
||||||
"description": "Rate limit warning message",
|
|
||||||
"variables": {**common_vars, "wait": "Seconds to wait before retry"},
|
|
||||||
},
|
|
||||||
"no_results": {
|
|
||||||
"description": "Empty results fallback",
|
|
||||||
"variables": {**common_vars, "command": "Command name", "query": "Search query (empty for non-search commands)"},
|
|
||||||
},
|
|
||||||
# --- Description slots (shown in /help listing) ---
|
|
||||||
"desc_help": {"description": "Description for /help command", "variables": common_vars},
|
|
||||||
"desc_status": {"description": "Description for /status command", "variables": common_vars},
|
|
||||||
"desc_albums": {"description": "Description for /albums command", "variables": common_vars},
|
"desc_albums": {"description": "Description for /albums command", "variables": common_vars},
|
||||||
"desc_events": {"description": "Description for /events command", "variables": common_vars},
|
"desc_events": {"description": "Description for /events command", "variables": common_vars},
|
||||||
"desc_summary": {"description": "Description for /summary command", "variables": common_vars},
|
"desc_summary": {"description": "Description for /summary command", "variables": common_vars},
|
||||||
@@ -186,7 +190,6 @@ async def get_command_variables(
|
|||||||
"desc_place": {"description": "Description for /place command", "variables": common_vars},
|
"desc_place": {"description": "Description for /place command", "variables": common_vars},
|
||||||
"desc_favorites": {"description": "Description for /favorites command", "variables": common_vars},
|
"desc_favorites": {"description": "Description for /favorites command", "variables": common_vars},
|
||||||
"desc_people": {"description": "Description for /people command", "variables": common_vars},
|
"desc_people": {"description": "Description for /people command", "variables": common_vars},
|
||||||
# --- Usage example slots (shown in /help listing) ---
|
|
||||||
"usage_search": {"description": "Usage example for /search (e.g. '/search sunset')", "variables": common_vars},
|
"usage_search": {"description": "Usage example for /search (e.g. '/search sunset')", "variables": common_vars},
|
||||||
"usage_find": {"description": "Usage example for /find", "variables": common_vars},
|
"usage_find": {"description": "Usage example for /find", "variables": common_vars},
|
||||||
"usage_person": {"description": "Usage example for /person", "variables": common_vars},
|
"usage_person": {"description": "Usage example for /person", "variables": common_vars},
|
||||||
@@ -198,6 +201,183 @@ async def get_command_variables(
|
|||||||
"usage_memory": {"description": "Usage example for /memory", "variables": common_vars},
|
"usage_memory": {"description": "Usage example for /memory", "variables": common_vars},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Gitea-specific ---
|
||||||
|
repo_fields = {
|
||||||
|
"full_name": "Repository full name (owner/repo)",
|
||||||
|
"description": "Repository description",
|
||||||
|
}
|
||||||
|
issue_fields = {
|
||||||
|
"repo": "Repository name",
|
||||||
|
"number": "Issue number",
|
||||||
|
"title": "Issue title",
|
||||||
|
"url": "Issue URL",
|
||||||
|
"user": "Issue author",
|
||||||
|
}
|
||||||
|
pr_fields = {
|
||||||
|
"repo": "Repository name",
|
||||||
|
"number": "PR number",
|
||||||
|
"title": "PR title",
|
||||||
|
"url": "PR URL",
|
||||||
|
"user": "PR author",
|
||||||
|
}
|
||||||
|
commit_fields = {
|
||||||
|
"repo": "Repository name",
|
||||||
|
"short_id": "Short commit hash",
|
||||||
|
"message": "Commit message",
|
||||||
|
"author": "Commit author",
|
||||||
|
}
|
||||||
|
gitea = {
|
||||||
|
"status": {
|
||||||
|
"description": "/status Gitea server summary",
|
||||||
|
"variables": {**common_vars, "repos_count": "Number of tracked repositories", "server_version": "Gitea server version", "last_event": "Last event timestamp"},
|
||||||
|
},
|
||||||
|
"repos": {
|
||||||
|
"description": "/repos tracked repositories",
|
||||||
|
"variables": {**common_vars, "repos": "List of repo dicts (use {% for repo in repos %})"},
|
||||||
|
"repo_fields": repo_fields,
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"description": "/issues open issues",
|
||||||
|
"variables": {**common_vars, "issues": "List of issue dicts (use {% for issue in issues %})"},
|
||||||
|
"issue_fields": issue_fields,
|
||||||
|
},
|
||||||
|
"prs": {
|
||||||
|
"description": "/prs open pull requests",
|
||||||
|
"variables": {**common_vars, "prs": "List of PR dicts (use {% for pr in prs %})"},
|
||||||
|
"pr_fields": pr_fields,
|
||||||
|
},
|
||||||
|
"commits": {
|
||||||
|
"description": "/commits recent commits",
|
||||||
|
"variables": {**common_vars, "commits": "List of commit dicts (use {% for c in commits %})"},
|
||||||
|
"commit_fields": commit_fields,
|
||||||
|
},
|
||||||
|
"desc_repos": {"description": "Description for /repos command", "variables": common_vars},
|
||||||
|
"desc_issues": {"description": "Description for /issues command", "variables": common_vars},
|
||||||
|
"desc_prs": {"description": "Description for /prs command", "variables": common_vars},
|
||||||
|
"desc_commits": {"description": "Description for /commits command", "variables": common_vars},
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Planka-specific ---
|
||||||
|
board_fields = {"name": "Board name"}
|
||||||
|
card_fields_planka = {
|
||||||
|
"name": "Card name",
|
||||||
|
"list_name": "List the card belongs to",
|
||||||
|
"board_name": "Board the card belongs to",
|
||||||
|
}
|
||||||
|
list_fields = {"name": "List name", "board_name": "Board name"}
|
||||||
|
planka = {
|
||||||
|
"status": {
|
||||||
|
"description": "/status Planka board summary",
|
||||||
|
"variables": {**common_vars, "boards_count": "Number of tracked boards", "last_event": "Last event timestamp"},
|
||||||
|
},
|
||||||
|
"boards": {
|
||||||
|
"description": "/boards tracked boards",
|
||||||
|
"variables": {**common_vars, "boards": "List of board dicts (use {% for board in boards %})"},
|
||||||
|
"board_fields": board_fields,
|
||||||
|
},
|
||||||
|
"cards": {
|
||||||
|
"description": "/cards recent cards",
|
||||||
|
"variables": {**common_vars, "cards": "List of card dicts (use {% for card in cards %})"},
|
||||||
|
"card_fields": card_fields_planka,
|
||||||
|
},
|
||||||
|
"lists": {
|
||||||
|
"description": "/lists board lists",
|
||||||
|
"variables": {**common_vars, "lists": "List of list dicts (use {% for lst in lists %})"},
|
||||||
|
"list_fields": list_fields,
|
||||||
|
},
|
||||||
|
"desc_boards": {"description": "Description for /boards command", "variables": common_vars},
|
||||||
|
"desc_cards": {"description": "Description for /cards command", "variables": common_vars},
|
||||||
|
"desc_lists": {"description": "Description for /lists command", "variables": common_vars},
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- NUT-specific ---
|
||||||
|
device_fields = {
|
||||||
|
"name": "UPS device name",
|
||||||
|
"description": "UPS description",
|
||||||
|
"model": "UPS model",
|
||||||
|
"battery_charge": "Battery charge percentage",
|
||||||
|
"battery_runtime": "Estimated runtime (formatted)",
|
||||||
|
"input_voltage": "Input voltage",
|
||||||
|
"output_voltage": "Output voltage",
|
||||||
|
}
|
||||||
|
nut = {
|
||||||
|
"status": {
|
||||||
|
"description": "/status UPS summary",
|
||||||
|
"variables": {**common_vars, "devices_count": "Number of monitored devices", "last_event": "Last event timestamp"},
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"description": "/devices monitored UPS devices",
|
||||||
|
"variables": {**common_vars, "devices": "List of device dicts (use {% for d in devices %})"},
|
||||||
|
"device_fields": device_fields,
|
||||||
|
},
|
||||||
|
"battery": {
|
||||||
|
"description": "/battery battery report",
|
||||||
|
"variables": {**common_vars, "devices": "List of UPS dicts (use {% for ups in devices %})"},
|
||||||
|
"device_fields": device_fields,
|
||||||
|
},
|
||||||
|
"desc_devices": {"description": "Description for /devices command", "variables": common_vars},
|
||||||
|
"desc_battery": {"description": "Description for /battery command", "variables": common_vars},
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Google Photos-specific ---
|
||||||
|
gp_asset_fields = {
|
||||||
|
"id": "Asset ID",
|
||||||
|
"filename": "Original filename",
|
||||||
|
"type": "IMAGE or VIDEO",
|
||||||
|
"created_at": "Creation date/time (ISO 8601)",
|
||||||
|
}
|
||||||
|
google_photos = {
|
||||||
|
"status": {
|
||||||
|
"description": "/status Google Photos summary",
|
||||||
|
"variables": {**common_vars, "trackers_active": "Number of active trackers", "trackers_total": "Total tracker count", "total_albums": "Total tracked albums", "last_event": "Last event timestamp"},
|
||||||
|
},
|
||||||
|
"albums": {
|
||||||
|
"description": "/albums tracked albums",
|
||||||
|
"variables": {**common_vars, "albums": "List of album dicts (use {% for album in albums %})"},
|
||||||
|
"album_fields": album_fields,
|
||||||
|
},
|
||||||
|
"latest": {
|
||||||
|
"description": "/latest recent photos",
|
||||||
|
"variables": {**common_vars, "assets": "List of asset dicts (use {% for asset in assets %})", "count": "Number of results"},
|
||||||
|
"asset_fields": gp_asset_fields,
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"description": "/search photo search results",
|
||||||
|
"variables": {**common_vars, "assets": "List of asset dicts (use {% for asset in assets %})", "query": "Search query", "count": "Number of results"},
|
||||||
|
"asset_fields": gp_asset_fields,
|
||||||
|
},
|
||||||
|
"random": {
|
||||||
|
"description": "/random random photos",
|
||||||
|
"variables": {**common_vars, "assets": "List of asset dicts (use {% for asset in assets %})", "count": "Number of results"},
|
||||||
|
"asset_fields": gp_asset_fields,
|
||||||
|
},
|
||||||
|
"desc_albums": {"description": "Description for /albums command", "variables": common_vars},
|
||||||
|
"desc_latest": {"description": "Description for /latest command", "variables": common_vars},
|
||||||
|
"desc_search": {"description": "Description for /search command", "variables": common_vars},
|
||||||
|
"desc_random": {"description": "Description for /random command", "variables": common_vars},
|
||||||
|
"usage_latest": {"description": "Usage example for /latest", "variables": common_vars},
|
||||||
|
"usage_search": {"description": "Usage example for /search", "variables": common_vars},
|
||||||
|
"usage_random": {"description": "Usage example for /random", "variables": common_vars},
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Webhook-specific ---
|
||||||
|
webhook = {
|
||||||
|
"status": {
|
||||||
|
"description": "/status webhook provider summary",
|
||||||
|
"variables": {**common_vars, "provider_name": "Webhook provider name", "last_event": "Last event timestamp"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
**shared,
|
||||||
|
"immich": immich,
|
||||||
|
"gitea": gitea,
|
||||||
|
"planka": planka,
|
||||||
|
"nut": nut,
|
||||||
|
"google_photos": google_photos,
|
||||||
|
"webhook": webhook,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_configs(
|
async def list_configs(
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ from ..database.models import (
|
|||||||
NotificationTarget,
|
NotificationTarget,
|
||||||
NotificationTracker,
|
NotificationTracker,
|
||||||
NotificationTrackerTarget,
|
NotificationTrackerTarget,
|
||||||
TargetReceiver,
|
|
||||||
TelegramChat,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -164,16 +162,3 @@ async def check_notification_target(session: AsyncSession, target_id: int) -> li
|
|||||||
return consumers
|
return consumers
|
||||||
|
|
||||||
|
|
||||||
async def check_notification_tracker(session: AsyncSession, tracker_id: int) -> list[str]:
|
|
||||||
"""Check if a NotificationTracker has any linked targets."""
|
|
||||||
consumers = []
|
|
||||||
result = await session.exec(
|
|
||||||
select(NotificationTrackerTarget).where(
|
|
||||||
NotificationTrackerTarget.tracker_id == tracker_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for tt in result.all():
|
|
||||||
target = await session.get(NotificationTarget, tt.target_id)
|
|
||||||
name = target.name if target else f"#{tt.target_id}"
|
|
||||||
consumers.append(f"Linked Target: {name}")
|
|
||||||
return consumers
|
|
||||||
|
|||||||
@@ -180,7 +180,11 @@ async def create_target(
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(target)
|
await session.refresh(target)
|
||||||
return {"id": target.id, "type": target.type, "name": target.name}
|
# Load receivers for a full response consistent with GET
|
||||||
|
recv_result = await session.exec(
|
||||||
|
select(TargetReceiver).where(TargetReceiver.target_id == target.id)
|
||||||
|
)
|
||||||
|
return _target_response(target, receivers=list(recv_result.all()))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{target_id}")
|
@router.get("/{target_id}")
|
||||||
|
|||||||
@@ -134,10 +134,10 @@ async def get_template_variables(
|
|||||||
"has_oversized_videos": "Whether any video exceeds the target's size limit (boolean)",
|
"has_oversized_videos": "Whether any video exceeds the target's size limit (boolean)",
|
||||||
"max_video_size": "Target video size limit in bytes (null if no limit)",
|
"max_video_size": "Target video size limit in bytes (null if no limit)",
|
||||||
"max_video_size_mb": "Target video size limit in MB (null if no limit)",
|
"max_video_size_mb": "Target video size limit in MB (null if no limit)",
|
||||||
# Immich aliases
|
# Provider-specific aliases
|
||||||
"album_name": "Alias for collection_name",
|
"album_name": "Alias for collection_name",
|
||||||
"album_id": "Alias for collection_id",
|
"album_id": "Alias for collection_id",
|
||||||
"album_url": "Alias for collection_url",
|
"album_url": "Alias for collection_url (Immich) or album product URL (Google Photos)",
|
||||||
}
|
}
|
||||||
rename_vars = {
|
rename_vars = {
|
||||||
**event_vars,
|
**event_vars,
|
||||||
@@ -236,7 +236,7 @@ async def get_template_variables(
|
|||||||
"message_scheduled_message": {
|
"message_scheduled_message": {
|
||||||
"description": "Notification for scheduled message events",
|
"description": "Notification for scheduled message events",
|
||||||
"variables": {
|
"variables": {
|
||||||
"tracker_name": "Name of the tracker that fired",
|
"schedule_name": "Name of the schedule that fired",
|
||||||
"fire_count": "How many times this tracker has fired",
|
"fire_count": "How many times this tracker has fired",
|
||||||
"current_date": "Current date (formatted)",
|
"current_date": "Current date (formatted)",
|
||||||
"current_time": "Current time (formatted)",
|
"current_time": "Current time (formatted)",
|
||||||
@@ -420,6 +420,8 @@ async def update_config(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
config = await _get(session, config_id, user.id)
|
config = await _get(session, config_id, user.id)
|
||||||
|
if config.user_id == 0 and user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Cannot modify system default configs")
|
||||||
for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items():
|
for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items():
|
||||||
if value is not None:
|
if value is not None:
|
||||||
setattr(config, field, value)
|
setattr(config, field, value)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -71,6 +72,98 @@ def _passes_filters(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared dispatch helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _dispatch_webhook_event(
|
||||||
|
engine: Any,
|
||||||
|
provider_id: int,
|
||||||
|
provider_name: str,
|
||||||
|
provider_config: dict[str, Any],
|
||||||
|
event: ServiceEvent,
|
||||||
|
detail_keys: tuple[str, ...],
|
||||||
|
) -> int:
|
||||||
|
"""Load trackers, filter, create EventLogs, dispatch notifications, and commit.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
engine:
|
||||||
|
SQLAlchemy async engine.
|
||||||
|
provider_id:
|
||||||
|
ID of the ServiceProvider that received the webhook.
|
||||||
|
provider_name:
|
||||||
|
Human-readable name of the provider (for logging).
|
||||||
|
provider_config:
|
||||||
|
The provider's ``config`` dict (passed through to target config builder).
|
||||||
|
event:
|
||||||
|
Parsed :class:`ServiceEvent` to dispatch.
|
||||||
|
detail_keys:
|
||||||
|
Keys from ``event.extra`` to include in the EventLog ``details`` dict.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
int
|
||||||
|
Number of successfully dispatched notifications.
|
||||||
|
"""
|
||||||
|
dispatched = 0
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
tracker_result = await session.exec(
|
||||||
|
select(NotificationTracker).where(
|
||||||
|
NotificationTracker.provider_id == provider_id,
|
||||||
|
NotificationTracker.enabled == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
trackers = tracker_result.all()
|
||||||
|
|
||||||
|
for tracker in trackers:
|
||||||
|
filters = tracker.filters or {}
|
||||||
|
if not _passes_filters(event, filters):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Event filtered out for tracker %d (%s)", tracker.id, tracker.name
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
link_data = await load_link_data(session, tracker.id)
|
||||||
|
if not link_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Log event
|
||||||
|
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
|
||||||
|
session.add(EventLog(
|
||||||
|
tracker_id=tracker.id,
|
||||||
|
tracker_name=tracker.name,
|
||||||
|
provider_id=provider_id,
|
||||||
|
provider_name=provider_name,
|
||||||
|
event_type=event.event_type.value,
|
||||||
|
collection_id=event.collection_id,
|
||||||
|
collection_name=event.collection_name,
|
||||||
|
assets_count=0,
|
||||||
|
details={
|
||||||
|
"provider_type": event.provider_type.value,
|
||||||
|
**extra_details,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
# Dispatch to targets
|
||||||
|
dispatcher = NotificationDispatcher()
|
||||||
|
target_configs = _build_target_configs(event, link_data, provider_config)
|
||||||
|
if target_configs:
|
||||||
|
results = await dispatcher.dispatch(event, target_configs)
|
||||||
|
for r in results:
|
||||||
|
if r.get("success"):
|
||||||
|
dispatched += 1
|
||||||
|
else:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Notification failed for tracker %d: %s",
|
||||||
|
tracker.id, r.get("error", "unknown"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return dispatched
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Gitea webhook endpoint
|
# Gitea webhook endpoint
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -108,74 +201,27 @@ async def gitea_webhook(provider_id: int, request: Request):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
except Exception:
|
except (json.JSONDecodeError, ValueError):
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||||
|
|
||||||
event = parse_gitea_webhook(event_header, payload, provider.name)
|
event = parse_gitea_webhook(event_header, payload, provider.name)
|
||||||
if event is None:
|
if event is None:
|
||||||
return {"ok": True, "skipped": "unmapped event"}
|
return {"ok": True, "skipped": "unmapped event"}
|
||||||
|
|
||||||
# --- Find trackers for this provider and dispatch ---
|
# --- Dispatch ---
|
||||||
dispatched = 0
|
dispatched = await _dispatch_webhook_event(
|
||||||
async with AsyncSession(engine) as session:
|
engine=engine,
|
||||||
tracker_result = await session.exec(
|
provider_id=provider_id,
|
||||||
select(NotificationTracker).where(
|
provider_name=provider.name,
|
||||||
NotificationTracker.provider_id == provider_id,
|
provider_config=provider.config or {},
|
||||||
NotificationTracker.enabled == True,
|
event=event,
|
||||||
)
|
detail_keys=(
|
||||||
)
|
"sender", "branch", "commit_count",
|
||||||
trackers = tracker_result.all()
|
"issue_number", "issue_title",
|
||||||
|
"pr_number", "pr_title",
|
||||||
for tracker in trackers:
|
"release_tag", "release_name",
|
||||||
# Apply filters
|
),
|
||||||
filters = tracker.filters or {}
|
)
|
||||||
if not _passes_filters(event, filters):
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Event filtered out for tracker %d (%s)", tracker.id, tracker.name
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Load tracker-target links
|
|
||||||
link_data = await load_link_data(session, tracker.id)
|
|
||||||
if not link_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Log event
|
|
||||||
session.add(EventLog(
|
|
||||||
tracker_id=tracker.id,
|
|
||||||
tracker_name=tracker.name,
|
|
||||||
provider_id=provider_id,
|
|
||||||
provider_name=provider.name,
|
|
||||||
event_type=event.event_type.value,
|
|
||||||
collection_id=event.collection_id,
|
|
||||||
collection_name=event.collection_name,
|
|
||||||
assets_count=0,
|
|
||||||
details={
|
|
||||||
"provider_type": event.provider_type.value,
|
|
||||||
**{k: v for k, v in event.extra.items() if k in (
|
|
||||||
"sender", "branch", "commit_count",
|
|
||||||
"issue_number", "issue_title",
|
|
||||||
"pr_number", "pr_title",
|
|
||||||
"release_tag", "release_name",
|
|
||||||
)},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
# Dispatch to targets
|
|
||||||
dispatcher = NotificationDispatcher()
|
|
||||||
target_configs = _build_target_configs(event, link_data, provider.config or {})
|
|
||||||
if target_configs:
|
|
||||||
results = await dispatcher.dispatch(event, target_configs)
|
|
||||||
for r in results:
|
|
||||||
if r.get("success"):
|
|
||||||
dispatched += 1
|
|
||||||
else:
|
|
||||||
_LOGGER.error(
|
|
||||||
"Notification failed for tracker %d: %s",
|
|
||||||
tracker.id, r.get("error", "unknown"),
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {"ok": True, "dispatched": dispatched}
|
return {"ok": True, "dispatched": dispatched}
|
||||||
|
|
||||||
@@ -218,7 +264,7 @@ async def planka_webhook(provider_id: int, request: Request):
|
|||||||
# Parse payload
|
# Parse payload
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
except Exception:
|
except (json.JSONDecodeError, ValueError):
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||||
|
|
||||||
event_type = payload.get("type", "")
|
event_type = payload.get("type", "")
|
||||||
@@ -230,65 +276,20 @@ async def planka_webhook(provider_id: int, request: Request):
|
|||||||
if event is None:
|
if event is None:
|
||||||
return {"ok": True, "skipped": "unmapped event"}
|
return {"ok": True, "skipped": "unmapped event"}
|
||||||
|
|
||||||
# --- Find trackers for this provider and dispatch ---
|
# --- Dispatch ---
|
||||||
dispatched = 0
|
dispatched = await _dispatch_webhook_event(
|
||||||
async with AsyncSession(engine) as session:
|
engine=engine,
|
||||||
tracker_result = await session.exec(
|
provider_id=provider_id,
|
||||||
select(NotificationTracker).where(
|
provider_name=provider.name,
|
||||||
NotificationTracker.provider_id == provider_id,
|
provider_config=provider.config or {},
|
||||||
NotificationTracker.enabled == True,
|
event=event,
|
||||||
)
|
detail_keys=(
|
||||||
)
|
"sender", "card_name", "board_name",
|
||||||
trackers = tracker_result.all()
|
"list_name", "old_list_name", "new_list_name",
|
||||||
|
"comment_text", "task_name", "attachment_name",
|
||||||
for tracker in trackers:
|
"label_name",
|
||||||
filters = tracker.filters or {}
|
),
|
||||||
if not _passes_filters(event, filters):
|
)
|
||||||
_LOGGER.debug(
|
|
||||||
"Event filtered out for tracker %d (%s)", tracker.id, tracker.name
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
link_data = await load_link_data(session, tracker.id)
|
|
||||||
if not link_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Log event
|
|
||||||
session.add(EventLog(
|
|
||||||
tracker_id=tracker.id,
|
|
||||||
tracker_name=tracker.name,
|
|
||||||
provider_id=provider_id,
|
|
||||||
provider_name=provider.name,
|
|
||||||
event_type=event.event_type.value,
|
|
||||||
collection_id=event.collection_id,
|
|
||||||
collection_name=event.collection_name,
|
|
||||||
assets_count=0,
|
|
||||||
details={
|
|
||||||
"provider_type": event.provider_type.value,
|
|
||||||
**{k: v for k, v in event.extra.items() if k in (
|
|
||||||
"sender", "card_name", "board_name",
|
|
||||||
"list_name", "old_list_name", "new_list_name",
|
|
||||||
"comment_text", "task_name", "attachment_name",
|
|
||||||
"label_name",
|
|
||||||
)},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
# Dispatch to targets
|
|
||||||
dispatcher = NotificationDispatcher()
|
|
||||||
target_configs = _build_target_configs(event, link_data, provider.config or {})
|
|
||||||
if target_configs:
|
|
||||||
results = await dispatcher.dispatch(event, target_configs)
|
|
||||||
for r in results:
|
|
||||||
if r.get("success"):
|
|
||||||
dispatched += 1
|
|
||||||
else:
|
|
||||||
_LOGGER.error(
|
|
||||||
"Notification failed for tracker %d: %s",
|
|
||||||
tracker.id, r.get("error", "unknown"),
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {"ok": True, "dispatched": dispatched}
|
return {"ok": True, "dispatched": dispatched}
|
||||||
|
|
||||||
@@ -424,7 +425,7 @@ async def generic_webhook(provider_id: int, request: Request):
|
|||||||
# Parse JSON payload
|
# Parse JSON payload
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
except Exception:
|
except (json.JSONDecodeError, ValueError):
|
||||||
if store_payloads:
|
if store_payloads:
|
||||||
async with AsyncSession(get_engine()) as log_session:
|
async with AsyncSession(get_engine()) as log_session:
|
||||||
await _save_webhook_log(
|
await _save_webhook_log(
|
||||||
@@ -451,70 +452,28 @@ async def generic_webhook(provider_id: int, request: Request):
|
|||||||
source_ip = request.client.host if request.client else ""
|
source_ip = request.client.host if request.client else ""
|
||||||
event.extra["source_ip"] = source_ip
|
event.extra["source_ip"] = source_ip
|
||||||
|
|
||||||
# --- Find trackers for this provider and dispatch ---
|
# --- Dispatch ---
|
||||||
dispatched = 0
|
dispatched = await _dispatch_webhook_event(
|
||||||
async with AsyncSession(engine) as session:
|
engine=engine,
|
||||||
tracker_result = await session.exec(
|
provider_id=provider_id,
|
||||||
select(NotificationTracker).where(
|
provider_name=provider_name,
|
||||||
NotificationTracker.provider_id == provider_id,
|
provider_config=provider_config,
|
||||||
NotificationTracker.enabled == True,
|
event=event,
|
||||||
)
|
detail_keys=(
|
||||||
)
|
"event_type_raw", "source_ip",
|
||||||
trackers = tracker_result.all()
|
),
|
||||||
|
)
|
||||||
|
|
||||||
for tracker in trackers:
|
# Log matched payload (separate session — dispatch already committed)
|
||||||
filters = tracker.filters or {}
|
if store_payloads:
|
||||||
if not _passes_filters(event, filters):
|
async with AsyncSession(engine) as log_session:
|
||||||
_LOGGER.debug(
|
|
||||||
"Event filtered out for tracker %d (%s)", tracker.id, tracker.name
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
link_data = await load_link_data(session, tracker.id)
|
|
||||||
if not link_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Log event
|
|
||||||
session.add(EventLog(
|
|
||||||
tracker_id=tracker.id,
|
|
||||||
tracker_name=tracker.name,
|
|
||||||
provider_id=provider_id,
|
|
||||||
provider_name=provider_name,
|
|
||||||
event_type=event.event_type.value,
|
|
||||||
collection_id=event.collection_id,
|
|
||||||
collection_name=event.collection_name,
|
|
||||||
assets_count=0,
|
|
||||||
details={
|
|
||||||
"provider_type": "webhook",
|
|
||||||
"event_type_raw": event.extra.get("event_type_raw", ""),
|
|
||||||
"source_ip": source_ip,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
# Dispatch to targets
|
|
||||||
dispatcher = NotificationDispatcher()
|
|
||||||
target_configs = _build_target_configs(event, link_data, provider_config)
|
|
||||||
if target_configs:
|
|
||||||
results = await dispatcher.dispatch(event, target_configs)
|
|
||||||
for r in results:
|
|
||||||
if r.get("success"):
|
|
||||||
dispatched += 1
|
|
||||||
else:
|
|
||||||
_LOGGER.error(
|
|
||||||
"Notification failed for tracker %d: %s",
|
|
||||||
tracker.id, r.get("error", "unknown"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log matched payload
|
|
||||||
if store_payloads:
|
|
||||||
await _save_webhook_log(
|
await _save_webhook_log(
|
||||||
session, provider_id, request.method, safe_headers,
|
log_session, provider_id, request.method, safe_headers,
|
||||||
payload, "matched" if dispatched > 0 else "unmatched",
|
payload, "matched" if dispatched > 0 else "unmatched",
|
||||||
extracted_fields=dict(event.extra),
|
extracted_fields=dict(event.extra),
|
||||||
max_count=max_stored,
|
max_count=max_stored,
|
||||||
)
|
)
|
||||||
|
await log_session.commit()
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {"ok": True, "dispatched": dispatched}
|
return {"ok": True, "dispatched": dispatched}
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,24 @@ async def _seed_default_tracking_configs() -> None:
|
|||||||
"name": "Default Webhook",
|
"name": "Default Webhook",
|
||||||
"track_webhook_received": True,
|
"track_webhook_received": True,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"provider_type": "immich",
|
||||||
|
"name": "Default Immich",
|
||||||
|
"track_assets_added": True,
|
||||||
|
"track_assets_removed": True,
|
||||||
|
"track_collection_renamed": True,
|
||||||
|
"track_collection_deleted": True,
|
||||||
|
"track_sharing_changed": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"provider_type": "google_photos",
|
||||||
|
"name": "Default Google Photos",
|
||||||
|
"track_assets_added": True,
|
||||||
|
"track_assets_removed": True,
|
||||||
|
"track_collection_renamed": True,
|
||||||
|
"track_collection_deleted": True,
|
||||||
|
"track_sharing_changed": False,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"provider_type": "nut",
|
"provider_type": "nut",
|
||||||
"name": "Default NUT",
|
"name": "Default NUT",
|
||||||
|
|||||||
Reference in New Issue
Block a user