1024085cdd
CronTrigger.from_crontab was constructed without a timezone, so a cron like '0 9 * * *' fired at 09:00 host-local instead of 09:00 in the admin-configured timezone. Now all tracker/action cron triggers are built with the app tz, and the setting endpoint rebuilds existing cron jobs when the tz changes (since CronTrigger freezes its tz at construction time). The scheduler provider also renders current_date/time/datetime/weekday in the configured tz and exposes a new 'timezone' template variable. EventLog entries for scheduled_message now include schedule_type, cron_expression/interval_seconds, timezone, and fire_count, and the dashboard shows the event type with a label/icon/color.
429 lines
19 KiB
Svelte
429 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { slide } from 'svelte/transition';
|
|
import { api, parseDate } from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import { providersCache } from '$lib/stores/caches.svelte';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import Loading from '$lib/components/Loading.svelte';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import EventChart from '$lib/components/EventChart.svelte';
|
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
|
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
|
import { getDescriptor } from '$lib/providers';
|
|
|
|
import type { DashboardStatus } from '$lib/types';
|
|
|
|
const CHART_KEY = 'dashboard_chart_visible';
|
|
let chartVisible = $state(typeof localStorage !== 'undefined' ? localStorage.getItem(CHART_KEY) !== 'false' : true);
|
|
function toggleChart() {
|
|
chartVisible = !chartVisible;
|
|
if (typeof localStorage !== 'undefined') localStorage.setItem(CHART_KEY, String(chartVisible));
|
|
}
|
|
|
|
let status = $state<DashboardStatus | null>(null);
|
|
let providers = $derived(providersCache.items);
|
|
const providerFilterItems = $derived([
|
|
{ value: '', label: t('dashboard.allProviders'), icon: 'mdiFilterOff' },
|
|
...providers.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })),
|
|
]);
|
|
let chartDays = $state<{ date: string; [eventType: string]: string | number }[]>([]);
|
|
let loaded = $state(false);
|
|
let error = $state('');
|
|
|
|
let displayProviders = $state(0);
|
|
let displayActive = $state(0);
|
|
let displayTotal = $state(0);
|
|
let displayTargets = $state(0);
|
|
|
|
// Event filters
|
|
let filterEventType = $state('');
|
|
let filterProviderId = $state('');
|
|
let effectiveProviderId = $derived(globalProviderFilter.id ? String(globalProviderFilter.id) : filterProviderId);
|
|
let filterSearch = $state('');
|
|
let filterSort = $state('newest');
|
|
|
|
const EVENTS_PER_PAGE_KEY = 'dashboard_events_per_page';
|
|
function loadEventsPerPage(): number {
|
|
if (typeof localStorage === 'undefined') return 10;
|
|
const stored = localStorage.getItem(EVENTS_PER_PAGE_KEY);
|
|
return stored ? parseInt(stored, 10) || 10 : 10;
|
|
}
|
|
|
|
let eventsLimit = $state(loadEventsPerPage());
|
|
let eventsOffset = $state(0);
|
|
let eventsLoading = $state(false);
|
|
let confirmClearEvents = $state(false);
|
|
|
|
async function clearEvents() {
|
|
try {
|
|
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
|
|
snackSuccess(t('snack.eventsCleared').replace('{count}', String(res.deleted)));
|
|
eventsOffset = 0;
|
|
await loadEvents();
|
|
await loadChart();
|
|
} catch (err: any) {
|
|
snackError(err.message || t('common.error'));
|
|
} finally {
|
|
confirmClearEvents = false;
|
|
}
|
|
}
|
|
|
|
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
|
|
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
|
|
|
|
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
|
|
if (to === 0) { setter(0); return; }
|
|
const start = performance.now();
|
|
function frame(now: number) {
|
|
const elapsed = now - start;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const eased = 1 - Math.pow(1 - progress, 3);
|
|
setter(Math.round(from + (to - from) * eased));
|
|
if (progress < 1) requestAnimationFrame(frame);
|
|
}
|
|
requestAnimationFrame(frame);
|
|
}
|
|
|
|
/** Build filter query string (shared by events list + chart). */
|
|
function buildFilterParams(): URLSearchParams {
|
|
const params = new URLSearchParams();
|
|
if (filterEventType) params.set('event_type', filterEventType);
|
|
if (effectiveProviderId) params.set('provider_id', effectiveProviderId);
|
|
if (filterSearch) params.set('search', filterSearch);
|
|
return params;
|
|
}
|
|
|
|
async function loadEvents() {
|
|
eventsLoading = true;
|
|
try {
|
|
const params = buildFilterParams();
|
|
params.set('sort', filterSort);
|
|
params.set('limit', String(eventsLimit));
|
|
params.set('offset', String(eventsOffset));
|
|
const qs = params.toString();
|
|
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
|
|
} catch (err: any) {
|
|
error = err.message || t('common.error');
|
|
} finally {
|
|
eventsLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadChart() {
|
|
try {
|
|
const params = buildFilterParams();
|
|
const qs = params.toString();
|
|
const chartRes = await api<{ days: { date: string; [k: string]: string | number }[] }>(`/status/chart${qs ? '?' + qs : ''}`);
|
|
chartDays = chartRes.days || [];
|
|
} catch (e) { console.warn('Failed to load chart data:', e); }
|
|
}
|
|
|
|
// Auto-apply when filter values change (via IconGridSelect bind:value)
|
|
let _prevFilterKey = '';
|
|
$effect(() => {
|
|
const key = `${filterEventType}|${effectiveProviderId}|${filterSort}`;
|
|
if (loaded && key !== _prevFilterKey && _prevFilterKey !== '') {
|
|
applyFilters();
|
|
}
|
|
_prevFilterKey = key;
|
|
});
|
|
|
|
function applyFilters() {
|
|
eventsOffset = 0;
|
|
loadEvents();
|
|
loadChart();
|
|
}
|
|
|
|
function goToPage(page: number) {
|
|
eventsOffset = (page - 1) * eventsLimit;
|
|
loadEvents();
|
|
}
|
|
|
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
|
function onSearchInput() {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(applyFilters, 300);
|
|
}
|
|
|
|
onMount(() => {
|
|
loadInitial();
|
|
return () => {
|
|
clearTimeout(searchTimeout);
|
|
};
|
|
});
|
|
|
|
async function loadInitial() {
|
|
try {
|
|
const [statusRes, , chartRes] = await Promise.all([
|
|
api<DashboardStatus>(`/status?limit=${eventsLimit}`),
|
|
providersCache.fetch(),
|
|
api<{ days: { date: string; [k: string]: string | number }[] }>('/status/chart'),
|
|
]);
|
|
status = statusRes;
|
|
chartDays = chartRes.days || [];
|
|
setTimeout(() => {
|
|
if (!status) return;
|
|
animateCount(0, status.providers, (v) => displayProviders = v);
|
|
animateCount(0, status.trackers.active, (v) => displayActive = v);
|
|
animateCount(0, status.trackers.total, (v) => displayTotal = v);
|
|
animateCount(0, status.targets, (v) => displayTargets = v);
|
|
if (status.command_trackers !== undefined) {
|
|
animateCount(0, status.command_trackers, (v) => displayCommandTrackers = v);
|
|
}
|
|
}, 200);
|
|
} catch (err: any) {
|
|
error = err.message || t('common.error');
|
|
} finally {
|
|
loaded = true;
|
|
}
|
|
}
|
|
|
|
let displayCommandTrackers = $state(0);
|
|
|
|
const filteredProviderCount = $derived(globalProviderFilter.providerType
|
|
? providers.filter(p => p.type === globalProviderFilter.providerType).length
|
|
: displayProviders);
|
|
|
|
const providerCard = $derived.by(() => {
|
|
const gp = globalProviderFilter.provider;
|
|
if (gp) {
|
|
const desc = getDescriptor(gp.type);
|
|
return { icon: providerDefaultIcon(gp), label: '', literalLabel: desc?.defaultName ?? gp.type, value: 0, literalValue: gp.name, color: '#0d9488' };
|
|
}
|
|
return { icon: 'mdiServer', label: 'dashboard.providers', value: filteredProviderCount, color: '#0d9488' };
|
|
});
|
|
|
|
interface StatCard { icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; color: string }
|
|
const statCards = $derived<StatCard[]>(status ? [
|
|
providerCard,
|
|
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
|
|
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
|
|
...(status.command_trackers !== undefined ? [{ icon: 'mdiConsoleLine', label: 'nav.commandTrackers', value: displayCommandTrackers, color: '#8b5cf6' }] : []),
|
|
] : []);
|
|
|
|
function timeAgo(dateStr: string): string {
|
|
const diff = Date.now() - parseDate(dateStr).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return t('dashboard.justNow');
|
|
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return t('dashboard.hoursAgo').replace('{n}', String(hours));
|
|
return t('dashboard.daysAgo').replace('{n}', String(Math.floor(hours / 24)));
|
|
}
|
|
|
|
const eventLabels: Record<string, string> = {
|
|
assets_added: 'dashboard.assetsAdded',
|
|
assets_removed: 'dashboard.assetsRemoved',
|
|
collection_renamed: 'dashboard.collectionRenamed',
|
|
collection_deleted: 'dashboard.collectionDeleted',
|
|
sharing_changed: 'dashboard.sharingChanged',
|
|
scheduled_message: 'dashboard.scheduledMessage',
|
|
action_success: 'dashboard.actionSuccess',
|
|
action_partial: 'dashboard.actionPartial',
|
|
action_failed: 'dashboard.actionFailed',
|
|
};
|
|
|
|
const eventIcons: Record<string, string> = {
|
|
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
|
|
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
|
scheduled_message: 'mdiCalendarClock',
|
|
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
|
};
|
|
const eventColors: Record<string, string> = {
|
|
assets_added: '#059669', assets_removed: '#ef4444',
|
|
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
|
scheduled_message: '#8b5cf6',
|
|
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
|
|
};
|
|
|
|
</script>
|
|
|
|
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
|
|
|
{#if !loaded}
|
|
<Loading lines={4} />
|
|
{:else if error}
|
|
<Card>
|
|
<div class="flex items-center gap-2" style="color: var(--color-error-fg);">
|
|
<MdiIcon name="mdiAlertCircle" size={20} />
|
|
<p class="text-sm">{error}</p>
|
|
</div>
|
|
</Card>
|
|
{:else if status}
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8 stagger-children">
|
|
{#each statCards as card, i}
|
|
<div class="stat-card" style="--accent: {card.color};">
|
|
<div class="stat-card-inner">
|
|
<div class="flex items-center gap-3">
|
|
<div class="stat-icon" style="background: {card.color}15; color: {card.color};">
|
|
<MdiIcon name={card.icon} size={22} />
|
|
</div>
|
|
<div>
|
|
<p class="text-sm" style="color: var(--color-muted-foreground);">{card.literalLabel || t(card.label)}</p>
|
|
<p class="{card.literalValue ? 'stat-value-text' : 'stat-value'} font-mono" style="animation-delay: {i * 80 + 200}ms;">
|
|
{#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Events section -->
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h3 class="text-base font-semibold flex items-center gap-2">
|
|
<MdiIcon name="mdiPulse" size={18} />
|
|
{t('dashboard.recentEvents')}
|
|
{#if status.total_events > 0}
|
|
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
|
|
{/if}
|
|
</h3>
|
|
{#if status.total_events > 0}
|
|
<button type="button" onclick={() => confirmClearEvents = true}
|
|
class="clear-events-btn flex items-center gap-1.5 px-2.5 py-1 text-xs border border-[var(--color-border)] rounded-md transition-colors"
|
|
title={t('dashboard.clearEvents')}>
|
|
<MdiIcon name="mdiTrashCanOutline" size={14} />
|
|
<span class="hidden sm:inline">{t('dashboard.clearEvents')}</span>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="flex flex-wrap items-center gap-2 mb-4">
|
|
<div class="flex-1 min-w-[150px] max-w-[260px]">
|
|
<input type="text" bind:value={filterSearch} oninput={onSearchInput}
|
|
placeholder={t('dashboard.searchEvents')}
|
|
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
|
|
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
|
|
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
|
|
</div>
|
|
|
|
<!-- Chart -->
|
|
<button type="button" onclick={toggleChart}
|
|
class="flex items-center gap-1.5 text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors mb-2 cursor-pointer">
|
|
<MdiIcon name={chartVisible ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
|
|
<MdiIcon name="mdiChartBar" size={14} />
|
|
{t('dashboard.chart')}
|
|
</button>
|
|
{#if chartVisible}
|
|
<div in:slide={{ duration: 200 }}>
|
|
<EventChart days={chartDays} />
|
|
</div>
|
|
{/if}
|
|
|
|
{#snippet paginator()}
|
|
<div class="flex items-center justify-center gap-1">
|
|
{#if totalPages > 1}
|
|
<button onclick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1}
|
|
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 disabled:cursor-default">
|
|
<MdiIcon name="mdiChevronLeft" size={16} />
|
|
</button>
|
|
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
|
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
|
|
<button onclick={() => goToPage(page)}
|
|
class="px-2.5 py-1 text-xs font-mono rounded-md border transition-colors {page === currentPage
|
|
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)] border-[var(--color-primary)]'
|
|
: 'border-[var(--color-border)] hover:bg-[var(--color-muted)]'}">
|
|
{page}
|
|
</button>
|
|
{:else if page === currentPage - 2 || page === currentPage + 2}
|
|
<span class="px-1 text-xs text-[var(--color-muted-foreground)]">...</span>
|
|
{/if}
|
|
{/each}
|
|
<button onclick={() => goToPage(currentPage + 1)} disabled={currentPage >= totalPages}
|
|
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 disabled:cursor-default">
|
|
<MdiIcon name="mdiChevronRight" size={16} />
|
|
</button>
|
|
{/if}
|
|
<select value={eventsLimit}
|
|
onchange={(e) => { const v = parseInt((e.target as HTMLSelectElement).value, 10); eventsLimit = v; eventsOffset = 0; if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_PER_PAGE_KEY, String(v)); loadEvents(); }}
|
|
class="ml-2 px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)] text-[var(--color-foreground)]">
|
|
{#each [5, 10, 20, 50] as n}
|
|
<option value={n}>{n}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#if eventsLoading}
|
|
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
|
|
{:else if status.recent_events.length === 0}
|
|
<Card>
|
|
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
|
<div style="opacity: 0.4;"><MdiIcon name="mdiCalendarBlank" size={40} /></div>
|
|
<p class="text-sm">{t('dashboard.noEvents')}</p>
|
|
</div>
|
|
</Card>
|
|
{:else}
|
|
<div class="event-timeline stagger-children">
|
|
{#each status.recent_events as event, i}
|
|
<div class="event-item" style="animation-delay: {i * 60}ms;">
|
|
<div class="event-dot" style="background: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; box-shadow: 0 0 8px {eventColors[event.event_type] || 'var(--color-muted-foreground)'}40;"></div>
|
|
{#if i < status.recent_events.length - 1}<div class="event-line"></div>{/if}
|
|
<div class="event-content">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<div class="flex items-center gap-2 min-w-0 flex-wrap">
|
|
<span style="color: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; flex-shrink: 0;">
|
|
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
|
|
</span>
|
|
<span class="text-sm font-medium truncate">{event.collection_name}</span>
|
|
<span class="event-badge">{t(eventLabels[event.event_type] || event.event_type)}</span>
|
|
{#if event.assets_count > 0}
|
|
<span class="event-badge" style="background: {eventColors[event.event_type]}20; color: {eventColors[event.event_type]};">{event.assets_count} {event.assets_count === 1 ? t('dashboard.asset') : t('dashboard.assets')}</span>
|
|
{/if}
|
|
</div>
|
|
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
|
|
</div>
|
|
{#if event.provider_name || event.tracker_name}
|
|
<div class="flex items-center gap-2 mt-1 text-xs" style="color: var(--color-muted-foreground);">
|
|
{#if event.provider_name}
|
|
<span class="flex items-center gap-1"><MdiIcon name="mdiServer" size={12} />{event.provider_name}</span>
|
|
{/if}
|
|
{#if event.tracker_name}
|
|
<span class="flex items-center gap-1"><MdiIcon name="mdiRadar" size={12} />{event.tracker_name}</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Bottom paginator -->
|
|
<div class="mt-4">
|
|
{@render paginator()}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
|
|
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
|
|
|
|
<style>
|
|
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
|
|
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
|
|
.stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; }
|
|
.stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; }
|
|
.stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; animation: countUp 0.5s ease-out both; }
|
|
.stat-value-text { font-size: 1rem; font-weight: 600; line-height: 1.3; animation: countUp 0.5s ease-out both; }
|
|
.stat-suffix { font-size: 1rem; font-weight: 400; color: var(--color-muted-foreground); }
|
|
.event-timeline { display: flex; flex-direction: column; }
|
|
.event-item { display: flex; align-items: flex-start; gap: 0.75rem; position: relative; padding-bottom: 0.5rem; }
|
|
.event-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; z-index: 1; }
|
|
.event-line { position: absolute; left: 4px; top: 18px; bottom: 0; width: 2px; background: var(--color-border); }
|
|
.event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
|
|
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
|
|
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
|
|
.clear-events-btn { color: var(--color-muted-foreground); background: transparent; }
|
|
.clear-events-btn:hover { background: color-mix(in srgb, var(--color-error-fg) 10%, transparent); border-color: color-mix(in srgb, var(--color-error-fg) 40%, var(--color-border)); color: var(--color-error-fg); }
|
|
</style>
|