lowlander 0.5.0 → 0.6.1
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 +43 -67
- package/build/client/client.d.ts +8 -1
- package/build/client/client.js +39 -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.js +238 -246
- package/build/dashboard/client/main.js.map +1 -1
- package/build/dashboard/dashboard.html +8 -8
- package/build/dashboard/server.d.ts +20 -9
- package/build/dashboard/server.d.ts.map +1 -1
- package/build/dashboard/server.js +139 -3
- package/build/dashboard/server.js.map +1 -1
- package/build/examples/helloworld/.edinburgh/commit_worker.log +1809 -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 +2 -13
- package/build/examples/helloworld/client/js/base.d.ts +1 -4
- package/build/examples/helloworld/client/js/base.js +8 -217
- package/build/examples/helloworld/client/js/base.js.map +1 -1
- package/build/examples/helloworld/server/api.d.ts +4 -0
- package/build/examples/helloworld/server/api.d.ts.map +1 -1
- package/build/examples/helloworld/server/api.js +10 -0
- package/build/examples/helloworld/server/api.js.map +1 -1
- package/build/server/server.d.ts +3 -3
- package/build/server/server.d.ts.map +1 -1
- package/build/server/server.js +44 -5
- package/build/server/server.js.map +1 -1
- package/build/tsconfig.client.tsbuildinfo +1 -1
- package/build/tsconfig.server.tsbuildinfo +1 -1
- package/client/client.ts +41 -23
- package/dashboard/build-bundle.ts +8 -2
- package/dashboard/client/crud.ts +634 -0
- package/dashboard/client/main.ts +234 -246
- package/dashboard/server.ts +149 -5
- package/package.json +9 -5
- package/server/server.ts +43 -8
- package/skill/SKILL.md +30 -47
- package/skill/Connection_pruneCommitIds.md +0 -8
package/dashboard/client/main.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import A from 'aberdeen';
|
|
2
|
-
import
|
|
2
|
+
import * as route from 'aberdeen/route';
|
|
3
|
+
import * as S from 'staffa';
|
|
4
|
+
import type { Slot } from 'staffa';
|
|
3
5
|
import { Connection, type ClientProxyObject } from 'lowlander/client';
|
|
4
6
|
import type { _dashboard } from './shim-server.js';
|
|
7
|
+
import { openCreateModal, openEditModal, openDeleteConfirm } from './crud.js';
|
|
5
8
|
|
|
6
9
|
type ServerExports = { _dashboard: typeof _dashboard };
|
|
7
10
|
type API = ClientProxyObject<ServerExports>;
|
|
@@ -33,32 +36,31 @@ const $state = A.proxy({
|
|
|
33
36
|
authed: false,
|
|
34
37
|
indexRefreshKey: 0,
|
|
35
38
|
connecting: false,
|
|
39
|
+
connection: undefined as Connection<ServerExports> | undefined,
|
|
36
40
|
});
|
|
37
|
-
|
|
38
|
-
let connection: Connection<ServerExports> | undefined;
|
|
39
41
|
let api: API | undefined;
|
|
40
42
|
let authProxy: ReturnType<API['_dashboard']> | undefined;
|
|
41
43
|
|
|
42
|
-
// URL-backed state
|
|
44
|
+
// URL-backed state — reactive via aberdeen/route
|
|
43
45
|
const url = {
|
|
44
|
-
get debug() { return route.search.debug === '1'; },
|
|
46
|
+
get debug() { return route.current.search.debug === '1'; },
|
|
45
47
|
set debug(v: boolean) { setSearch('debug', v ? '1' : ''); },
|
|
46
|
-
get model() { return route.search.model || ''; },
|
|
48
|
+
get model() { return route.current.search.model || ''; },
|
|
47
49
|
set model(v: string) { setSearch('model', v); },
|
|
48
|
-
get index() { return route.search.index || ''; },
|
|
50
|
+
get index() { return route.current.search.index || ''; },
|
|
49
51
|
set index(v: string) { setSearch('index', v); },
|
|
50
|
-
get search() { return route.search.search || ''; },
|
|
52
|
+
get search() { return route.current.search.search || ''; },
|
|
51
53
|
set search(v: string) { setSearch('search', v); },
|
|
52
|
-
get reverse() { return route.search.reverse === '1'; },
|
|
54
|
+
get reverse() { return route.current.search.reverse === '1'; },
|
|
53
55
|
set reverse(v: boolean) { setSearch('reverse', v ? '1' : ''); },
|
|
54
|
-
get limit() { return parseInt(route.search.limit || '10', 10) || 10; },
|
|
56
|
+
get limit() { return parseInt(route.current.search.limit || '10', 10) || 10; },
|
|
55
57
|
set limit(v: number) { setSearch('limit', v === 10 ? '' : String(v)); },
|
|
56
|
-
get pk() { return route.search.pk || ''; },
|
|
58
|
+
get pk() { return route.current.search.pk || ''; },
|
|
57
59
|
set pk(v: string) { setSearch('pk', v); },
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
function setSearch(k: string, v: string) {
|
|
61
|
-
const s = route.search;
|
|
63
|
+
const s = route.current.search;
|
|
62
64
|
if (v === '' || v === undefined) delete s[k];
|
|
63
65
|
else s[k] = v;
|
|
64
66
|
}
|
|
@@ -66,80 +68,47 @@ function setSearch(k: string, v: string) {
|
|
|
66
68
|
function effectiveIndex() { return url.index || '(primary)'; }
|
|
67
69
|
function effectivePk(): string { return url.pk === '-' ? '' : url.pk; }
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
});
|
|
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
|
+
});
|
|
104
81
|
|
|
105
|
-
|
|
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
|
-
}
|
|
82
|
+
// ─── Login ───────────────────────────────────────────────────────────────────
|
|
116
83
|
|
|
117
84
|
function login() {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
content: $state.connected && !$state.authed ? 'Checking…' : 'Log in',
|
|
106
|
+
type: 'submit',
|
|
107
|
+
disabled: $state.connected && !$state.authed,
|
|
108
|
+
}),
|
|
140
109
|
});
|
|
141
110
|
});
|
|
142
|
-
}
|
|
111
|
+
},
|
|
143
112
|
});
|
|
144
113
|
}
|
|
145
114
|
|
|
@@ -148,10 +117,10 @@ async function attemptLogin() {
|
|
|
148
117
|
$state.loginError = '';
|
|
149
118
|
saveSettings({ wsUrl: $state.wsUrl, password: $state.password });
|
|
150
119
|
try {
|
|
151
|
-
if (connection) (connection as any).ws?.close?.();
|
|
120
|
+
if ($state.connection) ($state.connection as any).ws?.close?.();
|
|
152
121
|
} catch {}
|
|
153
|
-
connection = new Connection<ServerExports>($state.wsUrl);
|
|
154
|
-
api = connection.api;
|
|
122
|
+
$state.connection = new Connection<ServerExports>($state.wsUrl);
|
|
123
|
+
api = $state.connection.api;
|
|
155
124
|
$state.connected = false;
|
|
156
125
|
$state.authed = false;
|
|
157
126
|
const proxy = api._dashboard($state.password);
|
|
@@ -166,7 +135,7 @@ async function attemptLogin() {
|
|
|
166
135
|
$state.loginError = err?.message || 'Login failed';
|
|
167
136
|
$state.connected = false;
|
|
168
137
|
$state.authed = false;
|
|
169
|
-
connection = undefined;
|
|
138
|
+
$state.connection = undefined;
|
|
170
139
|
api = undefined;
|
|
171
140
|
authProxy = undefined;
|
|
172
141
|
}
|
|
@@ -177,124 +146,126 @@ function logout() {
|
|
|
177
146
|
$state.connected = false;
|
|
178
147
|
$state.password = '';
|
|
179
148
|
saveSettings({ wsUrl: $state.wsUrl, password: '' });
|
|
180
|
-
try { (connection as any)?.ws?.close?.(); } catch {}
|
|
181
|
-
connection = undefined;
|
|
149
|
+
try { ($state.connection as any)?.ws?.close?.(); } catch {}
|
|
150
|
+
$state.connection = undefined;
|
|
182
151
|
api = undefined;
|
|
183
152
|
authProxy = undefined;
|
|
184
153
|
}
|
|
185
154
|
|
|
186
|
-
|
|
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
|
-
}
|
|
155
|
+
// ─── Dashboard (authenticated) ───────────────────────────────────────────────
|
|
208
156
|
|
|
209
|
-
function
|
|
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: Slot = () => {
|
|
210
160
|
const models = authProxy!.serverProxy.listModels();
|
|
211
161
|
A(() => {
|
|
212
|
-
if (models.busy) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
});
|
|
225
177
|
}
|
|
226
178
|
});
|
|
227
|
-
}
|
|
179
|
+
};
|
|
228
180
|
|
|
181
|
+
const navDebug: Slot = () => {
|
|
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
|
+
};
|
|
229
189
|
|
|
230
|
-
function
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
190
|
+
function dashboard() {
|
|
191
|
+
S.main({
|
|
192
|
+
icon: '⬡',
|
|
193
|
+
title: 'Lowlander',
|
|
194
|
+
menu: () => S.button({ content: '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
|
+
},
|
|
236
201
|
});
|
|
237
202
|
}
|
|
238
203
|
|
|
204
|
+
// ─── Model detail ─────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
239
206
|
function modelDetail() {
|
|
240
207
|
const name = url.model;
|
|
241
208
|
const info = authProxy!.serverProxy.getModel(name);
|
|
242
209
|
A(() => {
|
|
243
|
-
if (info.busy) { A('
|
|
244
|
-
if (info.error) { 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; }
|
|
245
212
|
const m = info.value;
|
|
246
213
|
if (!m) return;
|
|
247
214
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
}
|
|
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
|
+
});
|
|
264
231
|
});
|
|
265
|
-
}
|
|
232
|
+
},
|
|
266
233
|
});
|
|
267
234
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
indexesTable(m)
|
|
235
|
+
S.box({
|
|
236
|
+
header: 'Indexes',
|
|
237
|
+
content: () => indexesTable(m),
|
|
271
238
|
});
|
|
272
239
|
|
|
273
240
|
A(() => {
|
|
274
241
|
const cur = effectiveIndex();
|
|
275
242
|
const idx = m.indexes.find((i: any) => i.name === cur);
|
|
276
243
|
if (!idx) return;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
244
|
+
S.box({
|
|
245
|
+
header: () => {
|
|
246
|
+
A('span', 'flex:1', 'text=', `Data · ${cur}`);
|
|
247
|
+
S.button({ content: '+ New', attrs: '.outlined .small', click: () => {
|
|
248
|
+
openCreateModal(authProxy!, m.tableName, m.fields, () => { $state.indexRefreshKey++; });
|
|
249
|
+
}});
|
|
250
|
+
},
|
|
251
|
+
content: () => indexBrowser(m, idx),
|
|
280
252
|
});
|
|
281
253
|
});
|
|
282
254
|
|
|
283
|
-
if (m.streamTypes.length)
|
|
284
|
-
|
|
285
|
-
streamTypesTable(m)
|
|
255
|
+
if (m.streamTypes.length) S.box({
|
|
256
|
+
header: 'Stream types',
|
|
257
|
+
content: () => streamTypesTable(m),
|
|
286
258
|
});
|
|
287
259
|
});
|
|
288
260
|
}
|
|
289
261
|
|
|
290
|
-
// Render a clickable link to another model
|
|
291
262
|
function modelLink(modelName: string, pk?: any, display?: string) {
|
|
292
263
|
A('a', 'click=', (e: Event) => {
|
|
293
264
|
e.preventDefault();
|
|
294
265
|
e.stopPropagation();
|
|
295
266
|
const search: Record<string, string | number> = { model: modelName };
|
|
296
267
|
if (pk !== undefined) { search.search = jsonStringify(pk); search.limit = 1; }
|
|
297
|
-
go({ path: route.path, search });
|
|
268
|
+
route.go({ path: route.current.path, search });
|
|
298
269
|
}, 'text=', display ?? modelName);
|
|
299
270
|
}
|
|
300
271
|
|
|
@@ -316,7 +287,7 @@ function indexesTable(m: any) {
|
|
|
316
287
|
}, () => {
|
|
317
288
|
A(() => A('.selected=', effectiveIndex() === idx.name));
|
|
318
289
|
A('td', () => A('code', 'text=', idx.name));
|
|
319
|
-
A('td', 'fg:$
|
|
290
|
+
A('td', 'fg:$s-success', 'text=', idx.info.kind);
|
|
320
291
|
A('td', 'text=', idx.info.fields.join(', ') || '(computed)');
|
|
321
292
|
});
|
|
322
293
|
}
|
|
@@ -351,19 +322,19 @@ function streamTypesTable(m: any) {
|
|
|
351
322
|
}
|
|
352
323
|
|
|
353
324
|
function streamFieldsInline(sel: any, fieldByName: Record<string, any>) {
|
|
354
|
-
if (sel === true) { A('span', 'fg:$muted', '#(scalar)'); return; }
|
|
325
|
+
if (sel === true) { A('span', 'fg:$s-fg-muted', '#(scalar)'); return; }
|
|
355
326
|
if (!sel || typeof sel !== 'object') { A('span', 'text=', String(sel)); return; }
|
|
356
327
|
for (const [k, v] of Object.entries(sel)) {
|
|
357
|
-
if (v ===
|
|
358
|
-
A('div', 'text=', k);
|
|
359
|
-
} else if (typeof v === 'number') {
|
|
328
|
+
if (typeof v === 'number') {
|
|
360
329
|
const linked = fieldByName[k]?.type?.linkedModel;
|
|
361
|
-
A(`div
|
|
330
|
+
A(`div#${k}→`, () => {
|
|
362
331
|
if (linked) modelLink(linked, undefined, v.toString());
|
|
363
332
|
else A('#?');
|
|
364
333
|
});
|
|
334
|
+
} else if (v === false) {
|
|
335
|
+
A(`div#${k}*`);
|
|
365
336
|
} else {
|
|
366
|
-
A(`div
|
|
337
|
+
A(`div#${k}`);
|
|
367
338
|
}
|
|
368
339
|
}
|
|
369
340
|
}
|
|
@@ -371,80 +342,101 @@ function streamFieldsInline(sel: any, fieldByName: Record<string, any>) {
|
|
|
371
342
|
function streamLiveCell(modelName: string, streamTypeId: number) {
|
|
372
343
|
A(() => {
|
|
373
344
|
const pkRaw = effectivePk();
|
|
374
|
-
if (!pkRaw) { A('span', 'fg:$muted', '#(select a row)'); return; }
|
|
345
|
+
if (!pkRaw) { A('span', 'fg:$s-fg-muted', '#(select a row)'); return; }
|
|
375
346
|
let pk: any;
|
|
376
347
|
try { pk = JSON.parse(pkRaw); } catch { pk = pkRaw; }
|
|
377
348
|
const $stream = authProxy!.serverProxy.streamRecord(modelName, streamTypeId, pk);
|
|
378
349
|
A(() => {
|
|
379
|
-
if ($stream.busy) { A('span', 'fg:$muted', '#…'); return; }
|
|
380
|
-
if ($stream.error) { A('span', 'fg:$danger', 'text=', $stream.error.message); return; }
|
|
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; }
|
|
381
352
|
A.dump($stream.value);
|
|
382
353
|
});
|
|
383
354
|
});
|
|
384
355
|
}
|
|
385
356
|
|
|
357
|
+
// ─── Index browser ────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
386
359
|
function indexBrowser(m: any, idx: any) {
|
|
387
360
|
const modelName = m.tableName;
|
|
388
361
|
const indexName = idx.name;
|
|
389
362
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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({ content: '↺', attrs: '.outlined .small', ariaLabel: 'Refresh',
|
|
377
|
+
click: () => $state.indexRefreshKey++ });
|
|
401
378
|
});
|
|
402
379
|
|
|
403
380
|
A(() => {
|
|
404
381
|
void $state.indexRefreshKey;
|
|
405
|
-
const opts = {
|
|
406
|
-
search: parseMaybe(url.search),
|
|
407
|
-
reverse: url.reverse,
|
|
408
|
-
limit: url.limit,
|
|
409
|
-
};
|
|
382
|
+
const opts = { search: parseMaybe(url.search), reverse: url.reverse, limit: url.limit };
|
|
410
383
|
const rows = authProxy!.serverProxy.findRecords(modelName, indexName, opts);
|
|
411
384
|
A(() => {
|
|
412
|
-
if (rows.busy) { A('
|
|
413
|
-
if (rows.error) { 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; }
|
|
414
387
|
const r = rows.value;
|
|
415
388
|
if (!r) return;
|
|
416
|
-
// Auto-select first row when no explicit selection
|
|
417
389
|
if (!url.pk && r.rows.length > 0) {
|
|
418
390
|
url.pk = jsonStringify(r.rows[0].pk);
|
|
419
391
|
return;
|
|
420
392
|
}
|
|
421
|
-
A('
|
|
422
|
-
if (!r.rows.length) { A('
|
|
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; }
|
|
423
395
|
A('div', 'overflow:auto', () => {
|
|
424
396
|
A('table', tableClass, () => {
|
|
425
397
|
const cols = Object.keys(r.rows[0].values);
|
|
426
|
-
A('thead tr', () => {
|
|
398
|
+
A('thead tr', () => {
|
|
399
|
+
for (const c of cols) A('th', 'text=', c);
|
|
400
|
+
A('th', 'w:1px');
|
|
401
|
+
});
|
|
427
402
|
A('tbody', () => {
|
|
428
403
|
for (const row of r.rows) {
|
|
429
404
|
const pkStr = jsonStringify(row.pk);
|
|
430
405
|
A('tr.selectable', 'click=', () => { url.pk = (effectivePk() === pkStr) ? '-' : pkStr; }, () => {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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({ content: '✎', 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({ content: '✕', 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
|
+
});
|
|
435
428
|
});
|
|
429
|
+
});
|
|
436
430
|
}
|
|
437
431
|
});
|
|
438
432
|
});
|
|
439
433
|
});
|
|
440
|
-
// 'more' button
|
|
441
434
|
if (r.rows.length >= url.limit) {
|
|
442
|
-
A(
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
});
|
|
435
|
+
A(() => S.button({
|
|
436
|
+
content: `+ more (currently limit ${url.limit})`,
|
|
437
|
+
attrs: '.outlined .small',
|
|
438
|
+
click: () => { const cur = url.limit; url.limit = Math.min(cur * 5, cur + 250); },
|
|
439
|
+
}));
|
|
448
440
|
}
|
|
449
441
|
});
|
|
450
442
|
});
|
|
@@ -472,47 +464,47 @@ function parseMaybe(s: string): any {
|
|
|
472
464
|
return s;
|
|
473
465
|
}
|
|
474
466
|
|
|
467
|
+
// ─── Debug view ───────────────────────────────────────────────────────────────
|
|
468
|
+
|
|
475
469
|
function debugView() {
|
|
476
|
-
const $mode = A.proxy({
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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]));
|
|
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);
|
|
510
494
|
});
|
|
511
|
-
|
|
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
|
+
});
|
|
512
504
|
});
|
|
513
505
|
});
|
|
514
|
-
}
|
|
515
|
-
})
|
|
506
|
+
},
|
|
507
|
+
})),
|
|
516
508
|
});
|
|
517
509
|
}
|
|
518
510
|
|
|
@@ -521,7 +513,6 @@ function debugCellText(value: any): string {
|
|
|
521
513
|
if (value instanceof Uint8Array) return escapeBytes([...value]);
|
|
522
514
|
if (typeof value === 'object') {
|
|
523
515
|
if (value.type === 'Buffer' && Array.isArray(value.data)) return escapeBytes(value.data);
|
|
524
|
-
// Buffer-like (proxied Uint8Array): sequential integer keys
|
|
525
516
|
const keys = Object.keys(value);
|
|
526
517
|
if (!Array.isArray(value) && keys.length > 0 && keys.length <= 4096 && keys.every((k, i) => k === String(i))) {
|
|
527
518
|
const arr: number[] = [];
|
|
@@ -551,14 +542,11 @@ function escapeBytes(bytes: number[]): string {
|
|
|
551
542
|
return s;
|
|
552
543
|
}
|
|
553
544
|
|
|
554
|
-
|
|
545
|
+
// ─── Mount ────────────────────────────────────────────────────────────────────
|
|
555
546
|
|
|
556
547
|
A.mount(document.body, () => {
|
|
557
548
|
if (!$state.authed) { login(); return; }
|
|
558
|
-
|
|
559
|
-
sidebar();
|
|
560
|
-
mainArea();
|
|
561
|
-
});
|
|
549
|
+
dashboard();
|
|
562
550
|
});
|
|
563
551
|
|
|
564
552
|
if ($state.password) {
|