Skip to content

Commit cf5113a

Browse files
authored
Merge pull request #5 from muke1908/copilot/create-namespace-based-on-origin
feat: origin-based namespaces + 10s bucket grid view
2 parents b5a3094 + 5fe9df2 commit cf5113a

4 files changed

Lines changed: 246 additions & 21 deletions

File tree

packages/streamer/server/index.js

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,28 @@ const MIME_TYPES = {
2323
};
2424

2525
// Namespace management
26-
// Map from namespace UUID → Set of viewer WebSocket connections
26+
// Map from namespace → Set of viewer WebSocket connections
2727
const namespaceViewers = new Map();
28-
// Set of namespace UUIDs that currently have an active logger connection
29-
const activeLoggers = new Set();
30-
31-
// UUID pattern used to identify viewer connections by URL path
32-
const UUID_PATH_RE = /^\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i;
28+
// Map from namespace → count of active logger connections
29+
const activeLoggerCounts = new Map();
30+
31+
// Pattern used to identify viewer connections by URL path.
32+
// Matches namespace slugs that are either UUIDs or origin hostnames (e.g. www.domain1.com).
33+
const NAMESPACE_PATH_RE = /^\/([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)\/?$/;
34+
35+
// Valid hostname characters (no colons so IPv6 addresses fall back to UUID).
36+
const VALID_NAMESPACE_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/;
37+
38+
/** Derive a namespace slug from the WebSocket request's Origin header. */
39+
function getNamespaceFromOrigin(originHeader) {
40+
if (!originHeader) return null;
41+
try {
42+
const { hostname } = new URL(originHeader);
43+
return hostname && VALID_NAMESPACE_RE.test(hostname) ? hostname : null;
44+
} catch {
45+
return null;
46+
}
47+
}
3348

3449
// HTTP server — serves the built React client from dist/
3550
const server = http.createServer((req, res) => {
@@ -63,7 +78,7 @@ const server = http.createServer((req, res) => {
6378
// Return the list of namespaces that currently have an active logger
6479
if (reqUrl === '/api/namespaces') {
6580
res.writeHead(200, { 'Content-Type': 'application/json' });
66-
res.end(JSON.stringify([...activeLoggers]));
81+
res.end(JSON.stringify([...activeLoggerCounts.keys()]));
6782
return;
6883
}
6984

@@ -108,11 +123,11 @@ server.listen(PORT, () => {
108123

109124
wss.on('connection', (ws, req) => {
110125
const urlPath = (req.url ?? '/').split('?')[0];
111-
const uuidMatch = urlPath.match(UUID_PATH_RE);
126+
const namespaceMatch = urlPath.match(NAMESPACE_PATH_RE);
112127

113-
if (uuidMatch) {
128+
if (namespaceMatch) {
114129
// ── Viewer connection ─────────────────────────────────────────────────
115-
const namespace = uuidMatch[1].toLowerCase();
130+
const namespace = namespaceMatch[1].toLowerCase();
116131
if (!namespaceViewers.has(namespace)) {
117132
namespaceViewers.set(namespace, new Set());
118133
}
@@ -130,7 +145,7 @@ wss.on('connection', (ws, req) => {
130145
const viewers = namespaceViewers.get(namespace);
131146
if (viewers) {
132147
viewers.delete(ws);
133-
if (viewers.size === 0 && !activeLoggers.has(namespace)) {
148+
if (viewers.size === 0 && !activeLoggerCounts.has(namespace)) {
134149
namespaceViewers.delete(namespace);
135150
}
136151
}
@@ -142,9 +157,16 @@ wss.on('connection', (ws, req) => {
142157
});
143158
} else {
144159
// ── Logger connection ─────────────────────────────────────────────────
145-
const namespace = crypto.randomUUID();
146-
activeLoggers.add(namespace);
147-
namespaceViewers.set(namespace, new Set());
160+
// Derive namespace from the Origin header so that all connections from
161+
// the same origin share one namespace. Fall back to a UUID when the
162+
// header is absent (e.g. Node.js clients) or contains an unusable value.
163+
const originNamespace = getNamespaceFromOrigin(req.headers.origin);
164+
const namespace = originNamespace ?? crypto.randomUUID();
165+
166+
activeLoggerCounts.set(namespace, (activeLoggerCounts.get(namespace) ?? 0) + 1);
167+
if (!namespaceViewers.has(namespace)) {
168+
namespaceViewers.set(namespace, new Set());
169+
}
148170
console.log(`Logger connected | namespace: ${namespace}`);
149171
console.log(`View logs at: http://localhost:${PORT}/${namespace}`);
150172

@@ -175,10 +197,15 @@ wss.on('connection', (ws, req) => {
175197
});
176198

177199
ws.on('close', () => {
178-
activeLoggers.delete(namespace);
179-
const viewers = namespaceViewers.get(namespace);
180-
if (viewers && viewers.size === 0) {
181-
namespaceViewers.delete(namespace);
200+
const count = (activeLoggerCounts.get(namespace) ?? 1) - 1;
201+
if (count <= 0) {
202+
activeLoggerCounts.delete(namespace);
203+
const viewers = namespaceViewers.get(namespace);
204+
if (viewers && viewers.size === 0) {
205+
namespaceViewers.delete(namespace);
206+
}
207+
} else {
208+
activeLoggerCounts.set(namespace, count);
182209
}
183210
console.log(`Logger disconnected | namespace: ${namespace}`);
184211
});

packages/streamer/src/App.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,26 @@ import { useWebSocket } from './hooks/useWebSocket';
33
import { Header } from './components/Header';
44
import { FilterBar } from './components/FilterBar';
55
import { LogViewer } from './components/LogViewer';
6+
import { BucketViewer } from './components/BucketViewer';
67
import { LLMPanel } from './components/LLMPanel';
78
import { NamespaceLanding } from './components/NamespaceLanding';
89
import type { FilterState, LogLevel } from './types';
910

1011
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
11-
const UUID_PATH_RE = /^\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i;
12+
// Accept both UUID-style and origin-hostname-style namespaces (e.g. www.domain1.com)
13+
const NAMESPACE_PATH_RE = /^\/([a-zA-Z0-9][a-zA-Z0-9._-]+)\/?$/i;
1214

1315
function getNamespaceFromPath(): string | null {
14-
const match = window.location.pathname.match(UUID_PATH_RE);
16+
const match = window.location.pathname.match(NAMESPACE_PATH_RE);
1517
return match ? match[1].toLowerCase() : null;
1618
}
1719

20+
type ViewMode = 'list' | 'bucket';
21+
1822
function LogConsole({ wsUrl }: { wsUrl: string }) {
1923
const { logs, status, isPaused, togglePause, clearLogs } = useWebSocket(wsUrl);
2024
const [llmPanelOpen, setLlmPanelOpen] = useState(false);
25+
const [viewMode, setViewMode] = useState<ViewMode>('list');
2126
const [filter, setFilter] = useState<FilterState>({
2227
levels: new Set<LogLevel>(['log', 'info', 'warn', 'error', 'debug']),
2328
searchText: '',
@@ -46,12 +51,22 @@ function LogConsole({ wsUrl }: { wsUrl: string }) {
4651
onExportLogs={handleExportLogs}
4752
onToggleLLMPanel={() => setLlmPanelOpen(!llmPanelOpen)}
4853
llmPanelOpen={llmPanelOpen}
54+
viewMode={viewMode}
55+
onToggleViewMode={() => setViewMode(v => v === 'list' ? 'bucket' : 'list')}
4956
/>
5057

5158
<FilterBar filter={filter} onFilterChange={setFilter} />
5259

5360
<div className="flex-1 flex overflow-hidden relative">
54-
<LogViewer logs={logs} filter={filter} />
61+
{viewMode === 'list' ? (
62+
<LogViewer logs={logs} filter={filter} />
63+
) : (
64+
<BucketViewer
65+
logs={logs}
66+
searchText={filter.searchText}
67+
caseSensitive={filter.caseSensitive}
68+
/>
69+
)}
5570
<LLMPanel
5671
logs={logs}
5772
isOpen={llmPanelOpen}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { useMemo, useRef, useEffect } from 'react';
2+
import type { LogMessage } from '../types';
3+
4+
interface BucketViewerProps {
5+
logs: LogMessage[];
6+
searchText: string;
7+
caseSensitive: boolean;
8+
}
9+
10+
const BUCKET_SIZE_MS = 10_000; // 10-second buckets
11+
12+
const BUCKET_LEVELS = ['error', 'debug', 'info', 'warn'] as const;
13+
type BucketLevel = (typeof BUCKET_LEVELS)[number];
14+
15+
interface BucketData {
16+
bucketStart: number;
17+
error: LogMessage[];
18+
debug: LogMessage[];
19+
info: LogMessage[];
20+
warn: LogMessage[];
21+
}
22+
23+
const levelTextColors: Record<BucketLevel, string> = {
24+
error: 'text-red-400',
25+
debug: 'text-purple-400',
26+
info: 'text-blue-400',
27+
warn: 'text-yellow-400',
28+
};
29+
30+
const levelHeaderColors: Record<BucketLevel, string> = {
31+
error: 'bg-red-950 text-red-300 border-red-800',
32+
debug: 'bg-purple-950 text-purple-300 border-purple-800',
33+
info: 'bg-blue-950 text-blue-300 border-blue-800',
34+
warn: 'bg-yellow-950 text-yellow-300 border-yellow-800',
35+
};
36+
37+
function formatArgs(args: any[]): string {
38+
return args
39+
.map(arg => {
40+
if (typeof arg === 'object' && arg !== null) {
41+
try {
42+
return JSON.stringify(arg);
43+
} catch {
44+
return String(arg);
45+
}
46+
}
47+
return String(arg);
48+
})
49+
.join(' ');
50+
}
51+
52+
function formatBucketTime(bucketStart: number): string {
53+
const fmt = (ts: number) =>
54+
new Date(ts).toLocaleTimeString('en-US', {
55+
hour12: false,
56+
hour: '2-digit',
57+
minute: '2-digit',
58+
second: '2-digit',
59+
});
60+
return `${fmt(bucketStart)}${fmt(bucketStart + BUCKET_SIZE_MS)}`;
61+
}
62+
63+
export function BucketViewer({ logs, searchText, caseSensitive }: BucketViewerProps) {
64+
const bottomRef = useRef<HTMLDivElement>(null);
65+
66+
const buckets = useMemo<BucketData[]>(() => {
67+
const map = new Map<number, BucketData>();
68+
69+
for (const log of logs) {
70+
if (!(BUCKET_LEVELS as readonly string[]).includes(log.type)) continue;
71+
72+
if (searchText) {
73+
const msg = formatArgs(log.args);
74+
const haystack = caseSensitive ? msg : msg.toLowerCase();
75+
const needle = caseSensitive ? searchText : searchText.toLowerCase();
76+
if (!haystack.includes(needle)) continue;
77+
}
78+
79+
const key = Math.floor(log.timestamp / BUCKET_SIZE_MS) * BUCKET_SIZE_MS;
80+
if (!map.has(key)) {
81+
map.set(key, { bucketStart: key, error: [], debug: [], info: [], warn: [] });
82+
}
83+
(map.get(key)![log.type as BucketLevel] as LogMessage[]).push(log);
84+
}
85+
86+
return Array.from(map.values()).sort((a, b) => a.bucketStart - b.bucketStart);
87+
}, [logs, searchText, caseSensitive]);
88+
89+
// Auto-scroll to the latest bucket
90+
useEffect(() => {
91+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
92+
}, [buckets.length]);
93+
94+
if (buckets.length === 0) {
95+
return (
96+
<div className="flex-1 flex items-center justify-center bg-[#0d1117] text-gray-500">
97+
<div className="text-center">
98+
<p className="text-lg mb-2">No logs to display</p>
99+
<p className="text-sm">Waiting for log messages…</p>
100+
</div>
101+
</div>
102+
);
103+
}
104+
105+
return (
106+
<div className="flex-1 overflow-auto bg-[#0d1117] min-h-0">
107+
<table className="w-full border-collapse text-sm table-fixed">
108+
<colgroup>
109+
<col className="w-36" />
110+
<col />
111+
<col />
112+
<col />
113+
<col />
114+
</colgroup>
115+
<thead className="sticky top-0 z-10">
116+
<tr>
117+
<th className="px-3 py-2 text-left text-gray-400 font-medium bg-[#161b22] border-b border-r border-gray-700">
118+
Time Bucket
119+
</th>
120+
{BUCKET_LEVELS.map(level => (
121+
<th
122+
key={level}
123+
className={`px-3 py-2 text-left font-semibold border-b border-r uppercase tracking-wide ${levelHeaderColors[level]}`}
124+
>
125+
{level}
126+
</th>
127+
))}
128+
</tr>
129+
</thead>
130+
<tbody>
131+
{buckets.map((bucket, i) => (
132+
<tr
133+
key={bucket.bucketStart}
134+
className={i % 2 === 0 ? 'bg-[#0d1117]' : 'bg-[#111820]'}
135+
>
136+
<td className="px-3 py-2 text-gray-500 font-mono text-xs align-top border-b border-r border-gray-800 whitespace-nowrap">
137+
{formatBucketTime(bucket.bucketStart)}
138+
</td>
139+
{BUCKET_LEVELS.map(level => (
140+
<td
141+
key={level}
142+
className="px-3 py-2 align-top border-b border-r border-gray-800"
143+
>
144+
{bucket[level].length === 0 ? (
145+
<span className="text-gray-700 text-xs italic">n/a</span>
146+
) : (
147+
<div className="space-y-1">
148+
{bucket[level].map(log => (
149+
<div
150+
key={log.id}
151+
className={`font-mono text-xs break-words ${levelTextColors[level]}`}
152+
>
153+
{formatArgs(log.args)}
154+
</div>
155+
))}
156+
</div>
157+
)}
158+
</td>
159+
))}
160+
</tr>
161+
))}
162+
</tbody>
163+
</table>
164+
<div ref={bottomRef} />
165+
</div>
166+
);
167+
}

packages/streamer/src/components/Header.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ interface HeaderProps {
99
onExportLogs: () => void;
1010
onToggleLLMPanel: () => void;
1111
llmPanelOpen: boolean;
12+
viewMode: 'list' | 'bucket';
13+
onToggleViewMode: () => void;
1214
}
1315

1416
export function Header({
@@ -19,6 +21,8 @@ export function Header({
1921
onExportLogs,
2022
onToggleLLMPanel,
2123
llmPanelOpen,
24+
viewMode,
25+
onToggleViewMode,
2226
}: HeaderProps) {
2327
return (
2428
<header className="bg-[#161b22] border-b border-gray-700 px-6 py-4">
@@ -31,6 +35,18 @@ export function Header({
3135
</div>
3236

3337
<div className="flex items-center gap-3">
38+
<button
39+
onClick={onToggleViewMode}
40+
className={`px-4 py-2 rounded-md font-medium transition-colors ${
41+
viewMode === 'bucket'
42+
? 'bg-blue-600 hover:bg-blue-700 text-white'
43+
: 'bg-gray-700 hover:bg-gray-600 text-gray-200'
44+
}`}
45+
title={viewMode === 'bucket' ? 'Switch to list view' : 'Switch to bucket view'}
46+
>
47+
{viewMode === 'bucket' ? '☰ List' : '⊞ Bucket'}
48+
</button>
49+
3450
<button
3551
onClick={onTogglePause}
3652
className={`px-4 py-2 rounded-md font-medium transition-colors ${

0 commit comments

Comments
 (0)