clavarium 0.1.0-next.4 → 0.1.0-next.6

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/deploy/install.sh CHANGED
@@ -661,6 +661,12 @@ if [ -n "$RELAY" ]; then
661
661
  echo
662
662
  echo "ON THE RELAY ($RELAY), run this one command; the tunnel then comes up by itself:"
663
663
  echo " sudo deploy/relay.sh --key \"$TUNNEL_KEY\""
664
+ # A server with a screen and a keyboard only: the key, carried by a phone.
665
+ # It is a PUBLIC key: showing it is safe; it lets the relay know this machine.
666
+ if command -v qrencode >/dev/null 2>&1 || apt-get install -y -qq qrencode >/dev/null 2>&1; then
667
+ echo "The same key, to read with a phone or a tablet:"
668
+ qrencode -t ANSIUTF8 -m 2 -- "$TUNNEL_KEY" || true
669
+ fi
664
670
  echo "It will print this, and it must be the same: $RELAY_PRINT"
665
671
  echo "The name $DOMAIN must lead to the relay."
666
672
  else
@@ -366,7 +366,9 @@ export class Updater {
366
366
  async apply(version) {
367
367
  if (!VERSION.test(version ?? '')) throw new Refused(`not a version: ${version}`);
368
368
  const from = this.current();
369
- if (from && compareVersions(version, from.version) <= 0) throw new Refused(`${version} is not newer than ${from.version}`);
369
+ /* Asked for the version that runs (a request that waited while it came by itself): done already. */
370
+ if (from && compareVersions(version, from.version) === 0) return;
371
+ if (from && compareVersions(version, from.version) < 0) throw new Refused(`${version} is not newer than ${from.version}`);
370
372
  const why = this.busy(); if (why) throw new Refused(why);
371
373
  const unlock = this.#lock();
372
374
  try {
@@ -480,7 +482,9 @@ export class Updater {
480
482
  if ((state.history ?? []).some((h) => h.to === available && h.action === 'update' && h.result !== 'done')) return `${available} failed here before: not tried again by itself`;
481
483
  if (this.busy()) return this.busy();
482
484
  await this.apply(available);
483
- return `updated to ${available}`;
485
+ /* What the history says, not what was hoped: a version that did not start was undone. */
486
+ const last = (this.#state().history ?? []).at(-1);
487
+ return last?.to === available && last.result !== 'done' ? `${available} did not work: back to ${last.from ?? 'nothing'} (${last.why ?? last.result})` : `updated to ${available}`;
484
488
  }
485
489
 
486
490
  /* What the host program asked (a request file), or the timer's turn. */
@@ -513,7 +517,7 @@ export class Updater {
513
517
  let cwd = ''; try { cwd = realpathSync(`/proc/${pid}/cwd`); } catch { /* gone */ }
514
518
  if (cwd.startsWith(target)) break;
515
519
  said = 'the server that answers is not this release';
516
- } else if (active.code !== 0) said = 'the service is not running';
520
+ } else if (active.code !== 0) said = 'its service stopped';
517
521
  if (Date.now() > end) return said;
518
522
  await new Promise((r) => setTimeout(r, 1000));
519
523
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.1.0-next.4",
3
+ "version": "0.1.0-next.6",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.1.0-next.4",
9
+ "version": "0.1.0-next.6",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.1.0-next.4",
3
+ "version": "0.1.0-next.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
package/server/api.mjs CHANGED
@@ -268,7 +268,7 @@ export async function handleApi(request, response, conversations, headers, resou
268
268
  if (pathname === '/api/updates/settings' && updates) return send(200, await updates.settings({ mode: body.mode, channel: body.channel, window: body.window })), true;
269
269
  if (pathname === '/api/updates/apply' && updates) return send(202, await updates.apply(body.version)), true;
270
270
  if (pathname === '/api/updates/back' && updates) return send(202, await updates.back(body.version)), true;
271
- if (pathname === '/api/server/reserve' && capacity) { capacity.setReserve({ diskMb: body.diskMb, memoryMb: body.memoryMb }); return send(200, await capacity.about()), true; }
271
+ if (pathname === '/api/server/reserve' && capacity) { capacity.setReserve({ diskMb: body.diskMb, diskPercent: body.diskPercent, memoryMb: body.memoryMb, memoryPercent: body.memoryPercent }); return send(200, await capacity.about()), true; }
272
272
  if (pathname === '/api/git-hosts/forget' && gitAccess) return send(200, gitAccess.forgetHost(body.host)), true;
273
273
  const unkeyed = /^\/api\/git-keys\/([a-z0-9-]{1,32})\/remove$/.exec(pathname);
274
274
  if (unkeyed && gitAccess) { gitAccess.removeKey(unkeyed[1]); return send(200, { ok: true }), true; }
@@ -14,22 +14,35 @@
14
14
  * disks, the machines' disks, the images). Slow (it measures
15
15
  * folders): asked again after USAGE_MS at most, in the background.
16
16
  *
17
- * THE RESERVE (Settings, This server): creating, starting, waking or growing
18
- * a machine is refused, with a sentence that says why and what to do, when
19
- * it would leave less than the reserve. Memory: the machine's size (or what
20
- * it grows by) against the memory free -- less what the running machines
21
- * were given and have not taken yet, since they will. Disk: what a new
22
- * machine takes, or what a disk grows by, against the disk free where the
23
- * machines' disks are. Starting or waking is never refused for disk: a full
24
- * disk is cleaned from inside the conversations.
17
+ * THE RESERVE (Settings, This server, Resources): for each of disk and
18
+ * memory, the larger of a fixed amount and a share of the whole (by default
19
+ * disk 5 GB or 5 %, memory 1 GB or 5 %). It is judged on what the system
20
+ * says is free (df on the machines' disk, MemAvailable), never on sums of
21
+ * what machines were given:
22
+ * - creating, starting, waking or growing a machine is refused, with a
23
+ * sentence that says why and what to do, when what is free less what the
24
+ * machine needs at first would be under the reserve. Memory: its size
25
+ * (or what it grows by). Disk: what a new machine takes, or what a disk
26
+ * grows by. Starting or waking is never refused for disk: a full disk is
27
+ * cleaned from inside the conversations.
28
+ * - all the time (watch): once what is free falls under the reserve, the
29
+ * owner is alerted and the running machine that takes the most is put to
30
+ * sleep, said in its conversation. Once: again only after what is free
31
+ * came back above the reserve.
32
+ * What was PROMISED (the disks' largest sizes, the memory given to running
33
+ * machines) is only said, with a warning when it is more than there is.
25
34
  *
26
35
  * When the figures cannot be read (a host program from before these verbs),
27
36
  * nothing is refused: the reserve is a guard, not a gate that jams.
28
37
  */
29
- import { Refusal } from './conversations.mjs';
38
+ import { Refusal, DISK_GB } from './conversations.mjs';
30
39
 
31
- export const RESERVE_DISK_MB = 3 * 1024;
40
+ export const RESERVE_DISK_MB = 5 * 1024;
32
41
  export const RESERVE_MEMORY_MB = 1024;
42
+ export const RESERVE_DISK_PERCENT = 5;
43
+ export const RESERVE_MEMORY_PERCENT = 5;
44
+ /* How often what is free is looked at, against the reserve. */
45
+ export const WATCH_MS = 60_000;
33
46
  /* What may be set, in megabytes. */
34
47
  export const RESERVE_DISK_MAX_MB = 1024 * 1024;
35
48
  export const RESERVE_MEMORY_MAX_MB = 256 * 1024;
@@ -74,32 +87,39 @@ export const gb = (mb) => `${(mb / 1024).toFixed(1)} GB`;
74
87
  const percent = (part, all) => (all > 0 ? Math.round((part / all) * 100) : 0);
75
88
 
76
89
  export class Capacity {
77
- #run; #store; #conversations; #shelf; #now; #roomMs; #room = null; #roomAt = -Infinity; #usage = null; #usageAt = -Infinity; #going = null; #measuring = null;
90
+ #run; #store; #conversations; #shelf; #now; #roomMs; #sleep; #alert; #say; #short = { disk: false, memory: false }; #room = null; #roomAt = -Infinity; #usage = null; #usageAt = -Infinity; #going = null; #measuring = null;
78
91
 
79
92
  /* `run(argv)`: the host program. `store`: where the reserve is kept.
80
93
  `conversations`: what each machine was given. `shelf`: how large a
81
94
  ready-made machine is (a new one is a copy of it). */
82
95
  /* `roomMs`: how long what is free is taken as known (ROOM_MS). */
83
- constructor({ run, store, conversations, shelf = null, now = () => Date.now(), roomMs = ROOM_MS }) {
84
- this.#roomMs = roomMs;
96
+ /* `sleep(name)`, `alert(payload)`, `say(name, text)`: what watch() does
97
+ when what is free falls under the reserve. */
98
+ constructor({ run, store, conversations, shelf = null, now = () => Date.now(), roomMs = ROOM_MS, sleep = null, alert = null, say = null }) {
99
+ this.#roomMs = roomMs; this.#sleep = sleep; this.#alert = alert; this.#say = say;
85
100
  this.#run = run; this.#store = store; this.#conversations = conversations; this.#shelf = shelf; this.#now = now;
86
101
  }
87
102
 
103
+ /* The two settings of each: a fixed amount and a share of the whole. */
88
104
  reserve() {
89
105
  const r = this.#store.get('reserve') ?? {};
90
- return { diskMb: r.diskMb ?? RESERVE_DISK_MB, memoryMb: r.memoryMb ?? RESERVE_MEMORY_MB };
106
+ return { diskMb: r.diskMb ?? RESERVE_DISK_MB, diskPercent: r.diskPercent ?? RESERVE_DISK_PERCENT, memoryMb: r.memoryMb ?? RESERVE_MEMORY_MB, memoryPercent: r.memoryPercent ?? RESERVE_MEMORY_PERCENT };
91
107
  }
92
- setReserve({ diskMb, memoryMb } = {}) {
108
+ setReserve({ diskMb, diskPercent, memoryMb, memoryPercent } = {}) {
93
109
  const now = this.reserve();
94
110
  const take = (value, max, what) => {
95
111
  if (value === undefined || value === null || value === '') return undefined;
96
112
  const n = Number(value);
97
- if (!Number.isInteger(n) || n < 0 || n > max) throw new Refusal(400, `The ${what} reserve is a whole number of megabytes, from 0 to ${max}.`);
113
+ if (!Number.isInteger(n) || n < 0 || n > max) throw new Refusal(400, max === 100 ? `The ${what} reserve share is a whole number of percent, from 0 to 100.` : `The ${what} reserve is a whole number of megabytes, from 0 to ${max}.`);
98
114
  return n;
99
115
  };
100
- const disk = take(diskMb, RESERVE_DISK_MAX_MB, 'disk') ?? now.diskMb;
101
- const memory = take(memoryMb, RESERVE_MEMORY_MAX_MB, 'memory') ?? now.memoryMb;
102
- this.#store.put({ name: 'reserve', diskMb: disk, memoryMb: memory });
116
+ const kept = {
117
+ diskMb: take(diskMb, RESERVE_DISK_MAX_MB, 'disk') ?? now.diskMb,
118
+ diskPercent: take(diskPercent, 100, 'disk') ?? now.diskPercent,
119
+ memoryMb: take(memoryMb, RESERVE_MEMORY_MAX_MB, 'memory') ?? now.memoryMb,
120
+ memoryPercent: take(memoryPercent, 100, 'memory') ?? now.memoryPercent,
121
+ };
122
+ this.#store.put({ name: 'reserve', ...kept });
103
123
  return this.reserve();
104
124
  }
105
125
 
@@ -131,21 +151,29 @@ export class Capacity {
131
151
  disk but the first one, and that one not long. */
132
152
  #figures(room) {
133
153
  if (!room) return null;
134
- const reserve = this.reserve();
135
- const records = new Map(this.#conversations.list().map((c) => [`clv-${c.name}`, c]));
154
+ const set = this.reserve();
155
+ const all = this.#conversations.list();
156
+ const records = new Map(all.map((c) => [`clv-${c.name}`, c]));
136
157
  const machines = room.machines.map((m) => {
137
158
  const c = records.get(m.vm);
138
159
  return { name: c ? c.name : null, shelf: m.vm.startsWith('clvshelf-'), rssMb: m.rssMb, sizeMb: c?.memoryMb ?? null };
139
160
  });
140
- /* Given to running machines and not taken yet: it will be. */
141
- const promisedMb = machines.reduce((sum, m) => sum + (m.sizeMb ? Math.max(0, m.sizeMb - m.rssMb) : 0), 0);
142
- const memory = room.memory ? {
143
- totalMb: room.memory.totalMb, freeMb: room.memory.availableMb, promisedMb,
144
- spareMb: Math.max(0, room.memory.availableMb - promisedMb),
145
- /* Counted as taken: what running machines were given and will take. */
146
- usedPercent: percent(room.memory.totalMb - Math.max(0, room.memory.availableMb - promisedMb), room.memory.totalMb),
147
- } : null;
161
+ /* What the system says is available: the machines' own use is in it already. */
162
+ const memory = room.memory ? { totalMb: room.memory.totalMb, freeMb: room.memory.availableMb, usedPercent: percent(room.memory.totalMb - room.memory.availableMb, room.memory.totalMb) } : null;
148
163
  const where = room.places.find((p) => p.what === 'machines') ?? room.places[0];
164
+ /* The reserve kept, in megabytes: the larger of the fixed amount and the share of the whole. */
165
+ const share = (totalMb, pct) => Math.round((totalMb * pct) / 100);
166
+ const reserve = {
167
+ diskMb: Math.max(set.diskMb, where ? share(where.sizeMb, set.diskPercent) : 0),
168
+ memoryMb: Math.max(set.memoryMb, memory ? share(memory.totalMb, set.memoryPercent) : 0),
169
+ };
170
+ /* Promised, only said: every disk at its largest, the memory given to running machines. */
171
+ const diskPromisedMb = all.reduce((sum, c) => sum + (c.diskGb ?? DISK_GB) * 1024, 0);
172
+ const memoryPromisedMb = machines.reduce((sum, m) => sum + (m.sizeMb ?? 0), 0);
173
+ const promised = {
174
+ disk: where ? { mb: diskPromisedMb, totalMb: where.sizeMb, over: diskPromisedMb > where.sizeMb } : null,
175
+ memory: memory ? { mb: memoryPromisedMb, totalMb: memory.totalMb, over: memoryPromisedMb > memory.totalMb } : null,
176
+ };
149
177
  /* The same disk named once, with all it holds. */
150
178
  const disks = [];
151
179
  for (const p of room.places) {
@@ -155,9 +183,9 @@ export class Capacity {
155
183
  const disk = where ? { sizeMb: where.sizeMb, freeMb: where.freeMb, usedPercent: percent(where.sizeMb - where.freeMb, where.sizeMb), disks: disks.map(({ device, ...d }) => d) } : null;
156
184
  const high = {
157
185
  disk: Boolean(disk) && disks.some((d) => d.usedPercent > HIGH_PERCENT || d.freeMb < HIGH_FACTOR * reserve.diskMb),
158
- memory: Boolean(memory) && (memory.usedPercent > HIGH_PERCENT || memory.spareMb < HIGH_FACTOR * reserve.memoryMb),
186
+ memory: Boolean(memory) && (memory.usedPercent > HIGH_PERCENT || memory.freeMb < HIGH_FACTOR * reserve.memoryMb),
159
187
  };
160
- return { reserve, disk, memory, machines, high };
188
+ return { reserve, settings: set, disk, memory, machines, promised, high };
161
189
  }
162
190
 
163
191
  /* For Settings, This server: everything known. */
@@ -175,7 +203,7 @@ export class Capacity {
175
203
  machines: { disksMb: Object.values(u.machines).reduce((a, b) => a + b, 0), each: Object.entries(u.machines).map(([name, mb]) => ({ name, mb })).sort((a, b) => b.mb - a.mb), shelfMb: u.shelf ?? null, imagesMb: u.images ?? null, othersMb: u.others ?? null },
176
204
  at: new Date(this.#usageAt).toISOString(),
177
205
  } : null;
178
- return { known: Boolean(figures), reserve: this.reserve(), ...(figures ?? {}), usage, measuring: Boolean(this.#measuring) };
206
+ return { known: Boolean(figures), reserve: { diskMb: this.reserve().diskMb, memoryMb: this.reserve().memoryMb }, settings: this.reserve(), ...(figures ?? {}), usage, measuring: Boolean(this.#measuring) };
179
207
  }
180
208
 
181
209
  /* For the list: only whether it is high, and how much. Cheap: what is
@@ -191,7 +219,7 @@ export class Capacity {
191
219
  return {
192
220
  known: true, high: f.high.disk || f.high.memory,
193
221
  disk: disk ? { usedPercent: disk.usedPercent, freeMb: disk.freeMb, aboveMb: disk.freeMb - r.diskMb, high: f.high.disk, level: level(f.high.disk, disk.usedPercent, disk.freeMb - r.diskMb, r.diskMb) } : null,
194
- memory: f.memory ? { usedPercent: f.memory.usedPercent, freeMb: f.memory.spareMb, aboveMb: f.memory.spareMb - r.memoryMb, high: f.high.memory, level: level(f.high.memory, f.memory.usedPercent, f.memory.spareMb - r.memoryMb, r.memoryMb) } : null,
222
+ memory: f.memory ? { usedPercent: f.memory.usedPercent, freeMb: f.memory.freeMb, aboveMb: f.memory.freeMb - r.memoryMb, high: f.high.memory, level: level(f.high.memory, f.memory.usedPercent, f.memory.freeMb - r.memoryMb, r.memoryMb) } : null,
195
223
  };
196
224
  }
197
225
 
@@ -206,10 +234,10 @@ export class Capacity {
206
234
  const r = f.reserve;
207
235
  const verb = kind === 'create' ? 'Not created' : kind === 'start' ? 'Not started' : 'Not changed';
208
236
  const who = name ? `${name}` : 'a new machine';
209
- if (f.memory && memoryMb > 0 && f.memory.spareMb - memoryMb < r.memoryMb) {
237
+ if (f.memory && memoryMb > 0 && f.memory.freeMb - memoryMb < r.memoryMb) {
210
238
  const asks = kind === 'grow' ? `${gb(memoryMb)} more memory for ${who}` : `${gb(memoryMb)} of memory for ${who}`;
211
239
  const what = kind === 'create' ? 'Put a conversation you are not using to sleep, or choose less memory' : kind === 'grow' ? 'Put a conversation you are not using to sleep, or give it less' : 'Put a conversation you are not using to sleep';
212
- throw new Refusal(409, `${verb}: this server cannot spare ${asks}. It has ${gb(f.memory.spareMb)} free for machines and keeps ${gb(r.memoryMb)} in reserve. ${what}, or lower the reserve in Settings, This server.`);
240
+ throw new Refusal(409, `${verb}: this server cannot spare ${asks}. It has ${gb(f.memory.freeMb)} of memory free and keeps ${gb(r.memoryMb)} in reserve. ${what}, or lower the reserve in Settings, This server.`);
213
241
  }
214
242
  const takes = kind === 'create' ? (this.#shelf?.about?.().current?.sizeMb ?? NEW_MACHINE_MB) : diskMb;
215
243
  if (f.disk && kind !== 'start' && takes > 0 && f.disk.freeMb - takes < r.diskMb) {
@@ -217,4 +245,40 @@ export class Capacity {
217
245
  throw new Refusal(409, `${verb}: this server cannot spare ${asks}. It has ${gb(f.disk.freeMb)} of disk free and keeps ${gb(r.diskMb)} in reserve. Archive conversations you no longer need, or lower the reserve in Settings, This server.`);
218
246
  }
219
247
  }
248
+
249
+ /* WATCHED: what is free against the reserve, all the time. Under it, once
250
+ (again only after it came back above): the owner alerted, and the
251
+ running machine that takes the most put to sleep and told so in its
252
+ conversation. Disk: the one whose disk is largest (by the last
253
+ measure; memory held when none is known). Resolves with what was done. */
254
+ async watch() {
255
+ const f = this.#figures(await this.room({ fresh: true }).catch(() => null));
256
+ if (!f) return [];
257
+ const done = [];
258
+ const awake = new Set(this.#conversations.awake?.() ?? []);
259
+ for (const what of ['disk', 'memory']) {
260
+ const free = f[what]?.freeMb;
261
+ const short = free !== undefined && free < f.reserve[`${what}Mb`];
262
+ const was = this.#short[what];
263
+ this.#short[what] = short;
264
+ if (!short || was) continue;
265
+ const running = f.machines.filter((m) => m.name && awake.has(m.name));
266
+ const takes = (m) => (what === 'disk' ? this.#usage?.machines?.[m.name] ?? -1 : m.rssMb);
267
+ const most = running.sort((a, b) => takes(b) - takes(a) || b.rssMb - a.rssMb)[0] ?? null;
268
+ let slept = null;
269
+ if (most && this.#sleep) slept = await Promise.resolve(this.#sleep(most.name)).then(() => most.name, () => null);
270
+ const figures = `${gb(free)} free, ${gb(f.reserve[`${what}Mb`])} kept in reserve`;
271
+ this.#alert?.({ title: `This server is short of ${what}`, body: `${figures}.${slept ? ` ${slept} was put to sleep.` : ''}`, url: '/?open=settings&at=resources' });
272
+ if (slept) this.#say?.(slept, `This server ran short of ${what} (${figures}). This machine took the most, so it was put to sleep. Wake it once there is room again.`);
273
+ done.push({ what, slept });
274
+ }
275
+ return done;
276
+ }
277
+
278
+ /* Watches every `everyMs`; returns what stops it. */
279
+ start(everyMs = WATCH_MS) {
280
+ const timer = setInterval(() => { void this.watch().catch(() => {}); }, everyMs);
281
+ timer.unref?.();
282
+ return () => clearInterval(timer);
283
+ }
220
284
  }
package/server/main.mjs CHANGED
@@ -338,10 +338,14 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
338
338
  const stopUpdates = updates.watch();
339
339
  const setup = new Setup({ store: new Store(`${dataDir}/flows.json`), version: platform.version, conversations: all, resources: folders, secrets, devices, shortcuts, wakeups, answerer, talk, gitKeeper, gitAccess, mailboxes, signin, network, guide, ...(setupPollMs ? { pollMs: setupPollMs } : {}) });
340
340
  /* This server's disk and memory, and the reserve no machine may eat into: see capacity.mjs. */
341
- const capacity = new Capacity({ run: asHost, store: new Store(`${dataDir}/server.json`), conversations: all, shelf, ...(serverRoomMs === undefined ? {} : { roomMs: serverRoomMs }) });
341
+ const capacity = new Capacity({ run: asHost, store: new Store(`${dataDir}/server.json`), conversations: all, shelf, ...(serverRoomMs === undefined ? {} : { roomMs: serverRoomMs }),
342
+ /* Under the reserve: the machine that takes the most put to sleep, the owner told, and its chat. */
343
+ sleep: (name) => all.sleep(name), alert: (payload) => { void alerts.tell(payload); },
344
+ say: (name, text) => { try { talk.event(name, { kind: 'machine-problem', text, tone: 'warn', topic: 'Machine' }); } catch { /* its chat may be off */ } } });
342
345
  all.room = (ask) => capacity.check(ask);
346
+ const stopWatch = capacity.start();
343
347
  const { server, listening } = serve(port, all, folders, { gate, originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, capacity, updates, repair: (name) => repairTerminal(name) });
344
- server.on('close', () => { closing = true; stopShelf(); stopHealth(); stopUpdates(); clearInterval(mailTimer); clearInterval(answerTimer); clearInterval(wakeTimer); clearInterval(gitTimer); talk.stop(); });
348
+ server.on('close', () => { closing = true; stopShelf(); stopHealth(); stopUpdates(); stopWatch(); clearInterval(mailTimer); clearInterval(answerTimer); clearInterval(wakeTimer); clearInterval(gitTimer); talk.stop(); });
345
349
  let closing = false;
346
350
  bridge = attachBridge(server, {
347
351
  onRejoin: (name) => all.rejoin(name),
@@ -397,8 +401,6 @@ function hostSetting(key) {
397
401
  }
398
402
 
399
403
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
400
- /* DELIBERATELY BROKEN, this version only: it proves an update that does not start is undone by itself. */
401
- console.error('0.1.0-next.4 refuses to start, on purpose: the updater must go back by itself.'); process.exit(70);
402
404
  const argv = process.argv.slice(2);
403
405
  const port = Number(option(argv, 'port', '7717'));
404
406
  const host = option(argv, 'host', '');
@@ -107,7 +107,7 @@ export class Updates {
107
107
  if (this.#store.get('told')?.key === key) return;
108
108
  this.#store.put({ name: 'told', key });
109
109
  const title = last.result === 'rolled-back' ? `The update to ${last.to} did not work: back on ${last.from}` : `${last.action === 'back' ? 'Going back to' : 'The update to'} ${last.to} failed`;
110
- void this.#alerts?.tell({ title, body: String(last.why ?? '').slice(0, 180), url: '/?open=settings' })?.catch?.(() => {});
110
+ void this.#alerts?.tell({ title, body: String(last.why ?? '').slice(0, 180), url: '/?open=settings&at=updates' })?.catch?.(() => {});
111
111
  };
112
112
  look();
113
113
  this.#timer = setInterval(look, everyMs); this.#timer.unref?.();
package/web/app.js CHANGED
@@ -1193,9 +1193,9 @@
1193
1193
  for (const b of $('network').children) b.setAttribute('aria-pressed', String((b.dataset.online === 'true') !== offline));
1194
1194
  await paintSignIn();
1195
1195
  }
1196
- /* ---------- the chapters of "This conversation": folded or not, kept on this device ---------- */
1197
- /* What is used every day is open at first; the rest is one tap away. */
1198
- const OPEN_AT_FIRST = { general: true, machine: true, access: true };
1196
+ /* ---------- chapters (a conversation's settings, and Settings): folded or not, kept on this device ---------- */
1197
+ /* All folded at first, as a project's settings: what the person opens is kept. */
1198
+ const OPEN_AT_FIRST = {};
1199
1199
  let chapters = {};
1200
1200
  try { chapters = JSON.parse(localStorage.getItem('clavarium-chapters') ?? '{}') ?? {}; } catch { /* a private window */ }
1201
1201
  for (const chapter of document.querySelectorAll('details.chapter')) {
@@ -1206,7 +1206,19 @@
1206
1206
  try { localStorage.setItem('clavarium-chapters', JSON.stringify(chapters)); } catch { /* as above */ }
1207
1207
  });
1208
1208
  }
1209
- const openChapter = (key) => { const chapter = document.querySelector(`details.chapter[data-chapter="${key}"]`); if (chapter) chapter.open = true; return chapter; };
1209
+ /* Reached by a link (or a button that leads to it): opened, and said for a moment. */
1210
+ let reachedTimer;
1211
+ const openChapter = (key, { show = true } = {}) => {
1212
+ const chapter = document.querySelector(`details.chapter[data-chapter="${key}"]`);
1213
+ if (!chapter) return chapter;
1214
+ chapter.open = true;
1215
+ if (show) {
1216
+ for (const other of document.querySelectorAll('details.chapter.reached')) other.classList.remove('reached');
1217
+ chapter.classList.add('reached');
1218
+ clearTimeout(reachedTimer); reachedTimer = setTimeout(() => chapter.classList.remove('reached'), 2000);
1219
+ }
1220
+ return chapter;
1221
+ };
1210
1222
 
1211
1223
  /* ---------- the machine: its health, its size, a restart ---------- */
1212
1224
  const sizeWords = (mb) => (mb >= 1024 ? `${Number.isInteger(mb / 1024) ? mb / 1024 : (mb / 1024).toFixed(1)} GB` : `${mb} MB`);
@@ -2371,7 +2383,13 @@
2371
2383
  const wanted = q.get('c') ?? '';
2372
2384
  if (home && wanted !== current && conversations.some((c) => c.name === wanted)) select(wanted);
2373
2385
  const open = q.get('open') ?? '';
2374
- if (open === 'settings') { await openSettings({ push: false }); return; }
2386
+ if (open === 'settings') {
2387
+ await openSettings({ push: false });
2388
+ /* A link to one chapter of Settings (?open=settings&at=resources): that one opened, and shown. */
2389
+ const at = q.get('at') ?? '';
2390
+ if (/^[a-z]{1,20}$/.test(at)) openChapter(`s-${at}`)?.scrollIntoView({ block: 'start' });
2391
+ return;
2392
+ }
2375
2393
  const former = FORMER[open === 'access' ? (q.get('tab') ?? 'map') : open];
2376
2394
  if (former) { await openAccessPage(former[0], { mode: former[1], of: q.get('for') ?? '', push: false }); return; }
2377
2395
  leavePlace({ fromHistory: true });
@@ -2436,11 +2454,13 @@
2436
2454
  };
2437
2455
  const when = (iso) => new Date(iso).toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
2438
2456
  function updatesError(text) { $('updates-error').textContent = text ?? ''; $('updates-error').hidden = !text; }
2457
+ let lastUpdates = null;
2439
2458
  async function paintUpdates() {
2440
2459
  clearTimeout(updatesTimer);
2441
2460
  const u = await fetch(`${API}/updates`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
2442
2461
  /* The server restarts during an update: asked again until it answers. */
2443
2462
  if (!u) { if (updatesBusy && !$('settings').hidden) updatesTimer = setTimeout(() => { void paintUpdates(); }, 3000); return; }
2463
+ lastUpdates = u;
2444
2464
  $('updates-version').textContent = `Clavarium ${u.version ?? ''}`;
2445
2465
  for (const id of ['updates-check', 'updates-settings', 'updates-back']) $(id).hidden = !u.installed;
2446
2466
  if (!u.installed) {
@@ -2456,12 +2476,22 @@
2456
2476
  : u.checkedAt ? `Up to date · checked ${sinceWords(u.checkedAt)}` : 'Up to date';
2457
2477
  $('updates-check').disabled = Boolean(running);
2458
2478
  $('updates-offer').hidden = !u.available || Boolean(running);
2459
- $('updates-apply').textContent = `Update to ${u.available ?? ''}`;
2460
- /* The last thing that went wrong, said once at the top, in words. */
2479
+ /* A version that already failed here is not offered as usual: tried again only on purpose, warned. */
2480
+ const failedHere = Boolean(u.available) && u.history.some((h) => h.action === 'update' && h.to === u.available && h.result !== 'done');
2481
+ const apply = $('updates-apply');
2482
+ apply.dataset.version = u.available ?? '';
2483
+ apply.dataset.retry = String(failedHere);
2484
+ apply.className = failedHere ? '' : 'primary';
2485
+ apply.textContent = failedHere ? `Try ${u.available} again\u2026` : `Update to ${u.available ?? ''}`;
2486
+ $('updates-retry-note').textContent = failedHere ? `It did not start here before. If it fails again, ${u.version} is put back.` : '';
2487
+ $('updates-retry-note').hidden = !failedHere;
2488
+ $('updates-retry-yes').hidden = true; $('updates-retry-no').hidden = true; apply.hidden = false;
2489
+ /* The last thing that went wrong, said once at the top, in words: a version put
2490
+ back runs normally -- only the new one did not start, here. */
2461
2491
  const last = u.history[0];
2462
- const why = (last?.why ?? '').replace(/^./, (c) => c.toUpperCase());
2463
- const news = !running && last && last.result !== 'done' ? (last.result === 'rolled-back' ? `The update to ${last.to} did not work, so ${last.from} was put back. ${why}` : `${last.action === 'back' ? 'Going back to' : 'The update to'} ${last.to} failed: ${last.why ?? ''}`)
2464
- : !running && u.refused && (!last || u.refused.at > last.at) ? `Not done: ${u.refused.why}` : '';
2492
+ const backFailed = /did not come back either/.test(last?.why ?? '');
2493
+ const news = !running && last && last.result !== 'done' ? (last.result === 'rolled-back' ? (backFailed ? `${last.to} did not start on this server, and putting ${last.from} back did not work either: ${last.why}` : `${last.to} did not start on this server, so ${last.from} was put back. ${last.from} runs normally.`) : `${last.action === 'back' ? 'Going back to' : 'The update to'} ${last.to} failed: ${last.why ?? ''}`)
2494
+ : !running && u.refused && (!last || u.refused.at > last.at) && !/is not newer than/.test(u.refused.why ?? '') ? `Not done: ${u.refused.why}` : '';
2465
2495
  $('updates-news').textContent = news.trim(); $('updates-news').hidden = !news;
2466
2496
  if (![$('updates-mode'), $('updates-from'), $('updates-to'), $('updates-channel')].includes(document.activeElement)) {
2467
2497
  $('updates-mode').value = u.mode; $('updates-channel').value = u.channel;
@@ -2500,17 +2530,30 @@
2500
2530
  if (running && !$('settings').hidden) updatesTimer = setTimeout(() => { void paintUpdates(); }, 3000);
2501
2531
  }
2502
2532
  $('updates-check').addEventListener('click', async () => {
2503
- updatesError(); $('updates-check').disabled = true; $('updates-state').textContent = 'Checking...';
2504
- try { await post(`${API}/updates/check`); } catch (error) { updatesError(error.message); }
2505
- $('updates-check').disabled = false;
2533
+ updatesError(); $('updates-check').disabled = true; $('updates-check').textContent = 'Checking...'; $('updates-state').textContent = 'Checking...';
2534
+ let failed = false;
2535
+ try { await post(`${API}/updates/check`); } catch (error) { failed = true; updatesError(`Could not ask npm: ${error.message}. Try again later.`); }
2536
+ $('updates-check').disabled = false; $('updates-check').textContent = 'Check now';
2506
2537
  await paintUpdates();
2538
+ /* The answer to the click, said next to it. */
2539
+ const u = lastUpdates;
2540
+ if (!failed && u && !u.checkError) { $('updates-news').textContent = u.available ? `${u.available} is out.` : `No new version: ${u.version} is the latest.`; $('updates-news').hidden = false; }
2507
2541
  });
2508
- $('updates-apply').addEventListener('click', async () => {
2542
+ async function applyUpdate() {
2509
2543
  updatesError();
2510
- const version = $('updates-apply').textContent.replace(/^Update to /, '');
2544
+ const version = $('updates-apply').dataset.version;
2511
2545
  try { await post(`${API}/updates/apply`, { version }); updatesBusy = true; } catch (error) { updatesError(error.message); }
2512
2546
  await paintUpdates();
2547
+ }
2548
+ $('updates-apply').addEventListener('click', () => {
2549
+ if ($('updates-apply').dataset.retry !== 'true') { void applyUpdate(); return; }
2550
+ /* Tried again: asked once more, in place. */
2551
+ $('updates-apply').hidden = true;
2552
+ $('updates-retry-note').textContent = `The server restarts to try ${$('updates-apply').dataset.version} again.`;
2553
+ $('updates-retry-yes').hidden = false; $('updates-retry-no').hidden = false; $('updates-retry-yes').focus();
2513
2554
  });
2555
+ $('updates-retry-yes').addEventListener('click', () => { void applyUpdate(); });
2556
+ $('updates-retry-no').addEventListener('click', () => { void paintUpdates(); });
2514
2557
  async function saveUpdates(change) {
2515
2558
  updatesError(); $('updates-saved').textContent = '';
2516
2559
  try { await post(`${API}/updates/settings`, change); $('updates-saved').textContent = 'Saved.'; } catch (error) { updatesError(error.message); }
@@ -2600,22 +2643,35 @@
2600
2643
  const r = await fetch(`${API}/server`).then((x) => (x.ok ? x.json() : null)).catch(() => null);
2601
2644
  $('settings-server-top').hidden = !r; $('settings-server').hidden = !r;
2602
2645
  if (!r) return;
2603
- const editing = [$('reserve-disk'), $('reserve-memory')].includes(document.activeElement);
2604
- if (!editing) { $('reserve-disk').value = String(Math.round((r.reserve.diskMb / 1024) * 100) / 100); $('reserve-memory').value = String(Math.round((r.reserve.memoryMb / 1024) * 100) / 100); }
2646
+ /* The two settings of each: a fixed amount and a share; the larger is kept free. */
2647
+ const set = r.settings ?? { ...r.reserve, diskPercent: 0, memoryPercent: 0 };
2648
+ const editing = ['reserve-disk', 'reserve-disk-percent', 'reserve-memory', 'reserve-memory-percent'].some((id) => $(id) === document.activeElement);
2649
+ if (!editing) {
2650
+ $('reserve-disk').value = String(Math.round((set.diskMb / 1024) * 100) / 100); $('reserve-memory').value = String(Math.round((set.memoryMb / 1024) * 100) / 100);
2651
+ $('reserve-disk-percent').value = String(set.diskPercent); $('reserve-memory-percent').value = String(set.memoryPercent);
2652
+ }
2653
+ $('reserve-now').textContent = r.known ? `Kept free now: ${GB(r.reserve.diskMb)} of disk, ${GB(r.reserve.memoryMb)} of memory.` : '';
2654
+ /* What was promised, only said: never refused for it. */
2655
+ const promised = (line, p, words) => {
2656
+ line.hidden = !p; if (!p) return;
2657
+ line.dataset.over = String(p.over);
2658
+ line.textContent = `${words(GB(p.mb), GB(p.totalMb))}${p.over ? ' That is more than there is: fine while they do not all fill up.' : ''}`;
2659
+ };
2660
+ promised($('promised-disk'), r.promised?.disk, (mb, total) => `Disks promised to machines, at their largest: ${mb} of ${total}.`);
2661
+ promised($('promised-memory'), r.promised?.memory, (mb, total) => `Memory given to running machines: ${mb} of ${total}.`);
2605
2662
  $('server-unknown').hidden = r.known;
2606
2663
  $('server-disks').hidden = !r.disk; $('server-memory').hidden = !r.memory;
2607
2664
  if (r.disk) paintDisks(r);
2608
2665
  if (r.memory) {
2609
2666
  const mem = r.memory;
2610
- $('server-memory-figures').textContent = `${GB(mem.spareMb)} free of ${GB(mem.totalMb)}`;
2611
- meter($('server-memory-meter'), mem.usedPercent, r.reserve.memoryMb, mem.totalMb, levelOf(r.high.memory, mem.usedPercent, mem.spareMb - r.reserve.memoryMb, r.reserve.memoryMb));
2667
+ $('server-memory-figures').textContent = `${GB(mem.freeMb)} free of ${GB(mem.totalMb)}`;
2668
+ meter($('server-memory-meter'), mem.usedPercent, r.reserve.memoryMb, mem.totalMb, levelOf(r.high.memory, mem.usedPercent, mem.freeMb - r.reserve.memoryMb, r.reserve.memoryMb));
2612
2669
  $('server-memory-state').textContent = 'Short'; $('server-memory-state').hidden = !r.high.memory;
2613
- $('server-memory-note').textContent = aboveWords(mem.spareMb - r.reserve.memoryMb, r.reserve.memoryMb);
2670
+ $('server-memory-note').textContent = aboveWords(mem.freeMb - r.reserve.memoryMb, r.reserve.memoryMb);
2614
2671
  const held = r.machines.reduce((a, x) => a + x.rssMb, 0);
2615
2672
  parts($('server-memory-parts'), [
2616
2673
  [`Running machines (${r.machines.length})`, held],
2617
2674
  ...r.machines.map((x) => [x.shelf ? 'ready-made machine (being made)' : (x.name ?? 'another machine'), x.sizeMb ? `uses ${GB(x.rssMb)} · given ${GB(x.sizeMb)}` : `uses ${GB(x.rssMb)}`, true]),
2618
- ...(mem.promisedMb > 0 ? [['given to them, not used yet', mem.promisedMb, true]] : []),
2619
2675
  ['The system and other programs', Math.max(0, mem.totalMb - mem.freeMb - held)],
2620
2676
  ]);
2621
2677
  }
@@ -2625,9 +2681,12 @@
2625
2681
  $('reserve-save').addEventListener('click', async () => {
2626
2682
  $('reserve-error').hidden = true; $('reserve-saved').textContent = '';
2627
2683
  const mb = (id) => { const v = Number.parseFloat($(id).value.replace(',', '.')); return Number.isFinite(v) ? Math.round(v * 1024) : NaN; };
2684
+ const share = (id) => { const v = Number($(id).value.trim()); return Number.isInteger(v) && v >= 0 && v <= 100 ? v : NaN; };
2628
2685
  const diskMb = mb('reserve-disk'); const memoryMb = mb('reserve-memory');
2686
+ const diskPercent = share('reserve-disk-percent'); const memoryPercent = share('reserve-memory-percent');
2629
2687
  if (!(diskMb >= 0) || !(memoryMb >= 0)) { $('reserve-error').textContent = 'Write a number of gigabytes, 0 or more.'; $('reserve-error').hidden = false; return; }
2630
- try { await post(`${API}/server/reserve`, { diskMb, memoryMb }); $('reserve-saved').textContent = 'Saved.'; } catch (error) { $('reserve-error').textContent = error.message; $('reserve-error').hidden = false; return; }
2688
+ if (Number.isNaN(diskPercent) || Number.isNaN(memoryPercent)) { $('reserve-error').textContent = 'Write a whole percent, from 0 to 100.'; $('reserve-error').hidden = false; return; }
2689
+ try { await post(`${API}/server/reserve`, { diskMb, diskPercent, memoryMb, memoryPercent }); $('reserve-saved').textContent = 'Saved.'; } catch (error) { $('reserve-error').textContent = error.message; $('reserve-error').hidden = false; return; }
2631
2690
  await paintServer(); void paintGauge();
2632
2691
  });
2633
2692
  /* THE SMALL GAUGE at the bottom of the list: only when disk or memory is
@@ -2657,7 +2716,7 @@
2657
2716
  }
2658
2717
  $('server-gauge').addEventListener('click', async () => {
2659
2718
  if (place !== 'settings') await openSettings(); else await paintServer();
2660
- const top = $('settings-server-top');
2719
+ const top = openChapter('s-resources');
2661
2720
  top.scrollIntoView({ block: 'start' });
2662
2721
  $('settings-server').focus({ preventScroll: true });
2663
2722
  });
package/web/index.html CHANGED
@@ -505,17 +505,33 @@
505
505
  <!-- SETTINGS: the platform, and this device. -->
506
506
  <div id="settings" class="sheet" hidden>
507
507
  <div class="panel-top"><b>Settings</b><button type="button" id="settings-close">Close</button></div>
508
- <div class="panel-top"><b>This device</b></div>
508
+ <h3 class="chapter-group">This device</h3>
509
+ <details class="chapter" id="settings-ch-screen" data-chapter="s-screen">
510
+ <summary><span class="chapter-title">Screen</span><span class="chapter-about">Light or dark, text size, the app.</span></summary>
511
+ <div class="chapter-body">
509
512
  <div class="row"><span>Screen</span><button type="button" id="theme" aria-label="Light or dark screen">Light</button><button type="button" id="install" class="primary" hidden>Install the app</button></div>
510
513
  <div class="row"><span>Text size</span><span class="text-size" id="settings-text" role="group" aria-label="Text size"><button type="button" data-text="smaller" aria-label="Smaller text">A&minus;</button><button type="button" data-text="normal" aria-label="Normal text size" class="text-now">100%</button><button type="button" data-text="larger" aria-label="Larger text">A+</button></span></div>
511
514
  <p class="hint">All text, the terminal's too, on this device only. Also: two fingers pinched, Ctrl and the wheel, Ctrl + and Ctrl -.</p>
512
- <div class="panel-top"><b>Alerts</b></div>
515
+ </div>
516
+ </details>
517
+ <details class="chapter" id="settings-ch-alerts" data-chapter="s-alerts">
518
+ <summary><span class="chapter-title">Alerts</span><span class="chapter-about">Be told here when a tool waits for you.</span></summary>
519
+ <div class="chapter-body">
513
520
  <p class="hint" id="alerts-state">When a tool stops and waits for you, this device can be told.</p>
514
521
  <div class="row"><button type="button" id="alerts-on" class="primary">Alert me on this device</button><button type="button" id="alerts-off" hidden>Stop alerting this device</button><button type="button" id="alerts-test" hidden>Send a test</button></div>
515
- <div class="panel-top"><b>Shortcuts</b></div>
522
+ </div>
523
+ </details>
524
+ <h3 class="chapter-group">This server</h3>
525
+ <details class="chapter" id="settings-ch-shortcuts" data-chapter="s-shortcuts">
526
+ <summary><span class="chapter-title">Shortcuts</span><span class="chapter-about">The ready-made answers under the terminal.</span></summary>
527
+ <div class="chapter-body">
516
528
  <p class="hint">The ready-made answers under the terminal. They are added there ("+ Shortcut"); they are removed here.</p>
517
529
  <div id="settings-shortcuts"></div>
518
- <div class="panel-top"><b>Chat screen</b></div>
530
+ </div>
531
+ </details>
532
+ <details class="chapter" id="settings-ch-words" data-chapter="s-words">
533
+ <summary><span class="chapter-title">Chat screen</span><span class="chapter-about">Its language, and your own words.</span></summary>
534
+ <div class="chapter-body">
519
535
  <div class="row"><label for="words-lang">Language</label><select id="words-lang"><option value="device">The language of each device</option></select></div>
520
536
  <details id="words-box">
521
537
  <summary>Your own words</summary>
@@ -527,18 +543,34 @@
527
543
  </details>
528
544
  <!-- WHAT THE THREAD SHOWS of what this server says there: each kind of
529
545
  event at a level (server/talk.mjs EVENT_LEVELS); the owner may move one. -->
530
- <div class="panel-top"><b>Chat: what the thread shows</b></div>
546
+ </div>
547
+ </details>
548
+ <details class="chapter" id="settings-ch-thread" data-chapter="s-thread">
549
+ <summary><span class="chapter-title">Chat thread</span><span class="chapter-about">What the chat says of machines and repositories.</span></summary>
550
+ <div class="chapter-body">
531
551
  <p class="hint">Besides the messages, the chat says what happened to its machine and its repositories. <b>Needs you</b>: in both views. <b>News</b>: in the simple view as one short line, several in a row grouped. <b>Trace</b>: in the detailed view only. Never counted as unread.</p>
532
552
  <div id="thread-levels" class="thread-levels"></div>
533
553
  <p class="hint" id="thread-saved" role="status"></p>
534
554
  <p id="thread-error" class="error" role="alert" hidden></p>
535
- <div class="panel-top"><b>Dictation</b></div>
555
+ </div>
556
+ </details>
557
+ <details class="chapter" id="settings-ch-dictation" data-chapter="s-dictation">
558
+ <summary><span class="chapter-title">Dictation</span><span class="chapter-about">Speech to text, on this server.</span></summary>
559
+ <div class="chapter-body">
536
560
  <p id="settings-dictation" class="hint"></p>
537
- <div class="panel-top"><b>People &amp; address</b></div>
561
+ </div>
562
+ </details>
563
+ <details class="chapter" id="settings-ch-people" data-chapter="s-people">
564
+ <summary><span class="chapter-title">People &amp; address</span><span class="chapter-about">Who may sign in, and where.</span></summary>
565
+ <div class="chapter-body">
538
566
  <p id="settings-people" class="hint"></p>
539
567
  <!-- THIS SERVER: its disk and memory, what takes them, and the reserve
540
568
  no machine may eat into (server/capacity.mjs). Nothing here wakes a machine. -->
541
- <div class="panel-top" id="settings-server-top"><b>This server</b></div>
569
+ </div>
570
+ </details>
571
+ <details class="chapter" id="settings-server-top" data-chapter="s-resources">
572
+ <summary><span class="chapter-title">Resources</span><span class="chapter-about">Disk and memory, what takes them, the reserve.</span></summary>
573
+ <div class="chapter-body">
542
574
  <div id="settings-server" tabindex="-1">
543
575
  <p id="server-unknown" class="hint" hidden>This server's disk and memory cannot be read just now. Nothing is refused for lack of room while they cannot.</p>
544
576
  <div id="server-disks"></div>
@@ -549,24 +581,41 @@
549
581
  <dl id="server-memory-parts" class="server-parts"></dl>
550
582
  </div>
551
583
  <div class="server-block">
552
- <div class="server-head"><b>Reserve</b><span class="server-figures">always left free</span></div>
584
+ <div class="server-head"><b>Reserve</b><span class="server-figures">always left free, the larger of the two</span></div>
585
+ <div class="reserve-row">
586
+ <span class="reserve-what">Disk</span>
587
+ <label><input type="number" id="reserve-disk" min="0" max="1024" step="0.5" inputmode="decimal" aria-label="Disk reserve, in GB"> GB</label>
588
+ <span aria-hidden="true">or</span>
589
+ <label><input type="number" id="reserve-disk-percent" min="0" max="100" step="1" inputmode="numeric" aria-label="Disk reserve, in percent of the disk"> %</label>
590
+ </div>
591
+ <div class="reserve-row">
592
+ <span class="reserve-what">Memory</span>
593
+ <label><input type="number" id="reserve-memory" min="0" max="256" step="0.25" inputmode="decimal" aria-label="Memory reserve, in GB"> GB</label>
594
+ <span aria-hidden="true">or</span>
595
+ <label><input type="number" id="reserve-memory-percent" min="0" max="100" step="1" inputmode="numeric" aria-label="Memory reserve, in percent of the memory"> %</label>
596
+ </div>
553
597
  <div class="reserve-row">
554
- <label>Disk <input type="number" id="reserve-disk" min="0" max="1024" step="0.5" inputmode="decimal"> GB</label>
555
- <label>Memory <input type="number" id="reserve-memory" min="0" max="256" step="0.25" inputmode="decimal"> GB</label>
556
598
  <button type="button" id="reserve-save" class="primary">Save</button>
557
599
  <span class="hint" id="reserve-saved" role="status"></span>
558
600
  </div>
559
- <p class="hint">Creating, starting or waking a machine, or giving a machine more memory or disk, is refused if it would leave less than this free; you are told what to free instead. Waking is never refused for lack of disk, so you can always get in to clean up. The striped end of each bar is the reserve.</p>
601
+ <p class="server-note" id="reserve-now"></p>
602
+ <p class="hint">Judged on what is really free. A machine is not created, started or given more if that would leave less. Below it, you are alerted and the running machine that takes the most is put to sleep. Waking is never refused for lack of disk, so you can get in to clean up.</p>
603
+ <p class="server-note promised" id="promised-disk" hidden></p>
604
+ <p class="server-note promised" id="promised-memory" hidden></p>
560
605
  <p id="reserve-error" class="error" role="alert" hidden></p>
561
606
  </div>
562
607
  </div>
563
608
  <!-- UPDATES: what runs, what is out, how this server updates itself
564
609
  (server/updates.mjs; the updater, as root: deploy/updater.mjs). -->
565
- <div class="panel-top" id="settings-updates-top"><b>Updates</b></div>
610
+ </div>
611
+ </details>
612
+ <details class="chapter" id="settings-updates-top" data-chapter="s-updates">
613
+ <summary><span class="chapter-title">Updates</span><span class="chapter-about">The version that runs, and how it updates.</span></summary>
614
+ <div class="chapter-body">
566
615
  <div id="updates" tabindex="-1">
567
616
  <div class="updates-head"><b id="updates-version"></b><span id="updates-state" role="status"></span><button type="button" id="updates-check">Check now</button></div>
568
617
  <p id="updates-news" class="updates-news" hidden></p>
569
- <div class="row" id="updates-offer" hidden><button type="button" id="updates-apply" class="primary"></button></div>
618
+ <div class="row" id="updates-offer" hidden><button type="button" id="updates-apply" class="primary"></button><button type="button" id="updates-retry-yes" class="primary" hidden>Try again</button><button type="button" id="updates-retry-no" hidden>Cancel</button><span class="hint" id="updates-retry-note" hidden></span></div>
570
619
  <div id="updates-settings" class="updates-grid">
571
620
  <label for="updates-mode">Mode</label>
572
621
  <select id="updates-mode"><option value="auto">Automatic, in a window</option><option value="asap">As soon as out</option><option value="ask">Ask me</option><option value="off">Off</option></select>
@@ -580,10 +629,17 @@
580
629
  <details id="updates-history-box" hidden><summary>History</summary><ul id="updates-history" class="updates-history"></ul></details>
581
630
  <p id="updates-error" class="error" role="alert" hidden></p>
582
631
  </div>
583
- <div class="panel-top"><b>Storage &amp; backup</b></div>
632
+ </div>
633
+ </details>
634
+ <details class="chapter" id="settings-ch-backup" data-chapter="s-backup">
635
+ <summary><span class="chapter-title">Storage &amp; backup</span><span class="chapter-about">Disk sizes, and how to back up.</span></summary>
636
+ <div class="chapter-body">
584
637
  <p class="hint">Each conversation works in a disk of a fixed size (10 GB unless the installation says otherwise). A backup is made on the machine itself, as two files -- the archive, and the key of the vault apart: <code>sudo deploy/backup.sh --to /a/folder</code>.</p>
585
- <details id="settings-advanced">
586
- <summary>Advanced</summary>
638
+ </div>
639
+ </details>
640
+ <details class="chapter" id="settings-ch-advanced" data-chapter="s-advanced">
641
+ <summary><span class="chapter-title">Advanced</span><span class="chapter-about">Ready-made machine, git server keys.</span></summary>
642
+ <div class="chapter-body" id="settings-advanced">
587
643
  <div id="shelf">
588
644
  <b>Ready-made machine</b>
589
645
  <p class="hint">New conversations start from a copy of a machine prepared in advance, with the tools already installed: a minute or two instead of several. It is rebuilt each month in the background; running conversations are not touched.</p>
@@ -598,9 +654,14 @@
598
654
  <div id="git-hosts"></div>
599
655
  <p id="git-hosts-error" class="error" role="alert" hidden></p>
600
656
  </div>
657
+ </div>
601
658
  </details>
602
- <div class="panel-top"><b>About</b></div>
659
+ <details class="chapter" id="settings-ch-about" data-chapter="s-about">
660
+ <summary><span class="chapter-title">About</span><span class="chapter-about">The version that runs here.</span></summary>
661
+ <div class="chapter-body">
603
662
  <p id="settings-about" class="hint"></p>
663
+ </div>
664
+ </details>
604
665
  </div>
605
666
  <div id="copy" class="sheet" hidden>
606
667
  <div class="panel-top"><b>Copy from the screen</b><button type="button" id="copy-close">Close</button></div>
package/web/style.css CHANGED
@@ -228,9 +228,9 @@ footer button.careful { border-style: dashed; color: var(--ink2); }
228
228
  #sharing-what { display: flex; flex-direction: column; }
229
229
 
230
230
  /* What can be unfolded looks like it can. */
231
- #panel details > summary { list-style: none; cursor: pointer; padding: 8px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--button, #222c38); font-weight: 600; }
232
- #panel details > summary::before { content: '+ '; }
233
- #panel details[open] > summary::before { content: '- '; }
231
+ #panel details:not(.chapter) > summary { list-style: none; cursor: pointer; padding: 8px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--button, #222c38); font-weight: 600; }
232
+ #panel details:not(.chapter) > summary::before { content: '+ '; }
233
+ #panel details[open]:not(.chapter) > summary::before { content: '- '; }
234
234
  #sharing-label { min-height: 40px; font: .875rem var(--sans); color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 7px 9px; }
235
235
  #sharing label { font-size: .8125rem; }
236
236
 
@@ -308,10 +308,10 @@ footer button.careful { border-style: dashed; color: var(--ink2); }
308
308
  @media (prefers-reduced-motion: reduce) { .asset-head b::before { transition: none; } }
309
309
  .asset-inside { flex-basis: 100%; padding: 0 0 8px 12px; display: flex; flex-direction: column; gap: 4px; }
310
310
  @media (max-width: 700px) { .asset-head { grid-template-columns: 1fr; gap: 2px; } }
311
- #assets details > summary, #access-advanced > summary, #settings-advanced > summary { cursor: pointer; padding: 8px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--button, #222c38); font-weight: 600; list-style: none; align-self: flex-start; }
312
- #assets details > summary::before, #access-advanced > summary::before, #settings-advanced > summary::before { content: '+ '; }
313
- #assets details[open] > summary::before, #access-advanced[open] > summary::before, #settings-advanced[open] > summary::before { content: '- '; }
314
- #assets details form, #access-advanced > div, #settings-advanced > div { margin-top: 8px; }
311
+ #assets details > summary, #access-advanced > summary { cursor: pointer; padding: 8px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--button, #222c38); font-weight: 600; list-style: none; align-self: flex-start; }
312
+ #assets details > summary::before, #access-advanced > summary::before { content: '+ '; }
313
+ #assets details[open] > summary::before, #access-advanced[open] > summary::before { content: '- '; }
314
+ #assets details form, #access-advanced > div { margin-top: 8px; }
315
315
  #attach-picker { border: 1px solid var(--brass); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
316
316
  #chips { cursor: pointer; }
317
317
  #settings .row { align-items: center; gap: 10px; }
@@ -398,17 +398,21 @@ textarea.public-key { flex: 1 1 260px; min-width: 0; font: .75rem ui-monospace,
398
398
  #gitview #gitview-guards ul { margin: 4px 0 8px; padding-left: 20px; }
399
399
  #gitview #gitview-guards li { overflow-wrap: anywhere; }
400
400
 
401
- /* ---------- this conversation: chapters that fold, as a project's settings ---------- */
402
- #panel { gap: 12px; }
403
- #panel details.chapter { border: 1px solid var(--line); border-radius: 10px; background: var(--bar); }
404
- #panel details.chapter > summary { list-style: none; cursor: pointer; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; padding: 12px 14px; border: 0; background: none; border-radius: 10px; min-height: 48px; }
405
- #panel details.chapter > summary::-webkit-details-marker { display: none; }
406
- #panel details.chapter > summary::before { content: none; }
407
- #panel details.chapter > summary::after { content: "Expand"; margin-left: auto; font-size: .75rem; font-weight: 600; color: var(--ink2); border: 1px solid var(--line); border-radius: 7px; padding: 3px 9px; }
408
- #panel details.chapter[open] > summary::after { content: "Collapse"; }
409
- #panel details.chapter[open] > summary { border-bottom: 1px solid var(--line); border-radius: 10px 10px 0 0; }
401
+ /* ---------- chapters that fold, as a project's settings (a conversation's, and Settings) ---------- */
402
+ #panel, #settings { gap: 12px; }
403
+ details.chapter { flex-shrink: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--bar); }
404
+ details.chapter > summary { list-style: none; cursor: pointer; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; padding: 12px 14px; border: 0; background: none; border-radius: 10px; min-height: 48px; }
405
+ details.chapter > summary::-webkit-details-marker { display: none; }
406
+ details.chapter > summary::before { content: none; }
407
+ details.chapter > summary::after { content: "Expand"; margin-left: auto; font-size: .75rem; font-weight: 600; color: var(--ink2); border: 1px solid var(--line); border-radius: 7px; padding: 3px 9px; }
408
+ details.chapter[open] > summary::after { content: "Collapse"; }
409
+ details.chapter[open] > summary { border-bottom: 1px solid var(--line); border-radius: 10px 10px 0 0; }
410
410
  .chapter-title { font-size: 1.0625rem; font-weight: 700; color: var(--ink); }
411
411
  .chapter-about { font-size: .8125rem; font-weight: 400; color: var(--ink2); flex: 1 1 14rem; }
412
+ .chapter-group { margin: 10px 0 -4px; font-size: .8125rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--ink2); }
413
+ /* Reached by a link: said for a moment. */
414
+ details.chapter.reached { outline: 2px solid var(--brass); outline-offset: 2px; }
415
+ @media (prefers-reduced-motion: no-preference) { details.chapter { transition: outline-color .6s; } }
412
416
  .chapter-body { display: flex; flex-direction: column; gap: 10px; padding: 12px 14px 14px; background: var(--panel); border-radius: 0 0 10px 10px; }
413
417
  .facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 0; }
414
418
  .facts dt { color: var(--ink2); }
@@ -973,6 +977,8 @@ button.inline:hover { color: var(--brass); }
973
977
  .server-parts .sub { font-weight: normal; color: var(--ink2); }
974
978
  .server-parts dt.sub { padding-left: 14px; }
975
979
  .reserve-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
980
+ .reserve-what { min-width: 4.5em; font-weight: 600; }
981
+ .server-note.promised[data-over="true"] { color: var(--warn, #b7791f); font-weight: 600; }
976
982
  .reserve-row label { display: inline-flex; align-items: center; gap: 6px; }
977
983
  .reserve-row input { width: 5.5em; min-height: 40px; font: .875rem var(--sans); color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 7px 9px; }
978
984