alpha-gate 0.1.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/bin/alpha-gate.mjs +75 -0
- package/deploy/backup.sh +45 -0
- package/deploy/deploy.sh +21 -0
- package/deploy/dev.sh +56 -0
- package/deploy/lib/statedir.sh +11 -0
- package/deploy/teardown.sh +17 -0
- package/docs/ONBOARDING.md +16 -0
- package/docs/PRINCIPLES.md +195 -0
- package/docs/README.md +54 -0
- package/docs/UPLOADING.md +14 -0
- package/docs/integrate/activation.md +56 -0
- package/docs/integrate/sparkle-go.md +106 -0
- package/docs/integrate/sparkle-swift.md +63 -0
- package/docs/maintain/backup.md +52 -0
- package/docs/maintain/migrate-account.md +95 -0
- package/docs/maintain/teardown.md +46 -0
- package/docs/maintain/troubleshooting.md +102 -0
- package/docs/maintain/updating.md +92 -0
- package/docs/operate/add-users.md +55 -0
- package/docs/operate/channels.md +54 -0
- package/docs/operate/email.md +51 -0
- package/docs/operate/monitoring.md +56 -0
- package/docs/operate/publish.md +90 -0
- package/docs/operate/remove-users.md +47 -0
- package/docs/setup/cloudflare-account.md +41 -0
- package/docs/setup/deploy.md +84 -0
- package/docs/setup/install.md +65 -0
- package/migrations/0001_clients.sql +12 -0
- package/migrations/0002_builds_streams.sql +30 -0
- package/migrations/0003_access_log.sql +14 -0
- package/migrations/0004_meta.sql +5 -0
- package/migrations/0005_admin_audit.sql +14 -0
- package/migrations/0006_build_dmg.sql +6 -0
- package/migrations/0007_access_requests.sql +12 -0
- package/migrations/0008_build_rollback_target.sql +7 -0
- package/migrations/0009_hidden.sql +6 -0
- package/migrations/0010_purged_at.sql +5 -0
- package/package.json +65 -0
- package/publish.sh +302 -0
- package/release.json +7 -0
- package/src/auth/access-jwt.ts +210 -0
- package/src/auth/token-gate.ts +27 -0
- package/src/core/appcast.ts +107 -0
- package/src/core/audit-chain.ts +145 -0
- package/src/core/invite-template.ts +127 -0
- package/src/core/no-build.ts +160 -0
- package/src/core/resolver.ts +60 -0
- package/src/core/tokens.ts +51 -0
- package/src/core/types.ts +76 -0
- package/src/core/validation.ts +60 -0
- package/src/core/verdict.ts +195 -0
- package/src/core/version.ts +113 -0
- package/src/cron.ts +37 -0
- package/src/db/access-log.ts +168 -0
- package/src/db/access-requests.ts +90 -0
- package/src/db/admin-audit.ts +101 -0
- package/src/db/builds.ts +169 -0
- package/src/db/client.ts +80 -0
- package/src/db/clients.ts +103 -0
- package/src/db/meta.ts +30 -0
- package/src/db/streams.ts +85 -0
- package/src/deploy/cli.ts +181 -0
- package/src/deploy/commands/deploy.ts +392 -0
- package/src/deploy/commands/dev.ts +190 -0
- package/src/deploy/commands/teardown.ts +159 -0
- package/src/deploy/core/args.ts +205 -0
- package/src/deploy/core/colors.ts +49 -0
- package/src/deploy/core/config.ts +65 -0
- package/src/deploy/core/parse.ts +51 -0
- package/src/deploy/core/paths.ts +27 -0
- package/src/deploy/core/plan.ts +138 -0
- package/src/deploy/core/result.ts +13 -0
- package/src/deploy/core/state.ts +64 -0
- package/src/deploy/core/table.ts +49 -0
- package/src/deploy/core/types.ts +39 -0
- package/src/deploy/core/ui.ts +107 -0
- package/src/deploy/seams/clock.ts +10 -0
- package/src/deploy/seams/files.ts +52 -0
- package/src/deploy/seams/io.ts +56 -0
- package/src/deploy/seams/wrangler.ts +100 -0
- package/src/deploy/ui-preview.ts +112 -0
- package/src/deps.ts +41 -0
- package/src/dev/admin-entry.ts +104 -0
- package/src/env.ts +47 -0
- package/src/lib/clock.ts +17 -0
- package/src/lib/hosts.ts +27 -0
- package/src/lib/text.ts +6 -0
- package/src/r2/builds-bucket.ts +50 -0
- package/src/r2/keys.ts +27 -0
- package/src/routes/admin/admin-context.ts +9 -0
- package/src/routes/admin/audit-fields.ts +22 -0
- package/src/routes/admin/branding.tsx +161 -0
- package/src/routes/admin/builds.tsx +388 -0
- package/src/routes/admin/clients.tsx +396 -0
- package/src/routes/admin/confirm.tsx +80 -0
- package/src/routes/admin/flash.ts +72 -0
- package/src/routes/admin/form.ts +43 -0
- package/src/routes/admin/index.ts +146 -0
- package/src/routes/admin/invite.ts +57 -0
- package/src/routes/admin/middleware.ts +52 -0
- package/src/routes/admin/negotiate.ts +13 -0
- package/src/routes/admin/pending.tsx +73 -0
- package/src/routes/admin/read-model.ts +518 -0
- package/src/routes/admin/streams.tsx +182 -0
- package/src/routes/admin/theme.ts +34 -0
- package/src/routes/admin/upload.tsx +320 -0
- package/src/routes/admin/views.tsx +293 -0
- package/src/routes/app/access.tsx +38 -0
- package/src/routes/app/app-context.ts +8 -0
- package/src/routes/app/appcast.ts +68 -0
- package/src/routes/app/assets.ts +25 -0
- package/src/routes/app/download.ts +45 -0
- package/src/routes/app/get.tsx +35 -0
- package/src/routes/app/index.ts +31 -0
- package/src/routes/app/resolve.ts +16 -0
- package/src/services/anchor.ts +54 -0
- package/src/services/audit.ts +19 -0
- package/src/services/branding.ts +51 -0
- package/src/services/email-cloudflare.ts +80 -0
- package/src/services/email.ts +68 -0
- package/src/services/self-update.ts +62 -0
- package/src/views/access-page.tsx +39 -0
- package/src/views/admin/ci-page.tsx +76 -0
- package/src/views/admin/combobox.tsx +191 -0
- package/src/views/admin/forms.tsx +37 -0
- package/src/views/admin/layout.tsx +496 -0
- package/src/views/admin/manage-pages.tsx +223 -0
- package/src/views/admin/manage.tsx +1120 -0
- package/src/views/admin/plist-extract.ts +233 -0
- package/src/views/admin/read-pages.tsx +1098 -0
- package/src/views/admin/setup-page.tsx +108 -0
- package/src/views/admin/table-enhance.ts +176 -0
- package/src/views/admin/ui.tsx +159 -0
- package/src/views/get-page.tsx +39 -0
- package/src/views/layout.tsx +57 -0
- package/src/worker.ts +23 -0
- package/tsconfig.json +35 -0
|
@@ -0,0 +1,1098 @@
|
|
|
1
|
+
import type { Child, FC } from "hono/jsx";
|
|
2
|
+
import type { AuditRow, ChainAssessment } from "../../core/audit-chain";
|
|
3
|
+
import type { Stream } from "../../core/types";
|
|
4
|
+
import { formatBytes } from "../../core/verdict";
|
|
5
|
+
import type { AccessLogEntry } from "../../db/access-log";
|
|
6
|
+
import type { AccessRequest } from "../../db/access-requests";
|
|
7
|
+
import type {
|
|
8
|
+
BuildView,
|
|
9
|
+
Dashboard,
|
|
10
|
+
FaultedUser,
|
|
11
|
+
RecentItem,
|
|
12
|
+
StreamView,
|
|
13
|
+
UserView,
|
|
14
|
+
} from "../../routes/admin/read-model";
|
|
15
|
+
import { Post } from "./forms";
|
|
16
|
+
import { AdminLayout, type Chrome } from "./layout";
|
|
17
|
+
import { BuildTags, Dot, Lk, Tag, VerdictCell, When } from "./ui";
|
|
18
|
+
|
|
19
|
+
// The back-office list pages, quiet-instrument style: one sheet, hairline slabs, exception-only
|
|
20
|
+
// state, and the resolver visible everywhere — the serving map on Overview, the Next-check column on
|
|
21
|
+
// Users, the Serving column on Channels. Rows link to detail pages; actions live there (no
|
|
22
|
+
// buttons-as-data). Detail pages live in manage.tsx.
|
|
23
|
+
|
|
24
|
+
// A header cell wired for the client-side table enhancer (table-enhance.ts): `sort` makes it
|
|
25
|
+
// click-to-sort ("text" | "num"); `col` is the key a filter control targets. Both are omitted from the
|
|
26
|
+
// markup when unset (a plain <th> stays unsortable), so the enhancer only touches columns we opt in.
|
|
27
|
+
const Th: FC<{ col?: string; sort?: "text" | "num"; right?: boolean; children?: Child }> = ({
|
|
28
|
+
col,
|
|
29
|
+
sort,
|
|
30
|
+
right,
|
|
31
|
+
children,
|
|
32
|
+
}) => {
|
|
33
|
+
const attrs: Record<string, string> = {};
|
|
34
|
+
if (col) attrs["data-key"] = col;
|
|
35
|
+
if (sort) attrs["data-sort"] = sort;
|
|
36
|
+
if (right) attrs.class = "r";
|
|
37
|
+
return <th {...attrs}>{children}</th>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ————————————————————————————— Overview —————————————————————————————
|
|
41
|
+
|
|
42
|
+
/** One attention row: cause in prose, exactly one remedy link. */
|
|
43
|
+
interface AttentionItem {
|
|
44
|
+
dot: "warn" | "req";
|
|
45
|
+
title: Child;
|
|
46
|
+
body: Child;
|
|
47
|
+
fix: { href: string; label: string };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function faultAttention(fault: FaultedUser): AttentionItem | null {
|
|
51
|
+
const userHref = `/admin/users/${fault.id}`;
|
|
52
|
+
switch (fault.verdict.kind) {
|
|
53
|
+
case "stranded":
|
|
54
|
+
return {
|
|
55
|
+
dot: "warn",
|
|
56
|
+
title: (
|
|
57
|
+
<>
|
|
58
|
+
<a href={userHref}>{fault.email}</a> is stranded
|
|
59
|
+
</>
|
|
60
|
+
),
|
|
61
|
+
body: (
|
|
62
|
+
<>
|
|
63
|
+
Installed <b class="num">#{fault.verdict.installed}</b> is above everything{" "}
|
|
64
|
+
{fault.streams.join(", ") || "their channels"} offer (top is{" "}
|
|
65
|
+
<Lk build={fault.verdict.top} dim short />) — Sparkle can't downgrade.
|
|
66
|
+
</>
|
|
67
|
+
),
|
|
68
|
+
fix: { href: "/admin/upload", label: "Roll forward →" },
|
|
69
|
+
};
|
|
70
|
+
case "pin-unavailable":
|
|
71
|
+
return {
|
|
72
|
+
dot: "warn",
|
|
73
|
+
title: (
|
|
74
|
+
<>
|
|
75
|
+
<a href={userHref}>{fault.email}</a>'s pin serves nothing
|
|
76
|
+
</>
|
|
77
|
+
),
|
|
78
|
+
body: <>The pinned build was withdrawn — their checks return an empty feed.</>,
|
|
79
|
+
fix: { href: userHref, label: "Review pin →" },
|
|
80
|
+
};
|
|
81
|
+
case "pin-below-installed":
|
|
82
|
+
return {
|
|
83
|
+
dot: "warn",
|
|
84
|
+
title: (
|
|
85
|
+
<>
|
|
86
|
+
<a href={userHref}>{fault.email}</a>'s pin serves nothing
|
|
87
|
+
</>
|
|
88
|
+
),
|
|
89
|
+
body: (
|
|
90
|
+
<>
|
|
91
|
+
Pinned <Lk build={fault.verdict.pinned} dim short /> sits below their installed{" "}
|
|
92
|
+
<b class="num">#{fault.verdict.installed}</b> — Sparkle can't downgrade.
|
|
93
|
+
</>
|
|
94
|
+
),
|
|
95
|
+
fix: { href: userHref, label: "Review pin →" },
|
|
96
|
+
};
|
|
97
|
+
default:
|
|
98
|
+
return null; // no-channel and empty-channel are covered by the map + channel rows
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const RECENT_PHRASES: Record<string, (target: string | null) => string> = {
|
|
103
|
+
"client.create": (t) => `you invited ${t}`,
|
|
104
|
+
"client.revoke": (t) => `you revoked ${t}`,
|
|
105
|
+
"client.reactivate": (t) => `you reactivated ${t}`,
|
|
106
|
+
"client.reissue": (t) => `you reissued a link for ${t}`,
|
|
107
|
+
"client.pin": (t) => `you pinned ${t}`,
|
|
108
|
+
"client.unpin": (t) => `you unpinned ${t}`,
|
|
109
|
+
"client.hide": (t) => `you hid ${t}`,
|
|
110
|
+
"client.unhide": (t) => `you unhid ${t}`,
|
|
111
|
+
"stream.create": (t) => `you created the ${t} channel`,
|
|
112
|
+
"stream.delete": (t) => `you deleted the ${t} channel`,
|
|
113
|
+
"stream.assign": (t) => `you assigned ${t}`,
|
|
114
|
+
"stream.unassign": (t) => `you unassigned ${t}`,
|
|
115
|
+
"build.upload": (t) => `you published build ${t}`,
|
|
116
|
+
"build.register": (t) => `you published build ${t}`,
|
|
117
|
+
"build.withdraw": (t) => `you withdrew build ${t}`,
|
|
118
|
+
"build.restore": (t) => `you restored build ${t}`,
|
|
119
|
+
"build.critical": (t) => `you changed the critical mark on build ${t}`,
|
|
120
|
+
"build.rollback": (t) => `you changed the rollback target on build ${t}`,
|
|
121
|
+
"build.link": (t) => `you linked build ${t}`,
|
|
122
|
+
"build.unlink": (t) => `you unlinked build ${t}`,
|
|
123
|
+
"build.hide": (t) => `you hid build ${t}`,
|
|
124
|
+
"build.unhide": (t) => `you unhid build ${t}`,
|
|
125
|
+
"request.invite": (t) => `you invited ${t}`,
|
|
126
|
+
"request.dismiss": (t) => `you dismissed the request from ${t}`,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const RecentLine: FC<{ item: RecentItem }> = ({ item }) => {
|
|
130
|
+
if (item.kind === "admin") {
|
|
131
|
+
const phrase = RECENT_PHRASES[item.action ?? ""];
|
|
132
|
+
return <span>{phrase ? phrase(item.target) : `${item.action} ${item.target ?? ""}`}</span>;
|
|
133
|
+
}
|
|
134
|
+
const who = item.clientId ? (
|
|
135
|
+
<a href={`/admin/users/${item.clientId}`}>{item.email}</a>
|
|
136
|
+
) : (
|
|
137
|
+
<span>{item.email ?? "someone"}</span>
|
|
138
|
+
);
|
|
139
|
+
const buildRef = item.buildNumber !== null ? <b class="num">#{item.buildNumber}</b> : null;
|
|
140
|
+
if (item.kind === "check") {
|
|
141
|
+
return (
|
|
142
|
+
<span>
|
|
143
|
+
{who} checked{buildRef ? <> — on {buildRef}</> : null}
|
|
144
|
+
</span>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (item.kind === "download") {
|
|
148
|
+
return (
|
|
149
|
+
<span>
|
|
150
|
+
{who} downloaded {buildRef}
|
|
151
|
+
</span>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return (
|
|
155
|
+
<span>
|
|
156
|
+
{who} updated to {buildRef}
|
|
157
|
+
</span>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const OverviewPage: FC<{ data: Dashboard; now: string; chrome: Chrome }> = ({
|
|
162
|
+
data,
|
|
163
|
+
now,
|
|
164
|
+
chrome,
|
|
165
|
+
}) => {
|
|
166
|
+
const attention: AttentionItem[] = [];
|
|
167
|
+
if (data.chain !== null && !data.chain.intact) {
|
|
168
|
+
attention.push({
|
|
169
|
+
dot: "warn",
|
|
170
|
+
title: <>Audit chain mismatch</>,
|
|
171
|
+
body: <>The admin audit log no longer matches its last anchor — inspect it now.</>,
|
|
172
|
+
fix: { href: "/admin/audit", label: "Inspect audit →" },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (data.selfUpdate.available) {
|
|
176
|
+
attention.push({
|
|
177
|
+
dot: "warn",
|
|
178
|
+
title: <>Alpha Gate {data.selfUpdate.latest} is available</>,
|
|
179
|
+
body: (
|
|
180
|
+
<>
|
|
181
|
+
{data.selfUpdate.breaking ? "Includes breaking changes. " : ""}Update Alpha Gate (git
|
|
182
|
+
pull, or npx alpha-gate@latest) and re-deploy this instance.
|
|
183
|
+
{data.selfUpdate.notesUrl ? (
|
|
184
|
+
<>
|
|
185
|
+
{" "}
|
|
186
|
+
<a href={data.selfUpdate.notesUrl}>Release notes</a>.
|
|
187
|
+
</>
|
|
188
|
+
) : null}
|
|
189
|
+
</>
|
|
190
|
+
),
|
|
191
|
+
fix: { href: data.selfUpdate.notesUrl ?? "/admin/settings", label: "Details →" },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
for (const fault of data.faults) {
|
|
195
|
+
const item = faultAttention(fault);
|
|
196
|
+
if (item) attention.push(item);
|
|
197
|
+
}
|
|
198
|
+
for (const s of data.servings) {
|
|
199
|
+
if (s.top === null && s.users > 0) {
|
|
200
|
+
attention.push({
|
|
201
|
+
dot: "warn",
|
|
202
|
+
title: <>{s.name} serves nothing</>,
|
|
203
|
+
body: (
|
|
204
|
+
<>
|
|
205
|
+
No available build is linked, so {s.users} {s.users === 1 ? "user gets" : "users get"}{" "}
|
|
206
|
+
an empty feed and their apps report “up to date”.
|
|
207
|
+
</>
|
|
208
|
+
),
|
|
209
|
+
fix: { href: `/admin/streams/${s.streamId}`, label: "Link a build →" },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const firstOff = data.offMap[0];
|
|
214
|
+
if (data.offMap.length === 1 && firstOff) {
|
|
215
|
+
attention.push({
|
|
216
|
+
dot: "warn",
|
|
217
|
+
title: (
|
|
218
|
+
<>
|
|
219
|
+
<a href={`/admin/users/${firstOff.id}`}>{firstOff.email}</a> has no channel
|
|
220
|
+
</>
|
|
221
|
+
),
|
|
222
|
+
body: <>Their token works but resolves to nothing — they have never received a build.</>,
|
|
223
|
+
fix: { href: `/admin/users/${firstOff.id}`, label: "Assign a channel →" },
|
|
224
|
+
});
|
|
225
|
+
} else if (data.offMap.length > 1) {
|
|
226
|
+
attention.push({
|
|
227
|
+
dot: "warn",
|
|
228
|
+
title: <>{data.offMap.length} users have no channel</>,
|
|
229
|
+
body: <>{data.offMap.map((u) => u.email).join(", ")} — their checks resolve to nothing.</>,
|
|
230
|
+
fix: { href: "/admin/users?nobuild=1", label: "Review users →" },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (data.pendingRequests > 0) {
|
|
234
|
+
attention.push({
|
|
235
|
+
dot: "req",
|
|
236
|
+
title: (
|
|
237
|
+
<>
|
|
238
|
+
{data.pendingRequests} access {data.pendingRequests === 1 ? "request" : "requests"}{" "}
|
|
239
|
+
waiting
|
|
240
|
+
</>
|
|
241
|
+
),
|
|
242
|
+
body: (
|
|
243
|
+
<>
|
|
244
|
+
{data.pendingSample.emails.join(", ")}
|
|
245
|
+
{data.pendingRequests > data.pendingSample.emails.length ? " and more" : ""}
|
|
246
|
+
{data.pendingSample.oldest ? (
|
|
247
|
+
<>
|
|
248
|
+
{" "}
|
|
249
|
+
— oldest from <When iso={data.pendingSample.oldest} now={now} />
|
|
250
|
+
</>
|
|
251
|
+
) : null}
|
|
252
|
+
.
|
|
253
|
+
</>
|
|
254
|
+
),
|
|
255
|
+
fix: { href: "/admin/pending", label: "Review requests →" },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<AdminLayout
|
|
261
|
+
title="Overview"
|
|
262
|
+
chrome={chrome}
|
|
263
|
+
head={
|
|
264
|
+
<>
|
|
265
|
+
{data.lastPublish ? (
|
|
266
|
+
<p class="sub">
|
|
267
|
+
Last publish{" "}
|
|
268
|
+
<Lk
|
|
269
|
+
build={data.lastPublish.build}
|
|
270
|
+
href={`/admin/builds/${data.lastPublish.build.id}`}
|
|
271
|
+
/>
|
|
272
|
+
{data.lastPublish.streams.length > 0 ? (
|
|
273
|
+
<> → {data.lastPublish.streams.join(", ")}</>
|
|
274
|
+
) : (
|
|
275
|
+
<> — in no channel</>
|
|
276
|
+
)}
|
|
277
|
+
, <When iso={data.lastPublish.build.createdAt} now={now} />
|
|
278
|
+
</p>
|
|
279
|
+
) : (
|
|
280
|
+
<p class="sub">Nothing published yet</p>
|
|
281
|
+
)}
|
|
282
|
+
<p class="inv">
|
|
283
|
+
<a href="/admin/users">
|
|
284
|
+
{data.users} users{data.hiddenUsers > 0 ? ` (+${data.hiddenUsers} hidden)` : ""}
|
|
285
|
+
</a>{" "}
|
|
286
|
+
· <a href="/admin/builds">{data.builds} builds</a> ·{" "}
|
|
287
|
+
<a href="/admin/streams">{data.streams} channels</a>
|
|
288
|
+
</p>
|
|
289
|
+
</>
|
|
290
|
+
}
|
|
291
|
+
>
|
|
292
|
+
<section aria-label="Serving map">
|
|
293
|
+
<div class="slab">
|
|
294
|
+
<h2>Serving now</h2>
|
|
295
|
+
<span class="hint">what each channel offers on the next update check</span>
|
|
296
|
+
</div>
|
|
297
|
+
<ul class="map">
|
|
298
|
+
{data.servings.map((s) => (
|
|
299
|
+
<li>
|
|
300
|
+
<Dot
|
|
301
|
+
kind={s.top === null ? "off" : s.faulted > 0 ? "warn" : "ok"}
|
|
302
|
+
title={
|
|
303
|
+
s.top === null
|
|
304
|
+
? "Serving nothing"
|
|
305
|
+
: s.faulted > 0
|
|
306
|
+
? "Serving, with faults"
|
|
307
|
+
: "Serving"
|
|
308
|
+
}
|
|
309
|
+
/>
|
|
310
|
+
<a class="ch" href={`/admin/streams/${s.streamId}`}>
|
|
311
|
+
{s.name}
|
|
312
|
+
</a>
|
|
313
|
+
<span class={s.top === null ? "rail dash" : "rail"} />
|
|
314
|
+
<span class="served">
|
|
315
|
+
{s.top === null ? (
|
|
316
|
+
<span class="none">
|
|
317
|
+
serving nothing <i>— no build linked</i>
|
|
318
|
+
</span>
|
|
319
|
+
) : (
|
|
320
|
+
<>
|
|
321
|
+
<Lk build={s.top} href={`/admin/builds/${s.top.id}`} />
|
|
322
|
+
<BuildTags build={s.top} />
|
|
323
|
+
</>
|
|
324
|
+
)}
|
|
325
|
+
</span>
|
|
326
|
+
<span class={s.top === null ? "rail dash" : "rail"} />
|
|
327
|
+
<a class="aud" href={`/admin/users?stream=${encodeURIComponent(s.name)}`}>
|
|
328
|
+
<b>
|
|
329
|
+
{s.users} {s.users === 1 ? "user" : "users"}
|
|
330
|
+
</b>
|
|
331
|
+
{s.users === 0 ? null : s.top === null ? (
|
|
332
|
+
<em class="w">empty feed</em>
|
|
333
|
+
) : (
|
|
334
|
+
<em class={s.faulted > 0 ? "w" : ""}>
|
|
335
|
+
{[
|
|
336
|
+
s.faulted > 0 ? `${s.faulted} faulted` : null,
|
|
337
|
+
s.willUpdate > 0 ? `${s.willUpdate} will update` : null,
|
|
338
|
+
s.pinned > 0 ? `${s.pinned} pinned` : null,
|
|
339
|
+
]
|
|
340
|
+
.filter(Boolean)
|
|
341
|
+
.join(" · ") || "all up to date"}
|
|
342
|
+
</em>
|
|
343
|
+
)}
|
|
344
|
+
</a>
|
|
345
|
+
</li>
|
|
346
|
+
))}
|
|
347
|
+
{data.offMap.length > 0 ? (
|
|
348
|
+
<li class="offrow">
|
|
349
|
+
<Dot kind="off" title="Routed nowhere" />
|
|
350
|
+
<span class="ch">off the map</span>
|
|
351
|
+
<span class="rail dash" />
|
|
352
|
+
<span class="served">
|
|
353
|
+
<span class="none">
|
|
354
|
+
routed nowhere <i>— no channel, no pin</i>
|
|
355
|
+
</span>
|
|
356
|
+
</span>
|
|
357
|
+
<span class="rail dash" />
|
|
358
|
+
<a class="aud" href="/admin/users?nobuild=1">
|
|
359
|
+
<b>
|
|
360
|
+
{data.offMap.length} {data.offMap.length === 1 ? "user" : "users"}
|
|
361
|
+
</b>
|
|
362
|
+
<em class="w">never served</em>
|
|
363
|
+
</a>
|
|
364
|
+
</li>
|
|
365
|
+
) : null}
|
|
366
|
+
</ul>
|
|
367
|
+
</section>
|
|
368
|
+
|
|
369
|
+
<div class="cols">
|
|
370
|
+
<section aria-label="Needs attention">
|
|
371
|
+
<div class="slab">
|
|
372
|
+
<h2>Needs attention</h2>
|
|
373
|
+
</div>
|
|
374
|
+
{attention.length === 0 ? (
|
|
375
|
+
<p class="allgood">
|
|
376
|
+
<Dot kind="ok" /> Nothing needs attention — every active user is served.
|
|
377
|
+
</p>
|
|
378
|
+
) : (
|
|
379
|
+
<ul class="attn">
|
|
380
|
+
{attention.map((item) => (
|
|
381
|
+
<li>
|
|
382
|
+
<span class={`dot ${item.dot}`} />
|
|
383
|
+
<div>
|
|
384
|
+
<b>{item.title}</b>
|
|
385
|
+
<p>{item.body}</p>
|
|
386
|
+
</div>
|
|
387
|
+
<a class="fix" href={item.fix.href}>
|
|
388
|
+
{item.fix.label}
|
|
389
|
+
</a>
|
|
390
|
+
</li>
|
|
391
|
+
))}
|
|
392
|
+
</ul>
|
|
393
|
+
)}
|
|
394
|
+
</section>
|
|
395
|
+
|
|
396
|
+
<section aria-label="Recent">
|
|
397
|
+
<div class="slab">
|
|
398
|
+
<h2>Recent</h2>
|
|
399
|
+
<a href="/admin/activity">activity →</a>
|
|
400
|
+
</div>
|
|
401
|
+
{data.recent.length === 0 ? (
|
|
402
|
+
<p class="empty">No activity yet — invite a user and publish a build.</p>
|
|
403
|
+
) : (
|
|
404
|
+
<ol class="rec">
|
|
405
|
+
{data.recent.map((item) => (
|
|
406
|
+
<li>
|
|
407
|
+
<When iso={item.at} now={now} />
|
|
408
|
+
<RecentLine item={item} />
|
|
409
|
+
</li>
|
|
410
|
+
))}
|
|
411
|
+
</ol>
|
|
412
|
+
)}
|
|
413
|
+
{data.chain !== null ? (
|
|
414
|
+
<p class={data.chain.intact ? "seal" : "seal bad"} style="margin-top:14px">
|
|
415
|
+
<Dot kind={data.chain.intact ? "ok" : "warn"} />
|
|
416
|
+
{data.chain.intact
|
|
417
|
+
? `audit chain intact · ${data.chain.count} entries${data.chain.anchored ? "" : " · not yet anchored"}`
|
|
418
|
+
: "AUDIT CHAIN MISMATCH — inspect the audit log"}
|
|
419
|
+
</p>
|
|
420
|
+
) : null}
|
|
421
|
+
</section>
|
|
422
|
+
</div>
|
|
423
|
+
</AdminLayout>
|
|
424
|
+
);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// ————————————————————————————— Requests —————————————————————————————
|
|
428
|
+
|
|
429
|
+
export const PendingPage: FC<{ requests: AccessRequest[]; now: string; chrome: Chrome }> = ({
|
|
430
|
+
requests,
|
|
431
|
+
now,
|
|
432
|
+
chrome,
|
|
433
|
+
}) => {
|
|
434
|
+
// Duplicates are the norm (copy-paste mode sends no confirmation) — group by email, act once.
|
|
435
|
+
const byEmail = new Map<string, AccessRequest[]>();
|
|
436
|
+
for (const r of requests) {
|
|
437
|
+
byEmail.set(r.email, [...(byEmail.get(r.email) ?? []), r]);
|
|
438
|
+
}
|
|
439
|
+
const grouped = [...byEmail.entries()];
|
|
440
|
+
return (
|
|
441
|
+
<AdminLayout title="Requests" chrome={chrome}>
|
|
442
|
+
{grouped.length === 0 ? (
|
|
443
|
+
<p class="empty">No pending access requests. The public request page feeds this list.</p>
|
|
444
|
+
) : (
|
|
445
|
+
<section>
|
|
446
|
+
<div class="slab">
|
|
447
|
+
<h2>Waiting for a decision</h2>
|
|
448
|
+
<span class="hint">Invite creates the user and shows the link to send</span>
|
|
449
|
+
</div>
|
|
450
|
+
<ul class="rows">
|
|
451
|
+
{grouped.map(([email, rows]) => (
|
|
452
|
+
<li>
|
|
453
|
+
<span>
|
|
454
|
+
<span class="who">{email}</span>
|
|
455
|
+
{rows.length > 1 ? <span class="lbl">asked {rows.length} times</span> : null}
|
|
456
|
+
</span>
|
|
457
|
+
<span class="t">
|
|
458
|
+
<When iso={rows[0]?.createdAt ?? null} now={now} />
|
|
459
|
+
</span>
|
|
460
|
+
<Post action={`/admin/pending/${rows[0]?.id}/invite`} label="Invite" />
|
|
461
|
+
<Post
|
|
462
|
+
action={`/admin/pending/${rows[0]?.id}/dismiss`}
|
|
463
|
+
label="Dismiss"
|
|
464
|
+
hidden={{ return_to: "/admin/pending" }}
|
|
465
|
+
/>
|
|
466
|
+
</li>
|
|
467
|
+
))}
|
|
468
|
+
</ul>
|
|
469
|
+
</section>
|
|
470
|
+
)}
|
|
471
|
+
</AdminLayout>
|
|
472
|
+
);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// ————————————————————————————— Users —————————————————————————————
|
|
476
|
+
|
|
477
|
+
export interface UsersFilter {
|
|
478
|
+
status: string;
|
|
479
|
+
stream: string;
|
|
480
|
+
nobuild: boolean;
|
|
481
|
+
pinned: boolean;
|
|
482
|
+
hidden: boolean;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export const UsersPage: FC<{
|
|
486
|
+
users: UserView[];
|
|
487
|
+
channels: Stream[];
|
|
488
|
+
filter: UsersFilter;
|
|
489
|
+
hiddenCount: number;
|
|
490
|
+
now: string;
|
|
491
|
+
chrome: Chrome;
|
|
492
|
+
}> = ({ users, channels, filter, hiddenCount, now, chrome }) => {
|
|
493
|
+
const channelId = new Map(channels.map((s) => [s.name, s.id]));
|
|
494
|
+
const revoked = users.filter((u) => u.status === "revoked").length;
|
|
495
|
+
return (
|
|
496
|
+
<AdminLayout
|
|
497
|
+
title="Users"
|
|
498
|
+
chrome={chrome}
|
|
499
|
+
head={
|
|
500
|
+
<p class="sub">
|
|
501
|
+
{users.length} shown{revoked > 0 ? ` · ${revoked} revoked` : ""}
|
|
502
|
+
</p>
|
|
503
|
+
}
|
|
504
|
+
>
|
|
505
|
+
<form method="get" action="/admin/users" class="filters">
|
|
506
|
+
<label>
|
|
507
|
+
status
|
|
508
|
+
<select name="status">
|
|
509
|
+
<option value="">any</option>
|
|
510
|
+
<option value="active" selected={filter.status === "active"}>
|
|
511
|
+
active
|
|
512
|
+
</option>
|
|
513
|
+
<option value="revoked" selected={filter.status === "revoked"}>
|
|
514
|
+
revoked
|
|
515
|
+
</option>
|
|
516
|
+
</select>
|
|
517
|
+
</label>
|
|
518
|
+
<label>
|
|
519
|
+
channel
|
|
520
|
+
<select name="stream">
|
|
521
|
+
<option value="">any</option>
|
|
522
|
+
{channels.map((s) => (
|
|
523
|
+
<option value={s.name} selected={filter.stream === s.name}>
|
|
524
|
+
{s.name}
|
|
525
|
+
</option>
|
|
526
|
+
))}
|
|
527
|
+
</select>
|
|
528
|
+
</label>
|
|
529
|
+
<label>
|
|
530
|
+
<input type="checkbox" name="nobuild" value="1" checked={filter.nobuild} /> needs
|
|
531
|
+
attention
|
|
532
|
+
</label>
|
|
533
|
+
<label>
|
|
534
|
+
<input type="checkbox" name="pinned" value="1" checked={filter.pinned} /> pinned
|
|
535
|
+
</label>
|
|
536
|
+
<label>
|
|
537
|
+
<input type="checkbox" name="hidden" value="1" checked={filter.hidden} /> show hidden
|
|
538
|
+
</label>
|
|
539
|
+
<button type="submit">Filter</button>
|
|
540
|
+
<a href="/admin/users">clear</a>
|
|
541
|
+
</form>
|
|
542
|
+
|
|
543
|
+
{users.length === 0 ? (
|
|
544
|
+
<p class="empty">No users match.</p>
|
|
545
|
+
) : (
|
|
546
|
+
<section>
|
|
547
|
+
<div class="tbl">
|
|
548
|
+
<table data-enhance>
|
|
549
|
+
<thead>
|
|
550
|
+
<tr>
|
|
551
|
+
<Th sort="text">User</Th>
|
|
552
|
+
<Th col="channels" sort="text">
|
|
553
|
+
Channels
|
|
554
|
+
</Th>
|
|
555
|
+
<th>Next check</th>
|
|
556
|
+
<Th sort="num" right>
|
|
557
|
+
Installed
|
|
558
|
+
</Th>
|
|
559
|
+
<Th sort="text">Last seen</Th>
|
|
560
|
+
</tr>
|
|
561
|
+
</thead>
|
|
562
|
+
<tbody>
|
|
563
|
+
{users.map((u) => (
|
|
564
|
+
<tr class={u.status === "revoked" || u.hidden ? "dim" : ""}>
|
|
565
|
+
<td>
|
|
566
|
+
<a class="who" href={`/admin/users/${u.id}`}>
|
|
567
|
+
{u.email}
|
|
568
|
+
</a>
|
|
569
|
+
{u.label ? <span class="lbl">{u.label}</span> : null}{" "}
|
|
570
|
+
{u.status === "revoked" ? (
|
|
571
|
+
<Tag kind="mut" label="revoked" title="Not served; reactivate to restore" />
|
|
572
|
+
) : null}
|
|
573
|
+
{u.hidden ? <Tag kind="mut" label="hidden" /> : null}
|
|
574
|
+
</td>
|
|
575
|
+
<td class="chs" data-value={u.streams.join(",")}>
|
|
576
|
+
{u.streams.length > 0 ? (
|
|
577
|
+
u.streams.map((name, i) => (
|
|
578
|
+
<>
|
|
579
|
+
{i > 0 ? " · " : ""}
|
|
580
|
+
<a href={`/admin/streams/${channelId.get(name) ?? ""}`}>{name}</a>
|
|
581
|
+
</>
|
|
582
|
+
))
|
|
583
|
+
) : (
|
|
584
|
+
<span class="mut">—</span>
|
|
585
|
+
)}
|
|
586
|
+
</td>
|
|
587
|
+
<td>
|
|
588
|
+
<VerdictCell verdict={u.verdict} />
|
|
589
|
+
</td>
|
|
590
|
+
<td class="r" data-value={String(u.currentBuild ?? "")}>
|
|
591
|
+
{u.currentBuild !== null ? (
|
|
592
|
+
<b class="num">#{u.currentBuild}</b>
|
|
593
|
+
) : (
|
|
594
|
+
<span class="mut">—</span>
|
|
595
|
+
)}
|
|
596
|
+
</td>
|
|
597
|
+
<td data-value={u.lastSeen ?? ""}>
|
|
598
|
+
<When iso={u.lastSeen} now={now} />
|
|
599
|
+
</td>
|
|
600
|
+
</tr>
|
|
601
|
+
))}
|
|
602
|
+
</tbody>
|
|
603
|
+
</table>
|
|
604
|
+
</div>
|
|
605
|
+
<p class="tfoot">
|
|
606
|
+
<span data-table-status />
|
|
607
|
+
{hiddenCount > 0 && !filter.hidden ? (
|
|
608
|
+
<a href="/admin/users?hidden=1">{hiddenCount} hidden not shown — show them</a>
|
|
609
|
+
) : null}
|
|
610
|
+
</p>
|
|
611
|
+
<p class="empty" data-table-empty hidden>
|
|
612
|
+
No users match the filters.
|
|
613
|
+
</p>
|
|
614
|
+
</section>
|
|
615
|
+
)}
|
|
616
|
+
|
|
617
|
+
<section aria-label="Add user">
|
|
618
|
+
<div class="slab">
|
|
619
|
+
<h2>Add user</h2>
|
|
620
|
+
<span class="hint">the invite link appears on the next page, ready to copy</span>
|
|
621
|
+
</div>
|
|
622
|
+
<form method="post" action="/admin/clients" class="frow">
|
|
623
|
+
<label class="field">
|
|
624
|
+
<span>Email</span>
|
|
625
|
+
<input type="email" name="email" required placeholder="e.g. mira@studio.dev" />
|
|
626
|
+
</label>
|
|
627
|
+
<label class="field">
|
|
628
|
+
<span>
|
|
629
|
+
Label <i>· optional</i>
|
|
630
|
+
</span>
|
|
631
|
+
<input type="text" name="label" placeholder="e.g. design partner" />
|
|
632
|
+
</label>
|
|
633
|
+
<label class="field">
|
|
634
|
+
<span>Channel</span>
|
|
635
|
+
<select name="streamId">
|
|
636
|
+
<option value="">— none —</option>
|
|
637
|
+
{channels.map((s) => (
|
|
638
|
+
<option value={s.id}>{s.name}</option>
|
|
639
|
+
))}
|
|
640
|
+
</select>
|
|
641
|
+
</label>
|
|
642
|
+
<button type="submit" class="btn-primary">
|
|
643
|
+
Create invite
|
|
644
|
+
</button>
|
|
645
|
+
</form>
|
|
646
|
+
<p class="fhint">
|
|
647
|
+
A user with no channel receives no updates until you assign one — leave it empty only if
|
|
648
|
+
you mean to.
|
|
649
|
+
</p>
|
|
650
|
+
</section>
|
|
651
|
+
</AdminLayout>
|
|
652
|
+
);
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// ————————————————————————————— Builds —————————————————————————————
|
|
656
|
+
|
|
657
|
+
const FREE_TIER_BYTES = 10 * 1024 * 1024 * 1024; // R2 free tier = 10 GB
|
|
658
|
+
|
|
659
|
+
export const BuildsPage: FC<{
|
|
660
|
+
builds: BuildView[];
|
|
661
|
+
channels: Stream[];
|
|
662
|
+
showHidden: boolean;
|
|
663
|
+
hiddenCount: number;
|
|
664
|
+
storageBytes: number;
|
|
665
|
+
now: string;
|
|
666
|
+
chrome: Chrome;
|
|
667
|
+
}> = ({ builds, channels, showHidden, hiddenCount, storageBytes, now, chrome }) => {
|
|
668
|
+
const channelId = new Map(channels.map((s) => [s.name, s.id]));
|
|
669
|
+
const nearCap = storageBytes > FREE_TIER_BYTES * 0.8;
|
|
670
|
+
return (
|
|
671
|
+
<AdminLayout
|
|
672
|
+
title="Builds"
|
|
673
|
+
chrome={chrome}
|
|
674
|
+
head={
|
|
675
|
+
<>
|
|
676
|
+
<p class="sub" title={`${storageBytes} bytes stored in R2`}>
|
|
677
|
+
{nearCap ? <Tag kind="warn" label="storage" /> : null} {formatBytes(storageBytes)} of
|
|
678
|
+
archives{nearCap ? " — near the 10 GB free tier; purge withdrawn builds" : ""}
|
|
679
|
+
</p>
|
|
680
|
+
<p class="inv">
|
|
681
|
+
<a href="/admin/upload">Upload a build →</a>
|
|
682
|
+
</p>
|
|
683
|
+
</>
|
|
684
|
+
}
|
|
685
|
+
>
|
|
686
|
+
{builds.length === 0 ? (
|
|
687
|
+
<p class="empty">
|
|
688
|
+
{showHidden
|
|
689
|
+
? "No builds published yet. Use Upload (or CI) to publish one."
|
|
690
|
+
: "No visible builds. Use Upload (or CI) to publish one, or show hidden builds."}
|
|
691
|
+
</p>
|
|
692
|
+
) : (
|
|
693
|
+
<section>
|
|
694
|
+
{/* Client-side instant filters (table-enhance.ts) narrow without a reload; the show-hidden
|
|
695
|
+
toggle is a server round-trip because hidden rows aren't in the page at all. */}
|
|
696
|
+
<div class="filters">
|
|
697
|
+
<label>
|
|
698
|
+
state
|
|
699
|
+
<select data-filter-col="state" aria-label="Filter by state">
|
|
700
|
+
<option value="">any</option>
|
|
701
|
+
<option value="available">available</option>
|
|
702
|
+
<option value="withdrawn">withdrawn</option>
|
|
703
|
+
</select>
|
|
704
|
+
</label>
|
|
705
|
+
<label>
|
|
706
|
+
<input type="checkbox" data-filter-col="crit" data-filter-value="yes" /> critical only
|
|
707
|
+
</label>
|
|
708
|
+
<label>
|
|
709
|
+
channel
|
|
710
|
+
<select
|
|
711
|
+
data-filter-col="channels"
|
|
712
|
+
data-filter-match="contains"
|
|
713
|
+
aria-label="Filter by channel"
|
|
714
|
+
>
|
|
715
|
+
<option value="">any</option>
|
|
716
|
+
{channels.map((s) => (
|
|
717
|
+
<option value={s.name}>{s.name}</option>
|
|
718
|
+
))}
|
|
719
|
+
</select>
|
|
720
|
+
</label>
|
|
721
|
+
<form method="get" action="/admin/builds" class="inline">
|
|
722
|
+
<label>
|
|
723
|
+
<input type="checkbox" name="hidden" value="1" checked={showHidden} /> show hidden
|
|
724
|
+
</label>
|
|
725
|
+
<button type="submit">Apply</button>
|
|
726
|
+
</form>
|
|
727
|
+
</div>
|
|
728
|
+
|
|
729
|
+
{/* Bulk bar (§13 #3). Row checkboxes bind via the HTML `form` attribute; the header
|
|
730
|
+
select-all checkbox + live count are progressive enhancement (table-enhance.ts). */}
|
|
731
|
+
<form method="post" action="/admin/builds/bulk" id="bulk" class="filters">
|
|
732
|
+
<span class="mut">
|
|
733
|
+
With selected
|
|
734
|
+
<span data-selected-count />:
|
|
735
|
+
</span>
|
|
736
|
+
<button type="submit" name="op" value="withdraw">
|
|
737
|
+
Withdraw
|
|
738
|
+
</button>
|
|
739
|
+
<button type="submit" name="op" value="critical">
|
|
740
|
+
Mark critical
|
|
741
|
+
</button>
|
|
742
|
+
<button type="submit" name="op" value="uncritical">
|
|
743
|
+
Clear critical
|
|
744
|
+
</button>
|
|
745
|
+
</form>
|
|
746
|
+
|
|
747
|
+
<div class="tbl">
|
|
748
|
+
<table data-enhance>
|
|
749
|
+
<thead>
|
|
750
|
+
<tr>
|
|
751
|
+
<th>
|
|
752
|
+
<input type="checkbox" data-check-all aria-label="Select all builds" />
|
|
753
|
+
</th>
|
|
754
|
+
<Th sort="num">Build</Th>
|
|
755
|
+
<Th col="state" sort="text">
|
|
756
|
+
State
|
|
757
|
+
</Th>
|
|
758
|
+
<Th col="crit">
|
|
759
|
+
<span class="sr-only">Critical</span>
|
|
760
|
+
</Th>
|
|
761
|
+
<Th col="channels" sort="text">
|
|
762
|
+
Channels
|
|
763
|
+
</Th>
|
|
764
|
+
<Th sort="num" right>
|
|
765
|
+
Downloads
|
|
766
|
+
</Th>
|
|
767
|
+
<Th sort="num" right>
|
|
768
|
+
Updates
|
|
769
|
+
</Th>
|
|
770
|
+
<Th sort="num" right>
|
|
771
|
+
Size
|
|
772
|
+
</Th>
|
|
773
|
+
<Th sort="text">Published</Th>
|
|
774
|
+
</tr>
|
|
775
|
+
</thead>
|
|
776
|
+
<tbody>
|
|
777
|
+
{builds.map((b) => (
|
|
778
|
+
<tr class={b.build.status === "withdrawn" ? "dim" : ""}>
|
|
779
|
+
<td>
|
|
780
|
+
<input
|
|
781
|
+
type="checkbox"
|
|
782
|
+
name="id"
|
|
783
|
+
value={b.build.id}
|
|
784
|
+
form="bulk"
|
|
785
|
+
aria-label={`Select build ${b.build.buildNumber}`}
|
|
786
|
+
/>
|
|
787
|
+
</td>
|
|
788
|
+
<td>
|
|
789
|
+
<Lk build={b.build} href={`/admin/builds/${b.build.id}`} />
|
|
790
|
+
</td>
|
|
791
|
+
<td data-value={b.build.status}>
|
|
792
|
+
<BuildTags build={b.build} />
|
|
793
|
+
</td>
|
|
794
|
+
<td data-value={b.build.critical ? "yes" : "no"}>
|
|
795
|
+
<span class="sr-only">{b.build.critical ? "critical" : "not critical"}</span>
|
|
796
|
+
</td>
|
|
797
|
+
<td class="chs" data-value={b.streams.join(",")}>
|
|
798
|
+
{b.streams.length > 0 ? (
|
|
799
|
+
b.streams.map((name, i) => (
|
|
800
|
+
<>
|
|
801
|
+
{i > 0 ? " · " : ""}
|
|
802
|
+
<a href={`/admin/streams/${channelId.get(name) ?? ""}`}>{name}</a>
|
|
803
|
+
</>
|
|
804
|
+
))
|
|
805
|
+
) : (
|
|
806
|
+
<span class="mut" title="In no channel — offered to no one">
|
|
807
|
+
—
|
|
808
|
+
</span>
|
|
809
|
+
)}
|
|
810
|
+
</td>
|
|
811
|
+
<td class="r">{b.downloads}</td>
|
|
812
|
+
<td class="r">{b.updates}</td>
|
|
813
|
+
<td
|
|
814
|
+
class="r"
|
|
815
|
+
data-value={String(b.build.purgedAt !== null ? 0 : b.build.length)}
|
|
816
|
+
>
|
|
817
|
+
{b.build.purgedAt !== null ? (
|
|
818
|
+
<Tag kind="mut" label="purged" title="Archive bytes deleted; record kept" />
|
|
819
|
+
) : (
|
|
820
|
+
<span title={`${b.build.length} bytes`}>{formatBytes(b.build.length)}</span>
|
|
821
|
+
)}
|
|
822
|
+
</td>
|
|
823
|
+
<td data-value={b.build.createdAt}>
|
|
824
|
+
<When iso={b.build.createdAt} now={now} />
|
|
825
|
+
</td>
|
|
826
|
+
</tr>
|
|
827
|
+
))}
|
|
828
|
+
</tbody>
|
|
829
|
+
</table>
|
|
830
|
+
</div>
|
|
831
|
+
<p class="tfoot">
|
|
832
|
+
<span data-table-status />
|
|
833
|
+
{hiddenCount > 0 && !showHidden ? (
|
|
834
|
+
<a href="/admin/builds?hidden=1">{hiddenCount} hidden not shown — show them</a>
|
|
835
|
+
) : null}
|
|
836
|
+
</p>
|
|
837
|
+
<p class="empty" data-table-empty hidden>
|
|
838
|
+
No builds match the filters.
|
|
839
|
+
</p>
|
|
840
|
+
</section>
|
|
841
|
+
)}
|
|
842
|
+
</AdminLayout>
|
|
843
|
+
);
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
// ————————————————————————————— Channels —————————————————————————————
|
|
847
|
+
|
|
848
|
+
export const StreamsPage: FC<{ streams: StreamView[]; chrome: Chrome }> = ({ streams, chrome }) => (
|
|
849
|
+
<AdminLayout title="Channels" chrome={chrome}>
|
|
850
|
+
{streams.length === 0 ? (
|
|
851
|
+
<p class="empty">
|
|
852
|
+
No channels yet. Users and builds attach to channels — create one (e.g. “stable”) to start
|
|
853
|
+
serving updates.
|
|
854
|
+
</p>
|
|
855
|
+
) : (
|
|
856
|
+
<section>
|
|
857
|
+
<div class="tbl">
|
|
858
|
+
<table data-enhance>
|
|
859
|
+
<thead>
|
|
860
|
+
<tr>
|
|
861
|
+
<Th sort="text">Channel</Th>
|
|
862
|
+
<th>Serving</th>
|
|
863
|
+
<Th sort="num" right>
|
|
864
|
+
Builds
|
|
865
|
+
</Th>
|
|
866
|
+
<Th sort="num" right>
|
|
867
|
+
Users
|
|
868
|
+
</Th>
|
|
869
|
+
</tr>
|
|
870
|
+
</thead>
|
|
871
|
+
<tbody>
|
|
872
|
+
{streams.map((s) => (
|
|
873
|
+
<tr>
|
|
874
|
+
<td class="chs">
|
|
875
|
+
<a class="who" href={`/admin/streams/${s.id}`}>
|
|
876
|
+
{s.name}
|
|
877
|
+
</a>
|
|
878
|
+
</td>
|
|
879
|
+
<td>
|
|
880
|
+
{s.topBuild ? (
|
|
881
|
+
<Lk build={s.topBuild} />
|
|
882
|
+
) : (
|
|
883
|
+
<span class="vd">
|
|
884
|
+
<Tag
|
|
885
|
+
kind="warn"
|
|
886
|
+
label="serving nothing"
|
|
887
|
+
title="No available build linked"
|
|
888
|
+
/>
|
|
889
|
+
</span>
|
|
890
|
+
)}
|
|
891
|
+
</td>
|
|
892
|
+
<td class="r">{s.buildCount}</td>
|
|
893
|
+
<td class="r">{s.userCount}</td>
|
|
894
|
+
</tr>
|
|
895
|
+
))}
|
|
896
|
+
</tbody>
|
|
897
|
+
</table>
|
|
898
|
+
</div>
|
|
899
|
+
</section>
|
|
900
|
+
)}
|
|
901
|
+
|
|
902
|
+
<section aria-label="Add channel">
|
|
903
|
+
<div class="slab">
|
|
904
|
+
<h2>Add channel</h2>
|
|
905
|
+
</div>
|
|
906
|
+
<form method="post" action="/admin/streams" class="frow">
|
|
907
|
+
<label class="field">
|
|
908
|
+
<span>Name</span>
|
|
909
|
+
<input type="text" name="name" required placeholder="e.g. stable" />
|
|
910
|
+
</label>
|
|
911
|
+
<button type="submit" class="btn-primary">
|
|
912
|
+
Create channel
|
|
913
|
+
</button>
|
|
914
|
+
</form>
|
|
915
|
+
<p class="fhint">
|
|
916
|
+
A channel serves its highest available linked build to every assigned user. Deleting one is
|
|
917
|
+
confirmed on its page.
|
|
918
|
+
</p>
|
|
919
|
+
</section>
|
|
920
|
+
</AdminLayout>
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
// ————————————————————————————— Activity —————————————————————————————
|
|
924
|
+
|
|
925
|
+
export interface ActivityFilterView {
|
|
926
|
+
email: string;
|
|
927
|
+
event: string;
|
|
928
|
+
build: string;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
export const ActivityPage: FC<{
|
|
932
|
+
events: AccessLogEntry[];
|
|
933
|
+
filter: ActivityFilterView;
|
|
934
|
+
truncated: boolean;
|
|
935
|
+
now: string;
|
|
936
|
+
chrome: Chrome;
|
|
937
|
+
}> = ({ events, filter, truncated, now, chrome }) => (
|
|
938
|
+
<AdminLayout title="Activity" chrome={chrome}>
|
|
939
|
+
<form method="get" action="/admin/activity" class="filters">
|
|
940
|
+
<label>
|
|
941
|
+
user
|
|
942
|
+
<input type="text" name="email" placeholder="any part of the email" value={filter.email} />
|
|
943
|
+
</label>
|
|
944
|
+
<label>
|
|
945
|
+
event
|
|
946
|
+
<select name="event">
|
|
947
|
+
<option value="">any</option>
|
|
948
|
+
{["check", "download", "update"].map((e) => (
|
|
949
|
+
<option value={e} selected={filter.event === e}>
|
|
950
|
+
{e}
|
|
951
|
+
</option>
|
|
952
|
+
))}
|
|
953
|
+
</select>
|
|
954
|
+
</label>
|
|
955
|
+
<label>
|
|
956
|
+
build
|
|
957
|
+
<input type="text" name="build" placeholder="e.g. 1500" value={filter.build} />
|
|
958
|
+
</label>
|
|
959
|
+
<button type="submit">Filter</button>
|
|
960
|
+
<a href="/admin/activity">clear</a>
|
|
961
|
+
</form>
|
|
962
|
+
|
|
963
|
+
{events.length === 0 ? (
|
|
964
|
+
<p class="empty">No activity matches.</p>
|
|
965
|
+
) : (
|
|
966
|
+
<section>
|
|
967
|
+
<div class="tbl">
|
|
968
|
+
<table data-enhance>
|
|
969
|
+
<thead>
|
|
970
|
+
<tr>
|
|
971
|
+
<Th sort="text">When</Th>
|
|
972
|
+
<Th sort="text">User</Th>
|
|
973
|
+
<Th sort="text">Event</Th>
|
|
974
|
+
<Th sort="num" right>
|
|
975
|
+
Build
|
|
976
|
+
</Th>
|
|
977
|
+
</tr>
|
|
978
|
+
</thead>
|
|
979
|
+
<tbody>
|
|
980
|
+
{events.map((e) => (
|
|
981
|
+
<tr>
|
|
982
|
+
<td data-value={e.createdAt}>
|
|
983
|
+
<When iso={e.createdAt} now={now} />
|
|
984
|
+
</td>
|
|
985
|
+
<td>{e.email ?? <span class="mut">—</span>}</td>
|
|
986
|
+
<td>{e.event}</td>
|
|
987
|
+
<td class="r">
|
|
988
|
+
{e.buildNumber !== null ? (
|
|
989
|
+
<b class="num">#{e.buildNumber}</b>
|
|
990
|
+
) : (
|
|
991
|
+
<span class="mut">—</span>
|
|
992
|
+
)}
|
|
993
|
+
</td>
|
|
994
|
+
</tr>
|
|
995
|
+
))}
|
|
996
|
+
</tbody>
|
|
997
|
+
</table>
|
|
998
|
+
</div>
|
|
999
|
+
<p class="tfoot">
|
|
1000
|
+
{truncated ? "Showing the latest 100 events — narrow with the filters above. " : ""}
|
|
1001
|
+
Checks, downloads and updates older than 90 days are pruned nightly.
|
|
1002
|
+
</p>
|
|
1003
|
+
</section>
|
|
1004
|
+
)}
|
|
1005
|
+
</AdminLayout>
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
// ————————————————————————————— Audit —————————————————————————————
|
|
1009
|
+
|
|
1010
|
+
export interface AuditFilterView {
|
|
1011
|
+
actor: string;
|
|
1012
|
+
action: string;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export const AuditPage: FC<{
|
|
1016
|
+
rows: AuditRow[];
|
|
1017
|
+
filter: AuditFilterView;
|
|
1018
|
+
chain: ChainAssessment | null;
|
|
1019
|
+
truncated: boolean;
|
|
1020
|
+
now: string;
|
|
1021
|
+
chrome: Chrome;
|
|
1022
|
+
}> = ({ rows, filter, chain, truncated, now, chrome }) => (
|
|
1023
|
+
<AdminLayout
|
|
1024
|
+
title="Audit"
|
|
1025
|
+
chrome={chrome}
|
|
1026
|
+
head={
|
|
1027
|
+
chain !== null ? (
|
|
1028
|
+
<p class={chain.intact ? "seal" : "seal bad"}>
|
|
1029
|
+
<Dot kind={chain.intact ? "ok" : "warn"} />
|
|
1030
|
+
{chain.intact
|
|
1031
|
+
? `chain intact · ${chain.count} entries${chain.anchored ? " · anchored" : " · not yet anchored"}`
|
|
1032
|
+
: "CHAIN MISMATCH — the log diverged from its last anchor"}
|
|
1033
|
+
</p>
|
|
1034
|
+
) : undefined
|
|
1035
|
+
}
|
|
1036
|
+
>
|
|
1037
|
+
{chain !== null && !chain.intact ? (
|
|
1038
|
+
<p class="callout danger">
|
|
1039
|
+
The audit log no longer verifies against its last anchor — rows were edited, removed, or
|
|
1040
|
+
rebuilt. Compare with the anchored copies in R2 and your Cloudflare account audit logs.
|
|
1041
|
+
</p>
|
|
1042
|
+
) : null}
|
|
1043
|
+
<form method="get" action="/admin/audit" class="filters">
|
|
1044
|
+
<label>
|
|
1045
|
+
actor
|
|
1046
|
+
<input type="text" name="actor" placeholder="any part of the email" value={filter.actor} />
|
|
1047
|
+
</label>
|
|
1048
|
+
<label>
|
|
1049
|
+
action
|
|
1050
|
+
<input type="text" name="action" placeholder="e.g. client.revoke" value={filter.action} />
|
|
1051
|
+
</label>
|
|
1052
|
+
<button type="submit">Filter</button>
|
|
1053
|
+
<a href="/admin/audit">clear</a>
|
|
1054
|
+
</form>
|
|
1055
|
+
|
|
1056
|
+
{rows.length === 0 ? (
|
|
1057
|
+
<p class="empty">No admin actions match.</p>
|
|
1058
|
+
) : (
|
|
1059
|
+
<section>
|
|
1060
|
+
<div class="tbl">
|
|
1061
|
+
<table data-enhance>
|
|
1062
|
+
<thead>
|
|
1063
|
+
<tr>
|
|
1064
|
+
<Th sort="text">When</Th>
|
|
1065
|
+
<Th sort="text">Actor</Th>
|
|
1066
|
+
<Th sort="text">Action</Th>
|
|
1067
|
+
<Th sort="text">Target</Th>
|
|
1068
|
+
<Th sort="text">IP</Th>
|
|
1069
|
+
<Th sort="text">Ray ID</Th>
|
|
1070
|
+
</tr>
|
|
1071
|
+
</thead>
|
|
1072
|
+
<tbody>
|
|
1073
|
+
{rows.map((r) => (
|
|
1074
|
+
<tr>
|
|
1075
|
+
<td data-value={r.createdAt}>
|
|
1076
|
+
<When iso={r.createdAt} now={now} />
|
|
1077
|
+
</td>
|
|
1078
|
+
<td>{r.actorEmail}</td>
|
|
1079
|
+
<td>
|
|
1080
|
+
<code>{r.action}</code>
|
|
1081
|
+
</td>
|
|
1082
|
+
<td>{r.target ?? <span class="mut">—</span>}</td>
|
|
1083
|
+
<td class="mut">{r.ip ?? "—"}</td>
|
|
1084
|
+
<td class="mut">
|
|
1085
|
+
<code>{r.rayId ?? "—"}</code>
|
|
1086
|
+
</td>
|
|
1087
|
+
</tr>
|
|
1088
|
+
))}
|
|
1089
|
+
</tbody>
|
|
1090
|
+
</table>
|
|
1091
|
+
</div>
|
|
1092
|
+
{truncated ? (
|
|
1093
|
+
<p class="tfoot">Showing the latest 200 actions — narrow with the filters above.</p>
|
|
1094
|
+
) : null}
|
|
1095
|
+
</section>
|
|
1096
|
+
)}
|
|
1097
|
+
</AdminLayout>
|
|
1098
|
+
);
|