sealkeep 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
package/web/rail.js ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The rail's identity strip, shared by every page.
3
+ *
4
+ * One implementation on purpose: the card first shipped inside app.js, so the
5
+ * dashboard showed "Sealkeep Pro · 1.9 GB of 500 GB" while Sessions and
6
+ * Retention showed nothing — the rail visibly lost its top on every page
7
+ * change and the nav items jumped to fill the space. Any page with the
8
+ * `.acct` markup and an `api(path)` helper can render the same card with one
9
+ * call; pages without the markup are a no-op, never an error.
10
+ */
11
+ function renderAccountRail(api, status) {
12
+ const $id = (id) => document.getElementById(id);
13
+ const card = $id("acct-card");
14
+ if (!card) return Promise.resolve();
15
+
16
+ // Identical to the app's own bytes(): binary units, one decimal. The first
17
+ // version divided by 1e9, so the same quota read "536.9 GB" in this card and
18
+ // "500.0 GB" in the account panel — two numbers for one fact.
19
+ const bytes = (n) => {
20
+ const value = Number(n);
21
+ if (!Number.isFinite(value)) return "—";
22
+ const units = ["B", "KB", "MB", "GB", "TB"];
23
+ let size = value, unit = 0;
24
+ while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; }
25
+ return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`;
26
+ };
27
+
28
+ // The card is a fixed skeleton: the markup reserves every slot, and this
29
+ // function only ever overwrites text in place — nothing appears or grows, so
30
+ // the nav below never jumps. Two paints: first from the last visit's
31
+ // snapshot (instant, this browser already knew these numbers), then from the
32
+ // live answer, which also refreshes the snapshot.
33
+ const SNAPSHOT_KEY = "vaultline.railSnapshot";
34
+ const paintIdentity = (snap) => {
35
+ const plan = snap.plan;
36
+ const isPro = plan === "pro" || plan === "pro_lite";
37
+ $id("acct-plan").className = `acct-plan${isPro ? " pro" : ""}`;
38
+ $id("acct-plan-name").textContent = plan === "pro" ? "Sealkeep Pro" : plan === "pro_lite" ? "Sealkeep Pro Lite" : plan ? plan.replace(/^./, (c) => c.toUpperCase()) : "Local vault";
39
+ const emailEl = $id("acct-email");
40
+ if (emailEl) emailEl.textContent = snap.email || "no account — this machine only";
41
+ if (snap.limit) {
42
+ const used = Number(snap.used) || 0;
43
+ $id("acct-fill").style.width = `${Math.min(100, (used / snap.limit) * 100).toFixed(1)}%`;
44
+ // A remembered answer is labelled as remembered, never passed off as live.
45
+ $id("acct-usage").textContent = `${bytes(used)} of ${bytes(snap.limit)}${snap.cached ? " · last known" : ""}`;
46
+ } else {
47
+ $id("acct-fill").style.width = "0%";
48
+ $id("acct-usage").textContent = snap.plan ? "—" : "nothing stored off this machine";
49
+ }
50
+ if (snap.diskText) railWriteDiskFact(snap.diskText);
51
+ if (snap.panelUrl && snap.plan) railPlaceDoor(card, snap.panelUrl);
52
+ };
53
+ try {
54
+ const remembered = sessionStorage.getItem(SNAPSHOT_KEY);
55
+ if (remembered) paintIdentity({ ...JSON.parse(remembered), cached: true });
56
+ } catch { /* a torn snapshot paints nothing; the live answer is seconds away */ }
57
+
58
+ const ready = status ? Promise.resolve(status) : api("/v1/status");
59
+ return ready.then((state) => {
60
+ const plan = state.plan;
61
+ const quota = state.quota;
62
+ const snapshot = {
63
+ plan,
64
+ email: state.accountEmail || null,
65
+ used: quota ? Number(quota.usedBytes) || 0 : null,
66
+ limit: quota && quota.quotaBytes ? Number(quota.quotaBytes) : null,
67
+ cached: state.planSource === "cache",
68
+ panelUrl: state.panelUrl || null
69
+ };
70
+ paintIdentity(snapshot);
71
+ try {
72
+ const prior = JSON.parse(sessionStorage.getItem(SNAPSHOT_KEY) || "{}");
73
+ sessionStorage.setItem(SNAPSHOT_KEY, JSON.stringify({ ...snapshot, diskText: prior.diskText || null }));
74
+ } catch { /* a browser refusing storage costs only the instant repaint */ }
75
+
76
+ // The two facts come from setup state; tolerate its absence rather than
77
+ // blocking the card.
78
+ return api("/v1/setup/state").catch(() => null).then((setup) => {
79
+ const held = Boolean(setup && setup.vault && setup.vault.phraseRemembered);
80
+ $id("acct-phrase").className = held ? "good" : "warn";
81
+ $id("acct-phrase-text").innerHTML = held ? "Seals on its own" : "<b>Phrase not stored</b> — Settings";
82
+
83
+ const provider = (state.remoteStorage && state.remoteStorage.provider) || (setup && setup.storage && setup.storage.provider);
84
+ const label = provider
85
+ ? { s3: "Amazon S3", r2: "Cloudflare R2", b2: "Backblaze B2", gcs: "Google Cloud", vaultline: "Sealkeep managed" }[provider] || provider
86
+ : (plan ? "Sealkeep managed" : null);
87
+ $id("acct-storage").className = label ? "good" : "";
88
+ $id("acct-storage-text").textContent = label ? `Storage: ${label}` : "This disk only";
89
+
90
+ // A paying plan gets its door: the panel where the subscription,
91
+ // machines and stored copies are managed. The slot for it is reserved in
92
+ // the markup, so the door appearing changes nothing around it.
93
+ if (state.plan && state.panelUrl) railPlaceDoor(card, state.panelUrl);
94
+
95
+ // Without a plan, the control plane may have an offer to show — the
96
+ // content is the backend's, so what appears (and whether) is edited
97
+ // there, never shipped in this file. Dismissing one offer stays
98
+ // dismissed until the backend publishes a different one (new id).
99
+ if (!state.plan && !document.getElementById("rail-offer")) {
100
+ return api("/v1/offer").catch(() => null).then((body) => {
101
+ const offer = body && body.offer;
102
+ if (!offer || !offer.headline) return;
103
+ const dismissed = localStorage.getItem("vaultline.offer.dismissed");
104
+ if (dismissed && String(offer.id) === dismissed) return;
105
+ const box = document.createElement("div");
106
+ box.id = "rail-offer";
107
+ box.className = "rail-offer";
108
+ const head = document.createElement("b");
109
+ head.textContent = offer.headline;
110
+ const text = document.createElement("p");
111
+ text.textContent = offer.body || "";
112
+ const go = document.createElement("a");
113
+ go.className = "action";
114
+ go.textContent = offer.cta_label || "Learn more";
115
+ go.href = offer.cta_url || "#";
116
+ go.target = "_blank";
117
+ go.rel = "noreferrer";
118
+ const close = document.createElement("button");
119
+ close.type = "button";
120
+ close.className = "rail-offer-close";
121
+ close.textContent = "✕";
122
+ close.title = "Dismiss";
123
+ close.onclick = () => { localStorage.setItem("vaultline.offer.dismissed", String(offer.id)); box.remove(); };
124
+ box.append(close, head, text, go);
125
+ card.after(box);
126
+ });
127
+ }
128
+ }).then(() => {
129
+ // Rendered last, and each self-guarded: neither the upload strip nor the
130
+ // disk-footprint fact may cost the card above them a single field.
131
+ railSyncUpload(api, bytes, card, state && state.upload);
132
+ return railDiskFact(api, bytes);
133
+ });
134
+ }).catch((error) => {
135
+ // The skeleton stays put on failure — placeholders are already honest
136
+ // ("—") — and the reason is kept for inspection rather than swallowed.
137
+ if (typeof window !== "undefined") window.__railError = String((error && error.message) || error);
138
+ });
139
+ }
140
+
141
+ // ---- Local footprint -------------------------------------------------------
142
+
143
+ /**
144
+ * One more fact for the identity strip: how much session history is sitting on
145
+ * this disk. /v1/sessions walks every agent's directory — hundreds of files —
146
+ * so it is asked exactly once per page load, never from a poll, and a failure
147
+ * or an empty answer just leaves the list one line shorter.
148
+ */
149
+ function railWriteDiskFact(text) {
150
+ const slot = document.getElementById("acct-disk-text");
151
+ if (slot) { slot.textContent = text; return true; }
152
+ // Older markup without the reserved row: append one, as before.
153
+ const storage = document.getElementById("acct-storage");
154
+ const list = storage && storage.parentElement;
155
+ if (!list || typeof list.append !== "function" || document.getElementById("acct-disk")) return false;
156
+ const item = document.createElement("li");
157
+ item.id = "acct-disk";
158
+ const dot = document.createElement("span");
159
+ dot.className = "dot";
160
+ const span = document.createElement("span");
161
+ span.className = "acct-fact-text";
162
+ span.textContent = text;
163
+ item.append(dot, span);
164
+ list.append(item);
165
+ return true;
166
+ }
167
+
168
+ /** The door renders into its reserved slot, once; re-renders just retarget the href. */
169
+ function railPlaceDoor(card, panelUrl) {
170
+ let door = document.getElementById("acct-panel-link");
171
+ if (!door) {
172
+ door = document.createElement("a");
173
+ door.id = "acct-panel-link";
174
+ door.className = "action acct-door";
175
+ door.target = "_blank";
176
+ door.rel = "noreferrer";
177
+ door.textContent = "Cloud panel ↗";
178
+ const slot = document.getElementById("acct-door-slot");
179
+ if (slot) slot.append(door);
180
+ else if (card && typeof card.append === "function") card.append(door);
181
+ }
182
+ door.href = panelUrl;
183
+ }
184
+
185
+ let railDiskFactAsked = false;
186
+ function railDiskFact(api, bytes) {
187
+ if (railDiskFactAsked) return;
188
+ railDiskFactAsked = true;
189
+ return api("/v1/sessions").catch(() => null).then((disk) => {
190
+ const total = disk == null ? NaN : Number(disk.totalBytes);
191
+ if (!Number.isFinite(total)) return;
192
+ const line = `${bytes(total)} of sessions on this disk`;
193
+ railWriteDiskFact(line);
194
+ // The slow fact joins the snapshot so the NEXT load paints it instantly.
195
+ try {
196
+ const prior = JSON.parse(sessionStorage.getItem("vaultline.railSnapshot") || "{}");
197
+ sessionStorage.setItem("vaultline.railSnapshot", JSON.stringify({ ...prior, diskText: line }));
198
+ } catch { /* snapshot is a convenience, never a requirement */ }
199
+ });
200
+ }
201
+
202
+ // ---- Upload progress in the rail -------------------------------------------
203
+
204
+ /**
205
+ * While an upload run is active, every page's rail carries a compact strip of
206
+ * it — meter, counts, pace — so walking from the dashboard to Sessions or
207
+ * Retention never means losing sight of a send. The run lives on the server;
208
+ * this only watches: a 3-second poll that exists exclusively while a run is
209
+ * active and stops the moment it is not, so a page rendered with no run never
210
+ * starts a timer at all. The whole strip is a link back to the dashboard's
211
+ * Retention view, where the full progress and its cancel live.
212
+ */
213
+ let railUploadTimer = null;
214
+ let railUploadEls = null;
215
+
216
+ /** Same honest edges as the dashboard's own pace line: no rate worth stating
217
+ before anything lands, then bytes-based so it converges as big files move. */
218
+ function railUploadEta(run) {
219
+ const elapsed = (Date.now() - Date.parse(run.startedAt)) / 1000;
220
+ if (!(run.doneBytes > 0) || !Number.isFinite(elapsed)) return "measuring…";
221
+ const left = (run.totalBytes - run.doneBytes) / (run.doneBytes / Math.max(1, elapsed));
222
+ if (!Number.isFinite(left) || left < 0) return "measuring…";
223
+ const human = left < 90 ? `${Math.max(1, Math.round(left / 10) * 10)}s` : left < 5400 ? `${Math.round(left / 60)} min` : `${(left / 3600).toFixed(1)} h`;
224
+ return `~${human} left`;
225
+ }
226
+
227
+ function railDrawUpload(run, bytes) {
228
+ if (!railUploadEls) return;
229
+ const total = Number(run.totalBytes) || 0;
230
+ const done = Number(run.doneBytes) || 0;
231
+ const active = !run.finishedAt;
232
+ const pct = total > 0 ? Math.min(100, (done / total) * 100) : active ? 0 : 100;
233
+ railUploadEls.fill.style.width = `${pct.toFixed(1)}%`;
234
+ const sent = (run.done || []).length;
235
+ if (active) {
236
+ const tail = run.cancelled ? "stopping…" : railUploadEta(run);
237
+ railUploadEls.line.textContent = `↑ ${sent} of ${Number(run.totalCount) || 0} · ${bytes(done)} of ${bytes(total)} · ${tail}`;
238
+ } else {
239
+ const failed = run.failed || [];
240
+ const word = run.cancelled ? "Stopped" : "Done";
241
+ railUploadEls.line.textContent = failed.length
242
+ ? `${word} — ${sent} sent, ${failed.length} failed — ${failed[0].error}`
243
+ : `${word} — ${sent} sent, ${bytes(Number(run.freedBytes) || 0)} freed locally`;
244
+ }
245
+ }
246
+
247
+ function railStopUploadPoll() {
248
+ if (railUploadTimer) { clearInterval(railUploadTimer); railUploadTimer = null; }
249
+ }
250
+
251
+ function railRemoveUploadStrip() {
252
+ const strip = railUploadEls && railUploadEls.strip;
253
+ railUploadEls = null;
254
+ if (!strip) return;
255
+ if (typeof strip.remove === "function") strip.remove();
256
+ else strip.hidden = true;
257
+ }
258
+
259
+ /** Under the identity card — or under the offer box when one sits there. */
260
+ function railInjectUploadStrip(card) {
261
+ if (document.getElementById("rail-upload")) return;
262
+ const anchor = document.getElementById("rail-offer") || card;
263
+ if (!anchor || typeof anchor.after !== "function") return;
264
+ const strip = document.createElement("a");
265
+ strip.id = "rail-upload";
266
+ strip.className = "rail-upload";
267
+ strip.href = "./index.html#archives";
268
+ strip.title = "Upload in progress — the full view, and its cancel, live on the dashboard's Retention tab";
269
+ const meter = document.createElement("div");
270
+ meter.className = "meter";
271
+ const fill = document.createElement("div");
272
+ fill.className = "meter-fill";
273
+ meter.append(fill);
274
+ const line = document.createElement("div");
275
+ line.className = "rail-upload-line";
276
+ strip.append(meter, line);
277
+ anchor.after(strip);
278
+ railUploadEls = { strip, fill, line, done: false };
279
+ }
280
+
281
+ function railSyncUpload(api, bytes, card, run) {
282
+ try {
283
+ const active = Boolean(run && !run.finishedAt);
284
+ if (!active) {
285
+ railStopUploadPoll();
286
+ if (!railUploadEls) return;
287
+ // A run that just finished gets its last word for this page view; a rail
288
+ // re-rendered after that — or with the run gone entirely — drops the strip.
289
+ if (run && run.finishedAt && !railUploadEls.done) {
290
+ railUploadEls.done = true;
291
+ railDrawUpload(run, bytes);
292
+ } else {
293
+ railRemoveUploadStrip();
294
+ }
295
+ return;
296
+ }
297
+ if (!railUploadEls) railInjectUploadStrip(card);
298
+ if (!railUploadEls) return; // nowhere to draw — nothing to watch either
299
+ railUploadEls.done = false;
300
+ railDrawUpload(run, bytes);
301
+ if (railUploadTimer) return;
302
+ railUploadTimer = setInterval(() => {
303
+ api("/v1/status")
304
+ .then((fresh) => railSyncUpload(api, bytes, card, fresh && fresh.upload))
305
+ .catch(() => { /* a missed poll is the next poll's problem */ });
306
+ }, 3000);
307
+ } catch { /* the strip is a garnish: no failure here may cost the rail */ }
308
+ }
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Free up disk — rules · Sealkeep</title>
6
+ <meta name="referrer" content="no-referrer">
7
+ </head>
8
+ <body>
9
+ <!-- The rules editor is a dashboard view now (index.html#rules) — one page,
10
+ no reloads between views. This stub keeps old bookmarks and links working,
11
+ preserving a #token=… fragment when one is carried. -->
12
+ <script>
13
+ const h = location.hash.replace(/^#/, "");
14
+ location.replace(h.includes("token=") ? `./index.html#${h}` : "./index.html#rules");
15
+ </script>
16
+ </body>
17
+ </html>
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The Free up disk rules editor, as a dashboard view.
3
+ *
4
+ * This was retention.html, a page of its own — reached from the Free up disk
5
+ * tab and Settings, and every visit was a reload of the whole panel. Same
6
+ * fold as the Memory pool: the markup is a view in index.html, this is the
7
+ * page's script wrapped in one init, and `api`/`token` come from app.js.
8
+ *
9
+ * On re-entry it re-reads the rules and the stored copies so the form never
10
+ * quietly disagrees with a vault edited elsewhere — but the form is already
11
+ * on screen while that happens; nothing blanks.
12
+ */
13
+ (() => {
14
+ const $ = (id) => document.getElementById(id);
15
+ const policy = () => document.querySelector('input[name=policy]:checked').value;
16
+
17
+ const MB = 1024 * 1024;
18
+
19
+ const bytes = (value) => {
20
+ if (!Number.isFinite(value) || value <= 0) return "0 B";
21
+ const units = ["B", "KB", "MB", "GB", "TB"];
22
+ let size = value, unit = 0;
23
+ while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; }
24
+ return `${size < 10 && unit > 0 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`;
25
+ };
26
+
27
+ /** Empty means "do not consider this rule" — which is not the same as zero. */
28
+ const optional = (id, scale = 1) => {
29
+ const raw = $(id).value.trim();
30
+ return raw === "" ? null : Math.max(0, Number(raw) || 0) * scale;
31
+ };
32
+
33
+ function render() {
34
+ const older = Math.max(0, Number($("older").value) || 0);
35
+ const grace = Math.max(0, Number($("grace").value) || 0);
36
+ const raw = $("deleteAfter").value.trim();
37
+ // Empty means never. "0" is a real answer and must not be swallowed by falsiness.
38
+ const deleteAfter = raw === "" ? "never" : String(Math.max(0, Number(raw) || 0));
39
+ const idle = optional("idle");
40
+ const minBytes = optional("minSize", MB);
41
+ $("out").textContent = `sealkeep retention policy --policy ${policy()} \\\n`
42
+ + ` --older-than-days ${older} --grace-days ${grace} \\\n`
43
+ + ` --delete-after-days ${deleteAfter} \\\n`
44
+ + ` --idle-days ${idle === null ? "off" : idle} --min-bytes ${minBytes === null ? "off" : minBytes}`;
45
+ $("saved").textContent = "";
46
+ }
47
+
48
+ /** What the rules currently mean, in the only unit that matters: disk. */
49
+ function showEffect(evaluation) {
50
+ const eligible = evaluation.candidates.filter((candidate) => candidate.eligible).length;
51
+ $("effect").textContent = eligible === 0
52
+ ? `Nothing is reclaimable under these rules right now — ${evaluation.candidates.length} archived session${evaluation.candidates.length === 1 ? " is" : "s are"} being tracked.`
53
+ : `${bytes(evaluation.reclaimableBytes)} across ${eligible} session${eligible === 1 ? "" : "s"} could be moved to your trash under these rules.`;
54
+ }
55
+
56
+ function fill(evaluation) {
57
+ const chosen = document.querySelector(`input[name=policy][value="${evaluation.policy}"]`);
58
+ if (chosen) chosen.checked = true;
59
+ $("older").value = evaluation.olderThanDays;
60
+ $("grace").value = evaluation.graceDays;
61
+ $("idle").value = evaluation.sourceIdleDays === null ? "" : evaluation.sourceIdleDays;
62
+ $("minSize").value = evaluation.minSourceBytes === null ? "" : Math.round(evaluation.minSourceBytes / MB);
63
+ $("deleteAfter").value = evaluation.deleteAfterDays == null ? "" : evaluation.deleteAfterDays;
64
+ render();
65
+ showEffect(evaluation);
66
+ }
67
+
68
+ // ---- Stored copies -------------------------------------------------------
69
+
70
+ const age = (iso) => {
71
+ if (!iso) return "—";
72
+ const days = Math.floor((Date.now() - Date.parse(iso)) / 86400000);
73
+ if (!Number.isFinite(days) || days < 0) return "—";
74
+ return days === 0 ? "today" : days === 1 ? "1 day" : `${days} days`;
75
+ };
76
+
77
+ function copyRow(copy) {
78
+ const row = document.createElement("tr");
79
+ const ref = document.createElement("td");
80
+ ref.className = "path";
81
+ ref.textContent = copy.ref;
82
+ if (copy.lastCopy) {
83
+ const mark = document.createElement("span");
84
+ mark.className = "lastmark";
85
+ mark.title = "This machine no longer holds these bytes, so this stored copy is the only one.";
86
+ mark.textContent = "last copy";
87
+ ref.append(mark);
88
+ }
89
+ const size = document.createElement("td");
90
+ size.className = "num";
91
+ size.textContent = bytes(copy.bytes);
92
+ const when = document.createElement("td");
93
+ when.className = "num";
94
+ when.textContent = age(copy.addedAt);
95
+ const cell = document.createElement("td");
96
+ const button = document.createElement("button");
97
+ button.type = "button";
98
+ button.className = "action danger del";
99
+ button.textContent = "Delete";
100
+ const message = document.createElement("span");
101
+ message.className = "rowmsg";
102
+ button.addEventListener("click", () => deleteCopy(copy, button, message));
103
+ cell.append(button, message);
104
+ row.append(ref, size, when, cell);
105
+ return row;
106
+ }
107
+
108
+ async function deleteCopy(copy, button, message) {
109
+ // The design system's destructive pattern: the first click arms, the second
110
+ // acts, and an armed button left alone stands down after four seconds so
111
+ // nothing stays one click from deletion.
112
+ const restLabel = () => (button.dataset.last === "yes" ? "Delete anyway" : "Delete");
113
+ if (button.dataset.armed !== "yes") {
114
+ button.dataset.armed = "yes";
115
+ button.textContent = "Confirm";
116
+ setTimeout(() => {
117
+ if (button.dataset.armed === "yes") { button.dataset.armed = "no"; button.textContent = restLabel(); }
118
+ }, 4000);
119
+ return;
120
+ }
121
+ button.dataset.armed = "no";
122
+ button.disabled = true;
123
+ message.textContent = "";
124
+ try {
125
+ await api(`/v1/cloud/copies/${encodeURIComponent(copy.ref)}`, {
126
+ method: "DELETE",
127
+ body: JSON.stringify({ confirmLastCopy: button.dataset.last === "yes" })
128
+ });
129
+ await loadCopies();
130
+ } catch (error) {
131
+ button.disabled = false;
132
+ if (error.lastCopy) {
133
+ // The server stopped short. Say what would be lost, what has not been,
134
+ // and both ways forward — then make deleting take the two steps again.
135
+ button.dataset.last = "yes";
136
+ button.textContent = "Delete anyway";
137
+ message.textContent = "This is the only copy left anywhere — nothing has been deleted. Restore the archive to get a local copy back first, or delete it knowing the archive is then gone for good.";
138
+ } else {
139
+ button.textContent = restLabel();
140
+ message.textContent = `Not deleted: ${error.message}`;
141
+ }
142
+ }
143
+ }
144
+
145
+ async function loadCopies() {
146
+ let data;
147
+ try {
148
+ data = await api("/v1/cloud/copies");
149
+ } catch (error) {
150
+ $("copies-line").textContent = token
151
+ ? `Could not read your stored copies: ${error.message}`
152
+ : "Connect the dashboard to see what is stored off this machine.";
153
+ return;
154
+ }
155
+ const { copies, mode } = data;
156
+ if (mode === "none") {
157
+ $("copies-line").textContent = "No storage is connected to this vault, so nothing is stored off this machine. Everything you have archived lives here only.";
158
+ $("copies-table").hidden = true;
159
+ return;
160
+ }
161
+ const total = copies.reduce((sum, copy) => sum + copy.bytes, 0);
162
+ $("copies-line").textContent = copies.length === 0
163
+ ? "Nothing is stored off this machine yet."
164
+ : `${copies.length} ${copies.length === 1 ? "copy" : "copies"} · ${bytes(total)} in your storage.`;
165
+ const body = $("copies-body");
166
+ body.textContent = "";
167
+ for (const copy of copies) body.append(copyRow(copy));
168
+ $("copies-table").hidden = copies.length === 0;
169
+ if ($("cap") !== document.activeElement) $("cap").value = data.rollingCapGb == null ? "" : data.rollingCapGb;
170
+ }
171
+
172
+ function loadRules() {
173
+ // Show what is actually configured rather than the markup's defaults, so
174
+ // this view never quietly disagrees with the vault it is editing.
175
+ api("/v1/retention").then(fill).catch((error) => {
176
+ $("effect").textContent = token
177
+ ? `Could not read the current rules: ${error.message}`
178
+ : "Connect the dashboard so it can read this vault's current rules.";
179
+ });
180
+ }
181
+
182
+ function wire() {
183
+ $("save").onclick = async () => {
184
+ $("save").disabled = true;
185
+ $("saved").textContent = "Saving…";
186
+ try {
187
+ const evaluation = await api("/v1/retention/policy", {
188
+ method: "PUT",
189
+ body: JSON.stringify({
190
+ policy: policy(),
191
+ olderThanDays: Math.max(0, Number($("older").value) || 0),
192
+ graceDays: Math.max(0, Number($("grace").value) || 0),
193
+ sourceIdleDays: optional("idle"),
194
+ minSourceBytes: optional("minSize", MB),
195
+ // Was collected, rendered into the command preview, and never sent —
196
+ // so the page said "Saved" about a rule for deleting archives that it
197
+ // had quietly dropped.
198
+ deleteAfterDays: optional("deleteAfter")
199
+ })
200
+ });
201
+ showEffect(evaluation);
202
+ $("saved").textContent = "Saved. The background service uses these from its next pass.";
203
+ } catch (error) {
204
+ $("saved").textContent = `Not saved: ${error.message}`;
205
+ } finally {
206
+ $("save").disabled = false;
207
+ }
208
+ };
209
+
210
+ for (const input of document.querySelectorAll('input[name=policy]')) input.addEventListener("change", render);
211
+ for (const id of ["older", "grace", "deleteAfter", "idle", "minSize"]) $(id).addEventListener("input", render);
212
+
213
+ // The rolling cap is a Pro setting: the account is what enforces it, so a
214
+ // vault without that account has nowhere real to write it.
215
+ api("/v1/status").then((status) => { if (status.plan === "pro") $("rolling").hidden = false; }).catch(() => {});
216
+
217
+ $("cap-save").onclick = async () => {
218
+ const raw = $("cap").value.trim();
219
+ const capGb = raw === "" ? null : Number(raw);
220
+ if (capGb !== null && (!Number.isFinite(capGb) || capGb < 1)) {
221
+ $("cap-msg").textContent = "The cap must be at least 1 GB — or leave it empty to keep every copy.";
222
+ return;
223
+ }
224
+ $("cap-save").disabled = true;
225
+ $("cap-msg").textContent = "Saving…";
226
+ try {
227
+ await api("/v1/cloud/rolling", { method: "PUT", body: JSON.stringify({ capGb }) });
228
+ $("cap-msg").textContent = capGb === null
229
+ ? "No cap — every stored copy is kept."
230
+ : `Saved. Your storage trims to the newest ${capGb} GB within a day; nothing on this machine changes.`;
231
+ } catch (error) {
232
+ $("cap-msg").textContent = `Not saved: ${error.message}`;
233
+ } finally {
234
+ $("cap-save").disabled = false;
235
+ }
236
+ };
237
+ }
238
+
239
+ let initialised = false;
240
+ window.initRulesView = function () {
241
+ if (!initialised) {
242
+ initialised = true;
243
+ wire();
244
+ render();
245
+ }
246
+ void loadCopies();
247
+ loadRules();
248
+ };
249
+ })();