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.
- package/ARCHITECTURE.md +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sealkeep dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Every value comes from the local agent's /v1 API — the page holds no business
|
|
5
|
+
* logic of its own and has no way to decrypt an archive, because the API refuses to
|
|
6
|
+
* accept a recovery phrase. Decryption stays a deliberate command you run yourself.
|
|
7
|
+
*/
|
|
8
|
+
const $ = (id) => document.getElementById(id);
|
|
9
|
+
const TOKEN_KEY = "vaultline.localApiToken";
|
|
10
|
+
|
|
11
|
+
/** The token arrives in the URL fragment, which browsers never send to a server. */
|
|
12
|
+
function readTokenFromLocation() {
|
|
13
|
+
const hash = new URLSearchParams(location.hash.replace(/^#/, ""));
|
|
14
|
+
const token = hash.get("token");
|
|
15
|
+
if (token) {
|
|
16
|
+
sessionStorage.setItem(TOKEN_KEY, token);
|
|
17
|
+
history.replaceState(null, "", location.pathname);
|
|
18
|
+
}
|
|
19
|
+
return sessionStorage.getItem(TOKEN_KEY);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let token = readTokenFromLocation();
|
|
23
|
+
let state = {};
|
|
24
|
+
// Populated once the Settings tab has been opened, and again right after a
|
|
25
|
+
// save. A background refresh triggered from elsewhere (Upload, Reclaim, the
|
|
26
|
+
// global Refresh button) must not silently overwrite an edit in progress on
|
|
27
|
+
// a tab the user cannot currently see, so it is loaded lazily rather than as
|
|
28
|
+
// part of refresh() below.
|
|
29
|
+
let settingsLoaded = false;
|
|
30
|
+
|
|
31
|
+
async function api(path, options = {}) {
|
|
32
|
+
const response = await fetch(path, {
|
|
33
|
+
...options,
|
|
34
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token ?? ""}`, ...options.headers }
|
|
35
|
+
});
|
|
36
|
+
// With no token presented, a 401 just means this browser has not been here
|
|
37
|
+
// yet — the gate's standing copy explains what to do. An error line on top
|
|
38
|
+
// of it is only true when a token was actually offered and refused.
|
|
39
|
+
if (response.status === 401) { showGate(token ? "That token was not accepted." : undefined); throw new Error("unauthorized"); }
|
|
40
|
+
const body = await response.json().catch(() => ({}));
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const error = new Error(body?.error?.message ?? `${response.status} ${response.statusText}`);
|
|
43
|
+
// Structured refusals (lastCopy, forever) ride to the caller so a button
|
|
44
|
+
// can change its own words instead of string-matching prose.
|
|
45
|
+
if (body?.error && typeof body.error === "object") Object.assign(error, { forever: body.error.forever, lastCopy: body.error.lastCopy });
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
return body;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function showGate(message) {
|
|
52
|
+
$("app").hidden = true;
|
|
53
|
+
$("gate").hidden = false;
|
|
54
|
+
$("gate-error").hidden = !message;
|
|
55
|
+
$("gate-error").textContent = message ?? "";
|
|
56
|
+
setStatus("Not connected", "warn");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function setStatus(text, tone) {
|
|
60
|
+
const pill = $("connection");
|
|
61
|
+
pill.textContent = text;
|
|
62
|
+
pill.className = `pill ${tone ?? ""}`.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const bytes = (value) => {
|
|
66
|
+
if (!Number.isFinite(value)) return "—";
|
|
67
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
68
|
+
let size = value, unit = 0;
|
|
69
|
+
while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; }
|
|
70
|
+
return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const ago = (iso) => {
|
|
74
|
+
const delta = Date.now() - Date.parse(iso);
|
|
75
|
+
if (!Number.isFinite(delta)) return "—";
|
|
76
|
+
const minutes = Math.round(delta / 60000);
|
|
77
|
+
if (minutes < 1) return "just now";
|
|
78
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
79
|
+
const hours = Math.round(minutes / 60);
|
|
80
|
+
if (hours < 24) return `${hours}h ago`;
|
|
81
|
+
const days = Math.round(hours / 24);
|
|
82
|
+
return days < 30 ? `${days}d ago` : `${Math.round(days / 30)}mo ago`;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** The mirror image of ago(): a plain description of a moment still to come. */
|
|
86
|
+
const until = (iso) => {
|
|
87
|
+
const delta = Date.parse(iso) - Date.now();
|
|
88
|
+
if (!Number.isFinite(delta)) return "—";
|
|
89
|
+
if (delta <= 0) return "any time now";
|
|
90
|
+
const days = Math.ceil(delta / 86_400_000);
|
|
91
|
+
return days <= 1 ? "within a day" : `in ${days} days`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const shortPath = (path) => (path && path.length > 52 ? `…${path.slice(-51)}` : path ?? "—");
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Text that was written for a terminal, shown in a browser.
|
|
98
|
+
*
|
|
99
|
+
* Most messages reach this page from code that also prints them in a
|
|
100
|
+
* terminal, where `sealkeep autopilot` in backticks is a convention. In HTML
|
|
101
|
+
* the backticks arrived as literal characters — the header pill read
|
|
102
|
+
* "…\`sealkeep autopilot\` does it unattended instead" with the ticks showing,
|
|
103
|
+
* which is what an unfinished internal tool looks like. Backtick pairs become
|
|
104
|
+
* <code>; everything else stays text, so nothing here can inject markup.
|
|
105
|
+
*/
|
|
106
|
+
function prose(target, text) {
|
|
107
|
+
target.replaceChildren();
|
|
108
|
+
const parts = String(text ?? "").split(/`([^`]+)`/g);
|
|
109
|
+
parts.forEach((part, index) => {
|
|
110
|
+
if (!part) return;
|
|
111
|
+
if (index % 2 === 1) {
|
|
112
|
+
const code = document.createElement("code");
|
|
113
|
+
code.textContent = part;
|
|
114
|
+
target.append(code);
|
|
115
|
+
} else {
|
|
116
|
+
// A string child is a text node in the real DOM and in the test double
|
|
117
|
+
// alike; createTextNode is only the former.
|
|
118
|
+
target.append(part);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
return target;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What a check is called on a page, as opposed to in a log.
|
|
126
|
+
*
|
|
127
|
+
* Doctor names its checks the way a maintainer greps for them —
|
|
128
|
+
* `sealed-key-copy`, `local-api-token`, `signer`. Shown raw they made the
|
|
129
|
+
* Health tab read as debug output, and `signer` in particular told a paying
|
|
130
|
+
* customer that their uploads were off in a word they had never seen.
|
|
131
|
+
*/
|
|
132
|
+
const CHECK_LABELS = {
|
|
133
|
+
config: "Vault",
|
|
134
|
+
"archive-store": "Archive folder",
|
|
135
|
+
crypto: "Encryption",
|
|
136
|
+
"local-api-token": "This window's key",
|
|
137
|
+
queue: "Waiting to archive",
|
|
138
|
+
quota: "Storage plan",
|
|
139
|
+
"recovery-phrase": "Recovery phrase",
|
|
140
|
+
"remote-storage": "Storage",
|
|
141
|
+
"sealed-key-copy": "Sealed key backup",
|
|
142
|
+
signer: "Uploads",
|
|
143
|
+
agents: "Agents"
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Same names the retention page uses, so the two never disagree. */
|
|
147
|
+
const POLICY_NAMES = {
|
|
148
|
+
"local-only": "keeping everything",
|
|
149
|
+
"sync-only": "uploading, keeping originals",
|
|
150
|
+
"archive-and-reclaim": "reclaiming once safely stored",
|
|
151
|
+
"manual-approval": "asking first"
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function badge(text, kind) {
|
|
155
|
+
const node = document.createElement("span");
|
|
156
|
+
node.className = `badge badge-${kind}`;
|
|
157
|
+
node.textContent = text;
|
|
158
|
+
return node;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cell(row, value, className) {
|
|
162
|
+
const node = row.insertCell();
|
|
163
|
+
if (value instanceof Node) node.append(value);
|
|
164
|
+
else node.textContent = value;
|
|
165
|
+
if (className) node.className = className;
|
|
166
|
+
return node;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Empty states name the one command that fills them. */
|
|
170
|
+
function showEmpty(panelId, title, detail) {
|
|
171
|
+
const panel = $(panelId);
|
|
172
|
+
panel.replaceChildren();
|
|
173
|
+
const wrap = document.createElement("div");
|
|
174
|
+
wrap.className = "empty";
|
|
175
|
+
const heading = document.createElement("p");
|
|
176
|
+
heading.textContent = title;
|
|
177
|
+
const note = document.createElement("small");
|
|
178
|
+
note.innerHTML = detail;
|
|
179
|
+
wrap.append(heading, note);
|
|
180
|
+
panel.append(wrap);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function ensureTable(panelId, headers) {
|
|
184
|
+
const panel = $(panelId);
|
|
185
|
+
if (panel.querySelector("table")) return panel.querySelector("tbody");
|
|
186
|
+
const table = document.createElement("table");
|
|
187
|
+
const thead = table.createTHead().insertRow();
|
|
188
|
+
for (const header of headers) {
|
|
189
|
+
const th = document.createElement("th");
|
|
190
|
+
th.textContent = header.label;
|
|
191
|
+
if (header.num) th.className = "num";
|
|
192
|
+
thead.append(th);
|
|
193
|
+
}
|
|
194
|
+
const tbody = table.createTBody();
|
|
195
|
+
panel.replaceChildren(table);
|
|
196
|
+
return tbody;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const QUEUE_HEADERS = [{ label: "State" }, { label: "Agent" }, { label: "Tries", num: true }, { label: "Session" }, { label: "" }];
|
|
200
|
+
// One vault table where three used to stand (My vault / Restore / Free up
|
|
201
|
+
// disk were the same 53 rows wearing different verbs). Every row: what it is,
|
|
202
|
+
// where its copies live, and every action that follows from that state.
|
|
203
|
+
const ARCHIVE_HEADERS = [{ label: "" }, { label: "Archived" }, { label: "Size", num: true }, { label: "Session" }, { label: "Where the copies live" }, { label: "Held because" }, { label: "" }];
|
|
204
|
+
|
|
205
|
+
// The vault's selection — survives redraws so ticking ten rows and refreshing
|
|
206
|
+
// does not silently discard the work. Ids that stop existing are dropped.
|
|
207
|
+
const vaultChosen = new Set();
|
|
208
|
+
// Which project folders and snapshot chains a person opened — remembered
|
|
209
|
+
// across refreshes, because a redraw that re-folds everything is a redraw
|
|
210
|
+
// that loses your place.
|
|
211
|
+
const vaultOpenGroups = new Set();
|
|
212
|
+
const vaultOpenChains = new Set();
|
|
213
|
+
|
|
214
|
+
function renderOverview({ status, retention, agents, archives, disk }) {
|
|
215
|
+
$("m-archives").textContent = status.archiveCount;
|
|
216
|
+
$("m-bytes").textContent = `${bytes(status.archivedBytes)} of history kept`;
|
|
217
|
+
$("m-queue").textContent = status.queue.ready + status.queue.leased;
|
|
218
|
+
$("m-queue-detail").textContent = status.queue.failed
|
|
219
|
+
? `${status.queue.done} done · ${status.queue.failed} need attention`
|
|
220
|
+
: `${status.queue.done} archived so far`;
|
|
221
|
+
// A managed-tier machine has no `remoteStorage` of its own — the bucket is
|
|
222
|
+
// ours, provisioned by enrolment rather than typed into a form. Reading that
|
|
223
|
+
// absence as "not set up" told every paying customer their cloud storage was
|
|
224
|
+
// off while their archives were sitting in it, verified. doctor.ts fixed this
|
|
225
|
+
// and left a note calling it "a comforting falsehood"; the dashboard went on
|
|
226
|
+
// saying it, which is the wrong way round for the tier that pays.
|
|
227
|
+
if (status.remoteStorage) {
|
|
228
|
+
$("m-remote").textContent = `${status.remoteStorage.provider}://${status.remoteStorage.bucket}`;
|
|
229
|
+
$("m-remote-hint").textContent = `prefix ${status.remoteStorage.prefix}`;
|
|
230
|
+
} else if (status.plan) {
|
|
231
|
+
const verified = (archives ?? []).filter((archive) => archive.remote?.verifiedAt).length;
|
|
232
|
+
$("m-remote").textContent = "Sealkeep managed";
|
|
233
|
+
$("m-remote-hint").textContent = verified
|
|
234
|
+
? `${verified} archive${verified === 1 ? "" : "s"} verified in your storage`
|
|
235
|
+
: "included with your plan";
|
|
236
|
+
} else {
|
|
237
|
+
$("m-remote").textContent = "This disk only";
|
|
238
|
+
$("m-remote-hint").textContent = "add storage to keep a second copy";
|
|
239
|
+
}
|
|
240
|
+
$("m-reclaim").textContent = bytes(retention.reclaimableBytes);
|
|
241
|
+
// The retention page names these four in plain words; the dashboard was
|
|
242
|
+
// showing the internal slug ("policy: local-only") beneath a number.
|
|
243
|
+
$("m-policy").textContent = POLICY_NAMES[retention.policy] ?? retention.policy;
|
|
244
|
+
|
|
245
|
+
const sealed = status.archiveCount;
|
|
246
|
+
$("boundary-above").textContent = `${status.queue.ready + status.queue.leased} readable, waiting`;
|
|
247
|
+
$("boundary-below").textContent = `${sealed} sealed`;
|
|
248
|
+
|
|
249
|
+
const detected = agents.filter((agent) => agent.detected);
|
|
250
|
+
const list = detected.length ? detected : agents;
|
|
251
|
+
$("agents").replaceChildren(...list.map((agent) => {
|
|
252
|
+
const row = document.createElement("div");
|
|
253
|
+
row.className = "agent";
|
|
254
|
+
const name = document.createElement("strong");
|
|
255
|
+
name.textContent = agent.agent === "codex" ? "Codex" : "Claude Code";
|
|
256
|
+
const root = document.createElement("span");
|
|
257
|
+
root.textContent = agent.root;
|
|
258
|
+
row.append(name, root, agent.detected ? badge("found", "verified") : badge("not installed", "local"));
|
|
259
|
+
return row;
|
|
260
|
+
}));
|
|
261
|
+
|
|
262
|
+
// The headline is the disk story, because that is why anyone opened this. It
|
|
263
|
+
// used to read "2 sessions preserved" on a machine holding 79 GB of
|
|
264
|
+
// unarchived transcripts, and the 79 GB appeared nowhere on the page.
|
|
265
|
+
const onDisk = disk?.unarchivedBytes ?? 0;
|
|
266
|
+
const total = disk?.sessions?.length ?? 0;
|
|
267
|
+
const pending = disk ? disk.sessions.filter((row) => !row.archived).length : 0;
|
|
268
|
+
if (status.archiveCount === 0 && onDisk > 0) {
|
|
269
|
+
overviewHeader = {
|
|
270
|
+
headline: `${bytes(onDisk)} of agent history on this disk`,
|
|
271
|
+
lede: `${pending} session${pending === 1 ? "" : "s"}, none archived yet. `
|
|
272
|
+
+ '<a href="#sessions">Choose which to keep</a> — you pick them, and the originals stay where they are.'
|
|
273
|
+
};
|
|
274
|
+
} else if (status.archiveCount === 0) {
|
|
275
|
+
overviewHeader = {
|
|
276
|
+
headline: "Nothing preserved yet",
|
|
277
|
+
lede: 'Nothing has been archived yet. <a href="#sessions">Choose which sessions to keep</a> — '
|
|
278
|
+
+ "you pick them, and the originals stay where they are."
|
|
279
|
+
};
|
|
280
|
+
} else if (onDisk > 0) {
|
|
281
|
+
overviewHeader = {
|
|
282
|
+
headline: `${bytes(status.archivedBytes)} sealed · ${bytes(onDisk)} still on this disk`,
|
|
283
|
+
lede: `${status.archiveCount} of ${total} session${total === 1 ? "" : "s"} archived. `
|
|
284
|
+
+ `<a href="#sessions">${pending} more</a> could be, and the sealed ones restore on any machine that has your recovery kit.`
|
|
285
|
+
};
|
|
286
|
+
} else {
|
|
287
|
+
overviewHeader = {
|
|
288
|
+
headline: `${status.archiveCount} session${status.archiveCount === 1 ? "" : "s"} preserved`,
|
|
289
|
+
lede: `${bytes(status.archivedBytes)} of agent history you can restore on any machine that has your recovery kit.`
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// Only write it to the header if Overview is the tab in view. Refresh runs this
|
|
293
|
+
// on a timer regardless of which tab is open; without this guard the disk story
|
|
294
|
+
// would overwrite the Queue or Settings title the moment a refresh landed.
|
|
295
|
+
if (currentView === "overview") applyHeader();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function renderQueue(queue, retention) {
|
|
299
|
+
// The automatic side's account of itself: what the rules could reclaim and
|
|
300
|
+
// what stands in the way — the conveyor's own dashboard, above the belt.
|
|
301
|
+
const eligible = retention.candidates.filter((candidate) => candidate.eligible);
|
|
302
|
+
$("retention-headline").textContent = retention.candidates.length === 0
|
|
303
|
+
? "Nothing tracked for reclaim yet"
|
|
304
|
+
: eligible.length === 0 ? "Every source is protected" : `${bytes(retention.reclaimableBytes)} can be reclaimed`;
|
|
305
|
+
$("retention-summary").textContent = `${(POLICY_NAMES[retention.policy] ?? retention.policy).replace(/^./, (c) => c.toUpperCase())} · older than ${retention.olderThanDays} days · ${retention.graceDays}-day grace period.`;
|
|
306
|
+
$("reclaim").disabled = eligible.length === 0;
|
|
307
|
+
|
|
308
|
+
const automation = $("automation-panel");
|
|
309
|
+
const held = retention.candidates.filter((candidate) => !candidate.eligible);
|
|
310
|
+
const reasons = new Map();
|
|
311
|
+
for (const candidate of held) {
|
|
312
|
+
for (const blocker of candidate.blockers) {
|
|
313
|
+
const kind = blockerKind(blocker);
|
|
314
|
+
const entry = reasons.get(kind.chip) ?? { count: 0, size: 0, action: kind.action, example: blocker };
|
|
315
|
+
entry.count += 1;
|
|
316
|
+
entry.size += candidate.bytes;
|
|
317
|
+
reasons.set(kind.chip, entry);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
automation.hidden = reasons.size === 0;
|
|
321
|
+
automation.replaceChildren();
|
|
322
|
+
if (reasons.size > 0) {
|
|
323
|
+
const intro = document.createElement("p");
|
|
324
|
+
intro.className = "note";
|
|
325
|
+
intro.textContent = `The automatic rules hold ${held.length} source${held.length === 1 ? "" : "s"} (${bytes(held.reduce((sum, c) => sum + c.bytes, 0))}). Why:`;
|
|
326
|
+
automation.append(intro);
|
|
327
|
+
const ACTIONS = {
|
|
328
|
+
upload: () => { const a = document.createElement("a"); a.href = "#queue"; a.textContent = "Upload pending sends the copies"; a.onclick = (e) => { e.preventDefault(); $("upload").click(); }; return a; },
|
|
329
|
+
rules: () => { const a = document.createElement("a"); a.href = "#rules"; a.textContent = "change the rules"; return a; },
|
|
330
|
+
age: () => { const span = document.createElement("span"); span.textContent = "passes on its own"; return span; }
|
|
331
|
+
};
|
|
332
|
+
for (const [chip, entry] of reasons) {
|
|
333
|
+
const line = document.createElement("p");
|
|
334
|
+
line.className = "note hold-line";
|
|
335
|
+
line.title = entry.example;
|
|
336
|
+
const label = document.createElement("b");
|
|
337
|
+
label.textContent = chip;
|
|
338
|
+
line.append(label, ` — ${entry.count} session${entry.count === 1 ? "" : "s"}, ${bytes(entry.size)} · `);
|
|
339
|
+
if (entry.action && ACTIONS[entry.action]) line.append(ACTIONS[entry.action]());
|
|
340
|
+
automation.append(line);
|
|
341
|
+
}
|
|
342
|
+
const manual = document.createElement("p");
|
|
343
|
+
manual.className = "note";
|
|
344
|
+
manual.textContent = "Or take rows in My vault yourself: check the copy, then free the disk — that click is the approval.";
|
|
345
|
+
automation.append(manual);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// A done job lives in Archives; leaving it in the queue list means a finished
|
|
349
|
+
// session sits forever beside ones still waiting, under a heading that says
|
|
350
|
+
// they are waiting. This view is only what is in flight or needs another try.
|
|
351
|
+
const active = queue.filter((job) => job.status !== "done");
|
|
352
|
+
$("count-queue").textContent = active.length || "";
|
|
353
|
+
if (active.length === 0) {
|
|
354
|
+
showEmpty("queue-panel", "Nothing waiting", 'Sessions land here when an agent hook fires, or when you <a href="#sessions">choose some to archive</a>.');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const tbody = ensureTable("queue-panel", QUEUE_HEADERS);
|
|
358
|
+
tbody.replaceChildren();
|
|
359
|
+
for (const job of active) {
|
|
360
|
+
const row = tbody.insertRow();
|
|
361
|
+
// The state cell says what is actually happening, in a person's words:
|
|
362
|
+
// a working job shows the % of the file read so far, a waiting one says
|
|
363
|
+
// why it waits, a failed one says what went wrong. `badge(status)` alone
|
|
364
|
+
// was the system talking to itself.
|
|
365
|
+
const state = row.insertCell();
|
|
366
|
+
if (job.status === "leased") {
|
|
367
|
+
const pct = job.progress && job.progress.of > 0 ? Math.min(99, Math.floor((job.progress.bytes / job.progress.of) * 100)) : null;
|
|
368
|
+
state.innerHTML = `<span class="queued">sealing…${pct !== null ? ` ${pct}%` : ""}</span>`;
|
|
369
|
+
if (pct !== null) state.title = `${bytes(job.progress.bytes)} of ${bytes(job.progress.of)} read`;
|
|
370
|
+
} else if (job.status === "failed") {
|
|
371
|
+
state.innerHTML = '<span class="bad">failed</span>';
|
|
372
|
+
if (job.lastError) state.title = job.lastError.message ?? String(job.lastError);
|
|
373
|
+
} else if (job.lastError) {
|
|
374
|
+
const span = document.createElement("span");
|
|
375
|
+
span.className = "muted";
|
|
376
|
+
span.textContent = "waiting for room";
|
|
377
|
+
span.title = job.lastError.message ?? String(job.lastError);
|
|
378
|
+
state.append(span);
|
|
379
|
+
} else {
|
|
380
|
+
state.innerHTML = '<span class="muted">queued</span>';
|
|
381
|
+
}
|
|
382
|
+
cell(row, job.agent);
|
|
383
|
+
cell(row, String(job.attempts), "num");
|
|
384
|
+
cell(row, shortPath(job.sourcePath), "path");
|
|
385
|
+
// A waiting job can simply be taken back out — the session file is
|
|
386
|
+
// untouched and queueing it again is one click. The queue refuses the
|
|
387
|
+
// cases where "remove" would mean something bigger, and its refusal
|
|
388
|
+
// message is shown as-is.
|
|
389
|
+
const act = row.insertCell();
|
|
390
|
+
if (job.status !== "leased") {
|
|
391
|
+
const remove = document.createElement("button");
|
|
392
|
+
remove.type = "button";
|
|
393
|
+
remove.className = "action";
|
|
394
|
+
remove.textContent = "Remove";
|
|
395
|
+
remove.onclick = async () => {
|
|
396
|
+
remove.disabled = true;
|
|
397
|
+
try { await api(`/v1/queue/${encodeURIComponent(job.id)}`, { method: "DELETE" }); await guard(refresh); }
|
|
398
|
+
catch (error) { remove.disabled = false; setStatus(error.message, "warn"); }
|
|
399
|
+
};
|
|
400
|
+
act.append(remove);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The one button that hands over the restore command, shared by My vault and
|
|
407
|
+
* the Restore view so the two can never drift. Restoring stays a deliberate
|
|
408
|
+
* command run in a terminal — this page cannot decrypt and never will (the
|
|
409
|
+
* API refuses the phrase by design). What a row CAN do is hand over the exact
|
|
410
|
+
* command, filled in, so "how do I get this back" is a paste instead of a
|
|
411
|
+
* manual.
|
|
412
|
+
*/
|
|
413
|
+
function restoreButton(archive) {
|
|
414
|
+
const hand = document.createElement("button");
|
|
415
|
+
hand.type = "button";
|
|
416
|
+
hand.className = "action";
|
|
417
|
+
hand.textContent = "Restore…";
|
|
418
|
+
hand.title = "Copies the terminal command that restores this session";
|
|
419
|
+
hand.onclick = async () => {
|
|
420
|
+
const name = (archive.path || archive.id).split("/").pop();
|
|
421
|
+
// A reclaimed source is the resume-broken case: the agent's catalog still
|
|
422
|
+
// points at the old path, so the command that helps is the one that puts
|
|
423
|
+
// the file BACK THERE — not a copy on the Desktop.
|
|
424
|
+
const command = archive.reclaimed
|
|
425
|
+
? `sealkeep recover ${archive.id} --native`
|
|
426
|
+
: `sealkeep recover ${archive.id} ~/Desktop/${name}`;
|
|
427
|
+
try { await navigator.clipboard.writeText(command); hand.textContent = "Copied"; }
|
|
428
|
+
catch { window.prompt("Run this in a terminal:", command); }
|
|
429
|
+
setTimeout(() => { hand.textContent = "Restore…"; }, 2000);
|
|
430
|
+
};
|
|
431
|
+
return hand;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function renderArchives(archives, retention) {
|
|
435
|
+
$("count-archives").textContent = archives.length || "";
|
|
436
|
+
if (archives.length === 0) {
|
|
437
|
+
showEmpty("archives-panel", "No archives yet", 'Nothing has been sealed on this machine. <a href="#sessions">Choose sessions to archive</a> and they appear here.');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const panel = $("archives-panel");
|
|
442
|
+
panel.replaceChildren();
|
|
443
|
+
|
|
444
|
+
// Selection that outlived its rows is not a selection.
|
|
445
|
+
const liveIds = new Set(archives.map((archive) => archive.id));
|
|
446
|
+
for (const id of [...vaultChosen]) if (!liveIds.has(id)) vaultChosen.delete(id);
|
|
447
|
+
|
|
448
|
+
// The bulk bar: pick rows, then act on all of them at once. The dangerous
|
|
449
|
+
// verbs keep the armed two-step and say what they are about to do; bulk
|
|
450
|
+
// delete deliberately never destroys a last copy — those rows are reported
|
|
451
|
+
// and left for the row's own button, where "forever" can be said properly.
|
|
452
|
+
const bar = document.createElement("div");
|
|
453
|
+
bar.className = "row";
|
|
454
|
+
bar.style.margin = "0 0 .6rem";
|
|
455
|
+
const barCount = document.createElement("span");
|
|
456
|
+
barCount.className = "note";
|
|
457
|
+
const barActions = document.createElement("div");
|
|
458
|
+
barActions.className = "actions";
|
|
459
|
+
const byId = new Map(archives.map((archive) => [archive.id, archive]));
|
|
460
|
+
const chosenList = () => [...vaultChosen].map((id) => byId.get(id)).filter(Boolean);
|
|
461
|
+
|
|
462
|
+
const barWarning = document.createElement("p");
|
|
463
|
+
barWarning.className = "note act-note";
|
|
464
|
+
barWarning.hidden = true;
|
|
465
|
+
|
|
466
|
+
const updateBar = () => {
|
|
467
|
+
const rows = chosenList();
|
|
468
|
+
const total = rows.reduce((sum, archive) => sum + archive.bytes, 0);
|
|
469
|
+
barCount.textContent = rows.length === 0 ? "Tick rows to act on several at once." : `${rows.length} selected · ${bytes(total)}`;
|
|
470
|
+
for (const button of barActions.children) button.disabled = rows.length === 0;
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const bulkCheck = document.createElement("button");
|
|
474
|
+
bulkCheck.type = "button";
|
|
475
|
+
bulkCheck.className = "action";
|
|
476
|
+
bulkCheck.textContent = "Check copies";
|
|
477
|
+
bulkCheck.title = "Asks your storage, right now, about every selected archive";
|
|
478
|
+
bulkCheck.onclick = async () => {
|
|
479
|
+
const ids = [...vaultChosen];
|
|
480
|
+
bulkCheck.disabled = true;
|
|
481
|
+
bulkCheck.textContent = "Asking…";
|
|
482
|
+
try {
|
|
483
|
+
const { results } = await api("/v1/archives/check-copies", { method: "POST", body: JSON.stringify({ ids }) });
|
|
484
|
+
const bad = results.filter((item) => !item.ok);
|
|
485
|
+
setStatus(bad.length === 0
|
|
486
|
+
? `Your storage answered for all ${results.length}: every copy is there at its recorded size.`
|
|
487
|
+
: `${results.length - bad.length} confirmed · ${bad.length} NOT confirmed — do not trash those originals. First problem: ${bad[0].detail}`,
|
|
488
|
+
bad.length === 0 ? "ok" : "warn");
|
|
489
|
+
} catch (error) {
|
|
490
|
+
setStatus(`Could not check: ${error.message}`, "warn");
|
|
491
|
+
} finally {
|
|
492
|
+
bulkCheck.textContent = "Check copies";
|
|
493
|
+
updateBar();
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const bulkFree = document.createElement("button");
|
|
498
|
+
bulkFree.type = "button";
|
|
499
|
+
bulkFree.className = "action";
|
|
500
|
+
bulkFree.textContent = "Free up disk";
|
|
501
|
+
bulkFree.title = "Moves every selected original to your trash — each one only if its cloud copy is verified, its bytes unchanged, and nothing is using it";
|
|
502
|
+
bulkFree.onclick = async () => {
|
|
503
|
+
if (bulkFree.dataset.armed !== "yes") {
|
|
504
|
+
bulkFree.dataset.armed = "yes";
|
|
505
|
+
bulkFree.textContent = "To trash?";
|
|
506
|
+
barWarning.hidden = false;
|
|
507
|
+
barWarning.textContent = `Moves ${vaultChosen.size} original session file${vaultChosen.size === 1 ? "" : "s"} to your trash. Each row is checked first — no verified cloud copy, changed bytes, or a file in use means that row is kept and says why. Archives are never touched.`;
|
|
508
|
+
setTimeout(() => { if (bulkFree.dataset.armed === "yes") { bulkFree.dataset.armed = "no"; bulkFree.textContent = "Free up disk"; barWarning.hidden = true; } }, 6000);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
bulkFree.dataset.armed = "no";
|
|
512
|
+
barWarning.hidden = true;
|
|
513
|
+
bulkFree.disabled = true;
|
|
514
|
+
try {
|
|
515
|
+
const outcome = await api("/v1/archives/reclaim-batch", { method: "POST", body: JSON.stringify({ ids: [...vaultChosen], confirm: true }) });
|
|
516
|
+
setStatus(`${outcome.reclaimed.length} moved to trash · ${bytes(outcome.freedBytes)} freed${outcome.kept.length ? ` · ${outcome.kept.length} kept — first reason: ${outcome.kept[0].reason}` : ""}`, outcome.kept.length ? "warn" : "ok");
|
|
517
|
+
vaultChosen.clear();
|
|
518
|
+
await guard(refresh);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
bulkFree.textContent = "Free up disk";
|
|
521
|
+
setStatus(`Nothing moved: ${error.message}`, "warn");
|
|
522
|
+
updateBar();
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const bulkDelete = document.createElement("button");
|
|
527
|
+
bulkDelete.type = "button";
|
|
528
|
+
bulkDelete.className = "action danger";
|
|
529
|
+
bulkDelete.textContent = "Delete from cloud";
|
|
530
|
+
bulkDelete.title = "Removes every selected archive — from your cloud and this machine. Last copies are skipped, never bulk-destroyed.";
|
|
531
|
+
bulkDelete.onclick = async () => {
|
|
532
|
+
if (bulkDelete.dataset.armed !== "yes") {
|
|
533
|
+
bulkDelete.dataset.armed = "yes";
|
|
534
|
+
bulkDelete.textContent = "Confirm delete";
|
|
535
|
+
barWarning.hidden = false;
|
|
536
|
+
barWarning.textContent = `Removes ${vaultChosen.size} archive${vaultChosen.size === 1 ? "" : "s"} — the cloud copies and everything Sealkeep holds. A row whose original session file is gone is the ONLY copy of that session: bulk delete refuses those and leaves them for their own row, where deleting forever has to be said to that one session.`;
|
|
537
|
+
setTimeout(() => { if (bulkDelete.dataset.armed === "yes") { bulkDelete.dataset.armed = "no"; bulkDelete.textContent = "Delete from cloud"; barWarning.hidden = true; } }, 6000);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
bulkDelete.dataset.armed = "no";
|
|
541
|
+
barWarning.hidden = true;
|
|
542
|
+
bulkDelete.disabled = true;
|
|
543
|
+
let deleted = 0;
|
|
544
|
+
const keptForever = [];
|
|
545
|
+
const failedRows = [];
|
|
546
|
+
for (const id of [...vaultChosen]) {
|
|
547
|
+
try {
|
|
548
|
+
await api(`/v1/archives/${encodeURIComponent(id)}`, { method: "DELETE", body: JSON.stringify({ confirm: true }) });
|
|
549
|
+
deleted += 1;
|
|
550
|
+
vaultChosen.delete(id);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
if (error.forever) keptForever.push(id);
|
|
553
|
+
else failedRows.push(error.message);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
setStatus(`${deleted} deleted${keptForever.length ? ` · ${keptForever.length} kept: the only copy of their session — delete those row by row, deliberately` : ""}${failedRows.length ? ` · ${failedRows.length} failed: ${failedRows[0]}` : ""}`, keptForever.length || failedRows.length ? "warn" : "ok");
|
|
557
|
+
await guard(refresh);
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
barActions.append(bulkCheck, bulkFree, bulkDelete);
|
|
561
|
+
bar.append(barCount, barActions);
|
|
562
|
+
panel.append(bar, barWarning);
|
|
563
|
+
updateBar();
|
|
564
|
+
|
|
565
|
+
const table = document.createElement("table");
|
|
566
|
+
const thead = table.createTHead().insertRow();
|
|
567
|
+
for (const header of ARCHIVE_HEADERS) {
|
|
568
|
+
const th = document.createElement("th");
|
|
569
|
+
th.textContent = header.label;
|
|
570
|
+
if (header.num) th.className = "num";
|
|
571
|
+
thead.append(th);
|
|
572
|
+
}
|
|
573
|
+
const tbody = table.createTBody();
|
|
574
|
+
const candidates = new Map(retention.candidates.map((candidate) => [candidate.id, candidate]));
|
|
575
|
+
|
|
576
|
+
const renderArchiveRow = (archive, indented) => {
|
|
577
|
+
|
|
578
|
+
const candidate = candidates.get(archive.id) ?? null;
|
|
579
|
+
const row = tbody.insertRow();
|
|
580
|
+
if (indented) row.className = "chain-row";
|
|
581
|
+
const tick = row.insertCell();
|
|
582
|
+
const box = document.createElement("input");
|
|
583
|
+
box.type = "checkbox";
|
|
584
|
+
box.checked = vaultChosen.has(archive.id);
|
|
585
|
+
box.onchange = () => { box.checked ? vaultChosen.add(archive.id) : vaultChosen.delete(archive.id); updateBar(); };
|
|
586
|
+
tick.append(box);
|
|
587
|
+
cell(row, ago(archive.createdAt));
|
|
588
|
+
cell(row, bytes(archive.bytes), "num");
|
|
589
|
+
const session = cell(row, String(archive.path || archive.id).split("/").pop(), "path");
|
|
590
|
+
session.title = archive.path || archive.id;
|
|
591
|
+
|
|
592
|
+
// Where the copies live — the cloud copy first because it is the one that
|
|
593
|
+
// survives this disk, then what is still ON this disk.
|
|
594
|
+
const where = row.insertCell();
|
|
595
|
+
const cloud = archive.remote
|
|
596
|
+
? (archive.offloaded ? badge("in your cloud", "verified") : badge("here + cloud", "verified"))
|
|
597
|
+
: badge("this disk only", "local");
|
|
598
|
+
cloud.title = archive.remote
|
|
599
|
+
? (archive.offloaded
|
|
600
|
+
? `Verified in your storage ${ago(archive.remote.verifiedAt)} — the local sealed copy was given back to the disk. Restore fetches it.`
|
|
601
|
+
: `Verified in your storage ${ago(archive.remote.verifiedAt)}; a sealed copy is also on this machine.`)
|
|
602
|
+
: "No verified cloud copy yet — this archive is lost with this disk.";
|
|
603
|
+
where.append(cloud);
|
|
604
|
+
if (archive.reclaimed) {
|
|
605
|
+
const src = badge("source reclaimed", "free");
|
|
606
|
+
src.title = "The original file went to your trash after its copy was verified.";
|
|
607
|
+
where.append(src);
|
|
608
|
+
} else if (candidate) {
|
|
609
|
+
const src = badge("source on disk", "local");
|
|
610
|
+
src.title = "The original session file is still on this machine.";
|
|
611
|
+
where.append(src);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Why the AUTOMATIC pass holds this one. The manual buttons on the right
|
|
615
|
+
// are how a person overrules the soft reasons; the hard ones (no cloud
|
|
616
|
+
// copy, file changed, file in use) refuse there too, with a sentence.
|
|
617
|
+
const why = row.insertCell();
|
|
618
|
+
if (!candidate || candidate.eligible) { why.textContent = "—"; why.className = "muted"; }
|
|
619
|
+
else for (const blocker of candidate.blockers) {
|
|
620
|
+
const chip = document.createElement("span");
|
|
621
|
+
chip.className = "hold-chip";
|
|
622
|
+
chip.textContent = blockerKind(blocker).chip;
|
|
623
|
+
chip.title = blocker;
|
|
624
|
+
why.append(chip);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const act = row.insertCell();
|
|
628
|
+
act.append(restoreButton(archive));
|
|
629
|
+
|
|
630
|
+
// "Is my backup really there?" — asked of the storage, right now, not of
|
|
631
|
+
// our own records. The answer arrives as a sentence on the status line
|
|
632
|
+
// and re-labels the button for a moment.
|
|
633
|
+
if (archive.remote) {
|
|
634
|
+
const check = document.createElement("button");
|
|
635
|
+
check.type = "button";
|
|
636
|
+
check.className = "action";
|
|
637
|
+
check.textContent = "Check copy";
|
|
638
|
+
check.title = "Asks your storage, right now, whether it still holds this archive at the recorded size";
|
|
639
|
+
check.onclick = async () => {
|
|
640
|
+
check.disabled = true;
|
|
641
|
+
check.textContent = "Asking…";
|
|
642
|
+
try {
|
|
643
|
+
const outcome = await api(`/v1/archives/${encodeURIComponent(archive.id)}/check-copy`, { method: "POST" });
|
|
644
|
+
check.textContent = outcome.ok ? "Copy is there ✓" : "Missing!";
|
|
645
|
+
check.classList.toggle("danger", !outcome.ok);
|
|
646
|
+
setStatus(outcome.detail, outcome.ok ? "ok" : "warn");
|
|
647
|
+
} catch (error) {
|
|
648
|
+
check.textContent = "Check copy";
|
|
649
|
+
setStatus(`Could not check: ${error.message}`, "warn");
|
|
650
|
+
} finally {
|
|
651
|
+
check.disabled = false;
|
|
652
|
+
setTimeout(() => { check.textContent = "Check copy"; check.classList.remove("danger"); }, 4000);
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
act.append(check);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Free THIS source — the armed two-step, per row. The server holds the
|
|
659
|
+
// hard gates (verified copy incl. delta chains, unchanged bytes, not in
|
|
660
|
+
// use) and refuses with the reason; policy, age and grace are what this
|
|
661
|
+
// button exists to overrule, so they are not consulted.
|
|
662
|
+
if (candidate && !archive.reclaimed) {
|
|
663
|
+
const free = document.createElement("button");
|
|
664
|
+
free.type = "button";
|
|
665
|
+
free.className = "action";
|
|
666
|
+
free.textContent = "Free up disk";
|
|
667
|
+
free.title = archive.remote
|
|
668
|
+
? "Moves the original session file to your trash — the verified cloud copy is the archive"
|
|
669
|
+
: "Needs a verified cloud copy first — right now this disk holds the only bytes";
|
|
670
|
+
free.disabled = !archive.remote;
|
|
671
|
+
free.onclick = async () => {
|
|
672
|
+
if (free.dataset.armed !== "yes") {
|
|
673
|
+
free.dataset.armed = "yes";
|
|
674
|
+
free.textContent = "To trash?";
|
|
675
|
+
setTimeout(() => { if (free.dataset.armed === "yes") { free.dataset.armed = "no"; free.textContent = "Free up disk"; } }, 4000);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
free.dataset.armed = "no";
|
|
679
|
+
free.disabled = true;
|
|
680
|
+
try {
|
|
681
|
+
const outcome = await api(`/v1/archives/${encodeURIComponent(archive.id)}/reclaim`, { method: "POST", body: JSON.stringify({ confirm: true }) });
|
|
682
|
+
setStatus(`Moved to trash: ${bytes(outcome.reclaimed.bytes)} freed. The archive is untouched.`, "ok");
|
|
683
|
+
await guard(refresh);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
free.disabled = false;
|
|
686
|
+
free.textContent = "Free up disk";
|
|
687
|
+
setStatus(`Kept: ${error.message}`, "warn");
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
act.append(free);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Delete, renamed to what it actually removes, with the danger described
|
|
694
|
+
// BEFORE the second click instead of learned from a refusal. The armed
|
|
695
|
+
// two-step stays; arming now also shows a sentence naming exactly what
|
|
696
|
+
// goes and what remains. When the original session file is already gone
|
|
697
|
+
// (`reclaimed` says so), the first click is already the forever warning —
|
|
698
|
+
// this cloud copy is the only form the session exists in anywhere, and
|
|
699
|
+
// deleting it cannot be undone by anyone. The server's own stat-based
|
|
700
|
+
// refusal stays underneath for sources deleted by hand.
|
|
701
|
+
const cloudy = !!archive.remote;
|
|
702
|
+
const foreverUpfront = !!archive.reclaimed;
|
|
703
|
+
const del = document.createElement("button");
|
|
704
|
+
del.type = "button";
|
|
705
|
+
del.className = "action danger";
|
|
706
|
+
const delRestLabel = () => (foreverUpfront || del.dataset.forever === "yes" ? "Delete forever" : cloudy ? "Delete from cloud" : "Delete");
|
|
707
|
+
del.textContent = delRestLabel();
|
|
708
|
+
const delWarning = document.createElement("span");
|
|
709
|
+
delWarning.className = "act-note";
|
|
710
|
+
delWarning.hidden = true;
|
|
711
|
+
const describeDelete = () => {
|
|
712
|
+
if (foreverUpfront || del.dataset.forever === "yes") {
|
|
713
|
+
return "DANGER: the original file is gone, so this cloud copy is the ONLY copy of this session anywhere. Deleting it destroys the session forever — nothing can bring it back.";
|
|
714
|
+
}
|
|
715
|
+
if (cloudy && archive.offloaded) {
|
|
716
|
+
return "Removes the copy from your cloud — the only sealed copy. The original session file is still on this disk, so you can seal it again.";
|
|
717
|
+
}
|
|
718
|
+
if (cloudy) {
|
|
719
|
+
return "Removes the sealed copies from your cloud and this machine. The original session file stays on this disk.";
|
|
720
|
+
}
|
|
721
|
+
return "Removes the only sealed copy, which is on this machine. The original session file stays on this disk.";
|
|
722
|
+
};
|
|
723
|
+
del.onclick = async () => {
|
|
724
|
+
if (del.dataset.armed !== "yes") {
|
|
725
|
+
del.dataset.armed = "yes";
|
|
726
|
+
del.textContent = foreverUpfront || del.dataset.forever === "yes" ? "Destroy forever?" : "Confirm delete";
|
|
727
|
+
delWarning.textContent = describeDelete();
|
|
728
|
+
delWarning.hidden = false;
|
|
729
|
+
setTimeout(() => { if (del.dataset.armed === "yes") { del.dataset.armed = "no"; del.textContent = delRestLabel(); delWarning.hidden = true; } }, 6000);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
del.dataset.armed = "no";
|
|
733
|
+
delWarning.hidden = true;
|
|
734
|
+
del.disabled = true;
|
|
735
|
+
try {
|
|
736
|
+
await api(`/v1/archives/${encodeURIComponent(archive.id)}`, {
|
|
737
|
+
method: "DELETE",
|
|
738
|
+
body: JSON.stringify({ confirm: true, confirmForever: foreverUpfront || del.dataset.forever === "yes" })
|
|
739
|
+
});
|
|
740
|
+
await guard(refresh);
|
|
741
|
+
} catch (error) {
|
|
742
|
+
del.disabled = false;
|
|
743
|
+
if (error.forever) {
|
|
744
|
+
// The server knows something the record did not say: the source is
|
|
745
|
+
// gone. Re-arm with the forever words and the loud description.
|
|
746
|
+
del.dataset.forever = "yes";
|
|
747
|
+
del.textContent = "Delete forever";
|
|
748
|
+
delWarning.textContent = describeDelete();
|
|
749
|
+
delWarning.hidden = false;
|
|
750
|
+
setStatus("The original file is gone — this archive is the only copy of that session. Deleting it is forever.", "warn");
|
|
751
|
+
} else {
|
|
752
|
+
del.textContent = delRestLabel();
|
|
753
|
+
setStatus(error.message, "warn");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
act.append(del, delWarning);
|
|
758
|
+
return row;
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// ---- Folder-shaped vault -------------------------------------------------
|
|
762
|
+
// A vault is not a flat list of objects; it is sessions inside projects,
|
|
763
|
+
// and a session may be a CHAIN of snapshots (a full plus the deltas sealed
|
|
764
|
+
// since). One row per session, its earlier snapshots folded behind a count,
|
|
765
|
+
// sessions folded into their project the way the Memory pool names them.
|
|
766
|
+
const bySession = new Map();
|
|
767
|
+
for (const archive of archives) {
|
|
768
|
+
const key = archive.path || archive.id;
|
|
769
|
+
if (!bySession.has(key)) bySession.set(key, []);
|
|
770
|
+
bySession.get(key).push(archive);
|
|
771
|
+
}
|
|
772
|
+
const chains = [...bySession.values()].map((chain) => [...chain].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)));
|
|
773
|
+
chains.sort((a, b) => b[0].bytes - a[0].bytes);
|
|
774
|
+
|
|
775
|
+
const renderChain = (chain) => {
|
|
776
|
+
renderArchiveRow(chain[0], false);
|
|
777
|
+
if (chain.length > 1) {
|
|
778
|
+
const headId = chain[0].id;
|
|
779
|
+
const open = vaultOpenChains.has(headId);
|
|
780
|
+
const toggleRow = tbody.insertRow();
|
|
781
|
+
toggleRow.className = "chain-fold";
|
|
782
|
+
const cellSpan = toggleRow.insertCell();
|
|
783
|
+
cellSpan.colSpan = ARCHIVE_HEADERS.length;
|
|
784
|
+
const toggle = document.createElement("button");
|
|
785
|
+
toggle.type = "button";
|
|
786
|
+
toggle.className = "linkish";
|
|
787
|
+
toggle.textContent = `${open ? "▾" : "▸"} ${chain.length - 1} earlier snapshot${chain.length === 2 ? "" : "s"} of this session`;
|
|
788
|
+
toggle.title = "A session archived more than once is a chain: the newest snapshot above, the ones it grew from here.";
|
|
789
|
+
toggle.onclick = () => { open ? vaultOpenChains.delete(headId) : vaultOpenChains.add(headId); redrawTable(); };
|
|
790
|
+
cellSpan.append(toggle);
|
|
791
|
+
if (open) for (const older of chain.slice(1)) renderArchiveRow(older, true);
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
const byProject = new Map();
|
|
796
|
+
for (const chain of chains) {
|
|
797
|
+
const key = chain[0].project ?? "";
|
|
798
|
+
if (!byProject.has(key)) byProject.set(key, { name: chain[0].project, path: chain[0].projectPath, chains: [] });
|
|
799
|
+
byProject.get(key).chains.push(chain);
|
|
800
|
+
}
|
|
801
|
+
const groups = [...byProject.values()].map((group) => ({
|
|
802
|
+
...group,
|
|
803
|
+
bytes: group.chains.reduce((sum, chain) => sum + chain[0].bytes, 0),
|
|
804
|
+
stored: group.chains.filter((chain) => chain[0].remote).length
|
|
805
|
+
})).sort((a, b) => b.bytes - a.bytes);
|
|
806
|
+
|
|
807
|
+
const redrawTable = () => {
|
|
808
|
+
tbody.replaceChildren();
|
|
809
|
+
// One project (or none named): folders would be ceremony. Flat it stays.
|
|
810
|
+
if (groups.length <= 1) {
|
|
811
|
+
for (const chain of chains) renderChain(chain);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
for (const group of groups) {
|
|
815
|
+
const key = group.name ?? "";
|
|
816
|
+
const open = vaultOpenGroups.has(key);
|
|
817
|
+
const header = tbody.insertRow();
|
|
818
|
+
header.className = "group-row";
|
|
819
|
+
const tick = header.insertCell();
|
|
820
|
+
const box = document.createElement("input");
|
|
821
|
+
box.type = "checkbox";
|
|
822
|
+
const headIds = group.chains.map((chain) => chain[0].id);
|
|
823
|
+
box.checked = headIds.every((id) => vaultChosen.has(id));
|
|
824
|
+
box.title = "Select every session in this project";
|
|
825
|
+
box.onchange = () => {
|
|
826
|
+
for (const id of headIds) box.checked ? vaultChosen.add(id) : vaultChosen.delete(id);
|
|
827
|
+
updateBar();
|
|
828
|
+
redrawTable();
|
|
829
|
+
};
|
|
830
|
+
tick.append(box);
|
|
831
|
+
const label = header.insertCell();
|
|
832
|
+
label.colSpan = ARCHIVE_HEADERS.length - 1;
|
|
833
|
+
const name = document.createElement("b");
|
|
834
|
+
name.textContent = `${open ? "▾" : "▸"} ${group.name ?? "No project"}`;
|
|
835
|
+
const meta = document.createElement("span");
|
|
836
|
+
meta.className = "muted";
|
|
837
|
+
meta.textContent = ` ${group.path ? group.path.replace(/^\/(?:Users|home)\/[^/]+/, "~") + " · " : ""}${group.chains.length} session${group.chains.length === 1 ? "" : "s"} · ${bytes(group.bytes)} · ${group.stored === group.chains.length ? "all in your cloud" : `${group.stored} of ${group.chains.length} in your cloud`}`;
|
|
838
|
+
label.append(name, meta);
|
|
839
|
+
label.style.cursor = "pointer";
|
|
840
|
+
label.onclick = () => { open ? vaultOpenGroups.delete(key) : vaultOpenGroups.add(key); redrawTable(); };
|
|
841
|
+
if (open) for (const chain of group.chains) renderChain(chain);
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
redrawTable();
|
|
845
|
+
panel.append(table);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* A blocker sentence, folded to the two words a chip can carry, and the action
|
|
850
|
+
* that clears it. The full sentence stays on the tooltip and in the summary —
|
|
851
|
+
* what left the screen is the 48th repetition, not the information.
|
|
852
|
+
*/
|
|
853
|
+
function blockerKind(text) {
|
|
854
|
+
if (/not yet uploaded/.test(text)) return { chip: "no cloud copy", action: "upload" };
|
|
855
|
+
if (/keep everything|retention is set/.test(text)) return { chip: "policy keeps all", action: "rules" };
|
|
856
|
+
if (/younger than|threshold/.test(text)) return { chip: "too young", action: "age" };
|
|
857
|
+
if (/grace/.test(text)) return { chip: "in grace period", action: "age" };
|
|
858
|
+
if (/in use|using the file|open/.test(text)) return { chip: "file in use", action: null };
|
|
859
|
+
return { chip: text.length > 24 ? `${text.slice(0, 22)}…` : text, action: null };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** The background service's own account of itself, so "is it working?" is answerable. */
|
|
863
|
+
function renderDaemon(daemon) {
|
|
864
|
+
const pill = $("daemon");
|
|
865
|
+
pill.hidden = false;
|
|
866
|
+
const tone = daemon.state === "running" ? "ok" : daemon.state === "late" ? "warn" : "";
|
|
867
|
+
const totals = daemon.totals;
|
|
868
|
+
const summary = totals && (totals.archived || totals.reclaimed)
|
|
869
|
+
? ` · ${totals.archived} sealed, ${totals.reclaimed} reclaimed since it started`
|
|
870
|
+
: "";
|
|
871
|
+
pill.className = `pill ${tone}`.trim();
|
|
872
|
+
prose(pill, `${daemon.detail}${summary}`);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function renderHealth(doctor) {
|
|
876
|
+
// Problems first. Two warnings — "no sealed key backup" and "recovery phrase
|
|
877
|
+
// not stored" — used to sit in reading order among eight passes with only a
|
|
878
|
+
// 3px border to tell them apart, on a page whose whole job is to surface
|
|
879
|
+
// exactly those two.
|
|
880
|
+
const rank = { fail: 0, warn: 1, pass: 2 };
|
|
881
|
+
const ordered = [...doctor.checks].sort((a, b) => (rank[a.status] ?? 3) - (rank[b.status] ?? 3));
|
|
882
|
+
const attention = ordered.filter((check) => check.status !== "pass").length;
|
|
883
|
+
$("count-health").textContent = attention || "";
|
|
884
|
+
|
|
885
|
+
$("checks").replaceChildren(...ordered.map((check) => {
|
|
886
|
+
const row = document.createElement("div");
|
|
887
|
+
row.className = `check check-${check.status}`;
|
|
888
|
+
const label = document.createElement("strong");
|
|
889
|
+
label.textContent = CHECK_LABELS[check.name] ?? check.name;
|
|
890
|
+
const detail = document.createElement("span");
|
|
891
|
+
prose(detail, check.detail);
|
|
892
|
+
row.append(label, detail);
|
|
893
|
+
return row;
|
|
894
|
+
}));
|
|
895
|
+
// The footer keeps its own sentence. It used to be overwritten with the crypto
|
|
896
|
+
// check's detail, so the last line of every page became "…not independently
|
|
897
|
+
// audited" — an honest fact that belongs in Health with context, not standing
|
|
898
|
+
// under the archive count as a disclaimer.
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Whether this machine can seal on its own, and the way to make it so.
|
|
903
|
+
*
|
|
904
|
+
* A vault made from the terminal used to look finished and not be: the
|
|
905
|
+
* Archive button answered "no stored recovery phrase" and offered a command.
|
|
906
|
+
* This is the button.
|
|
907
|
+
*/
|
|
908
|
+
/**
|
|
909
|
+
* The account card in the rail: who this is, and the two facts a customer
|
|
910
|
+
* checks at a glance.
|
|
911
|
+
*
|
|
912
|
+
* The plan badge is the first acknowledgement anywhere in the chrome that a
|
|
913
|
+
* person pays; Pro gets the animated one, free gets the plain one, and a signed-
|
|
914
|
+
* out local vault gets "Local" rather than a blank. Below it: whether this
|
|
915
|
+
* machine can seal unattended, and where archives go — the two things a person
|
|
916
|
+
* wants confirmed without opening a tab.
|
|
917
|
+
*/
|
|
918
|
+
// The card itself lives in rail.js, shared with the Sessions and Free-up-disk
|
|
919
|
+
// rules pages — keeping a private copy here is how the rail lost its top on
|
|
920
|
+
// every page change. This wrapper only reuses the status the dashboard already has.
|
|
921
|
+
async function renderAccountCard(status) {
|
|
922
|
+
await renderAccountRail(api, status);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function renderRemember() {
|
|
926
|
+
try {
|
|
927
|
+
const state = await api("/v1/setup/state");
|
|
928
|
+
const held = Boolean(state.vault?.phraseRemembered);
|
|
929
|
+
$("remember-state").textContent = held
|
|
930
|
+
? "This machine holds the recovery phrase, so archiving runs without you typing it."
|
|
931
|
+
: "This machine does not hold the recovery phrase. It can only archive when you type the phrase in — or store it here once.";
|
|
932
|
+
$("remember-form").hidden = held;
|
|
933
|
+
} catch (error) {
|
|
934
|
+
$("remember-state").textContent = `Could not check: ${error.message}`;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Whether this machine seals on its own, and the one control that turns it on.
|
|
940
|
+
*
|
|
941
|
+
* Before this, automatic archiving could only be enabled by the one-time setup
|
|
942
|
+
* wizard or a terminal command — the dashboard talked about "turning it on from
|
|
943
|
+
* Settings" with no such control in Settings. This is that control.
|
|
944
|
+
*/
|
|
945
|
+
function renderAutopilot(daemon) {
|
|
946
|
+
const state = daemon?.state;
|
|
947
|
+
const offForGood = state === "never-started";
|
|
948
|
+
$("autopilot-state").textContent = offForGood
|
|
949
|
+
? "Automatic archiving is off. You are archiving only what you choose from the Sessions page."
|
|
950
|
+
: state === "running"
|
|
951
|
+
? "Automatic archiving is on. Sealkeep runs in the background and seals new sessions on its own."
|
|
952
|
+
: state === "stopped"
|
|
953
|
+
? "The background service is installed but not running right now. It should restart at your next login."
|
|
954
|
+
: "The background service is set up on this machine.";
|
|
955
|
+
$("autopilot-form").hidden = !offForGood;
|
|
956
|
+
$("autopilot-error").hidden = true;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
$("autopilot-go").onclick = async () => {
|
|
960
|
+
$("autopilot-error").hidden = true;
|
|
961
|
+
$("autopilot-go").disabled = true;
|
|
962
|
+
try {
|
|
963
|
+
const outcome = await api("/v1/setup/service", { method: "POST", body: JSON.stringify({ confirm: true }) });
|
|
964
|
+
$("autopilot-form").hidden = true;
|
|
965
|
+
$("autopilot-state").textContent = outcome.service?.alreadyInstalled
|
|
966
|
+
? "Automatic archiving is already set up on this machine."
|
|
967
|
+
: "Automatic archiving is on. Sealkeep will start sealing new sessions in the background within a few minutes.";
|
|
968
|
+
} catch (error) {
|
|
969
|
+
$("autopilot-error").textContent = error.message;
|
|
970
|
+
$("autopilot-error").hidden = false;
|
|
971
|
+
} finally {
|
|
972
|
+
$("autopilot-go").disabled = false;
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
$("remember-go").onclick = async () => {
|
|
977
|
+
const phrase = $("f-remember-phrase").value.trim();
|
|
978
|
+
$("remember-error").hidden = true;
|
|
979
|
+
if (!phrase) { $("remember-error").textContent = "Type the 24 words, in order."; $("remember-error").hidden = false; return; }
|
|
980
|
+
$("remember-go").disabled = true;
|
|
981
|
+
try {
|
|
982
|
+
await api("/v1/setup/remember", { method: "POST", body: JSON.stringify({ phrase }) });
|
|
983
|
+
$("f-remember-phrase").value = "";
|
|
984
|
+
await renderRemember();
|
|
985
|
+
// The sidebar card reads the same "does this machine hold the phrase" fact.
|
|
986
|
+
// Refresh it here too, or it keeps saying "Phrase not stored" beside a panel
|
|
987
|
+
// that just confirmed the opposite.
|
|
988
|
+
await renderAccountCard(state.status);
|
|
989
|
+
} catch (error) {
|
|
990
|
+
$("remember-error").textContent = error.message;
|
|
991
|
+
$("remember-error").hidden = false;
|
|
992
|
+
} finally {
|
|
993
|
+
$("remember-go").disabled = false;
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
/** Same wording as sync-rules.ts's describeSyncRules — the account and the mirror should read alike. */
|
|
998
|
+
function describeSync(sync) {
|
|
999
|
+
if (sync.mode === "recent") return `most recent ${sync.keepLast} sessions`;
|
|
1000
|
+
if (sync.mode === "settled") return `sessions untouched for ${sync.minAgeDays}d`;
|
|
1001
|
+
return "every session";
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function describeSyncSource(source) {
|
|
1005
|
+
if (source === "account") return "from the account";
|
|
1006
|
+
if (source === "cache") return "last known value; the account is unreachable right now";
|
|
1007
|
+
return "built-in default; no account has ever been reachable from here";
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function describeReclaimWindow(win) {
|
|
1011
|
+
if (win.state === "open") return "Open now";
|
|
1012
|
+
if (win.state === "waiting") return until(win.notBefore);
|
|
1013
|
+
return "Not scheduled";
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function describeReclaimDetail(win) {
|
|
1017
|
+
if (win.state === "open") return "sources are ready — use “Move sources to trash” on the Free up disk tab";
|
|
1018
|
+
if (win.state === "waiting") return `${win.pending} source${win.pending === 1 ? "" : "s"} waiting on age alone — see Free up disk for every blocker`;
|
|
1019
|
+
return "reclaim is off, or nothing is archived yet";
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** The status readout: everything here already exists in state.status from the last refresh(). */
|
|
1023
|
+
function renderSettingsStatus(status) {
|
|
1024
|
+
$("set-queue").textContent = status.queue.ready + status.queue.leased;
|
|
1025
|
+
$("set-queue-detail").textContent = status.queue.failed ? `${status.queue.failed} need attention` : `${status.queue.done} archived so far`;
|
|
1026
|
+
$("set-sync").textContent = status.lastSyncAt ? ago(status.lastSyncAt) : "never";
|
|
1027
|
+
$("set-sync-ago").textContent = status.lastSyncAt ? "" : "the background service has not run on this machine yet";
|
|
1028
|
+
$("set-archives").textContent = status.archiveCount;
|
|
1029
|
+
$("set-reclaim-window").textContent = describeReclaimWindow(status.nextReclaimWindow);
|
|
1030
|
+
$("set-reclaim-detail").textContent = describeReclaimDetail(status.nextReclaimWindow);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** The account's settings, read-only by construction: nothing here has an input element. */
|
|
1034
|
+
function renderAccountMirror(remote) {
|
|
1035
|
+
$("set-sync-mode").textContent = describeSync(remote.sync);
|
|
1036
|
+
$("set-sync-source").textContent = describeSyncSource(remote.sync.source);
|
|
1037
|
+
// "pro" is an enum, not a name, and the tile beneath it was a hard-coded
|
|
1038
|
+
// non-breaking space. This is the only place the dashboard acknowledges that
|
|
1039
|
+
// someone is paying, so it says the plan's name and what it includes.
|
|
1040
|
+
const planNames = { pro: "Pro", pro_lite: "Pro Lite", free: "Free" };
|
|
1041
|
+
$("set-plan").textContent = remote.plan ? (planNames[remote.plan] ?? remote.plan) : "Free — your own bucket";
|
|
1042
|
+
const planNote = $("set-plan").nextElementSibling;
|
|
1043
|
+
if (planNote && remote.quota?.quotaBytes) {
|
|
1044
|
+
planNote.textContent = `${bytes(remote.quota.quotaBytes)} included · ${bytes(remote.quota.usedBytes ?? 0)} used`;
|
|
1045
|
+
} else if (planNote) {
|
|
1046
|
+
// Pro Lite holds no bytes here — its value is the escrow and the signed-in
|
|
1047
|
+
// machines, and the note must not promise storage that plan does not include.
|
|
1048
|
+
planNote.textContent = remote.plan === "pro_lite" ? "your storage · key escrow · every machine"
|
|
1049
|
+
: remote.plan ? "managed storage" : "no account needed";
|
|
1050
|
+
}
|
|
1051
|
+
$("set-quota").textContent = remote.quota ? bytes(remote.quota.usedBytes) : "not known";
|
|
1052
|
+
$("set-quota-detail").textContent = remote.quota ? `of ${bytes(remote.quota.quotaBytes)} included` : "sign in, or nothing is hosted on this account";
|
|
1053
|
+
$("set-panel-link").href = remote.panelUrl;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function renderSettingsForm(local, resolvedTrashStrategy) {
|
|
1057
|
+
$("f-device-label").value = local.deviceLabel;
|
|
1058
|
+
$("f-watch-codex").checked = local.watchedAgents.includes("codex");
|
|
1059
|
+
$("f-watch-claude").checked = local.watchedAgents.includes("claude");
|
|
1060
|
+
$("f-watched-paths").value = local.watchedPaths.join("\n");
|
|
1061
|
+
$("f-exclusions").value = local.exclusions.join("\n");
|
|
1062
|
+
$("f-reclaim-enabled").checked = local.reclaimEnabled;
|
|
1063
|
+
$("f-trash-strategy").value = local.trashStrategy;
|
|
1064
|
+
$("f-trash-resolved").textContent = `"Automatic" currently means ${resolvedTrashStrategy} on this machine.`;
|
|
1065
|
+
$("f-interval").value = local.schedule.intervalMinutes;
|
|
1066
|
+
$("f-bandwidth").value = local.bandwidth.uploadLimitMbps ?? "";
|
|
1067
|
+
$("f-reserve").value = local.diskReserveMb ?? "";
|
|
1068
|
+
$("settings-saved").textContent = "";
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** Turns the form's current contents into the shape PUT /v1/settings expects. */
|
|
1072
|
+
function collectSettingsPatch() {
|
|
1073
|
+
const lines = (value) => value.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
1074
|
+
const watchedAgents = [];
|
|
1075
|
+
if ($("f-watch-codex").checked) watchedAgents.push("codex");
|
|
1076
|
+
if ($("f-watch-claude").checked) watchedAgents.push("claude");
|
|
1077
|
+
const bandwidth = $("f-bandwidth").value.trim();
|
|
1078
|
+
return {
|
|
1079
|
+
deviceLabel: $("f-device-label").value.trim(),
|
|
1080
|
+
watchedAgents,
|
|
1081
|
+
watchedPaths: lines($("f-watched-paths").value),
|
|
1082
|
+
exclusions: lines($("f-exclusions").value),
|
|
1083
|
+
reclaimEnabled: $("f-reclaim-enabled").checked,
|
|
1084
|
+
trashStrategy: $("f-trash-strategy").value,
|
|
1085
|
+
schedule: { intervalMinutes: Math.min(1440, Math.max(1, Math.round(Number($("f-interval").value)) || 5)) },
|
|
1086
|
+
bandwidth: { uploadLimitMbps: bandwidth === "" ? null : Math.max(0, Number(bandwidth) || 0) },
|
|
1087
|
+
diskReserveMb: (() => {
|
|
1088
|
+
const raw = $("f-reserve").value.trim();
|
|
1089
|
+
return raw === "" ? null : Math.min(102_400, Math.max(200, Math.round(Number(raw) || 0)));
|
|
1090
|
+
})()
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Fetched only when this tab is opened, not folded into refresh()'s Promise.all:
|
|
1096
|
+
* the status half is already in state.status, and fetching /v1/settings on
|
|
1097
|
+
* every background refresh would repopulate the form out from under someone
|
|
1098
|
+
* mid-edit on a tab that happens to still be open.
|
|
1099
|
+
*/
|
|
1100
|
+
async function loadSettings() {
|
|
1101
|
+
renderSettingsStatus(state.status);
|
|
1102
|
+
const settings = await api("/v1/settings");
|
|
1103
|
+
state.settings = settings;
|
|
1104
|
+
renderAccountMirror(settings.remote);
|
|
1105
|
+
if (!settingsLoaded) { renderSettingsForm(settings.local, settings.resolvedTrashStrategy); settingsLoaded = true; }
|
|
1106
|
+
void renderRemember();
|
|
1107
|
+
renderAutopilot(state.daemon);
|
|
1108
|
+
// Keep the sidebar card in lockstep with the Unlocking panel it sits beside:
|
|
1109
|
+
// both answer "does this machine hold the phrase", and refreshing only one led
|
|
1110
|
+
// to a green "Seals on its own" card next to a "does not hold the phrase" panel.
|
|
1111
|
+
void renderAccountCard(state.status);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async function refresh() {
|
|
1115
|
+
setStatus("Refreshing", "busy");
|
|
1116
|
+
const [status, queue, archives, retention, doctor, agents, daemon, disk] = await Promise.all([
|
|
1117
|
+
api("/v1/status"), api("/v1/queue"), api("/v1/archives"), api("/v1/retention"), api("/v1/doctor"), api("/v1/agents"), api("/v1/daemon"),
|
|
1118
|
+
// Tolerated failure: the picture of what is on disk is worth having and not
|
|
1119
|
+
// worth blocking the whole dashboard over.
|
|
1120
|
+
api("/v1/sessions").catch(() => null)
|
|
1121
|
+
]);
|
|
1122
|
+
state = { status, queue, archives, retention, doctor, agents, daemon, disk };
|
|
1123
|
+
|
|
1124
|
+
$("gate").hidden = true;
|
|
1125
|
+
$("app").hidden = false;
|
|
1126
|
+
// A refresh — or a fresh visit — mid-upload picks the progress straight
|
|
1127
|
+
// back up: the run lives on the server, the page only watches it.
|
|
1128
|
+
drawUpload(status.upload ?? null);
|
|
1129
|
+
if (status.upload && !status.upload.finishedAt) watchUpload();
|
|
1130
|
+
renderOverview(state);
|
|
1131
|
+
void renderAccountCard(status);
|
|
1132
|
+
renderQueue(queue, retention);
|
|
1133
|
+
renderArchives(archives, retention);
|
|
1134
|
+
renderHealth(doctor);
|
|
1135
|
+
renderDaemon(daemon);
|
|
1136
|
+
setStatus(doctor.ok ? `Connected · vault ${status.vaultId.slice(0, 8)}` : "Connected · needs attention", doctor.ok ? "ok" : "warn");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Each view owns its title. The header used to be written once, by the Overview
|
|
1140
|
+
// render, and left there — so "3.1 GB sealed · 79 GB still on this disk" sat
|
|
1141
|
+
// above the Settings form and the Queue and everything else, as if the whole app
|
|
1142
|
+
// were one page about the disk. Overview's line is computed from live state (see
|
|
1143
|
+
// renderOverview); the rest are fixed.
|
|
1144
|
+
const VIEW_HEADERS = {
|
|
1145
|
+
sessions: { headline: "Memory pool", lede: "Pick what to archive. Nothing is chosen for you, nothing runs in the background, and no original is removed here — archiving only makes the sealed copy." },
|
|
1146
|
+
rules: { headline: "Free up disk — rules", lede: "When Sealkeep may reclaim the original file. Archives are never touched by this — only the source on your disk." },
|
|
1147
|
+
queue: { headline: "Queue", lede: "Work in motion: sessions sealing (with progress), uploads pending, and what the automatic rules would reclaim once copies are safe. Finished work lands in My vault." },
|
|
1148
|
+
archives: { headline: "My vault", lede: "Every archive, where its copies live, and what each row can do: restore it, check the copy your storage holds, free the disk it came from, or delete it. Sources go to your trash; sealed archives are never touched by reclaiming." },
|
|
1149
|
+
settings: { headline: "This machine", lede: "How this machine watches, seals and reclaims. These apply here; account-wide options live in the panel." },
|
|
1150
|
+
health: { headline: "Health", lede: "What Sealkeep can and cannot do on this machine right now." }
|
|
1151
|
+
};
|
|
1152
|
+
let currentView = "overview";
|
|
1153
|
+
let overviewHeader = { headline: "Your agent history", lede: "Reading live state from the Sealkeep agent on this machine." };
|
|
1154
|
+
|
|
1155
|
+
function applyHeader() {
|
|
1156
|
+
const header = currentView === "overview" ? overviewHeader : VIEW_HEADERS[currentView];
|
|
1157
|
+
if (!header) return;
|
|
1158
|
+
$("headline").textContent = header.headline;
|
|
1159
|
+
$("lede").innerHTML = header.lede;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// The dashboard's views double as URL fragments, so the other pages (Sessions,
|
|
1163
|
+
// the retention rules) can link straight to Queue / Archives / Settings / Health,
|
|
1164
|
+
// and a bookmarked or shared #view opens the right tab. Without this, an
|
|
1165
|
+
// #archives on the URL was simply ignored and every cross-page link dumped you
|
|
1166
|
+
// back on Overview.
|
|
1167
|
+
const VIEWS = ["overview", "sessions", "queue", "archives", "restore", "retention", "rules", "settings", "health"];
|
|
1168
|
+
// Restore and Free up disk were folded into My vault — same rows, different
|
|
1169
|
+
// verbs — so their names stay recognisable in URLs but land on the one table.
|
|
1170
|
+
const VIEW_ALIASES = { restore: "archives", retention: "archives" };
|
|
1171
|
+
function viewFromHash() {
|
|
1172
|
+
const name = location.hash.replace(/^#/, "");
|
|
1173
|
+
return VIEWS.includes(name) ? name : null;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function showView(name) {
|
|
1177
|
+
currentView = name;
|
|
1178
|
+
for (const view of document.querySelectorAll(".view")) view.hidden = view.id !== `view-${name}`;
|
|
1179
|
+
for (const button of document.querySelectorAll("nav button")) button.classList.toggle("active", button.dataset.view === name);
|
|
1180
|
+
applyHeader();
|
|
1181
|
+
const hash = name === "overview" ? "" : `#${name}`;
|
|
1182
|
+
if (location.hash !== hash) history.replaceState(null, "", location.pathname + hash);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function routeToView(name) {
|
|
1186
|
+
name = VIEW_ALIASES[name] ?? name;
|
|
1187
|
+
showView(name);
|
|
1188
|
+
if (name === "settings") void guard(loadSettings);
|
|
1189
|
+
// The folded pages: first entry mounts and loads, later entries are instant —
|
|
1190
|
+
// the view is already rendered, and its data refreshes quietly underneath.
|
|
1191
|
+
if (name === "sessions" && typeof initSessionsView === "function") initSessionsView();
|
|
1192
|
+
if (name === "rules" && typeof initRulesView === "function") initRulesView();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
document.querySelectorAll("nav button").forEach((button) => {
|
|
1196
|
+
button.onclick = () => routeToView(button.dataset.view);
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
// A hash arrived at from another page, a bookmark, or the back button switches
|
|
1200
|
+
// the view to match. Guarded so the fake-DOM render tests, which have no window,
|
|
1201
|
+
// still load this script.
|
|
1202
|
+
if (typeof window !== "undefined" && window.addEventListener) {
|
|
1203
|
+
window.addEventListener("hashchange", () => {
|
|
1204
|
+
const v = viewFromHash();
|
|
1205
|
+
if (v && v !== currentView) routeToView(v);
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
$("token-form").onsubmit = async (event) => {
|
|
1210
|
+
event.preventDefault();
|
|
1211
|
+
token = $("token-input").value.trim();
|
|
1212
|
+
sessionStorage.setItem(TOKEN_KEY, token);
|
|
1213
|
+
$("token-input").value = "";
|
|
1214
|
+
await start();
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
$("refresh").onclick = () => void guard(refresh);
|
|
1218
|
+
|
|
1219
|
+
// ---- Upload progress ------------------------------------------------------
|
|
1220
|
+
// The run lives on the server, so this block only *watches*: start it, poll
|
|
1221
|
+
// it through /v1/status, offer the one honest cancel there is (the archive in
|
|
1222
|
+
// flight finishes and verifies, nothing after it starts), and re-attach after
|
|
1223
|
+
// a refresh. The meter is determinate — bytes done over bytes asked — because
|
|
1224
|
+
// motion that ends is progress and motion that loops is decoration.
|
|
1225
|
+
let uploadTimer = null;
|
|
1226
|
+
|
|
1227
|
+
function drawUpload(run) {
|
|
1228
|
+
const block = $("upload-progress");
|
|
1229
|
+
if (!run) { block.hidden = true; $("upload").disabled = false; return; }
|
|
1230
|
+
block.hidden = false;
|
|
1231
|
+
const active = !run.finishedAt;
|
|
1232
|
+
$("upload").disabled = active;
|
|
1233
|
+
$("upload-cancel").hidden = !active || run.cancelled;
|
|
1234
|
+
const pct = run.totalBytes > 0 ? Math.round((run.doneBytes / run.totalBytes) * 100) : (active ? 0 : 100);
|
|
1235
|
+
$("upload-fill").style.width = `${pct}%`;
|
|
1236
|
+
// The pace, measured from this run's own history, and what it implies.
|
|
1237
|
+
// Honest edges: before anything lands there is no rate worth stating, and
|
|
1238
|
+
// a rate from two small files says little about the 1.6 GB one after them —
|
|
1239
|
+
// bytes-based, so it converges as the big ones move through.
|
|
1240
|
+
let pace = "";
|
|
1241
|
+
if (!run.finishedAt && run.doneBytes > 0) {
|
|
1242
|
+
const elapsed = (Date.now() - Date.parse(run.startedAt)) / 1000;
|
|
1243
|
+
const rate = run.doneBytes / Math.max(1, elapsed);
|
|
1244
|
+
const left = (run.totalBytes - run.doneBytes) / rate;
|
|
1245
|
+
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`;
|
|
1246
|
+
pace = ` · ${bytes(rate)}/s · ~${human} left`;
|
|
1247
|
+
} else if (!run.finishedAt && run.current) {
|
|
1248
|
+
pace = " · measuring speed…";
|
|
1249
|
+
}
|
|
1250
|
+
const sent = `${run.done.length} of ${run.totalCount} sent · ${bytes(run.doneBytes)} of ${bytes(run.totalBytes)}${run.freedBytes ? ` · ${bytes(run.freedBytes)} freed locally` : ""}${pace}`;
|
|
1251
|
+
if (active) {
|
|
1252
|
+
const now = run.current ? ` · sending ${run.current.archiveId.slice(0, 8)} (${bytes(run.current.bytes)})` : "";
|
|
1253
|
+
$("upload-line").textContent = (run.cancelled ? "Stopping — finishing the archive in flight. " : "") + sent + now;
|
|
1254
|
+
} else {
|
|
1255
|
+
const failed = run.failed.length ? ` · ${run.failed.length} failed — ${run.failed[0].error}` : "";
|
|
1256
|
+
$("upload-line").textContent = run.cancelled ? `Stopped. ${sent}${failed}` : `Done. ${sent}${failed}`;
|
|
1257
|
+
// A stopped run leaves a decision on the table: the uploads that already
|
|
1258
|
+
// landed. Keeping them is one click (dismiss); taking them back goes
|
|
1259
|
+
// through the same per-copy delete as everywhere else, so an archive
|
|
1260
|
+
// whose only remaining form is the cloud copy is refused by name.
|
|
1261
|
+
if (run.cancelled && run.done.length > 0 && !document.getElementById("upload-rollback")) {
|
|
1262
|
+
const undo = document.createElement("button");
|
|
1263
|
+
undo.type = "button";
|
|
1264
|
+
undo.id = "upload-rollback";
|
|
1265
|
+
undo.className = "action danger";
|
|
1266
|
+
undo.textContent = `Delete the ${run.done.length} uploaded`;
|
|
1267
|
+
undo.onclick = async () => {
|
|
1268
|
+
undo.disabled = true;
|
|
1269
|
+
try {
|
|
1270
|
+
const result = await api("/v1/upload/rollback", { method: "POST" });
|
|
1271
|
+
const refusedNote = result.refused.length ? ` · ${result.refused.length} kept — ${result.refused[0].reason}` : "";
|
|
1272
|
+
$("upload-line").textContent = `Took back ${result.deleted.length} upload${result.deleted.length === 1 ? "" : "s"}${refusedNote}`;
|
|
1273
|
+
undo.remove();
|
|
1274
|
+
keep.remove();
|
|
1275
|
+
await guard(refresh);
|
|
1276
|
+
} catch (error) { undo.disabled = false; setStatus(error.message, "warn"); }
|
|
1277
|
+
};
|
|
1278
|
+
const keep = document.createElement("button");
|
|
1279
|
+
keep.type = "button";
|
|
1280
|
+
keep.id = "upload-keep";
|
|
1281
|
+
keep.className = "action";
|
|
1282
|
+
keep.textContent = "Keep them";
|
|
1283
|
+
keep.onclick = () => { block.hidden = true; undo.remove(); keep.remove(); };
|
|
1284
|
+
$("upload-line").after(keep, undo);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function watchUpload() {
|
|
1290
|
+
if (uploadTimer) return;
|
|
1291
|
+
uploadTimer = setInterval(async () => {
|
|
1292
|
+
try {
|
|
1293
|
+
const status = await api("/v1/status");
|
|
1294
|
+
drawUpload(status.upload);
|
|
1295
|
+
if (!status.upload || status.upload.finishedAt) {
|
|
1296
|
+
clearInterval(uploadTimer);
|
|
1297
|
+
uploadTimer = null;
|
|
1298
|
+
await guard(refresh);
|
|
1299
|
+
}
|
|
1300
|
+
} catch { /* a missed poll is the next poll's problem */ }
|
|
1301
|
+
}, 2500);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
$("upload").onclick = async () => {
|
|
1305
|
+
await guard(async () => {
|
|
1306
|
+
const result = await api("/v1/upload", { method: "POST" });
|
|
1307
|
+
drawUpload(result.upload);
|
|
1308
|
+
if (result.upload && !result.upload.finishedAt) watchUpload();
|
|
1309
|
+
else if (result.upload && result.upload.totalCount === 0) setStatus("Nothing waiting to upload", "ok");
|
|
1310
|
+
});
|
|
1311
|
+
};
|
|
1312
|
+
|
|
1313
|
+
$("upload-cancel").onclick = async () => {
|
|
1314
|
+
await guard(async () => { await api("/v1/upload/cancel", { method: "POST" }); });
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
$("reclaim").onclick = async () => {
|
|
1318
|
+
const preview = await api("/v1/retention/apply", { method: "POST", body: JSON.stringify({ confirm: false }) });
|
|
1319
|
+
const message = `Move ${preview.wouldReclaim} source file${preview.wouldReclaim === 1 ? "" : "s"} to the trash?\n\nThey stay recoverable from the trash, and their archives are untouched.`;
|
|
1320
|
+
if (!confirm(message)) return;
|
|
1321
|
+
await guard(async () => {
|
|
1322
|
+
setStatus("Reclaiming", "busy");
|
|
1323
|
+
const applied = await api("/v1/retention/apply", { method: "POST", body: JSON.stringify({ confirm: true }) });
|
|
1324
|
+
await refresh();
|
|
1325
|
+
setStatus(`Moved ${applied.reclaimed.length} to the trash`, "ok");
|
|
1326
|
+
});
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
// Guarded rather than assumed present: the render tests run app.js against a
|
|
1330
|
+
// fixed page fixture that predates this form, the same reason the data it
|
|
1331
|
+
// would submit is loaded lazily instead of through refresh() above.
|
|
1332
|
+
const settingsForm = $("settings-form");
|
|
1333
|
+
if (settingsForm) {
|
|
1334
|
+
settingsForm.onsubmit = async (event) => {
|
|
1335
|
+
event.preventDefault();
|
|
1336
|
+
await guard(async () => {
|
|
1337
|
+
const patch = collectSettingsPatch();
|
|
1338
|
+
// Same rule the reclaim endpoint already enforces: turning this on is
|
|
1339
|
+
// the one change here that lets the machine act on its own later, so
|
|
1340
|
+
// it gets the one confirmation dialog on this whole page.
|
|
1341
|
+
if (patch.reclaimEnabled && !state.settings?.local?.reclaimEnabled) {
|
|
1342
|
+
const message = "Let this machine move verified, grace-period-expired sources to the trash on its own from now on?";
|
|
1343
|
+
if (!confirm(message)) return;
|
|
1344
|
+
patch.confirm = true;
|
|
1345
|
+
}
|
|
1346
|
+
$("settings-saved").textContent = "Saving…";
|
|
1347
|
+
const saved = await api("/v1/settings", { method: "PUT", body: JSON.stringify(patch) });
|
|
1348
|
+
state.settings = saved;
|
|
1349
|
+
renderSettingsForm(saved.local, saved.resolvedTrashStrategy);
|
|
1350
|
+
renderAccountMirror(saved.remote);
|
|
1351
|
+
$("settings-saved").textContent = "Saved.";
|
|
1352
|
+
});
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
async function guard(action) {
|
|
1357
|
+
try { await action(); }
|
|
1358
|
+
catch (error) { if (error.message !== "unauthorized") setStatus(error.message, "warn"); }
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
async function start() {
|
|
1362
|
+
// No token in hand is not yet a reason to gate: a browser that has been here
|
|
1363
|
+
// before holds the HttpOnly cookie, which the server accepts and this page
|
|
1364
|
+
// cannot see. Try the API first; only a 401 shows the gate.
|
|
1365
|
+
await guard(refresh);
|
|
1366
|
+
// Land on the view named in the URL (e.g. arriving at index.html#settings from
|
|
1367
|
+
// the Sessions page), not always Overview.
|
|
1368
|
+
const v = viewFromHash();
|
|
1369
|
+
if (v) routeToView(v);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
void start();
|