You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
998 lines
25 KiB
998 lines
25 KiB
<script lang="ts">
|
|
import type { LogEntry, Transport } from '$logger';
|
|
import {
|
|
LogLevel,
|
|
callbackTransport,
|
|
consoleTransport,
|
|
createEngineLogger
|
|
} from '$logger';
|
|
import { sentryTransport } from '$logger/adapters/sentry';
|
|
import { registerWebVitals } from '$logger/vitals';
|
|
import { browser } from '$app/environment';
|
|
|
|
// Capturamos las entries localmente para mostrarlas en una tabla.
|
|
const captured: LogEntry[] = $state([]);
|
|
const captureTransport: Transport = {
|
|
name: 'ui-capture',
|
|
write(entry) {
|
|
captured.push(entry);
|
|
}
|
|
};
|
|
|
|
const logger = createEngineLogger({
|
|
level: LogLevel.DEBUG,
|
|
captureSource: true,
|
|
globalContext: {
|
|
appVersion: '1.0.0',
|
|
env: 'test',
|
|
sessionId: 'sess-' + Math.random().toString(36).slice(2, 8)
|
|
},
|
|
transports: [
|
|
// Filter vitals out of the console to keep it readable — they
|
|
// still flow to Sentry/Datadog/Loki/Capture.
|
|
consoleTransport({
|
|
level: LogLevel.DEBUG,
|
|
filter: (e) => !e.tags?.includes('vitals')
|
|
}),
|
|
captureTransport
|
|
]
|
|
});
|
|
|
|
const LEVELS = [
|
|
{ name: 'TRACE', level: LogLevel.TRACE, color: '#8a8f96' },
|
|
{ name: 'DEBUG', level: LogLevel.DEBUG, color: '#6a737d' },
|
|
{ name: 'INFO', level: LogLevel.INFO, color: '#0366d6' },
|
|
{ name: 'WARN', level: LogLevel.WARN, color: '#b08800' },
|
|
{ name: 'ERROR', level: LogLevel.ERROR, color: '#d73a49' },
|
|
{ name: 'FATAL', level: LogLevel.FATAL, color: '#6f42c1' }
|
|
] as const;
|
|
|
|
let currentMinLevel = $state(LogLevel.TRACE);
|
|
let counter = $state(1);
|
|
let traceIdFilter = $state('');
|
|
|
|
// Referencias a transports dinámicos para poder mostrarlos / removerlos.
|
|
const attached: { name: string; remove: () => void }[] = $state([]);
|
|
// Forzamos un contador para refrescar la lista de transports (readonly array).
|
|
let transportsVersion = $state(0);
|
|
|
|
function refreshTransports(): void {
|
|
transportsVersion++;
|
|
}
|
|
|
|
function levelName(l: LogLevel): string {
|
|
return LogLevel[l] ?? String(l);
|
|
}
|
|
|
|
function logSample(level: LogLevel): void {
|
|
const message = `Test event #${counter}`;
|
|
const fns = {
|
|
[LogLevel.TRACE]: logger.trace,
|
|
[LogLevel.DEBUG]: logger.debug,
|
|
[LogLevel.INFO]: logger.info,
|
|
[LogLevel.WARN]: logger.warn,
|
|
[LogLevel.ERROR]: logger.error,
|
|
[LogLevel.FATAL]: logger.fatal
|
|
} as const;
|
|
fns[level as keyof typeof fns]?.('demo', message, {
|
|
context: { n: counter },
|
|
tags: ['demo', `counter-${counter % 3}`],
|
|
traceId: `trace-${Math.floor(counter / 3)}`
|
|
});
|
|
counter++;
|
|
}
|
|
|
|
function logWithError(): void {
|
|
try {
|
|
JSON.parse('{ not valid json }');
|
|
} catch (err) {
|
|
logger.error('parse', 'JSON parse error', {
|
|
error: err,
|
|
tags: ['parse', 'critical'],
|
|
context: { input: '{ not valid json }' }
|
|
});
|
|
}
|
|
}
|
|
|
|
function logWithChildContext(): void {
|
|
const requestLogger = logger.child({
|
|
requestId: 'req-' + Math.random().toString(36).slice(2, 6),
|
|
userId: 42
|
|
});
|
|
requestLogger.info('auth', 'Authenticated request');
|
|
requestLogger.warn('auth', 'Token near expiry');
|
|
}
|
|
|
|
function logWithLazyMessage(): void {
|
|
// El thunk solo se evalúa si supera el filtro de nivel global.
|
|
logger.debug('perf', () => {
|
|
const t0 = performance.now();
|
|
// Simular formatting caro
|
|
const data = Array.from({ length: 1000 }, (_, i) => i).reduce((a, b) => a + b, 0);
|
|
return `Lazy-computed sum=${data} in ${(performance.now() - t0).toFixed(2)}ms`;
|
|
});
|
|
}
|
|
|
|
async function logWithTimer(): Promise<void> {
|
|
logger.time('fake-request');
|
|
await new Promise((resolve) => setTimeout(resolve, 150 + Math.random() * 250));
|
|
logger.timeEnd('fake-request', 'perf', 'Simulated request completed');
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Demo: transports dinámicos
|
|
// -------------------------------------------------------------------------
|
|
|
|
function attachFakeSentry(): void {
|
|
const fakeSentry: Transport = {
|
|
name: 'fake-sentry',
|
|
level: LogLevel.ERROR,
|
|
write(entry) {
|
|
console.log('[fake-sentry] captureException/Message', {
|
|
message: entry.message,
|
|
error: entry.error?.message,
|
|
tags: entry.tags
|
|
});
|
|
}
|
|
};
|
|
const remove = logger.addTransport(fakeSentry);
|
|
attached.push({ name: fakeSentry.name!, remove });
|
|
refreshTransports();
|
|
}
|
|
|
|
function attachMetricsSubscriber(): void {
|
|
let errorCount = 0;
|
|
const unsub = logger.subscribe((entry) => {
|
|
if (entry.level >= LogLevel.ERROR) {
|
|
errorCount++;
|
|
console.log(`[metrics] errorCount=${errorCount}`, { category: entry.category });
|
|
}
|
|
});
|
|
attached.push({ name: 'metrics (subscriber)', remove: unsub });
|
|
refreshTransports();
|
|
}
|
|
|
|
function attachSyncFailingTransport(): void {
|
|
const failing: Transport = {
|
|
name: 'sync-fail',
|
|
write() {
|
|
throw new Error('Simulated sync transport failure');
|
|
}
|
|
};
|
|
const remove = logger.addTransport(failing);
|
|
attached.push({ name: failing.name!, remove });
|
|
refreshTransports();
|
|
}
|
|
|
|
function attachAsyncFailingTransport(): void {
|
|
const failing: Transport = {
|
|
name: 'async-fail',
|
|
async write() {
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
throw new Error('Simulated async transport rejection');
|
|
}
|
|
};
|
|
const remove = logger.addTransport(failing);
|
|
attached.push({ name: failing.name!, remove });
|
|
refreshTransports();
|
|
}
|
|
|
|
function detach(idx: number): void {
|
|
attached[idx].remove();
|
|
attached.splice(idx, 1);
|
|
refreshTransports();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Web Vitals integration
|
|
// -------------------------------------------------------------------------
|
|
|
|
let vitalsEnabled = $state(false);
|
|
|
|
async function enableVitals(): Promise<void> {
|
|
if (!browser || vitalsEnabled) return;
|
|
const webVitals = await import('web-vitals');
|
|
registerWebVitals(logger, webVitals, {
|
|
// Custom tag so we can filter this page's vitals easily in dashboards.
|
|
tags: ['test-page'],
|
|
// In this test page we promote every rating to ERROR so Sentry shows
|
|
// them as Issues — useful to validate the pipeline reaches the
|
|
// dashboard. In production you would keep the defaults (good=DEBUG,
|
|
// needs-improvement=INFO, poor=WARN) and route vitals through
|
|
// breadcrumbs or a metrics sink, not as individual Issues.
|
|
levels: {
|
|
good: LogLevel.ERROR,
|
|
'needs-improvement': LogLevel.ERROR,
|
|
poor: LogLevel.ERROR
|
|
}
|
|
});
|
|
vitalsEnabled = true;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Sentry integration
|
|
// -------------------------------------------------------------------------
|
|
|
|
const DSN_KEY = 'logr-test-sentry-dsn';
|
|
let sentryDsn = $state(browser ? sessionStorage.getItem(DSN_KEY) ?? '' : '');
|
|
let sentryStatus = $state<'idle' | 'initializing' | 'ready' | 'error'>('idle');
|
|
let sentryErrorMsg = $state('');
|
|
let sentryAttached = $state(false);
|
|
let sentryRemove: (() => void) | null = null;
|
|
|
|
function persistDsn(): void {
|
|
if (browser) sessionStorage.setItem(DSN_KEY, sentryDsn);
|
|
}
|
|
|
|
async function initSentry(): Promise<void> {
|
|
if (!sentryDsn.trim()) {
|
|
sentryStatus = 'error';
|
|
sentryErrorMsg = 'Pegá una DSN válida';
|
|
return;
|
|
}
|
|
sentryStatus = 'initializing';
|
|
try {
|
|
const Sentry = await import('@sentry/browser');
|
|
Sentry.init({
|
|
dsn: sentryDsn.trim(),
|
|
environment: 'logr-test',
|
|
release: 'logr-test@1.0.0',
|
|
sampleRate: 1.0,
|
|
// Browser tracing captures native Web Vitals (LCP/INP/CLS/FCP/TTFB)
|
|
// and sends them as Performance data — visible under
|
|
// "Insights → Browser → Web Vitals" in the Sentry dashboard.
|
|
integrations: [Sentry.browserTracingIntegration()],
|
|
// 1.0 = every page load sends a transaction; lower it in production.
|
|
tracesSampleRate: 1.0
|
|
});
|
|
persistDsn();
|
|
sentryStatus = 'ready';
|
|
sentryErrorMsg = '';
|
|
} catch (err) {
|
|
sentryStatus = 'error';
|
|
sentryErrorMsg = err instanceof Error ? err.message : String(err);
|
|
}
|
|
}
|
|
|
|
async function attachSentry(): Promise<void> {
|
|
if (sentryStatus !== 'ready') return;
|
|
const Sentry = await import('@sentry/browser');
|
|
sentryRemove = logger.addTransport(
|
|
sentryTransport(Sentry, {
|
|
eventLevel: LogLevel.ERROR,
|
|
breadcrumbLevel: LogLevel.INFO,
|
|
name: 'sentry'
|
|
})
|
|
);
|
|
sentryAttached = true;
|
|
refreshTransports();
|
|
}
|
|
|
|
function detachSentry(): void {
|
|
sentryRemove?.();
|
|
sentryRemove = null;
|
|
sentryAttached = false;
|
|
refreshTransports();
|
|
}
|
|
|
|
async function sendTestToSentry(): Promise<void> {
|
|
// Preparamos breadcrumbs
|
|
logger.info('sentry-test', 'Preparing Sentry test', {
|
|
context: { step: 1 }
|
|
});
|
|
logger.warn('sentry-test', 'About to trigger test error', {
|
|
context: { step: 2 },
|
|
tags: ['pre-flight']
|
|
});
|
|
// Evento real
|
|
try {
|
|
throw new Error('Logr → Sentry test: ' + new Date().toISOString());
|
|
} catch (err) {
|
|
logger.error('sentry-test', 'Test error captured', {
|
|
error: err,
|
|
traceId: 'sentry-test-' + Math.random().toString(36).slice(2, 8),
|
|
tags: ['sentry-test', 'demo'],
|
|
context: { intentional: true }
|
|
});
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
function changeLevel(l: LogLevel): void {
|
|
currentMinLevel = l;
|
|
logger.setLevel(l);
|
|
}
|
|
|
|
function clearHistory(): void {
|
|
logger.clear();
|
|
captured.splice(0, captured.length);
|
|
}
|
|
|
|
function downloadSerialized(): void {
|
|
const json = logger.serialize();
|
|
const blob = new Blob([json], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `logr-${new Date().toISOString()}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
const filtered = $derived(
|
|
traceIdFilter ? captured.filter((e) => e.traceId === traceIdFilter) : captured
|
|
);
|
|
|
|
const stats = $derived({
|
|
total: captured.length,
|
|
byLevel: LEVELS.map((l) => ({
|
|
...l,
|
|
count: captured.filter((e) => e.level === l.level).length
|
|
}))
|
|
});
|
|
|
|
const activeTransports = $derived.by(() => {
|
|
void transportsVersion; // fuerza re-evaluación al cambiar lista
|
|
return logger.transports();
|
|
});
|
|
|
|
// Usado solo en el efecto callback de captureSubscriber (no aplica aquí).
|
|
// Removemos callbackTransport del import si no se usa.
|
|
void callbackTransport;
|
|
|
|
function fileShort(file?: string): string {
|
|
if (!file) return '';
|
|
const idx = file.lastIndexOf('/');
|
|
const short = idx >= 0 ? file.slice(idx + 1) : file;
|
|
return short.split('?')[0];
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>/test/logr</title>
|
|
</svelte:head>
|
|
|
|
<main>
|
|
<header>
|
|
<h1>Prueba del módulo logr</h1>
|
|
<p class="subtitle">
|
|
Logger profesional, <strong>desacoplado de i18n</strong>. Transports dinámicos con filtros
|
|
per-sink, dispatch sync con soporte async, <code>deniedFor</code> en fallos,
|
|
<code>globalContext</code>, <code>child()</code>, <code>time()</code>, mensajes lazy,
|
|
<code>source</code> auto-capturado, error estructurado, <code>traceId</code>,
|
|
<code>tags</code>.
|
|
</p>
|
|
</header>
|
|
|
|
<section class="controls">
|
|
<div class="group">
|
|
<h3>Nivel mínimo</h3>
|
|
<div class="btn-row">
|
|
{#each LEVELS as { name, level } (level)}
|
|
<button
|
|
type="button"
|
|
class:active={currentMinLevel === level}
|
|
onclick={() => changeLevel(level)}
|
|
>
|
|
{name}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<p class="hint">Actual: <strong>{levelName(currentMinLevel)}</strong></p>
|
|
</div>
|
|
|
|
<div class="group">
|
|
<h3>Emitir básico</h3>
|
|
<div class="btn-row">
|
|
{#each LEVELS as { name, level, color } (level)}
|
|
<button
|
|
type="button"
|
|
style:background={color}
|
|
style:color="#fff"
|
|
onclick={() => logSample(level)}
|
|
>
|
|
{name}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<p class="hint">Cada 3 logs comparten <code>traceId</code>.</p>
|
|
</div>
|
|
|
|
<div class="group">
|
|
<h3>Features</h3>
|
|
<div class="btn-row">
|
|
<button type="button" onclick={logWithError}>error + Error</button>
|
|
<button type="button" onclick={logWithChildContext}>child(ctx)</button>
|
|
<button type="button" onclick={logWithTimer}>time/timeEnd</button>
|
|
<button type="button" onclick={logWithLazyMessage}>lazy msg</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="group">
|
|
<h3>Historial</h3>
|
|
<div class="btn-row">
|
|
<button type="button" onclick={clearHistory}>Vaciar</button>
|
|
<button type="button" onclick={downloadSerialized}>Descargar JSON</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="vitals">
|
|
<h2>Web Vitals (RUM)</h2>
|
|
<p class="hint">
|
|
Activa observadores de <code>LCP</code>, <code>INP</code>, <code>CLS</code>,
|
|
<code>FCP</code>, <code>TTFB</code>. Cada métrica se emite como una <code>LogEntry</code> con
|
|
categoría <code>vitals</code> y <code>durationMs</code> = valor. Los transports downstream
|
|
(Sentry, Datadog, Loki, OTel) las reciben automáticamente; el console está filtrado para
|
|
evitar ruido.
|
|
</p>
|
|
<div class="btn-row">
|
|
<button type="button" onclick={enableVitals} disabled={vitalsEnabled}>
|
|
{vitalsEnabled ? '✓ Web Vitals activados' : 'Activar Web Vitals'}
|
|
</button>
|
|
</div>
|
|
{#if vitalsEnabled}
|
|
<p class="hint success">
|
|
✓ Escuchando. Interactuá con la página (click, scroll, tab) o recargá para disparar
|
|
métricas. Aparecerán en el historial con <code>tag: vitals</code>.
|
|
</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="sentry">
|
|
<h2>Integración con Sentry</h2>
|
|
<p class="hint">
|
|
Pegá la DSN de tu proyecto Sentry (se guarda en <code>sessionStorage</code>). El SDK se
|
|
importa lazy para no cargarlo si no se usa.
|
|
</p>
|
|
<div class="sentry-form">
|
|
<label>
|
|
<span>DSN:</span>
|
|
<input
|
|
type="text"
|
|
bind:value={sentryDsn}
|
|
placeholder="https://xxxx@xxxx.ingest.sentry.io/xxxx"
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
/>
|
|
</label>
|
|
<div class="btn-row">
|
|
<button
|
|
type="button"
|
|
onclick={initSentry}
|
|
disabled={sentryStatus === 'initializing' || sentryStatus === 'ready'}
|
|
>
|
|
{sentryStatus === 'ready' ? '✓ Sentry inicializado' : 'Inicializar Sentry'}
|
|
</button>
|
|
{#if sentryStatus === 'ready' && !sentryAttached}
|
|
<button type="button" onclick={attachSentry} class="primary">
|
|
Attach sentryTransport (ERROR+ event, INFO/WARN breadcrumb)
|
|
</button>
|
|
{/if}
|
|
{#if sentryAttached}
|
|
<button type="button" onclick={detachSentry} class="remove-btn">Detach Sentry</button>
|
|
<button type="button" onclick={sendTestToSentry} class="primary">
|
|
Enviar test (2 breadcrumbs + 1 event)
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{#if sentryStatus === 'error'}
|
|
<p class="error-msg">Error: {sentryErrorMsg}</p>
|
|
{/if}
|
|
{#if sentryAttached}
|
|
<p class="hint success">
|
|
✓ Sentry attached como transport. Ahora cualquier log ≥ ERROR irá al dashboard de Sentry;
|
|
INFO y WARN como breadcrumbs del próximo error.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Transports activos ({activeTransports.length})</h2>
|
|
<div class="transports">
|
|
{#each activeTransports as t, i (i)}
|
|
<div class="transport">
|
|
<strong>{t.name ?? 'anonymous'}</strong>
|
|
{#if t.level !== undefined}
|
|
<span class="badge">level: {levelName(t.level)}+</span>
|
|
{/if}
|
|
{#if t.filter}
|
|
<span class="badge">filter</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<h3 class="attach-title">Añadir dinámicamente:</h3>
|
|
<div class="btn-row">
|
|
<button type="button" onclick={attachFakeSentry}>+ fake-sentry (ERROR+)</button>
|
|
<button type="button" onclick={attachMetricsSubscriber}>+ metrics (subscriber)</button>
|
|
<button type="button" onclick={attachSyncFailingTransport}>+ sync-fail</button>
|
|
<button type="button" onclick={attachAsyncFailingTransport}>+ async-fail</button>
|
|
</div>
|
|
|
|
{#if attached.length > 0}
|
|
<h3 class="attach-title">Attached runtime:</h3>
|
|
<div class="attached">
|
|
{#each attached as a, i (i)}
|
|
<div class="attached-item">
|
|
<code>{a.name}</code>
|
|
<button type="button" class="remove" onclick={() => detach(i)}>Quitar</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
<p class="hint">
|
|
Emite un log de nivel ≥ ERROR con el <code>sync-fail</code> o <code>async-fail</code>
|
|
adjunto y observá cómo aparece una entry <code>[logr]</code> con
|
|
<code>deniedFor: ["sync-fail"]</code> — el reporte del fallo NO se vuelve a enviar
|
|
al transport que falló.
|
|
</p>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Estadísticas</h2>
|
|
<div class="stats">
|
|
<div class="stat"><strong>{stats.total}</strong><span>Total</span></div>
|
|
{#each stats.byLevel as { name, count, color } (name)}
|
|
<div class="stat" style:border-color={color}>
|
|
<strong style:color>{count}</strong><span>{name}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Filtro por traceId</h2>
|
|
<label class="filter">
|
|
<span>traceId:</span>
|
|
<input type="text" bind:value={traceIdFilter} placeholder="trace-0" />
|
|
{#if traceIdFilter}
|
|
<button type="button" onclick={() => (traceIdFilter = '')}>Limpiar</button>
|
|
{/if}
|
|
<span class="hint-inline">
|
|
({filtered.length} de {captured.length} coinciden)
|
|
</span>
|
|
</label>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Historial ({filtered.length})</h2>
|
|
{#if filtered.length === 0}
|
|
<p class="empty">Sin entradas. Usá los botones de arriba.</p>
|
|
{:else}
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Nivel</th>
|
|
<th>Cat</th>
|
|
<th>Mensaje</th>
|
|
<th>traceId</th>
|
|
<th>tags</th>
|
|
<th>deniedFor</th>
|
|
<th>source</th>
|
|
<th>dur</th>
|
|
<th>context</th>
|
|
<th>error</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each filtered as entry, i (i)}
|
|
<tr class:failure={entry.deniedFor !== undefined}>
|
|
<td>{i + 1}</td>
|
|
<td>
|
|
<span
|
|
class="level"
|
|
style:background={LEVELS.find((l) => l.level === entry.level)?.color}
|
|
>
|
|
{levelName(entry.level)}
|
|
</span>
|
|
</td>
|
|
<td><code>{entry.category}</code></td>
|
|
<td>{entry.message}</td>
|
|
<td>
|
|
{#if entry.traceId}
|
|
<code class="trace">{entry.traceId}</code>
|
|
{:else}
|
|
<span class="muted">—</span>
|
|
{/if}
|
|
</td>
|
|
<td>
|
|
{#if entry.tags?.length}
|
|
{#each entry.tags as t (t)}
|
|
<span class="tag">{t}</span>
|
|
{/each}
|
|
{:else}
|
|
<span class="muted">—</span>
|
|
{/if}
|
|
</td>
|
|
<td>
|
|
{#if entry.deniedFor?.length}
|
|
{#each entry.deniedFor as d (d)}
|
|
<span class="denied">{d}</span>
|
|
{/each}
|
|
{:else}
|
|
<span class="muted">—</span>
|
|
{/if}
|
|
</td>
|
|
<td>
|
|
{#if entry.source}
|
|
<code class="source" title={entry.source.file}
|
|
>{fileShort(entry.source.file)}:{entry.source.line}</code
|
|
>
|
|
{:else}
|
|
<span class="muted">—</span>
|
|
{/if}
|
|
</td>
|
|
<td>
|
|
{#if entry.durationMs !== undefined}
|
|
<code>{entry.durationMs.toFixed(1)}ms</code>
|
|
{:else}
|
|
<span class="muted">—</span>
|
|
{/if}
|
|
</td>
|
|
<td>
|
|
{#if entry.context}
|
|
<code class="ctx">{JSON.stringify(entry.context)}</code>
|
|
{:else}
|
|
<span class="muted">—</span>
|
|
{/if}
|
|
</td>
|
|
<td>
|
|
{#if entry.error}
|
|
<details>
|
|
<summary><strong>{entry.error.name}</strong>: {entry.error.message}</summary>
|
|
{#if entry.error.stack}
|
|
<pre class="stack">{entry.error.stack}</pre>
|
|
{/if}
|
|
</details>
|
|
{:else}
|
|
<span class="muted">—</span>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
</section>
|
|
</main>
|
|
|
|
<style>
|
|
main {
|
|
max-width: 1280px;
|
|
margin: 2rem auto;
|
|
padding: 0 1.5rem;
|
|
font-family:
|
|
system-ui,
|
|
-apple-system,
|
|
sans-serif;
|
|
line-height: 1.5;
|
|
color: #222;
|
|
}
|
|
h1 {
|
|
margin: 0 0 0.5rem;
|
|
}
|
|
h2 {
|
|
border-bottom: 1px solid #eee;
|
|
padding-bottom: 0.3rem;
|
|
margin-top: 2rem;
|
|
font-size: 1.15rem;
|
|
}
|
|
h3 {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 0.85rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: #586069;
|
|
}
|
|
.subtitle {
|
|
color: #666;
|
|
}
|
|
.controls {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
gap: 1rem;
|
|
background: #f6f8fa;
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
margin: 1.5rem 0;
|
|
}
|
|
.group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.btn-row {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.btn-row button {
|
|
padding: 0.4rem 0.8rem;
|
|
border-radius: 5px;
|
|
border: 1px solid #d1d5da;
|
|
background: #fafbfc;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
}
|
|
.btn-row button.active {
|
|
background: #0366d6;
|
|
color: #fff;
|
|
border-color: #0366d6;
|
|
}
|
|
.hint {
|
|
font-size: 0.8rem;
|
|
color: #666;
|
|
margin: 0.5rem 0 0;
|
|
}
|
|
.hint-inline {
|
|
font-size: 0.8rem;
|
|
color: #666;
|
|
margin-left: 0.5rem;
|
|
}
|
|
.transports {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.transport {
|
|
background: #f0fff4;
|
|
border: 1px solid #bef5cb;
|
|
padding: 0.4rem 0.7rem;
|
|
border-radius: 6px;
|
|
font-size: 0.85rem;
|
|
}
|
|
.badge {
|
|
display: inline-block;
|
|
background: #e1ecf4;
|
|
color: #39739d;
|
|
padding: 0.05em 0.4em;
|
|
margin-left: 0.4em;
|
|
border-radius: 3px;
|
|
font-size: 0.72rem;
|
|
}
|
|
.attach-title {
|
|
margin-top: 1rem;
|
|
}
|
|
.attached {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.attached-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
.remove {
|
|
padding: 0.2rem 0.5rem;
|
|
border-radius: 3px;
|
|
border: 1px solid #d73a49;
|
|
background: #fff;
|
|
color: #d73a49;
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
}
|
|
.sentry {
|
|
background: #fff5f7;
|
|
border: 1px solid #f0b3c0;
|
|
padding: 1rem 1.25rem;
|
|
border-radius: 8px;
|
|
margin: 1.5rem 0;
|
|
}
|
|
.sentry h2 {
|
|
margin-top: 0;
|
|
}
|
|
.vitals {
|
|
background: #f0f7ff;
|
|
border: 1px solid #b3d1f0;
|
|
padding: 1rem 1.25rem;
|
|
border-radius: 8px;
|
|
margin: 1.5rem 0;
|
|
}
|
|
.vitals h2 {
|
|
margin-top: 0;
|
|
}
|
|
.vitals .btn-row button {
|
|
padding: 0.5rem 0.9rem;
|
|
font-size: 0.9rem;
|
|
}
|
|
.vitals .btn-row button:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
.sentry-form {
|
|
margin-top: 0.75rem;
|
|
}
|
|
.sentry-form label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.sentry-form label span {
|
|
font-weight: 600;
|
|
min-width: 3rem;
|
|
}
|
|
.sentry-form input {
|
|
flex: 1;
|
|
padding: 0.5rem 0.7rem;
|
|
border: 1px solid #d1d5da;
|
|
border-radius: 4px;
|
|
font-family: 'SF Mono', 'Menlo', monospace;
|
|
font-size: 0.85rem;
|
|
}
|
|
.sentry-form button:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
.sentry-form button.primary {
|
|
background: #6f42c1;
|
|
color: #fff;
|
|
border-color: #553098;
|
|
}
|
|
.sentry-form button.remove-btn {
|
|
background: #fff;
|
|
color: #d73a49;
|
|
border-color: #d73a49;
|
|
}
|
|
.error-msg {
|
|
background: #ffeef0;
|
|
color: #86181d;
|
|
padding: 0.5rem;
|
|
border-radius: 4px;
|
|
margin-top: 0.5rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
.hint.success {
|
|
color: #22863a;
|
|
}
|
|
.stats {
|
|
display: flex;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.stat {
|
|
background: #fff;
|
|
border: 2px solid #e1e4e8;
|
|
border-radius: 8px;
|
|
padding: 0.75rem 1.2rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
min-width: 80px;
|
|
}
|
|
.stat strong {
|
|
font-size: 1.5rem;
|
|
}
|
|
.stat span {
|
|
font-size: 0.7rem;
|
|
color: #666;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
}
|
|
.empty {
|
|
color: #666;
|
|
font-style: italic;
|
|
}
|
|
.filter {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.filter input {
|
|
padding: 0.3rem 0.5rem;
|
|
border: 1px solid #d1d5da;
|
|
border-radius: 4px;
|
|
font-family: 'SF Mono', 'Menlo', monospace;
|
|
font-size: 0.85rem;
|
|
}
|
|
.filter button {
|
|
padding: 0.3rem 0.6rem;
|
|
border-radius: 4px;
|
|
border: 1px solid #d1d5da;
|
|
background: #fafbfc;
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.82rem;
|
|
}
|
|
th,
|
|
td {
|
|
padding: 0.4rem 0.5rem;
|
|
text-align: left;
|
|
border-bottom: 1px solid #eee;
|
|
vertical-align: top;
|
|
}
|
|
th {
|
|
background: #f6f8fa;
|
|
font-weight: 600;
|
|
font-size: 0.7rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: #586069;
|
|
white-space: nowrap;
|
|
}
|
|
tr.failure {
|
|
background: #fff5f5;
|
|
}
|
|
.level {
|
|
display: inline-block;
|
|
padding: 0.1em 0.5em;
|
|
border-radius: 3px;
|
|
color: #fff;
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
}
|
|
.muted {
|
|
color: #bbb;
|
|
}
|
|
.tag {
|
|
display: inline-block;
|
|
background: #e1ecf4;
|
|
color: #39739d;
|
|
padding: 0.05em 0.4em;
|
|
margin: 0 0.15em 0.15em 0;
|
|
border-radius: 3px;
|
|
font-size: 0.72rem;
|
|
}
|
|
.denied {
|
|
display: inline-block;
|
|
background: #ffeef0;
|
|
color: #86181d;
|
|
padding: 0.05em 0.4em;
|
|
margin: 0 0.15em 0.15em 0;
|
|
border-radius: 3px;
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
}
|
|
.trace {
|
|
color: #6f42c1;
|
|
background: #f5f0ff;
|
|
}
|
|
.source {
|
|
color: #22863a;
|
|
background: #f0fff4;
|
|
font-size: 0.75rem;
|
|
}
|
|
.ctx {
|
|
font-size: 0.72rem;
|
|
max-width: 260px;
|
|
display: inline-block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
vertical-align: middle;
|
|
}
|
|
details summary {
|
|
cursor: pointer;
|
|
font-size: 0.82rem;
|
|
}
|
|
.stack {
|
|
font-size: 0.7rem;
|
|
background: #fff5f5;
|
|
color: #86181d;
|
|
padding: 0.4rem;
|
|
margin-top: 0.3rem;
|
|
border-radius: 3px;
|
|
overflow-x: auto;
|
|
max-width: 300px;
|
|
}
|
|
code {
|
|
background: #f4f4f4;
|
|
padding: 0.05em 0.3em;
|
|
border-radius: 3px;
|
|
font-size: 0.78rem;
|
|
}
|
|
</style>
|