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.
Files changed (41) hide show
  1. package/README.md +43 -67
  2. package/build/client/client.d.ts +8 -1
  3. package/build/client/client.js +39 -22
  4. package/build/client/client.js.map +1 -1
  5. package/build/dashboard/client/crud.d.ts +16 -0
  6. package/build/dashboard/client/crud.js +525 -0
  7. package/build/dashboard/client/crud.js.map +1 -0
  8. package/build/dashboard/client/main.js +238 -246
  9. package/build/dashboard/client/main.js.map +1 -1
  10. package/build/dashboard/dashboard.html +8 -8
  11. package/build/dashboard/server.d.ts +20 -9
  12. package/build/dashboard/server.d.ts.map +1 -1
  13. package/build/dashboard/server.js +139 -3
  14. package/build/dashboard/server.js.map +1 -1
  15. package/build/examples/helloworld/.edinburgh/commit_worker.log +1809 -0
  16. package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
  17. package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
  18. package/build/examples/helloworld/client/assets/style.css +0 -45
  19. package/build/examples/helloworld/client/index.html +2 -13
  20. package/build/examples/helloworld/client/js/base.d.ts +1 -4
  21. package/build/examples/helloworld/client/js/base.js +8 -217
  22. package/build/examples/helloworld/client/js/base.js.map +1 -1
  23. package/build/examples/helloworld/server/api.d.ts +4 -0
  24. package/build/examples/helloworld/server/api.d.ts.map +1 -1
  25. package/build/examples/helloworld/server/api.js +10 -0
  26. package/build/examples/helloworld/server/api.js.map +1 -1
  27. package/build/server/server.d.ts +3 -3
  28. package/build/server/server.d.ts.map +1 -1
  29. package/build/server/server.js +44 -5
  30. package/build/server/server.js.map +1 -1
  31. package/build/tsconfig.client.tsbuildinfo +1 -1
  32. package/build/tsconfig.server.tsbuildinfo +1 -1
  33. package/client/client.ts +41 -23
  34. package/dashboard/build-bundle.ts +8 -2
  35. package/dashboard/client/crud.ts +634 -0
  36. package/dashboard/client/main.ts +234 -246
  37. package/dashboard/server.ts +149 -5
  38. package/package.json +9 -5
  39. package/server/server.ts +43 -8
  40. package/skill/SKILL.md +30 -47
  41. package/skill/Connection_pruneCommitIds.md +0 -8
@@ -1,6 +1,8 @@
1
1
  import A from 'aberdeen';
