trickle-backend 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/db/connection.d.ts +3 -0
- package/dist/db/connection.js +16 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.js +51 -0
- package/dist/db/queries.d.ts +70 -0
- package/dist/db/queries.js +186 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +10 -0
- package/dist/routes/audit.d.ts +2 -0
- package/dist/routes/audit.js +251 -0
- package/dist/routes/codegen.d.ts +2 -0
- package/dist/routes/codegen.js +224 -0
- package/dist/routes/coverage.d.ts +2 -0
- package/dist/routes/coverage.js +98 -0
- package/dist/routes/dashboard.d.ts +2 -0
- package/dist/routes/dashboard.js +433 -0
- package/dist/routes/diff.d.ts +2 -0
- package/dist/routes/diff.js +181 -0
- package/dist/routes/errors.d.ts +2 -0
- package/dist/routes/errors.js +86 -0
- package/dist/routes/functions.d.ts +2 -0
- package/dist/routes/functions.js +69 -0
- package/dist/routes/ingest.d.ts +2 -0
- package/dist/routes/ingest.js +111 -0
- package/dist/routes/mock.d.ts +2 -0
- package/dist/routes/mock.js +57 -0
- package/dist/routes/search.d.ts +2 -0
- package/dist/routes/search.js +136 -0
- package/dist/routes/tail.d.ts +2 -0
- package/dist/routes/tail.js +11 -0
- package/dist/routes/types.d.ts +2 -0
- package/dist/routes/types.js +97 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +40 -0
- package/dist/services/sse-broker.d.ts +10 -0
- package/dist/services/sse-broker.js +39 -0
- package/dist/services/type-differ.d.ts +2 -0
- package/dist/services/type-differ.js +126 -0
- package/dist/services/type-generator.d.ts +319 -0
- package/dist/services/type-generator.js +3207 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +2 -0
- package/package.json +22 -0
- package/src/db/connection.ts +16 -0
- package/src/db/migrations.ts +50 -0
- package/src/db/queries.ts +260 -0
- package/src/index.ts +11 -0
- package/src/routes/audit.ts +283 -0
- package/src/routes/codegen.ts +237 -0
- package/src/routes/coverage.ts +120 -0
- package/src/routes/dashboard.ts +435 -0
- package/src/routes/diff.ts +215 -0
- package/src/routes/errors.ts +91 -0
- package/src/routes/functions.ts +75 -0
- package/src/routes/ingest.ts +139 -0
- package/src/routes/mock.ts +66 -0
- package/src/routes/search.ts +169 -0
- package/src/routes/tail.ts +12 -0
- package/src/routes/types.ts +106 -0
- package/src/server.ts +40 -0
- package/src/services/sse-broker.ts +51 -0
- package/src/services/type-differ.ts +141 -0
- package/src/services/type-generator.ts +3853 -0
- package/src/types.ts +37 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const express_1 = require("express");
|
|
4
|
+
const router = (0, express_1.Router)();
|
|
5
|
+
router.get("/", (_req, res) => {
|
|
6
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
7
|
+
res.send(dashboardHtml());
|
|
8
|
+
});
|
|
9
|
+
function dashboardHtml() {
|
|
10
|
+
return `<!DOCTYPE html>
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="UTF-8">
|
|
14
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
15
|
+
<title>trickle dashboard</title>
|
|
16
|
+
<style>
|
|
17
|
+
:root {
|
|
18
|
+
--bg: #0d1117; --surface: #161b22; --border: #30363d;
|
|
19
|
+
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
|
|
20
|
+
--green: #3fb950; --red: #f85149; --yellow: #d29922; --purple: #bc8cff;
|
|
21
|
+
}
|
|
22
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
23
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
24
|
+
background: var(--bg); color: var(--text); line-height: 1.5; }
|
|
25
|
+
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
|
26
|
+
|
|
27
|
+
header { display: flex; align-items: center; justify-content: space-between;
|
|
28
|
+
padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
|
|
29
|
+
header h1 { font-size: 20px; font-weight: 600; }
|
|
30
|
+
header h1 span { color: var(--accent); }
|
|
31
|
+
.status { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); }
|
|
32
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
33
|
+
.dot.live { background: var(--green); animation: pulse 2s infinite; }
|
|
34
|
+
.dot.off { background: var(--red); }
|
|
35
|
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
36
|
+
|
|
37
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
38
|
+
gap: 12px; margin-bottom: 24px; }
|
|
39
|
+
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
40
|
+
padding: 16px; }
|
|
41
|
+
.stat-value { font-size: 28px; font-weight: 700; color: var(--accent); }
|
|
42
|
+
.stat-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
43
|
+
|
|
44
|
+
.search { margin-bottom: 16px; }
|
|
45
|
+
.search input { width: 100%; padding: 10px 14px; background: var(--surface);
|
|
46
|
+
border: 1px solid var(--border); border-radius: 6px; color: var(--text);
|
|
47
|
+
font-size: 14px; outline: none; }
|
|
48
|
+
.search input:focus { border-color: var(--accent); }
|
|
49
|
+
.search input::placeholder { color: var(--muted); }
|
|
50
|
+
|
|
51
|
+
.route-list { display: flex; flex-direction: column; gap: 8px; }
|
|
52
|
+
.route { background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
53
|
+
overflow: hidden; transition: border-color 0.15s; }
|
|
54
|
+
.route:hover { border-color: var(--accent); }
|
|
55
|
+
.route.new { animation: highlight 1.5s; }
|
|
56
|
+
@keyframes highlight { 0% { border-color: var(--green); box-shadow: 0 0 12px rgba(63,185,80,0.15); }
|
|
57
|
+
100% { border-color: var(--border); box-shadow: none; } }
|
|
58
|
+
|
|
59
|
+
.route-header { display: flex; align-items: center; gap: 10px; padding: 12px 16px;
|
|
60
|
+
cursor: pointer; user-select: none; }
|
|
61
|
+
.method { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px;
|
|
62
|
+
text-transform: uppercase; letter-spacing: 0.5px; }
|
|
63
|
+
.method.get { background: rgba(63,185,80,0.15); color: var(--green); }
|
|
64
|
+
.method.post { background: rgba(88,166,255,0.15); color: var(--accent); }
|
|
65
|
+
.method.put { background: rgba(210,153,34,0.15); color: var(--yellow); }
|
|
66
|
+
.method.delete { background: rgba(248,81,73,0.15); color: var(--red); }
|
|
67
|
+
.method.patch { background: rgba(188,140,255,0.15); color: var(--purple); }
|
|
68
|
+
.route-path { font-size: 14px; font-weight: 500; font-family: 'SF Mono', Menlo, monospace; }
|
|
69
|
+
.route-meta { margin-left: auto; font-size: 12px; color: var(--muted); }
|
|
70
|
+
.chevron { color: var(--muted); transition: transform 0.15s; font-size: 12px; }
|
|
71
|
+
.route.open .chevron { transform: rotate(90deg); }
|
|
72
|
+
|
|
73
|
+
.route-detail { display: none; padding: 0 16px 16px; border-top: 1px solid var(--border); }
|
|
74
|
+
.route.open .route-detail { display: block; }
|
|
75
|
+
|
|
76
|
+
.type-section { margin-top: 12px; }
|
|
77
|
+
.type-label { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase;
|
|
78
|
+
letter-spacing: 0.5px; margin-bottom: 6px; }
|
|
79
|
+
.type-block { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
|
80
|
+
padding: 12px; font-family: 'SF Mono', Menlo, monospace; font-size: 13px;
|
|
81
|
+
line-height: 1.6; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
|
82
|
+
.type-block .key { color: var(--accent); }
|
|
83
|
+
.type-block .type { color: var(--green); }
|
|
84
|
+
.type-block .punct { color: var(--muted); }
|
|
85
|
+
|
|
86
|
+
.sample-section { margin-top: 12px; }
|
|
87
|
+
.sample-block { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
|
88
|
+
padding: 12px; font-family: 'SF Mono', Menlo, monospace; font-size: 12px;
|
|
89
|
+
line-height: 1.5; overflow-x: auto; white-space: pre-wrap; color: var(--muted); max-height: 200px; overflow-y: auto; }
|
|
90
|
+
|
|
91
|
+
.empty { text-align: center; padding: 48px; color: var(--muted); }
|
|
92
|
+
.empty p { margin-top: 8px; font-size: 14px; }
|
|
93
|
+
|
|
94
|
+
.live-banner { display: none; padding: 8px 16px; background: rgba(63,185,80,0.1);
|
|
95
|
+
border: 1px solid rgba(63,185,80,0.3); border-radius: 6px; margin-bottom: 16px;
|
|
96
|
+
font-size: 13px; color: var(--green); text-align: center; }
|
|
97
|
+
.live-banner.show { display: block; animation: fadeIn 0.3s; }
|
|
98
|
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
|
99
|
+
|
|
100
|
+
.tabs { display: flex; gap: 4px; margin-bottom: 16px; }
|
|
101
|
+
.tab { padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer;
|
|
102
|
+
color: var(--muted); background: transparent; border: 1px solid transparent; }
|
|
103
|
+
.tab:hover { color: var(--text); }
|
|
104
|
+
.tab.active { color: var(--text); background: var(--surface); border-color: var(--border); }
|
|
105
|
+
</style>
|
|
106
|
+
</head>
|
|
107
|
+
<body>
|
|
108
|
+
<div class="container">
|
|
109
|
+
<header>
|
|
110
|
+
<h1><span>trickle</span> dashboard</h1>
|
|
111
|
+
<div class="status">
|
|
112
|
+
<div class="dot" id="statusDot"></div>
|
|
113
|
+
<span id="statusText">connecting...</span>
|
|
114
|
+
</div>
|
|
115
|
+
</header>
|
|
116
|
+
|
|
117
|
+
<div class="stats" id="stats"></div>
|
|
118
|
+
|
|
119
|
+
<div class="live-banner" id="liveBanner">New types observed — view updated</div>
|
|
120
|
+
|
|
121
|
+
<div class="search">
|
|
122
|
+
<input type="text" id="searchInput" placeholder="Search routes... (e.g. /api/users, GET, POST)">
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div class="tabs" id="tabs">
|
|
126
|
+
<div class="tab active" data-filter="all">All</div>
|
|
127
|
+
<div class="tab" data-filter="GET">GET</div>
|
|
128
|
+
<div class="tab" data-filter="POST">POST</div>
|
|
129
|
+
<div class="tab" data-filter="PUT">PUT</div>
|
|
130
|
+
<div class="tab" data-filter="DELETE">DELETE</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div class="route-list" id="routeList"></div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<script>
|
|
137
|
+
const API = window.location.origin;
|
|
138
|
+
let allRoutes = [];
|
|
139
|
+
let currentFilter = 'all';
|
|
140
|
+
let searchQuery = '';
|
|
141
|
+
|
|
142
|
+
async function fetchRoutes() {
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(API + '/api/functions?limit=500');
|
|
145
|
+
const data = await res.json();
|
|
146
|
+
return data.functions || [];
|
|
147
|
+
} catch { return []; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function fetchSnapshot(funcId, env) {
|
|
151
|
+
try {
|
|
152
|
+
const url = API + '/api/types/' + funcId + (env ? '?env=' + env : '');
|
|
153
|
+
const res = await fetch(url);
|
|
154
|
+
const data = await res.json();
|
|
155
|
+
return data.snapshots?.[0] || null;
|
|
156
|
+
} catch { return null; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function fetchMockConfig() {
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(API + '/api/mock-config');
|
|
162
|
+
const data = await res.json();
|
|
163
|
+
return data.routes || [];
|
|
164
|
+
} catch { return []; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseRoute(name) {
|
|
168
|
+
const m = name.match(/^(GET|POST|PUT|DELETE|PATCH)\\s+(.+)$/i);
|
|
169
|
+
if (!m) return null;
|
|
170
|
+
return { method: m[1].toUpperCase(), path: m[2] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function timeAgo(iso) {
|
|
174
|
+
const d = new Date(iso);
|
|
175
|
+
const s = Math.floor((Date.now() - d.getTime()) / 1000);
|
|
176
|
+
if (s < 60) return s + 's ago';
|
|
177
|
+
const m = Math.floor(s / 60);
|
|
178
|
+
if (m < 60) return m + 'm ago';
|
|
179
|
+
const h = Math.floor(m / 60);
|
|
180
|
+
if (h < 24) return h + 'h ago';
|
|
181
|
+
return Math.floor(h / 24) + 'd ago';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderTypeNode(node, indent) {
|
|
185
|
+
indent = indent || 0;
|
|
186
|
+
const pad = ' '.repeat(indent);
|
|
187
|
+
if (!node || !node.kind) return '<span class="type">unknown</span>';
|
|
188
|
+
|
|
189
|
+
switch (node.kind) {
|
|
190
|
+
case 'primitive':
|
|
191
|
+
return '<span class="type">' + node.name + '</span>';
|
|
192
|
+
case 'unknown':
|
|
193
|
+
return '<span class="type">unknown</span>';
|
|
194
|
+
case 'array':
|
|
195
|
+
return renderTypeNode(node.element, indent) + '<span class="punct">[]</span>';
|
|
196
|
+
case 'object': {
|
|
197
|
+
const keys = Object.keys(node.properties || {});
|
|
198
|
+
if (keys.length === 0) return '<span class="punct">{}</span>';
|
|
199
|
+
let s = '<span class="punct">{</span>\\n';
|
|
200
|
+
for (const k of keys) {
|
|
201
|
+
s += pad + ' <span class="key">' + k + '</span><span class="punct">: </span>';
|
|
202
|
+
s += renderTypeNode(node.properties[k], indent + 1);
|
|
203
|
+
s += '<span class="punct">;</span>\\n';
|
|
204
|
+
}
|
|
205
|
+
s += pad + '<span class="punct">}</span>';
|
|
206
|
+
return s;
|
|
207
|
+
}
|
|
208
|
+
case 'union': {
|
|
209
|
+
return (node.members || []).map(m => renderTypeNode(m, indent)).join(' <span class="punct">|</span> ');
|
|
210
|
+
}
|
|
211
|
+
case 'tuple': {
|
|
212
|
+
const els = (node.elements || []).map(e => renderTypeNode(e, indent));
|
|
213
|
+
return '<span class="punct">[</span>' + els.join(', ') + '<span class="punct">]</span>';
|
|
214
|
+
}
|
|
215
|
+
case 'map':
|
|
216
|
+
return '<span class="type">Map</span><span class="punct"><</span>' +
|
|
217
|
+
renderTypeNode(node.key, indent) + ', ' + renderTypeNode(node.value, indent) +
|
|
218
|
+
'<span class="punct">></span>';
|
|
219
|
+
case 'set':
|
|
220
|
+
return '<span class="type">Set</span><span class="punct"><</span>' +
|
|
221
|
+
renderTypeNode(node.element, indent) + '<span class="punct">></span>';
|
|
222
|
+
case 'promise':
|
|
223
|
+
return '<span class="type">Promise</span><span class="punct"><</span>' +
|
|
224
|
+
renderTypeNode(node.resolved, indent) + '<span class="punct">></span>';
|
|
225
|
+
case 'function':
|
|
226
|
+
return '<span class="type">Function</span>';
|
|
227
|
+
default:
|
|
228
|
+
return '<span class="type">unknown</span>';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderRoute(r) {
|
|
233
|
+
const parsed = parseRoute(r.function_name);
|
|
234
|
+
if (!parsed) return '';
|
|
235
|
+
const method = parsed.method.toLowerCase();
|
|
236
|
+
const ago = timeAgo(r.last_seen_at);
|
|
237
|
+
|
|
238
|
+
return '<div class="route" data-method="' + parsed.method + '" data-id="' + r.id + '">' +
|
|
239
|
+
'<div class="route-header" onclick="toggleRoute(this.parentElement)">' +
|
|
240
|
+
'<span class="chevron">▶</span>' +
|
|
241
|
+
'<span class="method ' + method + '">' + parsed.method + '</span>' +
|
|
242
|
+
'<span class="route-path">' + escHtml(parsed.path) + '</span>' +
|
|
243
|
+
'<span class="route-meta">' + ago + '</span>' +
|
|
244
|
+
'</div>' +
|
|
245
|
+
'<div class="route-detail" id="detail-' + r.id + '"><div class="loading" style="color:var(--muted);font-size:13px;padding:8px 0">Loading types...</div></div>' +
|
|
246
|
+
'</div>';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function escHtml(s) {
|
|
250
|
+
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function toggleRoute(el) {
|
|
254
|
+
el.classList.toggle('open');
|
|
255
|
+
if (el.classList.contains('open')) {
|
|
256
|
+
const id = el.dataset.id;
|
|
257
|
+
const detail = document.getElementById('detail-' + id);
|
|
258
|
+
if (detail.querySelector('.loading')) {
|
|
259
|
+
const snap = await fetchSnapshot(id);
|
|
260
|
+
const mockRoutes = await fetchMockConfig();
|
|
261
|
+
const fn = allRoutes.find(r => r.id == id);
|
|
262
|
+
const mockRoute = fn ? mockRoutes.find(m => m.functionName === fn.function_name) : null;
|
|
263
|
+
|
|
264
|
+
let html = '';
|
|
265
|
+
|
|
266
|
+
if (snap) {
|
|
267
|
+
let argsType, returnType;
|
|
268
|
+
try { argsType = typeof snap.args_type === 'string' ? JSON.parse(snap.args_type) : snap.args_type; } catch { argsType = null; }
|
|
269
|
+
try { returnType = typeof snap.return_type === 'string' ? JSON.parse(snap.return_type) : snap.return_type; } catch { returnType = null; }
|
|
270
|
+
|
|
271
|
+
if (returnType) {
|
|
272
|
+
html += '<div class="type-section"><div class="type-label">Response Type</div>';
|
|
273
|
+
html += '<div class="type-block">' + renderTypeNode(returnType, 0) + '</div></div>';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (argsType) {
|
|
277
|
+
// For routes, show body/params/query separately
|
|
278
|
+
if (argsType.kind === 'object' && argsType.properties) {
|
|
279
|
+
if (argsType.properties.body && argsType.properties.body.kind === 'object' &&
|
|
280
|
+
Object.keys(argsType.properties.body.properties || {}).length > 0) {
|
|
281
|
+
html += '<div class="type-section"><div class="type-label">Request Body</div>';
|
|
282
|
+
html += '<div class="type-block">' + renderTypeNode(argsType.properties.body, 0) + '</div></div>';
|
|
283
|
+
}
|
|
284
|
+
if (argsType.properties.params && argsType.properties.params.kind === 'object' &&
|
|
285
|
+
Object.keys(argsType.properties.params.properties || {}).length > 0) {
|
|
286
|
+
html += '<div class="type-section"><div class="type-label">Path Params</div>';
|
|
287
|
+
html += '<div class="type-block">' + renderTypeNode(argsType.properties.params, 0) + '</div></div>';
|
|
288
|
+
}
|
|
289
|
+
if (argsType.properties.query && argsType.properties.query.kind === 'object' &&
|
|
290
|
+
Object.keys(argsType.properties.query.properties || {}).length > 0) {
|
|
291
|
+
html += '<div class="type-section"><div class="type-label">Query Params</div>';
|
|
292
|
+
html += '<div class="type-block">' + renderTypeNode(argsType.properties.query, 0) + '</div></div>';
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (mockRoute && mockRoute.sampleOutput) {
|
|
299
|
+
html += '<div class="sample-section"><div class="type-label">Sample Response</div>';
|
|
300
|
+
html += '<div class="sample-block">' + escHtml(JSON.stringify(mockRoute.sampleOutput, null, 2)) + '</div></div>';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!html) html = '<div style="color:var(--muted);font-size:13px;padding:8px 0">No type data available yet.</div>';
|
|
304
|
+
detail.innerHTML = html;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function renderStats(routes) {
|
|
310
|
+
const methods = {};
|
|
311
|
+
let routeCount = 0;
|
|
312
|
+
for (const r of routes) {
|
|
313
|
+
const p = parseRoute(r.function_name);
|
|
314
|
+
if (p) {
|
|
315
|
+
routeCount++;
|
|
316
|
+
methods[p.method] = (methods[p.method] || 0) + 1;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let html = '<div class="stat"><div class="stat-value">' + routes.length + '</div><div class="stat-label">Functions</div></div>';
|
|
321
|
+
html += '<div class="stat"><div class="stat-value">' + routeCount + '</div><div class="stat-label">API Routes</div></div>';
|
|
322
|
+
for (const [m, c] of Object.entries(methods)) {
|
|
323
|
+
html += '<div class="stat"><div class="stat-value">' + c + '</div><div class="stat-label">' + m + ' routes</div></div>';
|
|
324
|
+
}
|
|
325
|
+
document.getElementById('stats').innerHTML = html;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function applyFilters() {
|
|
329
|
+
const cards = document.querySelectorAll('.route');
|
|
330
|
+
cards.forEach(card => {
|
|
331
|
+
const method = card.dataset.method;
|
|
332
|
+
const text = card.textContent.toLowerCase();
|
|
333
|
+
const matchFilter = currentFilter === 'all' || method === currentFilter;
|
|
334
|
+
const matchSearch = !searchQuery || text.includes(searchQuery.toLowerCase());
|
|
335
|
+
card.style.display = matchFilter && matchSearch ? '' : 'none';
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function loadData() {
|
|
340
|
+
allRoutes = await fetchRoutes();
|
|
341
|
+
renderStats(allRoutes);
|
|
342
|
+
|
|
343
|
+
if (allRoutes.length === 0) {
|
|
344
|
+
document.getElementById('routeList').innerHTML =
|
|
345
|
+
'<div class="empty"><h3>No observations yet</h3><p>Instrument your app and make some requests to see types here.</p></div>';
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Sort: routes first, then by last_seen_at descending
|
|
350
|
+
const sorted = [...allRoutes].sort((a, b) => {
|
|
351
|
+
const aRoute = parseRoute(a.function_name);
|
|
352
|
+
const bRoute = parseRoute(b.function_name);
|
|
353
|
+
if (aRoute && !bRoute) return -1;
|
|
354
|
+
if (!aRoute && bRoute) return 1;
|
|
355
|
+
return new Date(b.last_seen_at).getTime() - new Date(a.last_seen_at).getTime();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
document.getElementById('routeList').innerHTML = sorted.map(renderRoute).join('');
|
|
359
|
+
applyFilters();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Search
|
|
363
|
+
document.getElementById('searchInput').addEventListener('input', e => {
|
|
364
|
+
searchQuery = e.target.value;
|
|
365
|
+
applyFilters();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Tabs
|
|
369
|
+
document.querySelectorAll('.tab').forEach(tab => {
|
|
370
|
+
tab.addEventListener('click', () => {
|
|
371
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
372
|
+
tab.classList.add('active');
|
|
373
|
+
currentFilter = tab.dataset.filter;
|
|
374
|
+
applyFilters();
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// SSE for live updates
|
|
379
|
+
function connectSSE() {
|
|
380
|
+
const dot = document.getElementById('statusDot');
|
|
381
|
+
const text = document.getElementById('statusText');
|
|
382
|
+
const banner = document.getElementById('liveBanner');
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const es = new EventSource(API + '/api/tail');
|
|
386
|
+
es.onopen = () => {
|
|
387
|
+
dot.className = 'dot live';
|
|
388
|
+
text.textContent = 'live';
|
|
389
|
+
};
|
|
390
|
+
es.addEventListener('type_snapshot', () => {
|
|
391
|
+
banner.classList.add('show');
|
|
392
|
+
setTimeout(() => banner.classList.remove('show'), 3000);
|
|
393
|
+
// Reload data
|
|
394
|
+
loadData();
|
|
395
|
+
});
|
|
396
|
+
es.addEventListener('function_seen', () => {
|
|
397
|
+
loadData();
|
|
398
|
+
});
|
|
399
|
+
es.onerror = () => {
|
|
400
|
+
dot.className = 'dot off';
|
|
401
|
+
text.textContent = 'disconnected';
|
|
402
|
+
// Retry
|
|
403
|
+
setTimeout(connectSSE, 5000);
|
|
404
|
+
};
|
|
405
|
+
} catch {
|
|
406
|
+
dot.className = 'dot off';
|
|
407
|
+
text.textContent = 'disconnected';
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Health check
|
|
412
|
+
async function checkHealth() {
|
|
413
|
+
try {
|
|
414
|
+
const res = await fetch(API + '/api/health');
|
|
415
|
+
if (res.ok) {
|
|
416
|
+
document.getElementById('statusDot').className = 'dot live';
|
|
417
|
+
document.getElementById('statusText').textContent = 'connected';
|
|
418
|
+
}
|
|
419
|
+
} catch {
|
|
420
|
+
document.getElementById('statusDot').className = 'dot off';
|
|
421
|
+
document.getElementById('statusText').textContent = 'disconnected';
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Init
|
|
426
|
+
checkHealth();
|
|
427
|
+
loadData();
|
|
428
|
+
connectSSE();
|
|
429
|
+
</script>
|
|
430
|
+
</body>
|
|
431
|
+
</html>`;
|
|
432
|
+
}
|
|
433
|
+
exports.default = router;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const express_1 = require("express");
|
|
4
|
+
const connection_1 = require("../db/connection");
|
|
5
|
+
const type_differ_1 = require("../services/type-differ");
|
|
6
|
+
const router = (0, express_1.Router)();
|
|
7
|
+
// GET / — cross-function type drift report
|
|
8
|
+
// Query params:
|
|
9
|
+
// since: ISO datetime string — only show changes after this time
|
|
10
|
+
// env1, env2: compare latest snapshots across environments
|
|
11
|
+
// env: filter functions by environment
|
|
12
|
+
router.get("/", (req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const { since, env1, env2, env } = req.query;
|
|
15
|
+
if (env1 && env2) {
|
|
16
|
+
// Cross-environment diff: for each function, compare latest snapshot in env1 vs env2
|
|
17
|
+
const functionsStmt = connection_1.db.prepare(`
|
|
18
|
+
SELECT DISTINCT f.id, f.function_name, f.module, f.language
|
|
19
|
+
FROM functions f
|
|
20
|
+
JOIN type_snapshots s ON s.function_id = f.id
|
|
21
|
+
WHERE s.env IN (?, ?)
|
|
22
|
+
ORDER BY f.function_name
|
|
23
|
+
`);
|
|
24
|
+
const functions = functionsStmt.all(env1, env2);
|
|
25
|
+
const snapshotStmt = connection_1.db.prepare(`
|
|
26
|
+
SELECT * FROM type_snapshots
|
|
27
|
+
WHERE function_id = ? AND env = ?
|
|
28
|
+
ORDER BY observed_at DESC
|
|
29
|
+
LIMIT 1
|
|
30
|
+
`);
|
|
31
|
+
const entries = [];
|
|
32
|
+
for (const fn of functions) {
|
|
33
|
+
const fromSnap = snapshotStmt.get(fn.id, env1);
|
|
34
|
+
const toSnap = snapshotStmt.get(fn.id, env2);
|
|
35
|
+
if (!fromSnap || !toSnap)
|
|
36
|
+
continue;
|
|
37
|
+
if (fromSnap.type_hash === toSnap.type_hash)
|
|
38
|
+
continue;
|
|
39
|
+
const fromArgs = JSON.parse(fromSnap.args_type);
|
|
40
|
+
const toArgs = JSON.parse(toSnap.args_type);
|
|
41
|
+
const fromReturn = JSON.parse(fromSnap.return_type);
|
|
42
|
+
const toReturn = JSON.parse(toSnap.return_type);
|
|
43
|
+
const diffs = [
|
|
44
|
+
...(0, type_differ_1.diffTypes)(fromArgs, toArgs, "args"),
|
|
45
|
+
...(0, type_differ_1.diffTypes)(fromReturn, toReturn, "return"),
|
|
46
|
+
];
|
|
47
|
+
if (diffs.length > 0) {
|
|
48
|
+
entries.push({
|
|
49
|
+
functionName: fn.function_name,
|
|
50
|
+
module: fn.module,
|
|
51
|
+
language: fn.language,
|
|
52
|
+
from: {
|
|
53
|
+
id: fromSnap.id,
|
|
54
|
+
env: fromSnap.env,
|
|
55
|
+
observed_at: fromSnap.observed_at,
|
|
56
|
+
type_hash: fromSnap.type_hash,
|
|
57
|
+
},
|
|
58
|
+
to: {
|
|
59
|
+
id: toSnap.id,
|
|
60
|
+
env: toSnap.env,
|
|
61
|
+
observed_at: toSnap.observed_at,
|
|
62
|
+
type_hash: toSnap.type_hash,
|
|
63
|
+
},
|
|
64
|
+
diffs,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
res.json({
|
|
69
|
+
mode: "cross-env",
|
|
70
|
+
env1,
|
|
71
|
+
env2,
|
|
72
|
+
entries,
|
|
73
|
+
total: entries.length,
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Time-based diff: for each function, compare the two most recent snapshots
|
|
78
|
+
// If `since` is provided, only consider functions with snapshots after that time
|
|
79
|
+
let functionsQuery;
|
|
80
|
+
const bindings = [];
|
|
81
|
+
if (since) {
|
|
82
|
+
if (env) {
|
|
83
|
+
functionsQuery = `
|
|
84
|
+
SELECT DISTINCT f.id, f.function_name, f.module, f.language
|
|
85
|
+
FROM functions f
|
|
86
|
+
JOIN type_snapshots s ON s.function_id = f.id
|
|
87
|
+
WHERE s.observed_at >= ? AND s.env = ?
|
|
88
|
+
ORDER BY f.function_name
|
|
89
|
+
`;
|
|
90
|
+
bindings.push(since, env);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
functionsQuery = `
|
|
94
|
+
SELECT DISTINCT f.id, f.function_name, f.module, f.language
|
|
95
|
+
FROM functions f
|
|
96
|
+
JOIN type_snapshots s ON s.function_id = f.id
|
|
97
|
+
WHERE s.observed_at >= ?
|
|
98
|
+
ORDER BY f.function_name
|
|
99
|
+
`;
|
|
100
|
+
bindings.push(since);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
if (env) {
|
|
105
|
+
functionsQuery = `
|
|
106
|
+
SELECT DISTINCT f.id, f.function_name, f.module, f.language
|
|
107
|
+
FROM functions f
|
|
108
|
+
JOIN type_snapshots s ON s.function_id = f.id
|
|
109
|
+
WHERE s.env = ?
|
|
110
|
+
ORDER BY f.function_name
|
|
111
|
+
`;
|
|
112
|
+
bindings.push(env);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
functionsQuery = `
|
|
116
|
+
SELECT DISTINCT f.id, f.function_name, f.module, f.language
|
|
117
|
+
FROM functions f
|
|
118
|
+
JOIN type_snapshots s ON s.function_id = f.id
|
|
119
|
+
ORDER BY f.function_name
|
|
120
|
+
`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const functions = connection_1.db.prepare(functionsQuery).all(...bindings);
|
|
124
|
+
// For each function, get the two most recent snapshots and diff them
|
|
125
|
+
const latestTwoStmt = connection_1.db.prepare(`
|
|
126
|
+
SELECT * FROM type_snapshots
|
|
127
|
+
WHERE function_id = ?
|
|
128
|
+
ORDER BY observed_at DESC
|
|
129
|
+
LIMIT 2
|
|
130
|
+
`);
|
|
131
|
+
const entries = [];
|
|
132
|
+
for (const fn of functions) {
|
|
133
|
+
const snapshots = latestTwoStmt.all(fn.id);
|
|
134
|
+
if (snapshots.length < 2)
|
|
135
|
+
continue;
|
|
136
|
+
const [newer, older] = snapshots;
|
|
137
|
+
if (newer.type_hash === older.type_hash)
|
|
138
|
+
continue;
|
|
139
|
+
const fromArgs = JSON.parse(older.args_type);
|
|
140
|
+
const toArgs = JSON.parse(newer.args_type);
|
|
141
|
+
const fromReturn = JSON.parse(older.return_type);
|
|
142
|
+
const toReturn = JSON.parse(newer.return_type);
|
|
143
|
+
const diffs = [
|
|
144
|
+
...(0, type_differ_1.diffTypes)(fromArgs, toArgs, "args"),
|
|
145
|
+
...(0, type_differ_1.diffTypes)(fromReturn, toReturn, "return"),
|
|
146
|
+
];
|
|
147
|
+
if (diffs.length > 0) {
|
|
148
|
+
entries.push({
|
|
149
|
+
functionName: fn.function_name,
|
|
150
|
+
module: fn.module,
|
|
151
|
+
language: fn.language,
|
|
152
|
+
from: {
|
|
153
|
+
id: older.id,
|
|
154
|
+
env: older.env,
|
|
155
|
+
observed_at: older.observed_at,
|
|
156
|
+
type_hash: older.type_hash,
|
|
157
|
+
},
|
|
158
|
+
to: {
|
|
159
|
+
id: newer.id,
|
|
160
|
+
env: newer.env,
|
|
161
|
+
observed_at: newer.observed_at,
|
|
162
|
+
type_hash: newer.type_hash,
|
|
163
|
+
},
|
|
164
|
+
diffs,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
res.json({
|
|
169
|
+
mode: "temporal",
|
|
170
|
+
since: since || null,
|
|
171
|
+
env: env || null,
|
|
172
|
+
entries,
|
|
173
|
+
total: entries.length,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
console.error("Diff report error:", err);
|
|
178
|
+
res.status(500).json({ error: "Internal server error" });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
exports.default = router;
|