From 57608312a068e5bc2c413c0297eb88921a73d125 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 25 May 2026 05:26:44 +0300 Subject: [PATCH 1/3] feat: add trace/chart view, resizable logger columns, trend arrows, stopId display, and green theme --- src/app.css | 2 +- src/lib/components/ComparatorPanel.svelte | 77 ++- src/lib/components/DiffViewer.svelte | 29 +- src/lib/components/GtfsRtLogViewer.svelte | 18 +- src/lib/components/JsonDiff.svelte | 2 +- src/lib/components/JsonTree.svelte | 64 +- src/lib/components/JsonViewer.svelte | 5 +- src/lib/components/KeyLogViewer.svelte | 761 +++++++++++++++++++--- src/lib/components/ProtobufPanel.svelte | 22 +- src/lib/components/ProtobufViewer.svelte | 35 +- src/lib/components/SimpleJsonTree.svelte | 2 +- src/lib/components/SplitPane.svelte | 4 +- src/lib/components/ToolsPanel.svelte | 12 +- src/lib/panelState.svelte.ts | 19 + src/lib/utils/jsonCompare.ts | 52 +- src/lib/utils/search.ts | 35 +- src/routes/+page.svelte | 12 +- 17 files changed, 956 insertions(+), 195 deletions(-) diff --git a/src/app.css b/src/app.css index 989db93..c910ea2 100644 --- a/src/app.css +++ b/src/app.css @@ -43,7 +43,7 @@ body { } *:focus-visible { - outline: 2px solid #3b82f6; + outline: 2px solid #16a34a; outline-offset: 2px; } diff --git a/src/lib/components/ComparatorPanel.svelte b/src/lib/components/ComparatorPanel.svelte index 848c56c..e5e6326 100644 --- a/src/lib/components/ComparatorPanel.svelte +++ b/src/lib/components/ComparatorPanel.svelte @@ -176,19 +176,9 @@ } } - function toggleWatchKey(key: string) { - const current = new SvelteSet(watchedKeys); - if (current.has(key)) { - current.delete(key); - } else { - current.add(key); - } - cmpState.watchedKeysInput = Array.from(current).join(', '); - handleWatchInput(); - } - function getValueByPath(obj: unknown, path: string): unknown { - const parts = path.split('.'); + const normalized = path.replace(/\[(\d+)\]/g, '.$1'); + const parts = normalized.split('.'); function walk(current: unknown, idx: number): unknown { if (idx >= parts.length) return current; if (current === null || current === undefined) return undefined; @@ -488,7 +478,7 @@ type="text" bind:value={cmpState.server1Base} list="server1-url-history" - class="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 font-mono text-sm text-gray-700 transition-all focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:ring-indigo-500/40" + class="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 font-mono text-sm text-gray-700 transition-all focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:ring-green-500/40" /> {#each server1UrlHistory as url (url)} @@ -507,7 +497,7 @@ type="text" bind:value={cmpState.server2Base} list="server2-url-history" - class="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 font-mono text-sm text-gray-700 transition-all focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:ring-indigo-500/40" + class="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 font-mono text-sm text-gray-700 transition-all focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:ring-green-500/40" /> {#each server2UrlHistory as url (url)} @@ -528,7 +518,7 @@ + @@ -783,7 +787,7 @@ class="absolute inset-0 z-50 flex items-center justify-center bg-white/80 backdrop-blur-sm dark:bg-gray-900/80" >
- +
{:else if !cmpState.loading} @@ -823,7 +828,7 @@

Ready to Compare

- Configure your endpoints above, then click Run Comparison to analyze the API response differences.

@@ -872,7 +877,7 @@ type="text" bind:value={cmpState.ignoreSearch} placeholder="Search keys..." - class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200" + class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200" /> @@ -900,13 +905,13 @@ type="checkbox" checked={ignoredKeys.includes(key)} onchange={() => toggleIgnoreKey(key)} - class="h-4 w-4 rounded border-gray-300 bg-white text-indigo-600 focus:ring-indigo-500 focus:ring-offset-0 dark:border-gray-600 dark:bg-gray-700" + class="h-4 w-4 rounded border-gray-300 bg-white text-green-600 focus:ring-green-500 focus:ring-offset-0 dark:border-gray-600 dark:bg-gray-700" /> {key} {#if ignoredKeys.includes(key)} - Ignored {/if} @@ -986,7 +991,7 @@ type="text" bind:value={cmpState.watchSearch} placeholder="Search keys..." - class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200" + class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200" /> @@ -1013,14 +1018,14 @@ toggleWatchKey(key)} - class="h-4 w-4 rounded border-gray-300 bg-white text-indigo-600 focus:ring-indigo-500 focus:ring-offset-0 dark:border-gray-600 dark:bg-gray-700" + onchange={() => cmpState.toggleWatchKey(key)} + class="h-4 w-4 rounded border-gray-300 bg-white text-green-600 focus:ring-green-500 focus:ring-offset-0 dark:border-gray-600 dark:bg-gray-700" /> {key} {#if watchedKeys.includes(key)} - Watching {/if} diff --git a/src/lib/components/DiffViewer.svelte b/src/lib/components/DiffViewer.svelte index 8874659..677ba86 100644 --- a/src/lib/components/DiffViewer.svelte +++ b/src/lib/components/DiffViewer.svelte @@ -9,9 +9,16 @@ response2: unknown; focusPath?: string; ignoredKeys?: string[]; + numericTolerancePercent?: number; } - let { response1, response2, focusPath = '', ignoredKeys = [] }: Props = $props(); + let { + response1, + response2, + focusPath = '', + ignoredKeys = [], + numericTolerancePercent = 0 + }: Props = $props(); const trimmedPath = $derived(focusPath.trim()); const focused1 = $derived(trimmedPath ? getByPath(response1, trimmedPath) : response1); @@ -338,7 +345,7 @@
{#if isSearching} {#if localSearchQuery}
diff --git a/src/lib/components/GtfsRtLogViewer.svelte b/src/lib/components/GtfsRtLogViewer.svelte index af6dbeb..832ba58 100644 --- a/src/lib/components/GtfsRtLogViewer.svelte +++ b/src/lib/components/GtfsRtLogViewer.svelte @@ -131,7 +131,7 @@

{#if totalSnapshotsLoading} Loading... @@ -161,7 +161,7 @@ onclick={() => (gtfsRtLogState.timeRange = range as 'live' | '1h' | '24h' | 'all')} class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-all sm:flex-none {gtfsRtLogState.timeRange === range - ? 'bg-indigo-600 text-white shadow' + ? 'bg-green-600 text-white shadow' : 'text-gray-600 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700'}" > {range === '1h' @@ -203,7 +203,7 @@ bind:value={gtfsRtLogState.limit} min="10" max="1000" - class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200" + class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 focus:border-green-500 focus:ring-2 focus:ring-green-500/30 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200" /> @@ -255,7 +255,7 @@
diff --git a/src/lib/components/JsonDiff.svelte b/src/lib/components/JsonDiff.svelte index 976cdcc..eb7935e 100644 --- a/src/lib/components/JsonDiff.svelte +++ b/src/lib/components/JsonDiff.svelte @@ -134,7 +134,7 @@ class:border-green-400={status === 'added' && side === 'left'} class:border-gray-200={status === 'same'} > - "{key}""{key}": {#if isPrimitive(value)} diff --git a/src/lib/components/JsonTree.svelte b/src/lib/components/JsonTree.svelte index ca1234d..1c69d91 100644 --- a/src/lib/components/JsonTree.svelte +++ b/src/lib/components/JsonTree.svelte @@ -9,6 +9,7 @@ isPrimitive, sortEntries } from '$lib/utils/jsonCompare'; + import { comparatorState } from '$lib/panelState.svelte'; interface Props { value: unknown; @@ -26,6 +27,7 @@ currentPath?: string; syncedExpandedPaths?: Set; onToggle?: (path: string, expanded: boolean) => void; + numericTolerancePercent?: number; } const CHUNK_SIZE = 50; @@ -47,7 +49,8 @@ matchingPaths = new Set(), currentPath = '', syncedExpandedPaths, - onToggle + onToggle, + numericTolerancePercent = 0 }: Props = $props(); function matchesSearch(text: string): boolean { @@ -145,12 +148,23 @@ : false ); + const watchedKeySet = $derived( + new Set( + comparatorState.watchedKeysInput + .split(',') + .map((k) => k.trim()) + .filter((k) => k.length > 0) + ) + ); + + const isWatched = $derived(!!currentPath && watchedKeySet.has(currentPath)); + const isPrimitiveValue = $derived(isPrimitive(value)); const status = $derived( skipComparison ? 'same' : isPrimitiveValue || expanded || level === 0 - ? getDiffStatus(value, otherValue, side, ignoredKeys) + ? getDiffStatus(value, otherValue, side, ignoredKeys, numericTolerancePercent) : 'same' ); const hasDiff = $derived(status !== 'same' && !isReference); @@ -158,7 +172,7 @@ const diffCount = $derived( skipComparison || expanded || isReference || isPrimitiveValue ? 0 - : countDifferences(value, otherValue, ignoredKeys) + : countDifferences(value, otherValue, ignoredKeys, 999, numericTolerancePercent) ); const arrayItems = $derived(isArray(value) ? value : []); @@ -279,7 +293,7 @@ valueMatchesSearch ? 'bg-yellow-50/30 ring-2 ring-yellow-400/50 dark:bg-yellow-900/20' : ''} {currentPath && matchingPaths.has(currentPath) - ? 'bg-indigo-100/50 ring-2 ring-indigo-500/50 dark:bg-indigo-900/30' + ? 'bg-green-100/50 ring-2 ring-green-500/50 dark:bg-green-900/30' : ''}" > {#if isArray(value) || isObject(value)} @@ -290,7 +304,43 @@ {:else} - + + {/if} + + {#if currentPath} + {/if} @@ -353,7 +403,7 @@ e.stopPropagation(); expandAllChildren(); }} - class="flex items-center gap-1 rounded-md border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700 transition-colors hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-900/50" + class="flex items-center gap-1 rounded-md border border-green-200 bg-green-50 px-2 py-0.5 text-[10px] font-medium text-green-700 transition-colors hover:bg-green-100 dark:border-green-800 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50" title="Expand all items" > {/each} {#if hasMoreItems} @@ -499,6 +550,7 @@ currentPath={currentPath ? `${currentPath}.${key}` : key} {syncedExpandedPaths} {onToggle} + {numericTolerancePercent} /> {/each} {#if hasMoreEntries} diff --git a/src/lib/components/JsonViewer.svelte b/src/lib/components/JsonViewer.svelte index f4653bf..3f9947c 100644 --- a/src/lib/components/JsonViewer.svelte +++ b/src/lib/components/JsonViewer.svelte @@ -12,6 +12,7 @@ matchingPaths?: Set; syncedExpandedPaths?: Set; onToggle?: (path: string, expanded: boolean) => void; + numericTolerancePercent?: number; } let { @@ -22,7 +23,8 @@ searchQuery = '', matchingPaths = new Set(), syncedExpandedPaths, - onToggle + onToggle, + numericTolerancePercent = 0 }: Props = $props(); let globalExpand = $state(null); @@ -145,6 +147,7 @@ {matchingPaths} {syncedExpandedPaths} {onToggle} + {numericTolerancePercent} /> {:else}
No JSON data
diff --git a/src/lib/components/KeyLogViewer.svelte b/src/lib/components/KeyLogViewer.svelte index 232aa6f..fb78f63 100644 --- a/src/lib/components/KeyLogViewer.svelte +++ b/src/lib/components/KeyLogViewer.svelte @@ -3,9 +3,9 @@ import { onMount, untrack } from 'svelte'; import { SvelteSet } from 'svelte/reactivity'; import { logState } from '$lib/logState.svelte'; - import { loggerState, type KeyLogEntry } from '$lib/panelState.svelte'; + import { comparatorState, loggerState, type KeyLogEntry } from '$lib/panelState.svelte'; import { fly } from 'svelte/transition'; - import { deepEqualIgnoreOrder } from '$lib/utils/jsonCompare'; + import { deepEqualIgnoreOrder, sortById } from '$lib/utils/jsonCompare'; import JsonViewer from '$lib/components/JsonViewer.svelte'; @@ -30,6 +30,175 @@ let syncedExpandedPaths = new SvelteSet(); let server1ScrollContainer = $state(undefined); + + let traceKeyPath = $state(''); + let showChart = $state(false); + let chartTimeRange = $state<'30m' | '1h' | '2h' | '6h' | '24h' | 'all'>('all'); + let chartLogs = $state([]); + + const trendByLogId = $derived.by(() => { + const map = new Map< + number, + { server1: 'up' | 'down' | 'same' | null; server2: 'up' | 'down' | 'same' | null } + >(); + const last = new Map(); + for (const log of filteredLogs) { + const s1 = typeof log.server1_value === 'number' ? log.server1_value : NaN; + const s2 = typeof log.server2_value === 'number' ? log.server2_value : NaN; + const prev = last.get(log.key_path); + const t1 = + prev !== undefined && !isNaN(s1) + ? s1 > prev.s1 + ? 'up' + : s1 < prev.s1 + ? 'down' + : 'same' + : null; + const t2 = + prev !== undefined && !isNaN(s2) + ? s2 > prev.s2 + ? 'up' + : s2 < prev.s2 + ? 'down' + : 'same' + : null; + map.set(log.id, { server1: t1, server2: t2 }); + last.set(log.key_path, { + s1: isNaN(s1) ? (prev?.s1 ?? 0) : s1, + s2: isNaN(s2) ? (prev?.s2 ?? 0) : s2 + }); + } + return map; + }); + + const S1_COLORS = ['#16a34a', '#15803d', '#166534', '#22c55e', '#4ade80', '#86efac']; + const S2_COLORS = ['#f97316', '#ea580c', '#c2410c', '#fb923c', '#fdba74', '#fed7aa']; + + const chartData = $derived.by(() => { + const empty = { + entries: [] as KeyLogEntry[], + hasNumeric: false, + s1: { series: [] as { label: string; points: { x: number; y: number }[] }[], maxLen: 0 }, + s2: { series: [] as { label: string; points: { x: number; y: number }[] }[], maxLen: 0 }, + minVal: 0, + maxVal: 1, + range: 1 + }; + if (!traceKeyPath) return empty; + + let raw = chartLogs.filter((l) => l.key_path === traceKeyPath); + + if (chartTimeRange !== 'all') { + const ms = { + '30m': 30 * 60 * 1000, + '1h': 60 * 60 * 1000, + '2h': 2 * 60 * 60 * 1000, + '6h': 6 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000 + }[chartTimeRange]; + const cutoff = Date.now() - ms; + raw = raw.filter((l) => new Date(l.timestamp).getTime() >= cutoff); + } + + const entries = raw; + const allRawVals = entries.flatMap((e) => { + const s1 = e.server1_value; + const s2 = e.server2_value; + const a1 = Array.isArray(s1) ? (s1 as unknown[]) : [s1]; + const a2 = Array.isArray(s2) ? (s2 as unknown[]) : [s2]; + return [...a1, ...a2].map((v) => Number(v)); + }); + const hasNumeric = allRawVals.some((v) => !isNaN(v)); + + function buildSeries( + getVal: (e: KeyLogEntry) => unknown, + colors: string[] + ): { series: { label: string; points: { x: number; y: number }[] }[]; maxLen: number } { + const maxLen = entries.reduce((m, e) => { + const v = getVal(e); + return Array.isArray(v) ? Math.max(m, v.length) : Math.max(m, 1); + }, 0); + const series: { label: string; points: { x: number; y: number }[] }[] = []; + for (let i = 0; i < maxLen; i++) { + const points: { x: number; y: number }[] = []; + for (let ei = 0; ei < entries.length; ei++) { + const raw = getVal(entries[ei]); + let val: number; + if (Array.isArray(raw)) { + val = i < raw.length ? Number(raw[i]) : NaN; + } else { + val = i === 0 ? Number(raw) : NaN; + } + if (!isNaN(val)) { + points.push({ x: ei, y: val }); + } + } + if (points.length > 0) { + series.push({ label: `#${i}`, points }); + } + } + return { series, maxLen }; + } + + const s1 = buildSeries((e) => e.server1_value, S1_COLORS); + const s2 = buildSeries((e) => e.server2_value, S2_COLORS); + + const allNumericVals = entries + .flatMap((e) => { + const a1 = Array.isArray(e.server1_value) + ? (e.server1_value as unknown[]).map(Number) + : [Number(e.server1_value)]; + const a2 = Array.isArray(e.server2_value) + ? (e.server2_value as unknown[]).map(Number) + : [Number(e.server2_value)]; + return [...a1, ...a2]; + }) + .filter((v) => !isNaN(v)); + + const minVal = allNumericVals.length > 0 ? Math.min(...allNumericVals) : 0; + const maxVal = allNumericVals.length > 0 ? Math.max(...allNumericVals) : 1; + const range = maxVal - minVal || 1; + + return { entries, hasNumeric, s1, s2, minVal, maxVal, range }; + }); + + let columnWidths = $state>({}); + const COLUMN_KEYS = ['timestamp', 'keypath', 'server1', 'server2', 'match']; + let resizingColumn = $state(null); + let resizeStartX = $state(0); + let resizeStartWidth = $state(0); + + function startResize(e: MouseEvent, col: string) { + e.preventDefault(); + const th = (e.currentTarget as HTMLElement).parentElement; + if (!th) return; + resizingColumn = col; + resizeStartX = e.clientX; + resizeStartWidth = th.offsetWidth; + } + + function resetColumnWidths() { + columnWidths = {}; + } + + $effect(() => { + if (!resizingColumn) return; + function onMouseMove(e: MouseEvent) { + if (!resizingColumn) return; + const diff = e.clientX - resizeStartX; + const newWidth = Math.max(60, resizeStartWidth + diff); + columnWidths = { ...columnWidths, [resizingColumn]: newWidth }; + } + function onMouseUp() { + resizingColumn = null; + } + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + return () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + }); let server2ScrollContainer = $state(undefined); let isScrollSyncing = false; @@ -223,12 +392,39 @@ } } + async function fetchChartLogs() { + if (!loggerState.selectedEndpoint || !traceKeyPath) return; + try { + let url = `/api/keylog?endpoint=${encodeURIComponent(loggerState.selectedEndpoint)}&keyPath=${encodeURIComponent(traceKeyPath)}&limit=10000`; + const res = await fetch(url); + const data = await res.json(); + chartLogs = data.logs || []; + } catch (e) { + console.error('Failed to fetch chart logs:', e); + } + } + + $effect(() => { + void traceKeyPath; + void showChart; + void loggerState.selectedEndpoint; + if (showChart && traceKeyPath && loggerState.selectedEndpoint) { + fetchChartLogs(); + } else { + chartLogs = []; + } + }); + function sortForDisplay(arr: readonly T[]): T[] { if (arr.length === 0) return []; const allNumbers = arr.every((x) => typeof x === 'number' && !Number.isNaN(x)); if (allNumbers) { return [...arr].sort((a, b) => (a as number) - (b as number)); } + const allObjects = arr.every((x) => typeof x === 'object' && x !== null); + if (allObjects) { + return [...arr].sort(sortById); + } return [...arr].sort((a, b) => { const sa = typeof a === 'object' && a !== null ? JSON.stringify(a) : String(a); const sb = typeof b === 'object' && b !== null ? JSON.stringify(b) : String(b); @@ -236,6 +432,11 @@ }); } + function lastPathSegment(path: string): string { + const parts = path.split('.'); + return parts[parts.length - 1]; + } + function formatValue(value: unknown): string { if (value === null || value === undefined) return '—'; if (Array.isArray(value)) return JSON.stringify(sortForDisplay(value)); @@ -244,7 +445,7 @@ } function valuesMatch(v1: unknown, v2: unknown): boolean { - return deepEqualIgnoreOrder(v1, v2); + return deepEqualIgnoreOrder(v1, v2, [], comparatorState.numericTolerancePercent); } function formatTimestamp(ts: string): string { @@ -314,10 +515,18 @@
{loggerState.totalCount} total logs + {#if Object.keys(columnWidths).length > 0} + + {/if}
@@ -332,7 +541,7 @@ + + {#each [...new Set(filteredLogs.map((l) => l.key_path))] as kp (kp)} + + {/each} + + + - -
+
-
+ + {#if showChart && traceKeyPath && chartData.entries.length > 1} + {@const pad = { top: 20, right: 16, bottom: 28, left: 48 }} + {@const cw = 400} + {@const ch = 220} + {@const iw = cw - pad.left - pad.right} + {@const ih = ch - pad.top - pad.bottom} + {@const scaleY = (v: number) => pad.top + ih - ((v - chartData.minVal) / chartData.range) * ih} + {@const scaleX = (ei: number) => + pad.left + (ei / Math.max(chartData.entries.length - 1, 1)) * iw} + {@const yTicks = Array.from({ length: 5 }, (_, i) => { + const v = chartData.minVal + (chartData.range * i) / 4; + return { value: v, y: scaleY(v) }; + })} +
+
+

+ Trace: {traceKeyPath} +

+
+ Time: +
+ {#each ['30m', '1h', '2h', '6h', '24h', 'all'] as range (range)} + + {/each} +
+ ({chartData.entries.length} pts) +
+
+ {#if chartData.hasNumeric} +
+
+

+ Server 1 +

+ + {#each yTicks as tick} + + {/each} + {#each yTicks as tick} + + {tick.value.toFixed(1)} + + {/each} + + + + + {#each chartData.s1.series as s, si} + `${scaleX(p.x)},${scaleY(p.y)}`).join(' ')} + fill="none" + stroke={S1_COLORS[si % S1_COLORS.length]} + stroke-width="2" + stroke-linejoin="round" + stroke-linecap="round" + /> + {/each} + + {#if chartData.s1.series.length > 1} + + {chartData.s1.series.length} lines + + {/if} + + {#if chartData.s1.series.length > 1} + + {#each chartData.s1.series.slice(0, 6) as s, si} + + + {s.label} + + {/each} + + {/if} + +
+
+

+ Server 2 +

+ + {#each yTicks as tick} + + {/each} + {#each yTicks as tick} + + {tick.value.toFixed(1)} + + {/each} + + + + + {#each chartData.s2.series as s, si} + `${scaleX(p.x)},${scaleY(p.y)}`).join(' ')} + fill="none" + stroke={S2_COLORS[si % S2_COLORS.length]} + stroke-width="2" + stroke-linejoin="round" + stroke-linecap="round" + /> + {/each} + + {#if chartData.s2.series.length > 1} + + {chartData.s2.series.length} lines + + {/if} + {#if chartData.s2.series.length > 1} + + {#each chartData.s2.series.slice(0, 6) as s, si} + + + {s.label} + + {/each} + + {/if} + +
+
+ {:else} +
+ Values are not numeric — chart cannot be rendered +
+ {/if} +
+ {/if} + {#if !loggerState.selectedEndpoint}

- No logs yet. Enable key watching in the API Comparator + No logs yet. Enable key watching in the API Comparator to start logging. {/if}

@@ -567,7 +1027,7 @@
- + {:else}
- +
@@ -628,15 +1128,22 @@ openDetail(log)} - class="cursor-pointer transition-colors hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10" + class="cursor-pointer transition-colors hover:bg-green-50/50 dark:hover:bg-green-900/10" > - - - -
- Timestamp + Timestamp + Key Path + Server 1 + Server 2 + Match +
+ {formatTimestamp(log.timestamp)} - {log.key_path} + + {lastPathSegment(log.key_path)} + {#if isArray} {:else} -
+ {@const trend = trendByLogId.get(log.id)} +
+ {#if trend?.server1} + {#if trend.server1 === 'up'} + + + + {:else if trend.server1 === 'down'} + + + + {:else} + + + + {/if} + {/if} {formatValue(log.server1_value)}
{/if}
+ {#if isArray} {:else} -
+ {@const trend = trendByLogId.get(log.id)} +
+ {#if trend?.server2} + {#if trend.server2 === 'up'} + + + + {:else if trend.server2 === 'down'} + + + + {:else} + + + + {/if} + {/if} {formatValue(log.server2_value)}
{/if} @@ -740,7 +1335,7 @@ >

- Log Detail: {selectedLogEntry.key_path}

@@ -836,7 +1431,7 @@ @@ -883,7 +1478,7 @@

Array Compare: - {arrayDetailLog.key_path}

@@ -1059,7 +1654,7 @@ diff --git a/src/lib/components/ProtobufPanel.svelte b/src/lib/components/ProtobufPanel.svelte index 1ef5e11..5118e31 100644 --- a/src/lib/components/ProtobufPanel.svelte +++ b/src/lib/components/ProtobufPanel.svelte @@ -373,7 +373,7 @@ type="text" bind:value={pbState.tripUpdatesUrl} placeholder="https://example.com/gtfs-rt/trip-updates" - class="mt-2 w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 font-mono text-sm text-gray-700 transition-all placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:placeholder:text-gray-600 dark:focus:ring-indigo-500/40" + class="mt-2 w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 font-mono text-sm text-gray-700 transition-all placeholder:text-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:placeholder:text-gray-600 dark:focus:ring-green-500/40" />
@@ -386,7 +386,7 @@ type="text" bind:value={pbState.vehiclePositionsUrl} placeholder="https://example.com/gtfs-rt/vehicle-positions" - class="mt-2 w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 font-mono text-sm text-gray-700 transition-all placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:placeholder:text-gray-600 dark:focus:ring-indigo-500/40" + class="mt-2 w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 font-mono text-sm text-gray-700 transition-all placeholder:text-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:placeholder:text-gray-600 dark:focus:ring-green-500/40" />
@@ -399,7 +399,7 @@ type="text" bind:value={pbState.serviceAlertsUrl} placeholder="https://example.com/gtfs-rt/service-alerts" - class="mt-2 w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 font-mono text-sm text-gray-700 transition-all placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:placeholder:text-gray-600 dark:focus:ring-indigo-500/40" + class="mt-2 w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 font-mono text-sm text-gray-700 transition-all placeholder:text-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:placeholder:text-gray-600 dark:focus:ring-green-500/40" />
@@ -412,7 +412,7 @@ > @@ -425,14 +425,14 @@ value={header.key} oninput={(e) => updateHeader(index, 'key', e.currentTarget.value)} placeholder="Header Name (e.g., x-api-key)" - class="flex-1 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 font-mono text-sm text-gray-700 transition-all placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:placeholder:text-gray-600" + class="flex-1 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 font-mono text-sm text-gray-700 transition-all placeholder:text-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:placeholder:text-gray-600" /> updateHeader(index, 'value', e.currentTarget.value)} placeholder="Header Value" - class="flex-1 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 font-mono text-sm text-gray-700 transition-all placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:placeholder:text-gray-600" + class="flex-1 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 font-mono text-sm text-gray-700 transition-all placeholder:text-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:placeholder:text-gray-600" /> {#if pbState.headers.length > 1} diff --git a/src/lib/components/ProtobufViewer.svelte b/src/lib/components/ProtobufViewer.svelte index e6875f8..e999e34 100644 --- a/src/lib/components/ProtobufViewer.svelte +++ b/src/lib/components/ProtobufViewer.svelte @@ -325,7 +325,7 @@ onclick={() => (protobufState.activeTab = tab.id)} class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all {protobufState.activeTab === tab.id - ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' + ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'}" > {#if tab.icon === 'trip'} @@ -389,7 +389,7 @@ ? 'bg-red-200 text-red-800 dark:bg-red-800 dark:text-red-200' : 'bg-red-100 text-red-600 dark:bg-red-700 dark:text-red-300' : protobufState.activeTab === tab.id - ? 'bg-indigo-200 text-indigo-800 dark:bg-indigo-800 dark:text-indigo-200' + ? 'bg-green-200 text-green-800 dark:bg-green-800 dark:text-green-200' : 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-300'}" title={tab.isLimited ? `Showing ${tab.count} of ${tab.total} (limited to prevent memory issues)` @@ -413,7 +413,7 @@
{#if isSearching} {#if protobufState.searchQuery}