lowlander 0.4.0 → 0.6.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 +108 -78
- package/build/client/client.d.ts +8 -1
- package/build/client/client.js +44 -22
- package/build/client/client.js.map +1 -1
- package/build/dashboard/client/crud.d.ts +16 -0
- package/build/dashboard/client/crud.js +525 -0
- package/build/dashboard/client/crud.js.map +1 -0
- package/build/dashboard/client/main.d.ts +1 -0
- package/build/dashboard/client/main.js +615 -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 +93 -0
- package/build/dashboard/server.d.ts.map +1 -0
- package/build/dashboard/server.js +384 -0
- package/build/dashboard/server.js.map +1 -0
- package/build/examples/helloworld/.edinburgh/commit_worker.log +4927 -0
- package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
- package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
- package/build/examples/helloworld/client/assets/style.css +0 -45
- package/build/examples/helloworld/client/index.html +3 -14
- package/build/examples/helloworld/client/js/base.css +1 -0
- package/build/examples/helloworld/client/js/base.d.ts +1 -4
- package/build/examples/helloworld/client/js/base.js +8 -71
- package/build/examples/helloworld/client/js/base.js.map +1 -1
- package/build/examples/helloworld/server/api.d.ts +8 -2
- package/build/examples/helloworld/server/api.d.ts.map +1 -1
- package/build/examples/helloworld/server/api.js +29 -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 +5 -3
- package/build/server/server.d.ts.map +1 -1
- package/build/server/server.js +65 -7
- 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 +47 -24
- package/dashboard/build-bundle.ts +44 -0
- package/dashboard/client/crud.ts +634 -0
- package/dashboard/client/index.html +12 -0
- package/dashboard/client/main.ts +554 -0
- package/dashboard/client/shim-server.ts +5 -0
- package/dashboard/index.ts +49 -0
- package/dashboard/server.ts +399 -0
- package/package.json +26 -11
- package/server/server.ts +61 -10
- package/server/wshandler.ts +57 -13
- package/skill/SKILL.md +82 -51
- package/skill/getStreamTypesForModel.md +7 -0
- package/skill/Connection_pruneCommitIds.md +0 -8
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import A from 'aberdeen';
|
|
2
|
+
import * as route from 'aberdeen/route';
|
|
3
|
+
import S from 'staffa';
|
|
4
|
+
import type { Content } from 'staffa';
|
|
5
|
+
import { Connection, type ClientProxyObject } from 'lowlander/client';
|
|
6
|
+
import type { _dashboard } from './shim-server.js';
|
|
7
|
+
import { openCreateModal, openEditModal, openDeleteConfirm } from './crud.js';
|
|
8
|
+
|
|
9
|
+
type ServerExports = { _dashboard: typeof _dashboard };
|
|
10
|
+
type API = ClientProxyObject<ServerExports>;
|
|
11
|
+
|
|
12
|
+
interface Settings {
|
|
13
|
+
wsUrl: string;
|
|
14
|
+
password: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SETTINGS_KEY = 'lowlander-dashboard';
|
|
18
|
+
function loadSettings(): Partial<Settings> {
|
|
19
|
+
try { return JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}'); } catch { return {}; }
|
|
20
|
+
}
|
|
21
|
+
function saveSettings(s: Partial<Settings>) {
|
|
22
|
+
localStorage.setItem(SETTINGS_KEY, JSON.stringify(s));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const defaultWs = () => {
|
|
26
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
27
|
+
return `${proto}//${location.host}/`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const stored = loadSettings();
|
|
31
|
+
const $state = A.proxy({
|
|
32
|
+
wsUrl: stored.wsUrl || defaultWs(),
|
|
33
|
+
password: stored.password || '',
|
|
34
|
+
loginError: '' as string,
|
|
35
|
+
connected: false,
|
|
36
|
+
authed: false,
|
|
37
|
+
indexRefreshKey: 0,
|
|
38
|
+
connecting: false,
|
|
39
|
+
connection: undefined as Connection<ServerExports> | undefined,
|
|
40
|
+
});
|
|
41
|
+
let api: API | undefined;
|
|
42
|
+
let authProxy: ReturnType<API['_dashboard']> | undefined;
|
|
43
|
+
|
|
44
|
+
// URL-backed state — reactive via aberdeen/route
|
|
45
|
+
const url = {
|
|
46
|
+
get debug() { return route.current.search.debug === '1'; },
|
|
47
|
+
set debug(v: boolean) { setSearch('debug', v ? '1' : ''); },
|
|
48
|
+
get model() { return route.current.search.model || ''; },
|
|
49
|
+
set model(v: string) { setSearch('model', v); },
|
|
50
|
+
get index() { return route.current.search.index || ''; },
|
|
51
|
+
set index(v: string) { setSearch('index', v); },
|
|
52
|
+
get search() { return route.current.search.search || ''; },
|
|
53
|
+
set search(v: string) { setSearch('search', v); },
|
|
54
|
+
get reverse() { return route.current.search.reverse === '1'; },
|
|
55
|
+
set reverse(v: boolean) { setSearch('reverse', v ? '1' : ''); },
|
|
56
|
+
get limit() { return parseInt(route.current.search.limit || '10', 10) || 10; },
|
|
57
|
+
set limit(v: number) { setSearch('limit', v === 10 ? '' : String(v)); },
|
|
58
|
+
get pk() { return route.current.search.pk || ''; },
|
|
59
|
+
set pk(v: string) { setSearch('pk', v); },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function setSearch(k: string, v: string) {
|
|
63
|
+
const s = route.current.search;
|
|
64
|
+
if (v === '' || v === undefined) delete s[k];
|
|
65
|
+
else s[k] = v;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function effectiveIndex() { return url.index || '(primary)'; }
|
|
69
|
+
function effectivePk(): string { return url.pk === '-' ? '' : url.pk; }
|
|
70
|
+
|
|
71
|
+
// Table styling — Staffa provides the base (border-collapse, th/td padding/borders,
|
|
72
|
+
// thead stronger border). We only add size, cursor, hover, and selection states.
|
|
73
|
+
const tableClass = A.insertCss({
|
|
74
|
+
'&': 'w:100% font-size:12.5px',
|
|
75
|
+
'& tbody tr': 'cursor:default',
|
|
76
|
+
'& tbody tr:hover': 'background: color-mix(in srgb, $s-fg 5%, transparent);',
|
|
77
|
+
'& tbody tr.selectable': 'cursor:pointer',
|
|
78
|
+
'& tbody tr.selected': 'background: color-mix(in srgb, $s-accent 15%, transparent);',
|
|
79
|
+
'& tbody tr.selected td:first-child': 'border-left: 3px solid $s-accent; padding-left: 5px;',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ─── Login ───────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function login() {
|
|
85
|
+
S.main({
|
|
86
|
+
title: 'Lowlander Dashboard',
|
|
87
|
+
maxWidth: '24rem',
|
|
88
|
+
content: () => {
|
|
89
|
+
A(() => {
|
|
90
|
+
if ($state.connecting && !$state.connection?.getError()) {
|
|
91
|
+
A('p', 'text-align:center fg:$s-fg-muted', '#Connecting…');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
S.form({
|
|
95
|
+
submit: () => attemptLogin(),
|
|
96
|
+
content: () => {
|
|
97
|
+
S.textline({ label: 'WebSocket URL', bind: A.ref($state, 'wsUrl') });
|
|
98
|
+
S.textline({ label: 'Password', type: 'password', bind: A.ref($state, 'password'), inputAttrs: 'autofocus=autofocus' });
|
|
99
|
+
A(() => {
|
|
100
|
+
const err = $state.connection?.getError() || $state.loginError;
|
|
101
|
+
if (err) A('p', 'fg:$s-danger m:0', 'text=', err);
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
actions: () => S.button({
|
|
105
|
+
text: $state.connected && !$state.authed ? 'Checking…' : 'Log in',
|
|
106
|
+
type: 'submit',
|
|
107
|
+
disabled: $state.connected && !$state.authed,
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function attemptLogin() {
|
|
116
|
+
$state.connecting = true;
|
|
117
|
+
$state.loginError = '';
|
|
118
|
+
saveSettings({ wsUrl: $state.wsUrl, password: $state.password });
|
|
119
|
+
try {
|
|
120
|
+
if ($state.connection) ($state.connection as any).ws?.close?.();
|
|
121
|
+
} catch {}
|
|
122
|
+
$state.connection = new Connection<ServerExports>($state.wsUrl);
|
|
123
|
+
api = $state.connection.api;
|
|
124
|
+
$state.connected = false;
|
|
125
|
+
$state.authed = false;
|
|
126
|
+
const proxy = api._dashboard($state.password);
|
|
127
|
+
authProxy = proxy;
|
|
128
|
+
try {
|
|
129
|
+
await (proxy as any).promise;
|
|
130
|
+
$state.connected = true;
|
|
131
|
+
$state.authed = true;
|
|
132
|
+
$state.connecting = false;
|
|
133
|
+
} catch (err: any) {
|
|
134
|
+
$state.connecting = false;
|
|
135
|
+
$state.loginError = err?.message || 'Login failed';
|
|
136
|
+
$state.connected = false;
|
|
137
|
+
$state.authed = false;
|
|
138
|
+
$state.connection = undefined;
|
|
139
|
+
api = undefined;
|
|
140
|
+
authProxy = undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function logout() {
|
|
145
|
+
$state.authed = false;
|
|
146
|
+
$state.connected = false;
|
|
147
|
+
$state.password = '';
|
|
148
|
+
saveSettings({ wsUrl: $state.wsUrl, password: '' });
|
|
149
|
+
try { ($state.connection as any)?.ws?.close?.(); } catch {}
|
|
150
|
+
$state.connection = undefined;
|
|
151
|
+
api = undefined;
|
|
152
|
+
authProxy = undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── Dashboard (authenticated) ───────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
// Sidebar nav: reactive model list. Rendered as a Content function so drawMenu
|
|
158
|
+
// calls it inside a reactive scope; models.busy/.value/.error drive re-renders.
|
|
159
|
+
const navModels: Content = () => {
|
|
160
|
+
const models = authProxy!.serverProxy.listModels();
|
|
161
|
+
A(() => {
|
|
162
|
+
if (models.busy) {
|
|
163
|
+
A('p', 'p:$2 fg:$s-fg-muted font-size:0.9em m:0', '#Loading…');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (models.error) {
|
|
167
|
+
A('p', 'p:$2 fg:$s-danger font-size:0.9em m:0', 'text=', models.error.message);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
for (const m of (models.value ?? [])) {
|
|
171
|
+
A('button.s-menu-item type=button', 'justify-content:space-between', () => {
|
|
172
|
+
A(() => { if (!url.debug && url.model === m.tableName) A('aria-current=page'); });
|
|
173
|
+
A('click=', () => route.go({ path: route.current.path, search: { model: m.tableName } }));
|
|
174
|
+
A('span', 'text=', m.tableName);
|
|
175
|
+
A('span', 'font-size:0.78em opacity:0.65', 'text=', `${m.fieldCount}f ${m.indexCount}i ${m.streamTypeCount}s`);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const navDebug: Content = () => {
|
|
182
|
+
A('button.s-menu-item type=button', () => {
|
|
183
|
+
A(() => { if (url.debug) A('aria-current=page'); });
|
|
184
|
+
A('click=', () => route.go({ path: route.current.path, search: { debug: '1' } }));
|
|
185
|
+
A('span.s-menu-icon aria-hidden=true #⬡');
|
|
186
|
+
A('#WarpSocket debug');
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
function dashboard() {
|
|
191
|
+
S.main({
|
|
192
|
+
icon: '⬡',
|
|
193
|
+
title: 'Lowlander',
|
|
194
|
+
menu: () => S.button({ text: 'Logout', attrs: '.neutral .outlined .small', click: logout }),
|
|
195
|
+
nav: { items: [navModels, { separator: true }, navDebug] },
|
|
196
|
+
content: () => {
|
|
197
|
+
if (url.debug) debugView();
|
|
198
|
+
else if (url.model) modelDetail();
|
|
199
|
+
else A('p', 'fg:$s-fg-muted text-align:center mt:$4', '#Select a model from the sidebar');
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Model detail ─────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
function modelDetail() {
|
|
207
|
+
const name = url.model;
|
|
208
|
+
const info = authProxy!.serverProxy.getModel(name);
|
|
209
|
+
A(() => {
|
|
210
|
+
if (info.busy) { A('p', 'fg:$s-fg-muted', '#Loading…'); return; }
|
|
211
|
+
if (info.error) { A('p', 'fg:$s-danger', 'text=', info.error.message); return; }
|
|
212
|
+
const m = info.value;
|
|
213
|
+
if (!m) return;
|
|
214
|
+
|
|
215
|
+
S.box({
|
|
216
|
+
header: m.tableName,
|
|
217
|
+
content: () => {
|
|
218
|
+
A('table', tableClass, () => {
|
|
219
|
+
A('thead tr', () => { A('th#Name'); A('th#Type'); A('th#Linked'); A('th#Default'); A('th#Description'); });
|
|
220
|
+
A('tbody', () => {
|
|
221
|
+
for (const f of m.fields) {
|
|
222
|
+
A('tr', () => {
|
|
223
|
+
A('td', () => A('code', 'text=', f.name));
|
|
224
|
+
A('td', 'fg:$s-success', 'text=', f.type.display);
|
|
225
|
+
A('td', () => { const lm = f.type.linkedModel; if (lm) modelLink(lm); });
|
|
226
|
+
A('td', 'text=', f.hasDefault ? '✓' : '');
|
|
227
|
+
A('td', 'fg:$s-fg-muted', 'text=', f.description || '');
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
S.box({
|
|
236
|
+
header: 'Indexes',
|
|
237
|
+
content: () => indexesTable(m),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
A(() => {
|
|
241
|
+
const cur = effectiveIndex();
|
|
242
|
+
const idx = m.indexes.find((i: any) => i.name === cur);
|
|
243
|
+
if (!idx) return;
|
|
244
|
+
S.box({
|
|
245
|
+
header: () => {
|
|
246
|
+
A('span', 'flex:1', 'text=', `Data · ${cur}`);
|
|
247
|
+
S.button({ text: '+ New', attrs: '.outlined .small', click: () => {
|
|
248
|
+
openCreateModal(authProxy!, m.tableName, m.fields, () => { $state.indexRefreshKey++; });
|
|
249
|
+
}});
|
|
250
|
+
},
|
|
251
|
+
content: () => indexBrowser(m, idx),
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (m.streamTypes.length) S.box({
|
|
256
|
+
header: 'Stream types',
|
|
257
|
+
content: () => streamTypesTable(m),
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function modelLink(modelName: string, pk?: any, display?: string) {
|
|
263
|
+
A('a', 'click=', (e: Event) => {
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
e.stopPropagation();
|
|
266
|
+
const search: Record<string, string | number> = { model: modelName };
|
|
267
|
+
if (pk !== undefined) { search.search = jsonStringify(pk); search.limit = 1; }
|
|
268
|
+
route.go({ path: route.current.path, search });
|
|
269
|
+
}, 'text=', display ?? modelName);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function jsonStringify(v: any): string {
|
|
273
|
+
if (typeof v === 'string') return v;
|
|
274
|
+
try { return JSON.stringify(v); } catch { return String(v); }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function indexesTable(m: any) {
|
|
278
|
+
A('table', tableClass, () => {
|
|
279
|
+
A('thead tr', () => { A('th#Name'); A('th#Kind'); A('th#Fields'); });
|
|
280
|
+
A('tbody', () => {
|
|
281
|
+
for (const idx of m.indexes) {
|
|
282
|
+
A('tr.selectable', 'click=', () => {
|
|
283
|
+
if (effectiveIndex() === idx.name) return;
|
|
284
|
+
url.index = (idx.name === '(primary)') ? '' : idx.name;
|
|
285
|
+
url.pk = '';
|
|
286
|
+
url.search = '';
|
|
287
|
+
}, () => {
|
|
288
|
+
A(() => A('.selected=', effectiveIndex() === idx.name));
|
|
289
|
+
A('td', () => A('code', 'text=', idx.name));
|
|
290
|
+
A('td', 'fg:$s-success', 'text=', idx.info.kind);
|
|
291
|
+
A('td', 'text=', idx.info.fields.join(', ') || '(computed)');
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function streamTypesTable(m: any) {
|
|
299
|
+
const fieldByName: Record<string, any> = {};
|
|
300
|
+
for (const f of m.fields) fieldByName[f.name] = f;
|
|
301
|
+
|
|
302
|
+
const seen = new Set<number>();
|
|
303
|
+
const streamTypes = (m.streamTypes as any[]).filter(st => {
|
|
304
|
+
if (seen.has(st.id)) return false;
|
|
305
|
+
seen.add(st.id);
|
|
306
|
+
return true;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
A('table', tableClass, () => {
|
|
310
|
+
A('thead tr', () => { A('th#id'); A('th#cache'); A('th#fields'); A('th', 'w:100%', '#live view for selected row'); });
|
|
311
|
+
A('tbody', () => {
|
|
312
|
+
for (const st of streamTypes) {
|
|
313
|
+
A('tr', () => {
|
|
314
|
+
A('td', () => A('code', 'text=', String(st.id)));
|
|
315
|
+
A('td', 'text=', st.cache ? `${st.cache}s` : '');
|
|
316
|
+
A('td', () => streamFieldsInline(st.fields, fieldByName));
|
|
317
|
+
A('td', () => streamLiveCell(m.tableName, st.id));
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function streamFieldsInline(sel: any, fieldByName: Record<string, any>) {
|
|
325
|
+
if (sel === true) { A('span', 'fg:$s-fg-muted', '#(scalar)'); return; }
|
|
326
|
+
if (!sel || typeof sel !== 'object') { A('span', 'text=', String(sel)); return; }
|
|
327
|
+
for (const [k, v] of Object.entries(sel)) {
|
|
328
|
+
if (typeof v === 'number') {
|
|
329
|
+
const linked = fieldByName[k]?.type?.linkedModel;
|
|
330
|
+
A(`div#${k}→`, () => {
|
|
331
|
+
if (linked) modelLink(linked, undefined, v.toString());
|
|
332
|
+
else A('#?');
|
|
333
|
+
});
|
|
334
|
+
} else if (v === false) {
|
|
335
|
+
A(`div#${k}*`);
|
|
336
|
+
} else {
|
|
337
|
+
A(`div#${k}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function streamLiveCell(modelName: string, streamTypeId: number) {
|
|
343
|
+
A(() => {
|
|
344
|
+
const pkRaw = effectivePk();
|
|
345
|
+
if (!pkRaw) { A('span', 'fg:$s-fg-muted', '#(select a row)'); return; }
|
|
346
|
+
let pk: any;
|
|
347
|
+
try { pk = JSON.parse(pkRaw); } catch { pk = pkRaw; }
|
|
348
|
+
const $stream = authProxy!.serverProxy.streamRecord(modelName, streamTypeId, pk);
|
|
349
|
+
A(() => {
|
|
350
|
+
if ($stream.busy) { A('span', 'fg:$s-fg-muted', '#…'); return; }
|
|
351
|
+
if ($stream.error) { A('span', 'fg:$s-danger', 'text=', $stream.error.message); return; }
|
|
352
|
+
A.dump($stream.value);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ─── Index browser ────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
function indexBrowser(m: any, idx: any) {
|
|
360
|
+
const modelName = m.tableName;
|
|
361
|
+
const indexName = idx.name;
|
|
362
|
+
|
|
363
|
+
const searchBind = {
|
|
364
|
+
get value() { return url.search; },
|
|
365
|
+
set value(v: string | number) { url.search = String(v); url.limit = 10; },
|
|
366
|
+
};
|
|
367
|
+
const reverseBind = {
|
|
368
|
+
get value() { return url.reverse; },
|
|
369
|
+
set value(v: any) { url.reverse = Boolean(v); },
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
A('div', 'display:flex flex-direction:column gap:$3', () => {
|
|
373
|
+
A('div', 'display:flex flex-wrap:wrap gap:$2 align-items:center', () => {
|
|
374
|
+
S.textline({ placeholder: 'search', attrs: 'flex:1 min-w:180px', bind: searchBind });
|
|
375
|
+
S.checkbox({ label: 'reverse', bind: reverseBind });
|
|
376
|
+
S.button({ text: '↺', attrs: '.outlined .small', ariaLabel: 'Refresh',
|
|
377
|
+
click: () => $state.indexRefreshKey++ });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
A(() => {
|
|
381
|
+
void $state.indexRefreshKey;
|
|
382
|
+
const opts = { search: parseMaybe(url.search), reverse: url.reverse, limit: url.limit };
|
|
383
|
+
const rows = authProxy!.serverProxy.findRecords(modelName, indexName, opts);
|
|
384
|
+
A(() => {
|
|
385
|
+
if (rows.busy) { A('p', 'fg:$s-fg-muted', '#Loading…'); return; }
|
|
386
|
+
if (rows.error) { A('p', 'fg:$s-danger', 'text=', rows.error.message); return; }
|
|
387
|
+
const r = rows.value;
|
|
388
|
+
if (!r) return;
|
|
389
|
+
if (!url.pk && r.rows.length > 0) {
|
|
390
|
+
url.pk = jsonStringify(r.rows[0].pk);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
A('p', 'fg:$s-fg-muted font-size:0.85em m:0', 'text=', `${r.rows.length} rows (scanned ${r.scanned})`);
|
|
394
|
+
if (!r.rows.length) { A('p', 'fg:$s-fg-muted m:0', '#(empty)'); return; }
|
|
395
|
+
A('div', 'overflow:auto', () => {
|
|
396
|
+
A('table', tableClass, () => {
|
|
397
|
+
const cols = Object.keys(r.rows[0].values);
|
|
398
|
+
A('thead tr', () => {
|
|
399
|
+
for (const c of cols) A('th', 'text=', c);
|
|
400
|
+
A('th', 'w:1px');
|
|
401
|
+
});
|
|
402
|
+
A('tbody', () => {
|
|
403
|
+
for (const row of r.rows) {
|
|
404
|
+
const pkStr = jsonStringify(row.pk);
|
|
405
|
+
A('tr.selectable', 'click=', () => { url.pk = (effectivePk() === pkStr) ? '-' : pkStr; }, () => {
|
|
406
|
+
A(() => A('.selected=', effectivePk() === pkStr));
|
|
407
|
+
for (const c of cols) {
|
|
408
|
+
A('td', () => A.dump(wrapForDump((row.values as any)[c])));
|
|
409
|
+
}
|
|
410
|
+
A('td', 'text-align:right white-space:nowrap', () => {
|
|
411
|
+
S.button({ text: '✎', attrs: '.outlined .small mr:$1',
|
|
412
|
+
ariaLabel: 'Edit', click: (e) => {
|
|
413
|
+
e.stopPropagation();
|
|
414
|
+
openEditModal(authProxy!, modelName, m.fields, row.pk, row.values, () => {
|
|
415
|
+
$state.indexRefreshKey++;
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
S.button({ text: '✕', attrs: '.danger .outlined .small',
|
|
420
|
+
ariaLabel: 'Delete', click: (e) => {
|
|
421
|
+
e.stopPropagation();
|
|
422
|
+
openDeleteConfirm(authProxy!, modelName, row.pk, pkStr, () => {
|
|
423
|
+
$state.indexRefreshKey++;
|
|
424
|
+
if (effectivePk() === pkStr) url.pk = '-';
|
|
425
|
+
});
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
if (r.rows.length >= url.limit) {
|
|
435
|
+
A(() => S.button({
|
|
436
|
+
text: `+ more (currently limit ${url.limit})`,
|
|
437
|
+
attrs: '.outlined .small',
|
|
438
|
+
click: () => { const cur = url.limit; url.limit = Math.min(cur * 5, cur + 250); },
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function wrapForDump(v: any): any {
|
|
447
|
+
if (v === null || v === undefined || typeof v !== 'object') return v;
|
|
448
|
+
if (v instanceof Date) return v;
|
|
449
|
+
if (v.__ref) {
|
|
450
|
+
const ref = v.__ref, pk = v.pk;
|
|
451
|
+
return { [A.CUSTOM_DUMP]() { modelLink(ref, pk, `<${ref} ${jsonStringify(pk)}>`); } };
|
|
452
|
+
}
|
|
453
|
+
if (Array.isArray(v)) return v.map(wrapForDump);
|
|
454
|
+
const result: Record<string, any> = {};
|
|
455
|
+
for (const [k, val] of Object.entries(v)) result[k] = wrapForDump(val);
|
|
456
|
+
return result;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function parseMaybe(s: string): any {
|
|
460
|
+
if (s === '') return undefined;
|
|
461
|
+
if (s === 'true') return true; if (s === 'false') return false;
|
|
462
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
|
|
463
|
+
try { return JSON.parse(s); } catch {}
|
|
464
|
+
return s;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─── Debug view ───────────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
function debugView() {
|
|
470
|
+
const $mode = A.proxy({ value: 'channels' as string });
|
|
471
|
+
S.tabs({
|
|
472
|
+
bind: $mode,
|
|
473
|
+
tabs: (['channels', 'sockets', 'workers', 'kv'] as const).map(t => ({
|
|
474
|
+
id: t,
|
|
475
|
+
label: t,
|
|
476
|
+
content: () => {
|
|
477
|
+
const debugInfo = authProxy!.serverProxy.getDebugState(t);
|
|
478
|
+
A(() => {
|
|
479
|
+
if (debugInfo.busy) { A('p', 'fg:$s-fg-muted', '#Loading…'); return; }
|
|
480
|
+
if (debugInfo.error) { A('p', 'fg:$s-danger', 'text=', debugInfo.error.message); return; }
|
|
481
|
+
const data = A.unproxy(debugInfo.value) as undefined | Record<string, Record<string, any>>;
|
|
482
|
+
if (!data) return;
|
|
483
|
+
const keySet = new Set<string>();
|
|
484
|
+
for (const obj of Object.values(data)) {
|
|
485
|
+
if (obj && typeof obj === 'object' && !(obj instanceof Uint8Array))
|
|
486
|
+
for (const k of Object.keys(obj)) keySet.add(k);
|
|
487
|
+
}
|
|
488
|
+
const cols = [...keySet];
|
|
489
|
+
A('div', 'overflow:auto', () => {
|
|
490
|
+
A('table', tableClass, () => {
|
|
491
|
+
A('thead tr', () => {
|
|
492
|
+
A('th##');
|
|
493
|
+
for (const k of cols) A('th', 'text=', k);
|
|
494
|
+
});
|
|
495
|
+
A('tbody', () => {
|
|
496
|
+
for (const [idx, obj] of Object.entries(data)) {
|
|
497
|
+
A('tr', () => {
|
|
498
|
+
A('td', () => A('code', 'text=', idx));
|
|
499
|
+
for (const k of cols) A('td', 'font-size:12px font-family:monospace', 'text=', debugCellText(obj?.[k]));
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
},
|
|
507
|
+
})),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function debugCellText(value: any): string {
|
|
512
|
+
if (value === null || value === undefined) return '';
|
|
513
|
+
if (value instanceof Uint8Array) return escapeBytes([...value]);
|
|
514
|
+
if (typeof value === 'object') {
|
|
515
|
+
if (value.type === 'Buffer' && Array.isArray(value.data)) return escapeBytes(value.data);
|
|
516
|
+
const keys = Object.keys(value);
|
|
517
|
+
if (!Array.isArray(value) && keys.length > 0 && keys.length <= 4096 && keys.every((k, i) => k === String(i))) {
|
|
518
|
+
const arr: number[] = [];
|
|
519
|
+
for (const k of keys) {
|
|
520
|
+
const b = value[k];
|
|
521
|
+
if (typeof b !== 'number' || b < 0 || b > 255) return JSON.stringify(value);
|
|
522
|
+
arr.push(b);
|
|
523
|
+
}
|
|
524
|
+
return escapeBytes(arr);
|
|
525
|
+
}
|
|
526
|
+
return JSON.stringify(value);
|
|
527
|
+
}
|
|
528
|
+
return String(value);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function escapeBytes(bytes: number[]): string {
|
|
532
|
+
let s = '';
|
|
533
|
+
for (const b of bytes) {
|
|
534
|
+
if (b >= 0x20 && b < 0x7f && b !== 0x22 && b !== 0x5c) s += String.fromCharCode(b);
|
|
535
|
+
else if (b === 0x09) s += '\\t';
|
|
536
|
+
else if (b === 0x0a) s += '\\n';
|
|
537
|
+
else if (b === 0x0d) s += '\\r';
|
|
538
|
+
else if (b === 0x22) s += '\\"';
|
|
539
|
+
else if (b === 0x5c) s += '\\\\';
|
|
540
|
+
else s += '\\x' + b.toString(16).padStart(2, '0');
|
|
541
|
+
}
|
|
542
|
+
return s;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ─── Mount ────────────────────────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
A.mount(document.body, () => {
|
|
548
|
+
if (!$state.authed) { login(); return; }
|
|
549
|
+
dashboard();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
if ($state.password) {
|
|
553
|
+
attemptLogin();
|
|
554
|
+
}
|
|
@@ -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
|
+
}
|