lowlander 0.4.0 → 0.5.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/README.md +76 -22
- package/build/client/client.js +5 -0
- package/build/client/client.js.map +1 -1
- package/build/dashboard/client/main.d.ts +1 -0
- package/build/dashboard/client/main.js +623 -0
- package/build/dashboard/client/main.js.map +1 -0
- package/build/dashboard/client/shim-server.d.ts +3 -0
- package/build/dashboard/client/shim-server.js +2 -0
- package/build/dashboard/client/shim-server.js.map +1 -0
- package/build/dashboard/dashboard.html +20 -0
- package/build/dashboard/index.d.ts +18 -0
- package/build/dashboard/index.d.ts.map +1 -0
- package/build/dashboard/index.js +50 -0
- package/build/dashboard/index.js.map +1 -0
- package/build/dashboard/serve.d.ts +18 -0
- package/build/dashboard/serve.d.ts.map +1 -0
- package/build/dashboard/serve.js +53 -0
- package/build/dashboard/serve.js.map +1 -0
- package/build/dashboard/server.d.ts +82 -0
- package/build/dashboard/server.d.ts.map +1 -0
- package/build/dashboard/server.js +248 -0
- package/build/dashboard/server.js.map +1 -0
- package/build/examples/helloworld/.edinburgh/commit_worker.log +3162 -0
- package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
- package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
- package/build/examples/helloworld/client/index.html +1 -1
- package/build/examples/helloworld/client/js/base.css +1 -0
- package/build/examples/helloworld/client/js/base.js +217 -71
- package/build/examples/helloworld/client/js/base.js.map +1 -1
- package/build/examples/helloworld/server/api.d.ts +4 -2
- package/build/examples/helloworld/server/api.d.ts.map +1 -1
- package/build/examples/helloworld/server/api.js +19 -8
- package/build/examples/helloworld/server/api.js.map +1 -1
- package/build/examples/helloworld/server/main.d.ts +1 -1
- package/build/examples/helloworld/server/main.d.ts.map +1 -1
- package/build/examples/helloworld/server/main.js +6 -17
- package/build/examples/helloworld/server/main.js.map +1 -1
- package/build/server/password.d.ts +10 -0
- package/build/server/password.d.ts.map +1 -0
- package/build/server/password.js +38 -0
- package/build/server/password.js.map +1 -0
- package/build/server/server.d.ts +2 -0
- package/build/server/server.d.ts.map +1 -1
- package/build/server/server.js +21 -2
- package/build/server/server.js.map +1 -1
- package/build/server/wshandler.d.ts +7 -1
- package/build/server/wshandler.d.ts.map +1 -1
- package/build/server/wshandler.js +54 -14
- package/build/server/wshandler.js.map +1 -1
- package/build/tsconfig.client.tsbuildinfo +1 -1
- package/build/tsconfig.server.tsbuildinfo +1 -1
- package/client/client.ts +6 -1
- package/dashboard/build-bundle.ts +38 -0
- package/dashboard/client/index.html +12 -0
- package/dashboard/client/main.ts +566 -0
- package/dashboard/client/shim-server.ts +5 -0
- package/dashboard/index.ts +49 -0
- package/dashboard/server.ts +255 -0
- package/package.json +22 -11
- package/server/server.ts +18 -2
- package/server/wshandler.ts +57 -13
- package/skill/SKILL.md +52 -4
- package/skill/getStreamTypesForModel.md +7 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import A from 'aberdeen';
|
|
2
|
+
import { current as route, go } from 'aberdeen/route';
|
|
3
|
+
import { Connection, type ClientProxyObject } from 'lowlander/client';
|
|
4
|
+
import type { _dashboard } from './shim-server.js';
|
|
5
|
+
|
|
6
|
+
type ServerExports = { _dashboard: typeof _dashboard };
|
|
7
|
+
type API = ClientProxyObject<ServerExports>;
|
|
8
|
+
|
|
9
|
+
interface Settings {
|
|
10
|
+
wsUrl: string;
|
|
11
|
+
password: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SETTINGS_KEY = 'lowlander-dashboard';
|
|
15
|
+
function loadSettings(): Partial<Settings> {
|
|
16
|
+
try { return JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}'); } catch { return {}; }
|
|
17
|
+
}
|
|
18
|
+
function saveSettings(s: Partial<Settings>) {
|
|
19
|
+
localStorage.setItem(SETTINGS_KEY, JSON.stringify(s));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const defaultWs = () => {
|
|
23
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
24
|
+
return `${proto}//${location.host}/`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const stored = loadSettings();
|
|
28
|
+
const $state = A.proxy({
|
|
29
|
+
wsUrl: stored.wsUrl || defaultWs(),
|
|
30
|
+
password: stored.password || '',
|
|
31
|
+
loginError: '' as string,
|
|
32
|
+
connected: false,
|
|
33
|
+
authed: false,
|
|
34
|
+
indexRefreshKey: 0,
|
|
35
|
+
connecting: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let connection: Connection<ServerExports> | undefined;
|
|
39
|
+
let api: API | undefined;
|
|
40
|
+
let authProxy: ReturnType<API['_dashboard']> | undefined;
|
|
41
|
+
|
|
42
|
+
// URL-backed state helpers (reactive via A.route.current.search)
|
|
43
|
+
const url = {
|
|
44
|
+
get debug() { return route.search.debug === '1'; },
|
|
45
|
+
set debug(v: boolean) { setSearch('debug', v ? '1' : ''); },
|
|
46
|
+
get model() { return route.search.model || ''; },
|
|
47
|
+
set model(v: string) { setSearch('model', v); },
|
|
48
|
+
get index() { return route.search.index || ''; },
|
|
49
|
+
set index(v: string) { setSearch('index', v); },
|
|
50
|
+
get search() { return route.search.search || ''; },
|
|
51
|
+
set search(v: string) { setSearch('search', v); },
|
|
52
|
+
get reverse() { return route.search.reverse === '1'; },
|
|
53
|
+
set reverse(v: boolean) { setSearch('reverse', v ? '1' : ''); },
|
|
54
|
+
get limit() { return parseInt(route.search.limit || '10', 10) || 10; },
|
|
55
|
+
set limit(v: number) { setSearch('limit', v === 10 ? '' : String(v)); },
|
|
56
|
+
get pk() { return route.search.pk || ''; },
|
|
57
|
+
set pk(v: string) { setSearch('pk', v); },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function setSearch(k: string, v: string) {
|
|
61
|
+
const s = route.search;
|
|
62
|
+
if (v === '' || v === undefined) delete s[k];
|
|
63
|
+
else s[k] = v;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function effectiveIndex() { return url.index || '(primary)'; }
|
|
67
|
+
function effectivePk(): string { return url.pk === '-' ? '' : url.pk; }
|
|
68
|
+
|
|
69
|
+
let tableClass = '';
|
|
70
|
+
|
|
71
|
+
function styles() {
|
|
72
|
+
A.setSpacingCssVars();
|
|
73
|
+
|
|
74
|
+
A.cssVars.bg = '#0f1117';
|
|
75
|
+
A.cssVars.panel = '#181b24';
|
|
76
|
+
A.cssVars.panel2 = '#1f2330';
|
|
77
|
+
A.cssVars.fg = '#e5e7eb';
|
|
78
|
+
A.cssVars.muted = '#94a3b8';
|
|
79
|
+
A.cssVars.accent = '#60a5fa';
|
|
80
|
+
A.cssVars.accent2 = '#34d399';
|
|
81
|
+
A.cssVars.danger = '#f87171';
|
|
82
|
+
A.cssVars.border = '#2a2f3d';
|
|
83
|
+
|
|
84
|
+
A.insertGlobalCss({
|
|
85
|
+
'html,body': 'm:0 p:0 bg:$bg fg:$fg font-family: -apple-system, system-ui, sans-serif; font-size:14px; h:100vh',
|
|
86
|
+
'body': 'display:flex flex-direction:column',
|
|
87
|
+
'a': 'color:$accent text-decoration:none cursor:pointer',
|
|
88
|
+
'a:hover': 'text-decoration:underline',
|
|
89
|
+
'input,select,textarea,button': 'font-family:inherit font-size:inherit',
|
|
90
|
+
'input[type=text],input[type=password],input:not([type]),select,textarea':
|
|
91
|
+
'bg:$panel2 fg:$fg border: 1px solid $border; r:4px p: 6px 8px; outline:none box-sizing:border-box w:100%',
|
|
92
|
+
'input:focus,textarea:focus,select:focus': 'border-color:$accent',
|
|
93
|
+
'button': 'bg:$accent fg:#0b1220 border:0 r:4px p: 6px 12px; cursor:pointer font-weight:600',
|
|
94
|
+
'button:hover': 'opacity:0.9',
|
|
95
|
+
'button.ghost': 'bg:transparent fg:$accent border: 1px solid $border;',
|
|
96
|
+
'code,pre': 'font-family: ui-monospace, Menlo, Consolas, monospace; font-size:12.5px',
|
|
97
|
+
'pre': 'bg:#0b0d14 p:$3 r:6px overflow:auto m:0',
|
|
98
|
+
'.tag': 'display:inline-block bg:$panel2 fg:$muted p: 1px 6px; r:3px font-size:11px ml:$1',
|
|
99
|
+
'.sidebarSel': 'bg:#1e3251 box-shadow: inset 3px 0 0 $accent;',
|
|
100
|
+
'.row': 'display:flex gap:$2 align-items:center',
|
|
101
|
+
'.col': 'display:flex flex-direction:column gap:$2',
|
|
102
|
+
'ul': 'margin-block-start:0 margin-block-end:0',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
tableClass = A.insertCss({
|
|
106
|
+
'&': 'w:100% border-collapse:collapse font-size:12.5px',
|
|
107
|
+
'& th,& td': 'text-align:left p: 4px 8px; border-bottom: 1px solid $border; vertical-align:top',
|
|
108
|
+
'& th': 'bg:$panel2 fg:$muted font-weight:600',
|
|
109
|
+
'& tbody tr': 'cursor:default',
|
|
110
|
+
'& tbody tr:hover': 'bg:#ffffff08',
|
|
111
|
+
'& tbody tr.selectable': 'cursor:pointer',
|
|
112
|
+
'& tbody tr.selected': 'bg:#1e3251',
|
|
113
|
+
'& tbody tr.selected td:first-child': 'border-left: 3px solid $accent; padding-left: 5px;',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function login() {
|
|
118
|
+
A(() => {
|
|
119
|
+
if ($state.connecting) {
|
|
120
|
+
A('div', 'display:flex align-items:center justify-content:center h:100vh fg:$muted', '#Connecting…');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
A('div', 'display:flex align-items:center justify-content:center h:100vh', () => {
|
|
124
|
+
A('form', 'bg:$panel p:$4 r:8px w:360px border: 1px solid $border; display:flex flex-direction:column gap:$3',
|
|
125
|
+
'submit=', (e: Event) => { e.preventDefault(); attemptLogin(); }, () => {
|
|
126
|
+
A('h2#Lowlander Dashboard', 'm:0');
|
|
127
|
+
A('label', () => {
|
|
128
|
+
A('span#WebSocket URL', 'fg:$muted display:block mb:$1');
|
|
129
|
+
A('input type=text bind=', A.ref($state, 'wsUrl'));
|
|
130
|
+
});
|
|
131
|
+
A('label', () => {
|
|
132
|
+
A('span#Password', 'fg:$muted display:block mb:$1');
|
|
133
|
+
A('input type=password bind=', A.ref($state, 'password'), 'autofocus=');
|
|
134
|
+
});
|
|
135
|
+
A(() => {
|
|
136
|
+
if ($state.loginError) A('div', 'fg:$danger', () => A('text=', $state.loginError));
|
|
137
|
+
});
|
|
138
|
+
A('button type=submit', () => {
|
|
139
|
+
A(() => A($state.connected && !$state.authed ? 'text=Checking…' : 'text="Log in"'));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function attemptLogin() {
|
|
147
|
+
$state.connecting = true;
|
|
148
|
+
$state.loginError = '';
|
|
149
|
+
saveSettings({ wsUrl: $state.wsUrl, password: $state.password });
|
|
150
|
+
try {
|
|
151
|
+
if (connection) (connection as any).ws?.close?.();
|
|
152
|
+
} catch {}
|
|
153
|
+
connection = new Connection<ServerExports>($state.wsUrl);
|
|
154
|
+
api = connection.api;
|
|
155
|
+
$state.connected = false;
|
|
156
|
+
$state.authed = false;
|
|
157
|
+
const proxy = api._dashboard($state.password);
|
|
158
|
+
authProxy = proxy;
|
|
159
|
+
try {
|
|
160
|
+
await (proxy as any).promise;
|
|
161
|
+
$state.connected = true;
|
|
162
|
+
$state.authed = true;
|
|
163
|
+
$state.connecting = false;
|
|
164
|
+
} catch (err: any) {
|
|
165
|
+
$state.connecting = false;
|
|
166
|
+
$state.loginError = err?.message || 'Login failed';
|
|
167
|
+
$state.connected = false;
|
|
168
|
+
$state.authed = false;
|
|
169
|
+
connection = undefined;
|
|
170
|
+
api = undefined;
|
|
171
|
+
authProxy = undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function logout() {
|
|
176
|
+
$state.authed = false;
|
|
177
|
+
$state.connected = false;
|
|
178
|
+
$state.password = '';
|
|
179
|
+
saveSettings({ wsUrl: $state.wsUrl, password: '' });
|
|
180
|
+
try { (connection as any)?.ws?.close?.(); } catch {}
|
|
181
|
+
connection = undefined;
|
|
182
|
+
api = undefined;
|
|
183
|
+
authProxy = undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sidebar() {
|
|
187
|
+
A('aside', 'w:280px bg:$panel border-right: 1px solid $border; display:flex flex-direction:column overflow:hidden', () => {
|
|
188
|
+
A('div', 'p:$3 border-bottom: 1px solid $border; display:flex justify-content:space-between align-items:center', () => {
|
|
189
|
+
A('strong#Lowlander');
|
|
190
|
+
A('button.ghost#Logout', 'click=', () => logout());
|
|
191
|
+
});
|
|
192
|
+
A('div', 'overflow:auto flex:1 p:$2', () => {
|
|
193
|
+
if (!authProxy) return;
|
|
194
|
+
sidebarModels();
|
|
195
|
+
A('div', 'border-top: 1px solid $border; mt:$2 pt:$2', () => {
|
|
196
|
+
A('div', 'p: 6px 8px; r:4px cursor:pointer',
|
|
197
|
+
'click=', () => { go({ path: route.path, search: { debug: '1' } }); },
|
|
198
|
+
() => {
|
|
199
|
+
A(() => A('.sidebarSel=', url.debug));
|
|
200
|
+
A('div', 'display:flex justify-content:space-between align-items:baseline', () => {
|
|
201
|
+
A('span#⬡ WarpSocket debug');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sidebarModels() {
|
|
210
|
+
const models = authProxy!.serverProxy.listModels();
|
|
211
|
+
A(() => {
|
|
212
|
+
if (models.busy) { A('div', 'fg:$muted', '#Loading…'); return; }
|
|
213
|
+
if (models.error) { A('div', 'fg:$danger', 'text=', models.error.message); return; }
|
|
214
|
+
const list = models.value || [];
|
|
215
|
+
for (const m of list) {
|
|
216
|
+
A('div', 'p: 6px 8px; r:4px cursor:pointer',
|
|
217
|
+
'click=', () => { go({ path: route.path, search: { model: m.tableName } }); },
|
|
218
|
+
() => {
|
|
219
|
+
A(() => A('.sidebarSel=', !url.debug && url.model === m.tableName));
|
|
220
|
+
A('div', 'display:flex justify-content:space-between align-items:baseline', () => {
|
|
221
|
+
A('span', 'text=', m.tableName);
|
|
222
|
+
A('span.tag', 'text=', `${m.fieldCount}f ${m.indexCount}i ${m.streamTypeCount}s`);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
function mainArea() {
|
|
231
|
+
A('main', 'flex:1 overflow:auto p:$4', () => {
|
|
232
|
+
if (!authProxy) return;
|
|
233
|
+
if (url.debug) debugView();
|
|
234
|
+
else if (url.model) modelDetail();
|
|
235
|
+
else A('div', 'fg:$muted p:$4 text-align:center', '#Select a model from the sidebar');
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function modelDetail() {
|
|
240
|
+
const name = url.model;
|
|
241
|
+
const info = authProxy!.serverProxy.getModel(name);
|
|
242
|
+
A(() => {
|
|
243
|
+
if (info.busy) { A('div', 'fg:$muted', '#Loading…'); return; }
|
|
244
|
+
if (info.error) { A('div', 'fg:$danger', 'text=', info.error.message); return; }
|
|
245
|
+
const m = info.value;
|
|
246
|
+
if (!m) return;
|
|
247
|
+
|
|
248
|
+
A('h2', 'm:0 mb:$2', 'text=', m.tableName);
|
|
249
|
+
|
|
250
|
+
A('section', 'mb:$4', () => {
|
|
251
|
+
A('h3#Fields', 'm:0 mb:$2 fg:$muted font-weight:600');
|
|
252
|
+
A('table', tableClass, () => {
|
|
253
|
+
A('thead tr', () => { A('th#Name'); A('th#Type'); A('th#Linked'); A('th#Default'); A('th#Description'); });
|
|
254
|
+
A('tbody', () => {
|
|
255
|
+
for (const f of m.fields) {
|
|
256
|
+
A('tr', () => {
|
|
257
|
+
A('td', () => A('code', 'text=', f.name));
|
|
258
|
+
A('td', 'fg:$accent2', 'text=', f.type.display);
|
|
259
|
+
A('td', () => { const lm = f.type.linkedModel; if (lm) modelLink(lm); });
|
|
260
|
+
A('td', 'text=', f.hasDefault ? '✓' : '');
|
|
261
|
+
A('td', 'fg:$muted', 'text=', f.description || '');
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
A('section', 'mb:$4', () => {
|
|
269
|
+
A('h3#Indexes', 'm:0 mb:$2 fg:$muted font-weight:600');
|
|
270
|
+
indexesTable(m);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
A(() => {
|
|
274
|
+
const cur = effectiveIndex();
|
|
275
|
+
const idx = m.indexes.find((i: any) => i.name === cur);
|
|
276
|
+
if (!idx) return;
|
|
277
|
+
A('section', 'mb:$4', () => {
|
|
278
|
+
A('h3', 'm:0 mb:$2 fg:$muted font-weight:600', 'text=', `Data for ${cur}`);
|
|
279
|
+
indexBrowser(m, idx);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (m.streamTypes.length) A('section', 'mb:$4', () => {
|
|
284
|
+
A('h3#Stream types', 'm:0 mb:$2 fg:$muted font-weight:600');
|
|
285
|
+
streamTypesTable(m);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Render a clickable link to another model
|
|
291
|
+
function modelLink(modelName: string, pk?: any, display?: string) {
|
|
292
|
+
A('a', 'click=', (e: Event) => {
|
|
293
|
+
e.preventDefault();
|
|
294
|
+
e.stopPropagation();
|
|
295
|
+
const search: Record<string, string | number> = { model: modelName };
|
|
296
|
+
if (pk !== undefined) { search.search = jsonStringify(pk); search.limit = 1; }
|
|
297
|
+
go({ path: route.path, search });
|
|
298
|
+
}, 'text=', display ?? modelName);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function jsonStringify(v: any): string {
|
|
302
|
+
if (typeof v === 'string') return v;
|
|
303
|
+
try { return JSON.stringify(v); } catch { return String(v); }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function indexesTable(m: any) {
|
|
307
|
+
A('table', tableClass, () => {
|
|
308
|
+
A('thead tr', () => { A('th#Name'); A('th#Kind'); A('th#Fields'); });
|
|
309
|
+
A('tbody', () => {
|
|
310
|
+
for (const idx of m.indexes) {
|
|
311
|
+
A('tr.selectable', 'click=', () => {
|
|
312
|
+
if (effectiveIndex() === idx.name) return;
|
|
313
|
+
url.index = (idx.name === '(primary)') ? '' : idx.name;
|
|
314
|
+
url.pk = '';
|
|
315
|
+
url.search = '';
|
|
316
|
+
}, () => {
|
|
317
|
+
A(() => A('.selected=', effectiveIndex() === idx.name));
|
|
318
|
+
A('td', () => A('code', 'text=', idx.name));
|
|
319
|
+
A('td', 'fg:$accent2', 'text=', idx.info.kind);
|
|
320
|
+
A('td', 'text=', idx.info.fields.join(', ') || '(computed)');
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function streamTypesTable(m: any) {
|
|
328
|
+
const fieldByName: Record<string, any> = {};
|
|
329
|
+
for (const f of m.fields) fieldByName[f.name] = f;
|
|
330
|
+
|
|
331
|
+
const seen = new Set<number>();
|
|
332
|
+
const streamTypes = (m.streamTypes as any[]).filter(st => {
|
|
333
|
+
if (seen.has(st.id)) return false;
|
|
334
|
+
seen.add(st.id);
|
|
335
|
+
return true;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
A('table', tableClass, () => {
|
|
339
|
+
A('thead tr', () => { A('th#id'); A('th#cache'); A('th#fields'); A('th', 'w:100%', '#live view for selected row'); });
|
|
340
|
+
A('tbody', () => {
|
|
341
|
+
for (const st of streamTypes) {
|
|
342
|
+
A('tr', () => {
|
|
343
|
+
A('td', () => A('code', 'text=', String(st.id)));
|
|
344
|
+
A('td', 'text=', st.cache ? `${st.cache}s` : '');
|
|
345
|
+
A('td', () => streamFieldsInline(st.fields, fieldByName));
|
|
346
|
+
A('td', () => streamLiveCell(m.tableName, st.id));
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function streamFieldsInline(sel: any, fieldByName: Record<string, any>) {
|
|
354
|
+
if (sel === true) { A('span', 'fg:$muted', '#(scalar)'); return; }
|
|
355
|
+
if (!sel || typeof sel !== 'object') { A('span', 'text=', String(sel)); return; }
|
|
356
|
+
for (const [k, v] of Object.entries(sel)) {
|
|
357
|
+
if (v === true) {
|
|
358
|
+
A('div', 'text=', k);
|
|
359
|
+
} else if (typeof v === 'number') {
|
|
360
|
+
const linked = fieldByName[k]?.type?.linkedModel;
|
|
361
|
+
A(`div #${k}→`, () => {
|
|
362
|
+
if (linked) modelLink(linked, undefined, v.toString());
|
|
363
|
+
else A('#?');
|
|
364
|
+
});
|
|
365
|
+
} else {
|
|
366
|
+
A(`div #${k}={...}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function streamLiveCell(modelName: string, streamTypeId: number) {
|
|
372
|
+
A(() => {
|
|
373
|
+
const pkRaw = effectivePk();
|
|
374
|
+
if (!pkRaw) { A('span', 'fg:$muted', '#(select a row)'); return; }
|
|
375
|
+
let pk: any;
|
|
376
|
+
try { pk = JSON.parse(pkRaw); } catch { pk = pkRaw; }
|
|
377
|
+
const $stream = authProxy!.serverProxy.streamRecord(modelName, streamTypeId, pk);
|
|
378
|
+
A(() => {
|
|
379
|
+
if ($stream.busy) { A('span', 'fg:$muted', '#…'); return; }
|
|
380
|
+
if ($stream.error) { A('span', 'fg:$danger', 'text=', $stream.error.message); return; }
|
|
381
|
+
A.dump($stream.value);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function indexBrowser(m: any, idx: any) {
|
|
387
|
+
const modelName = m.tableName;
|
|
388
|
+
const indexName = idx.name;
|
|
389
|
+
|
|
390
|
+
A('div', 'display:flex flex-direction:column', () => {
|
|
391
|
+
A('div', 'display:flex flex-wrap:wrap gap:$2 align-items:center mb:$2', () => {
|
|
392
|
+
A('input type=text flex:1 min-w:200px placeholder=search',
|
|
393
|
+
'value=', A.peek(() => url.search),
|
|
394
|
+
'input=', (e: any) => { url.search = e.target.value; url.limit = 10; });
|
|
395
|
+
A('label', 'display:flex align-items:center gap:$1 white-space:nowrap', () => {
|
|
396
|
+
A('input type=checkbox', 'checked=', url.reverse, 'change=', (e: any) => url.reverse = e.target.checked);
|
|
397
|
+
A('span', 'fg:$muted', '#reverse');
|
|
398
|
+
});
|
|
399
|
+
A('button.ghost', 'p: 2px 8px; font-size:11px', '#↺ refresh',
|
|
400
|
+
'click=', () => $state.indexRefreshKey++);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
A(() => {
|
|
404
|
+
void $state.indexRefreshKey;
|
|
405
|
+
const opts = {
|
|
406
|
+
search: parseMaybe(url.search),
|
|
407
|
+
reverse: url.reverse,
|
|
408
|
+
limit: url.limit,
|
|
409
|
+
};
|
|
410
|
+
const rows = authProxy!.serverProxy.findRecords(modelName, indexName, opts);
|
|
411
|
+
A(() => {
|
|
412
|
+
if (rows.busy) { A('div', 'fg:$muted', '#Loading…'); return; }
|
|
413
|
+
if (rows.error) { A('div', 'fg:$danger', 'text=', rows.error.message); return; }
|
|
414
|
+
const r = rows.value;
|
|
415
|
+
if (!r) return;
|
|
416
|
+
// Auto-select first row when no explicit selection
|
|
417
|
+
if (!url.pk && r.rows.length > 0) {
|
|
418
|
+
url.pk = jsonStringify(r.rows[0].pk);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
A('div', 'fg:$muted mb:$2 font-size:12px', 'text=', `${r.rows.length} rows (scanned ${r.scanned})`);
|
|
422
|
+
if (!r.rows.length) { A('div', 'fg:$muted', '#(empty)'); return; }
|
|
423
|
+
A('div', 'overflow:auto', () => {
|
|
424
|
+
A('table', tableClass, () => {
|
|
425
|
+
const cols = Object.keys(r.rows[0].values);
|
|
426
|
+
A('thead tr', () => { for (const c of cols) A('th', 'text=', c); });
|
|
427
|
+
A('tbody', () => {
|
|
428
|
+
for (const row of r.rows) {
|
|
429
|
+
const pkStr = jsonStringify(row.pk);
|
|
430
|
+
A('tr.selectable', 'click=', () => { url.pk = (effectivePk() === pkStr) ? '-' : pkStr; }, () => {
|
|
431
|
+
A(() => A('.selected=', effectivePk() === pkStr));
|
|
432
|
+
for (const c of cols) {
|
|
433
|
+
A('td', () => A.dump(wrapForDump((row.values as any)[c])));
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
// 'more' button
|
|
441
|
+
if (r.rows.length >= url.limit) {
|
|
442
|
+
A('div', 'mt:$2', () => {
|
|
443
|
+
A('button.ghost', 'p: 2px 8px; font-size:11px', 'click=', () => {
|
|
444
|
+
const cur = url.limit;
|
|
445
|
+
url.limit = Math.min(cur * 5, cur + 250);
|
|
446
|
+
}, () => A('span', 'text=', `+ more (currently limit ${url.limit})`));
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function wrapForDump(v: any): any {
|
|
455
|
+
if (v === null || v === undefined || typeof v !== 'object') return v;
|
|
456
|
+
if (v instanceof Date) return v;
|
|
457
|
+
if (v.__ref) {
|
|
458
|
+
const ref = v.__ref, pk = v.pk;
|
|
459
|
+
return { [A.CUSTOM_DUMP]() { modelLink(ref, pk, `<${ref} ${jsonStringify(pk)}>`); } };
|
|
460
|
+
}
|
|
461
|
+
if (Array.isArray(v)) return v.map(wrapForDump);
|
|
462
|
+
const result: Record<string, any> = {};
|
|
463
|
+
for (const [k, val] of Object.entries(v)) result[k] = wrapForDump(val);
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function parseMaybe(s: string): any {
|
|
468
|
+
if (s === '') return undefined;
|
|
469
|
+
if (s === 'true') return true; if (s === 'false') return false;
|
|
470
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
|
|
471
|
+
try { return JSON.parse(s); } catch {}
|
|
472
|
+
return s;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function debugView() {
|
|
476
|
+
const $mode = A.proxy({v: 'channels' as 'channels' | 'sockets' | 'workers' | 'kv'});
|
|
477
|
+
A('h2#WarpSocket debug', 'm:0 mb:$3');
|
|
478
|
+
A('div', '.row mb:$3', () => {
|
|
479
|
+
for (const t of ['channels','sockets','workers','kv'] as const) {
|
|
480
|
+
A('button click=', () => $mode.v = t, () => {
|
|
481
|
+
A(() => A($mode.v === t ? 'bg:$accent fg:#0b1220' : 'bg:$panel2 fg:$fg border: 1px solid $border;'));
|
|
482
|
+
A('text=', t);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
A(() => {
|
|
487
|
+
const debugInfo = authProxy!.serverProxy.getDebugState($mode.v);
|
|
488
|
+
A(() => {
|
|
489
|
+
if (debugInfo.busy) { A('div', 'fg:$muted', '#Loading…'); return; }
|
|
490
|
+
if (debugInfo.error) { A('div', 'fg:$danger', 'text=', debugInfo.error.message); return; }
|
|
491
|
+
const data = A.unproxy(debugInfo.value) as undefined | Record<string, Record<string, any>>;
|
|
492
|
+
if (!data) return;
|
|
493
|
+
const keySet = new Set<string>();
|
|
494
|
+
for (const obj of Object.values(data)) {
|
|
495
|
+
if (obj && typeof obj === 'object' && !(obj instanceof Uint8Array))
|
|
496
|
+
for (const k of Object.keys(obj)) keySet.add(k);
|
|
497
|
+
}
|
|
498
|
+
const cols = [...keySet];
|
|
499
|
+
A('div', 'overflow:auto', () => {
|
|
500
|
+
A('table', tableClass, () => {
|
|
501
|
+
A('thead tr', () => {
|
|
502
|
+
A('th##');
|
|
503
|
+
for (const k of cols) A('th', 'text=', k);
|
|
504
|
+
});
|
|
505
|
+
A('tbody', () => {
|
|
506
|
+
for (const [idx, obj] of Object.entries(data)) {
|
|
507
|
+
A('tr', () => {
|
|
508
|
+
A('td', () => A('code', 'text=', idx));
|
|
509
|
+
for (const k of cols) A('td', 'font-size:12px font-family:monospace', 'text=', debugCellText(obj?.[k]));
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function debugCellText(value: any): string {
|
|
520
|
+
if (value === null || value === undefined) return '';
|
|
521
|
+
if (value instanceof Uint8Array) return escapeBytes([...value]);
|
|
522
|
+
if (typeof value === 'object') {
|
|
523
|
+
if (value.type === 'Buffer' && Array.isArray(value.data)) return escapeBytes(value.data);
|
|
524
|
+
// Buffer-like (proxied Uint8Array): sequential integer keys
|
|
525
|
+
const keys = Object.keys(value);
|
|
526
|
+
if (!Array.isArray(value) && keys.length > 0 && keys.length <= 4096 && keys.every((k, i) => k === String(i))) {
|
|
527
|
+
const arr: number[] = [];
|
|
528
|
+
for (const k of keys) {
|
|
529
|
+
const b = value[k];
|
|
530
|
+
if (typeof b !== 'number' || b < 0 || b > 255) return JSON.stringify(value);
|
|
531
|
+
arr.push(b);
|
|
532
|
+
}
|
|
533
|
+
return escapeBytes(arr);
|
|
534
|
+
}
|
|
535
|
+
return JSON.stringify(value);
|
|
536
|
+
}
|
|
537
|
+
return String(value);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function escapeBytes(bytes: number[]): string {
|
|
541
|
+
let s = '';
|
|
542
|
+
for (const b of bytes) {
|
|
543
|
+
if (b >= 0x20 && b < 0x7f && b !== 0x22 && b !== 0x5c) s += String.fromCharCode(b);
|
|
544
|
+
else if (b === 0x09) s += '\\t';
|
|
545
|
+
else if (b === 0x0a) s += '\\n';
|
|
546
|
+
else if (b === 0x0d) s += '\\r';
|
|
547
|
+
else if (b === 0x22) s += '\\"';
|
|
548
|
+
else if (b === 0x5c) s += '\\\\';
|
|
549
|
+
else s += '\\x' + b.toString(16).padStart(2, '0');
|
|
550
|
+
}
|
|
551
|
+
return s;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
styles();
|
|
555
|
+
|
|
556
|
+
A.mount(document.body, () => {
|
|
557
|
+
if (!$state.authed) { login(); return; }
|
|
558
|
+
A('div', 'display:flex flex-direction:row h:100vh w:100vw overflow:hidden', () => {
|
|
559
|
+
sidebar();
|
|
560
|
+
mainArea();
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if ($state.password) {
|
|
565
|
+
attemptLogin();
|
|
566
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Bare type shim so the dashboard client can compile without depending on the
|
|
2
|
+
// server runtime. Mirrors the public signature of dashboard/server.ts's _dashboard.
|
|
3
|
+
import type { ServerProxy } from 'lowlander/server';
|
|
4
|
+
import type { DashboardAPI } from '../server.js';
|
|
5
|
+
export declare function _dashboard(password: string): ServerProxy<DashboardAPI, 'authenticated'>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export { _dashboard } from './server.js';
|
|
2
|
+
export type { DashboardAPI } from './server.js';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, resolve } from 'path';
|
|
6
|
+
import type { ServerResponse } from 'http';
|
|
7
|
+
import type { HttpResponse } from 'warpsocket';
|
|
8
|
+
|
|
9
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
// Bundle lives next to the compiled serve.js (build/dashboard/dashboard.html);
|
|
11
|
+
// when running from TS source under Bun, fall back to ../build/dashboard/.
|
|
12
|
+
const CANDIDATES = [
|
|
13
|
+
resolve(HERE, 'dashboard.html'),
|
|
14
|
+
resolve(HERE, '../build/dashboard/dashboard.html'),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
let cachedHtml: string | undefined;
|
|
18
|
+
function getHtml(): string {
|
|
19
|
+
if (cachedHtml === undefined) {
|
|
20
|
+
let lastErr: any;
|
|
21
|
+
for (const p of CANDIDATES) {
|
|
22
|
+
try { cachedHtml = readFileSync(p, 'utf8'); break; } catch (e) { lastErr = e; }
|
|
23
|
+
}
|
|
24
|
+
if (cachedHtml === undefined) {
|
|
25
|
+
cachedHtml = `<!doctype html><html><body><pre>Dashboard bundle missing: ${lastErr?.message || lastErr}\n\nDid you run \`npm run build\` in the lowlander package?</pre></body></html>`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return cachedHtml;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Serve the bundled dashboard HTML on an HTTP response. Compatible with
|
|
33
|
+
* Node.js `http.ServerResponse`, Bun's node-compat response, and
|
|
34
|
+
* warpsocket's `HttpResponse`. Wire into e.g.:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* import { serveDashboard } from 'lowlander/dashboard';
|
|
38
|
+
* export function handleHttpRequest(req, res) {
|
|
39
|
+
* if (req.url === '/_dashboard') return serveDashboard(res);
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function serveDashboard(res: ServerResponse | HttpResponse): void {
|
|
44
|
+
const html = getHtml();
|
|
45
|
+
res.statusCode = 200;
|
|
46
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
47
|
+
res.setHeader('cache-control', 'no-store');
|
|
48
|
+
res.end(html);
|
|
49
|
+
}
|