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

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

Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
  notification_tracker).
- command_tracker_listener: + allowed_album_ids.
2026-04-22 01:13:11 +03:00

264 lines
6.3 KiB
Svelte

<script lang="ts">
import { t } from '$lib/i18n';
import { parseDate } from '$lib/api';
import MdiIcon from './MdiIcon.svelte';
interface DayData {
date: string;
[eventType: string]: string | number;
}
let { days = [] }: { days: DayData[] } = $props();
const EVENT_TYPES = ['assets_added', 'assets_removed', 'collection_renamed', 'collection_deleted', 'sharing_changed'] as const;
const COLORS: Record<string, string> = {
assets_added: '#059669',
assets_removed: '#ef4444',
collection_renamed: '#6366f1',
collection_deleted: '#dc2626',
sharing_changed: '#f59e0b',
};
const LABELS: Record<string, string> = {
assets_added: 'dashboard.filterAssetsAdded',
assets_removed: 'dashboard.filterAssetsRemoved',
collection_renamed: 'dashboard.filterRenamed',
collection_deleted: 'dashboard.filterDeleted',
sharing_changed: 'dashboard.filterSharingChanged',
};
let tooltip = $state<{ x: number; y: number; text: string } | null>(null);
const maxValue = $derived.by(() => {
let max = 0;
for (const day of days) {
let sum = 0;
for (const et of EVENT_TYPES) {
sum += (day[et] as number) || 0;
}
if (sum > max) max = sum;
}
return Math.max(max, 1);
});
const hasData = $derived(days.some(d => EVENT_TYPES.some(et => (d[et] as number) > 0)));
// Active event types (ones that actually have data)
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
function formatDate(dateStr: string): string {
const d = parseDate(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function showTooltip(e: MouseEvent, day: DayData) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const parts: string[] = [];
for (const et of EVENT_TYPES) {
const v = (day[et] as number) || 0;
if (v > 0) parts.push(`${t(LABELS[et])}: ${v} ${v === 1 ? t('dashboard.event') : t('dashboard.events')}`);
}
if (parts.length === 0) parts.push(`0 ${t('dashboard.events')}`);
tooltip = {
x: rect.left + rect.width / 2,
y: rect.top,
text: `${formatDate(day.date)}\n${parts.join('\n')}`,
};
}
function hideTooltip() {
tooltip = null;
}
</script>
<div class="chart-wrapper">
<div class="chart-header">
<h4 class="chart-title">
<MdiIcon name="mdiChartBar" size={18} />
{t('dashboard.eventActivity')}
</h4>
<span class="chart-subtitle">{t('dashboard.last14days')}</span>
</div>
{#if !hasData}
<div class="chart-empty">
<MdiIcon name="mdiChartBoxOutline" size={32} />
<span>{t('dashboard.noChartData')}</span>
</div>
{:else}
<div class="chart-body">
<div class="chart-bars">
{#each days as day, i}
{@const total = EVENT_TYPES.reduce((s, et) => s + ((day[et] as number) || 0), 0)}
<div
class="bar-col"
role="img"
aria-label="{formatDate(day.date)}: {total} {t('dashboard.events')}"
onmouseenter={(e) => showTooltip(e, day)}
onmouseleave={hideTooltip}
>
<div class="bar-stack" style="--max: {maxValue}">
{#each EVENT_TYPES as et}
{@const v = (day[et] as number) || 0}
{#if v > 0}
<div
class="bar-segment"
style="height: {(v / maxValue) * 100}%; background: {COLORS[et]};"
></div>
{/if}
{/each}
</div>
<span class="bar-label">{i % 2 === 0 ? formatDate(day.date) : ''}</span>
</div>
{/each}
</div>
<!-- Legend -->
<div class="chart-legend">
{#each activeTypes as et}
<span class="legend-item">
<span class="legend-dot" style="background: {COLORS[et]};"></span>
{t(LABELS[et])}
</span>
{/each}
</div>
</div>
{/if}
</div>
{#if tooltip}
<div
class="chart-tooltip"
style="position: fixed; left: {tooltip.x}px; top: {tooltip.y}px; z-index: 9999; transform: translate(-50%, -100%) translateY(-8px);"
>
{#each tooltip.text.split('\n') as line}
<div>{line}</div>
{/each}
</div>
{/if}
<style>
.chart-wrapper {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
transition: border-color 0.2s;
}
.chart-wrapper:hover {
border-color: var(--color-primary);
box-shadow: 0 0 16px var(--color-glow);
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.chart-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
margin: 0;
}
.chart-subtitle {
font-size: 0.75rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
}
.chart-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 0;
color: var(--color-muted-foreground);
opacity: 0.5;
font-size: 0.8rem;
}
.chart-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chart-bars {
display: flex;
align-items: flex-end;
gap: 3px;
height: 120px;
}
.bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
cursor: default;
}
.bar-stack {
flex: 1;
width: 100%;
display: flex;
flex-direction: column-reverse;
align-items: stretch;
justify-content: flex-start;
border-radius: 3px 3px 0 0;
overflow: hidden;
min-height: 0;
}
.bar-segment {
width: 100%;
min-height: 2px;
transition: height 0.5s ease, opacity 0.2s;
opacity: 0.85;
}
.bar-col:hover .bar-segment {
opacity: 1;
}
.bar-label {
font-size: 0.55rem;
color: var(--color-muted-foreground);
margin-top: 4px;
white-space: nowrap;
font-family: var(--font-mono);
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.65rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.chart-tooltip {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
font-family: var(--font-mono);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
pointer-events: none;
white-space: nowrap;
line-height: 1.5;
}
</style>