feat: global Docker health indicator and graceful degradation
- GET /api/health endpoint returning Docker connectivity status - Sidebar shows Docker connection dot (green=connected, red=disconnected) - Stale scanner returns store-only results when Docker is unavailable - Polls health every 30s
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<!--
|
||||
Event log entry display component.
|
||||
Shows timestamp, severity badge, source icon, message, and expandable metadata.
|
||||
Event log entry — timeline style.
|
||||
Left severity color bar, compact inline layout, expandable metadata.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { EventLogEntry } from '$lib/types';
|
||||
@@ -15,14 +15,10 @@
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
// ── Relative time formatting ──────────────────────────────────
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
|
||||
const diffSec = Math.floor((now - then) / 1000);
|
||||
if (diffSec < 60) return `${diffSec}s ago`;
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
@@ -30,30 +26,25 @@
|
||||
if (diffHour < 24) return `${diffHour}h ago`;
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
if (diffDay < 30) return `${diffDay}d ago`;
|
||||
const diffMonth = Math.floor(diffDay / 30);
|
||||
return `${diffMonth}mo ago`;
|
||||
return `${Math.floor(diffDay / 30)}mo ago`;
|
||||
}
|
||||
|
||||
function formatFull(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
// ── Severity styling ──────────────────────────────────────────
|
||||
|
||||
const severityClasses: Record<string, string> = {
|
||||
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
warn: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
const severityBar: Record<string, string> = {
|
||||
info: 'bg-blue-400 dark:bg-blue-500',
|
||||
warn: 'bg-amber-400 dark:bg-amber-500',
|
||||
error: 'bg-red-400 dark:bg-red-500'
|
||||
};
|
||||
|
||||
const severityLabelKeys: Record<string, string> = {
|
||||
info: 'events.severity.info',
|
||||
warn: 'events.severity.warn',
|
||||
error: 'events.severity.error'
|
||||
const severityBadge: Record<string, string> = {
|
||||
info: 'text-blue-600 dark:text-blue-400',
|
||||
warn: 'text-amber-600 dark:text-amber-400',
|
||||
error: 'text-red-600 dark:text-red-400'
|
||||
};
|
||||
|
||||
// ── Metadata parsing ──────────────────────────────────────────
|
||||
|
||||
const parsedMetadata = $derived.by<Record<string, unknown> | null>(() => {
|
||||
if (!entry.metadata || entry.metadata === '{}' || entry.metadata === 'null') return null;
|
||||
try {
|
||||
@@ -71,91 +62,56 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-3 transition-all duration-200 hover:border-[var(--border-secondary)]
|
||||
{isNew ? 'animate-fade-in ring-2 ring-[var(--color-brand-200)] dark:ring-[var(--color-brand-800)]' : ''}"
|
||||
class="group relative flex gap-3 py-2.5 px-3 rounded-lg transition-colors duration-150 hover:bg-[var(--surface-card-hover)]
|
||||
{isNew ? 'animate-fade-in bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/10' : ''}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Source icon -->
|
||||
<div class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-[var(--surface-card-hover)] text-[var(--text-tertiary)]">
|
||||
{#if entry.source === 'deploy'}
|
||||
<!-- Rocket -->
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z" />
|
||||
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2Z" />
|
||||
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 3 0 3 0" /><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-3 0-3" />
|
||||
</svg>
|
||||
{:else if entry.source === 'container'}
|
||||
<!-- Box -->
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||
<path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" />
|
||||
</svg>
|
||||
{:else if entry.source === 'proxy'}
|
||||
<!-- Globe -->
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Cog (system) -->
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
{/if}
|
||||
<!-- Severity color bar -->
|
||||
<div class="shrink-0 pt-0.5">
|
||||
<div class="w-0.5 h-full min-h-[1.5rem] rounded-full {severityBar[entry.severity] ?? severityBar.info} opacity-70"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Header: source + severity + timestamp -->
|
||||
<div class="flex items-center gap-1.5 text-xs leading-none">
|
||||
<span class="text-[var(--text-tertiary)]">{$t(`events.source.${entry.source}`)}</span>
|
||||
<span class="text-[var(--text-tertiary)]">·</span>
|
||||
<span class="font-medium {severityBadge[entry.severity] ?? severityBadge.info}">
|
||||
{$t(`events.severity.${entry.severity}`)}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-[var(--text-tertiary)] tabular-nums" title={formatFull(entry.created_at)}>
|
||||
{timeAgo(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<!-- Severity badge -->
|
||||
<span class="inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium {severityClasses[entry.severity] ?? severityClasses.info}">
|
||||
{$t(severityLabelKeys[entry.severity] ?? 'events.severity.info')}
|
||||
</span>
|
||||
<!-- Message -->
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)] leading-relaxed">
|
||||
{entry.message}
|
||||
</p>
|
||||
|
||||
<!-- Source label -->
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
{$t(`events.source.${entry.source}`)}
|
||||
</span>
|
||||
<!-- Expandable metadata -->
|
||||
{#if hasMetadata}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-[var(--text-tertiary)] hover:text-[var(--color-brand-600)] transition-colors"
|
||||
onclick={() => { expanded = !expanded; }}
|
||||
>
|
||||
<svg class="h-2.5 w-2.5 transition-transform duration-150 {expanded ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
{$t('events.metadata')}
|
||||
</button>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<span class="ml-auto shrink-0 text-xs text-[var(--text-tertiary)]" title={formatFull(entry.created_at)}>
|
||||
{timeAgo(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)] leading-relaxed">
|
||||
{entry.message}
|
||||
</p>
|
||||
|
||||
<!-- Expandable metadata -->
|
||||
{#if hasMetadata}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)] transition-colors"
|
||||
onclick={() => { expanded = !expanded; }}
|
||||
>
|
||||
<svg class="h-3 w-3 transition-transform duration-200 {expanded ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
{$t('events.metadata')}
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="mt-2 rounded-md border border-[var(--border-primary)] bg-[var(--surface-page)] p-3 animate-fade-in">
|
||||
<table class="w-full text-xs">
|
||||
<tbody>
|
||||
{#each Object.entries(parsedMetadata ?? {}) as [key, value]}
|
||||
<tr class="border-b border-[var(--border-primary)] last:border-0">
|
||||
<td class="py-1 pr-3 font-medium text-[var(--text-secondary)] whitespace-nowrap align-top">{key}</td>
|
||||
<td class="py-1 text-[var(--text-primary)] break-all">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{#if expanded}
|
||||
<div class="mt-1.5 rounded-md bg-[var(--surface-page)] border border-[var(--border-primary)] p-2.5 animate-fade-in">
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{#each Object.entries(parsedMetadata ?? {}) as [key, value]}
|
||||
<dt class="font-medium text-[var(--text-tertiary)] whitespace-nowrap">{key}</dt>
|
||||
<dd class="text-[var(--text-secondary)] break-all font-mono text-[11px]">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<!--
|
||||
Event log filter controls component.
|
||||
Severity + source multi-select, date range presets, free-text search.
|
||||
Event log filter controls.
|
||||
Compact pill-based severity & source toggles, date range presets, search.
|
||||
Severity pills double as stats display when counts are provided.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import type { EventLogStats } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
severities: string[];
|
||||
sources: string[];
|
||||
dateRange: string;
|
||||
searchText: string;
|
||||
stats?: EventLogStats;
|
||||
onseveritieschange: (v: string[]) => void;
|
||||
onsourceschange: (v: string[]) => void;
|
||||
ondaterangechange: (v: string) => void;
|
||||
@@ -22,6 +25,7 @@
|
||||
sources,
|
||||
dateRange,
|
||||
searchText,
|
||||
stats,
|
||||
onseveritieschange,
|
||||
onsourceschange,
|
||||
ondaterangechange,
|
||||
@@ -39,7 +43,24 @@
|
||||
{ value: 'all', labelKey: 'events.filter.allTime' }
|
||||
] as const;
|
||||
|
||||
// ── Active filter count ──────────────────────────────────────
|
||||
// Severity pill styling
|
||||
const severityStyles: Record<string, { active: string; inactive: string; dot: string }> = {
|
||||
info: {
|
||||
active: 'bg-blue-100 text-blue-700 ring-1 ring-blue-300 dark:bg-blue-900/40 dark:text-blue-300 dark:ring-blue-700',
|
||||
inactive: 'bg-[var(--surface-card-hover)] text-[var(--text-tertiary)] line-through decoration-1',
|
||||
dot: 'bg-blue-500'
|
||||
},
|
||||
warn: {
|
||||
active: 'bg-amber-100 text-amber-700 ring-1 ring-amber-300 dark:bg-amber-900/40 dark:text-amber-300 dark:ring-amber-700',
|
||||
inactive: 'bg-[var(--surface-card-hover)] text-[var(--text-tertiary)] line-through decoration-1',
|
||||
dot: 'bg-amber-500'
|
||||
},
|
||||
error: {
|
||||
active: 'bg-red-100 text-red-700 ring-1 ring-red-300 dark:bg-red-900/40 dark:text-red-300 dark:ring-red-700',
|
||||
inactive: 'bg-[var(--surface-card-hover)] text-[var(--text-tertiary)] line-through decoration-1',
|
||||
dot: 'bg-red-500'
|
||||
}
|
||||
};
|
||||
|
||||
const activeFilterCount = $derived(
|
||||
(severities.length < allSeverities.length ? 1 : 0) +
|
||||
@@ -48,8 +69,6 @@
|
||||
(searchText.trim() !== '' ? 1 : 0)
|
||||
);
|
||||
|
||||
// ── Toggle helpers ───────────────────────────────────────────
|
||||
|
||||
function toggleSeverity(sev: string): void {
|
||||
const next = severities.includes(sev)
|
||||
? severities.filter((s) => s !== sev)
|
||||
@@ -63,105 +82,95 @@
|
||||
: [...sources, src];
|
||||
if (next.length > 0) onsourceschange(next);
|
||||
}
|
||||
|
||||
// ── Severity checkbox colors ─────────────────────────────────
|
||||
|
||||
const severityCheckboxColors: Record<string, string> = {
|
||||
info: 'accent-blue-600',
|
||||
warn: 'accent-amber-600',
|
||||
error: 'accent-red-600'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-4">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:gap-6">
|
||||
<!-- Severity filter -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-[var(--text-secondary)]">{$t('events.filter.severity')}</label>
|
||||
<div class="flex items-center gap-3">
|
||||
{#each allSeverities as sev}
|
||||
<label class="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={severities.includes(sev)}
|
||||
onchange={() => toggleSeverity(sev)}
|
||||
class="h-3.5 w-3.5 rounded border-[var(--border-primary)] {severityCheckboxColors[sev]}"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-primary)]">{$t(`events.severity.${sev}`)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<!-- Row 1: Severity pills (with counts) + search -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<!-- Severity toggle pills -->
|
||||
{#each allSeverities as sev}
|
||||
{@const active = severities.includes(sev)}
|
||||
{@const style = severityStyles[sev]}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-150 select-none cursor-pointer
|
||||
{active ? style.active : style.inactive}"
|
||||
onclick={() => toggleSeverity(sev)}
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full {active ? style.dot : 'bg-[var(--text-tertiary)] opacity-40'}"></span>
|
||||
{$t(`events.severity.${sev}`)}
|
||||
{#if stats}
|
||||
<span class="tabular-nums font-semibold {active ? '' : 'opacity-50'}">{stats[sev as keyof EventLogStats] ?? 0}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Source filter -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-[var(--text-secondary)]">{$t('events.filter.source')}</label>
|
||||
<div class="flex items-center gap-3">
|
||||
{#each allSources as src}
|
||||
<label class="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sources.includes(src)}
|
||||
onchange={() => toggleSource(src)}
|
||||
class="h-3.5 w-3.5 rounded border-[var(--border-primary)] accent-[var(--color-brand-600)]"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-primary)]">{$t(`events.source.${src}`)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Divider -->
|
||||
<div class="h-4 w-px bg-[var(--border-primary)] mx-0.5 hidden sm:block"></div>
|
||||
|
||||
<!-- Date range -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-[var(--text-secondary)]">{$t('events.filter.dateRange')}</label>
|
||||
<div class="flex items-center gap-1">
|
||||
{#each dateRangeOptions as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2 py-1 text-xs font-medium transition-colors
|
||||
{dateRange === opt.value
|
||||
? 'bg-[var(--color-brand-600)] text-white'
|
||||
: 'bg-[var(--surface-card-hover)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}"
|
||||
onclick={() => ondaterangechange(opt.value)}
|
||||
>
|
||||
{$t(opt.labelKey)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Source toggle pills -->
|
||||
{#each allSources as src}
|
||||
{@const active = sources.includes(src)}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-150 select-none cursor-pointer
|
||||
{active
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] ring-1 ring-[var(--border-primary)] shadow-[var(--shadow-sm)]'
|
||||
: 'bg-[var(--surface-card-hover)] text-[var(--text-tertiary)] line-through decoration-1'}"
|
||||
onclick={() => toggleSource(src)}
|
||||
>
|
||||
{$t(`events.source.${src}`)}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Clear filters -->
|
||||
{#if activeFilterCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
onclick={onclear}
|
||||
>
|
||||
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
|
||||
</svg>
|
||||
{$t('events.filter.clear')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Date range + search -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<!-- Date range pills -->
|
||||
<div class="inline-flex items-center rounded-lg bg-[var(--surface-card-hover)] p-0.5">
|
||||
{#each dateRangeOptions as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-150
|
||||
{dateRange === opt.value
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
|
||||
onclick={() => ondaterangechange(opt.value)}
|
||||
>
|
||||
{$t(opt.labelKey)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex-1 space-y-1.5 min-w-0">
|
||||
<label class="text-xs font-medium text-[var(--text-secondary)]"> </label>
|
||||
<div class="relative">
|
||||
<svg class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--text-tertiary)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('events.filter.search')}
|
||||
value={searchText}
|
||||
oninput={(e) => onsearchchange((e.target as HTMLInputElement).value)}
|
||||
class="w-full rounded-md border border-[var(--border-primary)] bg-[var(--surface-page)] py-1.5 pl-8 pr-3 text-xs text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear button + active count -->
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onclick={onclear}
|
||||
disabled={activeFilterCount === 0}
|
||||
>
|
||||
{$t('events.filter.clear')}
|
||||
{#if activeFilterCount > 0}
|
||||
<span class="inline-flex h-4 w-4 items-center justify-center rounded-full bg-[var(--color-brand-600)] text-[10px] font-bold text-white">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="relative flex-1 min-w-[180px] max-w-sm">
|
||||
<svg class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--text-tertiary)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('events.filter.search')}
|
||||
value={searchText}
|
||||
oninput={(e) => onsearchchange((e.target as HTMLInputElement).value)}
|
||||
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-1.5 pl-8 pr-3 text-xs text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-400)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-400)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user