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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import A from 'aberdeen';
|
|
2
|
-
import
|
|
2
|
+
import * as route from 'aberdeen/route';
|
|
3
|
+
import * as S from 'staffa';
|
|
3
4
|
import { Connection } from 'lowlander/client';
|
|
5
|
+
import { openCreateModal, openEditModal, openDeleteConfirm } from './crud.js';
|
|
4
6
|
const SETTINGS_KEY = 'lowlander-dashboard';
|
|
5
7
|
function loadSettings() {
|
|
6
8
|
try {
|
|
@@ -26,29 +28,29 @@ const $state = A.proxy({
|
|
|
26
28
|
authed: false,
|
|
27
29
|
indexRefreshKey: 0,
|
|
28
30
|
connecting: false,
|
|
31
|
+
connection: undefined,
|
|
29
32
|
});
|
|
30
|
-
let connection;
|
|
31
33
|
let api;
|
|
32
34
|
let authProxy;
|
|
33
|
-
// URL-backed state
|
|
35
|
+
// URL-backed state — reactive via aberdeen/route
|
|
34
36
|
const url = {
|
|
35
|
-
get debug() { return route.search.debug === '1'; },
|
|
37
|
+
get debug() { return route.current.search.debug === '1'; },
|
|
36
38
|
set debug(v) { setSearch('debug', v ? '1' : ''); },
|
|
37
|
-
get model() { return route.search.model || ''; },
|
|
39
|
+
get model() { return route.current.search.model || ''; },
|
|
38
40
|
set model(v) { setSearch('model', v); },
|
|
39
|
-
get index() { return route.search.index || ''; },
|
|
41
|
+
get index() { return route.current.search.index || ''; },
|
|
40
42
|
set index(v) { setSearch('index', v); },
|
|
41
|
-
get search() { return route.search.search || ''; },
|
|
43
|
+
get search() { return route.current.search.search || ''; },
|
|
42
44
|
set search(v) { setSearch('search', v); },
|
|
43
|
-
get reverse() { return route.search.reverse === '1'; },
|
|
45
|
+
get reverse() { return route.current.search.reverse === '1'; },
|
|
44
46
|
set reverse(v) { setSearch('reverse', v ? '1' : ''); },
|
|
45
|
-
get limit() { return parseInt(route.search.limit || '10', 10) || 10; },
|
|
47
|
+
get limit() { return parseInt(route.current.search.limit || '10', 10) || 10; },
|
|
46
48
|
set limit(v) { setSearch('limit', v === 10 ? '' : String(v)); },
|
|
47
|
-
get pk() { return route.search.pk || ''; },
|
|
49
|
+
get pk() { return route.current.search.pk || ''; },
|
|
48
50
|
set pk(v) { setSearch('pk', v); },
|
|
49
51
|
};
|
|
50
52
|
function setSearch(k, v) {
|
|
51
|
-
const s = route.search;
|
|
53
|
+
const s = route.current.search;
|
|
52
54
|
if (v === '' || v === undefined)
|
|
53
55
|
delete s[k];
|
|
54
56
|
else
|
|
@@ -56,74 +58,46 @@ function setSearch(k, v) {
|
|
|
56
58
|
}
|
|
57
59
|
function effectiveIndex() { return url.index || '(primary)'; }
|
|
58
60
|
function effectivePk() { return url.pk === '-' ? '' : url.pk; }
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
A.cssVars.border = '#2a2f3d';
|
|
71
|
-
A.insertGlobalCss({
|
|
72
|
-
'html,body': 'm:0 p:0 bg:$bg fg:$fg font-family: -apple-system, system-ui, sans-serif; font-size:14px; h:100vh',
|
|
73
|
-
'body': 'display:flex flex-direction:column',
|
|
74
|
-
'a': 'color:$accent text-decoration:none cursor:pointer',
|
|
75
|
-
'a:hover': 'text-decoration:underline',
|
|
76
|
-
'input,select,textarea,button': 'font-family:inherit font-size:inherit',
|
|
77
|
-
'input[type=text],input[type=password],input:not([type]),select,textarea': 'bg:$panel2 fg:$fg border: 1px solid $border; r:4px p: 6px 8px; outline:none box-sizing:border-box w:100%',
|
|
78
|
-
'input:focus,textarea:focus,select:focus': 'border-color:$accent',
|
|
79
|
-
'button': 'bg:$accent fg:#0b1220 border:0 r:4px p: 6px 12px; cursor:pointer font-weight:600',
|
|
80
|
-
'button:hover': 'opacity:0.9',
|
|
81
|
-
'button.ghost': 'bg:transparent fg:$accent border: 1px solid $border;',
|
|
82
|
-
'code,pre': 'font-family: ui-monospace, Menlo, Consolas, monospace; font-size:12.5px',
|
|
83
|
-
'pre': 'bg:#0b0d14 p:$3 r:6px overflow:auto m:0',
|
|
84
|
-
'.tag': 'display:inline-block bg:$panel2 fg:$muted p: 1px 6px; r:3px font-size:11px ml:$1',
|
|
85
|
-
'.sidebarSel': 'bg:#1e3251 box-shadow: inset 3px 0 0 $accent;',
|
|
86
|
-
'.row': 'display:flex gap:$2 align-items:center',
|
|
87
|
-
'.col': 'display:flex flex-direction:column gap:$2',
|
|
88
|
-
'ul': 'margin-block-start:0 margin-block-end:0',
|
|
89
|
-
});
|
|
90
|
-
tableClass = A.insertCss({
|
|
91
|
-
'&': 'w:100% border-collapse:collapse font-size:12.5px',
|
|
92
|
-
'& th,& td': 'text-align:left p: 4px 8px; border-bottom: 1px solid $border; vertical-align:top',
|
|
93
|
-
'& th': 'bg:$panel2 fg:$muted font-weight:600',
|
|
94
|
-
'& tbody tr': 'cursor:default',
|
|
95
|
-
'& tbody tr:hover': 'bg:#ffffff08',
|
|
96
|
-
'& tbody tr.selectable': 'cursor:pointer',
|
|
97
|
-
'& tbody tr.selected': 'bg:#1e3251',
|
|
98
|
-
'& tbody tr.selected td:first-child': 'border-left: 3px solid $accent; padding-left: 5px;',
|
|
99
|
-
});
|
|
100
|
-
}
|
|
61
|
+
// Table styling — Staffa provides the base (border-collapse, th/td padding/borders,
|
|
62
|
+
// thead stronger border). We only add size, cursor, hover, and selection states.
|
|
63
|
+
const tableClass = A.insertCss({
|
|
64
|
+
'&': 'w:100% font-size:12.5px',
|
|
65
|
+
'& tbody tr': 'cursor:default',
|
|
66
|
+
'& tbody tr:hover': 'background: color-mix(in srgb, $s-fg 5%, transparent);',
|
|
67
|
+
'& tbody tr.selectable': 'cursor:pointer',
|
|
68
|
+
'& tbody tr.selected': 'background: color-mix(in srgb, $s-accent 15%, transparent);',
|
|
69
|
+
'& tbody tr.selected td:first-child': 'border-left: 3px solid $s-accent; padding-left: 5px;',
|
|
70
|
+
});
|
|
71
|
+
// ─── Login ───────────────────────────────────────────────────────────────────
|
|
101
72
|
function login() {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
73
|
+
S.main({
|
|
74
|
+
title: 'Lowlander Dashboard',
|
|
75
|
+
maxWidth: '24rem',
|
|
76
|
+
content: () => {
|
|
77
|
+
A(() => {
|
|
78
|
+
if ($state.connecting && !$state.connection?.getError()) {
|
|
79
|
+
A('p', 'text-align:center fg:$s-fg-muted', '#Connecting…');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
S.form({
|
|
83
|
+
submit: () => attemptLogin(),
|
|
84
|
+
content: () => {
|
|
85
|
+
S.textline({ label: 'WebSocket URL', bind: A.ref($state, 'wsUrl') });
|
|
86
|
+
S.textline({ label: 'Password', type: 'password', bind: A.ref($state, 'password'), inputAttrs: 'autofocus=autofocus' });
|
|
87
|
+
A(() => {
|
|
88
|
+
const err = $state.connection?.getError() || $state.loginError;
|
|
89
|
+
if (err)
|
|
90
|
+
A('p', 'fg:$s-danger m:0', 'text=', err);
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
actions: () => S.button({
|
|
94
|
+
content: $state.connected && !$state.authed ? 'Checking…' : 'Log in',
|
|
95
|
+
type: 'submit',
|
|
96
|
+
disabled: $state.connected && !$state.authed,
|
|
97
|
+
}),
|
|
124
98
|
});
|
|
125
99
|
});
|
|
126
|
-
}
|
|
100
|
+
},
|
|
127
101
|
});
|
|
128
102
|
}
|
|
129
103
|
async function attemptLogin() {
|
|
@@ -131,12 +105,12 @@ async function attemptLogin() {
|
|
|
131
105
|
$state.loginError = '';
|
|
132
106
|
saveSettings({ wsUrl: $state.wsUrl, password: $state.password });
|
|
133
107
|
try {
|
|
134
|
-
if (connection)
|
|
135
|
-
connection.ws?.close?.();
|
|
108
|
+
if ($state.connection)
|
|
109
|
+
$state.connection.ws?.close?.();
|
|
136
110
|
}
|
|
137
111
|
catch { }
|
|
138
|
-
connection = new Connection($state.wsUrl);
|
|
139
|
-
api = connection.api;
|
|
112
|
+
$state.connection = new Connection($state.wsUrl);
|
|
113
|
+
api = $state.connection.api;
|
|
140
114
|
$state.connected = false;
|
|
141
115
|
$state.authed = false;
|
|
142
116
|
const proxy = api._dashboard($state.password);
|
|
@@ -152,7 +126,7 @@ async function attemptLogin() {
|
|
|
152
126
|
$state.loginError = err?.message || 'Login failed';
|
|
153
127
|
$state.connected = false;
|
|
154
128
|
$state.authed = false;
|
|
155
|
-
connection = undefined;
|
|
129
|
+
$state.connection = undefined;
|
|
156
130
|
api = undefined;
|
|
157
131
|
authProxy = undefined;
|
|
158
132
|
}
|
|
@@ -163,125 +137,125 @@ function logout() {
|
|
|
163
137
|
$state.password = '';
|
|
164
138
|
saveSettings({ wsUrl: $state.wsUrl, password: '' });
|
|
165
139
|
try {
|
|
166
|
-
connection?.ws?.close?.();
|
|
140
|
+
$state.connection?.ws?.close?.();
|
|
167
141
|
}
|
|
168
142
|
catch { }
|
|
169
|
-
connection = undefined;
|
|
143
|
+
$state.connection = undefined;
|
|
170
144
|
api = undefined;
|
|
171
145
|
authProxy = undefined;
|
|
172
146
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
A('button.ghost#Logout', 'click=', () => logout());
|
|
178
|
-
});
|
|
179
|
-
A('div', 'overflow:auto flex:1 p:$2', () => {
|
|
180
|
-
if (!authProxy)
|
|
181
|
-
return;
|
|
182
|
-
sidebarModels();
|
|
183
|
-
A('div', 'border-top: 1px solid $border; mt:$2 pt:$2', () => {
|
|
184
|
-
A('div', 'p: 6px 8px; r:4px cursor:pointer', 'click=', () => { go({ path: route.path, search: { debug: '1' } }); }, () => {
|
|
185
|
-
A(() => A('.sidebarSel=', url.debug));
|
|
186
|
-
A('div', 'display:flex justify-content:space-between align-items:baseline', () => {
|
|
187
|
-
A('span#⬡ WarpSocket debug');
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
function sidebarModels() {
|
|
147
|
+
// ─── Dashboard (authenticated) ───────────────────────────────────────────────
|
|
148
|
+
// Sidebar nav: reactive model list. Rendered as a Content function so drawMenu
|
|
149
|
+
// calls it inside a reactive scope; models.busy/.value/.error drive re-renders.
|
|
150
|
+
const navModels = () => {
|
|
195
151
|
const models = authProxy.serverProxy.listModels();
|
|
196
152
|
A(() => {
|
|
197
153
|
if (models.busy) {
|
|
198
|
-
A('
|
|
154
|
+
A('p', 'p:$2 fg:$s-fg-muted font-size:0.9em m:0', '#Loading…');
|
|
199
155
|
return;
|
|
200
156
|
}
|
|
201
157
|
if (models.error) {
|
|
202
|
-
A('
|
|
158
|
+
A('p', 'p:$2 fg:$s-danger font-size:0.9em m:0', 'text=', models.error.message);
|
|
203
159
|
return;
|
|
204
160
|
}
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
A('
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
});
|
|
161
|
+
for (const m of (models.value ?? [])) {
|
|
162
|
+
A('button.s-menu-item type=button', 'justify-content:space-between', () => {
|
|
163
|
+
A(() => { if (!url.debug && url.model === m.tableName)
|
|
164
|
+
A('aria-current=page'); });
|
|
165
|
+
A('click=', () => route.go({ path: route.current.path, search: { model: m.tableName } }));
|
|
166
|
+
A('span', 'text=', m.tableName);
|
|
167
|
+
A('span', 'font-size:0.78em opacity:0.65', 'text=', `${m.fieldCount}f ${m.indexCount}i ${m.streamTypeCount}s`);
|
|
213
168
|
});
|
|
214
169
|
}
|
|
215
170
|
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
A('
|
|
219
|
-
if (
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
171
|
+
};
|
|
172
|
+
const navDebug = () => {
|
|
173
|
+
A('button.s-menu-item type=button', () => {
|
|
174
|
+
A(() => { if (url.debug)
|
|
175
|
+
A('aria-current=page'); });
|
|
176
|
+
A('click=', () => route.go({ path: route.current.path, search: { debug: '1' } }));
|
|
177
|
+
A('span.s-menu-icon aria-hidden=true #⬡');
|
|
178
|
+
A('#WarpSocket debug');
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
function dashboard() {
|
|
182
|
+
S.main({
|
|
183
|
+
icon: '⬡',
|
|
184
|
+
title: 'Lowlander',
|
|
185
|
+
menu: () => S.button({ content: 'Logout', attrs: '.neutral .outlined .small', click: logout }),
|
|
186
|
+
nav: { items: [navModels, { separator: true }, navDebug] },
|
|
187
|
+
content: () => {
|
|
188
|
+
if (url.debug)
|
|
189
|
+
debugView();
|
|
190
|
+
else if (url.model)
|
|
191
|
+
modelDetail();
|
|
192
|
+
else
|
|
193
|
+
A('p', 'fg:$s-fg-muted text-align:center mt:$4', '#Select a model from the sidebar');
|
|
194
|
+
},
|
|
227
195
|
});
|
|
228
196
|
}
|
|
197
|
+
// ─── Model detail ─────────────────────────────────────────────────────────────
|
|
229
198
|
function modelDetail() {
|
|
230
199
|
const name = url.model;
|
|
231
200
|
const info = authProxy.serverProxy.getModel(name);
|
|
232
201
|
A(() => {
|
|
233
202
|
if (info.busy) {
|
|
234
|
-
A('
|
|
203
|
+
A('p', 'fg:$s-fg-muted', '#Loading…');
|
|
235
204
|
return;
|
|
236
205
|
}
|
|
237
206
|
if (info.error) {
|
|
238
|
-
A('
|
|
207
|
+
A('p', 'fg:$s-danger', 'text=', info.error.message);
|
|
239
208
|
return;
|
|
240
209
|
}
|
|
241
210
|
const m = info.value;
|
|
242
211
|
if (!m)
|
|
243
212
|
return;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
213
|
+
S.box({
|
|
214
|
+
header: m.tableName,
|
|
215
|
+
content: () => {
|
|
216
|
+
A('table', tableClass, () => {
|
|
217
|
+
A('thead tr', () => { A('th#Name'); A('th#Type'); A('th#Linked'); A('th#Default'); A('th#Description'); });
|
|
218
|
+
A('tbody', () => {
|
|
219
|
+
for (const f of m.fields) {
|
|
220
|
+
A('tr', () => {
|
|
221
|
+
A('td', () => A('code', 'text=', f.name));
|
|
222
|
+
A('td', 'fg:$s-success', 'text=', f.type.display);
|
|
223
|
+
A('td', () => { const lm = f.type.linkedModel; if (lm)
|
|
224
|
+
modelLink(lm); });
|
|
225
|
+
A('td', 'text=', f.hasDefault ? '✓' : '');
|
|
226
|
+
A('td', 'fg:$s-fg-muted', 'text=', f.description || '');
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
});
|
|
260
230
|
});
|
|
261
|
-
}
|
|
231
|
+
},
|
|
262
232
|
});
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
indexesTable(m)
|
|
233
|
+
S.box({
|
|
234
|
+
header: 'Indexes',
|
|
235
|
+
content: () => indexesTable(m),
|
|
266
236
|
});
|
|
267
237
|
A(() => {
|
|
268
238
|
const cur = effectiveIndex();
|
|
269
239
|
const idx = m.indexes.find((i) => i.name === cur);
|
|
270
240
|
if (!idx)
|
|
271
241
|
return;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
242
|
+
S.box({
|
|
243
|
+
header: () => {
|
|
244
|
+
A('span', 'flex:1', 'text=', `Data · ${cur}`);
|
|
245
|
+
S.button({ content: '+ New', attrs: '.outlined .small', click: () => {
|
|
246
|
+
openCreateModal(authProxy, m.tableName, m.fields, () => { $state.indexRefreshKey++; });
|
|
247
|
+
} });
|
|
248
|
+
},
|
|
249
|
+
content: () => indexBrowser(m, idx),
|
|
275
250
|
});
|
|
276
251
|
});
|
|
277
252
|
if (m.streamTypes.length)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
streamTypesTable(m)
|
|
253
|
+
S.box({
|
|
254
|
+
header: 'Stream types',
|
|
255
|
+
content: () => streamTypesTable(m),
|
|
281
256
|
});
|
|
282
257
|
});
|
|
283
258
|
}
|
|
284
|
-
// Render a clickable link to another model
|
|
285
259
|
function modelLink(modelName, pk, display) {
|
|
286
260
|
A('a', 'click=', (e) => {
|
|
287
261
|
e.preventDefault();
|
|
@@ -291,7 +265,7 @@ function modelLink(modelName, pk, display) {
|
|
|
291
265
|
search.search = jsonStringify(pk);
|
|
292
266
|
search.limit = 1;
|
|
293
267
|
}
|
|
294
|
-
go({ path: route.path, search });
|
|
268
|
+
route.go({ path: route.current.path, search });
|
|
295
269
|
}, 'text=', display ?? modelName);
|
|
296
270
|
}
|
|
297
271
|
function jsonStringify(v) {
|
|
@@ -318,7 +292,7 @@ function indexesTable(m) {
|
|
|
318
292
|
}, () => {
|
|
319
293
|
A(() => A('.selected=', effectiveIndex() === idx.name));
|
|
320
294
|
A('td', () => A('code', 'text=', idx.name));
|
|
321
|
-
A('td', 'fg:$
|
|
295
|
+
A('td', 'fg:$s-success', 'text=', idx.info.kind);
|
|
322
296
|
A('td', 'text=', idx.info.fields.join(', ') || '(computed)');
|
|
323
297
|
});
|
|
324
298
|
}
|
|
@@ -352,7 +326,7 @@ function streamTypesTable(m) {
|
|
|
352
326
|
}
|
|
353
327
|
function streamFieldsInline(sel, fieldByName) {
|
|
354
328
|
if (sel === true) {
|
|
355
|
-
A('span', 'fg:$muted', '#(scalar)');
|
|
329
|
+
A('span', 'fg:$s-fg-muted', '#(scalar)');
|
|
356
330
|
return;
|
|
357
331
|
}
|
|
358
332
|
if (!sel || typeof sel !== 'object') {
|
|
@@ -360,20 +334,20 @@ function streamFieldsInline(sel, fieldByName) {
|
|
|
360
334
|
return;
|
|
361
335
|
}
|
|
362
336
|
for (const [k, v] of Object.entries(sel)) {
|
|
363
|
-
if (v ===
|
|
364
|
-
A('div', 'text=', k);
|
|
365
|
-
}
|
|
366
|
-
else if (typeof v === 'number') {
|
|
337
|
+
if (typeof v === 'number') {
|
|
367
338
|
const linked = fieldByName[k]?.type?.linkedModel;
|
|
368
|
-
A(`div
|
|
339
|
+
A(`div#${k}→`, () => {
|
|
369
340
|
if (linked)
|
|
370
341
|
modelLink(linked, undefined, v.toString());
|
|
371
342
|
else
|
|
372
343
|
A('#?');
|
|
373
344
|
});
|
|
374
345
|
}
|
|
346
|
+
else if (v === false) {
|
|
347
|
+
A(`div#${k}*`);
|
|
348
|
+
}
|
|
375
349
|
else {
|
|
376
|
-
A(`div
|
|
350
|
+
A(`div#${k}`);
|
|
377
351
|
}
|
|
378
352
|
}
|
|
379
353
|
}
|
|
@@ -381,7 +355,7 @@ function streamLiveCell(modelName, streamTypeId) {
|
|
|
381
355
|
A(() => {
|
|
382
356
|
const pkRaw = effectivePk();
|
|
383
357
|
if (!pkRaw) {
|
|
384
|
-
A('span', 'fg:$muted', '#(select a row)');
|
|
358
|
+
A('span', 'fg:$s-fg-muted', '#(select a row)');
|
|
385
359
|
return;
|
|
386
360
|
}
|
|
387
361
|
let pk;
|
|
@@ -394,64 +368,69 @@ function streamLiveCell(modelName, streamTypeId) {
|
|
|
394
368
|
const $stream = authProxy.serverProxy.streamRecord(modelName, streamTypeId, pk);
|
|
395
369
|
A(() => {
|
|
396
370
|
if ($stream.busy) {
|
|
397
|
-
A('span', 'fg:$muted', '#…');
|
|
371
|
+
A('span', 'fg:$s-fg-muted', '#…');
|
|
398
372
|
return;
|
|
399
373
|
}
|
|
400
374
|
if ($stream.error) {
|
|
401
|
-
A('span', 'fg:$danger', 'text=', $stream.error.message);
|
|
375
|
+
A('span', 'fg:$s-danger', 'text=', $stream.error.message);
|
|
402
376
|
return;
|
|
403
377
|
}
|
|
404
378
|
A.dump($stream.value);
|
|
405
379
|
});
|
|
406
380
|
});
|
|
407
381
|
}
|
|
382
|
+
// ─── Index browser ────────────────────────────────────────────────────────────
|
|
408
383
|
function indexBrowser(m, idx) {
|
|
409
384
|
const modelName = m.tableName;
|
|
410
385
|
const indexName = idx.name;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
386
|
+
const searchBind = {
|
|
387
|
+
get value() { return url.search; },
|
|
388
|
+
set value(v) { url.search = String(v); url.limit = 10; },
|
|
389
|
+
};
|
|
390
|
+
const reverseBind = {
|
|
391
|
+
get value() { return url.reverse; },
|
|
392
|
+
set value(v) { url.reverse = Boolean(v); },
|
|
393
|
+
};
|
|
394
|
+
A('div', 'display:flex flex-direction:column gap:$3', () => {
|
|
395
|
+
A('div', 'display:flex flex-wrap:wrap gap:$2 align-items:center', () => {
|
|
396
|
+
S.textline({ placeholder: 'search', attrs: 'flex:1 min-w:180px', bind: searchBind });
|
|
397
|
+
S.checkbox({ label: 'reverse', bind: reverseBind });
|
|
398
|
+
S.button({ content: '↺', attrs: '.outlined .small', ariaLabel: 'Refresh',
|
|
399
|
+
click: () => $state.indexRefreshKey++ });
|
|
419
400
|
});
|
|
420
401
|
A(() => {
|
|
421
402
|
void $state.indexRefreshKey;
|
|
422
|
-
const opts = {
|
|
423
|
-
search: parseMaybe(url.search),
|
|
424
|
-
reverse: url.reverse,
|
|
425
|
-
limit: url.limit,
|
|
426
|
-
};
|
|
403
|
+
const opts = { search: parseMaybe(url.search), reverse: url.reverse, limit: url.limit };
|
|
427
404
|
const rows = authProxy.serverProxy.findRecords(modelName, indexName, opts);
|
|
428
405
|
A(() => {
|
|
429
406
|
if (rows.busy) {
|
|
430
|
-
A('
|
|
407
|
+
A('p', 'fg:$s-fg-muted', '#Loading…');
|
|
431
408
|
return;
|
|
432
409
|
}
|
|
433
410
|
if (rows.error) {
|
|
434
|
-
A('
|
|
411
|
+
A('p', 'fg:$s-danger', 'text=', rows.error.message);
|
|
435
412
|
return;
|
|
436
413
|
}
|
|
437
414
|
const r = rows.value;
|
|
438
415
|
if (!r)
|
|
439
416
|
return;
|
|
440
|
-
// Auto-select first row when no explicit selection
|
|
441
417
|
if (!url.pk && r.rows.length > 0) {
|
|
442
418
|
url.pk = jsonStringify(r.rows[0].pk);
|
|
443
419
|
return;
|
|
444
420
|
}
|
|
445
|
-
A('
|
|
421
|
+
A('p', 'fg:$s-fg-muted font-size:0.85em m:0', 'text=', `${r.rows.length} rows (scanned ${r.scanned})`);
|
|
446
422
|
if (!r.rows.length) {
|
|
447
|
-
A('
|
|
423
|
+
A('p', 'fg:$s-fg-muted m:0', '#(empty)');
|
|
448
424
|
return;
|
|
449
425
|
}
|
|
450
426
|
A('div', 'overflow:auto', () => {
|
|
451
427
|
A('table', tableClass, () => {
|
|
452
428
|
const cols = Object.keys(r.rows[0].values);
|
|
453
|
-
A('thead tr', () => {
|
|
454
|
-
|
|
429
|
+
A('thead tr', () => {
|
|
430
|
+
for (const c of cols)
|
|
431
|
+
A('th', 'text=', c);
|
|
432
|
+
A('th', 'w:1px');
|
|
433
|
+
});
|
|
455
434
|
A('tbody', () => {
|
|
456
435
|
for (const row of r.rows) {
|
|
457
436
|
const pkStr = jsonStringify(row.pk);
|
|
@@ -460,19 +439,37 @@ function indexBrowser(m, idx) {
|
|
|
460
439
|
for (const c of cols) {
|
|
461
440
|
A('td', () => A.dump(wrapForDump(row.values[c])));
|
|
462
441
|
}
|
|
442
|
+
A('td', 'text-align:right white-space:nowrap', () => {
|
|
443
|
+
S.button({ content: '✎', attrs: '.outlined .small mr:$1',
|
|
444
|
+
ariaLabel: 'Edit', click: (e) => {
|
|
445
|
+
e.stopPropagation();
|
|
446
|
+
openEditModal(authProxy, modelName, m.fields, row.pk, row.values, () => {
|
|
447
|
+
$state.indexRefreshKey++;
|
|
448
|
+
});
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
S.button({ content: '✕', attrs: '.danger .outlined .small',
|
|
452
|
+
ariaLabel: 'Delete', click: (e) => {
|
|
453
|
+
e.stopPropagation();
|
|
454
|
+
openDeleteConfirm(authProxy, modelName, row.pk, pkStr, () => {
|
|
455
|
+
$state.indexRefreshKey++;
|
|
456
|
+
if (effectivePk() === pkStr)
|
|
457
|
+
url.pk = '-';
|
|
458
|
+
});
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
});
|
|
463
462
|
});
|
|
464
463
|
}
|
|
465
464
|
});
|
|
466
465
|
});
|
|
467
466
|
});
|
|
468
|
-
// 'more' button
|
|
469
467
|
if (r.rows.length >= url.limit) {
|
|
470
|
-
A(
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
});
|
|
468
|
+
A(() => S.button({
|
|
469
|
+
content: `+ more (currently limit ${url.limit})`,
|
|
470
|
+
attrs: '.outlined .small',
|
|
471
|
+
click: () => { const cur = url.limit; url.limit = Math.min(cur * 5, cur + 250); },
|
|
472
|
+
}));
|
|
476
473
|
}
|
|
477
474
|
});
|
|
478
475
|
});
|
|
@@ -509,57 +506,56 @@ function parseMaybe(s) {
|
|
|
509
506
|
catch { }
|
|
510
507
|
return s;
|
|
511
508
|
}
|
|
509
|
+
// ─── Debug view ───────────────────────────────────────────────────────────────
|
|
512
510
|
function debugView() {
|
|
513
|
-
const $mode = A.proxy({
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
A('div', 'overflow:auto', () => {
|
|
545
|
-
A('table', tableClass, () => {
|
|
546
|
-
A('thead tr', () => {
|
|
547
|
-
A('th##');
|
|
548
|
-
for (const k of cols)
|
|
549
|
-
A('th', 'text=', k);
|
|
550
|
-
});
|
|
551
|
-
A('tbody', () => {
|
|
552
|
-
for (const [idx, obj] of Object.entries(data)) {
|
|
553
|
-
A('tr', () => {
|
|
554
|
-
A('td', () => A('code', 'text=', idx));
|
|
511
|
+
const $mode = A.proxy({ value: 'channels' });
|
|
512
|
+
S.tabs({
|
|
513
|
+
bind: $mode,
|
|
514
|
+
tabs: ['channels', 'sockets', 'workers', 'kv'].map(t => ({
|
|
515
|
+
id: t,
|
|
516
|
+
label: t,
|
|
517
|
+
content: () => {
|
|
518
|
+
const debugInfo = authProxy.serverProxy.getDebugState(t);
|
|
519
|
+
A(() => {
|
|
520
|
+
if (debugInfo.busy) {
|
|
521
|
+
A('p', 'fg:$s-fg-muted', '#Loading…');
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (debugInfo.error) {
|
|
525
|
+
A('p', 'fg:$s-danger', 'text=', debugInfo.error.message);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const data = A.unproxy(debugInfo.value);
|
|
529
|
+
if (!data)
|
|
530
|
+
return;
|
|
531
|
+
const keySet = new Set();
|
|
532
|
+
for (const obj of Object.values(data)) {
|
|
533
|
+
if (obj && typeof obj === 'object' && !(obj instanceof Uint8Array))
|
|
534
|
+
for (const k of Object.keys(obj))
|
|
535
|
+
keySet.add(k);
|
|
536
|
+
}
|
|
537
|
+
const cols = [...keySet];
|
|
538
|
+
A('div', 'overflow:auto', () => {
|
|
539
|
+
A('table', tableClass, () => {
|
|
540
|
+
A('thead tr', () => {
|
|
541
|
+
A('th##');
|
|
555
542
|
for (const k of cols)
|
|
556
|
-
A('
|
|
543
|
+
A('th', 'text=', k);
|
|
557
544
|
});
|
|
558
|
-
|
|
545
|
+
A('tbody', () => {
|
|
546
|
+
for (const [idx, obj] of Object.entries(data)) {
|
|
547
|
+
A('tr', () => {
|
|
548
|
+
A('td', () => A('code', 'text=', idx));
|
|
549
|
+
for (const k of cols)
|
|
550
|
+
A('td', 'font-size:12px font-family:monospace', 'text=', debugCellText(obj?.[k]));
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
});
|
|
559
555
|
});
|
|
560
556
|
});
|
|
561
|
-
}
|
|
562
|
-
})
|
|
557
|
+
},
|
|
558
|
+
})),
|
|
563
559
|
});
|
|
564
560
|
}
|
|
565
561
|
function debugCellText(value) {
|
|
@@ -570,7 +566,6 @@ function debugCellText(value) {
|
|
|
570
566
|
if (typeof value === 'object') {
|
|
571
567
|
if (value.type === 'Buffer' && Array.isArray(value.data))
|
|
572
568
|
return escapeBytes(value.data);
|
|
573
|
-
// Buffer-like (proxied Uint8Array): sequential integer keys
|
|
574
569
|
const keys = Object.keys(value);
|
|
575
570
|
if (!Array.isArray(value) && keys.length > 0 && keys.length <= 4096 && keys.every((k, i) => k === String(i))) {
|
|
576
571
|
const arr = [];
|
|
@@ -606,16 +601,13 @@ function escapeBytes(bytes) {
|
|
|
606
601
|
}
|
|
607
602
|
return s;
|
|
608
603
|
}
|
|
609
|
-
|
|
604
|
+
// ─── Mount ────────────────────────────────────────────────────────────────────
|
|
610
605
|
A.mount(document.body, () => {
|
|
611
606
|
if (!$state.authed) {
|
|
612
607
|
login();
|
|
613
608
|
return;
|
|
614
609
|
}
|
|
615
|
-
|
|
616
|
-
sidebar();
|
|
617
|
-
mainArea();
|
|
618
|
-
});
|
|
610
|
+
dashboard();
|
|
619
611
|
});
|
|
620
612
|
if ($state.password) {
|
|
621
613
|
attemptLogin();
|