a7a2b4efa4
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.
264 lines
6.3 KiB
Svelte
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>
|