2
- import { current as route, go } from 'aberdeen/route';
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 helpers (reactive via A.route.current.search)
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
- let tableClass = '';
60
- function styles() {
61
- A.setSpacingCssVars();
62
- A.cssVars.bg = '#0f1117';
63
- A.cssVars.panel = '#181b24';
64
- A.cssVars.panel2 = '#1f2330';
65
- A.cssVars.fg = '#e5e7eb';
66
- A.cssVars.muted = '#94a3b8';
67
- A.cssVars.accent = '#60a5fa';
68
- A.cssVars.accent2 = '#34d399';
69
- A.cssVars.danger = '#f87171';
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
- A(() => {
103
- if ($state.connecting) {
104
- A('div', 'display:flex align-items:center justify-content:center h:100vh fg:$muted', '#Connecting…');
105
- return;
106
- }
107
- A('div', 'display:flex align-items:center justify-content:center h:100vh', () => {
108
- A('form', 'bg:$panel p:$4 r:8px w:360px border: 1px solid $border; display:flex flex-direction:column gap:$3', 'submit=', (e) => { e.preventDefault(); attemptLogin(); }, () => {
109
- A('h2#Lowlander Dashboard', 'm:0');
110
- A('label', () => {
111
- A('span#WebSocket URL', 'fg:$muted display:block mb:$1');
112
- A('input type=text bind=', A.ref($state, 'wsUrl'));
113
- });
114
- A('label', () => {
115
- A('span#Password', 'fg:$muted display:block mb:$1');
116
- A('input type=password bind=', A.ref($state, 'password'), 'autofocus=');
117
- });
118
- A(() => {
119
- if ($state.loginError)
120
- A('div', 'fg:$danger', () => A('text=', $state.loginError));
121
- });
122
- A('button type=submit', () => {
123
- A(() => A($state.connected && !$state.authed ? 'text=Checking…' : 'text="Log in"'));
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
- function sidebar() {
174
- A('aside', 'w:280px bg:$panel border-right: 1px solid $border; display:flex flex-direction:column overflow:hidden', () => {
175
- A('div', 'p:$3 border-bottom: 1px solid $border; display:flex justify-content:space-between align-items:center', () => {
176
- A('strong#Lowlander');
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('div', 'fg:$muted', '#Loading…');
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('div', 'fg:$danger', 'text=', models.error.message);
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 list = models.value || [];
206
- for (const m of list) {
207
- A('div', 'p: 6px 8px; r:4px cursor:pointer', 'click=', () => { go({ path: route.path, search: { model: m.tableName } }); }, () => {
208
- A(() => A('.sidebarSel=', !url.debug && url.model === m.tableName));
209
- A('div', 'display:flex justify-content:space-between align-items:baseline', () => {
210
- A('span', 'text=', m.tableName);
211
- A('span.tag', 'text=', `${m.fieldCount}f ${m.indexCount}i ${m.streamTypeCount}s`);
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
- function mainArea() {
218
- A('main', 'flex:1 overflow:auto p:$4', () => {
219
- if (!authProxy)
220
- return;
221
- if (url.debug)
222
- debugView();
223
- else if (url.model)
224
- modelDetail();
225
- else
226
- A('div', 'fg:$muted p:$4 text-align:center', '#Select a model from the sidebar');
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('div', 'fg:$muted', '#Loading…');
203
+ A('p', 'fg:$s-fg-muted', '#Loading…');
235
204
  return;
236
205
  }
237
206
  if (info.error) {
238
- A('div', 'fg:$danger', 'text=', info.error.message);
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
- A('h2', 'm:0 mb:$2', 'text=', m.tableName);
245
- A('section', 'mb:$4', () => {
246
- A('h3#Fields', 'm:0 mb:$2 fg:$muted font-weight:600');
247
- A('table', tableClass, () => {
248
- A('thead tr', () => { A('th#Name'); A('th#Type'); A('th#Linked'); A('th#Default'); A('th#Description'); });
249
- A('tbody', () => {
250
- for (const f of m.fields) {
251
- A('tr', () => {
252
- A('td', () => A('code', 'text=', f.name));
253
- A('td', 'fg:$accent2', 'text=', f.type.display);
254
- A('td', () => { const lm = f.type.linkedModel; if (lm)
255
- modelLink(lm); });
256
- A('td', 'text=', f.hasDefault ? '✓' : '');
257
- A('td', 'fg:$muted', 'text=', f.description || '');
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
- A('section', 'mb:$4', () => {
264
- A('h3#Indexes', 'm:0 mb:$2 fg:$muted font-weight:600');
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
- A('section', 'mb:$4', () => {
273
- A('h3', 'm:0 mb:$2 fg:$muted font-weight:600', 'text=', `Data for ${cur}`);
274
- indexBrowser(m, idx);
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
- A('section', 'mb:$4', () => {
279
- A('h3#Stream types', 'm:0 mb:$2 fg:$muted font-weight:600');
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:$accent2', 'text=', idx.info.kind);
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 === true) {
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 #${k}→`, () => {
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 #${k}={...}`);
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
- A('div', 'display:flex flex-direction:column', () => {
412
- A('div', 'display:flex flex-wrap:wrap gap:$2 align-items:center mb:$2', () => {
413
- A('input type=text flex:1 min-w:200px placeholder=search', 'value=', A.peek(() => url.search), 'input=', (e) => { url.search = e.target.value; url.limit = 10; });
414
- A('label', 'display:flex align-items:center gap:$1 white-space:nowrap', () => {
415
- A('input type=checkbox', 'checked=', url.reverse, 'change=', (e) => url.reverse = e.target.checked);
416
- A('span', 'fg:$muted', '#reverse');
417
- });
418
- A('button.ghost', 'p: 2px 8px; font-size:11px', '#↺ refresh', 'click=', () => $state.indexRefreshKey++);
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('div', 'fg:$muted', '#Loading…');
407
+ A('p', 'fg:$s-fg-muted', '#Loading…');
431
408
  return;
432
409
  }
433
410
  if (rows.error) {
434
- A('div', 'fg:$danger', 'text=', rows.error.message);
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('div', 'fg:$muted mb:$2 font-size:12px', 'text=', `${r.rows.length} rows (scanned ${r.scanned})`);
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('div', 'fg:$muted', '#(empty)');
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', () => { for (const c of cols)
454
- A('th', 'text=', c); });
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('div', 'mt:$2', () => {
471
- A('button.ghost', 'p: 2px 8px; font-size:11px', 'click=', () => {
472
- const cur = url.limit;
473
- url.limit = Math.min(cur * 5, cur + 250);
474
- }, () => A('span', 'text=', `+ more (currently limit ${url.limit})`));
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({ v: 'channels' });
514
- A('h2#WarpSocket debug', 'm:0 mb:$3');
515
- A('div', '.row mb:$3', () => {
516
- for (const t of ['channels', 'sockets', 'workers', 'kv']) {
517
- A('button click=', () => $mode.v = t, () => {
518
- A(() => A($mode.v === t ? 'bg:$accent fg:#0b1220' : 'bg:$panel2 fg:$fg border: 1px solid $border;'));
519
- A('text=', t);
520
- });
521
- }
522
- });
523
- A(() => {
524
- const debugInfo = authProxy.serverProxy.getDebugState($mode.v);
525
- A(() => {
526
- if (debugInfo.busy) {
527
- A('div', 'fg:$muted', '#Loading…');
528
- return;
529
- }
530
- if (debugInfo.error) {
531
- A('div', 'fg:$danger', 'text=', debugInfo.error.message);
532
- return;
533
- }
534
- const data = A.unproxy(debugInfo.value);
535
- if (!data)
536
- return;
537
- const keySet = new Set();
538
- for (const obj of Object.values(data)) {
539
- if (obj && typeof obj === 'object' && !(obj instanceof Uint8Array))
540
- for (const k of Object.keys(obj))
541
- keySet.add(k);
542
- }
543
- const cols = [...keySet];
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('td', 'font-size:12px font-family:monospace', 'text=', debugCellText(obj?.[k]));
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
- styles();
604
+ // ─── Mount ────────────────────────────────────────────────────────────────────
610
605
  A.mount(document.body, () => {
611
606
  if (!$state.authed) {
612
607
  login();
613
608
  return;
614
609
  }
615
- A('div', 'display:flex flex-direction:row h:100vh w:100vw overflow:hidden', () => {
616
- sidebar();
617
- mainArea();
618
- });
610
+ dashboard();
619
611
  });
620
612
  if ($state.password) {
621
613
  attemptLogin();