authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-23 13:36:36-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 20:06:49-07:00
log76765cb2aed4394bf710c2bb4d026ec80115f593
tree46475555873058708de0aae1e334b3518e68cbc1
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Run home services on NixOS with isolated staging and main deployments

Assisted-by: gpt-6

328 files changed, 46869 insertions(+), 0 deletions(-)

.gitignore created+4
......@@ -0,0 +1,4 @@
1result
2secrets/
3config/.identities.lock
4config/identities.pending
config/Service.pkl created+184
......@@ -0,0 +1,184 @@
1open module Service
2
3import "site.pkl" as site
4
5const nomadHostPort = "{{ if regexMatch \":\" .Address }}[{{ .Address }}]{{ else }}{{ .Address }}{{ end }}:{{ .Port }}"
6
7/// Name shown in the management UI and service listings.
8class Metadata {
9 name: String
10 tagline: String = ""
11}
12
13/// One HTTP listener and its public route.
14class Http {
15 containerPort: UInt16
16 hostPort: UInt16?
17 subdomain: String?
18 authRole: String?
19 /// Backend header set from the authenticated OIDC preferred username.
20 userHeader: String?
21 /// Identity headers copied from a valid SSO session; anonymous requests continue.
22 identityHeaders: Mapping<String, String> = new {}
23 /// Set X-Real-Ip from Caddy's observed client address before proxying.
24 forwardRealIp: Boolean = false
25 /// Generated secret whose value becomes the private proxy header name.
26 identityProofSecret: String?
27 /// Additional hosts routed to this container without SSO headers.
28 plainHostnames: Listing<String> = new {}
29 hostname: String? = read?("prop:hostname") ?? if (subdomain != null) "\(subdomain).\(site.domain)" else null
30 tlsInternal: Boolean = site.tlsInternal
31 checkPath: String = "/"
32 /// Internal Prometheus endpoint collected by the home server dashboard.
33 metricsPath: String?
34 checkHeaders: Mapping<String, String> = new {}
35 /// Request path to a file or directory in this service's folder.
36 overrideFiles: Mapping<String, String> = new {}
37 /// HTML inserted before </head> for the listed page paths.
38 headHtml: Mapping<String, String> = new {}
39}
40
41/// One TCP listener published through Nomad.
42class Tcp {
43 name: String
44 containerPort: UInt16
45 hostPort: UInt16?
46 loopback: Boolean = true
47}
48
49/// A container mount, keyed by its absolute path inside the container.
50class Volume {
51 /// Host source; absent means the same path beneath this service's data root.
52 src: String?
53 /// File or directory relative to this service's assets folder; copied into a read-only mount.
54 config: String?
55 readOnly: Boolean = config != null
56}
57
58/// A persistent value generated when the service is first provisioned.
59class GeneratedSecret {
60 bytes: Int(isBetween(16, 128)) = 32
61}
62
63/// Provider-owned resource request; the alias names its allocated secret.
64open class Requirement {
65 alias: String
66 fixed provider: String
67 fixed kind: String
68}
69
70/// One Podman task. A service can contain one or several of these.
71class Container {
72 /// Use either an image or a build directory in this service's release folder.
73 image: String?
74 build: String?
75 entrypoint: String?
76 http: Http?
77 tcp: Tcp?
78 volumes: Mapping<String, Volume> = new {}
79 /// Podman tmpfs mounts for data that must not persist in service storage.
80 tmpfs: Listing<String> = new {}
81 /// `${secret.own.key}` and `${secret.alias.key}` resolve from Nomad variables.
82 env: Mapping<String, String> = new {}
83 /// Nomad template for environment values resolved after allocation.
84 envTemplate: String?
85 args: Listing<String> = new {}
86 capAdd: Listing<String> = new {}
87 devices: Listing<String> = new {}
88 extraHosts: Listing<String> = new {}
89 hostNetwork: Boolean = false
90 imageUser: Boolean = false
91 /// Prestart tasks finish first; prestart sidecars stay up for the main tasks.
92 lifecycle: "main"|"prestart"|"prestartSidecar" = "main"
93 rootGroup: Boolean = false
94 cpu: Int = 200
95 memory: Int = 512
96}
97
98/// Stable internal ID supplied from the service definition name by the caller.
99id: String = read?("prop:serviceId")
100/// Stable numeric owner assigned by the deployment tool.
101uid: Int = read("prop:uid").toInt()
102meta: Metadata
103/// Allows site-specific services to stay out of deployments where they would cause side effects.
104enabled: Boolean = true
105/// Simple replaces one allocation; overlapped runs a canary alongside it.
106rollout: String = "simple"
107/// Fresh previews create empty storage and new secrets instead of forking production.
108stageIsolation: "clone"|"fresh" = "clone"
109/// Time allowed for a new allocation to pass its service checks.
110healthyDeadline: String?
111/// Delay before persistent health-check failures may restart a running allocation.
112healthRestartGrace: String?
113/// Repeatable service configuration run after a healthy deployment.
114setup: String?
115/// Service-owned data preparation before its container starts.
116prepare: String?
117/// Handler for input requests owned by this service.
118provide: String?
119
120/// Use this for one container; it becomes the "app" task in the output.
121container: Container?
122
123/// Use this instead of `container` when the service has named tasks.
124containers: Mapping<String, Container>?
125
126secrets: Mapping<String, GeneratedSecret> = new {}
127/// Secret names supplied from outside the release, in import-file line order.
128requiredSecrets: Listing<String> = new {}
129requirements: Listing<Requirement> = new {}
130/// Service startup dependency with no resource to allocate.
131dependsOn: Listing<String> = new {}
132/// Resource service.name emitted by this app's trace exporter.
133traceServiceName: String?
134/// Metrics sent by the app over OTLP instead of a Prometheus HTTP endpoint.
135metricsPushed: Boolean = false
136
137local deploymentContainers: Mapping<String, Container> =
138 if (!enabled)
139 new Mapping {}
140 else if (container != null && containers != null)
141 throw("Declare either container or containers, not both")
142 else if (container != null)
143 new Mapping { ["app"] = container!! }
144 else if (containers != null && !containers!!.isEmpty)
145 containers!!
146 else if (!requirements.isEmpty)
147 new Mapping {}
148 else
149 throw("Declare a container or an input")
150
151local vmStartupGrace: String? = if (read?("env:STUDIO_VM_ACCEL") == "qemu") "60m" else null
152
153output {
154 renderer = new JsonRenderer {}
155 value = new {
156 id = module.id
157 name = module.meta.name
158 tagline = module.meta.tagline
159 rollout = module.rollout
160 stageIsolation = module.stageIsolation
161 healthyDeadline = vmStartupGrace ?? module.healthyDeadline
162 healthRestartGrace = vmStartupGrace ?? module.healthRestartGrace
163 hostRoot = "\(site.root)/prod/\(module.id)"
164 stagingRoot = "\(site.root)/staging"
165 pool = site.pool
166 cloverRoot = site.cloverRoot
167 cloverGid = site.cloverGid
168 mediaRoot = site.mediaRoot
169 ownerEmail = site.ownerEmail
170 containers = deploymentContainers
171 setup = module.setup
172 prepare = module.prepare
173 provide = module.provide
174 // External mounts do not require this service's dataset.
175 hasManagedVolumes = deploymentContainers.toMap().values.any((task) ->
176 task.volumes.toMap().values.any((volume) -> volume.src == null && volume.config == null))
177 secrets = module.secrets
178 requiredSecrets = module.requiredSecrets
179 requirements = module.requirements
180 dependsOn = module.dependsOn
181 traceServiceName = module.traceServiceName
182 metricsPushed = module.metricsPushed
183 }
184}
config/identities.json created+10
......@@ -0,0 +1,10 @@
1{
2 "shale": 3101,
3 "samba": 3102,
4 "postgres": 3103,
5 "keycloak": 1000,
6 "tailscale": 0,
7 "dawarich": 3100,
8 "forward-auth": 3105,
9 "jellyfin": 3106
10}
config/policies/dashboard.hcl created+7
......@@ -0,0 +1,7 @@
1namespace "default" {
2 capabilities = ["list-jobs", "read-job", "read-logs"]
3}
4
5node {
6 policy = "read"
7}
config/policies/router.hcl created+3
......@@ -0,0 +1,3 @@
1namespace "default" {
2 policy = "read"
3}
config/site.pkl created+17
......@@ -0,0 +1,17 @@
1module Site
2
3/// The path to the root ZFS store
4root: String = read?("prop:root") ?? read?("env:STUDIO_ROOT") ?? "/srv"
5pool: String = read?("prop:pool") ?? read?("env:STUDIO_POOL") ?? "studio-demo"
6nodeName: String = read?("prop:nodeName") ?? read?("env:STUDIO_NODE_NAME") ?? "clover-demo"
7/// The base domain that the infrastructure runs on
8domain: String = read?("prop:domain") ?? read?("env:STUDIO_DOMAIN") ?? "studio.test"
9ownerEmail: String = read?("prop:ownerEmail") ?? read?("env:STUDIO_OWNER_EMAIL") ?? "account@paperclover.net"
10cloverRoot: String = read?("prop:cloverRoot") ?? read?("env:STUDIO_CLOVER_ROOT") ?? "\(root)/clover"
11cloverUid: Int = (read?("prop:cloverUid") ?? read?("env:STUDIO_CLOVER_UID") ?? "3000").toInt()
12cloverGid: Int = (read?("prop:cloverGid") ?? read?("env:STUDIO_CLOVER_GID") ?? "3000").toInt()
13cloverReadOnly: Boolean = (read?("prop:cloverReadOnly") ?? read?("env:STUDIO_CLOVER_READ_ONLY") ?? "false") == "true"
14mediaRoot: String = read?("prop:mediaRoot") ?? read?("env:STUDIO_MEDIA_ROOT") ?? "\(cloverRoot)/Media"
15mediaReadOnly: Boolean = (read?("prop:mediaReadOnly") ?? read?("env:STUDIO_MEDIA_READ_ONLY") ?? "false") == "true"
16tlsInternal: Boolean = (read?("prop:tlsInternal") ?? read?("env:STUDIO_TLS_INTERNAL") ?? "false") == "true" || domain.endsWith(".test")
17preview: Boolean = (read?("prop:preview") ?? "false") == "true"
dashboard/.gitignore created+5
......@@ -0,0 +1,5 @@
1node_modules/
2dist/
3.cache/
4data/
5target/
dashboard/Cargo.lock created+2544
......@@ -0,0 +1,2544 @@
1# This file is automatically @generated by Cargo.
2# It is not intended for manual editing.
3version = 4
4
5[[package]]
6name = "aho-corasick"
7version = "1.1.5"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
10dependencies = [
11 "memchr",
12]
13
14[[package]]
15name = "android_system_properties"
16version = "0.1.6"
17source = "registry+https://github.com/rust-lang/crates.io-index"
18checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
19dependencies = [
20 "libc",
21]
22
23[[package]]
24name = "async-trait"
25version = "0.1.92"
26source = "registry+https://github.com/rust-lang/crates.io-index"
27checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
28dependencies = [
29 "proc-macro2",
30 "quote",
31 "syn 3.0.6",
32]
33
34[[package]]
35name = "atomic-waker"
36version = "1.1.2"
37source = "registry+https://github.com/rust-lang/crates.io-index"
38checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
39
40[[package]]
41name = "autocfg"
42version = "1.5.1"
43source = "registry+https://github.com/rust-lang/crates.io-index"
44checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
45
46[[package]]
47name = "axum"
48version = "0.8.9"
49source = "registry+https://github.com/rust-lang/crates.io-index"
50checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
51dependencies = [
52 "axum-core",
53 "base64 0.22.1",
54 "bytes",
55 "form_urlencoded",
56 "futures-util",
57 "http",
58 "http-body",
59 "http-body-util",
60 "hyper",
61 "hyper-util",
62 "itoa",
63 "matchit",
64 "memchr",
65 "mime",
66 "multer",
67 "percent-encoding",
68 "pin-project-lite",
69 "serde_core",
70 "serde_json",
71 "serde_path_to_error",
72 "serde_urlencoded",
73 "sha1",
74 "sync_wrapper",
75 "tokio",
76 "tokio-tungstenite",
77 "tower",
78 "tower-layer",
79 "tower-service",
80 "tracing",
81]
82
83[[package]]
84name = "axum-core"
85version = "0.5.6"
86source = "registry+https://github.com/rust-lang/crates.io-index"
87checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
88dependencies = [
89 "bytes",
90 "futures-core",
91 "http",
92 "http-body",
93 "http-body-util",
94 "mime",
95 "pin-project-lite",
96 "sync_wrapper",
97 "tower-layer",
98 "tower-service",
99 "tracing",
100]
101
102[[package]]
103name = "base64"
104version = "0.22.1"
105source = "registry+https://github.com/rust-lang/crates.io-index"
106checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
107
108[[package]]
109name = "base64"
110version = "0.23.1"
111source = "registry+https://github.com/rust-lang/crates.io-index"
112checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
113
114[[package]]
115name = "bitflags"
116version = "2.13.2"
117source = "registry+https://github.com/rust-lang/crates.io-index"
118checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
119
120[[package]]
121name = "block-buffer"
122version = "0.10.4"
123source = "registry+https://github.com/rust-lang/crates.io-index"
124checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
125dependencies = [
126 "generic-array",
127]
128
129[[package]]
130name = "bstr"
131version = "1.13.1"
132source = "registry+https://github.com/rust-lang/crates.io-index"
133checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
134dependencies = [
135 "memchr",
136 "serde_core",
137]
138
139[[package]]
140name = "bumpalo"
141version = "3.20.3"
142source = "registry+https://github.com/rust-lang/crates.io-index"
143checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
144
145[[package]]
146name = "bytes"
147version = "1.12.1"
148source = "registry+https://github.com/rust-lang/crates.io-index"
149checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
150
151[[package]]
152name = "cc"
153version = "1.5.1"
154source = "registry+https://github.com/rust-lang/crates.io-index"
155checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
156dependencies = [
157 "find-msvc-tools",
158 "shlex",
159]
160
161[[package]]
162name = "cfg-if"
163version = "1.0.5"
164source = "registry+https://github.com/rust-lang/crates.io-index"
165checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
166
167[[package]]
168name = "cfg_aliases"
169version = "0.2.2"
170source = "registry+https://github.com/rust-lang/crates.io-index"
171checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
172
173[[package]]
174name = "chacha20"
175version = "0.10.2"
176source = "registry+https://github.com/rust-lang/crates.io-index"
177checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
178dependencies = [
179 "cfg-if",
180 "cpufeatures 0.3.1",
181 "rand_core 0.10.1",
182]
183
184[[package]]
185name = "chrono"
186version = "0.4.45"
187source = "registry+https://github.com/rust-lang/crates.io-index"
188checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
189dependencies = [
190 "iana-time-zone",
191 "js-sys",
192 "num-traits",
193 "serde",
194 "wasm-bindgen",
195 "windows-link",
196]
197
198[[package]]
199name = "core-foundation-sys"
200version = "0.8.7"
201source = "registry+https://github.com/rust-lang/crates.io-index"
202checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
203
204[[package]]
205name = "core_detect"
206version = "1.0.0"
207source = "registry+https://github.com/rust-lang/crates.io-index"
208checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
209
210[[package]]
211name = "cpufeatures"
212version = "0.2.17"
213source = "registry+https://github.com/rust-lang/crates.io-index"
214checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
215dependencies = [
216 "libc",
217]
218
219[[package]]
220name = "cpufeatures"
221version = "0.3.1"
222source = "registry+https://github.com/rust-lang/crates.io-index"
223checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
224dependencies = [
225 "libc",
226]
227
228[[package]]
229name = "crypto-common"
230version = "0.1.7"
231source = "registry+https://github.com/rust-lang/crates.io-index"
232checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
233dependencies = [
234 "generic-array",
235 "typenum",
236]
237
238[[package]]
239name = "cssparser"
240version = "0.37.0"
241source = "registry+https://github.com/rust-lang/crates.io-index"
242checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
243dependencies = [
244 "cssparser-macros",
245 "dtoa-short",
246 "itoa",
247 "phf",
248 "smallvec",
249]
250
251[[package]]
252name = "cssparser-macros"
253version = "0.7.1"
254source = "registry+https://github.com/rust-lang/crates.io-index"
255checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
256dependencies = [
257 "quote",
258 "syn 3.0.6",
259]
260
261[[package]]
262name = "data-encoding"
263version = "2.11.1"
264source = "registry+https://github.com/rust-lang/crates.io-index"
265checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
266
267[[package]]
268name = "derive_more"
269version = "2.1.1"
270source = "registry+https://github.com/rust-lang/crates.io-index"
271checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
272dependencies = [
273 "derive_more-impl",
274]
275
276[[package]]
277name = "derive_more-impl"
278version = "2.1.1"
279source = "registry+https://github.com/rust-lang/crates.io-index"
280checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
281dependencies = [
282 "proc-macro2",
283 "quote",
284 "rustc_version",
285 "syn 2.0.119",
286]
287
288[[package]]
289name = "digest"
290version = "0.10.7"
291source = "registry+https://github.com/rust-lang/crates.io-index"
292checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
293dependencies = [
294 "block-buffer",
295 "crypto-common",
296]
297
298[[package]]
299name = "displaydoc"
300version = "0.2.7"
301source = "registry+https://github.com/rust-lang/crates.io-index"
302checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
303dependencies = [
304 "proc-macro2",
305 "quote",
306 "syn 3.0.6",
307]
308
309[[package]]
310name = "dtoa"
311version = "1.0.11"
312source = "registry+https://github.com/rust-lang/crates.io-index"
313checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
314
315[[package]]
316name = "dtoa-short"
317version = "0.3.5"
318source = "registry+https://github.com/rust-lang/crates.io-index"
319checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
320dependencies = [
321 "dtoa",
322]
323
324[[package]]
325name = "dyn-clone"
326version = "1.0.20"
327source = "registry+https://github.com/rust-lang/crates.io-index"
328checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
329
330[[package]]
331name = "ego-tree"
332version = "0.11.0"
333source = "registry+https://github.com/rust-lang/crates.io-index"
334checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3"
335
336[[package]]
337name = "encoding_rs"
338version = "0.8.42"
339source = "registry+https://github.com/rust-lang/crates.io-index"
340checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
341dependencies = [
342 "cfg-if",
343 "core_detect",
344 "multiversion_no_op",
345 "rustversion",
346 "scopeguard",
347 "simdutf8",
348]
349
350[[package]]
351name = "equivalent"
352version = "1.0.2"
353source = "registry+https://github.com/rust-lang/crates.io-index"
354checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
355
356[[package]]
357name = "errno"
358version = "0.3.14"
359source = "registry+https://github.com/rust-lang/crates.io-index"
360checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
361dependencies = [
362 "libc",
363 "windows-sys 0.61.2",
364]
365
366[[package]]
367name = "fallible-iterator"
368version = "0.3.0"
369source = "registry+https://github.com/rust-lang/crates.io-index"
370checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
371
372[[package]]
373name = "fallible-streaming-iterator"
374version = "0.1.9"
375source = "registry+https://github.com/rust-lang/crates.io-index"
376checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
377
378[[package]]
379name = "fastrand"
380version = "2.5.0"
381source = "registry+https://github.com/rust-lang/crates.io-index"
382checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
383
384[[package]]
385name = "find-msvc-tools"
386version = "0.1.14"
387source = "registry+https://github.com/rust-lang/crates.io-index"
388checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
389
390[[package]]
391name = "foldhash"
392version = "0.1.5"
393source = "registry+https://github.com/rust-lang/crates.io-index"
394checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
395
396[[package]]
397name = "form_urlencoded"
398version = "1.2.2"
399source = "registry+https://github.com/rust-lang/crates.io-index"
400checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
401dependencies = [
402 "percent-encoding",
403]
404
405[[package]]
406name = "futures"
407version = "0.3.34"
408source = "registry+https://github.com/rust-lang/crates.io-index"
409checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
410dependencies = [
411 "futures-channel",
412 "futures-core",
413 "futures-executor",
414 "futures-io",
415 "futures-sink",
416 "futures-task",
417 "futures-util",
418]
419
420[[package]]
421name = "futures-channel"
422version = "0.3.34"
423source = "registry+https://github.com/rust-lang/crates.io-index"
424checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
425dependencies = [
426 "futures-core",
427 "futures-sink",
428]
429
430[[package]]
431name = "futures-core"
432version = "0.3.34"
433source = "registry+https://github.com/rust-lang/crates.io-index"
434checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
435
436[[package]]
437name = "futures-executor"
438version = "0.3.34"
439source = "registry+https://github.com/rust-lang/crates.io-index"
440checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
441dependencies = [
442 "futures-core",
443 "futures-task",
444 "futures-util",
445]
446
447[[package]]
448name = "futures-io"
449version = "0.3.34"
450source = "registry+https://github.com/rust-lang/crates.io-index"
451checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
452
453[[package]]
454name = "futures-macro"
455version = "0.3.34"
456source = "registry+https://github.com/rust-lang/crates.io-index"
457checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
458dependencies = [
459 "proc-macro2",
460 "quote",
461 "syn 3.0.6",
462]
463
464[[package]]
465name = "futures-sink"
466version = "0.3.34"
467source = "registry+https://github.com/rust-lang/crates.io-index"
468checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
469
470[[package]]
471name = "futures-task"
472version = "0.3.34"
473source = "registry+https://github.com/rust-lang/crates.io-index"
474checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
475
476[[package]]
477name = "futures-util"
478version = "0.3.34"
479source = "registry+https://github.com/rust-lang/crates.io-index"
480checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
481dependencies = [
482 "futures-channel",
483 "futures-core",
484 "futures-io",
485 "futures-macro",
486 "futures-sink",
487 "futures-task",
488 "memchr",
489 "pin-project-lite",
490 "slab",
491]
492
493[[package]]
494name = "generic-array"
495version = "0.14.7"
496source = "registry+https://github.com/rust-lang/crates.io-index"
497checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
498dependencies = [
499 "typenum",
500 "version_check",
501]
502
503[[package]]
504name = "getrandom"
505version = "0.2.17"
506source = "registry+https://github.com/rust-lang/crates.io-index"
507checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
508dependencies = [
509 "cfg-if",
510 "js-sys",
511 "libc",
512 "wasi",
513 "wasm-bindgen",
514]
515
516[[package]]
517name = "getrandom"
518version = "0.3.4"
519source = "registry+https://github.com/rust-lang/crates.io-index"
520checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
521dependencies = [
522 "cfg-if",
523 "libc",
524 "r-efi 5.3.0",
525 "wasip2",
526]
527
528[[package]]
529name = "getrandom"
530version = "0.4.3"
531source = "registry+https://github.com/rust-lang/crates.io-index"
532checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
533dependencies = [
534 "cfg-if",
535 "js-sys",
536 "libc",
537 "r-efi 6.0.0",
538 "rand_core 0.10.1",
539 "wasm-bindgen",
540]
541
542[[package]]
543name = "globset"
544version = "0.4.20"
545source = "registry+https://github.com/rust-lang/crates.io-index"
546checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
547dependencies = [
548 "aho-corasick",
549 "bstr",
550 "log",
551 "regex-automata",
552 "regex-syntax",
553]
554
555[[package]]
556name = "hashbrown"
557version = "0.15.5"
558source = "registry+https://github.com/rust-lang/crates.io-index"
559checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
560dependencies = [
561 "foldhash",
562]
563
564[[package]]
565name = "hashbrown"
566version = "0.17.1"
567source = "registry+https://github.com/rust-lang/crates.io-index"
568checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
569
570[[package]]
571name = "hashlink"
572version = "0.10.0"
573source = "registry+https://github.com/rust-lang/crates.io-index"
574checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
575dependencies = [
576 "hashbrown 0.15.5",
577]
578
579[[package]]
580name = "home-dashboard"
581version = "0.1.0"
582dependencies = [
583 "axum",
584 "base64 0.22.1",
585 "bytes",
586 "chrono",
587 "futures",
588 "globset",
589 "rand 0.9.5",
590 "regex",
591 "reqwest",
592 "rmcp",
593 "rusqlite",
594 "scraper",
595 "serde_json",
596 "sha1",
597 "sha2",
598 "subtle",
599 "tokio",
600 "tokio-stream",
601 "tower-http",
602 "url",
603 "uuid",
604 "walkdir",
605]
606
607[[package]]
608name = "html5ever"
609version = "0.39.0"
610source = "registry+https://github.com/rust-lang/crates.io-index"
611checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
612dependencies = [
613 "log",
614 "markup5ever",
615]
616
617[[package]]
618name = "http"
619version = "1.5.0"
620source = "registry+https://github.com/rust-lang/crates.io-index"
621checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
622dependencies = [
623 "bytes",
624 "itoa",
625]
626
627[[package]]
628name = "http-body"
629version = "1.1.0"
630source = "registry+https://github.com/rust-lang/crates.io-index"
631checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
632dependencies = [
633 "bytes",
634 "http",
635]
636
637[[package]]
638name = "http-body-util"
639version = "0.1.5"
640source = "registry+https://github.com/rust-lang/crates.io-index"
641checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
642dependencies = [
643 "bytes",
644 "futures-core",
645 "http",
646 "http-body",
647 "pin-project-lite",
648]
649
650[[package]]
651name = "http-range-header"
652version = "0.4.2"
653source = "registry+https://github.com/rust-lang/crates.io-index"
654checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
655
656[[package]]
657name = "httparse"
658version = "1.10.1"
659source = "registry+https://github.com/rust-lang/crates.io-index"
660checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
661
662[[package]]
663name = "httpdate"
664version = "1.0.3"
665source = "registry+https://github.com/rust-lang/crates.io-index"
666checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
667
668[[package]]
669name = "hyper"
670version = "1.11.1"
671source = "registry+https://github.com/rust-lang/crates.io-index"
672checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
673dependencies = [
674 "atomic-waker",
675 "bytes",
676 "futures-channel",
677 "futures-core",
678 "http",
679 "http-body",
680 "httparse",
681 "httpdate",
682 "itoa",
683 "pin-project-lite",
684 "smallvec",
685 "tokio",
686 "want",
687]
688
689[[package]]
690name = "hyper-rustls"
691version = "0.27.10"
692source = "registry+https://github.com/rust-lang/crates.io-index"
693checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
694dependencies = [
695 "http",
696 "hyper",
697 "hyper-util",
698 "rustls",
699 "tokio",
700 "tokio-rustls",
701 "tower-service",
702 "webpki-roots",
703]
704
705[[package]]
706name = "hyper-util"
707version = "0.1.21"
708source = "registry+https://github.com/rust-lang/crates.io-index"
709checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
710dependencies = [
711 "base64 0.23.1",
712 "bytes",
713 "futures-channel",
714 "futures-util",
715 "http",
716 "http-body",
717 "httparse",
718 "hyper",
719 "ipnet",
720 "libc",
721 "percent-encoding",
722 "pin-project-lite",
723 "socket2",
724 "tokio",
725 "tower-service",
726 "tracing",
727]
728
729[[package]]
730name = "iana-time-zone"
731version = "0.1.65"
732source = "registry+https://github.com/rust-lang/crates.io-index"
733checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
734dependencies = [
735 "android_system_properties",
736 "core-foundation-sys",
737 "iana-time-zone-haiku",
738 "js-sys",
739 "log",
740 "wasm-bindgen",
741 "windows-core",
742]
743
744[[package]]
745name = "iana-time-zone-haiku"
746version = "0.1.2"
747source = "registry+https://github.com/rust-lang/crates.io-index"
748checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
749dependencies = [
750 "cc",
751]
752
753[[package]]
754name = "icu_collections"
755version = "2.3.0"
756source = "registry+https://github.com/rust-lang/crates.io-index"
757checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
758dependencies = [
759 "displaydoc",
760 "potential_utf",
761 "utf8_iter",
762 "yoke",
763 "zerofrom",
764 "zerovec",
765]
766
767[[package]]
768name = "icu_locale_core"
769version = "2.3.0"
770source = "registry+https://github.com/rust-lang/crates.io-index"
771checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
772dependencies = [
773 "displaydoc",
774 "litemap",
775 "tinystr",
776 "writeable",
777 "zerovec",
778]
779
780[[package]]
781name = "icu_normalizer"
782version = "2.3.0"
783source = "registry+https://github.com/rust-lang/crates.io-index"
784checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
785dependencies = [
786 "icu_collections",
787 "icu_normalizer_data",
788 "icu_properties",
789 "icu_provider",
790 "smallvec",
791 "zerovec",
792]
793
794[[package]]
795name = "icu_normalizer_data"
796version = "2.3.0"
797source = "registry+https://github.com/rust-lang/crates.io-index"
798checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
799
800[[package]]
801name = "icu_properties"
802version = "2.3.0"
803source = "registry+https://github.com/rust-lang/crates.io-index"
804checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
805dependencies = [
806 "displaydoc",
807 "icu_collections",
808 "icu_locale_core",
809 "icu_properties_data",
810 "icu_provider",
811 "zerotrie",
812 "zerovec",
813]
814
815[[package]]
816name = "icu_properties_data"
817version = "2.3.0"
818source = "registry+https://github.com/rust-lang/crates.io-index"
819checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
820
821[[package]]
822name = "icu_provider"
823version = "2.3.1"
824source = "registry+https://github.com/rust-lang/crates.io-index"
825checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
826dependencies = [
827 "displaydoc",
828 "icu_locale_core",
829 "writeable",
830 "yoke",
831 "zerofrom",
832 "zerotrie",
833 "zerovec",
834]
835
836[[package]]
837name = "idna"
838version = "1.1.0"
839source = "registry+https://github.com/rust-lang/crates.io-index"
840checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
841dependencies = [
842 "idna_adapter",
843 "smallvec",
844 "utf8_iter",
845]
846
847[[package]]
848name = "idna_adapter"
849version = "1.2.2"
850source = "registry+https://github.com/rust-lang/crates.io-index"
851checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
852dependencies = [
853 "icu_normalizer",
854 "icu_properties",
855]
856
857[[package]]
858name = "indexmap"
859version = "2.14.2"
860source = "registry+https://github.com/rust-lang/crates.io-index"
861checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
862dependencies = [
863 "equivalent",
864 "hashbrown 0.17.1",
865 "serde",
866 "serde_core",
867]
868
869[[package]]
870name = "ipnet"
871version = "2.12.2"
872source = "registry+https://github.com/rust-lang/crates.io-index"
873checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
874
875[[package]]
876name = "itoa"
877version = "1.0.18"
878source = "registry+https://github.com/rust-lang/crates.io-index"
879checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
880
881[[package]]
882name = "js-sys"
883version = "0.3.106"
884source = "registry+https://github.com/rust-lang/crates.io-index"
885checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
886dependencies = [
887 "cfg-if",
888 "futures-util",
889 "wasm-bindgen",
890]
891
892[[package]]
893name = "libc"
894version = "0.2.189"
895source = "registry+https://github.com/rust-lang/crates.io-index"
896checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
897
898[[package]]
899name = "libsqlite3-sys"
900version = "0.35.0"
901source = "registry+https://github.com/rust-lang/crates.io-index"
902checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
903dependencies = [
904 "cc",
905 "pkg-config",
906 "vcpkg",
907]
908
909[[package]]
910name = "litemap"
911version = "0.8.3"
912source = "registry+https://github.com/rust-lang/crates.io-index"
913checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
914
915[[package]]
916name = "lock_api"
917version = "0.4.14"
918source = "registry+https://github.com/rust-lang/crates.io-index"
919checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
920dependencies = [
921 "scopeguard",
922]
923
924[[package]]
925name = "log"
926version = "0.4.34"
927source = "registry+https://github.com/rust-lang/crates.io-index"
928checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
929
930[[package]]
931name = "lru-slab"
932version = "0.1.3"
933source = "registry+https://github.com/rust-lang/crates.io-index"
934checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
935
936[[package]]
937name = "markup5ever"
938version = "0.39.0"
939source = "registry+https://github.com/rust-lang/crates.io-index"
940checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
941dependencies = [
942 "log",
943 "tendril",
944 "web_atoms",
945]
946
947[[package]]
948name = "matchit"
949version = "0.8.4"
950source = "registry+https://github.com/rust-lang/crates.io-index"
951checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
952
953[[package]]
954name = "memchr"
955version = "2.8.3"
956source = "registry+https://github.com/rust-lang/crates.io-index"
957checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
958
959[[package]]
960name = "mime"
961version = "0.3.17"
962source = "registry+https://github.com/rust-lang/crates.io-index"
963checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
964
965[[package]]
966name = "mime_guess"
967version = "2.0.5"
968source = "registry+https://github.com/rust-lang/crates.io-index"
969checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
970dependencies = [
971 "mime",
972 "unicase",
973]
974
975[[package]]
976name = "mio"
977version = "1.2.3"
978source = "registry+https://github.com/rust-lang/crates.io-index"
979checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
980dependencies = [
981 "libc",
982 "wasi",
983 "windows-sys 0.61.2",
984]
985
986[[package]]
987name = "multer"
988version = "3.1.0"
989source = "registry+https://github.com/rust-lang/crates.io-index"
990checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
991dependencies = [
992 "bytes",
993 "encoding_rs",
994 "futures-util",
995 "http",
996 "httparse",
997 "memchr",
998 "mime",
999 "spin",
1000 "version_check",
1001]
1002
1003[[package]]
1004name = "multiversion_no_op"
1005version = "1.0.0"
1006source = "registry+https://github.com/rust-lang/crates.io-index"
1007checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
1008
1009[[package]]
1010name = "new_debug_unreachable"
1011version = "1.0.6"
1012source = "registry+https://github.com/rust-lang/crates.io-index"
1013checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
1014
1015[[package]]
1016name = "num-traits"
1017version = "0.2.19"
1018source = "registry+https://github.com/rust-lang/crates.io-index"
1019checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1020dependencies = [
1021 "autocfg",
1022]
1023
1024[[package]]
1025name = "once_cell"
1026version = "1.21.4"
1027source = "registry+https://github.com/rust-lang/crates.io-index"
1028checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1029
1030[[package]]
1031name = "parking_lot"
1032version = "0.12.5"
1033source = "registry+https://github.com/rust-lang/crates.io-index"
1034checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
1035dependencies = [
1036 "lock_api",
1037 "parking_lot_core",
1038]
1039
1040[[package]]
1041name = "parking_lot_core"
1042version = "0.9.12"
1043source = "registry+https://github.com/rust-lang/crates.io-index"
1044checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
1045dependencies = [
1046 "cfg-if",
1047 "libc",
1048 "redox_syscall",
1049 "smallvec",
1050 "windows-link",
1051]
1052
1053[[package]]
1054name = "pastey"
1055version = "0.2.3"
1056source = "registry+https://github.com/rust-lang/crates.io-index"
1057checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
1058
1059[[package]]
1060name = "percent-encoding"
1061version = "2.3.2"
1062source = "registry+https://github.com/rust-lang/crates.io-index"
1063checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1064
1065[[package]]
1066name = "phf"
1067version = "0.13.1"
1068source = "registry+https://github.com/rust-lang/crates.io-index"
1069checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
1070dependencies = [
1071 "phf_macros",
1072 "phf_shared",
1073 "serde",
1074]
1075
1076[[package]]
1077name = "phf_codegen"
1078version = "0.13.1"
1079source = "registry+https://github.com/rust-lang/crates.io-index"
1080checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
1081dependencies = [
1082 "phf_generator",
1083 "phf_shared",
1084]
1085
1086[[package]]
1087name = "phf_generator"
1088version = "0.13.1"
1089source = "registry+https://github.com/rust-lang/crates.io-index"
1090checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
1091dependencies = [
1092 "fastrand",
1093 "phf_shared",
1094]
1095
1096[[package]]
1097name = "phf_macros"
1098version = "0.13.1"
1099source = "registry+https://github.com/rust-lang/crates.io-index"
1100checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
1101dependencies = [
1102 "phf_generator",
1103 "phf_shared",
1104 "proc-macro2",
1105 "quote",
1106 "syn 2.0.119",
1107]
1108
1109[[package]]
1110name = "phf_shared"
1111version = "0.13.1"
1112source = "registry+https://github.com/rust-lang/crates.io-index"
1113checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
1114dependencies = [
1115 "siphasher",
1116]
1117
1118[[package]]
1119name = "pin-project-lite"
1120version = "0.2.17"
1121source = "registry+https://github.com/rust-lang/crates.io-index"
1122checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1123
1124[[package]]
1125name = "pkg-config"
1126version = "0.3.34"
1127source = "registry+https://github.com/rust-lang/crates.io-index"
1128checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
1129
1130[[package]]
1131name = "potential_utf"
1132version = "0.1.6"
1133source = "registry+https://github.com/rust-lang/crates.io-index"
1134checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
1135dependencies = [
1136 "zerovec",
1137]
1138
1139[[package]]
1140name = "ppv-lite86"
1141version = "0.2.21"
1142source = "registry+https://github.com/rust-lang/crates.io-index"
1143checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1144dependencies = [
1145 "zerocopy",
1146]
1147
1148[[package]]
1149name = "precomputed-hash"
1150version = "0.1.1"
1151source = "registry+https://github.com/rust-lang/crates.io-index"
1152checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
1153
1154[[package]]
1155name = "proc-macro2"
1156version = "1.0.107"
1157source = "registry+https://github.com/rust-lang/crates.io-index"
1158checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
1159dependencies = [
1160 "unicode-ident",
1161]
1162
1163[[package]]
1164name = "quinn"
1165version = "0.11.12"
1166source = "registry+https://github.com/rust-lang/crates.io-index"
1167checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
1168dependencies = [
1169 "bytes",
1170 "cfg_aliases",
1171 "pin-project-lite",
1172 "quinn-proto",
1173 "quinn-udp",
1174 "rustc-hash",
1175 "rustls",
1176 "socket2",
1177 "thiserror",
1178 "tokio",
1179 "tracing",
1180 "web-time",
1181]
1182
1183[[package]]
1184name = "quinn-proto"
1185version = "0.11.18"
1186source = "registry+https://github.com/rust-lang/crates.io-index"
1187checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
1188dependencies = [
1189 "bytes",
1190 "getrandom 0.4.3",
1191 "lru-slab",
1192 "rand 0.10.3",
1193 "rand_pcg",
1194 "ring",
1195 "rustc-hash",
1196 "rustls",
1197 "rustls-pki-types",
1198 "slab",
1199 "thiserror",
1200 "tinyvec",
1201 "tracing",
1202 "web-time",
1203]
1204
1205[[package]]
1206name = "quinn-udp"
1207version = "0.5.15"
1208source = "registry+https://github.com/rust-lang/crates.io-index"
1209checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
1210dependencies = [
1211 "cfg_aliases",
1212 "libc",
1213 "once_cell",
1214 "socket2",
1215 "tracing",
1216 "windows-sys 0.61.2",
1217]
1218
1219[[package]]
1220name = "quote"
1221version = "1.0.47"
1222source = "registry+https://github.com/rust-lang/crates.io-index"
1223checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
1224dependencies = [
1225 "proc-macro2",
1226]
1227
1228[[package]]
1229name = "r-efi"
1230version = "5.3.0"
1231source = "registry+https://github.com/rust-lang/crates.io-index"
1232checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1233
1234[[package]]
1235name = "r-efi"
1236version = "6.0.0"
1237source = "registry+https://github.com/rust-lang/crates.io-index"
1238checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1239
1240[[package]]
1241name = "rand"
1242version = "0.9.5"
1243source = "registry+https://github.com/rust-lang/crates.io-index"
1244checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
1245dependencies = [
1246 "rand_chacha",
1247 "rand_core 0.9.5",
1248]
1249
1250[[package]]
1251name = "rand"
1252version = "0.10.3"
1253source = "registry+https://github.com/rust-lang/crates.io-index"
1254checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
1255dependencies = [
1256 "chacha20",
1257 "getrandom 0.4.3",
1258 "rand_core 0.10.1",
1259]
1260
1261[[package]]
1262name = "rand_chacha"
1263version = "0.9.0"
1264source = "registry+https://github.com/rust-lang/crates.io-index"
1265checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1266dependencies = [
1267 "ppv-lite86",
1268 "rand_core 0.9.5",
1269]
1270
1271[[package]]
1272name = "rand_core"
1273version = "0.9.5"
1274source = "registry+https://github.com/rust-lang/crates.io-index"
1275checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1276dependencies = [
1277 "getrandom 0.3.4",
1278]
1279
1280[[package]]
1281name = "rand_core"
1282version = "0.10.1"
1283source = "registry+https://github.com/rust-lang/crates.io-index"
1284checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
1285
1286[[package]]
1287name = "rand_pcg"
1288version = "0.10.2"
1289source = "registry+https://github.com/rust-lang/crates.io-index"
1290checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
1291dependencies = [
1292 "rand_core 0.10.1",
1293]
1294
1295[[package]]
1296name = "redox_syscall"
1297version = "0.5.18"
1298source = "registry+https://github.com/rust-lang/crates.io-index"
1299checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1300dependencies = [
1301 "bitflags",
1302]
1303
1304[[package]]
1305name = "ref-cast"
1306version = "1.0.27"
1307source = "registry+https://github.com/rust-lang/crates.io-index"
1308checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
1309dependencies = [
1310 "ref-cast-impl",
1311]
1312
1313[[package]]
1314name = "ref-cast-impl"
1315version = "1.0.27"
1316source = "registry+https://github.com/rust-lang/crates.io-index"
1317checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
1318dependencies = [
1319 "proc-macro2",
1320 "quote",
1321 "syn 3.0.6",
1322]
1323
1324[[package]]
1325name = "regex"
1326version = "1.13.1"
1327source = "registry+https://github.com/rust-lang/crates.io-index"
1328checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
1329dependencies = [
1330 "aho-corasick",
1331 "memchr",
1332 "regex-automata",
1333 "regex-syntax",
1334]
1335
1336[[package]]
1337name = "regex-automata"
1338version = "0.4.18"
1339source = "registry+https://github.com/rust-lang/crates.io-index"
1340checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
1341dependencies = [
1342 "aho-corasick",
1343 "memchr",
1344 "regex-syntax",
1345]
1346
1347[[package]]
1348name = "regex-syntax"
1349version = "0.8.11"
1350source = "registry+https://github.com/rust-lang/crates.io-index"
1351checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1352
1353[[package]]
1354name = "reqwest"
1355version = "0.12.28"
1356source = "registry+https://github.com/rust-lang/crates.io-index"
1357checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
1358dependencies = [
1359 "base64 0.22.1",
1360 "bytes",
1361 "futures-core",
1362 "futures-util",
1363 "http",
1364 "http-body",
1365 "http-body-util",
1366 "hyper",
1367 "hyper-rustls",
1368 "hyper-util",
1369 "js-sys",
1370 "log",
1371 "mime_guess",
1372 "percent-encoding",
1373 "pin-project-lite",
1374 "quinn",
1375 "rustls",
1376 "rustls-pki-types",
1377 "serde",
1378 "serde_json",
1379 "serde_urlencoded",
1380 "sync_wrapper",
1381 "tokio",
1382 "tokio-rustls",
1383 "tower",
1384 "tower-http",
1385 "tower-service",
1386 "url",
1387 "wasm-bindgen",
1388 "wasm-bindgen-futures",
1389 "web-sys",
1390 "webpki-roots",
1391]
1392
1393[[package]]
1394name = "ring"
1395version = "0.17.14"
1396source = "registry+https://github.com/rust-lang/crates.io-index"
1397checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1398dependencies = [
1399 "cc",
1400 "cfg-if",
1401 "getrandom 0.2.17",
1402 "libc",
1403 "untrusted",
1404 "windows-sys 0.52.0",
1405]
1406
1407[[package]]
1408name = "rmcp"
1409version = "3.5.0"
1410source = "registry+https://github.com/rust-lang/crates.io-index"
1411checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3"
1412dependencies = [
1413 "async-trait",
1414 "base64 0.23.1",
1415 "bytes",
1416 "chrono",
1417 "futures",
1418 "http",
1419 "http-body",
1420 "http-body-util",
1421 "indexmap",
1422 "pastey",
1423 "pin-project-lite",
1424 "rand 0.10.3",
1425 "schemars",
1426 "serde",
1427 "serde_json",
1428 "sse-stream",
1429 "thiserror",
1430 "tokio",
1431 "tokio-stream",
1432 "tokio-util",
1433 "tower-service",
1434 "tracing",
1435 "uuid",
1436]
1437
1438[[package]]
1439name = "rusqlite"
1440version = "0.37.0"
1441source = "registry+https://github.com/rust-lang/crates.io-index"
1442checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
1443dependencies = [
1444 "bitflags",
1445 "fallible-iterator",
1446 "fallible-streaming-iterator",
1447 "hashlink",
1448 "libsqlite3-sys",
1449 "smallvec",
1450]
1451
1452[[package]]
1453name = "rustc-hash"
1454version = "2.1.3"
1455source = "registry+https://github.com/rust-lang/crates.io-index"
1456checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
1457
1458[[package]]
1459name = "rustc_version"
1460version = "0.4.1"
1461source = "registry+https://github.com/rust-lang/crates.io-index"
1462checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1463dependencies = [
1464 "semver",
1465]
1466
1467[[package]]
1468name = "rustls"
1469version = "0.23.45"
1470source = "registry+https://github.com/rust-lang/crates.io-index"
1471checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
1472dependencies = [
1473 "once_cell",
1474 "ring",
1475 "rustls-pki-types",
1476 "rustls-webpki",
1477 "subtle",
1478 "zeroize",
1479]
1480
1481[[package]]
1482name = "rustls-pki-types"
1483version = "1.15.1"
1484source = "registry+https://github.com/rust-lang/crates.io-index"
1485checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
1486dependencies = [
1487 "web-time",
1488 "zeroize",
1489]
1490
1491[[package]]
1492name = "rustls-webpki"
1493version = "0.103.15"
1494source = "registry+https://github.com/rust-lang/crates.io-index"
1495checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
1496dependencies = [
1497 "ring",
1498 "rustls-pki-types",
1499 "untrusted",
1500]
1501
1502[[package]]
1503name = "rustversion"
1504version = "1.0.23"
1505source = "registry+https://github.com/rust-lang/crates.io-index"
1506checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
1507
1508[[package]]
1509name = "ryu"
1510version = "1.0.23"
1511source = "registry+https://github.com/rust-lang/crates.io-index"
1512checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1513
1514[[package]]
1515name = "same-file"
1516version = "1.0.6"
1517source = "registry+https://github.com/rust-lang/crates.io-index"
1518checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1519dependencies = [
1520 "winapi-util",
1521]
1522
1523[[package]]
1524name = "schemars"
1525version = "1.2.2"
1526source = "registry+https://github.com/rust-lang/crates.io-index"
1527checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
1528dependencies = [
1529 "chrono",
1530 "dyn-clone",
1531 "ref-cast",
1532 "schemars_derive",
1533 "serde",
1534 "serde_json",
1535]
1536
1537[[package]]
1538name = "schemars_derive"
1539version = "1.2.2"
1540source = "registry+https://github.com/rust-lang/crates.io-index"
1541checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
1542dependencies = [
1543 "proc-macro2",
1544 "quote",
1545 "serde_derive_internals",
1546 "syn 3.0.6",
1547]
1548
1549[[package]]
1550name = "scopeguard"
1551version = "1.2.0"
1552source = "registry+https://github.com/rust-lang/crates.io-index"
1553checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1554
1555[[package]]
1556name = "scraper"
1557version = "0.27.0"
1558source = "registry+https://github.com/rust-lang/crates.io-index"
1559checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
1560dependencies = [
1561 "cssparser",
1562 "ego-tree",
1563 "html5ever",
1564 "precomputed-hash",
1565 "selectors",
1566 "tendril",
1567]
1568
1569[[package]]
1570name = "selectors"
1571version = "0.38.0"
1572source = "registry+https://github.com/rust-lang/crates.io-index"
1573checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
1574dependencies = [
1575 "bitflags",
1576 "cssparser",
1577 "derive_more",
1578 "log",
1579 "new_debug_unreachable",
1580 "phf",
1581 "phf_codegen",
1582 "precomputed-hash",
1583 "rustc-hash",
1584 "servo_arc",
1585 "smallvec",
1586]
1587
1588[[package]]
1589name = "semver"
1590version = "1.0.28"
1591source = "registry+https://github.com/rust-lang/crates.io-index"
1592checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1593
1594[[package]]
1595name = "serde"
1596version = "1.0.229"
1597source = "registry+https://github.com/rust-lang/crates.io-index"
1598checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
1599dependencies = [
1600 "serde_core",
1601 "serde_derive",
1602]
1603
1604[[package]]
1605name = "serde_core"
1606version = "1.0.229"
1607source = "registry+https://github.com/rust-lang/crates.io-index"
1608checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
1609dependencies = [
1610 "serde_derive",
1611]
1612
1613[[package]]
1614name = "serde_derive"
1615version = "1.0.229"
1616source = "registry+https://github.com/rust-lang/crates.io-index"
1617checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
1618dependencies = [
1619 "proc-macro2",
1620 "quote",
1621 "syn 3.0.6",
1622]
1623
1624[[package]]
1625name = "serde_derive_internals"
1626version = "0.30.0"
1627source = "registry+https://github.com/rust-lang/crates.io-index"
1628checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
1629dependencies = [
1630 "proc-macro2",
1631 "quote",
1632 "syn 3.0.6",
1633]
1634
1635[[package]]
1636name = "serde_json"
1637version = "1.0.151"
1638source = "registry+https://github.com/rust-lang/crates.io-index"
1639checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
1640dependencies = [
1641 "itoa",
1642 "memchr",
1643 "serde",
1644 "serde_core",
1645 "zmij",
1646]
1647
1648[[package]]
1649name = "serde_path_to_error"
1650version = "0.1.20"
1651source = "registry+https://github.com/rust-lang/crates.io-index"
1652checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
1653dependencies = [
1654 "itoa",
1655 "serde",
1656 "serde_core",
1657]
1658
1659[[package]]
1660name = "serde_urlencoded"
1661version = "0.7.1"
1662source = "registry+https://github.com/rust-lang/crates.io-index"
1663checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1664dependencies = [
1665 "form_urlencoded",
1666 "itoa",
1667 "ryu",
1668 "serde",
1669]
1670
1671[[package]]
1672name = "servo_arc"
1673version = "0.4.3"
1674source = "registry+https://github.com/rust-lang/crates.io-index"
1675checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
1676dependencies = [
1677 "stable_deref_trait",
1678]
1679
1680[[package]]
1681name = "sha1"
1682version = "0.10.7"
1683source = "registry+https://github.com/rust-lang/crates.io-index"
1684checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
1685dependencies = [
1686 "cfg-if",
1687 "cpufeatures 0.2.17",
1688 "digest",
1689]
1690
1691[[package]]
1692name = "sha2"
1693version = "0.10.9"
1694source = "registry+https://github.com/rust-lang/crates.io-index"
1695checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1696dependencies = [
1697 "cfg-if",
1698 "cpufeatures 0.2.17",
1699 "digest",
1700]
1701
1702[[package]]
1703name = "shlex"
1704version = "2.0.1"
1705source = "registry+https://github.com/rust-lang/crates.io-index"
1706checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1707
1708[[package]]
1709name = "signal-hook-registry"
1710version = "1.4.8"
1711source = "registry+https://github.com/rust-lang/crates.io-index"
1712checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1713dependencies = [
1714 "errno",
1715 "libc",
1716]
1717
1718[[package]]
1719name = "simdutf8"
1720version = "0.1.5"
1721source = "registry+https://github.com/rust-lang/crates.io-index"
1722checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
1723
1724[[package]]
1725name = "siphasher"
1726version = "1.0.4"
1727source = "registry+https://github.com/rust-lang/crates.io-index"
1728checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
1729
1730[[package]]
1731name = "slab"
1732version = "0.4.12"
1733source = "registry+https://github.com/rust-lang/crates.io-index"
1734checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1735
1736[[package]]
1737name = "smallvec"
1738version = "1.16.2"
1739source = "registry+https://github.com/rust-lang/crates.io-index"
1740checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
1741
1742[[package]]
1743name = "socket2"
1744version = "0.6.5"
1745source = "registry+https://github.com/rust-lang/crates.io-index"
1746checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
1747dependencies = [
1748 "libc",
1749 "windows-sys 0.61.2",
1750]
1751
1752[[package]]
1753name = "spin"
1754version = "0.9.9"
1755source = "registry+https://github.com/rust-lang/crates.io-index"
1756checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
1757
1758[[package]]
1759name = "sse-stream"
1760version = "0.2.6"
1761source = "registry+https://github.com/rust-lang/crates.io-index"
1762checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4"
1763dependencies = [
1764 "bytes",
1765 "futures-util",
1766 "http-body",
1767 "http-body-util",
1768 "pin-project-lite",
1769]
1770
1771[[package]]
1772name = "stable_deref_trait"
1773version = "1.2.1"
1774source = "registry+https://github.com/rust-lang/crates.io-index"
1775checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1776
1777[[package]]
1778name = "string_cache"
1779version = "0.9.0"
1780source = "registry+https://github.com/rust-lang/crates.io-index"
1781checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
1782dependencies = [
1783 "new_debug_unreachable",
1784 "parking_lot",
1785 "phf_shared",
1786 "precomputed-hash",
1787]
1788
1789[[package]]
1790name = "string_cache_codegen"
1791version = "0.6.1"
1792source = "registry+https://github.com/rust-lang/crates.io-index"
1793checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
1794dependencies = [
1795 "phf_generator",
1796 "phf_shared",
1797 "proc-macro2",
1798 "quote",
1799]
1800
1801[[package]]
1802name = "subtle"
1803version = "2.6.1"
1804source = "registry+https://github.com/rust-lang/crates.io-index"
1805checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1806
1807[[package]]
1808name = "syn"
1809version = "2.0.119"
1810source = "registry+https://github.com/rust-lang/crates.io-index"
1811checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
1812dependencies = [
1813 "proc-macro2",
1814 "quote",
1815 "unicode-ident",
1816]
1817
1818[[package]]
1819name = "syn"
1820version = "3.0.6"
1821source = "registry+https://github.com/rust-lang/crates.io-index"
1822checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
1823dependencies = [
1824 "proc-macro2",
1825 "quote",
1826 "unicode-ident",
1827]
1828
1829[[package]]
1830name = "sync_wrapper"
1831version = "1.0.2"
1832source = "registry+https://github.com/rust-lang/crates.io-index"
1833checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1834dependencies = [
1835 "futures-core",
1836]
1837
1838[[package]]
1839name = "synstructure"
1840version = "0.14.0"
1841source = "registry+https://github.com/rust-lang/crates.io-index"
1842checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
1843dependencies = [
1844 "proc-macro2",
1845 "quote",
1846 "syn 3.0.6",
1847]
1848
1849[[package]]
1850name = "tendril"
1851version = "0.5.1"
1852source = "registry+https://github.com/rust-lang/crates.io-index"
1853checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
1854dependencies = [
1855 "new_debug_unreachable",
1856]
1857
1858[[package]]
1859name = "thiserror"
1860version = "2.0.21"
1861source = "registry+https://github.com/rust-lang/crates.io-index"
1862checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
1863dependencies = [
1864 "thiserror-impl",
1865]
1866
1867[[package]]
1868name = "thiserror-impl"
1869version = "2.0.21"
1870source = "registry+https://github.com/rust-lang/crates.io-index"
1871checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
1872dependencies = [
1873 "proc-macro2",
1874 "quote",
1875 "syn 3.0.6",
1876]
1877
1878[[package]]
1879name = "tinystr"
1880version = "0.8.4"
1881source = "registry+https://github.com/rust-lang/crates.io-index"
1882checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
1883dependencies = [
1884 "displaydoc",
1885 "zerovec",
1886]
1887
1888[[package]]
1889name = "tinyvec"
1890version = "1.13.3"
1891source = "registry+https://github.com/rust-lang/crates.io-index"
1892checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
1893
1894[[package]]
1895name = "tokio"
1896version = "1.53.1"
1897source = "registry+https://github.com/rust-lang/crates.io-index"
1898checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
1899dependencies = [
1900 "bytes",
1901 "libc",
1902 "mio",
1903 "parking_lot",
1904 "pin-project-lite",
1905 "signal-hook-registry",
1906 "socket2",
1907 "tokio-macros",
1908 "windows-sys 0.61.2",
1909]
1910
1911[[package]]
1912name = "tokio-macros"
1913version = "2.7.2"
1914source = "registry+https://github.com/rust-lang/crates.io-index"
1915checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
1916dependencies = [
1917 "proc-macro2",
1918 "quote",
1919 "syn 3.0.6",
1920]
1921
1922[[package]]
1923name = "tokio-rustls"
1924version = "0.26.6"
1925source = "registry+https://github.com/rust-lang/crates.io-index"
1926checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
1927dependencies = [
1928 "rustls",
1929 "tokio",
1930]
1931
1932[[package]]
1933name = "tokio-stream"
1934version = "0.1.19"
1935source = "registry+https://github.com/rust-lang/crates.io-index"
1936checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
1937dependencies = [
1938 "futures-core",
1939 "pin-project-lite",
1940 "tokio",
1941 "tokio-util",
1942]
1943
1944[[package]]
1945name = "tokio-tungstenite"
1946version = "0.29.0"
1947source = "registry+https://github.com/rust-lang/crates.io-index"
1948checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
1949dependencies = [
1950 "futures-util",
1951 "log",
1952 "tokio",
1953 "tungstenite",
1954]
1955
1956[[package]]
1957name = "tokio-util"
1958version = "0.7.19"
1959source = "registry+https://github.com/rust-lang/crates.io-index"
1960checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
1961dependencies = [
1962 "bytes",
1963 "futures-core",
1964 "futures-sink",
1965 "libc",
1966 "pin-project-lite",
1967 "tokio",
1968]
1969
1970[[package]]
1971name = "tower"
1972version = "0.5.3"
1973source = "registry+https://github.com/rust-lang/crates.io-index"
1974checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
1975dependencies = [
1976 "futures-core",
1977 "futures-util",
1978 "pin-project-lite",
1979 "sync_wrapper",
1980 "tokio",
1981 "tower-layer",
1982 "tower-service",
1983 "tracing",
1984]
1985
1986[[package]]
1987name = "tower-http"
1988version = "0.6.11"
1989source = "registry+https://github.com/rust-lang/crates.io-index"
1990checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
1991dependencies = [
1992 "bitflags",
1993 "bytes",
1994 "futures-core",
1995 "futures-util",
1996 "http",
1997 "http-body",
1998 "http-body-util",
1999 "http-range-header",
2000 "httpdate",
2001 "mime",
2002 "mime_guess",
2003 "percent-encoding",
2004 "pin-project-lite",
2005 "tokio",
2006 "tokio-util",
2007 "tower",
2008 "tower-layer",
2009 "tower-service",
2010 "url",
2011]
2012
2013[[package]]
2014name = "tower-layer"
2015version = "0.3.3"
2016source = "registry+https://github.com/rust-lang/crates.io-index"
2017checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
2018
2019[[package]]
2020name = "tower-service"
2021version = "0.3.3"
2022source = "registry+https://github.com/rust-lang/crates.io-index"
2023checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
2024
2025[[package]]
2026name = "tracing"
2027version = "0.1.44"
2028source = "registry+https://github.com/rust-lang/crates.io-index"
2029checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2030dependencies = [
2031 "log",
2032 "pin-project-lite",
2033 "tracing-attributes",
2034 "tracing-core",
2035]
2036
2037[[package]]
2038name = "tracing-attributes"
2039version = "0.1.31"
2040source = "registry+https://github.com/rust-lang/crates.io-index"
2041checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2042dependencies = [
2043 "proc-macro2",
2044 "quote",
2045 "syn 2.0.119",
2046]
2047
2048[[package]]
2049name = "tracing-core"
2050version = "0.1.36"
2051source = "registry+https://github.com/rust-lang/crates.io-index"
2052checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2053dependencies = [
2054 "once_cell",
2055]
2056
2057[[package]]
2058name = "try-lock"
2059version = "0.2.5"
2060source = "registry+https://github.com/rust-lang/crates.io-index"
2061checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2062
2063[[package]]
2064name = "tungstenite"
2065version = "0.29.0"
2066source = "registry+https://github.com/rust-lang/crates.io-index"
2067checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
2068dependencies = [
2069 "bytes",
2070 "data-encoding",
2071 "http",
2072 "httparse",
2073 "log",
2074 "rand 0.9.5",
2075 "sha1",
2076 "thiserror",
2077]
2078
2079[[package]]
2080name = "typenum"
2081version = "1.20.1"
2082source = "registry+https://github.com/rust-lang/crates.io-index"
2083checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
2084
2085[[package]]
2086name = "unicase"
2087version = "2.9.0"
2088source = "registry+https://github.com/rust-lang/crates.io-index"
2089checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
2090
2091[[package]]
2092name = "unicode-ident"
2093version = "1.0.26"
2094source = "registry+https://github.com/rust-lang/crates.io-index"
2095checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
2096
2097[[package]]
2098name = "untrusted"
2099version = "0.9.0"
2100source = "registry+https://github.com/rust-lang/crates.io-index"
2101checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
2102
2103[[package]]
2104name = "url"
2105version = "2.5.8"
2106source = "registry+https://github.com/rust-lang/crates.io-index"
2107checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
2108dependencies = [
2109 "form_urlencoded",
2110 "idna",
2111 "percent-encoding",
2112 "serde",
2113]
2114
2115[[package]]
2116name = "utf8_iter"
2117version = "1.0.4"
2118source = "registry+https://github.com/rust-lang/crates.io-index"
2119checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2120
2121[[package]]
2122name = "uuid"
2123version = "1.26.1"
2124source = "registry+https://github.com/rust-lang/crates.io-index"
2125checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
2126dependencies = [
2127 "getrandom 0.4.3",
2128 "js-sys",
2129 "wasm-bindgen",
2130]
2131
2132[[package]]
2133name = "vcpkg"
2134version = "0.2.15"
2135source = "registry+https://github.com/rust-lang/crates.io-index"
2136checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2137
2138[[package]]
2139name = "version_check"
2140version = "0.9.5"
2141source = "registry+https://github.com/rust-lang/crates.io-index"
2142checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2143
2144[[package]]
2145name = "walkdir"
2146version = "2.5.0"
2147source = "registry+https://github.com/rust-lang/crates.io-index"
2148checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
2149dependencies = [
2150 "same-file",
2151 "winapi-util",
2152]
2153
2154[[package]]
2155name = "want"
2156version = "0.3.1"
2157source = "registry+https://github.com/rust-lang/crates.io-index"
2158checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
2159dependencies = [
2160 "try-lock",
2161]
2162
2163[[package]]
2164name = "wasi"
2165version = "0.11.1+wasi-snapshot-preview1"
2166source = "registry+https://github.com/rust-lang/crates.io-index"
2167checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2168
2169[[package]]
2170name = "wasip2"
2171version = "1.0.4+wasi-0.2.12"
2172source = "registry+https://github.com/rust-lang/crates.io-index"
2173checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
2174dependencies = [
2175 "wit-bindgen",
2176]
2177
2178[[package]]
2179name = "wasm-bindgen"
2180version = "0.2.129"
2181source = "registry+https://github.com/rust-lang/crates.io-index"
2182checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
2183dependencies = [
2184 "cfg-if",
2185 "once_cell",
2186 "rustversion",
2187 "wasm-bindgen-macro",
2188 "wasm-bindgen-shared",
2189]
2190
2191[[package]]
2192name = "wasm-bindgen-futures"
2193version = "0.4.79"
2194source = "registry+https://github.com/rust-lang/crates.io-index"
2195checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
2196dependencies = [
2197 "js-sys",
2198 "tokio",
2199 "wasm-bindgen",
2200]
2201
2202[[package]]
2203name = "wasm-bindgen-macro"
2204version = "0.2.129"
2205source = "registry+https://github.com/rust-lang/crates.io-index"
2206checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
2207dependencies = [
2208 "quote",
2209 "wasm-bindgen-macro-support",
2210]
2211
2212[[package]]
2213name = "wasm-bindgen-macro-support"
2214version = "0.2.129"
2215source = "registry+https://github.com/rust-lang/crates.io-index"
2216checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
2217dependencies = [
2218 "bumpalo",
2219 "proc-macro2",
2220 "quote",
2221 "syn 3.0.6",
2222 "wasm-bindgen-shared",
2223]
2224
2225[[package]]
2226name = "wasm-bindgen-shared"
2227version = "0.2.129"
2228source = "registry+https://github.com/rust-lang/crates.io-index"
2229checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
2230dependencies = [
2231 "unicode-ident",
2232]
2233
2234[[package]]
2235name = "web-sys"
2236version = "0.3.106"
2237source = "registry+https://github.com/rust-lang/crates.io-index"
2238checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
2239dependencies = [
2240 "js-sys",
2241 "wasm-bindgen",
2242]
2243
2244[[package]]
2245name = "web-time"
2246version = "1.1.0"
2247source = "registry+https://github.com/rust-lang/crates.io-index"
2248checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
2249dependencies = [
2250 "js-sys",
2251 "wasm-bindgen",
2252]
2253
2254[[package]]
2255name = "web_atoms"
2256version = "0.2.6"
2257source = "registry+https://github.com/rust-lang/crates.io-index"
2258checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
2259dependencies = [
2260 "phf",
2261 "phf_codegen",
2262 "string_cache",
2263 "string_cache_codegen",
2264]
2265
2266[[package]]
2267name = "webpki-roots"
2268version = "1.0.9"
2269source = "registry+https://github.com/rust-lang/crates.io-index"
2270checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
2271dependencies = [
2272 "rustls-pki-types",
2273]
2274
2275[[package]]
2276name = "winapi-util"
2277version = "0.1.11"
2278source = "registry+https://github.com/rust-lang/crates.io-index"
2279checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2280dependencies = [
2281 "windows-sys 0.61.2",
2282]
2283
2284[[package]]
2285name = "windows-core"
2286version = "0.62.2"
2287source = "registry+https://github.com/rust-lang/crates.io-index"
2288checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
2289dependencies = [
2290 "windows-implement",
2291 "windows-interface",
2292 "windows-link",
2293 "windows-result",
2294 "windows-strings",
2295]
2296
2297[[package]]
2298name = "windows-implement"
2299version = "0.60.2"
2300source = "registry+https://github.com/rust-lang/crates.io-index"
2301checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
2302dependencies = [
2303 "proc-macro2",
2304 "quote",
2305 "syn 2.0.119",
2306]
2307
2308[[package]]
2309name = "windows-interface"
2310version = "0.59.3"
2311source = "registry+https://github.com/rust-lang/crates.io-index"
2312checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
2313dependencies = [
2314 "proc-macro2",
2315 "quote",
2316 "syn 2.0.119",
2317]
2318
2319[[package]]
2320name = "windows-link"
2321version = "0.2.1"
2322source = "registry+https://github.com/rust-lang/crates.io-index"
2323checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2324
2325[[package]]
2326name = "windows-result"
2327version = "0.4.1"
2328source = "registry+https://github.com/rust-lang/crates.io-index"
2329checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2330dependencies = [
2331 "windows-link",
2332]
2333
2334[[package]]
2335name = "windows-strings"
2336version = "0.5.1"
2337source = "registry+https://github.com/rust-lang/crates.io-index"
2338checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2339dependencies = [
2340 "windows-link",
2341]
2342
2343[[package]]
2344name = "windows-sys"
2345version = "0.52.0"
2346source = "registry+https://github.com/rust-lang/crates.io-index"
2347checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
2348dependencies = [
2349 "windows-targets",
2350]
2351
2352[[package]]
2353name = "windows-sys"
2354version = "0.61.2"
2355source = "registry+https://github.com/rust-lang/crates.io-index"
2356checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2357dependencies = [
2358 "windows-link",
2359]
2360
2361[[package]]
2362name = "windows-targets"
2363version = "0.52.6"
2364source = "registry+https://github.com/rust-lang/crates.io-index"
2365checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
2366dependencies = [
2367 "windows_aarch64_gnullvm",
2368 "windows_aarch64_msvc",
2369 "windows_i686_gnu",
2370 "windows_i686_gnullvm",
2371 "windows_i686_msvc",
2372 "windows_x86_64_gnu",
2373 "windows_x86_64_gnullvm",
2374 "windows_x86_64_msvc",
2375]
2376
2377[[package]]
2378name = "windows_aarch64_gnullvm"
2379version = "0.52.6"
2380source = "registry+https://github.com/rust-lang/crates.io-index"
2381checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
2382
2383[[package]]
2384name = "windows_aarch64_msvc"
2385version = "0.52.6"
2386source = "registry+https://github.com/rust-lang/crates.io-index"
2387checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
2388
2389[[package]]
2390name = "windows_i686_gnu"
2391version = "0.52.6"
2392source = "registry+https://github.com/rust-lang/crates.io-index"
2393checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
2394
2395[[package]]
2396name = "windows_i686_gnullvm"
2397version = "0.52.6"
2398source = "registry+https://github.com/rust-lang/crates.io-index"
2399checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
2400
2401[[package]]
2402name = "windows_i686_msvc"
2403version = "0.52.6"
2404source = "registry+https://github.com/rust-lang/crates.io-index"
2405checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2406
2407[[package]]
2408name = "windows_x86_64_gnu"
2409version = "0.52.6"
2410source = "registry+https://github.com/rust-lang/crates.io-index"
2411checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2412
2413[[package]]
2414name = "windows_x86_64_gnullvm"
2415version = "0.52.6"
2416source = "registry+https://github.com/rust-lang/crates.io-index"
2417checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2418
2419[[package]]
2420name = "windows_x86_64_msvc"
2421version = "0.52.6"
2422source = "registry+https://github.com/rust-lang/crates.io-index"
2423checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2424
2425[[package]]
2426name = "wit-bindgen"
2427version = "0.57.1"
2428source = "registry+https://github.com/rust-lang/crates.io-index"
2429checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
2430
2431[[package]]
2432name = "writeable"
2433version = "0.6.4"
2434source = "registry+https://github.com/rust-lang/crates.io-index"
2435checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
2436
2437[[package]]
2438name = "yoke"
2439version = "0.8.3"
2440source = "registry+https://github.com/rust-lang/crates.io-index"
2441checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
2442dependencies = [
2443 "stable_deref_trait",
2444 "yoke-derive",
2445 "zerofrom",
2446]
2447
2448[[package]]
2449name = "yoke-derive"
2450version = "0.8.3"
2451source = "registry+https://github.com/rust-lang/crates.io-index"
2452checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
2453dependencies = [
2454 "proc-macro2",
2455 "quote",
2456 "syn 3.0.6",
2457 "synstructure",
2458]
2459
2460[[package]]
2461name = "zerocopy"
2462version = "0.8.59"
2463source = "registry+https://github.com/rust-lang/crates.io-index"
2464checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
2465dependencies = [
2466 "zerocopy-derive",
2467]
2468
2469[[package]]
2470name = "zerocopy-derive"
2471version = "0.8.59"
2472source = "registry+https://github.com/rust-lang/crates.io-index"
2473checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
2474dependencies = [
2475 "proc-macro2",
2476 "quote",
2477 "syn 2.0.119",
2478]
2479
2480[[package]]
2481name = "zerofrom"
2482version = "0.1.8"
2483source = "registry+https://github.com/rust-lang/crates.io-index"
2484checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
2485dependencies = [
2486 "zerofrom-derive",
2487]
2488
2489[[package]]
2490name = "zerofrom-derive"
2491version = "0.1.8"
2492source = "registry+https://github.com/rust-lang/crates.io-index"
2493checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
2494dependencies = [
2495 "proc-macro2",
2496 "quote",
2497 "syn 3.0.6",
2498 "synstructure",
2499]
2500
2501[[package]]
2502name = "zeroize"
2503version = "1.9.0"
2504source = "registry+https://github.com/rust-lang/crates.io-index"
2505checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
2506
2507[[package]]
2508name = "zerotrie"
2509version = "0.2.5"
2510source = "registry+https://github.com/rust-lang/crates.io-index"
2511checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
2512dependencies = [
2513 "displaydoc",
2514 "yoke",
2515 "zerofrom",
2516]
2517
2518[[package]]
2519name = "zerovec"
2520version = "0.11.8"
2521source = "registry+https://github.com/rust-lang/crates.io-index"
2522checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
2523dependencies = [
2524 "yoke",
2525 "zerofrom",
2526 "zerovec-derive",
2527]
2528
2529[[package]]
2530name = "zerovec-derive"
2531version = "0.11.6"
2532source = "registry+https://github.com/rust-lang/crates.io-index"
2533checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
2534dependencies = [
2535 "proc-macro2",
2536 "quote",
2537 "syn 3.0.6",
2538]
2539
2540[[package]]
2541name = "zmij"
2542version = "1.0.23"
2543source = "registry+https://github.com/rust-lang/crates.io-index"
2544checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
dashboard/Cargo.toml created+33
......@@ -0,0 +1,33 @@
1[package]
2name = "home-dashboard"
3version = "0.1.0"
4edition = "2024"
5
6[dependencies]
7axum = { version = "0.8.9", features = ["multipart", "ws"] }
8base64 = "0.22"
9bytes = "1"
10chrono = "0.4"
11futures = "0.3"
12globset = "0.4"
13rand = "0.9"
14regex = "1"
15reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }
16rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] }
17rusqlite = { version = "0.37", features = ["bundled"] }
18scraper = { version = "0.27", default-features = false }
19serde_json = "1"
20sha1 = "0.10"
21sha2 = "0.10.9"
22subtle = "2.6"
23tokio = { version = "1", features = ["full"] }
24tokio-stream = { version = "0.1", features = ["sync"] }
25tower-http = { version = "0.6", features = ["fs"] }
26url = "2"
27uuid = { version = "1", features = ["v4"] }
28walkdir = "2"
29
30[profile.release]
31lto = "thin"
32codegen-units = 1
33strip = true
dashboard/dev.ts created+37
......@@ -0,0 +1,37 @@
1import { execFile, spawn } from "node:child_process";
2import { watch } from "node:fs";
3import { promisify } from "node:util";
4
5const host = process.env.STUDIO_DEPLOY_HOST ?? "root@127.0.0.1";
6const ssh = ["-p", process.env.STUDIO_DEPLOY_PORT ?? "2222", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"];
7const dir = "/root/studio-dashboard-dev";
8const run = promisify(execFile);
9const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
10const restart = `import os, shlex, subprocess
11variables = dict(item.split("=", 1) for item in shlex.split(subprocess.check_output(["systemctl", "show", "-P", "Environment", "studio-dashboard"], text=True)))
12for key in ["STUDIO_INDEX_POOL", "STUDIO_YT_STATE", "STUDIO_FILES_WRITABLE"]: variables.pop(key, None)
13variables.update(PORT="7070", STUDIO_METRICS="follow", STUDIO_DATA_DIR="${dir}/data")
14subprocess.run(["systemctl", "stop", "studio-dashboard-dev"], check=False)
15subprocess.run(["systemd-run", "--unit=studio-dashboard-dev", "--collect", "--property=WorkingDirectory=${dir}/result/lib/home-dashboard", *["--setenv="+key+"="+value for key,value in variables.items()], "${dir}/result/bin/home-dashboard"], check=True)`;
16let pending = false;
17let rebuilding = false;
18async function rebuild() {
19 pending = true;
20 if (rebuilding) return;
21 rebuilding = true;
22 try {
23 while (pending) {
24 pending = false;
25 await run("rsync", ["-a", "--delete", ...[".jj", ".git", "node_modules", "target", "dist", ".cache", "data", "result"].map(name => "--exclude=" + name), "-e", ["ssh", ...ssh].join(" "), "../", `${host}:${dir}/`]);
26 await run("ssh", [...ssh, host, `cd ${dir} && nix --extra-experimental-features 'nix-command flakes' build path:.#dashboard --cores 2 --max-jobs 1 -o result && python3 -c ${quote(restart)}`], { maxBuffer: 16 * 1024 * 1024 });
27 console.log("Rust dashboard ready");
28 }
29 } finally { rebuilding = false; }
30}
31await rebuild();
32watch("src", { recursive: true }, () => rebuild().catch(console.error));
33const tunnel = spawn("ssh", [...ssh, "-N", "-o", "ExitOnForwardFailure=yes", "-L", "7070:127.0.0.1:7070", host], { stdio: "inherit" });
34tunnel.on("exit", code => process.exit(code ?? 1));
35for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => {
36 run("ssh", [...ssh, host, "systemctl stop studio-dashboard-dev"]).finally(() => tunnel.kill());
37});
dashboard/package.json created+25
......@@ -0,0 +1,25 @@
1{
2 "name": "home-dashboard",
3 "private": true,
4 "type": "module",
5 "scripts": {
6 "dev": "concurrently -k -n server,web \"node dev.ts\" \"vite\"",
7 "build": "vite build",
8 "check": "tsc --noEmit",
9 "start": "cargo run --release"
10 },
11 "dependencies": {
12 "hono": "^4.13.9"
13 },
14 "devDependencies": {
15 "@solidjs/router": "^1.0.0",
16 "@types/node": "^26.6.2",
17 "concurrently": "^10.0.5",
18 "lucide-solid": "^1.48.0",
19 "solid-js": "^1.9.15",
20 "typescript": "^7.0.2",
21 "uplot": "^1.6.32",
22 "vite": "^8.3.1",
23 "vite-plugin-solid": "^2.11.14"
24 }
25}
dashboard/pnpm-lock.yaml created+1654
......@@ -0,0 +1,1654 @@
1lockfileVersion: '9.0'
2
3settings:
4 autoInstallPeers: true
5 excludeLinksFromLockfile: false
6
7importers:
8
9 .:
10 dependencies:
11 hono:
12 specifier: ^4.13.9
13 version: 4.13.9
14 devDependencies:
15 '@solidjs/router':
16 specifier: ^1.0.0
17 version: 1.0.0(solid-js@1.9.15)
18 '@types/node':
19 specifier: ^26.6.2
20 version: 26.6.2
21 concurrently:
22 specifier: ^10.0.5
23 version: 10.0.5
24 lucide-solid:
25 specifier: ^1.48.0
26 version: 1.48.0(solid-js@1.9.15)
27 solid-js:
28 specifier: ^1.9.15
29 version: 1.9.15
30 typescript:
31 specifier: ^7.0.2
32 version: 7.0.2
33 uplot:
34 specifier: ^1.6.32
35 version: 1.6.32
36 vite:
37 specifier: ^8.3.1
38 version: 8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15)
39 vite-plugin-solid:
40 specifier: ^2.11.14
41 version: 2.11.14(solid-js@1.9.15)(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15))
42
43packages:
44
45 '@babel/code-frame@7.29.7':
46 resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
47 engines: {node: '>=6.9.0'}
48
49 '@babel/compat-data@7.29.7':
50 resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
51 engines: {node: '>=6.9.0'}
52
53 '@babel/core@7.29.7':
54 resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
55 engines: {node: '>=6.9.0'}
56
57 '@babel/generator@7.29.8':
58 resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
59 engines: {node: '>=6.9.0'}
60
61 '@babel/helper-compilation-targets@7.29.7':
62 resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
63 engines: {node: '>=6.9.0'}
64
65 '@babel/helper-globals@7.29.7':
66 resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
67 engines: {node: '>=6.9.0'}
68
69 '@babel/helper-module-imports@7.18.6':
70 resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==}
71 engines: {node: '>=6.9.0'}
72
73 '@babel/helper-module-imports@7.29.7':
74 resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
75 engines: {node: '>=6.9.0'}
76
77 '@babel/helper-module-transforms@7.29.7':
78 resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
79 engines: {node: '>=6.9.0'}
80 peerDependencies:
81 '@babel/core': ^7.0.0
82
83 '@babel/helper-plugin-utils@7.29.7':
84 resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
85 engines: {node: '>=6.9.0'}
86
87 '@babel/helper-string-parser@7.29.7':
88 resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
89 engines: {node: '>=6.9.0'}
90
91 '@babel/helper-validator-identifier@7.29.7':
92 resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
93 engines: {node: '>=6.9.0'}
94
95 '@babel/helper-validator-option@7.29.7':
96 resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
97 engines: {node: '>=6.9.0'}
98
99 '@babel/helpers@7.29.7':
100 resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
101 engines: {node: '>=6.9.0'}
102
103 '@babel/parser@7.29.9':
104 resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
105 engines: {node: '>=6.0.0'}
106 hasBin: true
107
108 '@babel/plugin-syntax-jsx@7.29.7':
109 resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==}
110 engines: {node: '>=6.9.0'}
111 peerDependencies:
112 '@babel/core': ^7.0.0-0
113
114 '@babel/template@7.29.7':
115 resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
116 engines: {node: '>=6.9.0'}
117
118 '@babel/traverse@7.29.8':
119 resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
120 engines: {node: '>=6.9.0'}
121
122 '@babel/types@7.29.8':
123 resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
124 engines: {node: '>=6.9.0'}
125
126 '@esbuild/aix-ppc64@0.28.2':
127 resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
128 engines: {node: '>=18'}
129 cpu: [ppc64]
130 os: [aix]
131
132 '@esbuild/android-arm64@0.28.2':
133 resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
134 engines: {node: '>=18'}
135 cpu: [arm64]
136 os: [android]
137
138 '@esbuild/android-arm@0.28.2':
139 resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
140 engines: {node: '>=18'}
141 cpu: [arm]
142 os: [android]
143
144 '@esbuild/android-x64@0.28.2':
145 resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
146 engines: {node: '>=18'}
147 cpu: [x64]
148 os: [android]
149
150 '@esbuild/darwin-arm64@0.28.2':
151 resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
152 engines: {node: '>=18'}
153 cpu: [arm64]
154 os: [darwin]
155
156 '@esbuild/darwin-x64@0.28.2':
157 resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
158 engines: {node: '>=18'}
159 cpu: [x64]
160 os: [darwin]
161
162 '@esbuild/freebsd-arm64@0.28.2':
163 resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
164 engines: {node: '>=18'}
165 cpu: [arm64]
166 os: [freebsd]
167
168 '@esbuild/freebsd-x64@0.28.2':
169 resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
170 engines: {node: '>=18'}
171 cpu: [x64]
172 os: [freebsd]
173
174 '@esbuild/linux-arm64@0.28.2':
175 resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
176 engines: {node: '>=18'}
177 cpu: [arm64]
178 os: [linux]
179
180 '@esbuild/linux-arm@0.28.2':
181 resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
182 engines: {node: '>=18'}
183 cpu: [arm]
184 os: [linux]
185
186 '@esbuild/linux-ia32@0.28.2':
187 resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
188 engines: {node: '>=18'}
189 cpu: [ia32]
190 os: [linux]
191
192 '@esbuild/linux-loong64@0.28.2':
193 resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
194 engines: {node: '>=18'}
195 cpu: [loong64]
196 os: [linux]
197
198 '@esbuild/linux-mips64el@0.28.2':
199 resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
200 engines: {node: '>=18'}
201 cpu: [mips64el]
202 os: [linux]
203
204 '@esbuild/linux-ppc64@0.28.2':
205 resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
206 engines: {node: '>=18'}
207 cpu: [ppc64]
208 os: [linux]
209
210 '@esbuild/linux-riscv64@0.28.2':
211 resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
212 engines: {node: '>=18'}
213 cpu: [riscv64]
214 os: [linux]
215
216 '@esbuild/linux-s390x@0.28.2':
217 resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
218 engines: {node: '>=18'}
219 cpu: [s390x]
220 os: [linux]
221
222 '@esbuild/linux-x64@0.28.2':
223 resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
224 engines: {node: '>=18'}
225 cpu: [x64]
226 os: [linux]
227
228 '@esbuild/netbsd-arm64@0.28.2':
229 resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
230 engines: {node: '>=18'}
231 cpu: [arm64]
232 os: [netbsd]
233
234 '@esbuild/netbsd-x64@0.28.2':
235 resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
236 engines: {node: '>=18'}
237 cpu: [x64]
238 os: [netbsd]
239
240 '@esbuild/openbsd-arm64@0.28.2':
241 resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
242 engines: {node: '>=18'}
243 cpu: [arm64]
244 os: [openbsd]
245
246 '@esbuild/openbsd-x64@0.28.2':
247 resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
248 engines: {node: '>=18'}
249 cpu: [x64]
250 os: [openbsd]
251
252 '@esbuild/openharmony-arm64@0.28.2':
253 resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
254 engines: {node: '>=18'}
255 cpu: [arm64]
256 os: [openharmony]
257
258 '@esbuild/sunos-x64@0.28.2':
259 resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
260 engines: {node: '>=18'}
261 cpu: [x64]
262 os: [sunos]
263
264 '@esbuild/win32-arm64@0.28.2':
265 resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
266 engines: {node: '>=18'}
267 cpu: [arm64]
268 os: [win32]
269
270 '@esbuild/win32-ia32@0.28.2':
271 resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
272 engines: {node: '>=18'}
273 cpu: [ia32]
274 os: [win32]
275
276 '@esbuild/win32-x64@0.28.2':
277 resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
278 engines: {node: '>=18'}
279 cpu: [x64]
280 os: [win32]
281
282 '@jridgewell/gen-mapping@0.3.13':
283 resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
284
285 '@jridgewell/remapping@2.3.5':
286 resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
287
288 '@jridgewell/resolve-uri@3.1.2':
289 resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
290 engines: {node: '>=6.0.0'}
291
292 '@jridgewell/sourcemap-codec@1.6.0':
293 resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
294
295 '@jridgewell/trace-mapping@0.3.31':
296 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
297
298 '@oxc-project/types@0.151.0':
299 resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==}
300
301 '@rolldown/binding-android-arm-eabi@1.2.11':
302 resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==}
303 engines: {node: ^20.19.0 || >=22.12.0}
304 cpu: [arm]
305 os: [android]
306
307 '@rolldown/binding-android-arm64@1.2.11':
308 resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==}
309 engines: {node: ^20.19.0 || >=22.12.0}
310 cpu: [arm64]
311 os: [android]
312
313 '@rolldown/binding-darwin-arm64@1.2.11':
314 resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==}
315 engines: {node: ^20.19.0 || >=22.12.0}
316 cpu: [arm64]
317 os: [darwin]
318
319 '@rolldown/binding-darwin-x64@1.2.11':
320 resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==}
321 engines: {node: ^20.19.0 || >=22.12.0}
322 cpu: [x64]
323 os: [darwin]
324
325 '@rolldown/binding-freebsd-x64@1.2.11':
326 resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==}
327 engines: {node: ^20.19.0 || >=22.12.0}
328 cpu: [x64]
329 os: [freebsd]
330
331 '@rolldown/binding-linux-arm-gnueabihf@1.2.11':
332 resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==}
333 engines: {node: ^20.19.0 || >=22.12.0}
334 cpu: [arm]
335 os: [linux]
336
337 '@rolldown/binding-linux-arm64-gnu@1.2.11':
338 resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==}
339 engines: {node: ^20.19.0 || >=22.12.0}
340 cpu: [arm64]
341 os: [linux]
342
343 '@rolldown/binding-linux-arm64-musl@1.2.11':
344 resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==}
345 engines: {node: ^20.19.0 || >=22.12.0}
346 cpu: [arm64]
347 os: [linux]
348
349 '@rolldown/binding-linux-ppc64-gnu@1.2.11':
350 resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==}
351 engines: {node: ^20.19.0 || >=22.12.0}
352 cpu: [ppc64]
353 os: [linux]
354
355 '@rolldown/binding-linux-s390x-gnu@1.2.11':
356 resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==}
357 engines: {node: ^20.19.0 || >=22.12.0}
358 cpu: [s390x]
359 os: [linux]
360
361 '@rolldown/binding-linux-x64-gnu@1.2.11':
362 resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==}
363 engines: {node: ^20.19.0 || >=22.12.0}
364 cpu: [x64]
365 os: [linux]
366
367 '@rolldown/binding-linux-x64-musl@1.2.11':
368 resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==}
369 engines: {node: ^20.19.0 || >=22.12.0}
370 cpu: [x64]
371 os: [linux]
372
373 '@rolldown/binding-openharmony-arm64@1.2.11':
374 resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==}
375 engines: {node: ^20.19.0 || >=22.12.0}
376 cpu: [arm64]
377 os: [openharmony]
378
379 '@rolldown/binding-win32-arm64-msvc@1.2.11':
380 resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==}
381 engines: {node: ^20.19.0 || >=22.12.0}
382 cpu: [arm64]
383 os: [win32]
384
385 '@rolldown/binding-win32-x64-msvc@1.2.11':
386 resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==}
387 engines: {node: ^20.19.0 || >=22.12.0}
388 cpu: [x64]
389 os: [win32]
390
391 '@rolldown/pluginutils@1.0.1':
392 resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
393
394 '@solidjs/router@1.0.0':
395 resolution: {integrity: sha512-cCSk1hvgCowiMa9bzzYWHiLu1U4E22+DfJe6/rOwAyECKrxc3jrd5QnoW3sDDJtW+e077cz/M67bPl3DqOBw1Q==}
396 peerDependencies:
397 solid-js: ^1.8.6
398
399 '@types/babel__core@7.20.5':
400 resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
401
402 '@types/babel__generator@7.27.0':
403 resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
404
405 '@types/babel__template@7.4.4':
406 resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
407
408 '@types/babel__traverse@7.28.0':
409 resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
410
411 '@types/node@26.6.2':
412 resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==}
413
414 '@typescript/typescript-aix-ppc64@7.0.2':
415 resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
416 engines: {node: '>=16.20.0'}
417 cpu: [ppc64]
418 os: [aix]
419
420 '@typescript/typescript-darwin-arm64@7.0.2':
421 resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
422 engines: {node: '>=16.20.0'}
423 cpu: [arm64]
424 os: [darwin]
425
426 '@typescript/typescript-darwin-x64@7.0.2':
427 resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
428 engines: {node: '>=16.20.0'}
429 cpu: [x64]
430 os: [darwin]
431
432 '@typescript/typescript-freebsd-arm64@7.0.2':
433 resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
434 engines: {node: '>=16.20.0'}
435 cpu: [arm64]
436 os: [freebsd]
437
438 '@typescript/typescript-freebsd-x64@7.0.2':
439 resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
440 engines: {node: '>=16.20.0'}
441 cpu: [x64]
442 os: [freebsd]
443
444 '@typescript/typescript-linux-arm64@7.0.2':
445 resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
446 engines: {node: '>=16.20.0'}
447 cpu: [arm64]
448 os: [linux]
449
450 '@typescript/typescript-linux-arm@7.0.2':
451 resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
452 engines: {node: '>=16.20.0'}
453 cpu: [arm]
454 os: [linux]
455
456 '@typescript/typescript-linux-loong64@7.0.2':
457 resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
458 engines: {node: '>=16.20.0'}
459 cpu: [loong64]
460 os: [linux]
461
462 '@typescript/typescript-linux-mips64el@7.0.2':
463 resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
464 engines: {node: '>=16.20.0'}
465 cpu: [mips64el]
466 os: [linux]
467
468 '@typescript/typescript-linux-ppc64@7.0.2':
469 resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
470 engines: {node: '>=16.20.0'}
471 cpu: [ppc64]
472 os: [linux]
473
474 '@typescript/typescript-linux-riscv64@7.0.2':
475 resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
476 engines: {node: '>=16.20.0'}
477 cpu: [riscv64]
478 os: [linux]
479
480 '@typescript/typescript-linux-s390x@7.0.2':
481 resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
482 engines: {node: '>=16.20.0'}
483 cpu: [s390x]
484 os: [linux]
485
486 '@typescript/typescript-linux-x64@7.0.2':
487 resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
488 engines: {node: '>=16.20.0'}
489 cpu: [x64]
490 os: [linux]
491
492 '@typescript/typescript-netbsd-arm64@7.0.2':
493 resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
494 engines: {node: '>=16.20.0'}
495 cpu: [arm64]
496 os: [netbsd]
497
498 '@typescript/typescript-netbsd-x64@7.0.2':
499 resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
500 engines: {node: '>=16.20.0'}
501 cpu: [x64]
502 os: [netbsd]
503
504 '@typescript/typescript-openbsd-arm64@7.0.2':
505 resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
506 engines: {node: '>=16.20.0'}
507 cpu: [arm64]
508 os: [openbsd]
509
510 '@typescript/typescript-openbsd-x64@7.0.2':
511 resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
512 engines: {node: '>=16.20.0'}
513 cpu: [x64]
514 os: [openbsd]
515
516 '@typescript/typescript-sunos-x64@7.0.2':
517 resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
518 engines: {node: '>=16.20.0'}
519 cpu: [x64]
520 os: [sunos]
521
522 '@typescript/typescript-win32-arm64@7.0.2':
523 resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
524 engines: {node: '>=16.20.0'}
525 cpu: [arm64]
526 os: [win32]
527
528 '@typescript/typescript-win32-x64@7.0.2':
529 resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
530 engines: {node: '>=16.20.0'}
531 cpu: [x64]
532 os: [win32]
533
534 ansi-regex@6.3.0:
535 resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==}
536 engines: {node: '>=12'}
537
538 ansi-styles@6.2.3:
539 resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
540 engines: {node: '>=12'}
541
542 babel-plugin-jsx-dom-expressions@0.40.10:
543 resolution: {integrity: sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA==}
544 peerDependencies:
545 '@babel/core': ^7.20.12
546
547 babel-preset-solid@1.9.15:
548 resolution: {integrity: sha512-GBmg1OiPb+OwcH51XbDAKPtvrPfQW7rCJTJxcp8+yhtWwN+kqnbEJk2SgVybd+uhTxTKAvjaFyiQSr/eUZBwzg==}
549 peerDependencies:
550 '@babel/core': ^7.0.0
551 solid-js: ^1.9.15
552 peerDependenciesMeta:
553 solid-js:
554 optional: true
555
556 baseline-browser-mapping@2.11.26:
557 resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==}
558 engines: {node: '>=6.0.0'}
559 hasBin: true
560
561 browserslist@4.29.1:
562 resolution: {integrity: sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==}
563 engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
564 hasBin: true
565
566 caniuse-lite@1.0.30001812:
567 resolution: {integrity: sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==}
568
569 chalk@5.6.2:
570 resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
571 engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
572
573 cliui@9.0.1:
574 resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
575 engines: {node: '>=20'}
576
577 concurrently@10.0.5:
578 resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==}
579 engines: {node: '>=22'}
580 hasBin: true
581
582 convert-source-map@2.0.0:
583 resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
584
585 csstype@3.2.3:
586 resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
587
588 debug@4.4.3:
589 resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
590 engines: {node: '>=6.0'}
591 peerDependencies:
592 supports-color: '*'
593 peerDependenciesMeta:
594 supports-color:
595 optional: true
596
597 detect-libc@2.1.2:
598 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
599 engines: {node: '>=8'}
600
601 electron-to-chromium@1.5.439:
602 resolution: {integrity: sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==}
603
604 emoji-regex@10.6.0:
605 resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
606
607 entities@6.0.1:
608 resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
609 engines: {node: '>=0.12'}
610
611 esbuild@0.28.2:
612 resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
613 engines: {node: '>=18'}
614 hasBin: true
615
616 escalade@3.2.0:
617 resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
618 engines: {node: '>=6'}
619
620 fdir@6.5.0:
621 resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
622 engines: {node: '>=12.0.0'}
623 peerDependencies:
624 picomatch: ^3 || ^4
625 peerDependenciesMeta:
626 picomatch:
627 optional: true
628
629 fsevents@2.3.3:
630 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
631 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
632 os: [darwin]
633
634 gensync@1.0.0-beta.2:
635 resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
636 engines: {node: '>=6.9.0'}
637
638 get-caller-file@2.0.5:
639 resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
640 engines: {node: 6.* || 8.* || >= 10.*}
641
642 get-east-asian-width@1.7.0:
643 resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==}
644 engines: {node: '>=18'}
645
646 hono@4.13.9:
647 resolution: {integrity: sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==}
648 engines: {node: '>=16.9.0'}
649
650 html-entities@2.3.3:
651 resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==}
652
653 is-what@4.1.16:
654 resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
655 engines: {node: '>=12.13'}
656
657 js-tokens@4.0.0:
658 resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
659
660 jsesc@3.1.0:
661 resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
662 engines: {node: '>=6'}
663 hasBin: true
664
665 json5@2.2.3:
666 resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
667 engines: {node: '>=6'}
668 hasBin: true
669
670 lightningcss-android-arm64@1.33.0:
671 resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
672 engines: {node: '>= 12.0.0'}
673 cpu: [arm64]
674 os: [android]
675
676 lightningcss-darwin-arm64@1.33.0:
677 resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
678 engines: {node: '>= 12.0.0'}
679 cpu: [arm64]
680 os: [darwin]
681
682 lightningcss-darwin-x64@1.33.0:
683 resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
684 engines: {node: '>= 12.0.0'}
685 cpu: [x64]
686 os: [darwin]
687
688 lightningcss-freebsd-x64@1.33.0:
689 resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
690 engines: {node: '>= 12.0.0'}
691 cpu: [x64]
692 os: [freebsd]
693
694 lightningcss-linux-arm-gnueabihf@1.33.0:
695 resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
696 engines: {node: '>= 12.0.0'}
697 cpu: [arm]
698 os: [linux]
699
700 lightningcss-linux-arm64-gnu@1.33.0:
701 resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
702 engines: {node: '>= 12.0.0'}
703 cpu: [arm64]
704 os: [linux]
705
706 lightningcss-linux-arm64-musl@1.33.0:
707 resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
708 engines: {node: '>= 12.0.0'}
709 cpu: [arm64]
710 os: [linux]
711
712 lightningcss-linux-x64-gnu@1.33.0:
713 resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
714 engines: {node: '>= 12.0.0'}
715 cpu: [x64]
716 os: [linux]
717
718 lightningcss-linux-x64-musl@1.33.0:
719 resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
720 engines: {node: '>= 12.0.0'}
721 cpu: [x64]
722 os: [linux]
723
724 lightningcss-win32-arm64-msvc@1.33.0:
725 resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
726 engines: {node: '>= 12.0.0'}
727 cpu: [arm64]
728 os: [win32]
729
730 lightningcss-win32-x64-msvc@1.33.0:
731 resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
732 engines: {node: '>= 12.0.0'}
733 cpu: [x64]
734 os: [win32]
735
736 lightningcss@1.33.0:
737 resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
738 engines: {node: '>= 12.0.0'}
739
740 lru-cache@5.1.1:
741 resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
742
743 lucide-solid@1.48.0:
744 resolution: {integrity: sha512-tes5UEPXUpMYwk8MRgFS9EtV90miJ8P/19H8NR4hhaiVDBfZY39YG0zm7Ofxv3YVNNdKP95XckdYXXc0CtQaoQ==}
745 peerDependencies:
746 solid-js: ^1.4.7
747
748 merge-anything@5.1.7:
749 resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==}
750 engines: {node: '>=12.13'}
751
752 ms@2.1.3:
753 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
754
755 nanoid@3.3.19:
756 resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
757 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
758 hasBin: true
759
760 node-releases@2.0.57:
761 resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
762 engines: {node: '>=18'}
763
764 parse5@7.3.0:
765 resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
766
767 picocolors@1.1.1:
768 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
769
770 picomatch@4.0.7:
771 resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
772 engines: {node: '>=12'}
773
774 postcss@8.5.28:
775 resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
776 engines: {node: ^10 || ^12 || >=14}
777
778 rolldown@1.2.11:
779 resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==}
780 engines: {node: ^20.19.0 || >=22.12.0}
781 hasBin: true
782
783 rxjs@7.8.2:
784 resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
785
786 semver@6.3.1:
787 resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
788 hasBin: true
789
790 seroval-plugins@1.5.6:
791 resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==}
792 engines: {node: '>=10'}
793 peerDependencies:
794 seroval: ^1.0
795
796 seroval@1.5.6:
797 resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==}
798 engines: {node: '>=10'}
799
800 shell-quote@1.9.0:
801 resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
802 engines: {node: '>= 0.4'}
803
804 solid-js@1.9.15:
805 resolution: {integrity: sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==}
806
807 solid-refresh@0.6.3:
808 resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==}
809 peerDependencies:
810 solid-js: ^1.3
811
812 source-map-js@1.2.1:
813 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
814 engines: {node: '>=0.10.0'}
815
816 string-width@7.2.0:
817 resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
818 engines: {node: '>=18'}
819
820 strip-ansi@7.2.0:
821 resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
822 engines: {node: '>=12'}
823
824 supports-color@10.2.2:
825 resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
826 engines: {node: '>=18'}
827
828 tinyglobby@0.2.17:
829 resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
830 engines: {node: '>=12.0.0'}
831
832 tree-kill@1.2.2:
833 resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
834 hasBin: true
835
836 tslib@2.8.1:
837 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
838
839 tsx@4.23.15:
840 resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
841 engines: {node: '>=18.0.0'}
842 hasBin: true
843
844 typescript@7.0.2:
845 resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
846 engines: {node: '>=16.20.0'}
847 hasBin: true
848
849 undici-types@8.9.0:
850 resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
851
852 update-browserslist-db@1.3.3:
853 resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==}
854 hasBin: true
855 peerDependencies:
856 browserslist: '>= 4.21.0'
857
858 uplot@1.6.32:
859 resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==}
860
861 vite-plugin-solid@2.11.14:
862 resolution: {integrity: sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw==}
863 peerDependencies:
864 '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0
865 solid-js: ^1.7.2
866 vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0
867 peerDependenciesMeta:
868 '@testing-library/jest-dom':
869 optional: true
870
871 vite@8.3.1:
872 resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==}
873 engines: {node: ^20.19.0 || >=22.12.0}
874 hasBin: true
875 peerDependencies:
876 '@types/node': ^20.19.0 || >=22.12.0
877 '@vitejs/devtools': ^0.7.1
878 esbuild: ^0.27.0 || ^0.28.0
879 jiti: '>=1.21.0'
880 less: ^4.0.0
881 sass: ^1.70.0
882 sass-embedded: ^1.70.0
883 stylus: '>=0.54.8'
884 sugarss: ^5.0.0
885 terser: ^5.16.0
886 tsx: ^4.8.1
887 yaml: ^2.4.2
888 peerDependenciesMeta:
889 '@types/node':
890 optional: true
891 '@vitejs/devtools':
892 optional: true
893 esbuild:
894 optional: true
895 jiti:
896 optional: true
897 less:
898 optional: true
899 sass:
900 optional: true
901 sass-embedded:
902 optional: true
903 stylus:
904 optional: true
905 sugarss:
906 optional: true
907 terser:
908 optional: true
909 tsx:
910 optional: true
911 yaml:
912 optional: true
913
914 vitefu@1.1.3:
915 resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==}
916 peerDependencies:
917 vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
918 peerDependenciesMeta:
919 vite:
920 optional: true
921
922 wrap-ansi@9.0.2:
923 resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
924 engines: {node: '>=18'}
925
926 y18n@5.0.8:
927 resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
928 engines: {node: '>=10'}
929
930 yallist@3.1.1:
931 resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
932
933 yargs-parser@22.0.0:
934 resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
935 engines: {node: ^20.19.0 || ^22.12.0 || >=23}
936
937 yargs@18.0.0:
938 resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
939 engines: {node: ^20.19.0 || ^22.12.0 || >=23}
940
941snapshots:
942
943 '@babel/code-frame@7.29.7':
944 dependencies:
945 '@babel/helper-validator-identifier': 7.29.7
946 js-tokens: 4.0.0
947 picocolors: 1.1.1
948
949 '@babel/compat-data@7.29.7': {}
950
951 '@babel/core@7.29.7':
952 dependencies:
953 '@babel/code-frame': 7.29.7
954 '@babel/generator': 7.29.8
955 '@babel/helper-compilation-targets': 7.29.7
956 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
957 '@babel/helpers': 7.29.7
958 '@babel/parser': 7.29.9
959 '@babel/template': 7.29.7
960 '@babel/traverse': 7.29.8
961 '@babel/types': 7.29.8
962 '@jridgewell/remapping': 2.3.5
963 convert-source-map: 2.0.0
964 debug: 4.4.3
965 gensync: 1.0.0-beta.2
966 json5: 2.2.3
967 semver: 6.3.1
968 transitivePeerDependencies:
969 - supports-color
970
971 '@babel/generator@7.29.8':
972 dependencies:
973 '@babel/parser': 7.29.9
974 '@babel/types': 7.29.8
975 '@jridgewell/gen-mapping': 0.3.13
976 '@jridgewell/trace-mapping': 0.3.31
977 jsesc: 3.1.0
978
979 '@babel/helper-compilation-targets@7.29.7':
980 dependencies:
981 '@babel/compat-data': 7.29.7
982 '@babel/helper-validator-option': 7.29.7
983 browserslist: 4.29.1
984 lru-cache: 5.1.1
985 semver: 6.3.1
986
987 '@babel/helper-globals@7.29.7': {}
988
989 '@babel/helper-module-imports@7.18.6':
990 dependencies:
991 '@babel/types': 7.29.8
992
993 '@babel/helper-module-imports@7.29.7':
994 dependencies:
995 '@babel/traverse': 7.29.8
996 '@babel/types': 7.29.8
997 transitivePeerDependencies:
998 - supports-color
999
1000 '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
1001 dependencies:
1002 '@babel/core': 7.29.7
1003 '@babel/helper-module-imports': 7.29.7
1004 '@babel/helper-validator-identifier': 7.29.7
1005 '@babel/traverse': 7.29.8
1006 transitivePeerDependencies:
1007 - supports-color
1008
1009 '@babel/helper-plugin-utils@7.29.7': {}
1010
1011 '@babel/helper-string-parser@7.29.7': {}
1012
1013 '@babel/helper-validator-identifier@7.29.7': {}
1014
1015 '@babel/helper-validator-option@7.29.7': {}
1016
1017 '@babel/helpers@7.29.7':
1018 dependencies:
1019 '@babel/template': 7.29.7
1020 '@babel/types': 7.29.8
1021
1022 '@babel/parser@7.29.9':
1023 dependencies:
1024 '@babel/types': 7.29.8
1025
1026 '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)':
1027 dependencies:
1028 '@babel/core': 7.29.7
1029 '@babel/helper-plugin-utils': 7.29.7
1030
1031 '@babel/template@7.29.7':
1032 dependencies:
1033 '@babel/code-frame': 7.29.7
1034 '@babel/parser': 7.29.9
1035 '@babel/types': 7.29.8
1036
1037 '@babel/traverse@7.29.8':
1038 dependencies:
1039 '@babel/code-frame': 7.29.7
1040 '@babel/generator': 7.29.8
1041 '@babel/helper-globals': 7.29.7
1042 '@babel/parser': 7.29.9
1043 '@babel/template': 7.29.7
1044 '@babel/types': 7.29.8
1045 debug: 4.4.3
1046 transitivePeerDependencies:
1047 - supports-color
1048
1049 '@babel/types@7.29.8':
1050 dependencies:
1051 '@babel/helper-string-parser': 7.29.7
1052 '@babel/helper-validator-identifier': 7.29.7
1053
1054 '@esbuild/aix-ppc64@0.28.2':
1055 optional: true
1056
1057 '@esbuild/android-arm64@0.28.2':
1058 optional: true
1059
1060 '@esbuild/android-arm@0.28.2':
1061 optional: true
1062
1063 '@esbuild/android-x64@0.28.2':
1064 optional: true
1065
1066 '@esbuild/darwin-arm64@0.28.2':
1067 optional: true
1068
1069 '@esbuild/darwin-x64@0.28.2':
1070 optional: true
1071
1072 '@esbuild/freebsd-arm64@0.28.2':
1073 optional: true
1074
1075 '@esbuild/freebsd-x64@0.28.2':
1076 optional: true
1077
1078 '@esbuild/linux-arm64@0.28.2':
1079 optional: true
1080
1081 '@esbuild/linux-arm@0.28.2':
1082 optional: true
1083
1084 '@esbuild/linux-ia32@0.28.2':
1085 optional: true
1086
1087 '@esbuild/linux-loong64@0.28.2':
1088 optional: true
1089
1090 '@esbuild/linux-mips64el@0.28.2':
1091 optional: true
1092
1093 '@esbuild/linux-ppc64@0.28.2':
1094 optional: true
1095
1096 '@esbuild/linux-riscv64@0.28.2':
1097 optional: true
1098
1099 '@esbuild/linux-s390x@0.28.2':
1100 optional: true
1101
1102 '@esbuild/linux-x64@0.28.2':
1103 optional: true
1104
1105 '@esbuild/netbsd-arm64@0.28.2':
1106 optional: true
1107
1108 '@esbuild/netbsd-x64@0.28.2':
1109 optional: true
1110
1111 '@esbuild/openbsd-arm64@0.28.2':
1112 optional: true
1113
1114 '@esbuild/openbsd-x64@0.28.2':
1115 optional: true
1116
1117 '@esbuild/openharmony-arm64@0.28.2':
1118 optional: true
1119
1120 '@esbuild/sunos-x64@0.28.2':
1121 optional: true
1122
1123 '@esbuild/win32-arm64@0.28.2':
1124 optional: true
1125
1126 '@esbuild/win32-ia32@0.28.2':
1127 optional: true
1128
1129 '@esbuild/win32-x64@0.28.2':
1130 optional: true
1131
1132 '@jridgewell/gen-mapping@0.3.13':
1133 dependencies:
1134 '@jridgewell/sourcemap-codec': 1.6.0
1135 '@jridgewell/trace-mapping': 0.3.31
1136
1137 '@jridgewell/remapping@2.3.5':
1138 dependencies:
1139 '@jridgewell/gen-mapping': 0.3.13
1140 '@jridgewell/trace-mapping': 0.3.31
1141
1142 '@jridgewell/resolve-uri@3.1.2': {}
1143
1144 '@jridgewell/sourcemap-codec@1.6.0': {}
1145
1146 '@jridgewell/trace-mapping@0.3.31':
1147 dependencies:
1148 '@jridgewell/resolve-uri': 3.1.2
1149 '@jridgewell/sourcemap-codec': 1.6.0
1150
1151 '@oxc-project/types@0.151.0': {}
1152
1153 '@rolldown/binding-android-arm-eabi@1.2.11':
1154 optional: true
1155
1156 '@rolldown/binding-android-arm64@1.2.11':
1157 optional: true
1158
1159 '@rolldown/binding-darwin-arm64@1.2.11':
1160 optional: true
1161
1162 '@rolldown/binding-darwin-x64@1.2.11':
1163 optional: true
1164
1165 '@rolldown/binding-freebsd-x64@1.2.11':
1166 optional: true
1167
1168 '@rolldown/binding-linux-arm-gnueabihf@1.2.11':
1169 optional: true
1170
1171 '@rolldown/binding-linux-arm64-gnu@1.2.11':
1172 optional: true
1173
1174 '@rolldown/binding-linux-arm64-musl@1.2.11':
1175 optional: true
1176
1177 '@rolldown/binding-linux-ppc64-gnu@1.2.11':
1178 optional: true
1179
1180 '@rolldown/binding-linux-s390x-gnu@1.2.11':
1181 optional: true
1182
1183 '@rolldown/binding-linux-x64-gnu@1.2.11':
1184 optional: true
1185
1186 '@rolldown/binding-linux-x64-musl@1.2.11':
1187 optional: true
1188
1189 '@rolldown/binding-openharmony-arm64@1.2.11':
1190 optional: true
1191
1192 '@rolldown/binding-win32-arm64-msvc@1.2.11':
1193 optional: true
1194
1195 '@rolldown/binding-win32-x64-msvc@1.2.11':
1196 optional: true
1197
1198 '@rolldown/pluginutils@1.0.1': {}
1199
1200 '@solidjs/router@1.0.0(solid-js@1.9.15)':
1201 dependencies:
1202 solid-js: 1.9.15
1203
1204 '@types/babel__core@7.20.5':
1205 dependencies:
1206 '@babel/parser': 7.29.9
1207 '@babel/types': 7.29.8
1208 '@types/babel__generator': 7.27.0
1209 '@types/babel__template': 7.4.4
1210 '@types/babel__traverse': 7.28.0
1211
1212 '@types/babel__generator@7.27.0':
1213 dependencies:
1214 '@babel/types': 7.29.8
1215
1216 '@types/babel__template@7.4.4':
1217 dependencies:
1218 '@babel/parser': 7.29.9
1219 '@babel/types': 7.29.8
1220
1221 '@types/babel__traverse@7.28.0':
1222 dependencies:
1223 '@babel/types': 7.29.8
1224
1225 '@types/node@26.6.2':
1226 dependencies:
1227 undici-types: 8.9.0
1228
1229 '@typescript/typescript-aix-ppc64@7.0.2':
1230 optional: true
1231
1232 '@typescript/typescript-darwin-arm64@7.0.2':
1233 optional: true
1234
1235 '@typescript/typescript-darwin-x64@7.0.2':
1236 optional: true
1237
1238 '@typescript/typescript-freebsd-arm64@7.0.2':
1239 optional: true
1240
1241 '@typescript/typescript-freebsd-x64@7.0.2':
1242 optional: true
1243
1244 '@typescript/typescript-linux-arm64@7.0.2':
1245 optional: true
1246
1247 '@typescript/typescript-linux-arm@7.0.2':
1248 optional: true
1249
1250 '@typescript/typescript-linux-loong64@7.0.2':
1251 optional: true
1252
1253 '@typescript/typescript-linux-mips64el@7.0.2':
1254 optional: true
1255
1256 '@typescript/typescript-linux-ppc64@7.0.2':
1257 optional: true
1258
1259 '@typescript/typescript-linux-riscv64@7.0.2':
1260 optional: true
1261
1262 '@typescript/typescript-linux-s390x@7.0.2':
1263 optional: true
1264
1265 '@typescript/typescript-linux-x64@7.0.2':
1266 optional: true
1267
1268 '@typescript/typescript-netbsd-arm64@7.0.2':
1269 optional: true
1270
1271 '@typescript/typescript-netbsd-x64@7.0.2':
1272 optional: true
1273
1274 '@typescript/typescript-openbsd-arm64@7.0.2':
1275 optional: true
1276
1277 '@typescript/typescript-openbsd-x64@7.0.2':
1278 optional: true
1279
1280 '@typescript/typescript-sunos-x64@7.0.2':
1281 optional: true
1282
1283 '@typescript/typescript-win32-arm64@7.0.2':
1284 optional: true
1285
1286 '@typescript/typescript-win32-x64@7.0.2':
1287 optional: true
1288
1289 ansi-regex@6.3.0: {}
1290
1291 ansi-styles@6.2.3: {}
1292
1293 babel-plugin-jsx-dom-expressions@0.40.10(@babel/core@7.29.7):
1294 dependencies:
1295 '@babel/core': 7.29.7
1296 '@babel/helper-module-imports': 7.18.6
1297 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
1298 '@babel/types': 7.29.8
1299 html-entities: 2.3.3
1300 parse5: 7.3.0
1301
1302 babel-preset-solid@1.9.15(@babel/core@7.29.7)(solid-js@1.9.15):
1303 dependencies:
1304 '@babel/core': 7.29.7
1305 babel-plugin-jsx-dom-expressions: 0.40.10(@babel/core@7.29.7)
1306 optionalDependencies:
1307 solid-js: 1.9.15
1308
1309 baseline-browser-mapping@2.11.26: {}
1310
1311 browserslist@4.29.1:
1312 dependencies:
1313 baseline-browser-mapping: 2.11.26
1314 caniuse-lite: 1.0.30001812
1315 electron-to-chromium: 1.5.439
1316 node-releases: 2.0.57
1317 update-browserslist-db: 1.3.3(browserslist@4.29.1)
1318
1319 caniuse-lite@1.0.30001812: {}
1320
1321 chalk@5.6.2: {}
1322
1323 cliui@9.0.1:
1324 dependencies:
1325 string-width: 7.2.0
1326 strip-ansi: 7.2.0
1327 wrap-ansi: 9.0.2
1328
1329 concurrently@10.0.5:
1330 dependencies:
1331 chalk: 5.6.2
1332 rxjs: 7.8.2
1333 shell-quote: 1.9.0
1334 supports-color: 10.2.2
1335 tree-kill: 1.2.2
1336 yargs: 18.0.0
1337
1338 convert-source-map@2.0.0: {}
1339
1340 csstype@3.2.3: {}
1341
1342 debug@4.4.3:
1343 dependencies:
1344 ms: 2.1.3
1345
1346 detect-libc@2.1.2: {}
1347
1348 electron-to-chromium@1.5.439: {}
1349
1350 emoji-regex@10.6.0: {}
1351
1352 entities@6.0.1: {}
1353
1354 esbuild@0.28.2:
1355 optionalDependencies:
1356 '@esbuild/aix-ppc64': 0.28.2
1357 '@esbuild/android-arm': 0.28.2
1358 '@esbuild/android-arm64': 0.28.2
1359 '@esbuild/android-x64': 0.28.2
1360 '@esbuild/darwin-arm64': 0.28.2
1361 '@esbuild/darwin-x64': 0.28.2
1362 '@esbuild/freebsd-arm64': 0.28.2
1363 '@esbuild/freebsd-x64': 0.28.2
1364 '@esbuild/linux-arm': 0.28.2
1365 '@esbuild/linux-arm64': 0.28.2
1366 '@esbuild/linux-ia32': 0.28.2
1367 '@esbuild/linux-loong64': 0.28.2
1368 '@esbuild/linux-mips64el': 0.28.2
1369 '@esbuild/linux-ppc64': 0.28.2
1370 '@esbuild/linux-riscv64': 0.28.2
1371 '@esbuild/linux-s390x': 0.28.2
1372 '@esbuild/linux-x64': 0.28.2
1373 '@esbuild/netbsd-arm64': 0.28.2
1374 '@esbuild/netbsd-x64': 0.28.2
1375 '@esbuild/openbsd-arm64': 0.28.2
1376 '@esbuild/openbsd-x64': 0.28.2
1377 '@esbuild/openharmony-arm64': 0.28.2
1378 '@esbuild/sunos-x64': 0.28.2
1379 '@esbuild/win32-arm64': 0.28.2
1380 '@esbuild/win32-ia32': 0.28.2
1381 '@esbuild/win32-x64': 0.28.2
1382 optional: true
1383
1384 escalade@3.2.0: {}
1385
1386 fdir@6.5.0(picomatch@4.0.7):
1387 optionalDependencies:
1388 picomatch: 4.0.7
1389
1390 fsevents@2.3.3:
1391 optional: true
1392
1393 gensync@1.0.0-beta.2: {}
1394
1395 get-caller-file@2.0.5: {}
1396
1397 get-east-asian-width@1.7.0: {}
1398
1399 hono@4.13.9: {}
1400
1401 html-entities@2.3.3: {}
1402
1403 is-what@4.1.16: {}
1404
1405 js-tokens@4.0.0: {}
1406
1407 jsesc@3.1.0: {}
1408
1409 json5@2.2.3: {}
1410
1411 lightningcss-android-arm64@1.33.0:
1412 optional: true
1413
1414 lightningcss-darwin-arm64@1.33.0:
1415 optional: true
1416
1417 lightningcss-darwin-x64@1.33.0:
1418 optional: true
1419
1420 lightningcss-freebsd-x64@1.33.0:
1421 optional: true
1422
1423 lightningcss-linux-arm-gnueabihf@1.33.0:
1424 optional: true
1425
1426 lightningcss-linux-arm64-gnu@1.33.0:
1427 optional: true
1428
1429 lightningcss-linux-arm64-musl@1.33.0:
1430 optional: true
1431
1432 lightningcss-linux-x64-gnu@1.33.0:
1433 optional: true
1434
1435 lightningcss-linux-x64-musl@1.33.0:
1436 optional: true
1437
1438 lightningcss-win32-arm64-msvc@1.33.0:
1439 optional: true
1440
1441 lightningcss-win32-x64-msvc@1.33.0:
1442 optional: true
1443
1444 lightningcss@1.33.0:
1445 dependencies:
1446 detect-libc: 2.1.2
1447 optionalDependencies:
1448 lightningcss-android-arm64: 1.33.0
1449 lightningcss-darwin-arm64: 1.33.0
1450 lightningcss-darwin-x64: 1.33.0
1451 lightningcss-freebsd-x64: 1.33.0
1452 lightningcss-linux-arm-gnueabihf: 1.33.0
1453 lightningcss-linux-arm64-gnu: 1.33.0
1454 lightningcss-linux-arm64-musl: 1.33.0
1455 lightningcss-linux-x64-gnu: 1.33.0
1456 lightningcss-linux-x64-musl: 1.33.0
1457 lightningcss-win32-arm64-msvc: 1.33.0
1458 lightningcss-win32-x64-msvc: 1.33.0
1459
1460 lru-cache@5.1.1:
1461 dependencies:
1462 yallist: 3.1.1
1463
1464 lucide-solid@1.48.0(solid-js@1.9.15):
1465 dependencies:
1466 solid-js: 1.9.15
1467
1468 merge-anything@5.1.7:
1469 dependencies:
1470 is-what: 4.1.16
1471
1472 ms@2.1.3: {}
1473
1474 nanoid@3.3.19: {}
1475
1476 node-releases@2.0.57: {}
1477
1478 parse5@7.3.0:
1479 dependencies:
1480 entities: 6.0.1
1481
1482 picocolors@1.1.1: {}
1483
1484 picomatch@4.0.7: {}
1485
1486 postcss@8.5.28:
1487 dependencies:
1488 nanoid: 3.3.19
1489 picocolors: 1.1.1
1490 source-map-js: 1.2.1
1491
1492 rolldown@1.2.11:
1493 dependencies:
1494 '@oxc-project/types': 0.151.0
1495 '@rolldown/pluginutils': 1.0.1
1496 optionalDependencies:
1497 '@rolldown/binding-android-arm-eabi': 1.2.11
1498 '@rolldown/binding-android-arm64': 1.2.11
1499 '@rolldown/binding-darwin-arm64': 1.2.11
1500 '@rolldown/binding-darwin-x64': 1.2.11
1501 '@rolldown/binding-freebsd-x64': 1.2.11
1502 '@rolldown/binding-linux-arm-gnueabihf': 1.2.11
1503 '@rolldown/binding-linux-arm64-gnu': 1.2.11
1504 '@rolldown/binding-linux-arm64-musl': 1.2.11
1505 '@rolldown/binding-linux-ppc64-gnu': 1.2.11
1506 '@rolldown/binding-linux-s390x-gnu': 1.2.11
1507 '@rolldown/binding-linux-x64-gnu': 1.2.11
1508 '@rolldown/binding-linux-x64-musl': 1.2.11
1509 '@rolldown/binding-openharmony-arm64': 1.2.11
1510 '@rolldown/binding-win32-arm64-msvc': 1.2.11
1511 '@rolldown/binding-win32-x64-msvc': 1.2.11
1512
1513 rxjs@7.8.2:
1514 dependencies:
1515 tslib: 2.8.1
1516
1517 semver@6.3.1: {}
1518
1519 seroval-plugins@1.5.6(seroval@1.5.6):
1520 dependencies:
1521 seroval: 1.5.6
1522
1523 seroval@1.5.6: {}
1524
1525 shell-quote@1.9.0: {}
1526
1527 solid-js@1.9.15:
1528 dependencies:
1529 csstype: 3.2.3
1530 seroval: 1.5.6
1531 seroval-plugins: 1.5.6(seroval@1.5.6)
1532
1533 solid-refresh@0.6.3(solid-js@1.9.15):
1534 dependencies:
1535 '@babel/generator': 7.29.8
1536 '@babel/helper-module-imports': 7.29.7
1537 '@babel/types': 7.29.8
1538 solid-js: 1.9.15
1539 transitivePeerDependencies:
1540 - supports-color
1541
1542 source-map-js@1.2.1: {}
1543
1544 string-width@7.2.0:
1545 dependencies:
1546 emoji-regex: 10.6.0
1547 get-east-asian-width: 1.7.0
1548 strip-ansi: 7.2.0
1549
1550 strip-ansi@7.2.0:
1551 dependencies:
1552 ansi-regex: 6.3.0
1553
1554 supports-color@10.2.2: {}
1555
1556 tinyglobby@0.2.17:
1557 dependencies:
1558 fdir: 6.5.0(picomatch@4.0.7)
1559 picomatch: 4.0.7
1560
1561 tree-kill@1.2.2: {}
1562
1563 tslib@2.8.1: {}
1564
1565 tsx@4.23.15:
1566 dependencies:
1567 esbuild: 0.28.2
1568 optionalDependencies:
1569 fsevents: 2.3.3
1570 optional: true
1571
1572 typescript@7.0.2:
1573 optionalDependencies:
1574 '@typescript/typescript-aix-ppc64': 7.0.2
1575 '@typescript/typescript-darwin-arm64': 7.0.2
1576 '@typescript/typescript-darwin-x64': 7.0.2
1577 '@typescript/typescript-freebsd-arm64': 7.0.2
1578 '@typescript/typescript-freebsd-x64': 7.0.2
1579 '@typescript/typescript-linux-arm': 7.0.2
1580 '@typescript/typescript-linux-arm64': 7.0.2
1581 '@typescript/typescript-linux-loong64': 7.0.2
1582 '@typescript/typescript-linux-mips64el': 7.0.2
1583 '@typescript/typescript-linux-ppc64': 7.0.2
1584 '@typescript/typescript-linux-riscv64': 7.0.2
1585 '@typescript/typescript-linux-s390x': 7.0.2
1586 '@typescript/typescript-linux-x64': 7.0.2
1587 '@typescript/typescript-netbsd-arm64': 7.0.2
1588 '@typescript/typescript-netbsd-x64': 7.0.2
1589 '@typescript/typescript-openbsd-arm64': 7.0.2
1590 '@typescript/typescript-openbsd-x64': 7.0.2
1591 '@typescript/typescript-sunos-x64': 7.0.2
1592 '@typescript/typescript-win32-arm64': 7.0.2
1593 '@typescript/typescript-win32-x64': 7.0.2
1594
1595 undici-types@8.9.0: {}
1596
1597 update-browserslist-db@1.3.3(browserslist@4.29.1):
1598 dependencies:
1599 browserslist: 4.29.1
1600 escalade: 3.2.0
1601 picocolors: 1.1.1
1602
1603 uplot@1.6.32: {}
1604
1605 vite-plugin-solid@2.11.14(solid-js@1.9.15)(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15)):
1606 dependencies:
1607 '@babel/core': 7.29.7
1608 '@types/babel__core': 7.20.5
1609 babel-preset-solid: 1.9.15(@babel/core@7.29.7)(solid-js@1.9.15)
1610 merge-anything: 5.1.7
1611 solid-js: 1.9.15
1612 solid-refresh: 0.6.3(solid-js@1.9.15)
1613 vite: 8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15)
1614 vitefu: 1.1.3(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15))
1615 transitivePeerDependencies:
1616 - supports-color
1617
1618 vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15):
1619 dependencies:
1620 lightningcss: 1.33.0
1621 picomatch: 4.0.7
1622 postcss: 8.5.28
1623 rolldown: 1.2.11
1624 tinyglobby: 0.2.17
1625 optionalDependencies:
1626 '@types/node': 26.6.2
1627 esbuild: 0.28.2
1628 fsevents: 2.3.3
1629 tsx: 4.23.15
1630
1631 vitefu@1.1.3(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15)):
1632 optionalDependencies:
1633 vite: 8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15)
1634
1635 wrap-ansi@9.0.2:
1636 dependencies:
1637 ansi-styles: 6.2.3
1638 string-width: 7.2.0
1639 strip-ansi: 7.2.0
1640
1641 y18n@5.0.8: {}
1642
1643 yallist@3.1.1: {}
1644
1645 yargs-parser@22.0.0: {}
1646
1647 yargs@18.0.0:
1648 dependencies:
1649 cliui: 9.0.1
1650 escalade: 3.2.0
1651 get-caller-file: 2.0.5
1652 string-width: 7.2.0
1653 y18n: 5.0.8
1654 yargs-parser: 22.0.0
dashboard/server/icons/victoria-logs/icon.svg created+5
......@@ -0,0 +1,5 @@
1<svg xmlns="http://www.w3.org/2000/svg" fill="#e94600" viewBox="3.74 0 41.64 47.63">
2 <path d="M24.5475 0C10.3246.0265251 1.11379 3.06365 4.40623 6.10077c0 0 12.32997 11.23333 16.58217 14.84083.8131.6896 2.1728 1.1936 3.5191 1.2201h.1199c1.3463-.0265 2.706-.5305 3.5191-1.2201 4.2522-3.5942 16.5422-14.84083 16.5422-14.84083C48.0478 3.06365 38.8636.0265251 24.6674 0"/>
3 <path d="M28.1579 27.0159c-.8131.6896-2.1728 1.1936-3.5191 1.2201h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2201-2.9725-2.5067-13.35639-11.87-17.26201-15.3979v5.4112c0 .5968.22661 1.3793.6265 1.7506C7.00358 21.1936 17.2675 30.5437 20.9731 33.6737c.8132.6896 2.1728 1.1936 3.5191 1.2201h.12c1.3463-.0265 2.7059-.5305 3.519-1.2201 3.679-3.13 13.9429-12.4536 16.6089-14.8939.4132-.3713.6265-1.1538.6265-1.7506V11.618c-3.9323 3.5411-14.3162 12.931-17.2354 15.3979h.0267Z"/>
4 <path d="M28.1579 39.748c-.8131.6897-2.1728 1.1937-3.5191 1.2202h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2202-2.9725-2.4933-13.35639-11.8567-17.26201-15.3978v5.4111c0 .5969.22661 1.3793.6265 1.7507C7.00358 33.9258 17.2675 43.2759 20.9731 46.4058c.8132.6897 2.1728 1.1937 3.5191 1.2202h.12c1.3463-.0265 2.7059-.5305 3.519-1.2202 3.679-3.1299 13.9429-12.4535 16.6089-14.8938.4132-.3714.6265-1.1538.6265-1.7507v-5.4111c-3.9323 3.5411-14.3162 12.931-17.2354 15.3978h.0267Z"/>
5</svg>
\ No newline at end of file
dashboard/server/youtube-worker.py created+727
......@@ -0,0 +1,727 @@
1#!/usr/bin/env python3
2import sys
3import json
4import os
5import queue
6import re
7import smtplib
8import subprocess
9import threading
10import time
11import urllib.request
12import xml.etree.ElementTree as ET
13from email.message import EmailMessage
14from email.utils import formatdate
15from html import unescape
16from xml.sax.saxutils import escape
17
18import yaml
19
20os.umask(0o007)
21
22YT_CONFIG_DIR = os.environ.get("STUDIO_YT_CONFIG", "/srv/clover/Documents/Config/Youtube Downloader")
23FEED_CONFIG = os.path.join(YT_CONFIG_DIR, "feed.yaml")
24STATE_DIR = os.environ.get("STUDIO_YT_STATE", "/srv/prod/yt-feed/data")
25INTERVAL = int(os.environ.get("CHECK_INTERVAL", "1800"))
26SMTP_HOST = os.environ.get("SMTP_HOST", "")
27SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
28SMTP_USER = os.environ.get("SMTP_USER", "")
29SMTP_PASS = os.environ.get("SMTP_PASS", "")
30MAIL_FROM = os.environ.get("MAIL_FROM", f"yt-feed@{os.environ.get('STUDIO_DOMAIN', 'studio.test')}")
31MAIL_TO = os.environ.get("MAIL_TO", os.environ.get("STUDIO_OWNER_EMAIL", "account@paperclover.net"))
32BASE_URL = os.environ.get("BASE_URL", f"https://globe.{os.environ.get('STUDIO_DOMAIN', 'studio.test')}/youtube").rstrip("/")
33READ_ONLY = os.environ.get("STUDIO_MEDIA_READ_ONLY") == "true"
34
35MEDIA_ROOT = os.environ.get("STUDIO_YT_MEDIA", "/srv/clover/Media")
36INDIE_DIR = os.path.join(MEDIA_ROOT, "jellyfin/Indie Shows")
37INDEP_DIR = os.path.join(MEDIA_ROOT, "jellyfin/Independent")
38MUSIC_DIR = os.path.join(MEDIA_ROOT, "music_intake")
39VIDEO_EXTS = (".webm", ".mp4", ".mkv")
40
41ATOM = "{http://www.w3.org/2005/Atom}"
42YT = "{http://www.youtube.com/xml/schemas/2015}"
43MEDIA = "{http://search.yahoo.com/mrss/}"
44UA = {"User-Agent": "Mozilla/5.0 (The Snow Globe; +https://paperclover.net)"}
45SEEN_CAP = 300
46
47state_lock = threading.Lock()
48job_queue = queue.Queue()
49shows_cache = []
50
51# RSS keeps working during a YouTube bot wall, so probing can resume downloads later.
52WALL_RE = re.compile(r"confirm you.re not a bot", re.I)
53WALL_PROBE_INTERVAL = int(os.environ.get("WALL_PROBE_INTERVAL", "10800"))
54PROBE_VIDEO = "https://www.youtube.com/watch?v=jNQXAC9IVRw"
55
56
57class WallError(Exception):
58 pass
59
60
61def wall_active():
62 return load_json("wall.json", {}).get("walled", False)
63
64
65def set_wall(walled):
66 with state_lock:
67 save_json("wall.json", {"walled": walled, "since": int(time.time())})
68 log(f"bot wall {'detected — downloads paused' if walled else 'lifted — downloads resumed'}")
69
70
71def wall_prober():
72 while True:
73 time.sleep(WALL_PROBE_INTERVAL if wall_active() else 600)
74 if not wall_active():
75 continue
76 probe = subprocess.run(
77 ["yt-dlp", "--simulate", "--print", "%(id)s", PROBE_VIDEO],
78 capture_output=True, text=True, timeout=120)
79 if probe.returncode == 0:
80 set_wall(False)
81 resolve_stragglers()
82 else:
83 log("wall probe: still walled")
84
85
86def resolve_stragglers():
87 with state_lock:
88 pending = load_json("pending.json", {})
89 for v in pending.values():
90 if v.get("unresolved"):
91 threading.Thread(target=resolve_and_update, args=(v["id"], v["link"]),
92 daemon=True).start()
93
94
95def log(msg):
96 print(msg, file=sys.stderr, flush=True)
97
98
99def fetch(url):
100 req = urllib.request.Request(url, headers=UA)
101 with urllib.request.urlopen(req, timeout=30) as resp:
102 return resp.read().decode("utf-8", errors="replace")
103
104
105def state_path(name):
106 return os.path.join(STATE_DIR, name)
107
108
109def load_json(name, fallback):
110 try:
111 with open(state_path(name)) as f:
112 return json.load(f)
113 except (FileNotFoundError, json.JSONDecodeError):
114 return fallback
115
116
117def save_json(name, data):
118 tmp = state_path(name) + ".tmp"
119 with open(tmp, "w") as f:
120 json.dump(data, f, indent=1)
121 os.replace(tmp, state_path(name))
122
123
124def safe_name(name):
125 return re.sub(r'[/\\:*?"<>|]', "-", name).strip(" .") or "untitled"
126
127
128def stable_key(v):
129 return v.get("stub_id") or v["id"]
130
131
132def safe_listdir(p):
133 try:
134 return os.listdir(p)
135 except OSError:
136 return []
137
138
139def nfo_fields(file):
140 try:
141 with open(file) as stream:
142 text = stream.read()
143 except OSError:
144 return {name: "" for name in ("title", "plot")}
145 return {name: unescape(match.group(1)).strip() if (match := re.search(
146 rf"<{name}>(.*?)</{name}>", text, re.S)) else "" for name in ("title", "plot")}
147
148
149def library_entries():
150 entries = []
151 for show in sorted(safe_listdir(INDIE_DIR)):
152 root = os.path.join(INDIE_DIR, show)
153 if not os.path.isdir(root):
154 continue
155 for sub in sorted(safe_listdir(root)):
156 season = re.fullmatch(r"Season (\d+)", sub)
157 if not season:
158 continue
159 folder = os.path.join(root, sub)
160 for name in sorted(safe_listdir(folder)):
161 if not name.lower().endswith(VIDEO_EXTS):
162 continue
163 stem = os.path.splitext(name)[0]
164 episode = re.match(r"S\d+E(\d+) - (.*)", stem)
165 nfo = nfo_fields(os.path.join(folder, stem + ".nfo"))
166 entries.append({
167 "type": "indie", "path": os.path.relpath(os.path.join(folder, name), MEDIA_ROOT),
168 "context": f"{show} · S{int(season.group(1))}E{int(episode.group(1)) if episode else 1}",
169 "title": nfo["title"] or (episode.group(2) if episode else stem),
170 "link": nfo["plot"] if nfo["plot"].startswith(("http://", "https://")) else "",
171 "season": int(season.group(1)), "episode": int(episode.group(1)) if episode else 1,
172 })
173 for channel in sorted(safe_listdir(INDEP_DIR)):
174 folder = os.path.join(INDEP_DIR, channel)
175 if not os.path.isdir(folder):
176 continue
177 for name in sorted(safe_listdir(folder)):
178 if not name.lower().endswith(VIDEO_EXTS):
179 continue
180 stem = os.path.splitext(name)[0]
181 dated = re.match(r"(\d{4}-\d{2}-\d{2}) - (.*)", stem)
182 nfo = nfo_fields(os.path.join(folder, stem + ".nfo"))
183 entries.append({
184 "type": "independent", "path": os.path.relpath(os.path.join(folder, name), MEDIA_ROOT),
185 "context": f"{channel} · {dated.group(1) if dated else ''}",
186 "title": nfo["title"] or (dated.group(2) if dated else stem),
187 "link": nfo["plot"] if nfo["plot"].startswith(("http://", "https://")) else "",
188 "season": None, "episode": None,
189 })
190 return entries
191
192
193def rename_entry(args):
194 full = os.path.realpath(os.path.join(MEDIA_ROOT, args["path"]))
195 indie = os.path.commonpath((full, INDIE_DIR)) == INDIE_DIR
196 independent = os.path.commonpath((full, INDEP_DIR)) == INDEP_DIR
197 if not (indie or independent) or not os.path.isfile(full):
198 raise ValueError("Video is outside the managed YouTube libraries")
199 title = args["title"].strip()
200 if not title:
201 raise ValueError("Video title is empty")
202 folder, name = os.path.split(full)
203 stem, ext = os.path.splitext(name)
204 if ext.lower() not in VIDEO_EXTS:
205 raise ValueError("Video format is unsupported")
206 if indie:
207 season, episode = args["season"], args["episode"]
208 if not isinstance(season, int) or not isinstance(episode, int) or min(season, episode) < 1:
209 raise ValueError("Season and episode must be positive numbers")
210 new_stem = f"S{season:02d}E{episode:02d} - {safe_name(title)}"
211 updates = {"title": title, "season": season, "episode": episode}
212 else:
213 date = re.match(r"(\d{4}-\d{2}-\d{2}) - ", stem)
214 new_stem = (date.group(1) + " - " if date else "") + safe_name(title)
215 updates = {"title": title}
216 sidecars = [name for name in safe_listdir(folder) if name.startswith(stem)
217 and (name[len(stem):].startswith(".") or name[len(stem):].startswith("-thumb"))]
218 moves = [(os.path.join(folder, name), os.path.join(folder, new_stem + name[len(stem):])) for name in sidecars]
219 if any(src != dst and os.path.exists(dst) for src, dst in moves):
220 raise FileExistsError("A file already has that title")
221 for src, dst in moves:
222 if src != dst:
223 os.rename(src, dst)
224 nfo_file = os.path.join(folder, new_stem + ".nfo")
225 try:
226 with open(nfo_file) as stream:
227 text = stream.read()
228 except FileNotFoundError:
229 return None
230 for key, value in updates.items():
231 encoded = f"<{key}>{escape(str(value))}</{key}>"
232 text = re.sub(rf"<{key}>.*?</{key}>", lambda _: encoded, text, count=1, flags=re.S) if re.search(
233 rf"<{key}>.*?</{key}>", text, re.S) else re.sub(
234 r"(\n</[A-Za-z]+>\s*)\Z", lambda match: f"\n {encoded}{match.group(1)}", text)
235 with open(nfo_file, "w") as stream:
236 stream.write(text)
237 return None
238
239
240def channel_id_for(url, cache):
241 if url in cache:
242 return cache[url]
243 m = re.search(r"/channel/(UC[0-9A-Za-z_-]{22})", url)
244 if not m:
245 html = fetch(url)
246 m = re.search(r"channel_id=(UC[0-9A-Za-z_-]{22})", html) or re.search(
247 r'"channelId":"(UC[0-9A-Za-z_-]{22})"', html
248 )
249 if not m:
250 raise ValueError(f"could not resolve channel id for {url}")
251 cache[url] = m.group(1)
252 return cache[url]
253
254
255def feed_entries(channel_id):
256 text = fetch(f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}")
257 entries = []
258 for e in ET.fromstring(text).findall(ATOM + "entry"):
259 vid = e.find(YT + "videoId")
260 title = e.find(ATOM + "title")
261 link = e.find(ATOM + "link")
262 published = e.find(ATOM + "published")
263 thumb = e.find(f"{MEDIA}group/{MEDIA}thumbnail")
264 if vid is None or vid.text is None or title is None:
265 continue
266 entries.append({
267 "id": vid.text,
268 "title": title.text or "(untitled)",
269 "link": link.get("href") if link is not None else f"https://youtu.be/{vid.text}",
270 "published": published.text[:10] if published is not None and published.text else "",
271 "thumb": thumb.get("url") if thumb is not None else "",
272 })
273 return entries
274
275
276def send_notification(channel, entry):
277 msg = EmailMessage()
278 slug = re.sub(r"[^a-z0-9]+", "-", channel.lower()).strip("-")
279 review = BASE_URL
280 msg["Subject"] = f"[yt] {channel}: {entry['title']}"
281 msg["From"] = MAIL_FROM
282 msg["To"] = MAIL_TO
283 msg["Date"] = formatdate(localtime=True)
284 msg["Message-ID"] = f"<yt-{entry['id']}@yt-feed.paperclover.net>"
285 msg["References"] = f"<yt-channel-{slug}@yt-feed.paperclover.net>"
286 msg["In-Reply-To"] = f"<yt-channel-{slug}@yt-feed.paperclover.net>"
287 msg.set_content(
288 f"{channel} uploaded: {entry['title']}\n\n"
289 f" {entry['link']}\n published {entry['published']}\n\n"
290 f"review and ingest: {review}\n"
291 )
292 h = escape(entry["title"])
293 msg.add_alternative(
294 f'<div style="font-family:sans-serif">'
295 f'<p><b>{escape(channel)}</b> uploaded:</p>'
296 f'<p><a href="{review or entry["link"]}">'
297 f'<img src="{entry["thumb"]}" alt="" width="320" style="display:block;border-radius:8px"></a></p>'
298 f'<p><a href="{review or entry["link"]}">{h}</a> · {entry["published"]}</p>'
299 f'<p><a href="{review}">review &amp; ingest →</a> · <a href="{entry["link"]}">watch</a></p>'
300 f"</div>",
301 subtype="html",
302 )
303 with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as s:
304 s.login(SMTP_USER, SMTP_PASS)
305 s.send_message(msg)
306
307
308def poll_once():
309 with open(FEED_CONFIG) as f:
310 channels = (yaml.safe_load(f) or {}).get("channels") or {}
311 with state_lock:
312 ids = load_json("channel-ids.json", {})
313 seen = load_json("seen.json", {})
314 pending = load_json("pending.json", {})
315 for name, url in channels.items():
316 try:
317 cid = channel_id_for(url, ids)
318 entries = feed_entries(cid)
319 except Exception as e:
320 log(f"{name}: fetch failed: {e}")
321 continue
322 if cid not in seen:
323 seen[cid] = [e["id"] for e in entries]
324 log(f"{name}: now tracking ({len(entries)} existing videos skipped)")
325 continue
326 known = set(seen[cid])
327 for entry in reversed(entries):
328 if entry["id"] in known:
329 continue
330 entry["channel"] = name
331 pending[entry["id"]] = entry
332 seen[cid].append(entry["id"])
333 known.add(entry["id"])
334 log(f"{name}: queued {entry['title']!r}")
335 try:
336 send_notification(name, entry)
337 except Exception as e:
338 log(f"{name}: notification failed: {e}")
339 seen[cid] = seen[cid][-SEEN_CAP:]
340 with state_lock:
341 save_json("channel-ids.json", ids)
342 save_json("seen.json", seen)
343 save_json("pending.json", pending)
344
345
346def poller():
347 while True:
348 try:
349 poll_once()
350 except Exception as e:
351 log(f"poll failed: {e}")
352 time.sleep(INTERVAL)
353
354
355def update_job(job_id, **fields):
356 with state_lock:
357 jobs = load_json("jobs.json", [])
358 for j in jobs:
359 if j["id"] == job_id:
360 j.update(fields)
361 save_json("jobs.json", jobs)
362
363
364def resolve_url(url):
365 out = subprocess.run(
366 ["yt-dlp", "--no-playlist", "--print",
367 "%(id)s\t%(title)s\t%(channel)s\t%(upload_date>%Y-%m-%d)s\t%(thumbnail)s", url],
368 capture_output=True, text=True, timeout=90)
369 if out.returncode != 0:
370 if WALL_RE.search(out.stderr):
371 raise WallError(url)
372 raise ValueError(out.stderr[-300:])
373 vid, title, channel, published, thumb = out.stdout.strip().split("\t")
374 return {"id": vid, "title": title, "channel": channel, "published": published,
375 "thumb": thumb, "link": f"https://www.youtube.com/watch?v={vid}"}
376
377
378def resolve_and_update(stub_id, url):
379 try:
380 entry = resolve_url(url)
381 except WallError:
382 set_wall(True)
383 return
384 except Exception as e:
385 log(f"resolve failed for {url}: {e}")
386 return
387 with state_lock:
388 pending = load_json("pending.json", {})
389 # if the stub is gone the user already ingested or skipped it
390 if stub_id in pending:
391 del pending[stub_id]
392 entry["stub_id"] = stub_id
393 pending[entry["id"]] = entry
394 save_json("pending.json", pending)
395
396
397def write_nfo(filepath, root, tags):
398 base, _ = os.path.splitext(filepath)
399 body = "\n".join(f" <{k}>{escape(str(v))}</{k}>" for k, v in tags.items() if v != "")
400 with open(base + ".nfo", "w") as f:
401 f.write(f"<?xml version='1.0' encoding='utf-8'?>\n<{root}>\n{body}\n</{root}>\n")
402
403
404def run_job(job):
405 for delay in (0, 30, 90):
406 if delay:
407 update_job(job["id"], status="retrying", progress="")
408 time.sleep(delay)
409 try:
410 if run_job_once(job):
411 return
412 except WallError:
413 raise
414 except Exception as e:
415 log(f"job {job['id']} attempt failed: {e}")
416 update_job(job["id"], status="error", progress="")
417
418
419def run_job_once(job):
420 if job.get("unresolved"):
421 update_job(job["id"], status="resolving")
422 meta = resolve_url(job["url"])
423 job.update(title=meta["title"], channel=meta["channel"],
424 published=meta["published"], url=meta["link"], unresolved=False)
425 update_job(job["id"], title=meta["title"])
426 dest, url = job["dest"], job["url"]
427 if dest == "indie":
428 outdir = os.path.join(INDIE_DIR, safe_name(job["show"]), f"Season {job['season']}")
429 prefix = f"S{job['season']:02d}E{job['episode']:02d}"
430 ep_name = safe_name(job.get("ep_title") or job["title"])
431 out = f"{outdir}/{prefix} - {ep_name}.%(ext)s"
432 thumb_out = f"thumbnail:{outdir}/{prefix} - {ep_name}-thumb.%(ext)s"
433 elif dest == "independent":
434 outdir = os.path.join(INDEP_DIR, safe_name(job["channel"]))
435 out = f"{outdir}/%(upload_date>%Y-%m-%d)s - %(title)s.%(ext)s"
436 thumb_out = f"thumbnail:{outdir}/%(upload_date>%Y-%m-%d)s - %(title)s.%(ext)s"
437 else:
438 outdir = os.path.join(MUSIC_DIR, safe_name(job["channel"]))
439 out = f"{outdir}/%(title)s.%(ext)s"
440 thumb_out = None
441 os.makedirs(outdir, exist_ok=True)
442 cmd = ["yt-dlp", "--newline", "--no-playlist", "--embed-chapters",
443 "--sleep-requests", "0.75",
444 "--print", "after_move:filepath", "--no-simulate", "-o", out]
445 if dest == "music":
446 cmd += ["-x"]
447 else:
448 cmd += ["-f", "bv*+ba/b", "--write-thumbnail", "--convert-thumbnails", "jpg",
449 "-o", thumb_out]
450 cmd.append(url)
451 update_job(job["id"], status="downloading")
452 filepath = None
453 tail = []
454 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
455 for line in proc.stdout:
456 line = line.rstrip()
457 tail = (tail + [line])[-30:]
458 m = re.search(r"\[download\]\s+([\d.]+%)", line)
459 if m:
460 update_job(job["id"], progress=m.group(1))
461 elif line.startswith("/"):
462 filepath = line
463 proc.wait()
464 if proc.returncode != 0 or (dest != "music" and not filepath):
465 if WALL_RE.search("\n".join(tail)):
466 raise WallError(url)
467 log(f"job {job['id']} attempt failed (exit {proc.returncode})")
468 return False
469 if dest == "indie":
470 write_nfo(filepath, "episodedetails", {
471 "title": job.get("ep_title") or job["title"],
472 "season": job["season"], "episode": job["episode"],
473 "aired": job.get("published", ""), "plot": url,
474 })
475 elif dest == "independent":
476 write_nfo(filepath, "movie", {
477 "title": job["title"], "premiered": job.get("published", ""), "plot": url,
478 })
479 update_job(job["id"], status="done", progress="")
480 log(f"job {job['id']} done: {filepath or job['title']}")
481 return True
482
483
484def worker():
485 while True:
486 job = job_queue.get()
487 if wall_active():
488 update_job(job["id"], status="waiting", progress="")
489 while wall_active():
490 time.sleep(30)
491 try:
492 run_job(job)
493 except WallError:
494 set_wall(True)
495 update_job(job["id"], status="queued", progress="")
496 job_queue.put(job)
497 except Exception as e:
498 update_job(job["id"], status="error")
499 log(f"job {job['id']} crashed: {e}")
500
501
502def writable():
503 if READ_ONLY:
504 raise PermissionError("Media is read-only on this host")
505
506
507def subscriptions():
508 try:
509 with open(os.path.join(YT_CONFIG_DIR, "subscriptions.yaml")) as f:
510 data = yaml.safe_load(f) or {}
511 except FileNotFoundError:
512 data = {}
513 preset = next((key for key in data if key != "__preset__"), "Jellyfin TV Show by Date | only-after | flat-videos")
514 sections = data.get(preset) or {}
515 section = next(iter(sections), "= Independent Creators")
516 channels = sections.get(section) or {}
517 result = []
518 fields = ("download_after", "title_include_keywords", "title_exclude_keywords",
519 "description_include_keywords", "description_exclude_keywords")
520 for name, value in channels.items():
521 if isinstance(value, str):
522 result.append({"name": name.lstrip("~"), "url": value, "rules": {}})
523 elif isinstance(value, dict):
524 result.append({"name": name.lstrip("~"), "url": value.get("url", ""),
525 "rules": {key: value[key] for key in fields if key in value}})
526 return data, preset, section, result
527
528
529def command(name, args):
530 if name == "snapshot":
531 with state_lock:
532 pending = list(load_json("pending.json", {}).values())
533 jobs = load_json("jobs.json", [])
534 wall = load_json("wall.json", {})
535 upscale = load_json("upscale-status.json", {})
536 enabled = load_json("upscale-enabled.json", {"enabled": True})["enabled"]
537 return {
538 "pending": [{"key": stable_key(v), "title": v["title"], "channel": v.get("channel", ""),
539 "published": v.get("published", ""), "duration": None,
540 "thumb": v.get("thumb"), "link": v["link"], "resolving": bool(v.get("unresolved"))}
541 for v in pending],
542 "shows": shows_cache,
543 "jobs": [{"id": j["id"], "title": j["title"], "channel": j.get("channel", ""),
544 "destination": j.get("dest_label", j.get("dest", "")),
545 "status": j["status"], "url": j["url"],
546 "folder": job_folder(j), "progress": progress(j.get("progress")),
547 "queuedAt": int(j["id"][1:]) / 1000} for j in jobs],
548 "wall": {"walled": bool(wall.get("walled")),
549 "nextProbe": wall.get("since", 0) + WALL_PROBE_INTERVAL if wall.get("walled") else None},
550 "archive": {"started": None, "finished": None, "walled": False, "next": 0},
551 "upscaler": {"enabled": enabled, "running": bool(upscale.get("running")),
552 "done": upscale.get("done", 0), "total": upscale.get("total", 0),
553 "current": upscale.get("current", ""), "errors": upscale.get("errors", 0)},
554 }
555 if name == "channels":
556 try:
557 with open(FEED_CONFIG) as f:
558 notify = (yaml.safe_load(f) or {}).get("channels") or {}
559 except FileNotFoundError:
560 notify = {}
561 return {"notify": [{"name": key, "url": value} for key, value in notify.items()],
562 "archive": subscriptions()[3]}
563 if name == "configs":
564 files = []
565 for filename in sorted(safe_listdir(YT_CONFIG_DIR)):
566 if filename.endswith((".yaml", ".yml")):
567 with open(os.path.join(YT_CONFIG_DIR, filename)) as f:
568 files.append({"name": filename, "body": f.read()})
569 return files
570 if name == "saveConfig":
571 filename = args["name"]
572 if filename not in safe_listdir(YT_CONFIG_DIR) or not filename.endswith((".yaml", ".yml")):
573 raise ValueError("Unknown YouTube config file")
574 target = os.path.join(YT_CONFIG_DIR, filename)
575 with open(target) as f:
576 if f.read() != args["original"]:
577 raise ValueError("Config changed since it was opened. Reload before saving")
578 yaml.safe_load(args["body"])
579 tmp = target + ".tmp"
580 with open(tmp, "w") as f:
581 f.write(args["body"])
582 os.replace(tmp, target)
583 return None
584 writable()
585 if name == "rename":
586 return rename_entry(args)
587 if name == "add":
588 with state_lock:
589 pending = load_json("pending.json", {})
590 for i, url in enumerate(args["urls"]):
591 key = f"u{int(time.time() * 1000)}{i}"
592 pending[key] = {"id": key, "title": url, "channel": "", "published": "",
593 "thumb": "", "link": url, "unresolved": True}
594 threading.Thread(target=resolve_and_update, args=(key, url), daemon=True).start()
595 save_json("pending.json", pending)
596 return None
597 if name in ("ingest", "skip"):
598 with state_lock:
599 pending = load_json("pending.json", {})
600 key = next((key for key, v in pending.items() if stable_key(v) == args["key"]), None)
601 if key is None:
602 raise KeyError("Video is no longer in the queue")
603 v = pending.pop(key)
604 save_json("pending.json", pending)
605 if name == "skip":
606 return None
607 choice = args["choice"]
608 job = {"id": f"j{int(time.time() * 1000)}", "url": v["link"], "title": v["title"],
609 "channel": v.get("channel", "unknown"), "published": v.get("published", ""),
610 "unresolved": v.get("unresolved", False), "status": "queued", "progress": "",
611 "dest": choice["dest"]}
612 if choice["dest"] == "indie":
613 job.update(show=choice["show"], season=choice["season"], episode=choice["episode"],
614 ep_title=choice["title"],
615 dest_label=f"{choice['show']} S{choice['season']:02d}E{choice['episode']:02d}")
616 else:
617 job["dest_label"] = "Independent" if choice["dest"] == "independent" else "music_intake"
618 jobs = load_json("jobs.json", [])
619 jobs.insert(0, job)
620 save_json("jobs.json", jobs[:50])
621 job_queue.put(job)
622 return None
623 if name == "retry":
624 with state_lock:
625 jobs = load_json("jobs.json", [])
626 job = next((j for j in jobs if j["id"] == args["id"]), None)
627 if not job:
628 raise KeyError("Download is no longer in history")
629 job.update(status="queued", progress="")
630 save_json("jobs.json", jobs)
631 job_queue.put(job)
632 return None
633 if name == "setUpscaler":
634 with state_lock:
635 save_json("upscale-enabled.json", {"enabled": args["enabled"]})
636 return None
637 if name == "setChannels":
638 try:
639 with open(FEED_CONFIG) as f:
640 feed = yaml.safe_load(f) or {}
641 except FileNotFoundError:
642 feed = {}
643 feed["channels"] = {ch["name"]: ch["url"] for ch in args["notify"]}
644 data, preset, section, _ = subscriptions()
645 fields = ("download_after", "title_include_keywords", "title_exclude_keywords",
646 "description_include_keywords", "description_exclude_keywords")
647 archive = {}
648 for ch in args["archive"]:
649 rules = {key: value for key in fields if (value := ch["rules"].get(key))}
650 archive[("~" if rules else "") + ch["name"]] = {"url": ch["url"], **rules} if rules else ch["url"]
651 sections = data.get(preset) or {}
652 sections[section] = archive
653 data[preset] = sections
654 for filename, body in (("feed.yaml", feed), ("subscriptions.yaml", data)):
655 target = os.path.join(YT_CONFIG_DIR, filename)
656 tmp = target + ".tmp"
657 with open(tmp, "w") as f:
658 yaml.safe_dump(body, f, sort_keys=False, allow_unicode=True)
659 os.replace(tmp, target)
660 return None
661 raise ValueError(f"Unknown YouTube operation: {name}")
662
663
664def job_folder(job):
665 if job["dest"] == "indie":
666 return os.path.join(INDIE_DIR, safe_name(job["show"]), f"Season {job['season']}")
667 if job["dest"] == "independent":
668 return os.path.join(INDEP_DIR, safe_name(job.get("channel", "unknown")))
669 return os.path.join(MUSIC_DIR, safe_name(job.get("channel", "unknown")))
670
671
672def progress(value):
673 try:
674 return float(str(value).rstrip("%")) / 100
675 except ValueError:
676 return None
677
678
679def indie_shows():
680 shows = {}
681 for name in sorted(safe_listdir(INDIE_DIR)):
682 path = os.path.join(INDIE_DIR, name)
683 if not os.path.isdir(path):
684 continue
685 seasons = {}
686 for sub in safe_listdir(path):
687 match = re.fullmatch(r"Season (\d+)", sub)
688 if match and os.path.isdir(os.path.join(path, sub)):
689 seasons[int(match.group(1))] = sum(f.lower().endswith(VIDEO_EXTS)
690 for f in safe_listdir(os.path.join(path, sub)))
691 shows[name] = seasons
692 return shows
693
694
695def refresh_shows():
696 global shows_cache
697 while True:
698 try:
699 shows = indie_shows()
700 shows_cache = [{"name": name, "seasons": [{"number": n, "episodes": count}
701 for n, count in seasons.items()]} for name, seasons in shows.items()]
702 except Exception as error:
703 log(f"show scan failed: {error}")
704 time.sleep(300)
705
706
707if __name__ == "__main__":
708 if sys.argv[1:] == ["--library"]:
709 print(json.dumps(library_entries()), flush=True)
710 sys.exit(0)
711 threading.Thread(target=refresh_shows, daemon=True).start()
712 if not READ_ONLY:
713 with state_lock:
714 for job in reversed(load_json("jobs.json", [])):
715 if job.get("status") in ("queued", "resolving", "downloading", "retrying"):
716 job_queue.put(job)
717 for loop in (poller, worker, wall_prober):
718 threading.Thread(target=loop, daemon=True).start()
719 for line in sys.stdin:
720 try:
721 request = json.loads(line)
722 result = command(request["method"], request.get("args", {}))
723 response = {"id": request["id"], "result": result}
724 except Exception as error:
725 response = {"id": request.get("id") if "request" in locals() else None,
726 "error": str(error)}
727 print(json.dumps(response), flush=True)
dashboard/src/apps.rs created+438
......@@ -0,0 +1,438 @@
1use crate::*;
2
3pub fn file_link(path: &str, zip: bool) -> Value {
4 let root = env("STUDIO_STORE_ROOT", "/srv");
5 let Some(relative) = std::path::Path::new(path).strip_prefix(&root).ok() else {
6 return Value::Null;
7 };
8 let path = relative
9 .components()
10 .map(|c| encoded(&c.as_os_str().to_string_lossy()))
11 .collect::<Vec<_>>()
12 .join("/");
13 let Ok(files) = std::env::var("STUDIO_FILES_URL") else {
14 return Value::Null;
15 };
16 json!(format!("{files}/{path}{}", if zip { "?zip" } else { "" }))
17}
18fn qbt_link(path: &str, zip: bool) -> Value {
19 file_link(
20 &if path == "/data/media" || path.starts_with("/data/media/") {
21 format!("/srv/clover/Media{}", &path[11..])
22 } else {
23 path.into()
24 },
25 zip,
26 )
27}
28async fn qbt(app: Arc<App>, route: &str, form: Option<Value>) -> Result<Value> {
29 let base = telemetry::endpoint(app.clone(), "qbittorrent")
30 .await
31 .map_err(|_| Error::new(503, "qBittorrent is unavailable. Check its service status."))?;
32 let mut request = if form.is_some() {
33 app.request(Method::POST, &format!("{base}/api/v2/{route}"))?
34 } else {
35 app.request(Method::GET, &format!("{base}/api/v2/{route}"))?
36 };
37 if let Some(form) = form {
38 request = request.form(&form);
39 }
40 let response = request.send().await?;
41 if !response.status().is_success() {
42 return Err(Error::new(
43 502,
44 format!(
45 "qBittorrent refused the request ({}). Retry from its page.",
46 response.status()
47 ),
48 ));
49 }
50 if response
51 .headers()
52 .get("content-type")
53 .and_then(|h| h.to_str().ok())
54 .is_some_and(|h| h.contains("json"))
55 {
56 Ok(response.json().await?)
57 } else {
58 Ok(json!(response.text().await?))
59 }
60}
61async fn seed_state(app: Arc<App>) -> Result<Arc<Document>> {
62 let state = app.clone();
63 app.cache
64 .get(
65 "seed-state".into(),
66 Duration::from_secs(5),
67 move || async move {
68 let (data, prefs) = tokio::try_join!(
69 qbt(state.clone(), "sync/maindata?rid=0", None),
70 qbt(state.clone(), "app/preferences", None)
71 )?;
72 let mut value = data["server_state"].clone();
73 if let (Some(value), Some(prefs)) = (value.as_object_mut(), prefs.as_object()) {
74 value.extend(prefs.clone());
75 }
76 let t = (now() / 5.0).floor() * 5.0;
77 let mut samples = state.seed_samples.lock().unwrap();
78 if samples.last().is_none_or(|s| s["t"] != t) {
79 samples.push(
80 json!({"t":t,"down":value["dl_info_speed"],"up":value["up_info_speed"]}),
81 );
82 }
83 samples.retain(|s| number(&s["t"]) >= t - 3600.0);
84 Ok(value)
85 },
86 )
87 .await
88}
89async fn vm_call(action: &str, payload: Option<Value>) -> Result<Value> {
90 let mut request = json!({"operation":format!("vm.{action}")});
91 if let Some(payload) = payload {
92 request["payload"] = payload;
93 }
94 host::call(request).await
95}
96
97fn vm_name(name: &str) -> Result<()> {
98 if regex::Regex::new(r"^[a-z0-9][a-z0-9-]{0,62}$")
99 .unwrap()
100 .is_match(name)
101 {
102 Ok(())
103 } else {
104 Err(Error::new(400, "No VM has that name."))
105 }
106}
107fn validate_vm(mut spec: Value) -> Result<Value> {
108 vm_name(string(&spec["name"]))?;
109 let description = spec["description"]
110 .as_str()
111 .ok_or_else(|| Error::new(400, "Enter a description."))?
112 .trim();
113 if description.chars().count() > 200 {
114 return Err(Error::new(
115 400,
116 "Keep the description under 200 characters.",
117 ));
118 }
119 spec["description"] = json!(description);
120 if string(&spec["image"]).is_empty() {
121 return Err(Error::new(400, "Pick an OS image."));
122 }
123 for (key, min) in [
124 ("vcpus", 1.0),
125 ("memory", 536870912.0),
126 ("disk", 1073741824.0),
127 ] {
128 let n = number(&spec[key]);
129 if !spec[key].is_number() || n.fract() != 0.0 || n < min {
130 return Err(Error::new(400, format!("Invalid {key}.")));
131 }
132 }
133 if !spec["autostart"].is_boolean() || !spec["start"].is_boolean() {
134 return Err(Error::new(
135 400,
136 "Choose whether this VM starts automatically.",
137 ));
138 }
139 Ok(spec)
140}
141async fn paper(app: Arc<App>, path: &str, body: Option<Value>) -> Result<Value> {
142 let jobs = core::scan(app.clone()).await?;
143 let host = jobs.value["clover-source-of-truth"]["job"]["Meta"]["studio_hostname"]
144 .as_str()
145 .ok_or_else(|| Error::new(502, "Paper Clover is unavailable."))?;
146 let key = host::call(
147 json!({"operation":"deploy.secret.get","service":"clover-source-of-truth","key":"key"}),
148 )
149 .await?;
150 let key = key
151 .as_str()
152 .ok_or_else(|| Error::new(502, "Paper Clover is unavailable."))?;
153 let url = format!(
154 "https://{host}/{}",
155 if path == "scan" {
156 path.into()
157 } else {
158 format!("studio/{path}")
159 }
160 );
161 let request = if let Some(body) = body {
162 app.http.post(&url).json(&body)
163 } else {
164 app.http.get(&url)
165 };
166 let response = request
167 .header("Authorization", key)
168 .timeout(Duration::from_secs(10))
169 .send()
170 .await?;
171 if path == "scan" {
172 if response.status() == 409 {
173 return Err(Error::new(
174 409,
175 "File scans are off on this host. Turn on the Paper Clover indexer to use this action.",
176 ));
177 }
178 if response.status() != 202 {
179 return Err(Error::new(
180 502,
181 "Paper Clover couldn't start the scan. Check its service logs.",
182 ));
183 }
184 return Ok(Value::Null);
185 }
186 if !response.status().is_success() {
187 return Err(Error::new(
188 502,
189 format!("Paper Clover answered {}.", response.status()),
190 ));
191 }
192 Ok(response.json().await?)
193}
194
195pub async fn route(
196 app: Arc<App>,
197 method: &Method,
198 parts: &[&str],
199 query: &HashMap<String, String>,
200 body: Value,
201) -> Result<Response> {
202 let value = match parts {
203 ["seedbox"] if method == Method::GET => {
204 let (state, mut torrents) = tokio::try_join!(
205 seed_state(app.clone()),
206 qbt(app.clone(), "torrents/info", None)
207 )?;
208 for torrent in torrents.as_array_mut().unwrap() {
209 let root = torrent["root_path"]
210 .as_str()
211 .filter(|s| !s.is_empty())
212 .unwrap_or(string(&torrent["save_path"]))
213 .to_owned();
214 torrent["folder"] = qbt_link(&root, false);
215 torrent["zip"] = if string(&torrent["root_path"]).is_empty() {
216 Value::Null
217 } else {
218 qbt_link(string(&torrent["root_path"]), true)
219 };
220 }
221 json!({"state":state.value,"downloads":qbt_link(string(&state.value["save_path"]),false),"torrents":torrents})
222 }
223 ["seedbox", "history"] if method == Method::GET => {
224 seed_state(app.clone()).await?;
225 let samples = app.seed_samples.lock().unwrap();
226 json!([("download","down"),("upload","up")].map(|(name,key)| json!({"name":name,"t":samples.iter().map(|s| s["t"].clone()).collect::<Vec<_>>(),"v":samples.iter().map(|s| s[key].clone()).collect::<Vec<_>>()})))
227 }
228 ["seedbox", "torrents"] if method == Method::POST => {
229 let url = string(&body["url"]).trim();
230 if !regex::Regex::new(r"(?i)^(magnet:\?|https?://)")
231 .unwrap()
232 .is_match(url)
233 {
234 return Err(Error::new(
235 400,
236 "Paste a magnet link or a link to a .torrent file",
237 ));
238 }
239 qbt(app, "torrents/add", Some(json!({"urls":url}))).await?;
240 Value::Null
241 }
242 ["seedbox", "torrents", hash, tail @ ..] => {
243 if !regex::Regex::new(r"^[0-9a-f]{40}$").unwrap().is_match(hash) {
244 return Err(Error::new(
245 400,
246 "Torrent hashes are 40 lowercase hex characters.",
247 ));
248 }
249 match tail {
250 ["files"] if method == Method::GET => {
251 let route = format!("torrents/files?{}", params(&[("hash", hash.to_string())]));
252 let (torrents, mut files) = tokio::try_join!(
253 qbt(app.clone(), "torrents/info", None),
254 qbt(app.clone(), &route, None)
255 )?;
256 let root = array(&torrents)
257 .iter()
258 .find(|t| t["hash"] == *hash)
259 .and_then(|t| t["save_path"].as_str());
260 for file in files.as_array_mut().unwrap() {
261 file["link"] = root
262 .map(|root| {
263 qbt_link(&format!("{root}/{}", string(&file["name"])), false)
264 })
265 .unwrap_or(Value::Null);
266 }
267 files
268 }
269 [action]
270 if method == Method::POST
271 && [
272 "stop",
273 "start",
274 "topPrio",
275 "increasePrio",
276 "decreasePrio",
277 "bottomPrio",
278 ]
279 .contains(action) =>
280 {
281 qbt(
282 app,
283 &format!("torrents/{action}"),
284 Some(json!({"hashes":hash})),
285 )
286 .await?;
287 Value::Null
288 }
289 [] if method == Method::DELETE => {
290 let files = query.get("files").map(String::as_str).unwrap_or("0");
291 if !["0", "1"].contains(&files) {
292 return Err(Error::new(400, "Invalid files option."));
293 }
294 qbt(app,"torrents/delete",Some(json!({"hashes":hash,"deleteFiles":if files=="1" {"true"} else {"false"}}))).await?;
295 Value::Null
296 }
297 _ => return Err(Error::new(404, "Not Found")),
298 }
299 }
300 ["vms"] if method == Method::GET => {
301 let mut values = Vec::new();
302 for (action, ttl) in [("node", 600), ("domains", 5), ("images", 60)] {
303 let value = app
304 .cache
305 .get(
306 format!("vms:{action}"),
307 Duration::from_secs(ttl),
308 move || async move { vm_call(action, None).await },
309 )
310 .await?;
311 values.push(value.value.clone());
312 }
313 for domain in values[1].as_array_mut().unwrap() {
314 domain["usage"] = app
315 .vm_usage
316 .lock()
317 .unwrap()
318 .get(string(&domain["name"]))
319 .cloned()
320 .unwrap_or(Value::Null);
321 }
322 json!({"node":values[0],"domains":values[1],"images":values[2]})
323 }
324 ["vms", "history"] if method == Method::GET => {
325 let range = telemetry::query_number(query, "range", 300.0, 60.0, 86400.0)?;
326 let mut query = query.clone();
327 query.insert("range".into(), range.to_string());
328 if let Some(name) = query.get("name").cloned() {
329 query.insert("service".into(), name);
330 }
331 let (cpu, memory) = tokio::try_join!(
332 telemetry::metrics(app.clone(), "vm.cpu", &query, None, None),
333 telemetry::metrics(app.clone(), "vm.memory", &query, None, None)
334 )?;
335 let mut domains = serde_json::Map::new();
336 for (values, key) in [(&cpu.value, "cpu"), (&memory.value, "memory")] {
337 for series in array(values) {
338 let domain = domains
339 .entry(string(&series["name"]).to_owned())
340 .or_insert_with(|| json!({"cpu":[],"memory":[]}));
341 domain[key] = series["v"].clone();
342 }
343 }
344 json!({"t":cpu.value[0]["t"].as_array().or(memory.value[0]["t"].as_array()).cloned().unwrap_or_default(),"domains":domains})
345 }
346 ["vms"] if method == Method::POST => {
347 vm_call("create", Some(validate_vm(body)?)).await?;
348 app.cache.invalidate("vms:domains");
349 Value::Null
350 }
351 ["vms", name, tail @ ..] => {
352 vm_name(name)?;
353 match tail {
354 [] if method == Method::PATCH => {
355 let mut payload = json!({"name":name});
356 if let Some(v) = body.get("autostart") {
357 if !v.is_boolean() {
358 return Err(Error::new(400, "Invalid autostart."));
359 }
360 payload["autostart"] = v.clone();
361 }
362 if let Some(v) = body.get("description") {
363 let v = v
364 .as_str()
365 .ok_or_else(|| Error::new(400, "Enter a description."))?
366 .trim();
367 if v.chars().count() > 200 {
368 return Err(Error::new(
369 400,
370 "Keep the description under 200 characters.",
371 ));
372 }
373 payload["description"] = json!(v);
374 }
375 vm_call("update", Some(payload)).await?;
376 }
377 [] if method == Method::DELETE => {
378 let disks = query.get("disks").map(String::as_str).unwrap_or("1");
379 if !["0", "1"].contains(&disks) {
380 return Err(Error::new(400, "Invalid disks option."));
381 }
382 vm_call("remove", Some(json!({"name":name,"disks":disks=="1"}))).await?;
383 }
384 [action]
385 if method == Method::POST
386 && ["start", "shutdown", "reboot", "destroy", "resume"]
387 .contains(action) =>
388 {
389 vm_call("act", Some(json!({"name":name,"action":action}))).await?;
390 }
391 _ => return Err(Error::new(404, "Not Found")),
392 }
393 app.cache.invalidate("vms:domains");
394 Value::Null
395 }
396 ["paper-clover"] if method == Method::GET => {
397 let mut value = paper(app, "stats", None).await?;
398 value["browse"] = std::env::var("STUDIO_PUBLISHED_ROOT")
399 .ok()
400 .map(|p| file_link(&p, false))
401 .unwrap_or(Value::Null);
402 value
403 }
404 ["paper-clover", "activity"] if method == Method::GET => {
405 paper(app, "activity", None).await?
406 }
407 ["paper-clover", "scan"] if method == Method::POST => {
408 let path = &body["path"];
409 if !path.is_null()
410 && (!path.is_string()
411 || !string(path).starts_with('/')
412 || string(path).split('/').any(|s| s == ".."))
413 {
414 return Err(Error::new(
415 400,
416 "Paths can't contain \"..\". Write the full path, like /2026/friends.",
417 ));
418 }
419 paper(
420 app,
421 "scan",
422 Some(if path.is_null() {
423 json!({})
424 } else {
425 json!({"path":path})
426 }),
427 )
428 .await?;
429 return Ok(StatusCode::ACCEPTED.into_response());
430 }
431 _ => return Err(Error::new(404, "Not Found")),
432 };
433 Ok(if value.is_null() {
434 StatusCode::NO_CONTENT.into_response()
435 } else {
436 Document::new(value).response()
437 })
438}
dashboard/src/cache.rs created+288
......@@ -0,0 +1,288 @@
1use crate::{Document, Error, Result};
2use std::{
3 collections::HashMap,
4 future::Future,
5 sync::{Arc, Mutex},
6 time::{Duration, Instant},
7};
8use tokio::sync::Mutex as AsyncMutex;
9
10#[derive(Default)]
11pub struct Cache(Mutex<HashMap<String, Arc<Entry>>>);
12
13#[derive(Default)]
14struct Entry {
15 state: Mutex<Cached>,
16 loading: Arc<AsyncMutex<()>>,
17}
18
19struct Cached {
20 value: Option<(Instant, Arc<Document>)>,
21 failure: Option<(Instant, Error)>,
22 used: Instant,
23}
24impl Default for Cached {
25 fn default() -> Self {
26 Self {
27 value: None,
28 failure: None,
29 used: Instant::now(),
30 }
31 }
32}
33
34impl Cache {
35 fn entry(&self, key: String) -> Result<Arc<Entry>> {
36 let mut entries = self.0.lock().unwrap();
37 if !entries.contains_key(&key) && entries.len() >= 256 {
38 let oldest = entries
39 .iter()
40 .filter(|(_, entry)| Arc::strong_count(entry) == 1)
41 .min_by_key(|(_, entry)| entry.state.lock().unwrap().used)
42 .map(|(key, _)| key.clone());
43 if let Some(oldest) = oldest {
44 entries.remove(&oldest);
45 } else {
46 return Err(Error::new(503, "The dashboard is busy. Retry in a moment."));
47 }
48 }
49 let entry = entries.entry(key).or_default().clone();
50 entry.state.lock().unwrap().used = Instant::now();
51 Ok(entry)
52 }
53
54 pub async fn coalesce<F, Fut>(&self, key: String, load: F) -> Result<Arc<Document>>
55 where
56 F: FnOnce() -> Fut + Send + 'static,
57 Fut: Future<Output = Result<serde_json::Value>> + Send + 'static,
58 {
59 let started = Instant::now();
60 let entry = self.entry(key)?;
61 let guard = entry.loading.clone().lock_owned().await;
62 {
63 let state = entry.state.lock().unwrap();
64 if let Some((at, value)) = &state.value
65 && *at >= started
66 {
67 return Ok(value.clone());
68 }
69 if let Some((at, error)) = &state.failure
70 && *at >= started
71 {
72 return Err(error.clone());
73 }
74 }
75 tokio::spawn(async move {
76 let _guard = guard;
77 entry.store(load().await)
78 })
79 .await?
80 }
81
82 pub fn invalidate(&self, key: &str) {
83 self.0.lock().unwrap().remove(key);
84 }
85
86 pub fn invalidate_prefix(&self, prefix: &str) {
87 self.0
88 .lock()
89 .unwrap()
90 .retain(|key, _| !key.starts_with(prefix));
91 }
92
93 pub fn peek(&self, key: &str, ttl: Duration) -> Option<Arc<Document>> {
94 let entry = self.0.lock().unwrap().get(key).cloned()?;
95 let state = entry.state.lock().unwrap();
96 state
97 .value
98 .as_ref()
99 .filter(|(at, _)| at.elapsed() <= ttl + Duration::from_secs(60))
100 .map(|(_, value)| value.clone())
101 }
102
103 pub async fn get<F, Fut>(&self, key: String, ttl: Duration, load: F) -> Result<Arc<Document>>
104 where
105 F: FnOnce() -> Fut + Send + 'static,
106 Fut: Future<Output = Result<serde_json::Value>> + Send + 'static,
107 {
108 let entry = self.entry(key)?;
109 let stale = {
110 let state = entry.state.lock().unwrap();
111 let value = state
112 .value
113 .as_ref()
114 .filter(|(at, _)| at.elapsed() <= ttl + Duration::from_secs(60));
115 if let Some((at, value)) = value
116 && at.elapsed() < ttl
117 {
118 return Ok(value.clone());
119 }
120 if let Some((at, error)) = &state.failure
121 && at.elapsed() < ttl
122 {
123 return value
124 .map(|(_, value)| value.clone())
125 .ok_or_else(|| error.clone());
126 }
127 value.map(|(_, value)| value.clone())
128 };
129 if let Some(stale) = stale {
130 if let Ok(guard) = entry.loading.clone().try_lock_owned() {
131 tokio::spawn(async move {
132 let _guard = guard;
133 let _ = entry.store(load().await);
134 });
135 }
136 return Ok(stale);
137 }
138 let guard = entry.loading.clone().lock_owned().await;
139 {
140 let state = entry.state.lock().unwrap();
141 if let Some((at, value)) = &state.value
142 && at.elapsed() < ttl
143 {
144 return Ok(value.clone());
145 }
146 if let Some((at, error)) = &state.failure
147 && at.elapsed() < ttl
148 {
149 return Err(error.clone());
150 }
151 }
152 tokio::spawn(async move {
153 let _guard = guard;
154 entry.store(load().await)
155 })
156 .await?
157 }
158}
159
160impl Entry {
161 fn store(&self, result: Result<serde_json::Value>) -> Result<Arc<Document>> {
162 let mut state = self.state.lock().unwrap();
163 match result {
164 Ok(value) => {
165 let document = Arc::new(Document::new(value));
166 state.value = Some((Instant::now(), document.clone()));
167 state.failure = None;
168 Ok(document)
169 }
170 Err(error) => {
171 state.failure = Some((Instant::now(), error.clone()));
172 Err(error)
173 }
174 }
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use std::sync::atomic::{AtomicUsize, Ordering};
182 #[tokio::test]
183 async fn coalesced_identity_reads_do_not_reuse_completed_or_failed_results() {
184 let cache = Arc::new(Cache::default());
185 let calls = Arc::new(AtomicUsize::new(0));
186 let mut readers = Vec::new();
187 for _ in 0..100 {
188 let (cache, calls) = (cache.clone(), calls.clone());
189 readers.push(tokio::spawn(async move {
190 cache
191 .coalesce("identity:owner".into(), move || async move {
192 calls.fetch_add(1, Ordering::SeqCst);
193 tokio::time::sleep(Duration::from_millis(20)).await;
194 Ok(serde_json::json!({"enabled":true}))
195 })
196 .await
197 .unwrap()
198 }));
199 }
200 for reader in readers {
201 assert_eq!(reader.await.unwrap().value["enabled"], true);
202 }
203 assert_eq!(calls.load(Ordering::SeqCst), 1);
204 let disabled = cache
205 .coalesce("identity:owner".into(), || async {
206 Ok(serde_json::json!({"enabled":false}))
207 })
208 .await
209 .unwrap();
210 assert_eq!(disabled.value["enabled"], false);
211 assert!(
212 cache
213 .coalesce("identity:owner".into(), || async {
214 Err(Error::new(502, "unavailable"))
215 })
216 .await
217 .is_err()
218 );
219 let recovered = cache
220 .coalesce("identity:owner".into(), || async {
221 Ok(serde_json::json!({"enabled":true}))
222 })
223 .await
224 .unwrap();
225 assert_eq!(recovered.value["enabled"], true);
226 }
227 #[tokio::test]
228 async fn disconnecting_reader_does_not_cancel_shared_load() {
229 let cache = Arc::new(Cache::default());
230 let started = Arc::new(tokio::sync::Notify::new());
231 let release = Arc::new(tokio::sync::Notify::new());
232 let (state, ready, finish) = (cache.clone(), started.clone(), release.clone());
233 let first = tokio::spawn(async move {
234 state
235 .get(
236 "cancelled".into(),
237 Duration::from_secs(10),
238 move || async move {
239 ready.notify_one();
240 finish.notified().await;
241 Ok(serde_json::json!({"ready":true}))
242 },
243 )
244 .await
245 });
246 started.notified().await;
247 first.abort();
248 release.notify_one();
249 let value = cache
250 .get("cancelled".into(), Duration::from_secs(10), || async {
251 panic!("shared load was repeated");
252 })
253 .await
254 .unwrap();
255 assert_eq!(value.value["ready"], true);
256 }
257 #[tokio::test]
258 async fn coalesces_readers_and_backs_off_failures() {
259 let cache = Arc::new(Cache::default());
260 let calls = Arc::new(AtomicUsize::new(0));
261 let mut readers = Vec::new();
262 for _ in 0..100 {
263 let (cache, calls) = (cache.clone(), calls.clone());
264 readers.push(tokio::spawn(async move {
265 cache
266 .get("same".into(), Duration::from_secs(10), move || async move {
267 calls.fetch_add(1, Ordering::SeqCst);
268 tokio::time::sleep(Duration::from_millis(20)).await;
269 Err(Error::new(502, "unavailable"))
270 })
271 .await
272 }));
273 }
274 for reader in readers {
275 assert!(reader.await.unwrap().is_err());
276 }
277 assert_eq!(calls.load(Ordering::SeqCst), 1);
278 cache.invalidate("same");
279 assert!(
280 cache
281 .get("same".into(), Duration::from_secs(10), || async {
282 Ok(serde_json::json!({"ok":true}))
283 })
284 .await
285 .is_ok()
286 );
287 }
288}
dashboard/src/core.rs created+803
......@@ -0,0 +1,803 @@
1use crate::*;
2use futures::{StreamExt, stream};
3use sha1::{Digest, Sha1};
4
5pub async fn nomad(app: &App, path: &str) -> Result<Value> {
6 let value = nomad_raw(app, path).await?;
7 Ok(value
8 .map(|bytes| serde_json::from_slice(&bytes))
9 .transpose()?
10 .unwrap_or(Value::Null))
11}
12pub async fn nomad_raw(app: &App, path: &str) -> Result<Option<Bytes>> {
13 let request = async {
14 let token = tokio::fs::read_to_string(env(
15 "STUDIO_NOMAD_TOKEN_FILE",
16 "/var/lib/studio/dashboard.token",
17 ))
18 .await
19 .map_err(|e| Error::new(501, format!("Nomad isn't connected: {e}")))?;
20 let _slot = app.nomad_slots.acquire().await?;
21 let base = match &app.internal {
22 Some((base, _)) => format!("{}nomad", base.as_str()),
23 None => env("NOMAD_ADDR", "http://127.0.0.1:4646"),
24 };
25 let response = app
26 .request(Method::GET, &format!("{base}{path}"))?
27 .header("X-Nomad-Token", token.trim())
28 .send()
29 .await
30 .map_err(|_| {
31 Error::new(502, "Service status is taking too long. Retry in a moment.")
32 })?;
33 if response.status() == 404 {
34 return Ok(None);
35 }
36 let status = response.status();
37 let bytes = response.bytes().await?;
38 if !status.is_success() {
39 return Err(Error::new(
40 502,
41 format!(
42 "Nomad refused {} ({status}): {}",
43 path.split('?').next().unwrap(),
44 String::from_utf8_lossy(&bytes).trim()
45 ),
46 ));
47 }
48 Ok(Some(bytes))
49 };
50 tokio::time::timeout(Duration::from_secs(15), request)
51 .await
52 .map_err(|_| Error::new(502, "Service status is taking too long. Retry in a moment."))?
53}
54
55fn live_alloc(alloc: &Value) -> bool {
56 alloc["DesiredStatus"] == "run"
57 && matches!(string(&alloc["ClientStatus"]), "pending" | "running")
58}
59pub fn health(job: &Value, allocs: &[Value], checks: &[Value]) -> &'static str {
60 if job["Stop"] == true {
61 return "stopped";
62 }
63 let running: Vec<_> = allocs.iter().filter(|a| live_alloc(a)).collect();
64 if running.is_empty() {
65 return "down";
66 }
67 if running
68 .iter()
69 .any(|a| a["JobVersion"] != running[0]["JobVersion"])
70 {
71 return "deploying";
72 }
73 if running.iter().any(|a| a["ClientStatus"] == "pending") {
74 return "starting";
75 }
76 if running.iter().any(|a| {
77 a["TaskStates"]
78 .as_object()
79 .into_iter()
80 .flat_map(|s| s.values())
81 .any(|s| s["State"] == "pending")
82 }) {
83 return "restarting";
84 }
85 if checks.iter().any(|c| c["Status"] == "failure")
86 || running
87 .iter()
88 .any(|a| a["DeploymentStatus"]["Healthy"] == false)
89 {
90 return "degraded";
91 }
92 "healthy"
93}
94
95pub async fn scan(app: Arc<App>) -> Result<Arc<Document>> {
96 let state = app.clone();
97 app.cache
98 .get(
99 "nomad-scan".into(),
100 Duration::from_secs(10),
101 move || async move {
102 let (stubs, allocations, nodes) = tokio::try_join!(
103 nomad(&state, "/v1/jobs"),
104 nomad(&state, "/v1/allocations"),
105 nomad(&state, "/v1/nodes?resources=true")
106 )?;
107 let cpu = &nodes[0]["NodeResources"]["Cpu"];
108 let mhz = number(&cpu["CpuShares"]) / number(&cpu["TotalCpuCores"]);
109 if !stubs.is_array() || !allocations.is_array() || !mhz.is_finite() || mhz <= 0.0 {
110 return Err(Error::new(
111 502,
112 "Nomad answered without its jobs, allocations or node.",
113 ));
114 }
115 let jobs = stream::iter(array(&stubs).iter().cloned())
116 .map(|stub| {
117 let state = state.clone();
118 async move {
119 let id = string(&stub["ID"]).to_owned();
120 let cached = state
121 .specs
122 .lock()
123 .unwrap()
124 .get(&id)
125 .filter(|job| job["JobModifyIndex"] == stub["JobModifyIndex"])
126 .cloned();
127 let job = match cached {
128 Some(job) => job,
129 None => nomad(&state, &format!("/v1/job/{}", encoded(&id))).await?,
130 };
131 state.specs.lock().unwrap().insert(id.clone(), job.clone());
132 Ok::<_, Error>((id, job))
133 }
134 })
135 .buffer_unordered(4)
136 .collect::<Vec<_>>()
137 .await
138 .into_iter()
139 .collect::<Result<HashMap<_, _>>>()?;
140 state
141 .specs
142 .lock()
143 .unwrap()
144 .retain(|id, _| jobs.contains_key(id));
145 let mut allocs = array(&allocations).to_vec();
146 allocs
147 .sort_by(|a, b| number(&b["CreateTime"]).total_cmp(&number(&a["CreateTime"])));
148 let checked = stream::iter(allocs)
149 .map(|alloc| {
150 let state = state.clone();
151 async move {
152 let checks = if live_alloc(&alloc) {
153 nomad(
154 &state,
155 &format!(
156 "/v1/client/allocation/{}/checks",
157 string(&alloc["ID"])
158 ),
159 )
160 .await?
161 .as_object()
162 .map(|v| v.values().cloned().collect::<Vec<_>>())
163 .unwrap_or_default()
164 } else {
165 Vec::new()
166 };
167 let mut alloc = alloc;
168 alloc["home_checks"] = json!(checks);
169 Ok::<_, Error>(alloc)
170 }
171 })
172 .buffered(4)
173 .collect::<Vec<_>>()
174 .await
175 .into_iter()
176 .collect::<Result<Vec<_>>>()?;
177 let mut found = serde_json::Map::new();
178 for (id, job) in jobs {
179 let mine: Vec<_> = checked.iter().filter(|a| a["JobID"] == id).collect();
180 let allocs: Vec<_> = mine.iter().map(|a| (*a).clone()).collect();
181 let checks: Vec<_> = mine
182 .iter()
183 .flat_map(|a| array(&a["home_checks"]).iter().cloned())
184 .collect();
185 let health = health(&job, &allocs, &checks);
186 found.insert(
187 id,
188 json!({"job":job,"allocs":allocs,"mhz":mhz,"health":health}),
189 );
190 }
191 Ok(Value::Object(found))
192 },
193 )
194 .await
195}
196
197pub async fn managed() -> Result<Vec<String>> {
198 Ok(host::call(json!({"operation":"deploy.managed"}))
199 .await?
200 .as_array()
201 .unwrap()
202 .iter()
203 .map(|id| string(id).to_owned())
204 .collect())
205}
206pub async fn read_json(file: &std::path::Path, missing: Value) -> Result<Value> {
207 match tokio::fs::read(file).await {
208 Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
209 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(missing),
210 Err(e) => Err(e.into()),
211 }
212}
213pub async fn write_json(file: &std::path::Path, value: &Value) -> Result<()> {
214 tokio::fs::create_dir_all(file.parent().unwrap()).await?;
215 let temporary = file.with_extension(format!("{}.tmp", rand::random::<u64>()));
216 tokio::fs::write(&temporary, serde_json::to_vec(value)?).await?;
217 tokio::fs::rename(&temporary, file).await?;
218 Ok(())
219}
220pub async fn service_file(app: &App, id: &str) -> Result<PathBuf> {
221 if !valid_id(id) {
222 return Err(Error::new(400, "Invalid service ID."));
223 }
224 let direct = app.repo.join("service").join(id).join("service.pkl");
225 if tokio::fs::try_exists(&direct).await? {
226 return Ok(direct);
227 }
228 let mut dirs = tokio::fs::read_dir(app.repo.join("service")).await?;
229 while let Some(dir) = dirs.next_entry().await? {
230 let file = dir.path().join(format!("{id}.pkl"));
231 if tokio::fs::try_exists(&file).await? {
232 return Ok(file);
233 }
234 }
235 Ok(direct)
236}
237pub fn valid_id(id: &str) -> bool {
238 !id.is_empty()
239 && id.as_bytes()[0].is_ascii_lowercase()
240 && id
241 .bytes()
242 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
243}
244
245async fn icons(app: Arc<App>, id: String) -> Result<Arc<Document>> {
246 let state = app.clone();
247 app.cache.get(format!("icon:{id}"),Duration::from_secs(30),move || async move {
248 let file = service_file(&state,&id).await?;
249 let dir = if file.file_name().unwrap() == "service.pkl" { file.parent().unwrap().to_path_buf() } else { file.parent().unwrap().join(&id) };
250 let mut found = serde_json::Map::new();
251 for file in ["icon.svg","icon-light.svg","icon-dark.svg"] {
252 let candidate = dir.join(file);
253 let fallback = PathBuf::from(env("STUDIO_ICON_DIR","server/icons")).join(&id).join(file);
254 let path = if tokio::fs::try_exists(&candidate).await? { candidate } else { fallback };
255 if let Ok(bytes) = tokio::fs::read(&path).await {
256 found.insert(file.to_owned(),json!({"path":path,"hash":format!("{:x}",Sha1::digest(&bytes))[..12].to_owned()}));
257 }
258 }
259 Ok(Value::Object(found))
260 }).await
261}
262async fn icon_of(app: Arc<App>, id: &str) -> Value {
263 let Ok(found) = icons(app, id.into()).await else {
264 return Value::Null;
265 };
266 let url = |mode| {
267 let file = if !found.value[mode].is_null() {
268 mode
269 } else {
270 "icon.svg"
271 };
272 found.value[file]["hash"]
273 .as_str()
274 .map(|hash| format!("/api/icons/{id}/{file}?v={hash}"))
275 };
276 match (url("icon-light.svg"), url("icon-dark.svg")) {
277 (Some(light), Some(dark)) => json!({"light":light,"dark":dark}),
278 _ => Value::Null,
279 }
280}
281pub async fn icon(
282 app: &Arc<App>,
283 parts: &[&str],
284 query: &HashMap<String, String>,
285) -> Result<Response> {
286 if parts.len() != 3
287 || !valid_id(parts[1])
288 || !["icon.svg", "icon-light.svg", "icon-dark.svg"].contains(&parts[2])
289 {
290 return Err(Error::new(404, "Not Found"));
291 }
292 let found = icons(app.clone(), parts[1].into()).await?;
293 let path = found.value[parts[2]]["path"]
294 .as_str()
295 .ok_or_else(|| Error::new(404, "Not Found"))?;
296 Ok((
297 [
298 ("content-type", "image/svg+xml"),
299 (
300 "cache-control",
301 if query.contains_key("v") {
302 "public, max-age=31536000, immutable"
303 } else {
304 "no-cache"
305 },
306 ),
307 ],
308 tokio::fs::read(path).await?,
309 )
310 .into_response())
311}
312
313async fn summarize(app: Arc<App>, id: &str, found: &Value) -> Value {
314 let meta = &found["job"]["Meta"];
315 let tasks: Vec<_> = array(&found["job"]["TaskGroups"])
316 .iter()
317 .flat_map(|g| array(&g["Tasks"]))
318 .collect();
319 let usage = app
320 .usage
321 .lock()
322 .unwrap()
323 .get(id)
324 .cloned()
325 .unwrap_or(Value::Null);
326 let mhz = number(&found["mhz"]);
327 let cpu: f64 = tasks.iter().map(|t| number(&t["Resources"]["CPU"])).sum();
328 let memory: f64 = tasks
329 .iter()
330 .map(|t| {
331 let max = number(&t["Resources"]["MemoryMaxMB"]);
332 if max > 0.0 {
333 max
334 } else {
335 number(&t["Resources"]["MemoryMB"])
336 }
337 })
338 .sum();
339 json!({"id":id,"name":meta["studio_name"].as_str().unwrap_or(id),"health":found["health"].as_str().unwrap_or("down"),"url":meta["studio_hostname"].as_str().filter(|h| !h.is_empty()).map(|h| format!("https://{h}")),"icon":icon_of(app.clone(),id).await,"access":meta["studio_auth_role"],"tagline":meta["studio_tagline"],"cpu":usage["cpu"],"cpuLimit":if mhz > 0.0 { cpu/mhz } else {0.0},"memory":usage["memory"],"memoryLimit":memory*1048576.0})
340}
341async fn summaries(app: Arc<App>) -> Result<Arc<Document>> {
342 let state = app.clone();
343 app.cache
344 .get(
345 "services".into(),
346 Duration::from_secs(2),
347 move || async move {
348 let (ids, jobs) = tokio::try_join!(managed(), scan(state.clone()))?;
349 let values = stream::iter(ids)
350 .map(|id| {
351 let state = state.clone();
352 let jobs = jobs.clone();
353 async move { summarize(state, &id, &jobs.value[&id]).await }
354 })
355 .buffered(4)
356 .collect::<Vec<_>>()
357 .await;
358 Ok(json!(values))
359 },
360 )
361 .await
362}
363
364pub fn seconds(value: &Value) -> Option<f64> {
365 chrono::DateTime::parse_from_rfc3339(value.as_str()?)
366 .ok()
367 .map(|t| t.timestamp() as f64 + t.timestamp_subsec_nanos() as f64 / 1e9)
368 .filter(|s| *s > 0.0)
369}
370pub fn last_restart(task: &Value) -> Value {
371 let Some(t) = seconds(&task["LastRestart"]) else {
372 return Value::Null;
373 };
374 let events = array(&task["Events"]);
375 let end = events
376 .iter()
377 .rposition(|e| e["Type"] == "Restarting")
378 .map(|i| i + 1)
379 .unwrap_or(0);
380 let reason = events[..end]
381 .iter()
382 .rev()
383 .find(|e| {
384 ["Terminated", "Restart Signaled", "Driver Failure", "Killed"]
385 .contains(&string(&e["Type"]))
386 })
387 .map(|e| string(&e["DisplayMessage"]))
388 .filter(|s| !s.is_empty())
389 .unwrap_or("restarted");
390 json!({"t":t,"reason":reason})
391}
392pub fn checks_of(checks: &Value) -> Value {
393 json!(array(checks).iter().map(|c| json!({"name":c["Check"],"passing":c["Status"] != "failure","output":c["Output"]})).collect::<Vec<_>>())
394}
395fn containers(found: &Value) -> Value {
396 let allocs = array(&found["allocs"]);
397 let alloc = allocs
398 .iter()
399 .find(|a| live_alloc(a))
400 .or_else(|| allocs.first())
401 .unwrap_or(&Value::Null);
402 let tasks: HashMap<_, _> = array(&found["job"]["TaskGroups"])
403 .iter()
404 .flat_map(|g| array(&g["Tasks"]))
405 .map(|t| (string(&t["Name"]), t))
406 .collect();
407 json!(alloc["TaskStates"].as_object().into_iter().flat_map(|s| s.iter()).map(|(name,state)| {
408 let spec = tasks.get(name.as_str()).copied().unwrap_or(&Value::Null);
409 json!({"name":name,"image":spec["Config"]["image"],"hook":spec["Lifecycle"]["Hook"],"state":state["State"],"restarts":state["Restarts"],"lastRestart":last_restart(state),"startedAt":seconds(&state["StartedAt"])})
410 }).collect::<Vec<_>>())
411}
412async fn issues(app: Arc<App>) -> Result<Value> {
413 let summaries = app
414 .cache
415 .peek("services", Duration::from_secs(2))
416 .ok_or_else(|| Error::new(503, "Service status is unavailable."))?;
417 let jobs = app
418 .cache
419 .peek("nomad-scan", Duration::from_secs(10))
420 .ok_or_else(|| Error::new(503, "Service status is unavailable."))?;
421 let ack = read_json(&app.data.join("restarts-acknowledged.json"), json!({})).await?;
422 let mut issues = Vec::new();
423 for service in array(&summaries.value) {
424 let id = string(&service["id"]);
425 let health = string(&service["health"]);
426 let found = &jobs.value[id];
427 let cs = containers(found);
428 let restart = array(&cs)
429 .iter()
430 .map(|c| &c["lastRestart"])
431 .filter(|r| !r.is_null() && (ack[id].is_null() || number(&r["t"]) > number(&ack[id])))
432 .max_by(|a, b| number(&a["t"]).total_cmp(&number(&b["t"])));
433 let trouble = health == "down" || health == "degraded";
434 if trouble || restart.is_some() {
435 issues.push(json!({"service":{"id":id,"name":service["name"],"icon":service["icon"],"url":service["url"]},"health":health,"failing":if trouble {array(&found["allocs"]).iter().flat_map(|a| array(&a["home_checks"])).find(|c| c["Status"] == "failure").map(|c| c["Output"].clone())} else {None},"restart":restart}));
436 }
437 }
438 Ok(json!(issues))
439}
440
441async fn launcher(app: Arc<App>) -> Result<Arc<Document>> {
442 let real = tokio::fs::canonicalize(&app.repo).await?;
443 let state = app.clone();
444 app.cache.get(format!("launcher:{}",real.display()),Duration::from_secs(365*86400),move || async move {
445 let module = format!(r#"import* "file://{}/service/*/*.pkl" as services
446output {{ renderer = new JsonRenderer {{ omitNullProperties = false }}
447value = services.toMap().filter((_, s) -> s.enabled).mapValues((_, s) ->
448let (route = (s.containers?.toMap()?.values ?? List()).add(s.container).filterNonNull().map((task) -> task.http).filterNonNull().findOrNull((http) -> http.hostname != null))
449new Dynamic {{ name = s.meta.name; tagline = s.meta.tagline; hostname = route?.hostname; access = route?.authRole }}) }}"#,real.display());
450 let value: Value = serde_json::from_slice(&command("pkl",&["eval","-"],Some(module.as_bytes())).await?)?;
451 let mut apps = Vec::new();
452 for (file, value) in value.as_object().into_iter().flat_map(|v| v.iter()) {
453 if let Some(host) = value["hostname"].as_str() {
454 let path = std::path::Path::new(file);
455 let id = if path.file_name().unwrap() == "service.pkl" { path.parent().unwrap().file_name().unwrap() } else { path.file_stem().unwrap() }.to_string_lossy();
456 apps.push(json!({"id":id,"name":value["name"],"tagline":value["tagline"].as_str().filter(|s| !s.is_empty()),"url":format!("https://{host}"),"access":value["access"],"icon":icon_of(state.clone(),&id).await}));
457 }
458 }
459 Ok(json!(apps))
460 }).await
461}
462
463async fn service(app: Arc<App>, id: &str) -> Result<Arc<Document>> {
464 let state = app.clone();
465 let id = id.to_owned();
466 app.cache.get(format!("service:{id}"), Duration::from_secs(2), move || async move {
467 let app = state;
468 let id = id.as_str();
469 let ids = managed().await?;
470 let jobs = scan(app.clone()).await?;
471 let found = &jobs.value[id];
472 if !ids.iter().any(|s| s == id) || found.is_null() {
473 return Err(Error::new(
474 404,
475 format!("No service is named {id}. Pick one from the sidebar."),
476 ));
477 }
478 let all = summaries(app.clone()).await?;
479 let link = |other: &str, kind: &Value| {
480 array(&all.value)
481 .iter()
482 .find(|s| s["id"] == other)
483 .map(|s| json!({"id":other,"name":s["name"],"kind":kind,"health":s["health"]}))
484 };
485 let needs = |other: &str| {
486 serde_json::from_str::<Value>(string(&jobs.value[other]["job"]["Meta"]["studio_requires"]))
487 .ok()
488 };
489 let requirements = needs(id).map(|n| {
490 n.as_object()
491 .into_iter()
492 .flat_map(|v| v.iter())
493 .filter_map(|(id, kind)| link(id, kind))
494 .collect::<Vec<_>>()
495 });
496 let dependents = if ids.iter().all(|id| needs(id).is_some()) {
497 Some(
498 ids.iter()
499 .filter_map(|other| {
500 needs(other).and_then(|n| n.get(id).and_then(|kind| link(other, kind)))
501 })
502 .collect::<Vec<_>>(),
503 )
504 } else {
505 None
506 };
507 let mut summary = summarize(app.clone(), id, found).await;
508 let meta = &found["job"]["Meta"];
509 let application_traces = if let Some(name) = meta["studio_trace_service"]
510 .as_str()
511 .filter(|s| !s.is_empty())
512 {
513 telemetry::has_traces(app.clone(), name)
514 .await
515 .unwrap_or(false)
516 } else {
517 false
518 };
519 let metrics =
520 if meta["studio_metrics_path"].as_str().is_some_and(|p| !p.is_empty()) || meta["studio_metrics_pushed"] == "true" {
521 telemetry::metric_names(app.clone(), id)
522 .await
523 .unwrap_or(json!([]))
524 } else {
525 json!([])
526 };
527 let release = host::call(json!({"operation":"deploy.current"})).await?;
528 let secrets = serde_json::from_str::<Value>(string(&meta["studio_secrets"]))
529 .ok()
530 .map(|v| {
531 array(&v)
532 .iter()
533 .map(|s| json!({"name":s["name"],"generated":s["generated"]}))
534 .collect::<Vec<_>>()
535 });
536 let ack = read_json(&app.data.join("restarts-acknowledged.json"), json!({})).await?;
537 let datasets = app.cache.get("zfs-datasets".into(),Duration::from_secs(30),move || async move {
538 let rows = host::call(json!({"operation":"storage.datasets"})).await?;
539 Ok(json!(array(&rows).iter().map(|d| json!({"name":d["name"],"mountpoint":d["mountpoint"],"used":d["usedbydataset"],"snapshots":d["usedbysnapshots"]})).collect::<Vec<_>>()))
540 }).await?;
541 let logs = array(&all.value).iter().find(|s| s["id"] == "victoria-logs" && s["url"].is_string()).map(|s| json!({"app":{"id":s["id"],"name":s["name"],"icon":s["icon"]},"url":format!("{}/select/vmui/#/?query={}",string(&s["url"]),encoded(&format!("{{job=\"{id}\"}}")))}));
542 let fields = json!({"applicationTraces":application_traces,"applicationMetrics":metrics,"release":release,"deployedAt":number(&found["job"]["SubmitTime"])/1e9,"rollout":if array(&found["job"]["TaskGroups"]).iter().any(|g| number(&g["Update"]["Canary"]) > 0.0) {"overlapped"} else {"simple"},"containers":containers(found),"checks":checks_of(&json!(array(&found["allocs"]).iter().flat_map(|a| array(&a["home_checks"])).collect::<Vec<_>>())),"requirements":requirements,"dependents":dependents,"secrets":secrets,"datasets":array(&datasets.value).iter().filter(|d| string(&d["name"]).ends_with(&format!("/prod/{id}"))).collect::<Vec<_>>(),"restartsAcknowledged":ack[id],"logs":logs});
543 summary
544 .as_object_mut()
545 .unwrap()
546 .extend(fields.as_object().unwrap().clone());
547 Ok(summary)
548 }).await
549}
550
551async fn change(app: Arc<App>, request: Value) -> Result<()> {
552 let run = host::call(request).await?;
553 app.cache.invalidate("deploys");
554 let outcome = tokio::time::timeout(Duration::from_secs(120), async {
555 loop {
556 let status = host::call(json!({"operation":"deploy.run","id":run["id"]})).await?;
557 if let Some(code) = status["code"].as_i64() {
558 return if code == 0 {
559 Ok(())
560 } else {
561 Err(Error::new(
562 502,
563 "The change failed. Check its run in deploys before retrying.",
564 ))
565 };
566 }
567 tokio::time::sleep(Duration::from_millis(500)).await;
568 }
569 })
570 .await
571 .map_err(|_| {
572 Error::new(
573 504,
574 "The change is still running. Check its run in deploys before retrying.",
575 )
576 });
577 app.cache.invalidate("deploys");
578 app.cache.invalidate("nomad-scan");
579 app.cache.invalidate("services");
580 app.cache
581 .invalidate(&format!("service:{}", string(&run["target"])));
582 outcome??;
583 Ok(())
584}
585
586pub async fn route(
587 app: Arc<App>,
588 method: &Method,
589 parts: &[&str],
590 query: &HashMap<String, String>,
591 me: &Value,
592 body: Value,
593) -> Result<Response> {
594 let empty = || StatusCode::NO_CONTENT.into_response();
595 let value = match parts {
596 ["me"] if method == Method::GET => me.clone(),
597 ["host"] if method == Method::GET => {
598 let sample = host::sample(app).await?;
599 json!({"cores":sample.value["cores"],"memory":sample.value["memory"]["total"],"bootedAt":sample.value["bootedAt"],"outages":null})
600 }
601 ["services"] if method == Method::GET => return Ok(summaries(app).await?.response()),
602 ["launcher"] if method == Method::GET => {
603 let found = launcher(app.clone()).await?;
604 let jobs = app.cache.peek("nomad-scan", Duration::from_secs(10));
605 let groups: Vec<_> = array(&me["groups"]).iter().map(string).collect();
606 let mut apps = Vec::new();
607 for value in array(&found.value)
608 .iter()
609 .filter(|v| can_open(&groups, v["access"].as_str()))
610 {
611 let mut value = value.clone();
612 value["health"] = jobs
613 .as_ref()
614 .map(|j| j.value[string(&value["id"])]["health"].clone())
615 .unwrap_or(Value::Null);
616 value.as_object_mut().unwrap().remove("access");
617 apps.push(value);
618 }
619 json!(apps)
620 }
621 ["status"] if method == Method::GET => {
622 let issues = issues(app.clone()).await.ok();
623 let apps = launcher(app.clone()).await?;
624 let groups: Vec<_> = array(&me["groups"]).iter().map(string).collect();
625 let admin = array(&me["sections"]).iter().any(|s| s == "admin");
626 let issues = issues.map(|v| {
627 array(&v)
628 .iter()
629 .filter(|i| {
630 admin
631 || array(&apps.value).iter().any(|a| {
632 a["id"] == i["service"]["id"]
633 && can_open(&groups, a["access"].as_str())
634 })
635 })
636 .cloned()
637 .collect::<Vec<_>>()
638 });
639 let sample = host::sample(app).await?;
640 json!({"load":(number(&sample.value["load"])/number(&sample.value["cores"])*100.0).min(100.0),"issues":issues})
641 }
642 ["services", id] if method == Method::GET => return Ok(service(app, id).await?.response()),
643 ["services", id, "definition"] if method == Method::GET => definition(app, id).await?,
644 ["services", id, "restarts", "acknowledge"] if method == Method::POST => {
645 let file = app.data.join("restarts-acknowledged.json");
646 let mut value = read_json(&file, json!({})).await?;
647 value[*id] = json!(now());
648 write_json(&file, &value).await?;
649 app.cache.invalidate(&format!("service:{id}"));
650 return Ok(empty());
651 }
652 ["services", id, action] if method == Method::POST => {
653 if !["start", "stop", "restart"].contains(action) {
654 return Err(Error::new(400, "Choose start, stop, or restart."));
655 }
656 change(
657 app,
658 json!({"operation":"deploy.start","action":action,"target":id}),
659 )
660 .await?;
661 return Ok(empty());
662 }
663 ["services", id, "secrets", name] if method == Method::GET => {
664 json!({"value":host::call(json!({"operation":"deploy.secret.get","service":id,"key":name})).await?})
665 }
666 ["services", id, "secrets", name] | ["services", id, "secrets", name, "rotate"]
667 if (parts.len() == 4 && method == Method::PUT)
668 || (parts.len() == 5 && method == Method::POST) =>
669 {
670 let mut request = json!({"operation":if parts.len()==5 {"deploy.secret.rotate"} else {"deploy.secret.set"},"service":id,"key":name});
671 if parts.len() == 4 {
672 request["value"] = body["value"].clone();
673 }
674 change(app, request).await?;
675 return Ok(empty());
676 }
677 ["metrics", metric] if method == Method::GET => {
678 return Ok(telemetry::metrics(app, metric, query, None, None)
679 .await?
680 .response());
681 }
682 ["services", id, "metrics", name] if method == Method::GET => {
683 let detail = service(app.clone(), id).await?;
684 if !array(&detail.value["applicationMetrics"])
685 .iter()
686 .any(|v| v == *name)
687 {
688 return Err(Error::new(404, "Metric unavailable for this service."));
689 }
690 return Ok(telemetry::metrics(app, name, query, Some(id), None)
691 .await?
692 .response());
693 }
694 ["services", id, "logs"] if method == Method::GET => {
695 telemetry::logs(app, id, query).await?
696 }
697 ["services", id, "traces"] if method == Method::GET => {
698 telemetry::traces(app, id, query, |_| true).await?
699 }
700 ["traces", id] if method == Method::GET => telemetry::trace(app, id).await?,
701 _ => return Err(Error::new(404, "Not Found")),
702 };
703 Ok(Document::new(value).response())
704}
705
706pub fn start(app: Arc<App>) {
707 let state = app.clone();
708 tokio::spawn(async move {
709 loop {
710 if let Err(e) = summaries(state.clone()).await {
711 eprintln!("status: {}", e.message);
712 }
713 tokio::time::sleep(Duration::from_secs(2)).await;
714 }
715 });
716 tokio::spawn(async move {
717 let mut previous = (0.0, 0.0);
718 let mut interval = tokio::time::interval(Duration::from_secs(2));
719 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
720 loop {
721 interval.tick().await;
722 let sample = async {
723 let sample = host::sample(app.clone()).await?;
724 let sample = &sample.value;
725 let total = number(&sample["cpu"]["total"]); let busy = number(&sample["cpu"]["busy"]);
726 let cpu = if previous.1 > 0.0 && total > previous.1 { ((busy-previous.0)/(total-previous.1)*100.0).clamp(0.0,100.0) } else {0.0}; previous = (busy,total);
727 let jobs = app.cache.peek("services",Duration::from_secs(2));
728 let services: serde_json::Map<_,_> = jobs.as_ref().into_iter().flat_map(|j| array(&j.value)).map(|s| (string(&s["id"]).to_owned(),json!({"cpu":s["cpu"],"memory":s["memory"],"health":s["health"]}))).collect();
729 Ok::<_,Error>(Document::new(json!({"t":now(),"host":{"cpu":cpu,"memory":sample["memory"]["used"],"arc":sample["arc"],"temperature":sample["temperature"]},"services":services,"ups":null})))
730 }.await;
731 match sample {
732 Ok(value) => {
733 app.live.send_replace(value.bytes);
734 }
735 Err(e) => eprintln!("host sample: {}", e.message),
736 }
737 }
738 });
739}
740
741async fn definition(app: Arc<App>, id: &str) -> Result<Value> {
742 if !managed().await?.iter().any(|s| s == id) {
743 return Err(Error::new(
744 404,
745 format!("No service is named {id}. Pick one from the sidebar."),
746 ));
747 }
748 let jobs = scan(app.clone()).await?;
749 let found = &jobs.value[id];
750 if found.is_null() {
751 return Err(Error::new(
752 404,
753 format!("No service is named {id}. Pick one from the sidebar."),
754 ));
755 }
756 let mhz = number(&found["mhz"]);
757 let groups=array(&found["job"]["TaskGroups"]).iter().map(|group| {
758 let tags=|service: &Value,key: &str| array(&service["Tags"]).iter().filter_map(|s| string(s).strip_prefix(&format!("{key}=")).map(str::to_owned)).collect::<Vec<_>>();
759 let services=array(&group["Services"]).iter().map(|service| {
760 let check=&service["Checks"][0];let restart=&check["CheckRestart"];
761 json!({"name":service["Name"],"port":service["PortLabel"],"hostnames":tags(service,"caddy-host"),"authRole":tags(service,"caddy-auth-role").first(),"check":if check.is_null() {Value::Null} else {json!({"task":check["TaskName"],"type":check["Type"],"path":check["Path"],"interval":number(&check["Interval"])/1e9,"timeout":number(&check["Timeout"])/1e9,"restartAfter":if number(&restart["Limit"])>0.0 {json!({"failures":restart["Limit"],"grace":number(&restart["Grace"])/1e9})} else {Value::Null}})}})
762 }).collect::<Vec<_>>();
763 let tasks=array(&group["Tasks"]).iter().map(|task| {
764 let mut ports=HashMap::new();for network in array(&group["Networks"]) {for (key,dynamic) in [("DynamicPorts",true),("ReservedPorts",false)] {for port in array(&network[key]) {ports.insert(string(&port["Label"]),(port,dynamic));}}}
765 let config=&task["Config"];let mut env:Vec<Value>=task["Env"].as_object().into_iter().flat_map(|v| v.keys()).map(|name| json!({"name":name,"from":"job"})).collect();
766 let assignments=regex::Regex::new(r"(?m)(?:^|\}\})([A-Za-z_]\w*)=").unwrap();
767 for template in array(&task["Templates"]).iter().filter(|t| t["Envvars"]==true) {let text=string(&template["EmbeddedTmpl"]);for capture in assignments.captures_iter(text) {env.push(json!({"name":&capture[1],"from":if text.contains("nomadVar") {"secret"} else {"template"}}));}}
768 json!({"name":task["Name"],"hook":task["Lifecycle"]["Hook"],"sidecar":task["Lifecycle"]["Sidecar"].as_bool().unwrap_or(false),"image":config["image"],"user":task["User"].as_str().filter(|s| !s.is_empty()),"cpu":number(&task["Resources"]["CPU"])/mhz,"memory":number(&task["Resources"]["MemoryMB"])*1048576.0,"memoryMax":if number(&task["Resources"]["MemoryMaxMB"])>0.0 {json!(number(&task["Resources"]["MemoryMaxMB"])*1048576.0)} else {Value::Null},"ports":array(&config["ports"]).iter().map(|label| {let port=ports.get(string(label));json!({"label":label,"container":port.and_then(|(p,_)| p["To"].as_u64()).filter(|n| *n>0),"host":port.filter(|(_,dynamic)| !dynamic).map(|(p,_)| p["Value"].clone()),"network":port.map(|(p,_)| p["HostNetwork"].as_str().unwrap_or("default")).unwrap_or("default")})}).collect::<Vec<_>>(),"mounts":array(&config["volumes"]).iter().map(|volume| {let fields:Vec<_>=string(volume).split(':').collect();json!({"source":fields[0],"target":fields.get(1).unwrap_or(&fields[0]),"readOnly":fields.get(2).is_some_and(|s| s.split(',').any(|s| s=="ro"))})}).collect::<Vec<_>>(),"tmpfs":array(&config["tmpfs"]),"devices":array(&config["devices"]),"capabilities":array(&config["cap_add"]),"hostNetwork":config["network_mode"]=="host","extraHosts":array(&config["extra_hosts"]),"env":env})
769 }).collect::<Vec<_>>();
770 let restart=&group["RestartPolicy"];
771 json!({"name":group["Name"],"count":group["Count"],"restart":{"attempts":restart["Attempts"],"interval":number(&restart["Interval"])/1e9,"delay":number(&restart["Delay"])/1e9,"fail":restart["Mode"]=="fail"},"services":services,"tasks":tasks})
772 }).collect::<Vec<_>>();
773 let source = tokio::fs::read_to_string(service_file(&app, id).await?)
774 .await
775 .ok();
776 Ok(json!({"groups":groups,"source":source}))
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 #[test]
783 fn health_precedence() {
784 let job = json!({"Stop":false});
785 let alloc = json!({"DesiredStatus":"run","ClientStatus":"running","JobVersion":1,"TaskStates":{"app":{"State":"running"}}});
786 assert_eq!(health(&job, &[], &[]), "down");
787 assert_eq!(health(&job, &[alloc.clone()], &[]), "healthy");
788 assert_eq!(
789 health(&job, &[alloc.clone()], &[json!({"Status":"failure"})]),
790 "degraded"
791 );
792 let mut pending = alloc.clone();
793 pending["TaskStates"]["app"]["State"] = json!("pending");
794 assert_eq!(
795 health(&job, &[pending], &[json!({"Status":"failure"})]),
796 "restarting"
797 );
798 let mut newer = alloc.clone();
799 newer["JobVersion"] = json!(2);
800 assert_eq!(health(&job, &[alloc, newer], &[]), "deploying");
801 assert_eq!(health(&json!({"Stop":true}), &[], &[]), "stopped");
802 }
803}
dashboard/src/deploys.rs created+534
......@@ -0,0 +1,534 @@
1use crate::*;
2use futures::stream;
3
4async fn history() -> Result<Value> {
5 host::call(json!({"operation":"deploy.history"})).await
6}
7async fn current() -> Result<Option<String>> {
8 Ok(serde_json::from_value(
9 host::call(json!({"operation":"deploy.current"})).await?,
10 )?)
11}
12async fn stages(app: Arc<App>) -> Result<Value> {
13 let mut values = host::call(json!({"operation":"deploy.stages"})).await?;
14 let jobs = core::scan(app).await.ok();
15 for stage in values.as_array_mut().unwrap() {
16 let job = jobs.as_ref().map(|jobs| &jobs.value[string(&stage["id"])]);
17 stage["hostname"] = json!(
18 job.and_then(|job| job["job"]["Meta"]["studio_hostname"].as_str())
19 .filter(|hostname| !hostname.is_empty())
20 );
21 stage["health"] = json!(job.map(|job| job["health"].as_str().unwrap_or("down")));
22 }
23 Ok(values)
24}
25async fn tree(app: Arc<App>, id: &str) -> Result<Arc<Document>> {
26 let id = id.to_owned();
27 app.cache
28 .get(
29 format!("release:{id}"),
30 Duration::from_secs(365 * 86400),
31 move || async move {
32 host::call(json!({"operation":"deploy.release","release":id})).await
33 },
34 )
35 .await
36}
37async fn changed(
38 app: Arc<App>,
39 from: Option<&str>,
40 to: Option<&str>,
41) -> Result<Option<Vec<String>>> {
42 let (Some(from), Some(to)) = (from, to) else {
43 return Ok(None);
44 };
45 let (a, b) = tokio::try_join!(tree(app.clone(), from), tree(app.clone(), to))?;
46 let (Some(a), Some(b)) = (a.value.as_object(), b.value.as_object()) else {
47 return Ok(None);
48 };
49 let ids: std::collections::BTreeSet<_> = a.keys().chain(b.keys()).collect();
50 Ok(Some(
51 ids.into_iter()
52 .filter(|id| a.get(*id) != b.get(*id))
53 .cloned()
54 .collect(),
55 ))
56}
57fn diff(before: &str, after: &str) -> Vec<Value> {
58 let a: Vec<_> = if before.is_empty() {
59 Vec::new()
60 } else {
61 before.split('\n').collect()
62 };
63 let b: Vec<_> = if after.is_empty() {
64 Vec::new()
65 } else {
66 after.split('\n').collect()
67 };
68 let mut common = vec![vec![0usize; b.len() + 1]; a.len() + 1];
69 for i in (0..a.len()).rev() {
70 for j in (0..b.len()).rev() {
71 common[i][j] = if a[i] == b[j] {
72 common[i + 1][j + 1] + 1
73 } else {
74 common[i + 1][j].max(common[i][j + 1])
75 };
76 }
77 }
78 let (mut i, mut j) = (0, 0);
79 let mut lines = Vec::new();
80 while i < a.len() || j < b.len() {
81 if i < a.len() && j < b.len() && a[i] == b[j] {
82 lines.push(json!([" ", a[i]]));
83 i += 1;
84 j += 1;
85 } else if i < a.len() && (j == b.len() || common[i + 1][j] >= common[i][j + 1]) {
86 lines.push(json!(["-", a[i]]));
87 i += 1;
88 } else {
89 lines.push(json!(["+", b[j]]));
90 j += 1;
91 }
92 }
93 lines
94}
95async fn stage(app: Arc<App>, id: &str) -> Result<Value> {
96 if !core::valid_id(id) {
97 return Err(Error::new(
98 400,
99 "Stage IDs are lowercase letters, digits and dashes.",
100 ));
101 }
102 array(&stages(app).await?)
103 .iter()
104 .find(|s| s["id"] == id)
105 .cloned()
106 .ok_or_else(|| {
107 Error::new(
108 404,
109 format!("No stage named {id}. It may have been destroyed; go back to deploys."),
110 )
111 })
112}
113fn entry(history: &Value, n: &str) -> Result<(usize, Value)> {
114 let n = n
115 .parse::<usize>()
116 .ok()
117 .filter(|n| *n > 0)
118 .ok_or_else(|| Error::new(400, "Invalid deploy number."))?;
119 array(history)
120 .get(n - 1)
121 .cloned()
122 .map(|v| (n, v))
123 .ok_or_else(|| {
124 Error::new(
125 404,
126 format!("No deploy number {n}. Go back to deploys to see the history."),
127 )
128 })
129}
130struct Scope {
131 jobs: Vec<String>,
132 allocations: Vec<Value>,
133 from: f64,
134 to: Option<f64>,
135}
136async fn scope(app: Arc<App>, kind: &str, target: &str) -> Result<Scope> {
137 let mut after = f64::NEG_INFINITY;
138 let mut until = f64::INFINITY;
139 let mut to = None;
140 let jobs = if kind == "stages" {
141 vec![string(&stage(app.clone(), target).await?["id"]).to_owned()]
142 } else {
143 let history = history().await?;
144 let (n, entry) = entry(&history, target)?;
145 let previous = if n >= 2 {
146 &history[n - 2]
147 } else {
148 &Value::Null
149 };
150 after = previous["time"].as_f64().unwrap_or(f64::NEG_INFINITY);
151 until = number(&entry["time"]);
152 to = history[n]["time"].as_f64();
153 match changed(
154 app.clone(),
155 previous["release"].as_str(),
156 entry["release"].as_str(),
157 )
158 .await?
159 {
160 Some(jobs) => jobs,
161 None => tree(app.clone(), string(&entry["release"]))
162 .await?
163 .value
164 .as_object()
165 .into_iter()
166 .flat_map(|t| t.keys().cloned())
167 .collect(),
168 }
169 };
170 let scanned = core::scan(app).await?;
171 let mut allocations = Vec::new();
172 let mut created = Vec::new();
173 for id in &jobs {
174 let found = &scanned.value[id];
175 let mine=array(&found["allocs"]).iter().filter(|a| number(&a["CreateTime"])/1e9>after && number(&a["CreateTime"])/1e9<=until).map(|a| {
176 let tasks:Vec<_>=a["TaskStates"].as_object().into_iter().flat_map(|t| t.iter()).map(|(name,task)| json!({"name":name,"restarts":task["Restarts"],"lastRestart":core::last_restart(task)})).collect();let finished:Vec<_>=a["TaskStates"].as_object().into_iter().flat_map(|t| t.values()).map(|t| core::seconds(&t["FinishedAt"]).unwrap_or(0.0)).collect();let ended=if ["complete","failed","lost"].contains(&string(&a["ClientStatus"])) && !finished.is_empty(){finished.into_iter().max_by(f64::total_cmp)}else{None};let created_at=number(&a["CreateTime"])/1e9;created.push(created_at);
177 json!({"id":a["ID"],"state":a["ClientStatus"],"healthy":a["DeploymentStatus"]["Healthy"],"created":created_at,"ended":ended,"tasks":tasks,"checks":if a["DesiredStatus"]=="run" && ["running","pending"].contains(&string(&a["ClientStatus"])) {core::checks_of(&a["home_checks"])}else{json!([])}})
178 }).collect::<Vec<_>>();
179 allocations.push(json!(mine));
180 }
181 Ok(Scope {
182 jobs,
183 allocations,
184 from: created
185 .into_iter()
186 .min_by(f64::total_cmp)
187 .unwrap_or(after.max(0.0)),
188 to,
189 })
190}
191async fn runtime(app: Arc<App>, scope: Scope) -> Result<Value> {
192 let end = scope.to.unwrap_or((now() / 2.0).floor() * 2.0);
193 let mut jobs = Vec::new();
194 for (i, id) in scope.jobs.iter().enumerate() {
195 let query = HashMap::from([("service".into(), id.clone())]);
196 let usage = tokio::try_join!(
197 telemetry::metrics(
198 app.clone(),
199 "service.cpu",
200 &query,
201 None,
202 Some((scope.from, end))
203 ),
204 telemetry::metrics(
205 app.clone(),
206 "service.memory",
207 &query,
208 None,
209 Some((scope.from, end))
210 )
211 );
212 let (cpu, memory) = match usage {
213 Ok((cpu, memory)) => (cpu.value[0].clone(), memory.value[0].clone()),
214 Err(e) if e.status == 501 => (Value::Null, Value::Null),
215 Err(e) => return Err(e),
216 };
217 jobs.push(json!({"id":id,"allocations":scope.allocations[i],"cpu":cpu,"memory":memory}));
218 }
219 Ok(json!({"from":scope.from,"to":scope.to,"jobs":jobs}))
220}
221fn display_run(run: Value, history: &Value) -> Result<Value> {
222 if run.is_null() {
223 return Ok(run);
224 }
225 let target = string(&run["target"]);
226 let title = match string(&run["action"]) {
227 "secret-set" => format!("set {target}/{}", string(&run["key"])),
228 "secret-rotate" => format!("rotate {target}/{}", string(&run["key"])),
229 action @ ("start" | "stop" | "restart") => format!("{action} {target}"),
230 "deploy" => "deploy main".to_owned(),
231 "promote" => format!("promote {target}"),
232 "destroy" => format!("destroy {target}"),
233 "rollback" => {
234 let (_, entry) = entry(history, target)?;
235 format!(
236 "roll back to {}",
237 string(&entry["release"]).get(..8).unwrap_or_default()
238 )
239 }
240 _ => {
241 return Err(Error::new(
242 502,
243 "The deployment action couldn't be read. Reload deploys.",
244 ));
245 }
246 };
247 Ok(json!({"id":run["id"],"title":title,"target":target,"code":run["code"]}))
248}
249async fn start(app: Arc<App>, action: &str, target: &str) -> Result<Value> {
250 let run =
251 host::call(json!({"operation":"deploy.start","action":action,"target":target})).await?;
252 app.cache.invalidate("deploys");
253 display_run(run, &history().await?)
254}
255pub async fn run_stream(app: Arc<App>, id: &str) -> Result<Response> {
256 host::call(json!({"operation":"deploy.run","id":id})).await?;
257 let id = id.to_owned();
258 let stream = stream::unfold(
259 (app, id, 0usize, false),
260 move |(app, id, mut sent, ended)| async move {
261 if ended {
262 return None;
263 }
264 let run = id.clone();
265 let response =
266 app.cache
267 .get(
268 format!("run:{id}"),
269 Duration::from_secs(1),
270 move || async move {
271 host::call(json!({"operation":"deploy.run","id":run})).await
272 },
273 )
274 .await;
275 match response {
276 Ok(response) => {
277 let mut chunk = String::new();
278 let lines = array(&response.value["lines"]);
279 while sent < lines.len() {
280 for line in string(&lines[sent]).lines() {
281 chunk.push_str(&format!("data: {line}\n"));
282 }
283 chunk.push('\n');
284 sent += 1;
285 }
286 let ended = !response.value["code"].is_null();
287 if ended {
288 chunk.push_str(&format!(
289 "event: exit\ndata: {}\n\n",
290 number(&response.value["code"])
291 ));
292 }
293 if chunk.is_empty() {
294 chunk.push_str(": keepalive\n\n");
295 }
296 tokio::time::sleep(Duration::from_secs(1)).await;
297 Some((
298 Ok::<_, std::io::Error>(Bytes::from(chunk)),
299 (app, id, sent, ended),
300 ))
301 }
302 Err(error) => Some((
303 Ok(Bytes::from(format!(
304 "event: exit\ndata: 1\n\n: {}\n\n",
305 error.message.replace('\n', " ")
306 ))),
307 (app, id, sent, true),
308 )),
309 }
310 },
311 );
312 Ok((
313 [
314 ("content-type", "text/event-stream"),
315 ("cache-control", "no-cache"),
316 ],
317 axum::body::Body::from_stream(stream),
318 )
319 .into_response())
320}
321
322pub async fn route(
323 app: Arc<App>,
324 method: &Method,
325 parts: &[&str],
326 query: &HashMap<String, String>,
327) -> Result<Response> {
328 let value = match parts {
329 [] if method == Method::GET => {
330 let state = app.clone();
331 let document = app
332 .cache
333 .get(
334 "deploys".into(),
335 Duration::from_secs(2),
336 move || async move {
337 let history = history().await?;
338 let current = current().await?;
339 let main = host::call(json!({"operation":"deploy.main"})).await?;
340 let mut stages = stages(state.clone()).await?;
341 let mut entries = Vec::new();
342 for (i, item) in array(&history).iter().enumerate() {
343 let mut item = item.clone();
344 item["n"] = json!(i + 1);
345 item["changed"] = json!(
346 changed(
347 state.clone(),
348 i.checked_sub(1)
349 .and_then(|n| history[n]["release"].as_str()),
350 item["release"].as_str()
351 )
352 .await?
353 );
354 item["redeploys"] = json!(
355 changed(state.clone(), current.as_deref(), item["release"].as_str()).await?
356 );
357 entries.push(item);
358 }
359 entries.reverse();
360 for stage in stages.as_array_mut().unwrap() {
361 let base = array(&history).iter().rposition(|e| {
362 e["source"] == stage["id"] || number(&e["time"]) <= number(&stage["created"])
363 });
364 stage["files"] = if stage["clone"].is_null() {
365 Value::Null
366 } else {
367 apps::file_link(string(&stage["mount"]), false)
368 };
369 stage["base"] = json!(base.map(|n| n + 1));
370 stage["changed"] = json!(
371 changed(state.clone(), current.as_deref(), stage["release"].as_str()).await?
372 );
373 stage["behind"] = json!(
374 changed(
375 state.clone(),
376 base.and_then(|n| history[n]["release"].as_str()),
377 current.as_deref()
378 )
379 .await?
380 .unwrap_or_default()
381 );
382 }
383 let run = display_run(
384 host::call(json!({"operation":"deploy.last"})).await?,
385 &history,
386 )?;
387 Ok(json!({
388 "current": current,
389 "main": main,
390 "recorded": array(&history).last().and_then(|e| e["release"].as_str()) == current.as_deref(),
391 "history": entries, "stages": stages, "run": run
392 }))
393 },
394 )
395 .await?;
396 return Ok(document.response());
397 }
398 ["changes"] if method == Method::GET => {
399 let to = query
400 .get("to")
401 .ok_or_else(|| Error::new(400, "Pick a release."))?;
402 let a = if let Some(from) = query.get("from") {
403 tree(app.clone(), from).await?
404 } else {
405 Arc::new(Document::new(json!({})))
406 };
407 let b = tree(app.clone(), to).await?;
408 let (Some(a), Some(b)) = (a.value.as_object(), b.value.as_object()) else {
409 return Err(Error::new(
410 410,
411 "That release is no longer on the host, so its changes can't be shown.",
412 ));
413 };
414 let ids: std::collections::BTreeSet<_> = a.keys().chain(b.keys()).collect();
415 json!(ids.into_iter().filter(|id| a.get(*id)!=b.get(*id)).map(|id| json!({"id":id,"lines":diff(a.get(id).map(string).unwrap_or_default(),b.get(id).map(string).unwrap_or_default())})).collect::<Vec<_>>())
416 }
417 [kind, target, "runtime"]
418 if method == Method::GET && ["stages", "history"].contains(kind) =>
419 {
420 runtime(app.clone(), scope(app, kind, target).await?).await?
421 }
422 [kind, target, "logs"] if method == Method::GET && ["stages", "history"].contains(kind) => {
423 let scope = scope(app.clone(), kind, target).await?;
424 let job = query
425 .get("job")
426 .or(scope.jobs.first())
427 .ok_or_else(|| Error::new(404, "No job ran here, so there are no logs to show."))?;
428 if !scope.jobs.contains(job) {
429 return Err(Error::new(
430 404,
431 format!("{job} didn't run here, so there are no logs to show."),
432 ));
433 }
434 job_logs(app, job, query, scope.from, scope.to).await?
435 }
436 [kind, target, "output"]
437 if method == Method::GET && ["stages", "history"].contains(kind) =>
438 {
439 json!({"lines":host::call(json!({"operation":"deploy.output","kind":kind,"target":target})).await?})
440 }
441 ["main", release, "deploy"] if method == Method::POST => {
442 start(app, "deploy", release).await?
443 }
444 ["stages", id, "destroy"] if method == Method::POST => start(app, "destroy", id).await?,
445 ["history", n, "rollback"] if method == Method::POST => start(app, "rollback", n).await?,
446 _ => return Err(Error::new(404, "Not Found")),
447 };
448 Ok(Document::new(value).response())
449}
450
451async fn job_logs(
452 app: Arc<App>,
453 job: &str,
454 query: &HashMap<String, String>,
455 from: f64,
456 to: Option<f64>,
457) -> Result<Value> {
458 let jobs = core::scan(app.clone()).await?;
459 let found = &jobs.value[job];
460 let limit = telemetry::query_number(query, "limit", 300.0, 1.0, 1000.0)? as usize;
461 let after =
462 telemetry::query_number(query, "after", from, f64::NEG_INFINITY, f64::INFINITY)?.max(from);
463 let before = telemetry::query_number(
464 query,
465 "before",
466 to.unwrap_or(f64::MAX),
467 f64::NEG_INFINITY,
468 f64::INFINITY,
469 )?
470 .min(to.unwrap_or(f64::MAX));
471 let mut lines = Vec::new();
472 let ansi = regex::Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]|\r").unwrap();
473 let error =
474 regex::Regex::new(r"(?i)\b(ERROR|ERR|FATAL|CRITICAL|PANIC)\b|level=(error|fatal)").unwrap();
475 let warn = regex::Regex::new(r"(?i)\b(WARN|WARNING|WRN)\b|level=warn").unwrap();
476 for alloc in array(&found["allocs"]) {
477 for task in alloc["TaskStates"]
478 .as_object()
479 .into_iter()
480 .flat_map(|t| t.keys())
481 {
482 let path = format!(
483 "/v1/client/fs/logs/{}?{}",
484 string(&alloc["ID"]),
485 params(&[
486 ("task", task.clone()),
487 ("type", "stdout".into()),
488 ("origin", "end".into()),
489 ("offset", (-512 * 1024).to_string()),
490 ("plain", "true".into())
491 ])
492 );
493 let Some(bytes) = core::nomad_raw(&app, &path).await? else {
494 continue;
495 };
496 let text = String::from_utf8_lossy(&bytes);
497 let mut partial = String::new();
498 for line in text.lines() {
499 let fields: Vec<_> = line.splitn(4, ' ').collect();
500 if fields.len() != 4
501 || !["stdout", "stderr"].contains(&fields[1])
502 || !["F", "P"].contains(&fields[2])
503 {
504 continue;
505 }
506 let Some(t) = core::seconds(&json!(fields[0])) else {
507 continue;
508 };
509 partial.push_str(fields[3]);
510 if fields[2] == "P" {
511 continue;
512 }
513 let text = std::mem::take(&mut partial);
514 if t <= after || t >= before {
515 continue;
516 }
517 let plain = ansi.replace_all(&text, "");
518 let level = if error.is_match(&plain) {
519 Some("error")
520 } else if warn.is_match(&plain) {
521 Some("warn")
522 } else {
523 None
524 };
525 lines.push(
526 json!({"t":t,"container":task,"stream":fields[1],"level":level,"text":text}),
527 );
528 }
529 }
530 }
531 lines.sort_by(|a, b| number(&b["t"]).total_cmp(&number(&a["t"])));
532 lines.truncate(limit);
533 Ok(json!(lines))
534}
dashboard/src/files.rs created+1157
......@@ -0,0 +1,1157 @@
1use crate::*;
2use std::{
3 collections::HashSet,
4 io::Read,
5 os::unix::fs::MetadataExt,
6 path::{Component, Path},
7};
8
9pub fn relative(rel: &str, item: bool) -> Result<String> {
10 if rel.len() > 4096 || rel.contains('\0') || Path::new(rel).is_absolute() {
11 return Err(Error::new(
12 400,
13 "Paths start from the library, so they can't start with /.",
14 ));
15 }
16 let mut path = PathBuf::new();
17 for component in Path::new(rel).components() {
18 match component {
19 Component::CurDir => (),
20 Component::ParentDir => {
21 if path.file_name().is_some_and(|s| s != "..") {
22 path.pop();
23 } else {
24 path.push("..");
25 }
26 }
27 _ => path.push(component.as_os_str()),
28 }
29 }
30 let value = path.to_string_lossy().into_owned();
31 if item && value.is_empty() {
32 return Err(Error::new(
33 400,
34 "Choose something inside the folder, not the folder itself.",
35 ));
36 }
37 Ok(value)
38}
39fn name(value: &Value) -> Result<&str> {
40 value
41 .as_str()
42 .filter(|s| {
43 !s.is_empty() && s.len() <= 255 && !s.contains(['/', '\0']) && *s != "." && *s != ".."
44 })
45 .ok_or_else(|| Error::new(400, "Names can't contain / or be . or .."))
46}
47#[derive(Clone)]
48struct Explorer {
49 root: PathBuf,
50 base: PathBuf,
51 scope: PathBuf,
52}
53impl Explorer {
54 async fn new(root: PathBuf) -> Result<Self> {
55 let store = PathBuf::from(env("STUDIO_STORE_ROOT", "/srv"));
56 let scope = root
57 .strip_prefix(&store)
58 .map_err(|_| Error::new(500, "Library root is outside the store."))?
59 .to_path_buf();
60 let real = tokio::fs::canonicalize(store).await?;
61 Ok(Self {
62 root,
63 base: real.join(&scope),
64 scope,
65 })
66 }
67 async fn resolve(&self, rel: &str) -> Result<PathBuf> {
68 let rel = relative(rel, false)?;
69 if rel == ".." || rel.starts_with("../") {
70 return Err(Error::new(
71 403,
72 format!("That path is outside {}.", self.root.display()),
73 ));
74 }
75 let abs = self.base.join(&rel);
76 let mut ancestor = abs.clone();
77 loop {
78 match tokio::fs::canonicalize(&ancestor).await {
79 Ok(real) => {
80 if !real.starts_with(&self.base) {
81 return Err(Error::new(
82 403,
83 format!("That path is outside {}.", self.root.display()),
84 ));
85 }
86 return Ok(abs);
87 }
88 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
89 if !ancestor.pop() {
90 return Err(e.into());
91 }
92 }
93 Err(e) => return Err(e.into()),
94 }
95 }
96 }
97 fn store(&self, rel: &str) -> String {
98 self.scope.join(rel).to_string_lossy().into_owned()
99 }
100 fn local(&self, rel: &str) -> Result<String> {
101 Path::new(rel)
102 .strip_prefix(&self.scope)
103 .map(|p| p.to_string_lossy().into_owned())
104 .map_err(|_| {
105 Error::new(
106 403,
107 format!("That change touched files outside {}.", self.root.display()),
108 )
109 })
110 }
111 async fn entries(&self, rel: &str, mounts: &Value) -> Result<Vec<Value>> {
112 let mut dir = tokio::fs::read_dir(self.resolve(rel).await?)
113 .await
114 .map_err(file_error)?;
115 let mut found = Vec::new();
116 while let Some(entry) = dir.next_entry().await? {
117 let name = entry.file_name().to_string_lossy().into_owned();
118 let child = Path::new(rel).join(&name).to_string_lossy().into_owned();
119 let Ok(abs) = self.resolve(&child).await else {
120 continue;
121 };
122 let Ok(info) = tokio::fs::metadata(&abs).await else {
123 continue;
124 };
125 let is_dir = info.is_dir();
126 let items = if is_dir {
127 tokio::time::timeout(Duration::from_millis(200), async {
128 let mut dir = tokio::fs::read_dir(&abs).await?;
129 let mut count = 0;
130 while dir.next_entry().await?.is_some() {
131 count += 1;
132 }
133 Ok::<_, std::io::Error>(count)
134 })
135 .await
136 .ok()
137 .and_then(std::result::Result::ok)
138 } else {
139 None
140 };
141 let dataset = if is_dir {
142 array(mounts)
143 .iter()
144 .find(|m| m["target"] == abs.to_string_lossy().as_ref())
145 .map(|m| m["source"].clone())
146 } else {
147 None
148 };
149 found.push(json!({"name":name,"dir":is_dir,"size":if is_dir {None}else{Some(info.len())},"alloc":if is_dir {None}else{Some(info.blocks()*512)},"items":items,"modified":info.mtime(),"inode":info.ino(),"dataset":dataset,"download":apps::file_link(&self.root.join(child).to_string_lossy(),is_dir)}));
150 }
151 Ok(found)
152 }
153 async fn not_dataset(&self, rels: &[String], mounts: &Value) -> Result<()> {
154 for rel in rels {
155 let abs = self.resolve(rel).await?;
156 if let Some(mount) = array(mounts)
157 .iter()
158 .find(|m| Path::new(string(&m["target"])).starts_with(&abs))
159 {
160 return Err(Error::new(
161 409,
162 if mount["target"] == abs.to_string_lossy().as_ref() {
163 format!(
164 "{rel} is the {} dataset. Rename or destroy it with zfs instead.",
165 string(&mount["source"])
166 )
167 } else {
168 format!(
169 "{rel} holds the {} dataset. Move that dataset out with zfs first.",
170 string(&mount["source"])
171 )
172 },
173 ));
174 }
175 }
176 Ok(())
177 }
178 async fn check_moves(&self, pairs: &[(String, String)], mounts: &Value) -> Result<()> {
179 self.not_dataset(
180 &pairs
181 .iter()
182 .map(|(from, _)| from.clone())
183 .collect::<Vec<_>>(),
184 mounts,
185 )
186 .await?;
187 let mut sources = HashSet::new();
188 let mut targets = HashSet::new();
189 for (from, to) in pairs {
190 if to.starts_with(&format!("{from}/")) {
191 return Err(Error::new(400, format!("Can't move {from} into itself.")));
192 }
193 if !sources.insert(from) {
194 return Err(Error::new(
195 400,
196 format!("{from} is listed twice. Pick one place for it."),
197 ));
198 }
199 if !targets.insert(to) {
200 return Err(Error::new(
201 409,
202 format!("More than one item would land at {to}. Rename one first."),
203 ));
204 }
205 let a = self.resolve(from).await?;
206 let b = self.resolve(to).await?;
207 if !tokio::fs::try_exists(&a).await? {
208 return Err(Error::new(
209 409,
210 format!("{from} isn't there anymore. Reload the folder."),
211 ));
212 }
213 if tokio::fs::try_exists(&b).await? {
214 return Err(Error::new(
215 409,
216 format!("{to} already exists. Move or rename it first."),
217 ));
218 }
219 let source = holder(mounts, &a);
220 let target = holder(mounts, b.parent().unwrap());
221 if source.map(|m| &m["source"]) != target.map(|m| &m["source"]) {
222 return Err(Error::new(
223 409,
224 format!(
225 "{from} is on {} and {} is on {}. Moves can't cross datasets, so copy it from a shell instead.",
226 source.map(|m| string(&m["source"])).unwrap_or("no dataset"),
227 b.parent()
228 .and_then(Path::file_name)
229 .unwrap_or_default()
230 .to_string_lossy(),
231 target.map(|m| string(&m["source"])).unwrap_or("no dataset")
232 ),
233 ));
234 }
235 }
236 Ok(())
237 }
238 async fn move_one(&self, from: &str, to: &str) -> Result<Option<String>> {
239 let target = self.resolve(to).await?;
240 let mut made = target.parent().unwrap().to_path_buf();
241 let mut first = None;
242 while !tokio::fs::try_exists(&made).await? {
243 first = Some(made.clone());
244 made.pop();
245 }
246 tokio::fs::create_dir_all(target.parent().unwrap()).await?;
247 if let Err(error) = tokio::fs::rename(self.resolve(from).await?, &target).await {
248 if let Some(first) = &first {
249 remove_empty(first.clone()).await?;
250 }
251 return Err(file_error(error));
252 }
253 Ok(first.map(|p| self.store(&p.strip_prefix(&self.base).unwrap().to_string_lossy())))
254 }
255 async fn folder(&self, rel: &str) -> Result<()> {
256 let abs = self.resolve(rel).await?;
257 if !tokio::fs::metadata(abs).await.is_ok_and(|m| m.is_dir()) {
258 return Err(Error::new(
259 400,
260 format!(
261 "{} isn't a folder. Create it first, or pick an existing folder.",
262 self.root.join(rel).display()
263 ),
264 ));
265 }
266 Ok(())
267 }
268 async fn matches(&self, folder: &str, pattern: &str, app: Arc<App>) -> Result<Vec<String>> {
269 if pattern.is_empty()
270 || pattern.len() > 1024
271 || pattern.starts_with('/')
272 || pattern.split('/').any(|s| s == "..")
273 {
274 return Err(Error::new(400, "Patterns can't start with / or contain .."));
275 }
276 let base = self.resolve(folder).await?;
277 let pattern = pattern.to_owned();
278 let _slot = app.heavy.acquire().await?;
279 let paths = tokio::task::spawn_blocking(move || {
280 let matcher = globset::GlobBuilder::new(&pattern)
281 .literal_separator(true)
282 .build()
283 .map_err(|e| Error::new(400, e.to_string()))?
284 .compile_matcher();
285 Ok::<_, Error>(
286 walkdir::WalkDir::new(&base)
287 .min_depth(1)
288 .follow_links(false)
289 .into_iter()
290 .filter_map(|e| e.ok())
291 .filter_map(|e| {
292 let rel = e.path().strip_prefix(&base).ok()?;
293 matcher
294 .is_match(rel)
295 .then(|| rel.to_string_lossy().into_owned())
296 })
297 .collect::<Vec<_>>(),
298 )
299 })
300 .await??;
301 let mut paths = paths
302 .into_iter()
303 .map(|p| Path::new(folder).join(p).to_string_lossy().into_owned())
304 .collect::<Vec<_>>();
305 paths.sort();
306 let mut valid = Vec::new();
307 for path in paths {
308 if self.resolve(&path).await.is_ok()
309 && !valid
310 .iter()
311 .any(|p: &String| path.starts_with(&format!("{p}/")))
312 {
313 valid.push(path);
314 }
315 }
316 Ok(valid)
317 }
318}
319fn file_error(error: std::io::Error) -> Error {
320 match error.kind() {
321 std::io::ErrorKind::NotFound => Error::new(
322 404,
323 "That file or folder no longer exists. Reload the folder.",
324 ),
325 std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty => {
326 Error::new(409, "Something with that name already exists.")
327 }
328 _ if error.raw_os_error() == Some(18) => Error::new(
329 409,
330 "Moves can't cross ZFS datasets. Copy it from a shell instead.",
331 ),
332 _ => error.into(),
333 }
334}
335async fn mounts() -> Result<Value> {
336 let value = host::call(json!({"operation":"storage.mounts"})).await?;
337 let mut values = array(&value["filesystems"]).to_vec();
338 values.sort_by_key(|m| std::cmp::Reverse(string(&m["target"]).len()));
339 Ok(json!(values))
340}
341fn holder<'a>(mounts: &'a Value, abs: &Path) -> Option<&'a Value> {
342 array(mounts)
343 .iter()
344 .find(|m| abs.starts_with(string(&m["target"])))
345}
346fn selection(value: &Value) -> Result<Vec<String>> {
347 if !value.is_array() || array(value).is_empty() || array(value).len() > 10000 {
348 return Err(Error::new(400, "Choose files or folders."));
349 }
350 let paths = array(value)
351 .iter()
352 .map(|p| {
353 p.as_str()
354 .ok_or_else(|| Error::new(400, "Invalid path."))
355 .and_then(|p| relative(p, true))
356 })
357 .collect::<Result<Vec<_>>>()?;
358 let mut seen = HashSet::new();
359 Ok(paths
360 .iter()
361 .filter(|p| {
362 seen.insert((*p).clone())
363 && !Path::new(p)
364 .ancestors()
365 .skip(1)
366 .any(|a| paths.iter().any(|p| p == a.to_string_lossy().as_ref()))
367 })
368 .cloned()
369 .collect())
370}
371fn item_count(count: usize) -> String {
372 format!("{count} {}", if count == 1 { "item" } else { "items" })
373}
374async fn remove_empty(abs: PathBuf) -> Result<bool> {
375 tokio::task::spawn_blocking(move || {
376 fn remove(abs: &Path) -> std::io::Result<bool> {
377 if !std::fs::symlink_metadata(abs).is_ok_and(|m| m.is_dir()) {
378 return Ok(false);
379 }
380 let mut empty = true;
381 for entry in std::fs::read_dir(abs)? {
382 if !remove(&entry?.path())? {
383 empty = false;
384 }
385 }
386 if empty {
387 std::fs::remove_dir(abs)?;
388 }
389 Ok(empty)
390 }
391 Ok::<_, Error>(remove(&abs)?)
392 })
393 .await?
394}
395fn walk_total(abs: &Path, budget: &mut usize) -> Option<Value> {
396 if *budget == 0 {
397 return None;
398 }
399 *budget -= 1;
400 let info = std::fs::symlink_metadata(abs).ok();
401 let Some(info) = info else {
402 return Some(json!({"size":0,"alloc":0,"files":0}));
403 };
404 if !info.is_dir() {
405 return Some(json!({"size":info.len(),"alloc":info.blocks()*512,"files":1}));
406 }
407 let mut total = json!({"size":0,"alloc":0,"files":0});
408 if let Ok(dir) = std::fs::read_dir(abs) {
409 for entry in dir.flatten() {
410 let child = walk_total(&entry.path(), budget)?;
411 for key in ["size", "alloc", "files"] {
412 total[key] = json!(number(&total[key]) + number(&child[key]));
413 }
414 }
415 }
416 Some(total)
417}
418async fn tree(app: Arc<App>, explorer: &Explorer, body: &Value, mounts: &Value) -> Result<Value> {
419 let top = relative(string(&body["path"]), false)?;
420 let mut folders = serde_json::Map::new();
421 let first = explorer.entries(&top, mounts).await?;
422 let mut listed = first.len();
423 folders.insert(top.clone(), json!(first));
424 let mut complete = true;
425 if body["expanded"] == "all" {
426 let mut level = vec![top.clone()];
427 while !level.is_empty() {
428 let next: Vec<_> = level
429 .iter()
430 .flat_map(|rel| {
431 array(&folders[rel])
432 .iter()
433 .filter(|e| e["dir"] == true)
434 .map(|e| {
435 (
436 Path::new(rel)
437 .join(string(&e["name"]))
438 .to_string_lossy()
439 .into_owned(),
440 e["items"].as_u64().map(|n| n as usize),
441 )
442 })
443 })
444 .collect();
445 let Some(total) = next
446 .iter()
447 .try_fold(listed, |total, (_, count)| count.map(|n| total + n))
448 else {
449 complete = false;
450 break;
451 };
452 listed = total;
453 if listed > 3000 {
454 complete = false;
455 break;
456 }
457 level.clear();
458 for (rel, _) in next {
459 let entries = explorer.entries(&rel, mounts).await?;
460 folders.insert(rel.clone(), json!(entries));
461 level.push(rel);
462 }
463 }
464 } else {
465 if !body["expanded"].is_array() || array(&body["expanded"]).len() > 3000 {
466 return Err(Error::new(400, "Choose expanded folders."));
467 }
468 for rel in array(&body["expanded"]) {
469 let rel = relative(string(rel), true)?;
470 if (!top.is_empty() && !rel.starts_with(&format!("{top}/")))
471 || folders.contains_key(&rel)
472 {
473 continue;
474 }
475 if let Ok(entries) = explorer.entries(&rel, mounts).await {
476 folders.insert(rel, json!(entries));
477 }
478 }
479 }
480 let index = app.index.clone();
481 let explorer = explorer.clone();
482 let _slot = app.heavy.acquire().await?;
483 tokio::task::spawn_blocking(move || {
484 fn measure(
485 rel: &str,
486 explorer: &Explorer,
487 index: Option<&crate::index::Index>,
488 folders: &serde_json::Map<String, Value>,
489 sizes: &mut serde_json::Map<String, Value>,
490 budget: &mut usize,
491 from_index: &mut bool,
492 ) -> Result<Option<Value>> {
493 if let Some(entries) = folders.get(rel) {
494 let mut total = json!({"size":0,"alloc":0,"files":0});
495 let mut known = true;
496 for entry in array(entries) {
497 let child = if entry["dir"] == true {
498 let rel = Path::new(rel)
499 .join(string(&entry["name"]))
500 .to_string_lossy()
501 .into_owned();
502 let value =
503 measure(&rel, explorer, index, folders, sizes, budget, from_index)?;
504 if let Some(value) = &value {
505 sizes.insert(rel, value.clone());
506 }
507 value
508 } else {
509 Some(json!({"size":entry["size"],"alloc":entry["alloc"],"files":1}))
510 };
511 if let Some(child) = child {
512 for key in ["size", "alloc", "files"] {
513 total[key] = json!(number(&total[key]) + number(&child[key]));
514 }
515 } else {
516 known = false;
517 }
518 }
519 return Ok(known.then_some(total));
520 }
521 if let Some(index) = index
522 && index.updated()?.is_some()
523 && let Some(total) =
524 index.children(&explorer.scope.join(rel).to_string_lossy(), 0)?
525 {
526 *from_index = true;
527 return Ok(Some(
528 json!({"size":total["size"],"alloc":total["alloc"],"files":total["files"]}),
529 ));
530 }
531 Ok(walk_total(&explorer.base.join(rel), budget))
532 }
533 let mut budget = 200000;
534 let mut sizes = serde_json::Map::new();
535 let mut from_index = false;
536 if let Some(total) = measure(
537 &top,
538 &explorer,
539 index.as_deref(),
540 &folders,
541 &mut sizes,
542 &mut budget,
543 &mut from_index,
544 )? {
545 sizes.insert(top, total);
546 }
547 let updated = if from_index {
548 index.as_ref().map(|i| i.updated()).transpose()?.flatten()
549 } else {
550 None
551 };
552 Ok(json!({"folders":folders,"sizes":sizes,"complete":complete,"updated":updated}))
553 })
554 .await?
555}
556
557async fn journal(app: &App) -> Result<Value> {
558 core::read_json(&app.data.join("file-ops.json"), json!([])).await
559}
560fn mine(explorer: &Explorer, op: &Value) -> bool {
561 array(&op["done"]).iter().all(|step| {
562 if step.is_array() {
563 array(step)
564 .iter()
565 .all(|p| explorer.local(string(p)).is_ok())
566 } else {
567 explorer.local(string(step)).is_ok()
568 }
569 })
570}
571async fn prune(ops: &Value) -> Result<()> {
572 let keep: HashSet<_> = array(ops)
573 .iter()
574 .filter(|op| op["kind"] == "delete" && op["undone"] == false)
575 .map(|op| string(&op["snapshot"]))
576 .collect();
577 let mounts = mounts().await?;
578 let datasets: HashSet<_> = array(&mounts)
579 .iter()
580 .filter(|m| Path::new(string(&m["target"])).starts_with(env("STUDIO_STORE_ROOT", "/srv")))
581 .map(|m| string(&m["source"]))
582 .collect();
583 if datasets.is_empty() {
584 return Ok(());
585 }
586 let listed = host::call(json!({"operation":"files.snapshots","datasets":datasets})).await?;
587 for snapshot in array(&listed).iter().map(string) {
588 if let Some((dataset, name)) = snapshot.split_once('@')
589 && !keep.contains(name)
590 {
591 host::call(json!({"operation":"files.discard","dataset":dataset,"snapshot":name}))
592 .await?;
593 }
594 }
595 Ok(())
596}
597
598pub async fn route(
599 app: Arc<App>,
600 media: bool,
601 method: &Method,
602 parts: &[&str],
603 query: &HashMap<String, String>,
604 body: Value,
605) -> Result<Response> {
606 let root = if media {
607 env(
608 "STUDIO_MEDIA_ROOT",
609 &format!("{}/clover/Media", env("STUDIO_STORE_ROOT", "/srv")),
610 )
611 } else {
612 env("STUDIO_STORE_ROOT", "/srv")
613 };
614 let explorer = Explorer::new(root.into()).await?;
615 if parts == ["refresh"] && media && method == Method::POST {
616 return refresh(app).await;
617 }
618 let value = match parts {
619 ["list"] if method == Method::GET => {
620 let rel = relative(
621 query.get("path").map(String::as_str).unwrap_or_default(),
622 false,
623 )?;
624 let key = format!("files:{}", explorer.root.join(&rel).display());
625 let listing = app
626 .cache
627 .get(key, Duration::from_secs(2), move || async move {
628 let served = explorer.root.join(&rel);
629 let entries = explorer.entries(&rel, &mounts().await?).await?;
630 Ok(json!({"host":served,"link":apps::file_link(&served.to_string_lossy(),false),"zip":apps::file_link(&served.to_string_lossy(),true),"entries":entries}))
631 })
632 .await?;
633 return Ok(listing.response());
634 }
635 ["tree"] if method == Method::POST => tree(app, &explorer, &body, &mounts().await?).await?,
636 ["peek"] if method == Method::GET => {
637 let rel = relative(
638 query.get("path").map(String::as_str).unwrap_or_default(),
639 true,
640 )?;
641 let abs = explorer.resolve(&rel).await?;
642 let _slot = app.heavy.acquire().await?;
643 tokio::task::spawn_blocking(move || {
644 let file = std::fs::File::open(abs).map_err(file_error)?;
645 let size = file.metadata()?.len();
646 let mut bytes = Vec::new();
647 file.take(65536).read_to_end(&mut bytes)?;
648 if bytes.contains(&0) {
649 return Err(Error::new(
650 415,
651 "This file isn't text. Open it in copyparty instead.",
652 ));
653 }
654 Ok::<_, Error>(
655 json!({"text":String::from_utf8_lossy(&bytes),"more":size>bytes.len() as u64}),
656 )
657 })
658 .await??
659 }
660 ["match"] if method == Method::GET => {
661 let folder = relative(
662 query.get("path").map(String::as_str).unwrap_or_default(),
663 false,
664 )?;
665 let pattern = query
666 .get("pattern")
667 .ok_or_else(|| Error::new(400, "Enter a pattern."))?;
668 let found = explorer.matches(&folder, pattern, app.clone()).await?;
669 let paths: Vec<_> = found.iter().map(|p| explorer.base.join(p)).collect();
670 let _slot = app.heavy.acquire().await?;
671 let size = tokio::task::spawn_blocking(move || {
672 let mut budget = 200000;
673 let mut size = Some(0.0);
674 for path in paths {
675 let total = walk_total(&path, &mut budget);
676 size = match (size, total) {
677 (Some(size), Some(total)) => Some(size + number(&total["size"])),
678 _ => None,
679 };
680 }
681 size
682 })
683 .await?;
684 let mut rows = serde_json::Map::new();
685 for rel in &found {
686 let inside = Path::new(rel)
687 .strip_prefix(&folder)
688 .unwrap_or(Path::new(rel));
689 rows.insert(inside.to_string_lossy().into_owned(), json!("self"));
690 for parent in inside
691 .ancestors()
692 .skip(1)
693 .filter(|p| !p.as_os_str().is_empty())
694 {
695 let key = parent.to_string_lossy().into_owned();
696 let count = rows.get(&key).map(number).unwrap_or(0.0) + 1.0;
697 rows.insert(key, json!(count));
698 }
699 }
700 json!({"count":found.len(),"size":size,"sample":found.iter().take(100).map(|p| Path::new(p).strip_prefix(&folder).unwrap_or(Path::new(p)).to_string_lossy().into_owned()).collect::<Vec<_>>(),"rows":rows})
701 }
702 ["ops"] if method == Method::GET => {
703 let ops = journal(&app).await?;
704 json!(array(&ops).iter().filter(|op| mine(&explorer,op)).map(|op| json!({"id":op["id"],"label":op["label"],"at":op["at"],"undone":op["undone"],"sample":array(&op["done"]).iter().take(3).map(|step| explorer.local(if step.is_array(){string(&step[0])}else{string(step)}).unwrap()).collect::<Vec<_>>(),"count":array(&op["done"]).len()})).collect::<Vec<_>>())
705 }
706 _ if method == Method::POST => {
707 let result = change(app.clone(), &explorer, parts, body).await;
708 app.cache.invalidate_prefix("files:");
709 return result;
710 }
711 _ => return Err(Error::new(404, "Not Found")),
712 };
713 Ok(Document::new(value).response())
714}
715
716async fn change(
717 app: Arc<App>,
718 explorer: &Explorer,
719 parts: &[&str],
720 body: Value,
721) -> Result<Response> {
722 if env("STUDIO_FILES_WRITABLE", "") != "1" {
723 return Err(Error::new(
724 501,
725 "The file browser can't change files yet. Use copyparty instead.",
726 ));
727 }
728 let _guard = app.file_changes.lock().await;
729 let mut ops = journal(&app).await?;
730 let mounts = mounts().await?;
731 if let ["ops", id, "undo"] = parts {
732 let position = array(&ops)
733 .iter()
734 .position(|op| op["id"] == *id && mine(explorer, op))
735 .ok_or_else(|| Error::new(404, "That change is too old to undo."))?;
736 let op = ops[position].clone();
737 if op["undone"] == true {
738 return Err(Error::new(409, "That change is already undone."));
739 }
740 revert(explorer, &op, &mounts).await?;
741 ops[position]["undone"] = json!(true);
742 core::write_json(&app.data.join("file-ops.json"), &ops).await?;
743 if let Err(e) = prune(&ops).await {
744 eprintln!("snapshot prune: {}", e.message);
745 }
746 if let Some(index) = &app.index {
747 index.changed();
748 }
749 return Ok(StatusCode::NO_CONTENT.into_response());
750 }
751 let mut pairs = Vec::new();
752 let mut paths = Vec::new();
753 let mut folder = None;
754 let label;
755 match parts {
756 ["folder"] => {
757 let parent = relative(string(&body["path"]), false)?;
758 let name = name(&body["name"])?;
759 explorer.folder(&parent).await?;
760 folder = Some(Path::new(&parent).join(name).to_string_lossy().into_owned());
761 label = format!("Created {name}");
762 }
763 ["rename"] => {
764 if !body["renames"].is_array()
765 || array(&body["renames"]).is_empty()
766 || array(&body["renames"]).len() > 10000
767 {
768 return Err(Error::new(400, "Choose names to change."));
769 }
770 for rename in array(&body["renames"]) {
771 let from = relative(string(&rename["path"]), true)?;
772 let name = name(&rename["name"])?;
773 let to = Path::new(&from)
774 .parent()
775 .unwrap()
776 .join(name)
777 .to_string_lossy()
778 .into_owned();
779 if from != to {
780 pairs.push((from, to));
781 }
782 }
783 if pairs.is_empty() {
784 return Err(Error::new(
785 400,
786 "Every name is unchanged. Edit a name, then rename.",
787 ));
788 }
789 label = if pairs.len() == 1 {
790 format!(
791 "Renamed {} to {}",
792 Path::new(&pairs[0].0)
793 .file_name()
794 .unwrap()
795 .to_string_lossy(),
796 Path::new(&pairs[0].1)
797 .file_name()
798 .unwrap()
799 .to_string_lossy()
800 )
801 } else {
802 format!("Renamed {}", item_count(pairs.len()))
803 };
804 }
805 ["move"] => {
806 let selected = selection(&body["paths"])?;
807 let to = relative(string(&body["to"]), false)?;
808 explorer.folder(&to).await?;
809 pairs = selected
810 .iter()
811 .map(|from| {
812 (
813 from.clone(),
814 Path::new(&to)
815 .join(Path::new(from).file_name().unwrap())
816 .to_string_lossy()
817 .into_owned(),
818 )
819 })
820 .collect();
821 label = format!(
822 "Moved {} to {}",
823 item_count(selected.len()),
824 explorer
825 .root
826 .join(&to)
827 .file_name()
828 .unwrap_or_default()
829 .to_string_lossy()
830 );
831 }
832 ["delete"] => {
833 paths = selection(&body["paths"])?;
834 label = if paths.len() == 1 {
835 format!(
836 "Deleted {}",
837 Path::new(&paths[0]).file_name().unwrap().to_string_lossy()
838 )
839 } else {
840 format!("Deleted {}", item_count(paths.len()))
841 };
842 }
843 ["bulk"] => {
844 let rel = relative(string(&body["path"]), false)?;
845 let pattern = string(&body["pattern"]);
846 let found = explorer.matches(&rel, pattern, app.clone()).await?;
847 let count = number(&body["count"]) as usize;
848 if count == 0 {
849 return Err(Error::new(400, "Choose at least one item."));
850 }
851 if found.len() != count {
852 return Err(Error::new(
853 409,
854 format!(
855 "The pattern matches {} now, not {count}. Go back and check the new matches.",
856 found.len()
857 ),
858 ));
859 }
860 if body["action"] == "delete" {
861 paths = found;
862 label = format!("Deleted {} matching {pattern}", item_count(count));
863 } else if body["action"] == "move" {
864 let to = body["to"]
865 .as_str()
866 .ok_or_else(|| Error::new(400, "Choose a folder to move into."))?;
867 let to = relative(to, false)?;
868 explorer.folder(&to).await?;
869 pairs = found
870 .iter()
871 .map(|from| {
872 (
873 from.clone(),
874 Path::new(&to)
875 .join(Path::new(from).strip_prefix(&rel).unwrap())
876 .to_string_lossy()
877 .into_owned(),
878 )
879 })
880 .collect();
881 label = format!(
882 "Moved {} matching {pattern} to {}",
883 item_count(count),
884 explorer
885 .root
886 .join(&to)
887 .file_name()
888 .unwrap_or_default()
889 .to_string_lossy()
890 );
891 } else {
892 return Err(Error::new(400, "Choose move or delete."));
893 }
894 }
895 _ => return Err(Error::new(404, "Not Found")),
896 }
897 if !pairs.is_empty() {
898 explorer.check_moves(&pairs, &mounts).await?;
899 }
900 let mut snapshot = None;
901 if !paths.is_empty() {
902 explorer.not_dataset(&paths, &mounts).await?;
903 let mut datasets = HashSet::new();
904 for rel in &paths {
905 let abs = explorer.resolve(rel).await?;
906 tokio::fs::symlink_metadata(&abs)
907 .await
908 .map_err(file_error)?;
909 let source = holder(&mounts, &abs).ok_or_else(|| {
910 Error::new(
911 409,
912 format!(
913 "{} isn't on a ZFS dataset, so deleting it couldn't be undone.",
914 abs.display()
915 ),
916 )
917 })?;
918 if datasets.insert(string(&source["source"]).to_owned()) {
919 drop(
920 tokio::fs::read_dir(Path::new(string(&source["target"])).join(".zfs/snapshot"))
921 .await
922 .map_err(|_| {
923 Error::new(
924 409,
925 format!(
926 "Can't delete {rel}. Its undo history isn't accessible here."
927 ),
928 )
929 })?,
930 );
931 }
932 }
933 let name = host::call(json!({"operation":"files.snapshot","datasets":datasets})).await?;
934 snapshot = Some(string(&name).to_owned());
935 }
936 let id = uuid::Uuid::new_v4().to_string();
937 let mut done = Vec::new();
938 let mut created = Vec::new();
939 let result = async {
940 if let Some(folder) = &folder {
941 tokio::fs::create_dir(explorer.resolve(folder).await?)
942 .await
943 .map_err(file_error)?;
944 done.push(json!(explorer.store(folder)));
945 }
946 for (from, to) in &pairs {
947 if let Some(made) = explorer.move_one(from, to).await? {
948 created.push(made);
949 }
950 done.push(json!([explorer.store(from), explorer.store(to)]));
951 }
952 for rel in &paths {
953 let abs = explorer.resolve(rel).await?;
954 let info = tokio::fs::symlink_metadata(&abs)
955 .await
956 .map_err(file_error)?;
957 if info.is_dir() {
958 tokio::fs::remove_dir_all(abs).await.map_err(file_error)?;
959 } else {
960 tokio::fs::remove_file(abs).await.map_err(file_error)?;
961 }
962 done.push(json!(explorer.store(rel)));
963 }
964 Ok::<_, Error>(())
965 }
966 .await;
967 if !done.is_empty() {
968 let mut op = json!({"id":id,"label":label,"at":now().floor(),"undone":false,"done":done,"kind":if folder.is_some(){"folder"}else if snapshot.is_some(){"delete"}else{"move"}});
969 if let Some(snapshot) = snapshot {
970 op["snapshot"] = json!(snapshot);
971 } else if folder.is_none() {
972 op["created"] = json!(created);
973 }
974 ops.as_array_mut().unwrap().insert(0, op);
975 ops.as_array_mut().unwrap().truncate(20);
976 core::write_json(&app.data.join("file-ops.json"), &ops).await?;
977 }
978 if let Err(e) = prune(&ops).await {
979 eprintln!("snapshot prune: {}", e.message);
980 }
981 if let Some(index) = &app.index {
982 index.changed();
983 }
984 result?;
985 Ok(Document::new(json!({"id":id,"label":label})).response())
986}
987async fn revert(explorer: &Explorer, op: &Value, mounts: &Value) -> Result<()> {
988 let paths = array(&op["done"]);
989 match string(&op["kind"]) {
990 "folder" => {
991 let rel = explorer.local(string(&paths[0]))?;
992 let abs = explorer.resolve(&rel).await?;
993 let mut dir = tokio::fs::read_dir(&abs).await?;
994 if dir.next_entry().await?.is_some() {
995 return Err(Error::new(
996 409,
997 format!("{rel} has something in it now. Empty it, then undo."),
998 ));
999 }
1000 tokio::fs::remove_dir(abs).await?;
1001 }
1002 "delete" => {
1003 let mut copies = Vec::new();
1004 for path in paths {
1005 let rel = explorer.local(string(path))?;
1006 let abs = explorer.resolve(&rel).await?;
1007 if tokio::fs::try_exists(&abs).await? {
1008 return Err(Error::new(
1009 409,
1010 format!("{rel} exists again. Move or rename it, then undo."),
1011 ));
1012 }
1013 let target = holder(mounts, &abs)
1014 .map(|m| PathBuf::from(string(&m["target"])))
1015 .unwrap_or(abs.clone());
1016 let saved = target
1017 .join(".zfs/snapshot")
1018 .join(string(&op["snapshot"]))
1019 .join(abs.strip_prefix(&target).unwrap());
1020 if !tokio::fs::try_exists(&saved).await? {
1021 return Err(Error::new(
1022 409,
1023 format!("Can't put {} back. Its snapshot is gone.", abs.display()),
1024 ));
1025 }
1026 copies.push((abs, saved));
1027 }
1028 for (abs, saved) in copies {
1029 tokio::fs::create_dir_all(abs.parent().unwrap()).await?;
1030 command(
1031 "cp",
1032 &[
1033 "-a",
1034 "--reflink=auto",
1035 &saved.to_string_lossy(),
1036 &abs.to_string_lossy(),
1037 ],
1038 None,
1039 )
1040 .await?;
1041 }
1042 }
1043 "move" => {
1044 let pairs = paths
1045 .iter()
1046 .rev()
1047 .map(|p| {
1048 Ok((
1049 explorer.local(string(&p[1]))?,
1050 explorer.local(string(&p[0]))?,
1051 ))
1052 })
1053 .collect::<Result<Vec<_>>>()?;
1054 explorer.check_moves(&pairs, mounts).await?;
1055 for (from, to) in pairs {
1056 explorer.move_one(&from, &to).await?;
1057 }
1058 for rel in array(&op["created"]).iter().rev() {
1059 remove_empty(explorer.resolve(&explorer.local(string(rel))?).await?).await?;
1060 }
1061 }
1062 _ => return Err(Error::new(409, "That change can't be undone.")),
1063 }
1064 Ok(())
1065}
1066async fn refresh(app: Arc<App>) -> Result<Response> {
1067 let base = std::env::var("STUDIO_JELLYFIN_URL")
1068 .map_err(|_| Error::new(501, "Jellyfin isn't connected to the dashboard."))?;
1069 let password = host::call(
1070 json!({"operation":"deploy.secret.get","service":"jellyfin","key":"admin_password"}),
1071 )
1072 .await?;
1073 let password = password
1074 .as_str()
1075 .ok_or_else(|| Error::new(409, "Jellyfin has no generated admin password."))?;
1076 let response = app
1077 .http
1078 .post(format!("{base}/Users/AuthenticateByName"))
1079 .header(
1080 "Authorization",
1081 "MediaBrowser Client=\"home server\", Device=\"server\", DeviceId=\"studio\", Version=\"1\"",
1082 )
1083 .json(&json!({"Username":"snow","Pw":password}))
1084 .send()
1085 .await?;
1086 if !response.status().is_success() {
1087 return Err(Error::new(
1088 502,
1089 format!("Jellyfin sign-in failed ({}).", response.status()),
1090 ));
1091 }
1092 let value: Value = response.json().await?;
1093 let token = value["AccessToken"]
1094 .as_str()
1095 .ok_or_else(|| Error::new(502, "Jellyfin didn't return an access token."))?;
1096 let response = app
1097 .http
1098 .post(format!("{base}/Library/Refresh"))
1099 .header("X-Emby-Token", token)
1100 .send()
1101 .await?;
1102 if !response.status().is_success() {
1103 return Err(Error::new(
1104 502,
1105 format!(
1106 "Jellyfin couldn't start a library scan ({}).",
1107 response.status()
1108 ),
1109 ));
1110 }
1111 Ok(StatusCode::ACCEPTED.into_response())
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117 #[test]
1118 fn selection_keeps_order_and_collapses_descendants() {
1119 assert_eq!(
1120 selection(&json!(["b/x", "a", "b", "a", "c/y"])).unwrap(),
1121 vec!["a", "b", "c/y"]
1122 );
1123 assert!(selection(&json!([""])).is_err());
1124 assert!(relative("/absolute", true).is_err());
1125 assert!(relative("a\0b", true).is_err());
1126 assert_eq!(relative("a/../b", true).unwrap(), "b");
1127 }
1128 #[tokio::test]
1129 async fn existing_and_missing_paths_cannot_escape_through_symlinks() {
1130 let dir = std::env::temp_dir().join(format!("snowglobe-files-{}", uuid::Uuid::new_v4()));
1131 let root = dir.join("root");
1132 let outside = dir.join("outside");
1133 std::fs::create_dir_all(&root).unwrap();
1134 std::fs::create_dir_all(&outside).unwrap();
1135 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
1136 let root = std::fs::canonicalize(root).unwrap();
1137 let explorer = Explorer {
1138 root: root.clone(),
1139 base: root,
1140 scope: PathBuf::new(),
1141 };
1142 assert_eq!(
1143 explorer.resolve("../outside").await.unwrap_err().status,
1144 403
1145 );
1146 assert_eq!(
1147 explorer
1148 .resolve("escape/new/file")
1149 .await
1150 .unwrap_err()
1151 .status,
1152 403
1153 );
1154 assert!(explorer.resolve("new/file").await.is_ok());
1155 std::fs::remove_dir_all(dir).unwrap();
1156 }
1157}
dashboard/src/host.rs created+74
......@@ -0,0 +1,74 @@
1use crate::*;
2use tokio::io::{AsyncReadExt, AsyncWriteExt};
3
4static SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
5
6pub async fn sample(app: Arc<App>) -> Result<Arc<Document>> {
7 app.cache
8 .get("host-sample".into(), Duration::from_secs(1), || async {
9 call(json!({"operation":"host.sample"})).await
10 })
11 .await
12}
13
14pub async fn call(request: Value) -> Result<Value> {
15 tokio::time::timeout(Duration::from_secs(70), async {
16 let _slot = SLOTS.acquire().await?;
17 let mut socket = tokio::net::UnixStream::connect(env(
18 "STUDIO_HOST_SOCKET",
19 "/run/studio-host/host.sock",
20 ))
21 .await?;
22 #[cfg(target_os = "linux")]
23 if socket.peer_cred()?.uid() != 0 {
24 return Err(Error::new(
25 502,
26 "The host service identity couldn't be verified.",
27 ));
28 }
29 let mut message = serde_json::to_vec(&request)?;
30 message.push(b'\n');
31 if message.len() > 65536 {
32 return Err(Error::new(
33 400,
34 "The host request is too large. Narrow the selection.",
35 ));
36 }
37 socket.write_all(&message).await?;
38 let length = socket.read_u32().await? as usize;
39 if length > 16 * 1024 * 1024 {
40 return Err(Error::new(
41 502,
42 "The host response is too large. Narrow the selection.",
43 ));
44 }
45 let mut bytes = vec![0; length];
46 socket.read_exact(&mut bytes).await?;
47 let mut response: Value = serde_json::from_slice(&bytes)?;
48 if let Some(message) = response["error"].as_str() {
49 return Err(Error::new(
50 response["status"]
51 .as_u64()
52 .filter(|s| (400..=599).contains(s))
53 .unwrap_or(502) as u16,
54 message,
55 ));
56 }
57 response
58 .as_object_mut()
59 .and_then(|v| v.remove("value"))
60 .ok_or_else(|| {
61 Error::new(
62 502,
63 "The host response is incomplete. Check its logs, then retry.",
64 )
65 })
66 })
67 .await
68 .map_err(|_| {
69 Error::new(
70 504,
71 "The host operation is taking too long. Check its logs, then retry.",
72 )
73 })?
74}
dashboard/src/index.rs created+1026
......@@ -0,0 +1,1026 @@
1use crate::*;
2use rusqlite::{Connection, OptionalExtension, params};
3use sha1::{Digest, Sha1};
4use std::{
5 collections::{BTreeSet, HashSet},
6 os::unix::fs::MetadataExt,
7 sync::atomic::{AtomicBool, Ordering},
8};
9
10const TABLES: &str = "CREATE TABLE IF NOT EXISTS file (id INTEGER PRIMARY KEY,parent INTEGER REFERENCES file ON DELETE CASCADE,name TEXT NOT NULL,dir INTEGER NOT NULL,size INTEGER NOT NULL,alloc INTEGER NOT NULL,files INTEGER NOT NULL,mtime INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS meta (snapshot TEXT,scanned INTEGER NOT NULL,updated INTEGER NOT NULL);";
11const INDEXES: &str = "CREATE UNIQUE INDEX IF NOT EXISTS file_child ON file (parent,name); CREATE INDEX IF NOT EXISTS file_parent_size ON file (parent,size); CREATE INDEX IF NOT EXISTS file_size ON file (size) WHERE NOT dir;";
12
13pub struct Index {
14 pool: String,
15 dir: PathBuf,
16 active: Mutex<BTreeSet<String>>,
17 scanning: AtomicBool,
18 wake: tokio::sync::Notify,
19}
20impl Index {
21 pub fn new(pool: String, dir: PathBuf) -> Self {
22 Self {
23 pool,
24 dir,
25 active: Mutex::new(BTreeSet::new()),
26 scanning: AtomicBool::new(false),
27 wake: tokio::sync::Notify::new(),
28 }
29 }
30 fn file(&self, dataset: &str) -> PathBuf {
31 self.dir.join(format!("{}.db", encoded(dataset)))
32 }
33 fn db(&self, dataset: &str) -> Result<Option<Connection>> {
34 if !self.active.lock().unwrap().contains(dataset) {
35 return Ok(None);
36 }
37 let file = self.file(dataset);
38 if !file.exists() {
39 return Ok(None);
40 }
41 let db = Connection::open_with_flags(file, rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE)?;
42 if meta(&db)?.is_none() {
43 return Ok(None);
44 }
45 Ok(Some(db))
46 }
47 fn names(&self) -> BTreeSet<String> {
48 let mut names = BTreeSet::new();
49 for name in self.active.lock().unwrap().iter() {
50 let parts: Vec<_> = name.split('/').collect();
51 for i in 1..=parts.len() {
52 names.insert(parts[..i].join("/"));
53 }
54 }
55 names
56 }
57 fn owner(&self, rel: &str) -> Option<(String, String)> {
58 let names = self.names();
59 let full = if rel.is_empty() {
60 self.pool.clone()
61 } else {
62 format!("{}/{rel}", self.pool)
63 };
64 let parts: Vec<_> = full.split('/').collect();
65 for i in (1..=parts.len()).rev() {
66 let dataset = parts[..i].join("/");
67 if names.contains(&dataset) {
68 return Some((dataset, parts[i..].join("/")));
69 }
70 }
71 None
72 }
73 fn subs(&self, dataset: &str) -> Vec<String> {
74 self.names()
75 .into_iter()
76 .filter_map(|name| {
77 let (parent, sub) = name.rsplit_once('/')?;
78 (parent == dataset).then(|| sub.to_owned())
79 })
80 .collect()
81 }
82 pub fn children(&self, rel: &str, limit: usize) -> Result<Option<Value>> {
83 let Some((dataset, inner)) = self.owner(rel) else {
84 return Ok(None);
85 };
86 let db = self.db(&dataset)?;
87 let subs = if inner.is_empty() {
88 self.subs(&dataset)
89 } else {
90 Vec::new()
91 };
92 let own = match db.as_ref() {
93 Some(db) => children(db, &inner, limit + subs.len())?,
94 None if inner.is_empty() => {
95 Some(json!({"size":0,"alloc":0,"files":0,"mtime":0,"count":0,"entries":[]}))
96 }
97 _ => None,
98 };
99 let Some(mut own) = own else {
100 return Ok(None);
101 };
102 let mut entries: Vec<_> = array(&own["entries"])
103 .iter()
104 .filter(|e| !subs.iter().any(|s| e["name"] == *s))
105 .cloned()
106 .collect();
107 for sub in subs {
108 let next = if rel.is_empty() {
109 sub.clone()
110 } else {
111 format!("{rel}/{sub}")
112 };
113 let Some(total) = self.children(&next, 0)? else {
114 continue;
115 };
116 let shadow = if let Some(db) = &db {
117 self::children(db, &sub, 0)?.unwrap_or(json!({}))
118 } else {
119 json!({})
120 };
121 for key in ["size", "alloc", "files"] {
122 own[key] = json!(number(&own[key]) - number(&shadow[key]) + number(&total[key]));
123 }
124 own["mtime"] = json!(number(&own["mtime"]).max(number(&total["mtime"])));
125 own["count"] = json!(
126 number(&own["count"]) + 1.0 - if shadow["size"].is_null() { 0.0 } else { 1.0 }
127 );
128 entries.push(json!({"name":sub,"dir":1,"size":total["size"],"alloc":total["alloc"],"files":total["files"],"mtime":total["mtime"]}));
129 }
130 entries.sort_by(|a, b| {
131 number(&b["size"])
132 .total_cmp(&number(&a["size"]))
133 .then_with(|| string(&a["name"]).cmp(string(&b["name"])))
134 });
135 entries.truncate(limit);
136 own["entries"] = json!(entries);
137 Ok(Some(own))
138 }
139 pub fn map(&self, rel: &str, min: f64) -> Result<Option<Value>> {
140 let Some((dataset, inner)) = self.owner(rel) else {
141 return Ok(None);
142 };
143 let db = self.db(&dataset)?;
144 let own = match db.as_ref() {
145 Some(db) => map(db, &inner, min)?,
146 None if inner.is_empty() => Some(json!(["", 0, 0, []])),
147 _ => None,
148 };
149 let Some(mut own) = own else {
150 return Ok(None);
151 };
152 if !inner.is_empty() {
153 return Ok(Some(own));
154 }
155 let subs = self.subs(&dataset);
156 let mut children: Vec<_> = array(&own[3])
157 .iter()
158 .filter(|n| !subs.iter().any(|s| n[0] == *s))
159 .cloned()
160 .collect();
161 for sub in subs {
162 let next = if rel.is_empty() {
163 sub.clone()
164 } else {
165 format!("{rel}/{sub}")
166 };
167 let Some(tree) = self.map(&next, min)? else {
168 continue;
169 };
170 let shadow = if let Some(db) = &db {
171 self::children(db, &sub, 0)?.unwrap_or(json!({}))
172 } else {
173 json!({})
174 };
175 own[1] = json!(number(&own[1]) + number(&tree[1]) - number(&shadow["size"]));
176 own[2] = json!(number(&own[2]) + number(&tree[2]) - number(&shadow["files"]));
177 if number(&tree[1]) >= min {
178 children.push(json!([sub, tree[1], tree[2], tree[3]]));
179 }
180 }
181 own[3] = json!(children);
182 Ok(Some(own))
183 }
184 pub fn largest(&self, limit: usize) -> Result<Value> {
185 let mut values = Vec::new();
186 let datasets = self.active.lock().unwrap().clone();
187 for dataset in datasets {
188 let Some(db) = self.db(&dataset)? else {
189 continue;
190 };
191 let mut query=db.prepare("SELECT (WITH RECURSIVE up(id,name,parent,depth) AS (SELECT id,name,parent,0 FROM file AS f WHERE f.id=top.id UNION ALL SELECT file.id,file.name,file.parent,depth+1 FROM file JOIN up ON file.id=up.parent WHERE file.parent IS NOT NULL) SELECT group_concat(name,'/' ORDER BY depth DESC) FROM up) AS path,size,alloc,mtime FROM file AS top WHERE NOT dir ORDER BY size DESC LIMIT ?")?;
192 let rows=query.query_map([limit],|r| Ok(json!({"path":r.get::<_,String>(0)?,"size":r.get::<_,i64>(1)?,"alloc":r.get::<_,i64>(2)?,"mtime":r.get::<_,i64>(3)?})))?;
193 let prefix = dataset
194 .strip_prefix(&format!("{}/", self.pool))
195 .map(|p| format!("{p}/"))
196 .unwrap_or_default();
197 for row in rows {
198 let mut row = row?;
199 row["path"] = json!(format!("{prefix}{}", string(&row["path"])));
200 values.push(row);
201 }
202 }
203 values.sort_by(|a, b| number(&b["size"]).total_cmp(&number(&a["size"])));
204 values.truncate(limit);
205 Ok(json!(values))
206 }
207 pub fn updated(&self) -> Result<Option<f64>> {
208 let mut values = Vec::new();
209 let datasets = self.active.lock().unwrap().clone();
210 for dataset in datasets {
211 let Some(db) = self.db(&dataset)? else {
212 return Ok(None);
213 };
214 let Some(meta) = meta(&db)? else {
215 return Ok(None);
216 };
217 values.push(number(&meta["updated"]));
218 }
219 Ok(values.into_iter().min_by(f64::total_cmp))
220 }
221 pub async fn start(self: Arc<Self>, app: Arc<App>) {
222 let mut failures = HashMap::<String, u32>::new();
223 loop {
224 let result = async {
225 tokio::fs::create_dir_all(&self.dir).await?;
226 let (listed, mounted) = tokio::try_join!(
227 crate::host::call(json!({"operation":"storage.datasets"})),
228 crate::host::call(json!({"operation":"storage.mounts"}))
229 )?;
230 let origins: HashMap<_, _> = array(&listed)
231 .iter()
232 .filter(|d| {
233 string(&d["name"]) == self.pool
234 || string(&d["name"]).starts_with(&format!("{}/", self.pool))
235 })
236 .map(|d| (string(&d["name"]), &d["origin"]))
237 .collect();
238 let visible =
239 mounted_datasets(&tokio::fs::read_to_string("/proc/self/mountinfo").await?);
240 let mounts: Vec<_> = array(&mounted["filesystems"])
241 .iter()
242 .filter(|m| {
243 origins
244 .get(string(&m["source"]))
245 .is_some_and(|v| v.is_null())
246 && !string(&m["source"]).starts_with(&format!("{}/staging/", self.pool))
247 && visible.contains(&(
248 string(&m["source"]).to_owned(),
249 string(&m["target"]).to_owned(),
250 ))
251 })
252 .map(|m| {
253 (
254 string(&m["source"]).to_owned(),
255 string(&m["target"]).to_owned(),
256 )
257 })
258 .collect();
259 *self.active.lock().unwrap() = mounts.iter().map(|(d, _)| d.clone()).collect();
260 self.scanning.store(true, Ordering::Relaxed);
261 for (dataset, mount) in mounts {
262 let result = self
263 .round(
264 app.clone(),
265 &dataset,
266 &mount,
267 *failures.get(&dataset).unwrap_or(&0),
268 )
269 .await;
270 if let Err(e) = result {
271 eprintln!("index {dataset}: {}", e.message);
272 *failures.entry(dataset).or_default() += 1;
273 } else {
274 failures.remove(&dataset);
275 }
276 }
277 let mut files = tokio::fs::read_dir(&self.dir).await?;
278 while let Some(file) = files.next_entry().await? {
279 if let Some(name) = file
280 .file_name()
281 .to_str()
282 .and_then(|s| s.strip_suffix(".db"))
283 {
284 let name = url::form_urlencoded::parse(format!("x={name}").as_bytes())
285 .next()
286 .map(|(_, v)| v.into_owned())
287 .unwrap_or_default();
288 if !origins.contains_key(name.as_str())
289 || name.starts_with(&format!("{}/staging/", self.pool))
290 {
291 tokio::fs::remove_file(file.path()).await?;
292 }
293 }
294 }
295 Ok::<_, Error>(())
296 }
297 .await;
298 self.scanning.store(false, Ordering::Relaxed);
299 if let Err(e) = result {
300 eprintln!("file index: {}", e.message);
301 }
302 tokio::select! {
303 _ = tokio::time::sleep(Duration::from_secs(600)) => (),
304 _ = self.wake.notified() => (),
305 }
306 }
307 }
308 async fn round(&self, app: Arc<App>, dataset: &str, mount: &str, failures: u32) -> Result<()> {
309 let snapshot: String = serde_json::from_value(
310 crate::host::call(json!({"operation":"index.snapshot","dataset":dataset})).await?,
311 )?;
312 let existing: Vec<String> = serde_json::from_value(
313 crate::host::call(json!({"operation":"index.snapshots","dataset":dataset})).await?,
314 )?;
315 let file = self.file(dataset);
316 let old = if let Some(db) = self.db(dataset)? {
317 meta(&db)?
318 } else {
319 None
320 };
321 let offset = u32::from_be_bytes(Sha1::digest(dataset.as_bytes())[..4].try_into().unwrap())
322 as f64
323 % (7.0 * 86400.0);
324 let incremental = old.as_ref().is_some_and(|m| {
325 m["snapshot"].is_string()
326 && existing.iter().any(|s| s == string(&m["snapshot"]))
327 && failures < 3
328 && ((now() - offset) / (7.0 * 86400.0)).floor()
329 == ((number(&m["scanned"]) - offset) / (7.0 * 86400.0)).floor()
330 });
331 let changes = if incremental {
332 let previous = string(&old.as_ref().unwrap()["snapshot"]);
333 let diff = crate::host::call(json!({"operation":"index.diff","dataset":dataset,
334 "from":previous.split_once('@').map(|(_, name)| name).unwrap_or_default(),
335 "to":snapshot.split_once('@').map(|(_, name)| name).unwrap_or_default()}))
336 .await?;
337 Some(parse_diff(string(&diff), mount)?)
338 } else {
339 None
340 };
341 let _slot = app.heavy.acquire().await?;
342 let frozen = PathBuf::from(mount)
343 .join(".zfs/snapshot")
344 .join(snapshot.split_once('@').unwrap().1);
345 let current = snapshot.clone();
346 tokio::task::spawn_blocking(move || {
347 if let Some(changes) = changes {
348 apply(&file, &frozen, &snapshot, &changes)
349 } else {
350 scan(&file, &frozen, Some(&snapshot))
351 }
352 })
353 .await??;
354 for previous in existing.iter().filter(|s| **s != current) {
355 let name = previous.split_once('@').unwrap().1;
356 if let Err(error) = crate::host::call(
357 json!({"operation":"index.discard","dataset":dataset,"snapshot":name}),
358 )
359 .await
360 {
361 eprintln!("index snapshot {previous}: {}", error.message);
362 }
363 }
364 Ok(())
365 }
366 pub fn changed(&self) {
367 self.wake.notify_one();
368 }
369 pub fn is_scanning(&self) -> bool {
370 self.scanning.load(Ordering::Relaxed)
371 }
372}
373fn mounted_datasets(text: &str) -> HashSet<(String, String)> {
374 fn unescape(value: &str) -> String {
375 value
376 .replace("\\040", " ")
377 .replace("\\011", "\t")
378 .replace("\\012", "\n")
379 .replace("\\134", "\\")
380 }
381 text.lines()
382 .filter_map(|line| {
383 let (mount, filesystem) = line.split_once(" - ")?;
384 let mut fields = mount.split_whitespace();
385 if fields.nth(3)? != "/" {
386 return None;
387 }
388 let target = fields.next()?;
389 let mut fields = filesystem.split_whitespace();
390 if fields.next()? != "zfs" {
391 return None;
392 }
393 let source = fields.next()?;
394 (!source.contains('@')).then(|| (unescape(source), unescape(target)))
395 })
396 .collect()
397}
398fn resolve(db: &Connection, rel: &str) -> Result<Option<Vec<i64>>> {
399 let mut chain = vec![1];
400 for name in rel.split('/').filter(|s| !s.is_empty()) {
401 let id = db
402 .query_row(
403 "SELECT id FROM file WHERE parent=? AND name=?",
404 params![chain.last().unwrap(), name],
405 |r| r.get::<_, i64>(0),
406 )
407 .optional()?;
408 let Some(id) = id else {
409 return Ok(None);
410 };
411 chain.push(id);
412 }
413 Ok(Some(chain))
414}
415fn meta(db: &Connection) -> Result<Option<Value>> {
416 Ok(db.query_row("SELECT snapshot,scanned,updated FROM meta",[],|r| Ok(json!({"snapshot":r.get::<_,Option<String>>(0)?,"scanned":r.get::<_,i64>(1)?,"updated":r.get::<_,i64>(2)?}))).optional()?)
417}
418fn children(db: &Connection, rel: &str, limit: usize) -> Result<Option<Value>> {
419 let Some(chain) = resolve(db, rel)? else {
420 return Ok(None);
421 };
422 let id = chain.last().unwrap();
423 let mut value=db.query_row("SELECT size,alloc,files,mtime,(SELECT count(*) FROM file WHERE parent=?) FROM file WHERE id=?",params![id,id],|r| Ok(json!({"size":r.get::<_,i64>(0)?,"alloc":r.get::<_,i64>(1)?,"files":r.get::<_,i64>(2)?,"mtime":r.get::<_,i64>(3)?,"count":r.get::<_,i64>(4)?})))?;
424 let mut query=db.prepare("SELECT name,dir,size,alloc,files,mtime FROM file WHERE parent=? ORDER BY size DESC,name LIMIT ?")?;
425 let rows=query.query_map(params![id,limit],|r| Ok(json!({"name":r.get::<_,String>(0)?,"dir":r.get::<_,i64>(1)?,"size":r.get::<_,i64>(2)?,"alloc":r.get::<_,i64>(3)?,"files":r.get::<_,i64>(4)?,"mtime":r.get::<_,i64>(5)?})))?.collect::<std::result::Result<Vec<_>,_>>()?;
426 value["entries"] = json!(rows);
427 Ok(Some(value))
428}
429fn map(db: &Connection, rel: &str, min: f64) -> Result<Option<Value>> {
430 let Some(chain) = resolve(db, rel)? else {
431 return Ok(None);
432 };
433 let id = *chain.last().unwrap();
434 let mut query=db.prepare("WITH RECURSIVE tree(id,parent,name,dir,size,files) AS (SELECT id,parent,name,dir,size,files FROM file WHERE id=? UNION ALL SELECT file.id,file.parent,file.name,file.dir,file.size,file.files FROM file JOIN tree ON file.parent=tree.id WHERE tree.dir AND file.size>=?) SELECT * FROM tree")?;
435 let rows = query
436 .query_map(params![id, min], |r| {
437 Ok((
438 r.get::<_, i64>(0)?,
439 r.get::<_, Option<i64>>(1)?,
440 r.get::<_, String>(2)?,
441 r.get::<_, bool>(3)?,
442 r.get::<_, i64>(4)?,
443 r.get::<_, i64>(5)?,
444 ))
445 })?
446 .collect::<std::result::Result<Vec<_>, _>>()?;
447 let nodes: HashMap<_, _> = rows
448 .iter()
449 .map(|(id, _, name, dir, size, files)| {
450 (
451 *id,
452 if *dir {
453 json!([name, size, files, []])
454 } else {
455 json!([name, size])
456 },
457 )
458 })
459 .collect();
460 let mut children = HashMap::<i64, Vec<i64>>::new();
461 for (id, parent, ..) in rows {
462 if let Some(parent) = parent {
463 children.entry(parent).or_default().push(id);
464 }
465 }
466 fn assemble(id: i64, nodes: &HashMap<i64, Value>, children: &HashMap<i64, Vec<i64>>) -> Value {
467 let mut value = nodes[&id].clone();
468 if value.as_array().unwrap().len() == 4 {
469 value[3] = json!(
470 children
471 .get(&id)
472 .into_iter()
473 .flatten()
474 .map(|id| assemble(*id, nodes, children))
475 .collect::<Vec<_>>()
476 );
477 }
478 value
479 }
480 Ok(Some(assemble(id, &nodes, &children)))
481}
482fn scan(file: &std::path::Path, root: &std::path::Path, snapshot: Option<&str>) -> Result<()> {
483 let next = file.with_extension("db.new");
484 let _ = std::fs::remove_file(&next);
485 let db = Connection::open(&next)?;
486 db.execute_batch(&format!(
487 "PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF;{TABLES} BEGIN;"
488 ))?;
489 let handle = std::fs::File::open(root)?;
490 let top = handle.metadata()?;
491 db.execute(
492 "INSERT INTO file VALUES(1,NULL,'',1,0,0,0,?)",
493 [top.mtime()],
494 )?;
495 fn walk(
496 db: &Connection,
497 id: i64,
498 root: &std::path::Path,
499 device: u64,
500 ) -> Result<(u64, u64, u64)> {
501 let mut sum = (0, 0, 0);
502 for entry in std::fs::read_dir(root)? {
503 let entry = entry?;
504 let info = std::fs::symlink_metadata(entry.path())?;
505 if info.is_dir() && info.dev() != device {
506 continue;
507 }
508 db.execute(
509 "INSERT INTO file(parent,name,dir,size,alloc,files,mtime) VALUES(?,?,?,?,?,?,?)",
510 params![
511 id,
512 entry.file_name().to_string_lossy(),
513 info.is_dir(),
514 if info.is_dir() { 0 } else { info.len() },
515 if info.is_dir() {
516 0
517 } else {
518 info.blocks() * 512
519 },
520 if info.is_dir() { 0 } else { 1 },
521 info.mtime()
522 ],
523 )?;
524 let child = db.last_insert_rowid();
525 let (size, alloc, files) = if info.is_dir() {
526 walk(db, child, &entry.path(), device)?
527 } else {
528 (info.len(), info.blocks() * 512, 1)
529 };
530 if info.is_dir() {
531 db.execute(
532 "UPDATE file SET size=?,alloc=?,files=? WHERE id=?",
533 params![size, alloc, files, child],
534 )?;
535 }
536 sum.0 += size;
537 sum.1 += alloc;
538 sum.2 += files;
539 }
540 Ok(sum)
541 }
542 let (size, alloc, files) = walk(&db, 1, root, top.dev())?;
543 db.execute(
544 "UPDATE file SET size=?,alloc=?,files=? WHERE id=1",
545 params![size, alloc, files],
546 )?;
547 db.execute(
548 "INSERT INTO meta VALUES(?,?,?)",
549 params![snapshot, now() as u64, now() as u64],
550 )?;
551 db.execute_batch(&format!("{INDEXES} COMMIT;"))?;
552 db.close().map_err(|(_, e)| Error::from(e))?;
553 std::fs::rename(next, file)?;
554 Ok(())
555}
556#[derive(Debug)]
557struct Change {
558 kind: char,
559 path: String,
560 to: Option<String>,
561}
562fn parse_diff(text: &str, mount: &str) -> Result<Vec<Change>> {
563 let mut changes = Vec::new();
564 let mount = std::path::Path::new(mount);
565 for line in text.lines().filter(|s| !s.is_empty()) {
566 let fields: Vec<_> = line.split('\t').collect();
567 if fields.len() < 3 || !["+", "-", "M", "R"].contains(&fields[0]) {
568 return Err(Error::new(500, format!("unexpected zfs diff line: {line}")));
569 }
570 let relative = |s: &str| {
571 let path = crate::storage::unescape(s);
572 std::path::Path::new(&path)
573 .strip_prefix(mount)
574 .map(|p| p.to_string_lossy().into_owned())
575 .map_err(|_| {
576 Error::new(
577 500,
578 format!("zfs diff path outside {}: {path}", mount.display()),
579 )
580 })
581 };
582 let to = if fields[0] == "R" {
583 Some(relative(
584 fields
585 .get(3)
586 .ok_or_else(|| Error::new(500, "Invalid rename."))?,
587 )?)
588 } else {
589 None
590 };
591 changes.push(Change {
592 kind: fields[0].chars().next().unwrap(),
593 path: relative(fields[2])?,
594 to,
595 });
596 }
597 Ok(changes)
598}
599fn apply(
600 file: &std::path::Path,
601 root: &std::path::Path,
602 snapshot: &str,
603 changes: &[Change],
604) -> Result<()> {
605 let db = Connection::open(file)?;
606 db.execute_batch("PRAGMA foreign_keys=ON;")?;
607 let targets: HashSet<String> = changes
608 .iter()
609 .filter(|c| c.kind != '-')
610 .map(|c| c.to.as_ref().unwrap_or(&c.path).clone())
611 .collect();
612 let mut listings = HashMap::<String, Option<Vec<std::fs::DirEntry>>>::new();
613 let mut wanted = HashSet::new();
614 for target in &targets {
615 let entries = match std::fs::read_dir(root.join(target)) {
616 Ok(dir) => Some(dir.collect::<std::io::Result<Vec<_>>>()?),
617 Err(error)
618 if matches!(
619 error.kind(),
620 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
621 ) =>
622 {
623 None
624 }
625 Err(error) => return Err(error.into()),
626 };
627 if let Some(entries) = &entries {
628 for entry in entries {
629 if !entry.file_type()?.is_dir() {
630 wanted.insert(
631 format!("{}/{}", target, entry.file_name().to_string_lossy())
632 .trim_start_matches('/')
633 .to_owned(),
634 );
635 }
636 }
637 }
638 listings.insert(target.clone(), entries);
639 let mut path = std::path::Path::new(target);
640 while !path.as_os_str().is_empty() {
641 wanted.insert(path.to_string_lossy().into_owned());
642 path = path.parent().unwrap_or(std::path::Path::new(""));
643 }
644 }
645 let stats: HashMap<_, _> = wanted
646 .into_iter()
647 .map(|p| {
648 let info = match std::fs::symlink_metadata(root.join(&p)) {
649 Ok(info) => Some(info),
650 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
651 Err(error) => return Err(Error::from(error)),
652 };
653 Ok((p, info))
654 })
655 .collect::<Result<_>>()?;
656 let mut dirty = HashSet::<i64>::new();
657 let moves: Vec<_> = changes
658 .iter()
659 .filter(|c| c.kind == 'R')
660 .map(|c| Ok((resolve(&db, &c.path)?, c.to.as_ref().unwrap().clone())))
661 .collect::<Result<_>>()?;
662 let removed: Vec<_> = changes
663 .iter()
664 .filter(|c| c.kind == '-')
665 .map(|c| resolve(&db, &c.path))
666 .collect::<Result<_>>()?;
667 let mut placed: Vec<_> = moves
668 .iter()
669 .map(|(chain, to)| (to.clone(), chain.as_ref().and_then(|c| c.last()).copied()))
670 .chain(
671 changes
672 .iter()
673 .filter(|c| c.kind == '+' || c.kind == 'M')
674 .map(|c| (c.path.clone(), None)),
675 )
676 .collect();
677 placed.sort_by_key(|(p, _)| p.split('/').count());
678 let upsert = "INSERT INTO file(parent,name,dir,size,alloc,files,mtime) VALUES(?,?,?,?,?,?,?) ON CONFLICT(parent,name) DO UPDATE SET dir=excluded.dir,size=excluded.size,alloc=excluded.alloc,files=excluded.files,mtime=excluded.mtime RETURNING id";
679 db.execute_batch("BEGIN;")?;
680 let result = || -> Result<()> {
681 for (chain, _) in &moves {
682 if let Some(chain) = chain {
683 dirty.extend(chain);
684 db.execute(
685 "UPDATE file SET parent=NULL WHERE id=?",
686 [chain.last().unwrap()],
687 )?;
688 }
689 }
690 for chain in removed.iter().flatten() {
691 dirty.extend(chain);
692 db.execute("DELETE FROM file WHERE id=?", [chain.last().unwrap()])?;
693 }
694 for (rel, id) in placed {
695 if rel.is_empty() {
696 continue;
697 }
698 let names: Vec<_> = rel.split('/').collect();
699 let mut parent = 1i64;
700 for (i, name) in names.iter().enumerate() {
701 let Some(Some(info)) = stats.get(&names[..=i].join("/")) else {
702 break;
703 };
704 if let Some(id) = id.filter(|_| i == names.len() - 1) {
705 db.execute(
706 "DELETE FROM file WHERE parent=? AND name=? AND id!=?",
707 params![parent, name, id],
708 )?;
709 db.execute(
710 "UPDATE file SET parent=?,name=? WHERE id=?",
711 params![parent, name, id],
712 )?;
713 }
714 dirty.insert(parent);
715 parent = db.query_row(
716 upsert,
717 params![
718 parent,
719 name,
720 info.is_dir(),
721 if info.is_dir() { 0 } else { info.len() },
722 if info.is_dir() {
723 0
724 } else {
725 info.blocks() * 512
726 },
727 if info.is_dir() { 0 } else { 1 },
728 info.mtime()
729 ],
730 |r| r.get(0),
731 )?;
732 dirty.insert(parent);
733 }
734 }
735 for (rel, entries) in listings {
736 let Some(entries) = entries else {
737 continue;
738 };
739 let Some(chain) = resolve(&db, &rel)? else {
740 continue;
741 };
742 dirty.extend(&chain);
743 let parent = *chain.last().unwrap();
744 let present: HashSet<_> = entries
745 .iter()
746 .map(|e| e.file_name().to_string_lossy().into_owned())
747 .collect();
748 let mut query = db.prepare("SELECT id,name FROM file WHERE parent=?")?;
749 let children = query
750 .query_map([parent], |r| {
751 Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))
752 })?
753 .collect::<std::result::Result<Vec<_>, _>>()?;
754 for (id, name) in children {
755 if !present.contains(&name) {
756 db.execute("DELETE FROM file WHERE id=?", [id])?;
757 }
758 }
759 for entry in entries {
760 let name = entry.file_name().to_string_lossy().into_owned();
761 let path = if rel.is_empty() {
762 name.clone()
763 } else {
764 format!("{rel}/{name}")
765 };
766 let Some(Some(info)) = stats.get(&path) else {
767 continue;
768 };
769 if info.is_dir() {
770 continue;
771 }
772 let _: i64 = db.query_row(
773 upsert,
774 params![
775 parent,
776 name,
777 false,
778 info.len(),
779 info.blocks() * 512,
780 1,
781 info.mtime()
782 ],
783 |r| r.get(0),
784 )?;
785 }
786 }
787 db.execute("DELETE FROM file WHERE parent IS NULL AND id!=1", [])?;
788 let mut order = Vec::new();
789 for id in dirty {
790 let depth:i64=db.query_row("WITH RECURSIVE up(id) AS(SELECT ? UNION ALL SELECT file.parent FROM file JOIN up USING(id) WHERE file.parent IS NOT NULL) SELECT count(*) FROM up",[id],|r| r.get(0))?;
791 order.push((id, depth));
792 }
793 order.sort_by_key(|(_, depth)| std::cmp::Reverse(*depth));
794 for (id, _) in order {
795 db.execute("UPDATE file SET (size,alloc,files)=(SELECT coalesce(sum(size),0),coalesce(sum(alloc),0),coalesce(sum(files),0) FROM file AS child WHERE child.parent=file.id) WHERE id=? AND dir",[id])?;
796 }
797 db.execute(
798 "UPDATE meta SET snapshot=?,updated=?",
799 params![snapshot, now() as u64],
800 )?;
801 Ok(())
802 }();
803 if result.is_ok() {
804 db.execute_batch("COMMIT;")?;
805 } else {
806 db.execute_batch("ROLLBACK;")?;
807 }
808 result
809}
810
811pub async fn route(app: Arc<App>, kind: &str, query: &HashMap<String, String>) -> Result<Response> {
812 let index = app
813 .index
814 .as_ref()
815 .ok_or_else(|| {
816 Error::new(
817 501,
818 "The file index is off. Enable it on this home server, then retry.",
819 )
820 })?
821 .clone();
822 let _slot = app.heavy.acquire().await?;
823 let root = env("STUDIO_STORE_ROOT", "/srv");
824 let rel = crate::files::relative(
825 query.get("path").map(String::as_str).unwrap_or_default(),
826 false,
827 )?;
828 let largest = kind == "largest";
829 let (width, height) = if largest {
830 (1.0, 1.0)
831 } else {
832 (
833 telemetry::query_number(query, "width", 0.0, 1.0, 8192.0)?,
834 telemetry::query_number(query, "height", 0.0, 1.0, 8192.0)?,
835 )
836 };
837 let value=tokio::task::spawn_blocking(move || {
838 if largest {let mut files=index.largest(100)?;for file in files.as_array_mut().unwrap() {file["link"]=apps::file_link(&format!("{root}/{}",string(&file["path"])),false);}return Ok::<_,Error>(files);}
839 let total=index.children(&rel,0)?;let min=(total.as_ref().map(|v| number(&v["size"])).unwrap_or(0.0)*16.0/(width*height)).max(1.0).ceil();
840 Ok(json!({"root":root,"min":min,"scanning":index.is_scanning(),"tree":if total.is_some() {index.map(&rel,min)?} else {None}}))
841 }).await??;
842 Ok(Document::new(value).response())
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848 use std::fs;
849
850 #[test]
851 fn reads_recover_interrupted_updates() {
852 if let Ok(file) = std::env::var("STUDIO_INDEX_CRASH_TEST_DB") {
853 let db = Connection::open(file).unwrap();
854 db.execute_batch("PRAGMA cache_size=1; BEGIN IMMEDIATE; UPDATE file SET size=99;")
855 .unwrap();
856 std::process::exit(0);
857 }
858 let dir = std::env::temp_dir().join(format!("snowglobe-index-{}", uuid::Uuid::new_v4()));
859 let root = dir.join("tree");
860 fs::create_dir_all(&root).unwrap();
861 for n in 0..512 {
862 fs::write(root.join(format!("{n}-{}", "x".repeat(150))), [0]).unwrap();
863 }
864 let index = Index::new("tank".into(), dir.clone());
865 index.active.lock().unwrap().insert("tank".into());
866 let file = index.file("tank");
867 scan(&file, &root, Some("tank@1")).unwrap();
868 let thread = std::thread::current();
869 assert!(
870 std::process::Command::new(std::env::current_exe().unwrap())
871 .args(["--exact", thread.name().unwrap()])
872 .env("STUDIO_INDEX_CRASH_TEST_DB", &file)
873 .status()
874 .unwrap()
875 .success()
876 );
877 let journal = fs::read(file.with_extension("db-journal")).unwrap();
878 assert!(journal.len() > 512 && journal[..8].iter().any(|byte| *byte != 0));
879 let tree = index.children("", 0).unwrap().unwrap();
880 assert_eq!(tree["size"], 512);
881 assert_eq!(tree["files"], 512);
882 fs::remove_dir_all(dir).unwrap();
883 }
884
885 fn dump(file: &std::path::Path) -> Vec<Value> {
886 let db = Connection::open(file).unwrap();
887 let mut query = db.prepare("WITH RECURSIVE walk(id,path) AS (SELECT id,'' FROM file WHERE parent IS NULL UNION ALL SELECT file.id,walk.path||'/'||file.name FROM file JOIN walk ON file.parent=walk.id) SELECT path,dir,size,alloc,files,CASE WHEN path='' THEN 0 ELSE mtime END FROM walk JOIN file USING(id) ORDER BY path").unwrap();
888 query
889 .query_map([], |r| {
890 Ok(json!([
891 r.get::<_, String>(0)?,
892 r.get::<_, bool>(1)?,
893 r.get::<_, i64>(2)?,
894 r.get::<_, i64>(3)?,
895 r.get::<_, i64>(4)?,
896 r.get::<_, i64>(5)?
897 ]))
898 })
899 .unwrap()
900 .map(|r| r.unwrap())
901 .collect()
902 }
903 #[test]
904 fn incremental_changes_match_fresh_scans_in_any_order() {
905 let cases: Value = serde_json::from_str(include_str!("../tests/index-cases.json")).unwrap();
906 for case in array(&cases) {
907 for seed in 0..6u64 {
908 let dir =
909 std::env::temp_dir().join(format!("snowglobe-index-{}", uuid::Uuid::new_v4()));
910 let root = dir.join("tree");
911 fs::create_dir_all(&root).unwrap();
912 for (name, value) in case["before"].as_object().unwrap() {
913 let path = root.join(name);
914 fs::create_dir_all(if name.ends_with('/') {
915 path.as_path()
916 } else {
917 path.parent().unwrap()
918 })
919 .unwrap();
920 if let Some(link) = value.as_str() {
921 fs::hard_link(root.join(link), path).unwrap();
922 } else if !name.ends_with('/') {
923 fs::write(path, vec![0; number(value) as usize]).unwrap();
924 }
925 }
926 let old = dir.join("old.db");
927 let fresh = dir.join("fresh.db");
928 scan(&old, &root, Some("t@1")).unwrap();
929 for op in array(&case["ops"]) {
930 let from = root.join(string(&op[1]));
931 match string(&op[0]) {
932 "renameSync" => fs::rename(from, root.join(string(&op[2]))).unwrap(),
933 "rmSync" => {
934 if from.is_dir() {
935 fs::remove_dir_all(from).unwrap()
936 } else {
937 fs::remove_file(from).unwrap()
938 }
939 }
940 "mkdirSync" => fs::create_dir_all(from).unwrap(),
941 "writeFileSync" => {
942 fs::write(from, vec![0; number(&op[2]) as usize]).unwrap()
943 }
944 "linkSync" => fs::hard_link(from, root.join(string(&op[2]))).unwrap(),
945 other => panic!("unknown fixture operation {other}"),
946 }
947 }
948 let mut lines = array(&case["diff"]).to_vec();
949 let mut state = seed;
950 if seed > 0 {
951 for i in (1..lines.len()).rev() {
952 state = (state * 1103515245 + 12345) % 2147483648;
953 lines.swap(i, state as usize % (i + 1));
954 }
955 }
956 let diff = lines.iter().map(string).collect::<Vec<_>>().join("\n");
957 apply(&old, &root, "t@2", &parse_diff(&diff, "/t").unwrap()).unwrap();
958 scan(&fresh, &root, None).unwrap();
959 assert_eq!(
960 dump(&old),
961 dump(&fresh),
962 "{} seed {seed}: {diff}",
963 string(&case["name"])
964 );
965 assert_eq!(
966 meta(&Connection::open(&old).unwrap()).unwrap().unwrap()["snapshot"],
967 "t@2"
968 );
969 fs::remove_dir_all(dir).unwrap();
970 }
971 }
972 }
973 #[test]
974 fn diff_unescapes_names_and_rejects_paths_outside_mount() {
975 let changes=parse_diff("+\tF\t/tank/r/caf\\0303\\0251\\0040notes\nR\tF\t/tank/r/a\\0134b\t/tank/r/tab\\0011name", "/tank/r").unwrap();
976 assert_eq!(changes[0].path, "café notes");
977 assert_eq!(changes[1].path, "a\\b");
978 assert_eq!(changes[1].to.as_deref(), Some("tab\tname"));
979 assert!(parse_diff("+\tF\t/other/x", "/tank/r").is_err());
980 assert!(parse_diff("1789\t+\tF\t/tank/r/x", "/tank/r").is_err());
981 }
982 #[test]
983 fn namespace_mounts_exclude_subdirectory_binds_and_snapshots() {
984 let text = "1 0 0:1 / /srv/clover rw shared:1 - zfs tank/clover rw\n2 0 0:2 /data /srv/prod/yt-feed/data rw - zfs tank/prod/yt-feed rw\n3 0 0:3 / /srv/clover/Media\\040Files ro - zfs tank/media\\040files rw\n4 0 0:4 / /srv/clover/.zfs/snapshot/index-1 ro - zfs tank/clover@index-1 ro\n5 0 0:5 / /srv/prod rw - overlay overlay rw\nmalformed";
985 assert_eq!(
986 mounted_datasets(text),
987 HashSet::from([
988 ("tank/clover".into(), "/srv/clover".into()),
989 ("tank/media files".into(), "/srv/clover/Media Files".into()),
990 ])
991 );
992 }
993 #[test]
994 fn mounted_datasets_replace_shadowed_totals() {
995 let dir = std::env::temp_dir().join(format!("snowglobe-index-{}", uuid::Uuid::new_v4()));
996 let root = dir.join("tree");
997 let nested = dir.join("nested");
998 let dbs = dir.join("dbs");
999 fs::create_dir_all(root.join("media")).unwrap();
1000 fs::create_dir_all(&nested).unwrap();
1001 fs::create_dir_all(&dbs).unwrap();
1002 fs::write(root.join("media/shadow"), [0; 100]).unwrap();
1003 fs::write(root.join("other"), [0; 10]).unwrap();
1004 fs::write(nested.join("film"), [0; 500]).unwrap();
1005 let index = Index::new("tank".into(), dbs);
1006 index
1007 .active
1008 .lock()
1009 .unwrap()
1010 .extend(["tank".into(), "tank/media".into()]);
1011 scan(&index.file("tank"), &root, None).unwrap();
1012 scan(&index.file("tank/media"), &nested, None).unwrap();
1013 let top = index.children("", 10).unwrap().unwrap();
1014 assert_eq!(number(&top["size"]), 510.0);
1015 assert_eq!(number(&top["files"]), 2.0);
1016 assert_eq!(number(&index.map("", 50.0).unwrap().unwrap()[1]), 510.0);
1017 index.active.lock().unwrap().remove("tank");
1018 assert_eq!(
1019 number(&index.children("", 10).unwrap().unwrap()["size"]),
1020 500.0
1021 );
1022 assert_eq!(number(&index.map("", 50.0).unwrap().unwrap()[1]), 500.0);
1023 assert!(index.children("other", 10).unwrap().is_none());
1024 fs::remove_dir_all(dir).unwrap();
1025 }
1026}
dashboard/src/main.rs created+539
......@@ -0,0 +1,539 @@
1mod apps;
2mod cache;
3mod core;
4mod deploys;
5mod files;
6mod host;
7mod index;
8mod mcp;
9mod observability;
10mod relay;
11mod shale;
12mod storage;
13mod telemetry;
14mod users;
15mod youtube;
16
17use axum::{
18 Router,
19 body::Bytes,
20 extract::{Request, State},
21 http::{HeaderMap, Method, StatusCode},
22 response::{IntoResponse, Response, Sse, sse::Event},
23 routing::any,
24};
25use serde_json::{Value, json};
26use std::{
27 collections::HashMap,
28 path::PathBuf,
29 sync::{Arc, Mutex},
30 time::Duration,
31};
32use subtle::ConstantTimeEq;
33use tokio::sync::{Semaphore, watch};
34use tokio_stream::{StreamExt, wrappers::WatchStream};
35use tower_http::services::{ServeDir, ServeFile};
36
37type Result<T> = std::result::Result<T, Error>;
38
39#[derive(Clone, Debug)]
40struct Error {
41 status: u16,
42 message: String,
43}
44impl Error {
45 fn new(status: u16, message: impl Into<String>) -> Self {
46 Self {
47 status,
48 message: message.into(),
49 }
50 }
51}
52impl<E: std::error::Error> From<E> for Error {
53 fn from(error: E) -> Self {
54 Self::new(500, error.to_string())
55 }
56}
57impl IntoResponse for Error {
58 fn into_response(self) -> Response {
59 if self.status >= 500 {
60 eprintln!("{}: {}", self.status, self.message);
61 }
62 (
63 StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
64 self.message,
65 )
66 .into_response()
67 }
68}
69
70struct Document {
71 value: Value,
72 bytes: Bytes,
73}
74impl Document {
75 fn new(value: Value) -> Self {
76 let bytes = Bytes::from(serde_json::to_vec(&value).unwrap());
77 Self { value, bytes }
78 }
79 fn response(&self) -> Response {
80 ([("content-type", "application/json")], self.bytes.clone()).into_response()
81 }
82}
83
84struct App {
85 mcp: mcp::Store,
86 relay: relay::Broker,
87 shale: shale::Backend,
88 http: reqwest::Client,
89 internal: Option<(url::Url, reqwest::Client)>,
90 cache: cache::Cache,
91 nomad_slots: Semaphore,
92 specs: Mutex<HashMap<String, Value>>,
93 repo: PathBuf,
94 data: PathBuf,
95 live: watch::Sender<Bytes>,
96 usage: Mutex<HashMap<String, Value>>,
97 vm_usage: Mutex<HashMap<String, Value>>,
98 seed_samples: Mutex<Vec<Value>>,
99 youtube: std::sync::OnceLock<youtube::Worker>,
100 heavy: Semaphore,
101 file_changes: tokio::sync::Mutex<()>,
102 index: Option<Arc<index::Index>>,
103}
104impl App {
105 fn request(&self, method: Method, address: &str) -> Result<reqwest::RequestBuilder> {
106 let url = url::Url::parse(address)?;
107 let client = match &self.internal {
108 Some((base, client)) if url.origin() == base.origin() => client,
109 _ => &self.http,
110 };
111 Ok(client.request(method, url))
112 }
113}
114
115fn env(name: &str, default: &str) -> String {
116 std::env::var(name).unwrap_or_else(|_| default.into())
117}
118fn array(value: &Value) -> &[Value] {
119 value.as_array().map(Vec::as_slice).unwrap_or_default()
120}
121fn string(value: &Value) -> &str {
122 value.as_str().unwrap_or_default()
123}
124fn number(value: &Value) -> f64 {
125 value
126 .as_f64()
127 .or_else(|| value.as_str().and_then(|s| s.parse().ok()))
128 .unwrap_or(0.0)
129}
130fn now() -> f64 {
131 std::time::SystemTime::now()
132 .duration_since(std::time::UNIX_EPOCH)
133 .unwrap()
134 .as_secs_f64()
135}
136fn encoded(value: &str) -> String {
137 url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
138}
139fn params(items: &[(&str, String)]) -> String {
140 url::form_urlencoded::Serializer::new(String::new())
141 .extend_pairs(items.iter().map(|(k, v)| (*k, v)))
142 .finish()
143}
144
145fn user(headers: &HeaderMap) -> Result<Value> {
146 let name = headers.get("User-Name").and_then(|v| v.to_str().ok()).filter(|v| !v.is_empty()).ok_or_else(|| Error::new(401, "No signed-in user came with this request. Open the dashboard through its sign-in page."))?;
147 let groups: Vec<&str> = headers
148 .get("User-Groups")
149 .and_then(|v| v.to_str().ok())
150 .unwrap_or_default()
151 .split(|c: char| c == ',' || c.is_whitespace())
152 .filter(|s| !s.is_empty())
153 .map(|s| s.strip_prefix("role:").unwrap_or(s))
154 .collect();
155 let preview = headers
156 .get("cookie")
157 .and_then(|v| v.to_str().ok())
158 .unwrap_or_default()
159 .split(';')
160 .find_map(|s| s.trim().strip_prefix("view-as="));
161 let viewing = preview.is_some() && groups.contains(&"infra-admin");
162 let groups = if viewing {
163 preview
164 .unwrap()
165 .split(',')
166 .filter(|s| !s.is_empty() && *s != "infra-admin")
167 .collect()
168 } else {
169 groups
170 };
171 let sections: Vec<&str> = [
172 ("launcher", None),
173 ("admin", Some("infra-admin")),
174 ("metrics", Some("metrics")),
175 ("media", Some("media-manage")),
176 ("vms", Some("vm")),
177 ]
178 .into_iter()
179 .filter(|(_, group)| can_open(&groups, *group))
180 .map(|(section, _)| section)
181 .collect();
182 Ok(json!({"name":name,"groups":groups,"sections":sections,"viewing":viewing}))
183}
184fn can_open(groups: &[&str], access: Option<&str>) -> bool {
185 access.is_none() || groups.contains(&"infra-admin") || groups.contains(&access.unwrap())
186}
187fn need(user: &Value, section: &str) -> Result<()> {
188 if array(&user["sections"]).iter().any(|s| s == section) {
189 Ok(())
190 } else {
191 Err(Error::new(
192 403,
193 "Your account can't open this section. Ask an admin to add you to its group.",
194 ))
195 }
196}
197
198async fn command(program: &str, args: &[&str], input: Option<&[u8]>) -> Result<Vec<u8>> {
199 use std::process::Stdio;
200 use tokio::io::AsyncWriteExt;
201 let mut child = tokio::process::Command::new(program)
202 .args(args)
203 .stdin(if input.is_some() {
204 Stdio::piped()
205 } else {
206 Stdio::null()
207 })
208 .stdout(Stdio::piped())
209 .stderr(Stdio::piped())
210 .kill_on_drop(true)
211 .spawn()?;
212 if let Some(input) = input {
213 child.stdin.take().unwrap().write_all(input).await?;
214 }
215 let output = tokio::time::timeout(Duration::from_secs(120), child.wait_with_output())
216 .await
217 .map_err(|_| {
218 Error::new(
219 504,
220 "The operation is taking too long. Check its logs, then retry.",
221 )
222 })??;
223 if !output.status.success() {
224 return Err(Error::new(
225 502,
226 String::from_utf8_lossy(&output.stderr).trim(),
227 ));
228 }
229 Ok(output.stdout)
230}
231
232async fn api(State(app): State<Arc<App>>, request: Request) -> Result<Response> {
233 let path = request
234 .uri()
235 .path()
236 .trim_start_matches("/api/")
237 .trim_end_matches('/')
238 .to_owned();
239 let method = request.method().clone();
240 let query: HashMap<String, String> =
241 url::form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes())
242 .into_owned()
243 .collect();
244 let parts: Vec<&str> = path.split('/').collect();
245 if parts.first() == Some(&"icons") && method == Method::GET {
246 return core::icon(&app, &parts, &query).await;
247 }
248 if parts.starts_with(&["account", "pictures"]) && method == Method::GET {
249 return users::picture(&app, &parts).await;
250 }
251 let me = user(request.headers())?;
252 let headers = request.headers().clone();
253 let section = match parts.first().copied().unwrap_or_default() {
254 "host" | "metrics" | "live" => Some("metrics"),
255 "services" if parts.len() == 1 => Some("metrics"),
256 "storage" if parts.len() == 1 => Some("metrics"),
257 "services" | "traces" | "storage" | "users" | "deploys" | "paper-clover" => Some("admin"),
258 "media" | "seedbox" | "youtube" => Some("media"),
259 "vms" => Some("vms"),
260 _ => None,
261 };
262 if let Some(section) = section {
263 need(&me, section)?;
264 }
265 if let ["deploys", "runs", id] = parts.as_slice() {
266 return deploys::run_stream(app, id).await;
267 }
268 if parts.first() == Some(&"account") {
269 return users::account(app, request, &parts[1..], &me).await;
270 }
271 if path == "live" && method == Method::GET {
272 let stream = WatchStream::new(app.live.subscribe())
273 .filter(|data| !data.is_empty())
274 .map(|data| {
275 Ok::<_, std::convert::Infallible>(
276 Event::default().data(String::from_utf8_lossy(&data)),
277 )
278 });
279 return Ok(Sse::new(stream).into_response());
280 }
281 let body = axum::body::to_bytes(request.into_body(), 8 * 1024 * 1024).await?;
282 let value = if body.is_empty() {
283 Value::Null
284 } else {
285 serde_json::from_slice(&body)
286 .map_err(|_| Error::new(400, "The request didn't match what this route expects."))?
287 };
288 match parts[0] {
289 "mcp" => mcp::manage(app, &method, &parts[1..], &me, value, &headers).await,
290 "users" => users::route(app, &method, &parts[1..], &me, value).await,
291 "vms" | "seedbox" | "paper-clover" => {
292 apps::route(app, &method, &parts, &query, value).await
293 }
294 "youtube" => youtube::route(app, &method, &parts[1..], value).await,
295 "media" => files::route(app, true, &method, &parts[1..], &query, value).await,
296 "storage" if parts.get(1) == Some(&"files") => {
297 if parts.len() == 3 && ["map", "largest"].contains(&parts[2]) {
298 index::route(app, parts[2], &query).await
299 } else {
300 files::route(app, false, &method, &parts[2..], &query, value).await
301 }
302 }
303 "storage" => storage::route(app, &method, &parts[1..], &query, value).await,
304 "deploys" => deploys::route(app, &method, &parts[1..], &query).await,
305 _ => core::route(app, &method, &parts, &query, &me, value).await,
306 }
307}
308
309#[tokio::main(worker_threads = 4)]
310async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
311 let address: std::net::IpAddr = env("STUDIO_LISTEN_ADDRESS", "127.0.0.1").parse()?;
312 let proof = match std::env::var_os("STUDIO_PROXY_TOKEN_FILE") {
313 Some(file) => {
314 let token = std::fs::read_to_string(file)?.trim().to_owned();
315 if token.len() != 64 || !token.bytes().all(|b| b.is_ascii_hexdigit()) {
316 return Err(std::io::Error::other(
317 "The dashboard proxy token must contain 64 hexadecimal characters.",
318 )
319 .into());
320 }
321 Some(Arc::<str>::from(token))
322 }
323 None if address.is_loopback() => None,
324 None => {
325 return Err(std::io::Error::other(
326 "Set STUDIO_PROXY_TOKEN_FILE before listening on a non-loopback address.",
327 )
328 .into());
329 }
330 };
331 let certificates = if let Ok(path) =
332 std::env::var("STUDIO_CA_BUNDLE").or_else(|_| std::env::var("NODE_EXTRA_CA_CERTS"))
333 {
334 reqwest::Certificate::from_pem_bundle(&std::fs::read(path)?)?
335 } else {
336 Vec::new()
337 };
338 let client = || {
339 let mut http = reqwest::Client::builder().timeout(Duration::from_secs(15));
340 for certificate in &certificates {
341 http = http.add_root_certificate(certificate.clone());
342 }
343 http
344 };
345 let internal = std::env::var("STUDIO_INTERNAL_URL")
346 .ok()
347 .map(
348 |address| -> std::result::Result<_, Box<dyn std::error::Error>> {
349 let base = url::Url::parse(&address)?;
350 if base.scheme() != "https"
351 || base.host_str().is_none()
352 || !base.username().is_empty()
353 || base.password().is_some()
354 || base.path() != "/"
355 || base.query().is_some()
356 || base.fragment().is_some()
357 {
358 return Err(std::io::Error::other(
359 "STUDIO_INTERNAL_URL must be an HTTPS origin.",
360 )
361 .into());
362 }
363 let token = proof.as_ref().ok_or_else(|| {
364 std::io::Error::other("STUDIO_INTERNAL_URL requires STUDIO_PROXY_TOKEN_FILE.")
365 })?;
366 let mut value = axum::http::HeaderValue::from_str(token)?;
367 value.set_sensitive(true);
368 let mut headers = HeaderMap::new();
369 headers.insert("Studio-Proxy-Token", value);
370 Ok((
371 base,
372 client()
373 .default_headers(headers)
374 .redirect(reqwest::redirect::Policy::none())
375 .build()?,
376 ))
377 },
378 )
379 .transpose()?;
380 let (live, _) = watch::channel(Bytes::new());
381 let index = std::env::var("STUDIO_INDEX_POOL").ok().map(|pool| {
382 Arc::new(index::Index::new(
383 pool,
384 env("STUDIO_INDEX_DIR", ".cache/index").into(),
385 ))
386 });
387 let origin = env(
388 "STUDIO_PUBLIC_ORIGIN",
389 &format!("https://globe.{}", env("STUDIO_DOMAIN", "studio.test")),
390 );
391 let app = Arc::new(App {
392 mcp: mcp::Store::new(&PathBuf::from(env("STUDIO_DATA_DIR", "data")), &origin)
393 .map_err(|error| std::io::Error::other(error.message))?,
394 relay: relay::Broker::default(),
395 shale: shale::Backend::new(
396 &env(
397 "STUDIO_SHALE_URL",
398 &format!("https://shale.{}", env("STUDIO_DOMAIN", "studio.test")),
399 ),
400 client(),
401 )
402 .map_err(|error| std::io::Error::other(error.message))?,
403 http: client().build()?,
404 internal,
405 cache: cache::Cache::default(),
406 nomad_slots: Semaphore::new(4),
407 specs: Mutex::new(HashMap::new()),
408 repo: env("STUDIO_REPO", "..").into(),
409 data: env("STUDIO_DATA_DIR", "data").into(),
410 live,
411 usage: Mutex::new(HashMap::new()),
412 vm_usage: Mutex::new(HashMap::new()),
413 seed_samples: Mutex::new(Vec::new()),
414 youtube: std::sync::OnceLock::new(),
415 heavy: Semaphore::new(4),
416 file_changes: tokio::sync::Mutex::new(()),
417 index,
418 });
419 if let Some(index) = app.index.clone() {
420 let state = app.clone();
421 tokio::spawn(async move {
422 index.start(state).await;
423 });
424 }
425 if std::env::var("STUDIO_YT_STATE").is_ok() {
426 for parts in [&[][..], &["library"][..]] {
427 let state = app.clone();
428 tokio::spawn(async move {
429 if let Err(error) = youtube::route(state, &Method::GET, parts, Value::Null).await {
430 eprintln!("youtube: {}", error.message);
431 }
432 });
433 }
434 }
435 core::start(app.clone());
436 telemetry::start(app.clone());
437 let dist = env("STUDIO_WEB_DIR", "dist");
438 let router = Router::new()
439 .route("/api/{*path}", any(api))
440 .route("/oauth/{*path}", any(mcp::oauth))
441 .route("/.well-known/{*path}", any(mcp::oauth))
442 .nest_service("/assets", ServeDir::new(format!("{dist}/assets")))
443 .with_state(app.clone())
444 .merge(observability::router(app.clone()))
445 .merge(shale::router(app.clone()))
446 .merge(relay::router(app))
447 .fallback_service(
448 ServeDir::new(&dist).fallback(ServeFile::new(format!("{dist}/index.html"))),
449 )
450 .layer(axum::middleware::from_fn(
451 move |mut request: Request, next: axum::middleware::Next| {
452 let proof = proof.clone();
453 async move {
454 if mcp::public(request.uri().path()) {
455 request.headers_mut().remove("Studio-Proxy-Token");
456 request.headers_mut().remove("User-Name");
457 request.headers_mut().remove("User-Groups");
458 } else if let Some(proof) = proof {
459 let supplied = request
460 .headers()
461 .get("Studio-Proxy-Token")
462 .map(|value| value.as_bytes())
463 .unwrap_or_default();
464 if supplied.ct_eq(proof.as_bytes()).unwrap_u8() == 0 {
465 return Error::new(403, "Open the dashboard through its sign-in page.")
466 .into_response();
467 }
468 request.headers_mut().remove("Studio-Proxy-Token");
469 }
470 let asset = request.uri().path().starts_with("/assets/");
471 let document = !asset && !request.uri().path().starts_with("/api/");
472 if document {
473 request.headers_mut().remove("if-modified-since");
474 request.headers_mut().remove("if-none-match");
475 }
476 let mut response = next.run(request).await;
477 if asset && response.status().is_success() {
478 response.headers_mut().insert(
479 "cache-control",
480 "public, max-age=31536000, immutable".parse().unwrap(),
481 );
482 } else if document {
483 response
484 .headers_mut()
485 .insert("cache-control", "no-store".parse().unwrap());
486 }
487 response
488 }
489 },
490 ));
491 let listener = tokio::net::TcpListener::bind(std::net::SocketAddr::new(
492 address,
493 env("PORT", "7070").parse()?,
494 ))
495 .await?;
496 println!("dashboard on {}", listener.local_addr()?);
497 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
498 let (shutdown, waiting) = tokio::sync::oneshot::channel::<()>();
499 let mut server = Box::pin(std::future::IntoFuture::into_future(
500 axum::serve(listener, router).with_graceful_shutdown(async {
501 let _ = waiting.await;
502 }),
503 ));
504 tokio::select! {
505 result = &mut server => { result?; return Ok(()); },
506 _ = terminate.recv() => {},
507 _ = tokio::signal::ctrl_c() => {},
508 }
509 let _ = shutdown.send(());
510 if let Ok(result) = tokio::time::timeout(Duration::from_secs(5), server).await {
511 result?;
512 }
513 Ok(())
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 #[test]
520 fn only_an_admin_can_preview_groups_and_preview_cannot_grant_admin() {
521 let mut headers = HeaderMap::new();
522 assert_eq!(user(&headers).unwrap_err().status, 401);
523 headers.insert("User-Name", "snow".parse().unwrap());
524 headers.insert("User-Groups", "role:metrics,media-manage".parse().unwrap());
525 headers.insert("cookie", "view-as=infra-admin".parse().unwrap());
526 let me = user(&headers).unwrap();
527 assert_eq!(me["viewing"], false);
528 assert!(need(&me, "admin").is_err());
529 assert!(need(&me, "metrics").is_ok());
530 headers.insert("User-Groups", "infra-admin".parse().unwrap());
531 let me = user(&headers).unwrap();
532 assert_eq!(me["viewing"], true);
533 assert!(need(&me, "admin").is_err());
534 headers.insert("cookie", "view-as=metrics".parse().unwrap());
535 let me = user(&headers).unwrap();
536 assert!(need(&me, "metrics").is_ok());
537 assert!(need(&me, "media").is_err());
538 }
539}
dashboard/src/mcp.rs created+1224
......@@ -0,0 +1,1224 @@
1use crate::*;
2use base64::{
3 Engine,
4 engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
5};
6use rusqlite::{Connection, OpenFlags, OptionalExtension};
7use sha2::{Digest, Sha256};
8use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
9
10const CATALOGS: &[(&str, &str, &[&str])] = &[
11 (
12 "observability",
13 "Logs and traces",
14 &["observability:read", "offline_access"],
15 ),
16 (
17 "agents",
18 "Local agents",
19 &["sessions:read", "sessions:write", "offline_access"],
20 ),
21 (
22 "shale",
23 "Shale",
24 &["shale:read", "shale:write", "offline_access"],
25 ),
26];
27
28pub struct Store {
29 pub(crate) db: Mutex<Connection>,
30 pub origin: url::Url,
31}
32pub(crate) fn secret() -> String {
33 URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>())
34}
35pub(crate) fn hash(value: &str) -> String {
36 format!("{:x}", Sha256::digest(value.as_bytes()))
37}
38pub(crate) fn get(db: &Connection, key: &str) -> Result<Value> {
39 let row: Option<String> = db
40 .query_row(
41 "SELECT value FROM records WHERE key=? AND (expires=0 OR expires>?)",
42 rusqlite::params![key, now() as i64],
43 |r| r.get(0),
44 )
45 .optional()?;
46 Ok(row
47 .map(|s| serde_json::from_str(&s))
48 .transpose()?
49 .unwrap_or(Value::Null))
50}
51pub(crate) fn put(db: &Connection, key: &str, value: &Value, ttl: i64) -> Result<()> {
52 db.execute(
53 "DELETE FROM records WHERE expires>0 AND expires<=?",
54 [now() as i64],
55 )?;
56 let count: i64 = db.query_row("SELECT count(*) FROM records", [], |r| r.get(0))?;
57 if count >= 16384 && get(db, key)?.is_null() {
58 return Err(Error::new(
59 503,
60 "The connection store is full. Remove an unused connection and retry.",
61 ));
62 }
63 db.execute(
64 "INSERT OR REPLACE INTO records VALUES (?,?,?)",
65 rusqlite::params![
66 key,
67 value.to_string(),
68 if ttl == 0 { 0 } else { now() as i64 + ttl }
69 ],
70 )?;
71 Ok(())
72}
73pub(crate) fn delete(db: &Connection, key: &str) -> Result<()> {
74 db.execute("DELETE FROM records WHERE key=?", [key])?;
75 Ok(())
76}
77pub(crate) fn revoke(db: &Connection, grant: &str) -> Result<()> {
78 db.execute(
79 "DELETE FROM records WHERE key=? OR json_extract(value,'$.grant')=?",
80 rusqlite::params![format!("grant:{grant}"), grant],
81 )?;
82 Ok(())
83}
84pub(crate) fn list(db: &Connection, prefix: &str) -> Result<Vec<Value>> {
85 let mut query = db.prepare(
86 "SELECT value FROM records WHERE substr(key,1,?)=? AND (expires=0 OR expires>?)",
87 )?;
88 let rows = query.query_map(rusqlite::params![prefix.len(), prefix, now() as i64], |r| {
89 r.get::<_, String>(0)
90 })?;
91 rows.map(|r| Ok(serde_json::from_str(&r?)?)).collect()
92}
93fn fail(code: &str) -> Error {
94 Error::new(400, code)
95}
96fn redirect(value: &str) -> Result<url::Url> {
97 let url = url::Url::parse(value).map_err(|_| fail("invalid_redirect_uri"))?;
98 let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
99 if value.len() > 4096
100 || !url.username().is_empty()
101 || url.password().is_some()
102 || url.fragment().is_some()
103 || url.host_str().is_none()
104 || !(url.scheme() == "https" || url.scheme() == "http" && loopback)
105 {
106 return Err(fail("invalid_redirect_uri"));
107 }
108 Ok(url)
109}
110impl Store {
111 pub fn new(data: &std::path::Path, origin: &str) -> Result<Self> {
112 let origin = url::Url::parse(origin)?;
113 if origin.path() != "/"
114 || origin.query().is_some()
115 || origin.fragment().is_some()
116 || !origin.username().is_empty()
117 || origin.password().is_some()
118 || origin.scheme() != "https"
119 && !(origin.scheme() == "http"
120 && matches!(origin.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")))
121 {
122 return Err(fail("Set an HTTPS origin for MCP connections."));
123 }
124 std::fs::DirBuilder::new()
125 .recursive(true)
126 .mode(0o700)
127 .create(data)?;
128 let path = data.join("connections.sqlite");
129 let db = Connection::open_with_flags(
130 &path,
131 OpenFlags::SQLITE_OPEN_READ_WRITE
132 | OpenFlags::SQLITE_OPEN_CREATE
133 | OpenFlags::SQLITE_OPEN_NOFOLLOW,
134 )?;
135 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
136 db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS records (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires INTEGER NOT NULL);")?;
137 Ok(Self {
138 db: Mutex::new(db),
139 origin,
140 })
141 }
142 pub fn resource(&self, catalog: &str) -> String {
143 self.origin
144 .join(&format!("mcp/{catalog}"))
145 .unwrap()
146 .to_string()
147 }
148 fn client(
149 &self,
150 db: &Connection,
151 input: &HashMap<String, String>,
152 headers: &HeaderMap,
153 ) -> Result<Value> {
154 let basic = headers.get("authorization").and_then(|v| v.to_str().ok());
155 let (id, credential) = if let Some(basic) = basic {
156 if input.contains_key("client_secret") {
157 return Err(fail("invalid_client"));
158 }
159 let bytes = STANDARD
160 .decode(
161 basic
162 .strip_prefix("Basic ")
163 .ok_or_else(|| fail("invalid_client"))?,
164 )
165 .map_err(|_| fail("invalid_client"))?;
166 let value = String::from_utf8(bytes).map_err(|_| fail("invalid_client"))?;
167 let (id, credential) = value
168 .split_once(':')
169 .ok_or_else(|| fail("invalid_client"))?;
170 (id.to_owned(), Some(credential.to_owned()))
171 } else {
172 (
173 input.get("client_id").cloned().unwrap_or_default(),
174 input.get("client_secret").cloned(),
175 )
176 };
177 if input.get("client_id").is_some_and(|v| v != &id) {
178 return Err(fail("invalid_client"));
179 }
180 let client = get(db, &format!("client:{id}"))?;
181 let method = string(&client["token_endpoint_auth_method"]);
182 let valid = match method {
183 "none" => basic.is_none() && credential.is_none(),
184 "client_secret_basic" => {
185 basic.is_some()
186 && credential.as_ref().is_some_and(|v| {
187 hash(v)
188 .as_bytes()
189 .ct_eq(string(&client["secret_hash"]).as_bytes())
190 .unwrap_u8()
191 == 1
192 })
193 }
194 "client_secret_post" => {
195 basic.is_none()
196 && credential.as_ref().is_some_and(|v| {
197 hash(v)
198 .as_bytes()
199 .ct_eq(string(&client["secret_hash"]).as_bytes())
200 .unwrap_u8()
201 == 1
202 })
203 }
204 _ => false,
205 };
206 if valid {
207 Ok(client)
208 } else {
209 Err(fail("invalid_client"))
210 }
211 }
212 fn issue(&self, db: &Connection, grant: &Value) -> Result<Value> {
213 let access = secret();
214 put(
215 db,
216 &format!("access:{}", hash(&access)),
217 &json!({"grant":grant["id"],"resource":grant["resource"]}),
218 3600,
219 )?;
220 let mut response = json!({"access_token":access,"token_type":"Bearer","expires_in":3600,"scope":array(&grant["scopes"]).iter().map(string).collect::<Vec<_>>().join(" ")});
221 if array(&grant["scopes"])
222 .iter()
223 .any(|s| s == "offline_access")
224 {
225 let refresh = secret();
226 put(
227 db,
228 &format!("refresh:{}", hash(&refresh)),
229 &json!({"grant":grant["id"]}),
230 30 * 86400,
231 )?;
232 response["refresh_token"] = json!(refresh);
233 }
234 Ok(response)
235 }
236 pub fn authenticate(&self, headers: &HeaderMap, resource: &str) -> Result<Value> {
237 let token = headers
238 .get("authorization")
239 .and_then(|v| v.to_str().ok())
240 .and_then(|s| s.strip_prefix("Bearer "))
241 .filter(|s| !s.is_empty() && s.len() <= 256)
242 .ok_or_else(|| Error::new(401, "invalid_token"))?;
243 let db = self.db.lock().unwrap();
244 let access = get(&db, &format!("access:{}", hash(token)))?;
245 let grant = get(&db, &format!("grant:{}", string(&access["grant"])))?;
246 if grant.is_null() || access["resource"] != resource || grant["resource"] != resource {
247 return Err(Error::new(401, "invalid_token"));
248 }
249 Ok(grant)
250 }
251 fn exchange(
252 &self,
253 db: &Connection,
254 input: &HashMap<String, String>,
255 headers: &HeaderMap,
256 ) -> Result<(String, Value)> {
257 let client = self.client(db, input, headers)?;
258 let (key, grant) = match input.get("grant_type").map(String::as_str) {
259 Some("authorization_code") => {
260 let key = format!(
261 "code:{}",
262 hash(input.get("code").map(String::as_str).unwrap_or_default())
263 );
264 let code = get(db, &key)?;
265 let verifier = input
266 .get("code_verifier")
267 .map(String::as_str)
268 .unwrap_or_default();
269 if code.is_null()
270 || code["client"] != client["client_id"]
271 || input.get("redirect_uri").map(String::as_str) != code["redirect"].as_str()
272 || !(43..=128).contains(&verifier.len())
273 || !verifier
274 .bytes()
275 .all(|b| b.is_ascii_alphanumeric() || b"-._~".contains(&b))
276 || URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
277 != string(&code["challenge"])
278 {
279 return Err(fail("invalid_grant"));
280 }
281 if input
282 .get("resource")
283 .is_some_and(|r| code["resource"] != r.as_str())
284 {
285 return Err(fail("invalid_target"));
286 }
287 let grant = get(db, &format!("grant:{}", string(&code["grant"])))?;
288 if grant.is_null() {
289 return Err(fail("invalid_grant"));
290 }
291 (key, grant)
292 }
293 Some("refresh_token") => {
294 let fingerprint = hash(
295 input
296 .get("refresh_token")
297 .map(String::as_str)
298 .unwrap_or_default(),
299 );
300 let key = format!("refresh:{fingerprint}");
301 let token = get(db, &key)?;
302 let used = get(db, &format!("used:{fingerprint}"))?;
303 if !used.is_null() {
304 let grant = get(db, &format!("grant:{}", string(&used["grant"])))?;
305 if grant["client"] == client["client_id"] {
306 revoke(db, string(&used["grant"]))?;
307 }
308 return Err(fail("invalid_grant"));
309 }
310 let grant = get(db, &format!("grant:{}", string(&token["grant"])))?;
311 if grant.is_null() || grant["client"] != client["client_id"] {
312 return Err(fail("invalid_grant"));
313 }
314 if input
315 .get("resource")
316 .is_some_and(|r| grant["resource"] != r.as_str())
317 {
318 return Err(fail("invalid_target"));
319 }
320 if input.get("scope").is_some_and(|s| {
321 s.split_whitespace()
322 .collect::<std::collections::HashSet<_>>()
323 != array(&grant["scopes"]).iter().map(string).collect()
324 }) {
325 return Err(fail("invalid_scope"));
326 }
327 (key, grant)
328 }
329 _ => return Err(fail("unsupported_grant_type")),
330 };
331 Ok((key, grant))
332 }
333 fn oauth(
334 &self,
335 path: &str,
336 method: &Method,
337 input: &HashMap<String, String>,
338 headers: &HeaderMap,
339 body: Value,
340 ) -> Result<Response> {
341 let mut db = self.db.lock().unwrap();
342 let tx = db.transaction()?;
343 let value = match (path, method) {
344 ("register", &Method::POST) => {
345 let name = body["client_name"]
346 .as_str()
347 .filter(|s| !s.is_empty() && s.len() <= 128)
348 .ok_or_else(|| fail("invalid_client_metadata"))?;
349 let uris = body["redirect_uris"]
350 .as_array()
351 .filter(|a| !a.is_empty() && a.len() <= 8)
352 .ok_or_else(|| fail("invalid_client_metadata"))?;
353 for uri in uris {
354 redirect(uri.as_str().ok_or_else(|| fail("invalid_redirect_uri"))?)?;
355 }
356 let auth = body["token_endpoint_auth_method"]
357 .as_str()
358 .unwrap_or("none");
359 if !["none", "client_secret_post", "client_secret_basic"].contains(&auth)
360 || body.get("grant_types").is_some_and(|v| {
361 v.as_array().is_none_or(|types| {
362 types.is_empty()
363 || types
364 .iter()
365 .any(|s| s != "authorization_code" && s != "refresh_token")
366 })
367 })
368 || body
369 .get("response_types")
370 .is_some_and(|v| v != &json!(["code"]))
371 {
372 return Err(fail("invalid_client_metadata"));
373 }
374 if list(&tx, "client:")?.len() >= 4096 {
375 return Err(Error::new(429, "too_many_clients"));
376 }
377 let id = uuid::Uuid::new_v4().to_string();
378 let credential = secret();
379 let mut client = json!({"client_id":id,"client_name":name,"redirect_uris":uris,"token_endpoint_auth_method":auth,"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"client_id_issued_at":now() as i64});
380 if auth != "none" {
381 client["secret_hash"] = json!(hash(&credential));
382 }
383 put(&tx, &format!("client:{id}"), &client, 0)?;
384 client.as_object_mut().unwrap().remove("secret_hash");
385 if auth != "none" {
386 client["client_secret"] = json!(credential);
387 client["client_secret_expires_at"] = json!(0);
388 }
389 tx.commit()?;
390 return Ok((StatusCode::CREATED, axum::Json(client)).into_response());
391 }
392 ("authorize", &Method::GET) => {
393 let id = input
394 .get("client_id")
395 .map(String::as_str)
396 .unwrap_or_default();
397 let client = get(&tx, &format!("client:{id}"))?;
398 let uri = input
399 .get("redirect_uri")
400 .map(String::as_str)
401 .unwrap_or_default();
402 let challenge = input
403 .get("code_challenge")
404 .map(String::as_str)
405 .unwrap_or_default();
406 let resource = input
407 .get("resource")
408 .map(String::as_str)
409 .unwrap_or_default();
410 let scopes_allowed = CATALOGS
411 .iter()
412 .find(|(id, _, _)| resource == self.resource(id))
413 .map(|(_, _, scopes)| *scopes)
414 .ok_or_else(|| fail("invalid_target"))?;
415 let scopes: Vec<_> = input
416 .get("scope")
417 .map(String::as_str)
418 .unwrap_or(scopes_allowed[0])
419 .split_whitespace()
420 .collect();
421 if client.is_null() || !array(&client["redirect_uris"]).iter().any(|v| v == uri) {
422 return Err(fail("invalid_redirect_uri"));
423 }
424 if input.get("response_type").map(String::as_str) != Some("code")
425 || input.get("code_challenge_method").map(String::as_str) != Some("S256")
426 || challenge.len() != 43
427 || !challenge
428 .bytes()
429 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
430 {
431 return Err(fail("invalid_request"));
432 }
433 if !scopes.contains(&scopes_allowed[0])
434 || scopes.iter().any(|s| !scopes_allowed.contains(s))
435 {
436 return Err(fail("invalid_scope"));
437 }
438 let pending = secret();
439 put(
440 &tx,
441 &format!("pending:{}", hash(&pending)),
442 &json!({"client":id,"redirect":uri,"challenge":challenge,"resource":resource,"scopes":scopes,"state":input.get("state"),"owner":null}),
443 600,
444 )?;
445 tx.commit()?;
446 return Ok((
447 StatusCode::FOUND,
448 [("location", format!("/mcp?request={}", encoded(&pending)))],
449 )
450 .into_response());
451 }
452 ("token", &Method::POST) => {
453 let (key, grant) = match self.exchange(&tx, input, headers) {
454 Ok(exchange) => exchange,
455 Err(error) => {
456 tx.commit()?;
457 return Err(error);
458 }
459 };
460 delete(&tx, &key)?;
461 if let Some(fingerprint) = key.strip_prefix("refresh:") {
462 put(
463 &tx,
464 &format!("used:{fingerprint}"),
465 &json!({"grant":grant["id"]}),
466 30 * 86400,
467 )?;
468 }
469 self.issue(&tx, &grant)?
470 }
471 ("revoke", &Method::POST) => {
472 let client = self.client(&tx, input, headers)?;
473 let fingerprint = hash(input.get("token").map(String::as_str).unwrap_or_default());
474 for kind in ["access", "refresh", "used"] {
475 let token = get(&tx, &format!("{kind}:{fingerprint}"))?;
476 let grant = get(&tx, &format!("grant:{}", string(&token["grant"])))?;
477 if !grant.is_null() && grant["client"] == client["client_id"] {
478 revoke(&tx, string(&grant["id"]))?;
479 }
480 }
481 tx.commit()?;
482 return Ok(StatusCode::OK.into_response());
483 }
484 _ => return Err(Error::new(404, "No endpoint here.")),
485 };
486 tx.commit()?;
487 Ok(axum::Json(value).into_response())
488 }
489}
490
491#[derive(Clone)]
492pub(crate) struct Grant(pub Value);
493pub fn router<H: rmcp::ServerHandler>(
494 app: Arc<App>,
495 catalog: &str,
496 handler: impl Fn() -> std::result::Result<H, std::io::Error> + Send + Sync + 'static,
497) -> Router {
498 use rmcp::transport::streamable_http_server::{
499 StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
500 };
501 let resource = app.mcp.resource(catalog);
502 let metadata = format!(
503 "{}.well-known/oauth-protected-resource/mcp/{catalog}",
504 app.mcp.origin.as_str()
505 );
506 let mut config = StreamableHttpServerConfig::default();
507 config.legacy_session_mode = false;
508 config.json_response = true;
509 config.allowed_hosts =
510 vec![app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned()];
511 config.allowed_origins = vec![format!(
512 "{}://{}:{}",
513 app.mcp.origin.scheme(),
514 app.mcp.origin.host_str().unwrap(),
515 app.mcp.origin.port_or_known_default().unwrap()
516 )];
517 let service =
518 StreamableHttpService::new(handler, Arc::new(LocalSessionManager::default()), config);
519 Router::new()
520 .nest_service(&format!("/mcp/{catalog}"), service)
521 .layer(axum::middleware::from_fn(
522 move |mut request: Request, next: axum::middleware::Next| {
523 let (app, resource, metadata) = (app.clone(), resource.clone(), metadata.clone());
524 async move {
525 if request.headers().get("origin").is_some_and(|origin| {
526 origin.to_str().ok()
527 != Some(app.mcp.origin.origin().ascii_serialization().as_str())
528 }) {
529 return Error::new(403, "This origin cannot use the connector.")
530 .into_response();
531 }
532 match app.mcp.authenticate(request.headers(), &resource) {
533 Ok(grant) => {
534 request.extensions_mut().insert(Grant(grant));
535 if let Some(value) = request.headers_mut().get_mut("authorization") {
536 value.set_sensitive(true);
537 }
538 next.run(request).await
539 }
540 Err(_) => (
541 StatusCode::UNAUTHORIZED,
542 [(
543 "www-authenticate",
544 format!("Bearer resource_metadata=\"{metadata}\""),
545 )],
546 "This connection expired. Connect again.",
547 )
548 .into_response(),
549 }
550 }
551 },
552 ))
553}
554
555pub fn public(path: &str) -> bool {
556 path.starts_with("/oauth/")
557 || path.starts_with("/mcp/")
558 || path.starts_with("/.well-known/oauth-")
559 || path == "/pairing"
560 || path == "/agent/connect"
561 || path.starts_with("/api/v1/")
562}
563pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response {
564 if request.uri().path().starts_with("/oauth/shale/") {
565 return shale::oauth(app, request).await;
566 }
567 let result = async {
568 let path = request.uri().path().to_owned();
569 if path=="/.well-known/oauth-authorization-server" && request.method()==Method::GET {
570 let base = app.mcp.origin.as_str();
571 let scopes: std::collections::BTreeSet<_> = CATALOGS.iter().flat_map(|(_, _, scopes)| scopes.iter()).collect();
572 return Ok(axum::Json(json!({"issuer":base,"authorization_endpoint":format!("{base}oauth/authorize"),"token_endpoint":format!("{base}oauth/token"),"registration_endpoint":format!("{base}oauth/register"),"revocation_endpoint":format!("{base}oauth/revoke"),"response_types_supported":["code"],"grant_types_supported":["authorization_code","refresh_token"],"token_endpoint_auth_methods_supported":["none","client_secret_post","client_secret_basic"],"code_challenge_methods_supported":["S256"],"scopes_supported":scopes})).into_response());
573 }
574 if request.method() == Method::GET {
575 for (catalog, _, scopes) in CATALOGS {
576 if path == format!("/.well-known/oauth-protected-resource/mcp/{catalog}") {
577 return Ok(axum::Json(json!({"resource":app.mcp.resource(catalog),"authorization_servers":[app.mcp.origin.as_str()],"scopes_supported":scopes,"bearer_methods_supported":["header"]})).into_response());
578 }
579 }
580 }
581 let method = request.method().clone();
582 let headers = request.headers().clone();
583 let query = request.uri().query().unwrap_or_default().to_owned();
584 let bytes = axum::body::to_bytes(request.into_body(),65536).await.map_err(|_| fail("invalid_request"))?;
585 let registration = path=="/oauth/register";
586 let encoded = if method==Method::GET {query.as_bytes()} else {&bytes};
587 if method==Method::POST && !headers.get("content-type").and_then(|v|v.to_str().ok()).is_some_and(|s| s.split(';').next()==Some(if registration {"application/json"} else {"application/x-www-form-urlencoded"})) { return Err(fail("invalid_request")); }
588 let mut input = HashMap::new();
589 if !registration { for (key,value) in url::form_urlencoded::parse(encoded) { if input.insert(key.into_owned(),value.into_owned()).is_some() {return Err(fail("invalid_request"));} } }
590 let body = if registration {serde_json::from_slice(&bytes).map_err(|_| fail("invalid_client_metadata"))?} else {Value::Null};
591 if path == "/oauth/token" && method == Method::POST {
592 let (_, grant) = app.mcp.exchange(&app.mcp.db.lock().unwrap(), &input, &headers)?;
593 let id = string(&grant["user"]);
594 let (profile, roles) = match tokio::try_join!(
595 host::call(json!({"operation":"iam.request","path":format!("/users/{id}"),"method":"GET","body":null})),
596 host::call(json!({"operation":"iam.request","path":format!("/users/{id}/role-mappings/realm"),"method":"GET","body":null}))
597 ) {
598 Ok(identity) => identity,
599 Err(error) if error.status == 404 => {
600 revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?;
601 return Err(fail("invalid_grant"));
602 }
603 Err(error) => return Err(error),
604 };
605 if profile["body"]["enabled"] != true || grant["resource"] == app.mcp.resource("observability") && !array(&roles["body"]).iter().any(|role| role["name"] == "infra-admin") {
606 revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?;
607 return Err(fail("invalid_grant"));
608 }
609 }
610 app.mcp.oauth(path.trim_start_matches("/oauth/"),&method,&input,&headers,body)
611 }.await;
612 let mut response = match result {
613 Ok(response) => response,
614 Err(error) => (
615 StatusCode::from_u16(if error.message == "invalid_client" {
616 401
617 } else {
618 error.status
619 })
620 .unwrap_or(StatusCode::BAD_REQUEST),
621 axum::Json(
622 json!({"error":if error.status >= 500 {"server_error"} else {&error.message}}),
623 ),
624 )
625 .into_response(),
626 };
627 response
628 .headers_mut()
629 .insert("cache-control", "no-store".parse().unwrap());
630 response
631 .headers_mut()
632 .insert("pragma", "no-cache".parse().unwrap());
633 response
634}
635
636pub async fn manage(
637 app: Arc<App>,
638 method: &Method,
639 parts: &[&str],
640 me: &Value,
641 body: Value,
642 headers: &HeaderMap,
643) -> Result<Response> {
644 if method != Method::GET
645 && (me["viewing"] == true
646 || headers.get("origin").and_then(|h| h.to_str().ok())
647 != Some(app.mcp.origin.origin().ascii_serialization().as_str()))
648 {
649 return Err(Error::new(
650 403,
651 "Open MCP settings from your own signed-in account.",
652 ));
653 }
654 let owner = users::self_user(&app, me).await?;
655 if owner["enabled"] != true {
656 return Err(Error::new(403, "This account is disabled."));
657 }
658 let owner_id = string(&owner["id"]);
659 if parts == ["shale"] {
660 return shale::manage(app, method, &owner, &body).await;
661 }
662 if parts == ["relay", "live"] && method == Method::GET {
663 let owner = owner_id.to_owned();
664 let stream = WatchStream::new(app.relay.changes.subscribe()).map(move |_| {
665 let machines = relay::machines(&app.mcp.db.lock().unwrap(), &owner)
666 .map(|machines| app.relay.view(machines, None));
667 machines
668 .map(|machines| Event::default().json_data(machines).unwrap())
669 .map_err(|error| std::io::Error::other(error.message))
670 });
671 return Ok(Sse::new(stream)
672 .keep_alive(axum::response::sse::KeepAlive::default())
673 .into_response());
674 }
675 let consent_resource = if let ["consent", id] = parts {
676 get(
677 &app.mcp.db.lock().unwrap(),
678 &format!("pending:{}", hash(id)),
679 )?["resource"]
680 .as_str()
681 .unwrap_or_default()
682 .to_owned()
683 } else {
684 String::new()
685 };
686 let agent_consent = consent_resource == app.mcp.resource("agents");
687 let shale_consent = consent_resource == app.mcp.resource("shale");
688 let mut linked = true;
689 let resources: Vec<Value> = if agent_consent {
690 relay::machines(&app.mcp.db.lock().unwrap(), owner_id)?
691 .into_iter()
692 .map(|m| json!({"id":m["id"],"name":m["name"]}))
693 .collect()
694 } else if shale_consent && body["deny"] != true {
695 match shale::repositories(&app, owner_id).await {
696 Ok(repositories) => repositories,
697 Err(error) if error.status == 401 => {
698 linked = false;
699 Vec::new()
700 }
701 Err(error) => return Err(error),
702 }
703 } else if consent_resource == app.mcp.resource("observability")
704 && array(&me["sections"]).iter().any(|s| s == "admin")
705 && array(&owner["groups"])
706 .iter()
707 .any(|g| g["name"] == "infra-admin")
708 {
709 core::scan(app.clone())
710 .await?
711 .value
712 .as_object()
713 .unwrap()
714 .keys()
715 .map(|id| json!({"id":id,"name":id}))
716 .collect()
717 } else {
718 Vec::new()
719 };
720 let mut db = app.mcp.db.lock().unwrap();
721 let tx = db.transaction()?;
722 let value = match parts {
723 [] if method == Method::GET => {
724 let mut grants = list(&tx, "grant:")?;
725 grants.retain(|g| g["user"] == owner_id);
726 let machines = relay::machines(&tx, owner_id)?;
727 let connections = grants.iter().map(|grant| {
728 let name = if grant["client"].is_null() {grant["name"].clone()} else {get(&tx, &format!("client:{}", string(&grant["client"])))?["client_name"].clone()};
729 let resources: Vec<_> = array(&grant["resources"]).iter().map(|id| if grant["resource"] == app.mcp.resource("agents") {machines.iter().find(|m| m["id"] == *id).map(|m| m["name"].clone()).unwrap_or_else(|| json!("Unlinked machine"))} else {id.clone()}).collect();
730 Ok(json!({"id":grant["id"],"name":name,"resources":resources,"scopes":grant["scopes"],"createdAt":grant["createdAt"]}))
731 }).collect::<Result<Vec<_>>>()?;
732 let shale = get(&tx, &format!("shale-session:{owner_id}"))?;
733 let catalogs: Vec<_> = CATALOGS
734 .iter()
735 .map(|(id, name, _)| json!({"name":name,"endpoint":app.mcp.resource(id)}))
736 .collect();
737 json!({"catalogs":catalogs,"connections":connections,"machines":app.relay.view(machines,None),"shale":if shale["origin"] != app.shale.origin.as_str() {Value::Null} else {json!({"linkedAt":shale["linkedAt"]})}})
738 }
739 ["consent", id] if method == Method::GET || method == Method::POST => {
740 let key = format!("pending:{}", hash(id));
741 let mut pending = get(&tx, &key)?;
742 if pending.is_null() {
743 return Err(Error::new(
744 404,
745 "This connection request expired. Start it again.",
746 ));
747 }
748 if !pending["owner"].is_null() && pending["owner"] != owner_id {
749 return Err(Error::new(
750 403,
751 "This connection request belongs to another account.",
752 ));
753 }
754 pending["owner"] = json!(owner_id);
755 if method == Method::POST && body["deny"] == true {
756 delete(&tx, &key)?;
757 let mut target = redirect(string(&pending["redirect"]))?;
758 target
759 .query_pairs_mut()
760 .append_pair("error", "access_denied")
761 .append_pair("iss", app.mcp.origin.as_str());
762 if let Some(state) = pending["state"].as_str() {
763 target.query_pairs_mut().append_pair("state", state);
764 }
765 tx.commit()?;
766 return Ok(axum::Json(json!({"redirect":target.as_str()})).into_response());
767 }
768 if method == Method::GET {
769 tx.execute(
770 "UPDATE records SET value=? WHERE key=?",
771 rusqlite::params![pending.to_string(), key],
772 )?;
773 json!({"client":get(&tx,&format!("client:{}",string(&pending["client"])))?["client_name"],"scopes":pending["scopes"],"resources":resources,"linked":linked})
774 } else {
775 if shale_consent
776 && get(&tx, &format!("shale-session:{owner_id}"))?["origin"]
777 != app.shale.origin.as_str()
778 {
779 return Err(Error::new(
780 401,
781 "Link your Shale account before allowing repository access.",
782 ));
783 }
784 let chosen = body["resources"]
785 .as_array()
786 .filter(|a| !a.is_empty() && a.len() <= resources.len())
787 .ok_or_else(|| Error::new(400, "Choose each available resource once."))?;
788 if chosen.iter().enumerate().any(|(index, r)| {
789 !resources.iter().any(|id| r == &id["id"]) || chosen[..index].contains(r)
790 }) {
791 return Err(Error::new(
792 403,
793 "Choose resources available to your account.",
794 ));
795 }
796 if list(&tx, "grant:")?
797 .iter()
798 .filter(|g| g["user"] == owner_id)
799 .count()
800 >= 256
801 {
802 return Err(Error::new(
803 409,
804 "Remove an unused connection before adding another.",
805 ));
806 }
807 let grant_id = uuid::Uuid::new_v4().to_string();
808 let mut grant = json!({"id":grant_id,"user":owner_id,"client":pending["client"],"resource":pending["resource"],"scopes":pending["scopes"],"resources":chosen,"createdAt":now()});
809 if agent_consent {
810 grant["targets"] = json!(chosen);
811 }
812 put(&tx, &format!("grant:{grant_id}"), &grant, 0)?;
813 let code = secret();
814 pending["grant"] = json!(grant_id);
815 put(&tx, &format!("code:{}", hash(&code)), &pending, 300)?;
816 delete(&tx, &key)?;
817 let mut target = redirect(string(&pending["redirect"]))?;
818 target
819 .query_pairs_mut()
820 .append_pair("code", &code)
821 .append_pair("iss", app.mcp.origin.as_str());
822 if let Some(state) = pending["state"].as_str() {
823 target.query_pairs_mut().append_pair("state", state);
824 }
825 json!({"redirect":target.as_str()})
826 }
827 }
828 ["relay", rest @ ..] => relay::manage(&app, &tx, rest, method, owner_id, &body)?,
829 ["connections", id] if method == Method::DELETE => {
830 let grant = get(&tx, &format!("grant:{id}"))?;
831 if grant["user"] != owner_id {
832 return Err(Error::new(404, "No connection with that ID."));
833 }
834 revoke(&tx, id)?;
835 Value::Null
836 }
837 _ => return Err(Error::new(404, "No endpoint here.")),
838 };
839 tx.commit()?;
840 if matches!(parts, ["relay", "pair"] | ["relay", "machines", _]) {
841 app.relay.changes.send_replace(());
842 }
843 Ok(if value.is_null() {
844 StatusCode::NO_CONTENT.into_response()
845 } else {
846 axum::Json(value).into_response()
847 })
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853 struct Fixture {
854 store: Arc<Store>,
855 path: std::path::PathBuf,
856 }
857 impl Fixture {
858 fn new() -> Self {
859 let path = std::env::temp_dir()
860 .canonicalize()
861 .unwrap()
862 .join(format!("studio-mcp-test-{}", uuid::Uuid::new_v4()));
863 let store = Arc::new(Store::new(&path, "https://globe.studio.test").unwrap());
864 Self { store, path }
865 }
866 fn code(&self, client: &str, code: &str) -> HashMap<String, String> {
867 let grant = uuid::Uuid::new_v4().to_string();
868 let db = self.store.db.lock().unwrap();
869 put(&db, &format!("client:{client}"), &json!({"client_id":client,"token_endpoint_auth_method":"none","redirect_uris":["http://127.0.0.1:20001/callback"]}),0).unwrap();
870 put(&db, &format!("grant:{grant}"), &json!({"id":grant,"user":"one","client":client,"resource":self.store.resource("observability"),"scopes":["observability:read","offline_access"],"resources":["allowed"]}),0).unwrap();
871 let verifier = "v".repeat(43);
872 put(&db, &format!("code:{}",hash(code)), &json!({"client":client,"redirect":"http://127.0.0.1:20001/callback","challenge":URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())),"resource":self.store.resource("observability"),"grant":grant}),300).unwrap();
873 fields(
874 json!({"grant_type":"authorization_code","client_id":client,"code":code,"code_verifier":verifier,"redirect_uri":"http://127.0.0.1:20001/callback","resource":self.store.resource("observability")}),
875 )
876 }
877 }
878 impl Drop for Fixture {
879 fn drop(&mut self) {
880 std::fs::remove_dir_all(&self.path).unwrap();
881 }
882 }
883 fn fields(value: Value) -> HashMap<String, String> {
884 value
885 .as_object()
886 .unwrap()
887 .iter()
888 .map(|(k, v)| (k.clone(), string(v).to_owned()))
889 .collect()
890 }
891 async fn value(response: Response) -> Value {
892 serde_json::from_slice(
893 &axum::body::to_bytes(response.into_body(), 65536)
894 .await
895 .unwrap(),
896 )
897 .unwrap()
898 }
899 fn bearer(token: &str) -> HeaderMap {
900 let mut headers = HeaderMap::new();
901 headers.insert("authorization", format!("Bearer {token}").parse().unwrap());
902 headers
903 }
904 #[test]
905 fn revocation_removes_durable_keys_and_family_records() {
906 let fixture = Fixture::new();
907 let db = fixture.store.db.lock().unwrap();
908 put(&db, "grant:revoked", &json!({"id":"revoked"}), 0).unwrap();
909 for kind in ["access", "refresh", "used", "code"] {
910 put(&db, &format!("{kind}:one"), &json!({"grant":"revoked"}), 0).unwrap();
911 put(
912 &db,
913 &format!("{kind}:other"),
914 &json!({"grant":"retained"}),
915 0,
916 )
917 .unwrap();
918 }
919 revoke(&db, "revoked").unwrap();
920 assert!(get(&db, "grant:revoked").unwrap().is_null());
921 for kind in ["access", "refresh", "used", "code"] {
922 assert!(get(&db, &format!("{kind}:one")).unwrap().is_null());
923 assert!(!get(&db, &format!("{kind}:other")).unwrap().is_null());
924 }
925 }
926 #[tokio::test]
927 async fn code_binds_pkce_client_redirect_and_audience_without_consuming_on_failure() {
928 let fixture = Fixture::new();
929 let input = fixture.code("one", "owned-code");
930 fixture.code("two", "other-code");
931 for (key, wrong) in [
932 ("client_id", "two"),
933 ("code_verifier", "wrong"),
934 ("redirect_uri", "http://127.0.0.1:20002/callback"),
935 ("resource", "https://other.invalid/mcp/observability"),
936 ] {
937 let mut attempt = input.clone();
938 attempt.insert(key.into(), wrong.into());
939 assert!(
940 fixture
941 .store
942 .oauth(
943 "token",
944 &Method::POST,
945 &attempt,
946 &HeaderMap::new(),
947 Value::Null
948 )
949 .is_err()
950 );
951 }
952 let tokens = value(
953 fixture
954 .store
955 .oauth(
956 "token",
957 &Method::POST,
958 &input,
959 &HeaderMap::new(),
960 Value::Null,
961 )
962 .unwrap(),
963 )
964 .await;
965 assert!(
966 fixture
967 .store
968 .oauth(
969 "token",
970 &Method::POST,
971 &input,
972 &HeaderMap::new(),
973 Value::Null
974 )
975 .is_err()
976 );
977 assert!(
978 fixture
979 .store
980 .authenticate(
981 &bearer(string(&tokens["access_token"])),
982 &fixture.store.resource("observability")
983 )
984 .is_ok()
985 );
986 assert!(
987 fixture
988 .store
989 .authenticate(
990 &bearer(string(&tokens["access_token"])),
991 "https://other.invalid/mcp/observability"
992 )
993 .is_err()
994 );
995 assert_eq!(
996 std::fs::metadata(fixture.path.join("connections.sqlite"))
997 .unwrap()
998 .permissions()
999 .mode()
1000 & 0o777,
1001 0o600
1002 );
1003 let rows = fixture
1004 .store
1005 .db
1006 .lock()
1007 .unwrap()
1008 .prepare("SELECT key,value FROM records")
1009 .unwrap()
1010 .query_map([], |row| {
1011 Ok(format!(
1012 "{} {}",
1013 row.get::<_, String>(0)?,
1014 row.get::<_, String>(1)?
1015 ))
1016 })
1017 .unwrap()
1018 .collect::<std::result::Result<Vec<_>, _>>()
1019 .unwrap()
1020 .join("\n");
1021 for token in [
1022 "owned-code",
1023 string(&tokens["access_token"]),
1024 string(&tokens["refresh_token"]),
1025 ] {
1026 assert!(!rows.contains(token));
1027 }
1028 }
1029 #[tokio::test]
1030 async fn refresh_replay_revokes_family_but_another_client_cannot_revoke_it() {
1031 let fixture = Fixture::new();
1032 let input = fixture.code("one", "owned-code");
1033 fixture.code("two", "other-code");
1034 let tokens = value(
1035 fixture
1036 .store
1037 .oauth(
1038 "token",
1039 &Method::POST,
1040 &input,
1041 &HeaderMap::new(),
1042 Value::Null,
1043 )
1044 .unwrap(),
1045 )
1046 .await;
1047 let refresh = fields(
1048 json!({"grant_type":"refresh_token","client_id":"one","refresh_token":tokens["refresh_token"]}),
1049 );
1050 let mut wrong = refresh.clone();
1051 wrong.insert("client_id".into(), "two".into());
1052 assert!(
1053 fixture
1054 .store
1055 .oauth(
1056 "token",
1057 &Method::POST,
1058 &wrong,
1059 &HeaderMap::new(),
1060 Value::Null
1061 )
1062 .is_err()
1063 );
1064 fixture
1065 .store
1066 .oauth(
1067 "revoke",
1068 &Method::POST,
1069 &fields(json!({"client_id":"two","token":tokens["access_token"]})),
1070 &HeaderMap::new(),
1071 Value::Null,
1072 )
1073 .unwrap();
1074 let access = bearer(string(&tokens["access_token"]));
1075 assert!(
1076 fixture
1077 .store
1078 .authenticate(&access, &fixture.store.resource("observability"))
1079 .is_ok()
1080 );
1081 let rotated = value(
1082 fixture
1083 .store
1084 .oauth(
1085 "token",
1086 &Method::POST,
1087 &refresh,
1088 &HeaderMap::new(),
1089 Value::Null,
1090 )
1091 .unwrap(),
1092 )
1093 .await;
1094 assert_ne!(rotated["refresh_token"], tokens["refresh_token"]);
1095 assert!(
1096 fixture
1097 .store
1098 .oauth(
1099 "token",
1100 &Method::POST,
1101 &wrong,
1102 &HeaderMap::new(),
1103 Value::Null
1104 )
1105 .is_err()
1106 );
1107 assert!(
1108 fixture
1109 .store
1110 .authenticate(&access, &fixture.store.resource("observability"))
1111 .is_ok()
1112 );
1113 assert!(
1114 fixture
1115 .store
1116 .oauth(
1117 "token",
1118 &Method::POST,
1119 &refresh,
1120 &HeaderMap::new(),
1121 Value::Null
1122 )
1123 .is_err()
1124 );
1125 assert!(
1126 fixture
1127 .store
1128 .authenticate(&access, &fixture.store.resource("observability"))
1129 .is_err()
1130 );
1131 assert!(
1132 fixture
1133 .store
1134 .authenticate(
1135 &bearer(string(&rotated["access_token"])),
1136 &fixture.store.resource("observability")
1137 )
1138 .is_err()
1139 );
1140 assert!(fixture.store.oauth("token",&Method::POST,&fields(json!({"grant_type":"refresh_token","client_id":"one","refresh_token":rotated["refresh_token"]})),&HeaderMap::new(),Value::Null).is_err());
1141 }
1142 #[test]
1143 fn concurrent_code_exchange_has_one_winner() {
1144 let fixture = Fixture::new();
1145 let input = fixture.code("one", "concurrent-code");
1146 let start = Arc::new(std::sync::Barrier::new(9));
1147 let workers = (0..8)
1148 .map(|_| {
1149 let store = fixture.store.clone();
1150 let input = input.clone();
1151 let start = start.clone();
1152 std::thread::spawn(move || {
1153 start.wait();
1154 store
1155 .oauth(
1156 "token",
1157 &Method::POST,
1158 &input,
1159 &HeaderMap::new(),
1160 Value::Null,
1161 )
1162 .is_ok()
1163 })
1164 })
1165 .collect::<Vec<_>>();
1166 start.wait();
1167 assert_eq!(
1168 workers
1169 .into_iter()
1170 .map(|w| w.join().unwrap() as u32)
1171 .sum::<u32>(),
1172 1
1173 );
1174 }
1175 #[tokio::test]
1176 async fn confidential_client_secret_is_required_and_hashed() {
1177 let fixture = Fixture::new();
1178 let client=value(fixture.store.oauth("register",&Method::POST,&HashMap::new(),&HeaderMap::new(),json!({"client_name":"Confidential","redirect_uris":["https://client.invalid/callback"],"token_endpoint_auth_method":"client_secret_basic"})).unwrap()).await;
1179 let id = string(&client["client_id"]);
1180 let stored = get(&fixture.store.db.lock().unwrap(), &format!("client:{id}")).unwrap();
1181 assert!(stored.get("client_secret").is_none());
1182 assert_ne!(stored["secret_hash"], client["client_secret"]);
1183 let input = fields(json!({"client_id":id,"token":"unknown"}));
1184 assert!(
1185 fixture
1186 .store
1187 .oauth(
1188 "revoke",
1189 &Method::POST,
1190 &input,
1191 &HeaderMap::new(),
1192 Value::Null
1193 )
1194 .is_err()
1195 );
1196 let mut headers = HeaderMap::new();
1197 headers.insert(
1198 "authorization",
1199 format!(
1200 "Basic {}",
1201 STANDARD.encode(format!("{id}:{}", string(&client["client_secret"])))
1202 )
1203 .parse()
1204 .unwrap(),
1205 );
1206 assert!(
1207 fixture
1208 .store
1209 .oauth("revoke", &Method::POST, &input, &headers, Value::Null)
1210 .is_ok()
1211 );
1212 let mut duplicate = input.clone();
1213 duplicate.insert(
1214 "client_secret".into(),
1215 string(&client["client_secret"]).into(),
1216 );
1217 assert!(
1218 fixture
1219 .store
1220 .oauth("revoke", &Method::POST, &duplicate, &headers, Value::Null)
1221 .is_err()
1222 );
1223 }
1224}
dashboard/src/observability.rs created+190
......@@ -0,0 +1,190 @@
1use crate::*;
2use rmcp::{
3 ErrorData, RoleServer, ServerHandler,
4 model::{
5 CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult,
6 PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, ToolAnnotations,
7 },
8 service::RequestContext,
9};
10
11#[derive(Clone)]
12struct Observability(Arc<App>);
13impl ServerHandler for Observability {
14 fn get_info(&self) -> ServerConfig {
15 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
16 }
17 async fn list_tools(
18 &self,
19 _: Option<PaginatedRequestParams>,
20 _: RequestContext<RoleServer>,
21 ) -> std::result::Result<ListToolsResult, ErrorData> {
22 Ok(ListToolsResult { tools: [
23 ("get_logs", "Retrieve logs from one granted service."),
24 ("get_traces", "Find traces from one granted service."),
25 ("get_trace", "Retrieve a trace with spans limited to granted services."),
26 ].into_iter().map(|(name, description)| {
27 let mut properties = json!({"service":{"type":"string"},"q":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":500}});
28 let required = if name=="get_trace" { properties=json!({"service":{"type":"string"},"trace_id":{"type":"string"}}); json!(["service","trace_id"]) } else {json!(["service"])};
29 Tool::new(name, description, json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}).as_object().unwrap().clone()).with_annotations(ToolAnnotations::new().read_only(true))
30 }).collect(), ..Default::default() })
31 }
32 async fn call_tool(
33 &self,
34 request: CallToolRequestParams,
35 context: RequestContext<RoleServer>,
36 ) -> std::result::Result<CallToolResponse, ErrorData> {
37 let result: Result<Value> = async {
38 let grant = &context
39 .extensions
40 .get::<axum::http::request::Parts>()
41 .and_then(|p| p.extensions.get::<mcp::Grant>())
42 .ok_or_else(|| Error::new(401, "This connection expired. Connect again."))?
43 .0;
44 let arguments = request.arguments.unwrap_or_default();
45 let service = arguments
46 .get("service")
47 .and_then(Value::as_str)
48 .ok_or_else(|| Error::new(400, "Choose a granted service."))?;
49 if !array(&grant["resources"]).iter().any(|id| id == service) {
50 return Err(Error::new(
51 403,
52 "This service is outside the connection's access.",
53 ));
54 }
55 let mut query = HashMap::new();
56 for (key, value) in &arguments {
57 match key.as_str() {
58 "service" => {}
59 "q" if request.name != "get_trace" => {
60 query.insert(
61 key.clone(),
62 value
63 .as_str()
64 .filter(|s| s.len() <= 4096)
65 .ok_or_else(|| Error::new(400, "Narrow the search."))?
66 .to_owned(),
67 );
68 }
69 "limit" if request.name != "get_trace" => {
70 query.insert(
71 key.clone(),
72 value
73 .as_u64()
74 .filter(|n| (1..=500).contains(n))
75 .ok_or_else(|| Error::new(400, "Choose a limit from 1 to 500."))?
76 .to_string(),
77 );
78 }
79 "trace_id" if request.name == "get_trace" && value.as_str().is_some() => {}
80 _ => return Err(Error::new(400, "Use the fields listed for this tool.")),
81 }
82 }
83 match request.name.as_ref() {
84 "get_logs" => {
85 Ok(json!({"logs":telemetry::logs(self.0.clone(), service, &query).await?}))
86 }
87 "get_traces" => {
88 let jobs = core::scan(self.0.clone()).await?;
89 let traces = telemetry::traces(self.0.clone(), service, &query, |span| {
90 visible_span(span, &jobs.value, &grant["resources"])
91 })
92 .await?;
93 Ok(json!({"traces":traces}))
94 }
95 "get_trace" => {
96 let id = arguments
97 .get("trace_id")
98 .and_then(Value::as_str)
99 .filter(|s| !s.is_empty() && s.len() <= 128)
100 .ok_or_else(|| Error::new(400, "Choose a trace ID."))?;
101 let mut trace = telemetry::trace(self.0.clone(), id).await?;
102 let jobs = core::scan(self.0.clone()).await?;
103 if !array(&trace["spans"])
104 .iter()
105 .any(|span| visible_span(span, &jobs.value, &json!([service])))
106 {
107 return Err(Error::new(404, "No trace from that service has this ID."));
108 }
109 trace["spans"]
110 .as_array_mut()
111 .unwrap()
112 .retain(|span| visible_span(span, &jobs.value, &grant["resources"]));
113 Ok(json!({"trace":trace}))
114 }
115 _ => Err(Error::new(404, "No tool with that name.")),
116 }
117 }
118 .await;
119 Ok(match result {
120 Ok(value) => CallToolResult::structured(value),
121 Err(error) => CallToolResult::error(vec![ContentBlock::text(if error.status >= 500 {
122 "The service couldn't answer. Check its dashboard and retry.".to_owned()
123 } else {
124 error.message
125 })]),
126 }
127 .into())
128 }
129}
130fn visible_span(span: &Value, jobs: &Value, resources: &Value) -> bool {
131 if let Some(id) = span["attributes"]["studio.service"].as_str() {
132 return array(resources).iter().any(|r| r == id);
133 }
134 let owners: Vec<_> = jobs
135 .as_object()
136 .into_iter()
137 .flat_map(|jobs| jobs.iter())
138 .filter(|(_, job)| job["job"]["Meta"]["studio_trace_service"] == span["service"])
139 .map(|(id, _)| id)
140 .collect();
141 owners.len() == 1 && array(resources).iter().any(|r| r == owners[0])
142}
143pub fn router(app: Arc<App>) -> Router {
144 let state = app.clone();
145 mcp::router(app, "observability", move || {
146 Ok(Observability(state.clone()))
147 })
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 #[test]
154 fn trace_grants_reject_foreign_and_ambiguous_spans() {
155 let jobs = json!({"allowed":{"job":{"Meta":{"studio_trace_service":"backend"}}},"private":{"job":{"Meta":{"studio_trace_service":"private-api"}}}});
156 let resources = json!(["allowed"]);
157 assert!(visible_span(
158 &json!({"service":"backend","attributes":{}}),
159 &jobs,
160 &resources
161 ));
162 assert!(!visible_span(
163 &json!({"service":"private-api","attributes":{}}),
164 &jobs,
165 &resources
166 ));
167 assert!(!visible_span(
168 &json!({"service":"backend","attributes":{"studio.service":"private"}}),
169 &jobs,
170 &resources
171 ));
172 assert!(visible_span(
173 &json!({"service":"allowed","attributes":{"studio.service":"allowed"}}),
174 &jobs,
175 &resources
176 ));
177 let mut ambiguous = jobs.clone();
178 ambiguous["private"]["job"]["Meta"]["studio_trace_service"] = json!("backend");
179 assert!(!visible_span(
180 &json!({"service":"backend","attributes":{}}),
181 &ambiguous,
182 &resources
183 ));
184 assert!(!visible_span(
185 &json!({"service":"unknown","attributes":{}}),
186 &jobs,
187 &resources
188 ));
189 }
190}
dashboard/src/relay.rs created+1065
......@@ -0,0 +1,1065 @@
1use crate::*;
2use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
3use futures::SinkExt;
4use mcp::{delete, get, hash, list, put, secret};
5use rmcp::{
6 ErrorData, RoleServer, ServerHandler,
7 model::{
8 CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult,
9 PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, ToolAnnotations,
10 },
11 service::RequestContext,
12};
13use tokio::sync::{mpsc, oneshot};
14
15pub struct Broker {
16 connections: Mutex<HashMap<String, Arc<Connection>>>,
17 pending_slots: Semaphore,
18 pub(crate) changes: watch::Sender<()>,
19}
20impl Default for Broker {
21 fn default() -> Self {
22 Self {
23 connections: Mutex::new(HashMap::new()),
24 pending_slots: Semaphore::new(128),
25 changes: watch::channel(()).0,
26 }
27 }
28}
29struct Connection {
30 messages: mpsc::Sender<Message>,
31 pending: Mutex<HashMap<String, oneshot::Sender<Result<Value>>>>,
32 closed: watch::Sender<bool>,
33}
34struct Pending {
35 connection: Arc<Connection>,
36 id: String,
37}
38impl Drop for Pending {
39 fn drop(&mut self) {
40 self.connection.pending.lock().unwrap().remove(&self.id);
41 }
42}
43fn unknown() -> Error {
44 Error::new(
45 409,
46 "Command outcome is unknown. Read the thread before retrying.",
47 )
48}
49impl Broker {
50 pub fn disconnect(&self, id: &str) {
51 if let Some(connection) = self.connections.lock().unwrap().remove(id) {
52 self.changes.send_replace(());
53 connection.closed.send_replace(true);
54 for (_, response) in connection.pending.lock().unwrap().drain() {
55 let _ = response.send(Err(unknown()));
56 }
57 }
58 }
59 fn remove(&self, id: &str, connection: &Arc<Connection>) {
60 let mut connections = self.connections.lock().unwrap();
61 if connections
62 .get(id)
63 .is_some_and(|current| Arc::ptr_eq(current, connection))
64 {
65 connections.remove(id);
66 self.changes.send_replace(());
67 }
68 connection.closed.send_replace(true);
69 for (_, response) in connection.pending.lock().unwrap().drain() {
70 let _ = response.send(Err(unknown()));
71 }
72 }
73 pub fn view(&self, machines: Vec<Value>, grant: Option<&Value>) -> Vec<Value> {
74 let connections = self.connections.lock().unwrap();
75 machines
76 .into_iter()
77 .filter(|machine| grant.is_none_or(|g| array(&g["resources"]).contains(&machine["id"])))
78 .map(|mut machine| {
79 machine.as_object_mut().unwrap().remove("tokenHash");
80 machine.as_object_mut().unwrap().remove("user");
81 machine["online"] = json!(connections.contains_key(string(&machine["id"])));
82 if let Some(grant) = grant {
83 machine["selected"] = json!(array(&grant["targets"]).contains(&machine["id"]));
84 }
85 machine
86 })
87 .collect()
88 }
89 async fn dispatch(
90 &self,
91 store: &mcp::Store,
92 grant_id: &str,
93 machine_id: &str,
94 method: &str,
95 params: Value,
96 deadline: Duration,
97 ) -> Result<Value> {
98 let params = command(method, params)?;
99 let (pending, receive, slot) = {
100 let db = store.db.lock().unwrap();
101 let grant = get(&db, &format!("grant:{grant_id}"))?;
102 let machine = get(&db, &format!("machine:{machine_id}"))?;
103 if grant.is_null()
104 || grant["resource"] != store.resource("agents")
105 || machine.is_null()
106 || machine["user"] != grant["user"]
107 || !array(&grant["resources"]).iter().any(|id| id == machine_id)
108 {
109 return Err(Error::new(
110 403,
111 "Choose a machine granted to this connection.",
112 ));
113 }
114 let scope = if matches!(method, "send_message" | "interrupt_thread" | "start_thread") {
115 "sessions:write"
116 } else {
117 "sessions:read"
118 };
119 if !array(&grant["scopes"]).iter().any(|s| s == scope) {
120 return Err(Error::new(
121 403,
122 "This connection has read access only. Connect again to request control.",
123 ));
124 }
125 let slot = self.pending_slots.try_acquire().map_err(|_| {
126 Error::new(
127 429,
128 "The relay has too many pending commands. Try again shortly.",
129 )
130 })?;
131 let connection = self
132 .connections
133 .lock()
134 .unwrap()
135 .get(machine_id)
136 .cloned()
137 .ok_or_else(|| Error::new(409, "Machine is offline. Start its local agent."))?;
138 let (send, receive) = oneshot::channel();
139 let id = uuid::Uuid::new_v4().to_string();
140 {
141 let mut pending = connection.pending.lock().unwrap();
142 if *connection.closed.borrow() {
143 return Err(Error::new(
144 409,
145 "Machine is offline. Start its local agent.",
146 ));
147 }
148 if pending.len() >= 8 {
149 return Err(Error::new(
150 429,
151 "Machine has eight pending commands. Wait for one to finish.",
152 ));
153 }
154 pending.insert(id.clone(), send);
155 }
156 let pending = Pending {
157 connection: connection.clone(),
158 id: id.clone(),
159 };
160 let frame = json!({"id":id,"method":method,"params":params}).to_string();
161 if frame.len() > 256 * 1024 {
162 return Err(Error::new(
163 413,
164 "Send a shorter message. Local agent commands are limited to 256 KB.",
165 ));
166 }
167 let frame = Message::Text(frame.into());
168 if connection.messages.try_send(frame).is_err() {
169 return Err(unknown());
170 }
171 (pending, receive, slot)
172 };
173 let result = tokio::time::timeout(deadline, receive)
174 .await
175 .map_err(|_| unknown())?
176 .map_err(|_| unknown())?;
177 drop(pending);
178 drop(slot);
179 result
180 }
181}
182pub(crate) fn machines(db: &rusqlite::Connection, user: &str) -> Result<Vec<Value>> {
183 Ok(list(db, "machine:")?
184 .into_iter()
185 .filter(|m| m["user"] == user)
186 .collect())
187}
188fn device(db: &rusqlite::Connection, headers: &HeaderMap) -> Result<Value> {
189 let token = headers
190 .get("authorization")
191 .and_then(|v| v.to_str().ok())
192 .and_then(|s| s.strip_prefix("Bearer "))
193 .filter(|s| !s.is_empty() && s.len() <= 256)
194 .ok_or_else(|| Error::new(401, "Pair this machine again."))?;
195 let fingerprint = hash(token);
196 list(db, "machine:")?
197 .into_iter()
198 .find(|machine| {
199 string(&machine["tokenHash"])
200 .as_bytes()
201 .ct_eq(fingerprint.as_bytes())
202 .unwrap_u8()
203 == 1
204 })
205 .ok_or_else(|| Error::new(401, "Pair this machine again."))
206}
207fn name(value: &Value) -> Result<&str> {
208 value
209 .as_str()
210 .filter(|s| !s.trim().is_empty() && s.encode_utf16().count() <= 100)
211 .ok_or_else(|| Error::new(400, "Enter a name up to 100 characters."))
212}
213pub(crate) fn manage(
214 app: &App,
215 db: &rusqlite::Connection,
216 parts: &[&str],
217 method: &Method,
218 owner: &str,
219 body: &Value,
220) -> Result<Value> {
221 match parts {
222 ["pair"] if method == Method::POST => {
223 let code = body["code"]
224 .as_str()
225 .filter(|s| !s.is_empty() && s.len() <= 40)
226 .ok_or_else(|| Error::new(400, "Enter the code from your local agent."))?;
227 let code: String = code
228 .chars()
229 .filter(|c| *c != '-' && !c.is_whitespace())
230 .flat_map(char::to_uppercase)
231 .collect();
232 let key = format!("pair:{}", hash(&code));
233 let pairing = get(db, &key)?;
234 if pairing.is_null() {
235 return Err(Error::new(
236 410,
237 "This code expired or was used. Start pairing again.",
238 ));
239 }
240 if machines(db, owner)?.len() >= 128 {
241 return Err(Error::new(
242 409,
243 "Unlink an unused machine before adding another.",
244 ));
245 }
246 let id = uuid::Uuid::new_v4().to_string();
247 let machine = json!({"id":id,"user":owner,"name":pairing["name"],"platform":pairing["platform"],"tokenHash":pairing["tokenHash"]});
248 put(db, &format!("machine:{id}"), &machine, 0)?;
249 delete(db, &key)?;
250 Ok(json!(app.relay.view(vec![machine], None).remove(0)))
251 }
252 ["machines", id] if method == Method::DELETE || method == Method::PATCH => {
253 let mut machine = get(db, &format!("machine:{id}"))?;
254 if machine["user"] != owner {
255 return Err(Error::new(404, "No linked machine with that ID."));
256 }
257 if method == Method::DELETE {
258 delete(db, &format!("machine:{id}"))?;
259 app.relay.disconnect(id);
260 Ok(Value::Null)
261 } else {
262 machine["name"] = json!(name(&body["name"])?);
263 put(db, &format!("machine:{id}"), &machine, 0)?;
264 Ok(json!(app.relay.view(vec![machine], None).remove(0)))
265 }
266 }
267 ["keys"] if method == Method::POST => {
268 let available = machines(db, owner)?;
269 let resources = selection(
270 &body["resources"],
271 &available
272 .iter()
273 .map(|m| m["id"].clone())
274 .collect::<Vec<_>>(),
275 )?;
276 let name = name(&body["name"])?;
277 let write = body
278 .get("write")
279 .map(|v| {
280 v.as_bool()
281 .ok_or_else(|| Error::new(400, "Choose read access or control."))
282 })
283 .transpose()?
284 .unwrap_or(false);
285 if list(db, "grant:")?
286 .iter()
287 .filter(|g| g["user"] == owner)
288 .count()
289 >= 256
290 {
291 return Err(Error::new(
292 409,
293 "Revoke an unused connection before adding another.",
294 ));
295 }
296 let id = uuid::Uuid::new_v4().to_string();
297 let grant = json!({"id":id,"user":owner,"name":name,"client":null,"resource":app.mcp.resource("agents"),"scopes":if write {json!(["sessions:read","sessions:write"])} else {json!(["sessions:read"])},"resources":resources,"targets":resources,"createdAt":now()});
298 let key = format!("ar_{}", secret());
299 put(db, &format!("grant:{id}"), &grant, 0)?;
300 put(
301 db,
302 &format!("access:{}", hash(&key)),
303 &json!({"grant":id,"resource":grant["resource"]}),
304 0,
305 )?;
306 Ok(json!({"key":key,"id":id}))
307 }
308 _ => Err(Error::new(404, "No endpoint here.")),
309 }
310}
311fn selection(value: &Value, allowed: &[Value]) -> Result<Vec<Value>> {
312 let ids = value
313 .as_array()
314 .filter(|ids| !ids.is_empty() && ids.len() <= 128)
315 .ok_or_else(|| Error::new(400, "Choose at least one linked machine."))?;
316 if ids
317 .iter()
318 .enumerate()
319 .any(|(i, id)| !allowed.contains(id) || ids[..i].contains(id))
320 {
321 return Err(Error::new(
322 403,
323 "Choose machines granted to this connection once each.",
324 ));
325 }
326 Ok(ids.clone())
327}
328fn view(app: &App, grant_id: &str, targets: Option<&Value>) -> Result<Vec<Value>> {
329 let mut db = app.mcp.db.lock().unwrap();
330 let tx = db.transaction()?;
331 let mut grant = get(&tx, &format!("grant:{grant_id}"))?;
332 if grant.is_null() || grant["resource"] != app.mcp.resource("agents") {
333 return Err(Error::new(
334 401,
335 "This connection was revoked. Connect again.",
336 ));
337 }
338 let machines = machines(&tx, string(&grant["user"]))?;
339 if let Some(targets) = targets {
340 let allowed: Vec<_> = machines
341 .iter()
342 .filter(|m| array(&grant["resources"]).contains(&m["id"]))
343 .map(|m| m["id"].clone())
344 .collect();
345 grant["targets"] = json!(selection(targets, &allowed)?);
346 put(&tx, &format!("grant:{grant_id}"), &grant, 0)?;
347 }
348 let result = app.relay.view(machines, Some(&grant));
349 tx.commit()?;
350 Ok(result)
351}
352fn schema(method: &str) -> Option<Value> {
353 let provider = json!({"type":"string","enum":["codex","claude"]});
354 let id = json!({"type":"string","format":"uuid"});
355 let message = json!({"type":"string","minLength":1,"maxLength":100000});
356 let (properties, required) = match method {
357 "list_threads" => (
358 json!({"provider":provider,"limit":{"type":"integer","minimum":1,"maximum":100,"default":30}}),
359 json!([]),
360 ),
361 "read_thread" => (
362 json!({"provider":provider,"thread_id":id,"limit":{"type":"integer","minimum":1,"maximum":100,"default":20}}),
363 json!(["provider", "thread_id"]),
364 ),
365 "send_message" => (
366 json!({"provider":provider,"thread_id":id,"message":message,"expected_turn_id":id}),
367 json!(["provider", "thread_id", "message"]),
368 ),
369 "interrupt_thread" => (
370 json!({"provider":provider,"thread_id":id,"expected_turn_id":id}),
371 json!(["provider", "thread_id"]),
372 ),
373 "start_thread" => (
374 json!({"provider":provider,"cwd":{"type":"string","minLength":1,"maxLength":4096},"message":message}),
375 json!(["provider", "cwd", "message"]),
376 ),
377 _ => return None,
378 };
379 Some(
380 json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}),
381 )
382}
383fn command(method: &str, value: Value) -> Result<Value> {
384 let schema = schema(method)
385 .ok_or_else(|| Error::new(404, "Choose a session tool listed by this connector."))?;
386 let mut params = value
387 .as_object()
388 .cloned()
389 .ok_or_else(|| Error::new(400, "Use the fields listed for this tool."))?;
390 let properties = schema["properties"].as_object().unwrap();
391 if params.keys().any(|key| !properties.contains_key(key))
392 || array(&schema["required"])
393 .iter()
394 .any(|key| !params.contains_key(string(key)))
395 {
396 return Err(Error::new(400, "Use the fields listed for this tool."));
397 }
398 for (key, rule) in properties {
399 if !params.contains_key(key) && rule.get("default").is_some() {
400 params.insert(key.clone(), rule["default"].clone());
401 }
402 let Some(value) = params.get(key) else {
403 continue;
404 };
405 let valid = match string(&rule["type"]) {
406 "integer" => value.as_i64().is_some_and(|n| {
407 n >= rule["minimum"].as_i64().unwrap() && n <= rule["maximum"].as_i64().unwrap()
408 }),
409 "string" => value.as_str().is_some_and(|s| {
410 let length = s.encode_utf16().count() as u64;
411 rule["minLength"].as_u64().is_none_or(|n| length >= n)
412 && rule["maxLength"].as_u64().is_none_or(|n| length <= n)
413 && rule
414 .get("enum")
415 .is_none_or(|values| array(values).contains(value))
416 && (rule["format"] != "uuid"
417 || uuid::Uuid::parse_str(s)
418 .is_ok_and(|id| id.to_string().eq_ignore_ascii_case(s)))
419 }),
420 _ => false,
421 };
422 if !valid {
423 return Err(Error::new(
424 400,
425 format!("Check {key} against the tool's fields."),
426 ));
427 }
428 }
429 Ok(json!(params))
430}
431async fn pairing(State(app): State<Arc<App>>, request: Request) -> Result<Response> {
432 let method = request.method().clone();
433 let headers = request.headers().clone();
434 let bytes = axum::body::to_bytes(request.into_body(), 4096)
435 .await
436 .map_err(|_| Error::new(400, "Enter a shorter machine name."))?;
437 let mut db = app.mcp.db.lock().unwrap();
438 let tx = db.transaction()?;
439 let response = if method == Method::POST {
440 if list(&tx, "pair:")?.len() >= 256 {
441 return Err(Error::new(
442 429,
443 "Too many pairing requests. Try again in ten minutes.",
444 ));
445 }
446 let body: Value = serde_json::from_slice(&bytes)
447 .map_err(|_| Error::new(400, "Enter a machine name and platform."))?;
448 let name = name(&body["name"])?;
449 let platform = body["platform"]
450 .as_str()
451 .filter(|s| s.encode_utf16().count() <= 100)
452 .ok_or_else(|| Error::new(400, "Enter a platform up to 100 characters."))?;
453 let token = secret();
454 let code: String = rand::random::<[u8; 5]>()
455 .iter()
456 .map(|b| format!("{b:02X}"))
457 .collect();
458 put(
459 &tx,
460 &format!("pair:{}", hash(&code)),
461 &json!({"name":name,"platform":platform,"tokenHash":hash(&token)}),
462 600,
463 )?;
464 (StatusCode::CREATED, axum::Json(json!({"code":format!("{}-{}", &code[..5], &code[5..]),"token":token,"expires_in":600}))).into_response()
465 } else if method == Method::GET {
466 match device(&tx, &headers) {
467 Ok(machine) => axum::Json(json!({"machine_id":machine["id"]})).into_response(),
468 Err(_) => {
469 let token = headers
470 .get("authorization")
471 .and_then(|v| v.to_str().ok())
472 .and_then(|s| s.strip_prefix("Bearer "))
473 .filter(|s| !s.is_empty() && s.len() <= 256)
474 .ok_or_else(|| Error::new(401, "Start pairing from your local agent."))?;
475 if !list(&tx, "pair:")?
476 .iter()
477 .any(|p| p["tokenHash"] == hash(token))
478 {
479 return Err(Error::new(
480 410,
481 "This pairing expired. Start pairing again.",
482 ));
483 }
484 (StatusCode::ACCEPTED, axum::Json(json!({"pending":true}))).into_response()
485 }
486 }
487 } else {
488 return Err(Error::new(405, "Use GET or POST for pairing."));
489 };
490 tx.commit()?;
491 Ok(response)
492}
493async fn connect(
494 State(app): State<Arc<App>>,
495 headers: HeaderMap,
496 ws: WebSocketUpgrade,
497) -> Result<Response> {
498 if headers.contains_key("origin") {
499 return Err(Error::new(401, "Connect from your local agent."));
500 }
501 let machine = device(&app.mcp.db.lock().unwrap(), &headers)?;
502 let id = string(&machine["id"]).to_owned();
503 let (send, receive) = mpsc::channel(16);
504 let (closed, _) = watch::channel(false);
505 let connection = Arc::new(Connection {
506 messages: send,
507 pending: Mutex::new(HashMap::new()),
508 closed,
509 });
510 {
511 let mut connections = app.relay.connections.lock().unwrap();
512 if connections.contains_key(&id) {
513 return Err(Error::new(
514 409,
515 "Another agent is connected. Stop it before starting a second copy.",
516 ));
517 }
518 if connections.len() >= 256 {
519 return Err(Error::new(
520 503,
521 "Too many agents are connected. Try again later.",
522 ));
523 }
524 connections.insert(id.clone(), connection.clone());
525 app.relay.changes.send_replace(());
526 }
527 let (failure_app, failure_id, failure_connection) =
528 (app.clone(), id.clone(), connection.clone());
529 Ok(ws
530 .max_frame_size(4 * 1024 * 1024)
531 .max_message_size(4 * 1024 * 1024)
532 .read_buffer_size(16 * 1024)
533 .write_buffer_size(0)
534 .max_write_buffer_size(256 * 1024)
535 .on_failed_upgrade(move |_| failure_app.relay.remove(&failure_id, &failure_connection))
536 .on_upgrade(move |socket| session(app, id, connection, receive, socket)))
537}
538async fn session(
539 app: Arc<App>,
540 id: String,
541 connection: Arc<Connection>,
542 mut messages: mpsc::Receiver<Message>,
543 mut socket: WebSocket,
544) {
545 let mut closed = connection.closed.subscribe();
546 let connected = Message::Text(
547 json!({"type":"connected","machine_id":id})
548 .to_string()
549 .into(),
550 );
551 if tokio::time::timeout(Duration::from_secs(5), socket.send(connected))
552 .await
553 .is_ok_and(|r| r.is_ok())
554 {
555 let mut heartbeat = tokio::time::interval_at(
556 tokio::time::Instant::now() + Duration::from_secs(20),
557 Duration::from_secs(20),
558 );
559 heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
560 let mut alive = true;
561 loop {
562 if *closed.borrow()
563 || !get(&app.mcp.db.lock().unwrap(), &format!("machine:{id}"))
564 .is_ok_and(|m| !m.is_null())
565 {
566 break;
567 }
568 let outgoing = tokio::select! {
569 _ = closed.changed() => break,
570 outgoing = messages.recv() => match outgoing {Some(frame) => frame, None => break},
571 _ = heartbeat.tick() => {
572 if !alive {break;}
573 alive = false;
574 Message::Ping(Bytes::new())
575 },
576 incoming = socket.recv() => {
577 match incoming {
578 Some(Ok(Message::Pong(_))) => alive = true,
579 Some(Ok(Message::Ping(bytes))) => {
580 if !tokio::time::timeout(Duration::from_secs(5), socket.send(Message::Pong(bytes))).await.is_ok_and(|r| r.is_ok()) {break;}
581 },
582 Some(Ok(Message::Text(text))) => {
583 let reply = serde_json::from_str::<Value>(&text);
584 let Ok(reply) = reply else {break};
585 let valid = reply.as_object().is_some_and(|fields| fields.keys().all(|k| ["id","result","error"].contains(&k.as_str())))
586 && reply["id"].as_str().is_some_and(|s| uuid::Uuid::parse_str(s).is_ok())
587 && (reply.get("result").is_some() != reply.get("error").is_some())
588 && reply.get("error").is_none_or(|error| error.as_str().is_some_and(|s| s.encode_utf16().count() <= 2000));
589 if !valid {break;}
590 if let Some(send) = connection.pending.lock().unwrap().remove(string(&reply["id"])) {
591 let result = if let Some(error) = reply["error"].as_str() {Err(Error::new(400, error))} else {Ok(reply["result"].clone())};
592 let _ = send.send(result);
593 }
594 },
595 _ => break,
596 }
597 continue;
598 }
599 };
600 if !tokio::time::timeout(Duration::from_secs(5), socket.send(outgoing))
601 .await
602 .is_ok_and(|r| r.is_ok())
603 {
604 break;
605 }
606 }
607 }
608 app.relay.remove(&id, &connection);
609 let _ = tokio::time::timeout(Duration::from_secs(1), socket.close()).await;
610}
611async fn rest(State(app): State<Arc<App>>, request: Request) -> Response {
612 let result: Result<Response> = async {
613 if request.headers().get("origin").is_some_and(|v| {
614 v.to_str().ok() != Some(app.mcp.origin.origin().ascii_serialization().as_str())
615 }) {
616 return Err(Error::new(403, "Use the relay from its own origin."));
617 }
618 let grant = app
619 .mcp
620 .authenticate(request.headers(), &app.mcp.resource("agents"))?;
621 let id = string(&grant["id"]);
622 let method = request.method().clone();
623 let path = request
624 .uri()
625 .path()
626 .trim_start_matches("/api/v1/")
627 .to_owned();
628 let bytes = axum::body::to_bytes(request.into_body(), 256 * 1024)
629 .await
630 .map_err(|_| Error::new(400, "Send a shorter command."))?;
631 let body: Value = if bytes.is_empty() {
632 Value::Null
633 } else {
634 serde_json::from_slice(&bytes).map_err(|_| Error::new(400, "Use JSON command fields."))?
635 };
636 let value = match path.split('/').collect::<Vec<_>>().as_slice() {
637 ["machines"] if method == Method::GET => json!(view(&app, id, None)?),
638 ["targets"] if method == Method::PUT => json!(view(&app, id, Some(&body["machine_ids"]))?),
639 ["machines", machine, "commands"] if method == Method::POST => {
640 let method = body["method"]
641 .as_str()
642 .ok_or_else(|| Error::new(400, "Choose a session command."))?;
643 json!({"result":app.relay.dispatch(&app.mcp, id, machine, method, body["params"].clone(), Duration::from_secs(30)).await?})
644 }
645 _ => return Err(Error::new(404, "No relay endpoint here.")),
646 };
647 Ok(axum::Json(value).into_response())
648 }.await;
649 match result {
650 Ok(response) => response,
651 Err(error) => (StatusCode::from_u16(error.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), axum::Json(json!({"error":if error.status >= 500 {"The relay couldn't answer. Check its dashboard and retry."} else {&error.message}}))).into_response(),
652 }
653}
654#[derive(Clone)]
655struct Agents(Arc<App>);
656impl ServerHandler for Agents {
657 fn get_info(&self) -> ServerConfig {
658 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
659 }
660 async fn list_tools(
661 &self,
662 _: Option<PaginatedRequestParams>,
663 _: RequestContext<RoleServer>,
664 ) -> std::result::Result<ListToolsResult, ErrorData> {
665 let tools = [
666 ("list_machines", "List granted machines and their connection state."),
667 ("set_target_machines", "Select default machines for this connection."),
668 ("list_threads", "List recent Codex and Claude Code threads on selected machines."),
669 ("read_thread", "Read a thread and its current status."),
670 ("send_message", "Submit a message. Submission acknowledges delivery, not task completion. Desktop control requires local opt-in."),
671 ("interrupt_thread", "Interrupt an agent-owned session or an opted-in Codex desktop turn."),
672 ("start_thread", "Start an agent-owned session under a locally allowed directory."),
673 ].into_iter().map(|(name, description)| {
674 let mut schema = schema(name).unwrap_or_else(|| if name == "set_target_machines" {json!({"type":"object","properties":{"machine_ids":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":128,"uniqueItems":true}},"required":["machine_ids"],"additionalProperties":false})} else {json!({"type":"object","properties":{},"additionalProperties":false})});
675 if !["list_machines","set_target_machines"].contains(&name) {schema["properties"]["machine_id"] = json!({"type":"string","format":"uuid"});}
676 let read = ["list_machines","list_threads","read_thread"].contains(&name);
677 Tool::new(name, description, schema.as_object().unwrap().clone()).with_annotations(ToolAnnotations::new().read_only(read).destructive(name == "interrupt_thread").idempotent(read))
678 }).collect();
679 Ok(ListToolsResult {
680 tools,
681 ..Default::default()
682 })
683 }
684 async fn call_tool(
685 &self,
686 request: CallToolRequestParams,
687 context: RequestContext<RoleServer>,
688 ) -> std::result::Result<CallToolResponse, ErrorData> {
689 let result: Result<CallToolResult> = async {
690 let grant = &context
691 .extensions
692 .get::<axum::http::request::Parts>()
693 .and_then(|parts| parts.extensions.get::<mcp::Grant>())
694 .ok_or_else(|| Error::new(401, "This connection expired. Connect again."))?
695 .0;
696 let mut arguments = request.arguments.unwrap_or_default();
697 let id = string(&grant["id"]);
698 match request.name.as_ref() {
699 "list_machines" if arguments.is_empty() => Ok(CallToolResult::structured(
700 json!({"machines":view(&self.0, id, None)?}),
701 )),
702 "set_target_machines"
703 if arguments.len() == 1 && arguments.contains_key("machine_ids") =>
704 {
705 Ok(CallToolResult::structured(
706 json!({"machines":view(&self.0, id, arguments.get("machine_ids"))?}),
707 ))
708 }
709 method => {
710 let explicit = arguments.remove("machine_id");
711 let machines = view(&self.0, id, None)?;
712 let targets: Vec<_> = if let Some(explicit) = explicit {
713 let explicit = explicit
714 .as_str()
715 .ok_or_else(|| Error::new(400, "Choose a machine ID."))?;
716 if !machines.iter().any(|m| m["id"] == explicit) {
717 return Err(Error::new(403, "Choose a granted machine."));
718 }
719 vec![explicit.to_owned()]
720 } else {
721 machines
722 .iter()
723 .filter(|m| m["selected"] == true)
724 .map(|m| string(&m["id"]).to_owned())
725 .collect()
726 };
727 if targets.is_empty() || method != "list_threads" && targets.len() != 1 {
728 return Err(Error::new(
729 400,
730 "Select one machine or supply machine_id for this command.",
731 ));
732 }
733 let params = command(method, json!(arguments))?;
734 let mut replies: futures::stream::FuturesUnordered<_> = targets.into_iter().map(|machine| {
735 let params = params.clone();
736 async move {
737 match self
738 .0
739 .relay
740 .dispatch(
741 &self.0.mcp,
742 id,
743 &machine,
744 method,
745 params,
746 Duration::from_secs(30),
747 )
748 .await
749 {
750 Ok(result) => json!({"machine_id":machine,"result":result}),
751 Err(error) => json!({"machine_id":machine,"error":error.message}),
752 }
753 }
754 }).collect();
755 let mut results = Vec::new();
756 let mut bytes = 0;
757 while let Some(reply) = futures::StreamExt::next(&mut replies).await {
758 bytes += reply.to_string().len();
759 if bytes > 16*1024*1024 {return Err(Error::new(413, "Machine replies exceed 16 MB. Select fewer machines or request fewer messages."));}
760 results.push(reply);
761 }
762 let errors = results.iter().any(|r| r.get("error").is_some());
763 let mut result = CallToolResult::structured(json!({"results":results}));
764 result.is_error = Some(errors);
765 Ok(result)
766 }
767 }
768 }
769 .await;
770 Ok(match result {
771 Ok(result) => result,
772 Err(error) => CallToolResult::error(vec![ContentBlock::text(if error.status >= 500 {
773 "The relay couldn't answer. Check its dashboard and retry.".to_owned()
774 } else {
775 error.message
776 })]),
777 }
778 .into())
779 }
780}
781pub fn router(app: Arc<App>) -> Router {
782 let state = app.clone();
783 let expected_host =
784 app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned();
785 Router::new()
786 .route("/pairing", any(pairing))
787 .route("/agent/connect", any(connect))
788 .route("/api/v1/{*path}", any(rest))
789 .with_state(app.clone())
790 .merge(mcp::router(app, "agents", move || {
791 Ok(Agents(state.clone()))
792 }))
793 .layer(axum::middleware::from_fn(
794 move |request: Request, next: axum::middleware::Next| {
795 let host = expected_host.clone();
796 async move {
797 if request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&host) {
798 return StatusCode::MISDIRECTED_REQUEST.into_response();
799 }
800 next.run(request).await
801 }
802 },
803 ))
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809 struct Fixture {
810 store: Arc<mcp::Store>,
811 broker: Arc<Broker>,
812 path: PathBuf,
813 machine: String,
814 grant: String,
815 }
816 impl Fixture {
817 fn new(write: bool) -> Self {
818 let path = std::env::temp_dir()
819 .canonicalize()
820 .unwrap()
821 .join(format!("studio-relay-test-{}", uuid::Uuid::new_v4()));
822 let store = Arc::new(mcp::Store::new(&path, "https://globe.studio.test").unwrap());
823 let machine = uuid::Uuid::new_v4().to_string();
824 let grant = uuid::Uuid::new_v4().to_string();
825 {
826 let db = store.db.lock().unwrap();
827 put(&db, &format!("machine:{machine}"), &json!({"id":machine,"user":"owner","name":"Fixture","tokenHash":hash("device-secret")}), 0).unwrap();
828 put(&db, &format!("grant:{grant}"), &json!({"id":grant,"user":"owner","resource":store.resource("agents"),"resources":[machine],"scopes":if write {json!(["sessions:read","sessions:write"])} else {json!(["sessions:read"])}}), 0).unwrap();
829 }
830 Self {
831 store,
832 broker: Arc::new(Broker::default()),
833 path,
834 machine,
835 grant,
836 }
837 }
838 fn connect(&self) -> (Arc<Connection>, mpsc::Receiver<Message>) {
839 let (messages, receive) = mpsc::channel(16);
840 let (closed, _) = watch::channel(false);
841 let connection = Arc::new(Connection {
842 messages,
843 closed,
844 pending: Mutex::new(HashMap::new()),
845 });
846 self.broker
847 .connections
848 .lock()
849 .unwrap()
850 .insert(self.machine.clone(), connection.clone());
851 (connection, receive)
852 }
853 fn dispatch(&self, duration: Duration) -> tokio::task::JoinHandle<Result<Value>> {
854 let (store, broker, machine, grant) = (
855 self.store.clone(),
856 self.broker.clone(),
857 self.machine.clone(),
858 self.grant.clone(),
859 );
860 tokio::spawn(async move {
861 broker
862 .dispatch(
863 &store,
864 &grant,
865 &machine,
866 "list_threads",
867 json!({}),
868 duration,
869 )
870 .await
871 })
872 }
873 }
874 impl Drop for Fixture {
875 fn drop(&mut self) {
876 std::fs::remove_dir_all(&self.path).unwrap();
877 }
878 }
879 #[test]
880 fn command_fields_defaults_and_provider_boundaries() {
881 assert_eq!(
882 command("list_threads", json!({})).unwrap(),
883 json!({"limit":30})
884 );
885 let id = uuid::Uuid::new_v4().to_string();
886 assert_eq!(
887 command("read_thread", json!({"provider":"claude","thread_id":id})).unwrap()["limit"],
888 20
889 );
890 for (method, params) in [
891 (
892 "read_thread",
893 json!({"provider":"codex","thread_id":"not-a-uuid"}),
894 ),
895 (
896 "read_thread",
897 json!({"provider":"native-chat","thread_id":id}),
898 ),
899 ("list_threads", json!({"limit":101})),
900 ("list_threads", json!({"limit":1.5})),
901 ("list_threads", json!({"approval_response":true})),
902 (
903 "send_message",
904 json!({"provider":"codex","thread_id":id,"message":""}),
905 ),
906 ("start_thread", json!({"provider":"claude","cwd":"/tmp"})),
907 (
908 "interrupt_thread",
909 json!({"provider":"codex","thread_id":id,"expected_turn_id":1}),
910 ),
911 ("approve_tool", json!({})),
912 ] {
913 assert!(command(method, params).is_err(), "{method}");
914 }
915 }
916 #[tokio::test]
917 async fn dispatch_uses_current_owner_grant_and_control_scope() {
918 let fixture = Fixture::new(false);
919 let (connection, mut receive) = fixture.connect();
920 let task = fixture.dispatch(Duration::from_secs(2));
921 let Message::Text(frame) = receive.recv().await.unwrap() else {
922 panic!()
923 };
924 let frame: Value = serde_json::from_str(&frame).unwrap();
925 assert_eq!(frame["params"], json!({"limit":30}));
926 connection
927 .pending
928 .lock()
929 .unwrap()
930 .remove(string(&frame["id"]))
931 .unwrap()
932 .send(Ok(json!({"threads":[]})))
933 .unwrap();
934 assert_eq!(task.await.unwrap().unwrap(), json!({"threads":[]}));
935 let refused = fixture
936 .broker
937 .dispatch(
938 &fixture.store,
939 &fixture.grant,
940 &fixture.machine,
941 "start_thread",
942 json!({"provider":"codex","cwd":"/tmp","message":"owned fixture"}),
943 Duration::from_secs(2),
944 )
945 .await
946 .unwrap_err();
947 assert_eq!(refused.status, 403);
948 assert!(receive.try_recv().is_err());
949 let foreign = uuid::Uuid::new_v4().to_string();
950 {
951 let db = fixture.store.db.lock().unwrap();
952 let mut grant = get(&db, &format!("grant:{}", fixture.grant)).unwrap();
953 grant["resources"] = json!([foreign]);
954 put(&db, &format!("grant:{}", fixture.grant), &grant, 0).unwrap();
955 put(
956 &db,
957 &format!("machine:{foreign}"),
958 &json!({"id":foreign,"user":"someone-else"}),
959 0,
960 )
961 .unwrap();
962 }
963 assert_eq!(
964 fixture
965 .broker
966 .dispatch(
967 &fixture.store,
968 &fixture.grant,
969 &foreign,
970 "list_threads",
971 json!({}),
972 Duration::from_secs(2)
973 )
974 .await
975 .unwrap_err()
976 .status,
977 403
978 );
979 delete(
980 &fixture.store.db.lock().unwrap(),
981 &format!("grant:{}", fixture.grant),
982 )
983 .unwrap();
984 assert_eq!(
985 fixture
986 .dispatch(Duration::from_secs(2))
987 .await
988 .unwrap()
989 .unwrap_err()
990 .status,
991 403
992 );
993 }
994 #[tokio::test]
995 async fn unicode_message_cannot_exceed_local_agent_frame_budget() {
996 let fixture = Fixture::new(true);
997 let (connection, mut receive) = fixture.connect();
998 let error = fixture.broker.dispatch(&fixture.store, &fixture.grant, &fixture.machine, "send_message", json!({"provider":"codex","thread_id":uuid::Uuid::new_v4().to_string(),"message":"雪".repeat(100000)}), Duration::from_secs(2)).await.unwrap_err();
999 assert_eq!(error.status, 413);
1000 assert!(receive.try_recv().is_err());
1001 assert!(connection.pending.lock().unwrap().is_empty());
1002 assert_eq!(fixture.broker.pending_slots.available_permits(), 128);
1003 }
1004 #[tokio::test]
1005 async fn eight_pending_limit_and_cancellation_release_capacity() {
1006 let fixture = Fixture::new(true);
1007 let (connection, mut receive) = fixture.connect();
1008 let mut tasks = Vec::new();
1009 for _ in 0..8 {
1010 tasks.push(fixture.dispatch(Duration::from_secs(2)));
1011 receive.recv().await.unwrap();
1012 }
1013 assert_eq!(
1014 fixture
1015 .dispatch(Duration::from_secs(2))
1016 .await
1017 .unwrap()
1018 .unwrap_err()
1019 .status,
1020 429
1021 );
1022 tasks.remove(0).abort();
1023 tokio::task::yield_now().await;
1024 assert_eq!(connection.pending.lock().unwrap().len(), 7);
1025 tasks.push(fixture.dispatch(Duration::from_secs(2)));
1026 receive.recv().await.unwrap();
1027 assert_eq!(connection.pending.lock().unwrap().len(), 8);
1028 fixture.broker.disconnect(&fixture.machine);
1029 for task in tasks {
1030 assert!(task.await.unwrap().unwrap_err().message.contains("unknown"));
1031 }
1032 assert!(connection.pending.lock().unwrap().is_empty());
1033 }
1034 #[tokio::test]
1035 async fn timeout_disconnect_and_reconnect_do_not_replay() {
1036 let fixture = Fixture::new(true);
1037 let (old, mut receive) = fixture.connect();
1038 let task = fixture.dispatch(Duration::from_millis(5));
1039 receive.recv().await.unwrap();
1040 assert!(task.await.unwrap().unwrap_err().message.contains("unknown"));
1041 assert!(old.pending.lock().unwrap().is_empty());
1042 assert!(receive.try_recv().is_err());
1043 let task = fixture.dispatch(Duration::from_secs(2));
1044 receive.recv().await.unwrap();
1045 fixture.broker.disconnect(&fixture.machine);
1046 assert!(task.await.unwrap().unwrap_err().message.contains("unknown"));
1047 let (new, mut receive) = fixture.connect();
1048 fixture.broker.remove(&fixture.machine, &old);
1049 assert!(Arc::ptr_eq(
1050 fixture
1051 .broker
1052 .connections
1053 .lock()
1054 .unwrap()
1055 .get(&fixture.machine)
1056 .unwrap(),
1057 &new
1058 ));
1059 assert!(receive.try_recv().is_err());
1060 let task = fixture.dispatch(Duration::from_secs(2));
1061 receive.recv().await.unwrap();
1062 fixture.broker.disconnect(&fixture.machine);
1063 assert!(task.await.unwrap().unwrap_err().message.contains("unknown"));
1064 }
1065}
dashboard/src/shale.rs created+847
......@@ -0,0 +1,847 @@
1use crate::*;
2use rmcp::{
3 ErrorData, RoleServer, ServerHandler,
4 model::{
5 CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult,
6 PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, ToolAnnotations,
7 },
8 service::RequestContext,
9};
10use scraper::{Html, Selector};
11
12pub struct Backend {
13 pub(crate) origin: url::Url,
14 http: reqwest::Client,
15 slots: Semaphore,
16}
17impl Backend {
18 pub fn new(address: &str, http: reqwest::ClientBuilder) -> Result<Self> {
19 let origin = url::Url::parse(address)?;
20 if origin.scheme() != "https"
21 || origin.host_str().is_none()
22 || !origin.username().is_empty()
23 || origin.password().is_some()
24 || origin.path() != "/"
25 || origin.query().is_some()
26 || origin.fragment().is_some()
27 {
28 return Err(Error::new(500, "Set an HTTPS origin for Shale."));
29 }
30 Ok(Self {
31 origin,
32 http: http
33 .redirect(reqwest::redirect::Policy::none())
34 .retry(reqwest::retry::never())
35 .build()?,
36 slots: Semaphore::new(8),
37 })
38 }
39 async fn get(
40 &self,
41 path: &str,
42 session: Option<&str>,
43 ) -> Result<(StatusCode, HeaderMap, String)> {
44 self.request(Method::GET, self.origin.join(path)?, session, None)
45 .await
46 }
47 async fn request(
48 &self,
49 method: Method,
50 target: url::Url,
51 session: Option<&str>,
52 form: Option<&HashMap<String, String>>,
53 ) -> Result<(StatusCode, HeaderMap, String)> {
54 if target.origin() != self.origin.origin()
55 || !target.username().is_empty()
56 || target.password().is_some()
57 || target.fragment().is_some()
58 {
59 return Err(Error::new(400, "Open a page on this Shale instance."));
60 }
61 let _slot = tokio::time::timeout(Duration::from_secs(5), self.slots.acquire())
62 .await
63 .map_err(|_| Error::new(429, "Shale is busy. Try again in a moment."))??;
64 let mut request = self.http.request(method, target.clone());
65 if let Some(session) = session {
66 let mut cookie = axum::http::HeaderValue::from_str(&format!("SessionID={session}"))?;
67 cookie.set_sensitive(true);
68 request = request.header("cookie", cookie);
69 }
70 if let Some(form) = form {
71 request = request
72 .header("origin", self.origin.origin().ascii_serialization())
73 .header("referer", target.as_str())
74 .form(form);
75 }
76 let mut response = request
77 .send()
78 .await
79 .map_err(|_| Error::new(502, "Shale couldn't answer. Open it and check its status."))?;
80 let status = response.status();
81 let headers = response.headers().clone();
82 let mut body = Vec::new();
83 while let Some(chunk) = response.chunk().await.map_err(|_| {
84 Error::new(
85 502,
86 "The Shale response was interrupted. Open it to check the result.",
87 )
88 })? {
89 if body.len() + chunk.len() > 4 * 1024 * 1024 {
90 return Err(Error::new(
91 502,
92 "The Shale page is too large. Narrow the selection.",
93 ));
94 }
95 body.extend_from_slice(&chunk);
96 }
97 Ok((status, headers, String::from_utf8(body)?))
98 }
99 async fn page(&self, target: &url::Url, session: &str) -> Result<String> {
100 let (status, _, body) = self
101 .request(Method::GET, target.clone(), Some(session), None)
102 .await?;
103 match status {
104 StatusCode::OK => Ok(body),
105 StatusCode::BAD_REQUEST => Err(Error::new(
106 400,
107 "Shale rejected this request. Check the fields or use Shale's issue filter syntax.",
108 )),
109 StatusCode::FORBIDDEN | StatusCode::NOT_FOUND => Err(Error::new(
110 403,
111 "Shale doesn't allow access to this repository. Check your account's permissions.",
112 )),
113 status if status.is_redirection() || status == StatusCode::UNAUTHORIZED => {
114 Err(Error::new(401, "The Shale session expired. Link it again."))
115 }
116 _ => Err(Error::new(
117 502,
118 "Shale couldn't open this page. Check it in Shale.",
119 )),
120 }
121 }
122}
123
124fn document(html: &str, page: &str, repository: Option<&str>) -> Result<Html> {
125 let document = Html::parse_document(html);
126 if document
127 .select(&Selector::parse("body").unwrap())
128 .next()
129 .and_then(|body| body.attr("id"))
130 != Some(page)
131 || repository.is_some_and(|name| {
132 document
133 .select(&Selector::parse("meta[name='astheno.shale.repo.name']").unwrap())
134 .next()
135 .and_then(|meta| meta.attr("content"))
136 != Some(name)
137 })
138 {
139 return Err(Error::new(
140 502,
141 "Shale's page changed. Open it to check the result.",
142 ));
143 }
144 Ok(document)
145}
146fn text(element: scraper::ElementRef<'_>) -> String {
147 element.text().collect::<String>().trim().to_owned()
148}
149fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Result<url::Url> {
150 if repository.is_empty()
151 || repository.len() > 255
152 || matches!(repository, "." | ".." | "-")
153 || repository
154 .chars()
155 .any(|c| c.is_control() || c.is_whitespace() || "/\\%?#".contains(c))
156 {
157 return Err(Error::new(
158 400,
159 "Choose a repository from this connection's access.",
160 ));
161 }
162 let mut target = origin.clone();
163 target
164 .path_segments_mut()
165 .unwrap()
166 .clear()
167 .push(repository)
168 .extend(suffix.iter().copied());
169 Ok(target)
170}
171fn session(app: &App, owner: &str) -> Result<String> {
172 let credential = mcp::get(
173 &app.mcp.db.lock().unwrap(),
174 &format!("shale-session:{owner}"),
175 )?;
176 if credential["origin"] != app.shale.origin.as_str() {
177 return Err(Error::new(
178 401,
179 "Link your Shale account from the dashboard's MCP tab.",
180 ));
181 }
182 credential["session"]
183 .as_str()
184 .map(str::to_owned)
185 .ok_or_else(|| Error::new(401, "Link your Shale account from the dashboard's MCP tab."))
186}
187pub async fn repositories(app: &App, owner: &str) -> Result<Vec<Value>> {
188 let session = session(app, owner)?;
189 username(
190 &app.shale
191 .page(&app.shale.origin.join("/-/settings")?, &session)
192 .await?,
193 )?;
194 let body = app.shale.page(&app.shale.origin, &session).await?;
195 let document = document(&body, "page-index", None)?;
196 let mut repositories = Vec::new();
197 for row in document.select(&Selector::parse(".grid-container table tbody tr").unwrap()) {
198 let cells: Vec<_> = row.select(&Selector::parse("td").unwrap()).collect();
199 if cells.len() != 3 {
200 return Err(Error::new(
201 502,
202 "Shale's repository list changed. Open Shale to browse it.",
203 ));
204 }
205 let link = cells[0]
206 .select(&Selector::parse("a").unwrap())
207 .next()
208 .ok_or_else(|| {
209 Error::new(
210 502,
211 "Shale's repository list changed. Open Shale to browse it.",
212 )
213 })?;
214 let name = text(link);
215 let target = app
216 .shale
217 .origin
218 .join(link.attr("href").unwrap_or_default())?;
219 if target != repository_path(&app.shale.origin, &name, &[""])?
220 || repositories.iter().any(|r: &Value| r["id"] == name)
221 {
222 return Err(Error::new(
223 502,
224 "Shale's repository list changed. Open Shale to browse it.",
225 ));
226 }
227 repositories.push(json!({"id":name,"name":name,"description":text(cells[1])}));
228 }
229 if repositories.len() > 1024 {
230 return Err(Error::new(
231 502,
232 "The repository list is too large. Open Shale to narrow it.",
233 ));
234 }
235 Ok(repositories)
236}
237fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {
238 let document = document(html, "page-issue", Some(repository))?;
239 let spans: Vec<_> = document
240 .select(&Selector::parse("h1 > span").unwrap())
241 .collect();
242 let issue_id = spans.first().and_then(|s| {
243 text(*s)
244 .strip_prefix('#')
245 .and_then(|s| s.parse::<u64>().ok())
246 });
247 let status = document
248 .select(&Selector::parse("select[name=status] option[selected]").unwrap())
249 .next()
250 .and_then(|e| e.attr("value"));
251 if spans.len() != 2
252 || issue_id.is_none_or(|n| n == 0 || id.is_some_and(|id| n != id))
253 || status.is_none()
254 {
255 return Err(Error::new(
256 502,
257 "Shale's issue page changed. Open the issue to check it.",
258 ));
259 }
260 let mut comments = Vec::new();
261 for comment in document.select(&Selector::parse("li.comment").unwrap()) {
262 let content = comment
263 .select(&Selector::parse(".markdown").unwrap())
264 .next()
265 .ok_or_else(|| {
266 Error::new(
267 502,
268 "Shale's comments changed. Open the issue to read them.",
269 )
270 })?;
271 let author = comment
272 .select(&Selector::parse(".n-card__header a[href^='/~']").unwrap())
273 .next()
274 .map(text);
275 let time = comment
276 .select(&Selector::parse(".n-card__header span[title]").unwrap())
277 .next()
278 .and_then(|e| e.attr("title"));
279 comments.push(
280 json!({"id":comment.attr("id"),"author":author,"createdAt":time,"text":text(content)}),
281 );
282 }
283 let labels: Vec<_> = document
284 .select(&Selector::parse("dd.sidebar-labels a").unwrap())
285 .map(text)
286 .collect();
287 Ok(
288 json!({"repository":repository,"id":issue_id,"title":text(spans[1]),"status":status,"labels":labels,"comments":comments}),
289 )
290}
291fn current(app: &App, grant: &Value, credential: &str) -> Result<()> {
292 let db = app.mcp.db.lock().unwrap();
293 if mcp::get(&db, &format!("grant:{}", string(&grant["id"])))? != *grant
294 || mcp::get(&db, &format!("shale-session:{}", string(&grant["user"])))?["session"]
295 != credential
296 {
297 return Err(Error::new(
298 401,
299 "This connection changed or was revoked. Connect again.",
300 ));
301 }
302 Ok(())
303}
304
305#[derive(Clone)]
306struct Shale(Arc<App>);
307impl ServerHandler for Shale {
308 fn get_info(&self) -> ServerConfig {
309 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
310 }
311 async fn list_tools(
312 &self,
313 _: Option<PaginatedRequestParams>,
314 _: RequestContext<RoleServer>,
315 ) -> std::result::Result<ListToolsResult, ErrorData> {
316 let tools = [
317 ("list_repositories", "List repositories granted to this connection.", json!({}), json!([]), true),
318 ("list_issues", "List issues in one granted repository.", json!({"repository":{"type":"string"},"q":{"type":"string","maxLength":4096,"description":"Shale filters using is, status, sort, limit, label, or author prefixes, such as is:open. Plain text search is unsupported."}}), json!(["repository"]), true),
319 ("get_issue", "Read an issue with rendered comment text and labels.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1}}), json!(["repository","id"]), true),
320 ("create_issue", "Create an issue. If the outcome is unknown, inspect the repository before retrying.", json!({"repository":{"type":"string"},"title":{"type":"string","minLength":1,"maxLength":4096},"description":{"type":"string","maxLength":262144}}), json!(["repository","title"]), false),
321 ("comment_issue", "Add a comment. If the outcome is unknown, inspect the issue before retrying.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"comment":{"type":"string","minLength":1,"maxLength":262144}}), json!(["repository","id","comment"]), false),
322 ("set_issue_status", "Change issue status using an available Shale status.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"status":{"type":"string","maxLength":64}}), json!(["repository","id","status"]), false),
323 ("set_issue_title", "Change an issue title.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"title":{"type":"string","minLength":1,"maxLength":4096}}), json!(["repository","id","title"]), false),
324 ].into_iter().map(|(name, description, properties, required, read)| {
325 Tool::new(name, description, json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}).as_object().unwrap().clone())
326 .with_annotations(ToolAnnotations::new().read_only(read).idempotent(read))
327 }).collect();
328 Ok(ListToolsResult {
329 tools,
330 ..Default::default()
331 })
332 }
333 async fn call_tool(
334 &self,
335 request: CallToolRequestParams,
336 context: RequestContext<RoleServer>,
337 ) -> std::result::Result<CallToolResponse, ErrorData> {
338 let result: Result<Value> = async {
339 let app = &self.0;
340 let grant = &context.extensions.get::<axum::http::request::Parts>()
341 .and_then(|p| p.extensions.get::<mcp::Grant>())
342 .ok_or_else(|| Error::new(401, "This connection expired. Connect again."))?.0;
343 let name = request.name.as_ref();
344 let arguments = request.arguments.unwrap_or_default();
345 let allowed: &[&str] = match name {
346 "list_repositories" => &[], "list_issues" => &["repository", "q"], "get_issue" => &["repository", "id"],
347 "create_issue" => &["repository", "title", "description"], "comment_issue" => &["repository", "id", "comment"],
348 "set_issue_status" => &["repository", "id", "status"], "set_issue_title" => &["repository", "id", "title"],
349 _ => return Err(Error::new(404, "No tool with that name.")),
350 };
351 if arguments.keys().any(|key| !allowed.contains(&key.as_str())) {
352 return Err(Error::new(400, "Use the fields listed for this tool."));
353 }
354 let write = !matches!(name, "list_repositories" | "list_issues" | "get_issue");
355 if !array(&grant["scopes"]).iter().any(|s| s == if write {"shale:write"} else {"shale:read"}) {
356 return Err(Error::new(403, "This connection allows reads only. Connect again to request issue editing."));
357 }
358 let credential = session(app, string(&grant["user"]))?;
359 current(app, grant, &credential)?;
360 if name == "list_repositories" {
361 let mut repositories = repositories(app, string(&grant["user"])).await?;
362 repositories.retain(|r| array(&grant["resources"]).contains(&r["id"]));
363 current(app, grant, &credential)?;
364 return Ok(json!({"repositories":repositories}));
365 }
366 let repository = arguments.get("repository").and_then(Value::as_str)
367 .filter(|r| array(&grant["resources"]).iter().any(|id| id == *r))
368 .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?;
369 let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?;
370 let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else {
371 Some(arguments.get("id").and_then(Value::as_u64).filter(|n| *n > 0)
372 .ok_or_else(|| Error::new(400, "Choose a positive issue ID."))?)
373 };
374 if let Some(id) = issue_id { target = repository_path(&app.shale.origin, repository, &["issues", &id.to_string()])?; }
375 if name == "create_issue" { target = repository_path(&app.shale.origin, repository, &["issues", "new"])?; }
376 if let Some(q) = arguments.get("q") {
377 let q = q.as_str().filter(|q| q.len() <= 4096).ok_or_else(|| Error::new(400, "Narrow the issue search."))?;
378 target.query_pairs_mut().append_pair("q", q);
379 }
380 let mut fields = HashMap::new();
381 for (key, max) in [("title", 4096), ("description", 262144), ("comment", 262144), ("status", 64)] {
382 if allowed.contains(&key) {
383 let value = arguments.get(key).and_then(Value::as_str)
384 .filter(|v| v.len() <= max && (key == "description" || !v.trim().is_empty()))
385 .or_else(|| (key == "description" && !arguments.contains_key(key)).then_some(""))
386 .ok_or_else(|| Error::new(400, format!("Enter {key} within the tool's size limit.")))?;
387 fields.insert(key.to_owned(), value.to_owned());
388 }
389 }
390 username(&app.shale.page(&app.shale.origin.join("/-/settings")?, &credential).await?)?;
391 let body = app.shale.page(&target, &credential).await?;
392 if name == "list_issues" {
393 let document = document(&body, "page-issues", Some(repository))?;
394 let mut issues = Vec::new();
395 for row in document.select(&Selector::parse(".grid-container table tbody tr").unwrap()) {
396 let cells: Vec<_> = row.select(&Selector::parse("td").unwrap()).collect();
397 if cells.len() != 6 { return Err(Error::new(502, "Shale's issue list changed. Open it in Shale.")); }
398 let id = text(cells[0]).strip_prefix('#').and_then(|s| s.parse::<u64>().ok()).filter(|n| *n > 0)
399 .ok_or_else(|| Error::new(502, "Shale's issue list changed. Open it in Shale."))?;
400 let status = cells[1].select(&Selector::parse("span[class]").unwrap())
401 .flat_map(|span| span.value().classes()).find_map(|class| class.strip_prefix("issuestatus-"))
402 .filter(|status| !status.is_empty())
403 .ok_or_else(|| Error::new(502, "Shale's issue list changed. Open it in Shale."))?;
404 issues.push(json!({"id":id,"title":text(cells[2]),"status":status,"author":text(cells[3]),
405 "modifiedAt":cells[4].select(&Selector::parse("span[title]").unwrap()).next().and_then(|e| e.attr("title")),
406 "createdAt":cells[5].select(&Selector::parse("span[title]").unwrap()).next().and_then(|e| e.attr("title"))}));
407 }
408 current(app, grant, &credential)?;
409 return Ok(json!({"repository":repository,"issues":issues}));
410 }
411 if !write {
412 let issue = issue(&body, repository, issue_id)?;
413 current(app, grant, &credential)?;
414 return Ok(json!({"issue":issue}));
415 }
416 let form_body = if name == "set_issue_title" {
417 issue(&body, repository, issue_id)?;
418 let mut edit = target.clone(); edit.query_pairs_mut().append_pair("edit", "title");
419 app.shale.page(&edit, &credential).await?
420 } else { body };
421 let post_target = {
422 let document = if name == "set_issue_title" { Html::parse_fragment(&form_body) } else {
423 document(&form_body, if name == "create_issue" {"page-new-issue"} else {"page-issue"}, Some(repository))?
424 };
425 let forms: Vec<_> = document.select(&Selector::parse("form[method=post]").unwrap())
426 .filter(|form| if name == "create_issue" { form.select(&Selector::parse("input[name=title]").unwrap()).next().is_some() }
427 else { form.select(&Selector::parse("input[name=t]").unwrap()).any(|input| input.attr("value") == Some(match name {
428 "comment_issue" => "comment", "set_issue_status" => "status", _ => "title",
429 })) }).collect();
430 if forms.len() != 1 { return Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")); }
431 let form = forms[0];
432 let post_target = target.join(form.attr("action").unwrap_or_default())?;
433 if post_target != target || form.attr("enctype").is_some_and(|s| s != "application/x-www-form-urlencoded") {
434 return Err(Error::new(502, "Shale's form destination changed. Open the issue to edit it."));
435 }
436 if name == "set_issue_status" && !form.select(&Selector::parse("select[name=status] option").unwrap())
437 .any(|option| option.attr("value") == fields.get("status").map(String::as_str)) {
438 return Err(Error::new(400, "Choose a status available on this issue in Shale."));
439 }
440 for field in form.select(&Selector::parse("input[type=hidden], input[hidden]").unwrap()) {
441 if let Some(name) = field.attr("name") {
442 if fields.insert(name.to_owned(), field.attr("value").unwrap_or_default().to_owned()).is_some() {
443 return Err(Error::new(502, "Shale's form fields changed. Open the issue to edit it."));
444 }
445 }
446 }
447 fields.insert("timezone".to_owned(), "UTC".to_owned());
448 fields.insert("tzoffset".to_owned(), "+00:00".to_owned());
449 post_target
450 };
451 current(app, grant, &credential)?;
452 let outcome: Result<Value> = async {
453 let (status, headers, body) = app.shale.request(Method::POST, post_target.clone(), Some(&credential), Some(&fields)).await?;
454 let body = if matches!(status, StatusCode::SEE_OTHER | StatusCode::FOUND) {
455 let target = headers.get("location").and_then(|v| v.to_str().ok())
456 .and_then(|location| post_target.join(location).ok())
457 .ok_or_else(|| Error::new(502, "Shale didn't return an issue destination."))?;
458 let prefix = repository_path(&app.shale.origin, repository, &["issues", ""])?;
459 if target.origin() != app.shale.origin.origin() || !target.path().starts_with(prefix.path())
460 || target.query().is_some() || target.fragment().is_some()
461 || target.path()[prefix.path().len()..].parse::<u64>().ok().is_none_or(|n| n == 0)
462 || !target.username().is_empty() || target.password().is_some()
463 { return Err(Error::new(502, "Shale returned a different destination.")); }
464 app.shale.page(&target, &credential).await?
465 } else if status == StatusCode::OK { body } else {
466 return Err(Error::new(502, "Shale didn't confirm the issue change."));
467 };
468 let result = issue(&body, repository, issue_id)?;
469 current(app, grant, &credential)?;
470 Ok(json!({"issue":result}))
471 }.await;
472 outcome.map_err(|_| Error::new(502, "The write outcome is unknown. Check the issue in Shale before retrying."))
473 }.await;
474 Ok(match result {
475 Ok(value) => CallToolResult::structured(value),
476 Err(error) => CallToolResult::error(vec![ContentBlock::text(error.message)]),
477 }
478 .into())
479 }
480}
481pub fn router(app: Arc<App>) -> Router {
482 let state = app.clone();
483 mcp::router(app, "shale", move || Ok(Shale(state.clone())))
484}
485
486fn session_cookie(headers: &HeaderMap) -> Result<String> {
487 let sessions: Vec<_> = headers
488 .get_all("set-cookie")
489 .iter()
490 .filter_map(|value| value.to_str().ok())
491 .filter_map(|value| value.split(';').next()?.strip_prefix("SessionID="))
492 .collect();
493 if sessions.len() != 1
494 || sessions[0].is_empty()
495 || sessions[0].len() > 4096
496 || !sessions[0]
497 .bytes()
498 .all(|b| matches!(b, 0x21 | 0x23..=0x2b | 0x2d..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e))
499 {
500 return Err(Error::new(
501 502,
502 "Shale didn't return a session. Link it again.",
503 ));
504 }
505 Ok(sessions[0].to_owned())
506}
507fn fields(query: &str) -> Result<HashMap<String, String>> {
508 if query.len() > 16384 {
509 return Err(Error::new(
510 400,
511 "This sign-in response is too large. Link Shale again.",
512 ));
513 }
514 let mut fields = HashMap::new();
515 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
516 if fields
517 .insert(key.into_owned(), value.into_owned())
518 .is_some()
519 {
520 return Err(Error::new(
521 400,
522 "This sign-in response has repeated fields. Link Shale again.",
523 ));
524 }
525 }
526 Ok(fields)
527}
528fn username(page: &str) -> Result<String> {
529 let document = Html::parse_document(page);
530 if document
531 .select(&Selector::parse("body#page-user-settings").unwrap())
532 .next()
533 .is_none()
534 {
535 return Err(Error::new(401, "The Shale session expired. Link it again."));
536 }
537 let names: Vec<_> = document
538 .select(&Selector::parse("kbd").unwrap())
539 .map(|element| element.text().collect::<String>())
540 .collect();
541 if names.len() != 1 || names[0].is_empty() {
542 return Err(Error::new(
543 502,
544 "Shale's account page changed. Open Shale to check your account.",
545 ));
546 }
547 Ok(names.into_iter().next().unwrap())
548}
549
550pub async fn manage(
551 app: Arc<App>,
552 method: &Method,
553 owner: &Value,
554 body: &Value,
555) -> Result<Response> {
556 let owner_id = string(&owner["id"]);
557 let key = format!("shale-session:{owner_id}");
558 match *method {
559 Method::POST => {
560 let pending = if let Some(id) = body["request"].as_str() {
561 let db = app.mcp.db.lock().unwrap();
562 let key = format!("pending:{}", mcp::hash(id));
563 let pending = mcp::get(&db, &key)?;
564 if pending["owner"] != owner_id || pending["resource"] != app.mcp.resource("shale")
565 {
566 return Err(Error::new(
567 403,
568 "Open your Shale connection request before linking.",
569 ));
570 }
571 Some(id.to_owned())
572 } else {
573 None
574 };
575 let (status, headers, _) = app.shale.get("/-/login", None).await?;
576 if status != StatusCode::FOUND {
577 return Err(Error::new(
578 502,
579 "Shale couldn't start sign-in. Open Shale and try again.",
580 ));
581 }
582 let authorization = url::Url::parse(
583 headers
584 .get("location")
585 .and_then(|v| v.to_str().ok())
586 .unwrap_or_default(),
587 )?;
588 let issuer = url::Url::parse(&env(
589 "STUDIO_KEYCLOAK_URL",
590 &format!("https://keycloak.{}", env("STUDIO_DOMAIN", "studio.test")),
591 ))?;
592 let parameters = fields(authorization.query().unwrap_or_default())?;
593 if authorization.origin() != issuer.origin()
594 || authorization.path() != "/realms/master/protocol/openid-connect/auth"
595 || !authorization.username().is_empty()
596 || authorization.password().is_some()
597 || authorization.fragment().is_some()
598 || parameters.get("redirect_uri")
599 != Some(&app.shale.origin.join("/-/callback")?.to_string())
600 || parameters.get("response_type").map(String::as_str) != Some("code")
601 || parameters
602 .get("state")
603 .is_none_or(|s| s.is_empty() || s.len() > 1024)
604 {
605 return Err(Error::new(
606 502,
607 "Shale's sign-in destination doesn't match this instance. Check its OIDC settings.",
608 ));
609 }
610 let nonce = mcp::secret();
611 let mut db = app.mcp.db.lock().unwrap();
612 let tx = db.transaction()?;
613 tx.execute("DELETE FROM records WHERE substr(key,1,11)='shale-link:' AND json_extract(value,'$.owner')=?", [owner_id])?;
614 mcp::put(
615 &tx,
616 &format!("shale-link:{}", mcp::hash(&nonce)),
617 &json!({"owner":owner_id,"authorization":authorization.as_str(),"phase":"prepared","request":pending}),
618 600,
619 )?;
620 tx.commit()?;
621 Ok(axum::Json(json!({"redirect":app.shale.origin.join(&format!("/-/studio-mcp/{nonce}"))?.as_str()})).into_response())
622 }
623 Method::DELETE => {
624 let session = {
625 let mut db = app.mcp.db.lock().unwrap();
626 let tx = db.transaction()?;
627 let session = mcp::get(&tx, &key)?;
628 mcp::delete(&tx, &key)?;
629 for grant in mcp::list(&tx, "grant:")? {
630 if grant["user"] == owner_id && grant["resource"] == app.mcp.resource("shale") {
631 mcp::revoke(&tx, string(&grant["id"]))?;
632 }
633 }
634 tx.execute("DELETE FROM records WHERE substr(key,1,11)='shale-link:' AND json_extract(value,'$.owner')=?", [owner_id])?;
635 tx.commit()?;
636 session
637 };
638 if session["origin"] == app.shale.origin.as_str() {
639 app.shale
640 .get("/-/logout", session["session"].as_str())
641 .await?;
642 }
643 Ok(StatusCode::NO_CONTENT.into_response())
644 }
645 _ => Err(Error::new(405, "Link or unlink Shale from MCP settings.")),
646 }
647}
648
649pub async fn oauth(app: Arc<App>, request: Request) -> Response {
650 let callback = request.uri().path() == "/oauth/shale/callback";
651 let result: Result<Response> = async move {
652 if request.method() != Method::GET
653 || request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&app.shale.origin[url::Position::BeforeHost..url::Position::AfterPort])
654 || request.headers().get("origin").is_some_and(|v| v.to_str().ok() != Some(app.shale.origin.origin().ascii_serialization().as_str()))
655 {
656 return Err(Error::new(403, "Start Shale linking from your dashboard's MCP tab."));
657 }
658 if let Some(nonce) = request.uri().path().strip_prefix("/oauth/shale/link/") {
659 if nonce.len() != 43 || !nonce.bytes().all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)) || request.uri().query().is_some() {
660 return Err(Error::new(400, "This Shale link is incomplete. Start linking again."));
661 }
662 let mut db = app.mcp.db.lock().unwrap();
663 let tx = db.transaction()?;
664 let key = format!("shale-link:{}", mcp::hash(nonce));
665 let mut link = mcp::get(&tx, &key)?;
666 if link.is_null() || link["phase"] != "prepared" {
667 return Err(Error::new(410, "This Shale link expired or was used. Start linking again."));
668 }
669 link["phase"] = json!("claimed");
670 tx.execute("UPDATE records SET value=? WHERE key=?", rusqlite::params![link.to_string(), key])?;
671 tx.commit()?;
672 return Ok((StatusCode::FOUND, [
673 ("location", string(&link["authorization"]).to_owned()),
674 ("set-cookie", format!("studio_mcp_shale_link={nonce}; Path=/-/callback; Secure; HttpOnly; SameSite=Lax; Max-Age=600")),
675 ]).into_response());
676 }
677 if !callback {
678 return Err(Error::new(404, "No Shale sign-in endpoint here."));
679 }
680 let cookies: Vec<_> = request.headers().get_all("cookie").iter()
681 .filter_map(|v| v.to_str().ok()).flat_map(|v| v.split(';'))
682 .filter_map(|part| part.trim().strip_prefix("studio_mcp_shale_link=")).collect();
683 if cookies.len() != 1 || cookies[0].len() != 43 {
684 return Err(Error::new(400, "This Shale link cookie is missing. Start linking again."));
685 }
686 let query = request.uri().query().unwrap_or_default().to_owned();
687 let parameters = fields(&query)?;
688 let pending_key = format!("shale-link:{}", mcp::hash(cookies[0]));
689 let link = {
690 let mut db = app.mcp.db.lock().unwrap();
691 let tx = db.transaction()?;
692 let mut link = mcp::get(&tx, &pending_key)?;
693 if link.is_null() || link["phase"] != "claimed" {
694 return Err(Error::new(410, "This Shale link expired or was used. Start linking again."));
695 }
696 let authorization = url::Url::parse(string(&link["authorization"]))?;
697 let expected = fields(authorization.query().unwrap_or_default())?;
698 if parameters.get("state") != expected.get("state") {
699 return Err(Error::new(403, "This response belongs to another Shale sign-in. Start linking again."));
700 }
701 link["phase"] = json!("processing");
702 tx.execute("UPDATE records SET value=? WHERE key=?", rusqlite::params![link.to_string(), pending_key])?;
703 tx.commit()?;
704 link
705 };
706 if parameters.get("code").is_none_or(|code| code.is_empty() || code.len() > 4096) || parameters.contains_key("error") {
707 return Err(Error::new(400, "Shale sign-in was declined or incomplete. Start linking again."));
708 }
709 let identity = host::call(json!({"operation":"iam.request", "path":format!("/users/{}", string(&link["owner"])), "method":"GET", "body":null})).await?;
710 if identity["body"]["enabled"] != true {
711 return Err(Error::new(403, "This dashboard account is disabled. Contact its administrator."));
712 }
713 let (status, headers, _) = app.shale.get(&format!("/-/callback?{query}"), None).await?;
714 if !status.is_redirection() {
715 return Err(Error::new(502, "Shale couldn't finish sign-in. Link it again."));
716 }
717 let session = session_cookie(&headers)?;
718 let verified: Result<()> = async {
719 let (status, _, body) = app.shale.get("/-/settings", Some(&session)).await?;
720 if status != StatusCode::OK || username(&body)? != identity["body"]["username"] {
721 return Err(Error::new(403, "Sign in to Shale with the same account as your dashboard, then link it again."));
722 }
723 let owner = string(&link["owner"]);
724 let session_key = format!("shale-session:{owner}");
725 let previous = mcp::get(&app.mcp.db.lock().unwrap(), &session_key)?;
726 if previous["origin"] == app.shale.origin.as_str() && previous["session"] != session {
727 app.shale.get("/-/logout", previous["session"].as_str()).await?;
728 }
729 let mut db = app.mcp.db.lock().unwrap();
730 let tx = db.transaction()?;
731 if mcp::get(&tx, &pending_key)?["phase"] != "processing" {
732 return Err(Error::new(410, "This Shale link was cancelled. Start linking again."));
733 }
734 mcp::put(&tx, &session_key, &json!({"origin":app.shale.origin.as_str(),"session":session,"linkedAt":now()}), 0)?;
735 mcp::delete(&tx, &pending_key)?;
736 tx.commit()?;
737 Ok(())
738 }.await;
739 if let Err(error) = verified {
740 let _ = app.shale.get("/-/logout", Some(&session)).await;
741 return Err(error);
742 }
743 let mut target = app.mcp.origin.join("mcp")?;
744 if let Some(request) = link["request"].as_str() {
745 target.query_pairs_mut().append_pair("request", request);
746 }
747 Ok((StatusCode::SEE_OTHER, [("location", target.to_string())]).into_response())
748 }.await;
749 let mut response = match result {
750 Ok(response) => response,
751 Err(error) => error.into_response(),
752 };
753 response
754 .headers_mut()
755 .insert("cache-control", "no-store".parse().unwrap());
756 response
757 .headers_mut()
758 .insert("referrer-policy", "no-referrer".parse().unwrap());
759 if callback {
760 response.headers_mut().append(
761 "set-cookie",
762 "studio_mcp_shale_link=; Path=/-/callback; Secure; HttpOnly; SameSite=Lax; Max-Age=0"
763 .parse()
764 .unwrap(),
765 );
766 }
767 response
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773 #[test]
774 fn repository_names_cannot_change_origin_or_path_segments() {
775 let origin = url::Url::parse("https://shale.studio.test").unwrap();
776 for name in [
777 "",
778 ".",
779 "..",
780 "-",
781 "../other",
782 "one/two",
783 "one\\two",
784 "%2e%2e",
785 "one?x",
786 "one#x",
787 "one\n",
788 "//foreign.test",
789 ] {
790 assert!(
791 repository_path(&origin, name, &["issues", "1"]).is_err(),
792 "{name:?}"
793 );
794 }
795 let path = repository_path(&origin, "雪☃", &["issues", "1"]).unwrap();
796 assert_eq!(path.origin(), origin.origin());
797 assert_eq!(path.path(), "/%E9%9B%AA%E2%98%83/issues/1");
798 }
799 #[test]
800 fn issue_pages_must_match_repository_identity_and_issue_number() {
801 let page = "<meta name='astheno.shale.repo.name' content='owned'><body id=page-issue><h1><span>#3</span><span>Snow &amp; ☃</span></h1><select name=status><option selected value=done>Done</option></select>";
802 assert_eq!(issue(page, "owned", Some(3)).unwrap()["title"], "Snow & ☃");
803 assert!(issue(page, "other", Some(3)).is_err());
804 assert!(issue(page, "owned", Some(4)).is_err());
805 assert!(issue(&page.replace("page-issue", "page-login"), "owned", Some(3)).is_err());
806 assert!(issue(&page.replace("selected", ""), "owned", Some(3)).is_err());
807 }
808 #[test]
809 fn account_identity_uses_html_text_and_rejects_login_or_changed_markup() {
810 assert_eq!(
811 username("<body id=page-user-settings><kbd>snow&amp;flake☃</kbd>").unwrap(),
812 "snow&flake☃"
813 );
814 for html in [
815 "<body><kbd>snow</kbd>",
816 "<body id=page-user-settings>",
817 "<body id=page-user-settings><kbd>snow</kbd><kbd>other</kbd>",
818 ] {
819 assert!(username(html).is_err());
820 }
821 }
822 #[test]
823 fn callback_fields_and_session_cookie_refuse_ambiguity_and_header_injection() {
824 assert!(fields("state=one&state=two").is_err());
825 assert!(fields(&"s".repeat(16385)).is_err());
826 let mut headers = HeaderMap::new();
827 headers.append(
828 "set-cookie",
829 "SessionID=owned:signature; Path=/; Secure; HttpOnly"
830 .parse()
831 .unwrap(),
832 );
833 assert_eq!(session_cookie(&headers).unwrap(), "owned:signature");
834 headers.append("set-cookie", "SessionID=other; Path=/".parse().unwrap());
835 assert!(session_cookie(&headers).is_err());
836 for value in [
837 "SessionID=",
838 "SessionID=with space",
839 "SessionID=bad,other",
840 "SessionID=\"quoted\"",
841 ] {
842 let mut headers = HeaderMap::new();
843 headers.insert("set-cookie", value.parse().unwrap());
844 assert!(session_cookie(&headers).is_err());
845 }
846 }
847}
dashboard/src/storage.rs created+255
......@@ -0,0 +1,255 @@
1use crate::*;
2
3async fn datasets() -> Result<Value> {
4 host::call(json!({"operation":"storage.datasets"})).await
5}
6fn leaves(vdev: &Value, out: &mut Vec<Value>) {
7 if let Some(children) = vdev["vdevs"].as_object() {
8 for child in children.values() {
9 leaves(child, out);
10 }
11 } else {
12 let errors = number(&vdev["read_errors"])
13 + number(&vdev["write_errors"])
14 + number(&vdev["checksum_errors"]);
15 let mut issues = Vec::new();
16 if vdev["state"] != "ONLINE" {
17 issues.push(string(&vdev["state"]).to_lowercase());
18 }
19 if errors > 0.0 {
20 issues.push(format!(
21 "{errors} error{}",
22 if errors == 1.0 { "" } else { "s" }
23 ));
24 }
25 out.push(json!({"name":vdev["name"],"state":vdev["state"],"read":vdev["read_errors"],"write":vdev["write_errors"],"checksum":vdev["checksum_errors"],"smart":null,"issue":if issues.is_empty() {None} else {Some(issues.join(", "))}}));
26 }
27}
28async fn pool() -> Result<Value> {
29 let value = host::call(json!({"operation":"storage.pool"})).await?;
30 let name = string(&value["name"]);
31 let pool = &value["status"]["pools"][name];
32 let mut output = value["summary"].clone();
33 output["name"] = json!(name);
34 output["state"] = pool["state"].clone();
35 output["scan"] = Value::Null;
36 let mut vdevs = Vec::new();
37 let mut striped = Vec::new();
38 for vdev in pool["vdevs"][name]["vdevs"]
39 .as_object()
40 .into_iter()
41 .flat_map(|v| v.values())
42 {
43 if vdev["vdevs"].is_object() {
44 let mut disks = Vec::new();
45 leaves(vdev, &mut disks);
46 vdevs.push(json!({"name":vdev["name"],"state":vdev["state"],"disks":disks}));
47 } else {
48 leaves(vdev, &mut striped);
49 }
50 }
51 if !striped.is_empty() {
52 vdevs.insert(
53 0,
54 json!({"name":name,"state":pool["state"],"disks":striped}),
55 );
56 }
57 for kind in ["logs", "l2cache", "special", "dedup", "spares"] {
58 let children: Vec<_> = pool[kind]
59 .as_object()
60 .into_iter()
61 .flat_map(|v| v.values())
62 .collect();
63 if children.is_empty() {
64 continue;
65 }
66 let worst = children
67 .iter()
68 .find(|v| v["state"] != "ONLINE" && v["state"] != "AVAIL")
69 .unwrap_or(&children[0]);
70 let mut disks = Vec::new();
71 for child in &children {
72 leaves(child, &mut disks);
73 }
74 vdevs.push(json!({"name":if kind=="l2cache" {"cache"} else {kind},"state":worst["state"],"disks":disks}));
75 }
76 output["vdevs"] = json!(vdevs);
77 let scan = &pool["scan_stats"];
78 if ["SCANNING", "FINISHED", "CANCELED"].contains(&string(&scan["state"])) {
79 output["scan"] = json!({"kind":if scan["function"]=="RESILVER" {"resilver"} else {"scrub"},"state":string(&scan["state"]).to_lowercase(),"start":scan["start_time"],"end":if scan["state"]=="SCANNING" {Value::Null} else {scan["end_time"].clone()},"examined":scan["examined"],"total":scan["to_examine"],"repaired":scan["processed"],"errors":scan["errors"]});
80 }
81 output["errors"] = json!(if number(&pool["error_count"]) > 0.0 {
82 format!("{} data errors", number(&pool["error_count"]))
83 } else {
84 "No known data errors".into()
85 });
86 Ok(output)
87}
88pub async fn snapshots(dataset: &str) -> Result<Value> {
89 validate(dataset, false)?;
90 host::call(json!({"operation":"storage.snapshots","dataset":dataset})).await
91}
92pub fn validate(name: &str, snapshot: bool) -> Result<()> {
93 let regex = if snapshot {
94 r"^[\w.: ][\w.: -]*$"
95 } else {
96 r"^[\w.: ][\w.: -]*(/[\w.: -]+)*$"
97 };
98 if regex::Regex::new(regex).unwrap().is_match(name) {
99 Ok(())
100 } else {
101 Err(Error::new(
102 400,
103 "That isn't a ZFS name. Pick one from the list.",
104 ))
105 }
106}
107async fn existing(dataset: &str, from: &str, to: &str) -> Result<()> {
108 validate(from, true)?;
109 validate(to, true)?;
110 let values = snapshots(dataset).await?;
111 for name in [from, to] {
112 if !array(&values).iter().any(|s| s["name"] == name) {
113 return Err(Error::new(404, format!("No snapshot {dataset}@{name}")));
114 }
115 }
116 Ok(())
117}
118pub fn unescape(name: &str) -> String {
119 let bytes = name.as_bytes();
120 let mut out = Vec::new();
121 let mut i = 0;
122 while i < bytes.len() {
123 if bytes[i] == b'\\'
124 && i + 4 < bytes.len()
125 && bytes[i + 1..i + 5]
126 .iter()
127 .all(|b| (b'0'..=b'7').contains(b))
128 {
129 let octal = std::str::from_utf8(&bytes[i + 1..i + 5]).unwrap();
130 out.push(u16::from_str_radix(octal, 8).unwrap() as u8);
131 i += 5;
132 } else {
133 out.push(bytes[i]);
134 i += 1;
135 }
136 }
137 String::from_utf8_lossy(&out).into_owned()
138}
139pub async fn route(
140 app: Arc<App>,
141 method: &Method,
142 parts: &[&str],
143 query: &HashMap<String, String>,
144 body: Value,
145) -> Result<Response> {
146 let value = match parts {
147 [] if method == Method::GET => {
148 let found=app.cache.get("storage".into(),Duration::from_secs(30),move || async move {
149 let (pool,mut datasets)=tokio::try_join!(pool(),datasets())?;let root=array(&datasets).iter().find(|d| d["name"]==pool["name"]).ok_or_else(|| Error::new(502,"The storage pool has no root dataset."))?;let held:f64=array(&datasets).iter().map(|d| number(&d["usedbysnapshots"])).sum();let space=json!({"live":number(&root["used"])-held,"held":held,"free":root["available"]});
150 let media=env("STUDIO_MEDIA_ROOT",&format!("{}/clover/Media",env("STUDIO_STORE_ROOT","/srv")));for dataset in datasets.as_array_mut().unwrap() {let mount=dataset["mountpoint"].as_str().map(str::to_owned);dataset["link"]=mount.as_ref().map(|p| apps::file_link(p,false)).unwrap_or(Value::Null);dataset["media"]=mount.and_then(|p| std::path::Path::new(&p).strip_prefix(&media).ok().map(|p| p.to_string_lossy().into_owned())).map(|p| json!(p)).unwrap_or(Value::Null);}Ok(json!({"pool":pool,"datasets":datasets,"space":space}))
151 }).await?;
152 return Ok(found.response());
153 }
154 ["snapshots"] if method == Method::GET => {
155 let dataset = query
156 .get("dataset")
157 .ok_or_else(|| Error::new(400, "Pick a dataset."))?;
158 json!(
159 array(&snapshots(dataset).await?)
160 .iter()
161 .filter(|s| !string(&s["name"]).starts_with("index-"))
162 .cloned()
163 .collect::<Vec<_>>()
164 )
165 }
166 ["reclaim"] if method == Method::GET => {
167 let dataset = query
168 .get("dataset")
169 .ok_or_else(|| Error::new(400, "Pick a dataset."))?;
170 let from = query
171 .get("from")
172 .ok_or_else(|| Error::new(400, "Pick a snapshot."))?;
173 let to = query
174 .get("to")
175 .ok_or_else(|| Error::new(400, "Pick a snapshot."))?;
176 existing(dataset, from, to).await?;
177 let value = host::call(
178 json!({"operation":"storage.reclaim","dataset":dataset,"from":from,"to":to}),
179 )
180 .await?;
181 let bytes = string(&value)
182 .lines()
183 .find_map(|line| line.strip_prefix("reclaim\t"))
184 .and_then(|s| s.parse::<f64>().ok())
185 .unwrap_or(0.0);
186 json!({"bytes":bytes})
187 }
188 ["destroy"] if method == Method::POST => {
189 let (dataset, from, to) = (
190 string(&body["dataset"]),
191 string(&body["from"]),
192 string(&body["to"]),
193 );
194 existing(dataset, from, to).await?;
195 host::call(
196 json!({"operation":"storage.destroy","dataset":dataset,"from":from,"to":to}),
197 )
198 .await?;
199 app.cache.invalidate("storage");
200 Value::Null
201 }
202 ["removed"] if method == Method::GET => {
203 let dataset = query
204 .get("dataset")
205 .ok_or_else(|| Error::new(400, "Pick a dataset."))?;
206 let snapshot = query
207 .get("snapshot")
208 .ok_or_else(|| Error::new(400, "Pick a snapshot."))?;
209 validate(dataset, false)?;
210 validate(snapshot, true)?;
211 let values = datasets().await?;
212 let mount = array(&values)
213 .iter()
214 .find(|d| d["name"] == *dataset)
215 .and_then(|d| d["mountpoint"].as_str())
216 .ok_or_else(|| {
217 Error::new(
218 409,
219 format!("{dataset} isn't mounted, so its deleted files can't be listed"),
220 )
221 })?;
222 let value = host::call(
223 json!({"operation":"storage.removed","dataset":dataset,"snapshot":snapshot}),
224 )
225 .await?;
226 let text = string(&value);
227 let mut files = Vec::new();
228 for line in text.lines() {
229 if let Some(path) = line.strip_prefix("-\t") {
230 let path = unescape(path);
231 let Ok(relative) = std::path::Path::new(&path).strip_prefix(mount) else {
232 continue;
233 };
234 let saved = std::path::Path::new(mount)
235 .join(".zfs/snapshot")
236 .join(snapshot)
237 .join(relative);
238 if let Ok(info) = tokio::fs::symlink_metadata(saved).await
239 && info.is_file()
240 {
241 files.push(json!({"path":relative,"size":info.len()}));
242 }
243 }
244 }
245 files.sort_by(|a, b| number(&b["size"]).total_cmp(&number(&a["size"])));
246 json!({"count":files.len(),"largest":files.into_iter().take(4).collect::<Vec<_>>()})
247 }
248 _ => return Err(Error::new(404, "Not Found")),
249 };
250 Ok(if value.is_null() {
251 StatusCode::NO_CONTENT.into_response()
252 } else {
253 Document::new(value).response()
254 })
255}
dashboard/src/telemetry.rs created+929
......@@ -0,0 +1,929 @@
1use crate::*;
2use futures::{StreamExt, stream};
3
4pub async fn endpoint(app: Arc<App>, service: &str) -> Result<String> {
5 if let Some((base, _)) = &app.internal {
6 return Ok(format!("{}services/{}", base.as_str(), encoded(service)));
7 }
8 let id = service.to_owned();
9 let state = app.clone();
10 let found = app
11 .cache
12 .get(
13 format!("endpoint:{service}"),
14 Duration::from_secs(10),
15 move || async move {
16 let values = core::nomad(&state, &format!("/v1/service/{}", encoded(&id))).await?;
17 let instance = array(&values)
18 .iter()
19 .find(|v| v["Address"] == "127.0.0.1")
20 .or_else(|| array(&values).first())
21 .ok_or_else(|| {
22 Error::new(501, format!("{id} isn't running on this host yet."))
23 })?;
24 let address = string(&instance["Address"]);
25 Ok(json!(format!(
26 "http://{}:{}",
27 if address.contains(':') {
28 format!("[{address}]")
29 } else {
30 address.into()
31 },
32 number(&instance["Port"]) as u16
33 )))
34 },
35 )
36 .await?;
37 Ok(string(&found.value).to_owned())
38}
39
40pub async fn read(
41 app: Arc<App>,
42 url: String,
43 form: Option<Vec<(String, String)>>,
44) -> Result<Arc<Document>> {
45 let state = app.clone();
46 let key = format!("read:{url}:{}", serde_json::to_string(&form)?);
47 app.cache
48 .get(key, Duration::from_secs(2), move || async move {
49 let request = if let Some(form) = &form {
50 state.request(Method::POST, &url)?.form(form)
51 } else {
52 state.request(Method::GET, &url)?
53 };
54 let response = request
55 .timeout(Duration::from_secs(8))
56 .send()
57 .await
58 .map_err(|_| Error::new(502, "The telemetry store isn't answering."))?;
59 if !response.status().is_success() {
60 return Err(Error::new(
61 502,
62 format!("The telemetry store answered {}.", response.status()),
63 ));
64 }
65 if form.is_some() {
66 Ok(json!(response.text().await?))
67 } else {
68 Ok(response.json().await?)
69 }
70 })
71 .await
72}
73
74const METRICS: &[(&str, &str)] = &[
75 ("host.cpu", "studio_host_cpu_percent"),
76 ("host.memory", "studio_host_memory_bytes"),
77 ("host.temperature", "studio_host_temperature_celsius"),
78 ("host.power", "studio_host_power_watts"),
79 ("host.gpu", "studio_host_gpu_percent"),
80 ("host.network", "studio_host_network_bytes_per_second"),
81 ("service.cpu", "studio_service_cpu_cores"),
82 ("service.memory", "studio_service_memory_bytes"),
83 ("vm.cpu", "studio_vm_cpu_percent"),
84 ("vm.memory", "studio_vm_memory_bytes"),
85];
86
87fn series(result: &Value, application: bool) -> Value {
88 json!(
89 array(&result["data"]["result"])
90 .iter()
91 .take(if application { 12 } else { usize::MAX })
92 .map(|row| {
93 let metric = &row["metric"];
94 let name = if application {
95 let labels = metric
96 .as_object()
97 .into_iter()
98 .flat_map(|v| v.iter())
99 .filter(|(k, _)| {
100 !["__name__", "service", "service_name"].contains(&k.as_str())
101 })
102 .map(|(k, v)| format!("{k}={}", string(v)))
103 .collect::<Vec<_>>()
104 .join(" · ");
105 if labels.is_empty() {
106 "total".into()
107 } else {
108 labels
109 }
110 } else {
111 metric["service"]
112 .as_str()
113 .or(metric["vm"].as_str())
114 .or(metric["direction"].as_str())
115 .unwrap_or("total")
116 .into()
117 };
118 let t: Vec<_> = array(&row["values"]).iter().map(|v| v[0].clone()).collect();
119 let v: Vec<_> = array(&row["values"])
120 .iter()
121 .map(|v| {
122 let n = string(&v[1]).parse::<f64>().unwrap_or(f64::NAN);
123 if n.is_finite() { json!(n) } else { Value::Null }
124 })
125 .collect();
126 json!({"name":name,"t":t,"v":v})
127 })
128 .collect::<Vec<_>>()
129 )
130}
131
132pub fn query_number(
133 query: &HashMap<String, String>,
134 key: &str,
135 default: f64,
136 min: f64,
137 max: f64,
138) -> Result<f64> {
139 let value = query
140 .get(key)
141 .map(|v| v.parse::<f64>())
142 .transpose()
143 .map_err(|_| Error::new(400, format!("Invalid {key}.")))?
144 .unwrap_or(default);
145 if !value.is_finite() || value < min || value > max {
146 return Err(Error::new(400, format!("Invalid {key}.")));
147 }
148 Ok(value)
149}
150pub async fn metrics(
151 app: Arc<App>,
152 name: &str,
153 query: &HashMap<String, String>,
154 application: Option<&str>,
155 window: Option<(f64, f64)>,
156) -> Result<Arc<Document>> {
157 let range = query_number(query, "range", 3600.0, 60.0, 30.0 * 86400.0)?;
158 let to = window
159 .map(|(_, to)| to)
160 .unwrap_or_else(|| (now() / 2.0).floor() * 2.0);
161 let from = window.map(|(from, _)| from).unwrap_or(to - range);
162 let service = query.get("service");
163 let quote = |s: &str| serde_json::to_string(s).unwrap();
164 let selector = if let Some(id) = application {
165 format!(
166 "{name}{{service={}}} or {name}{{service_name={}}}",
167 quote(id),
168 quote(id)
169 )
170 } else {
171 let metric = METRICS
172 .iter()
173 .find(|(key, _)| *key == name)
174 .ok_or_else(|| Error::new(400, "Invalid metric."))?
175 .1;
176 format!(
177 "{metric}{}",
178 service
179 .map(|id| format!(
180 "{{{}={}}}",
181 if name.starts_with("vm.") {
182 "vm"
183 } else {
184 "service"
185 },
186 quote(id)
187 ))
188 .unwrap_or_default()
189 )
190 };
191 let key = if window.is_some() {
192 format!("series:{selector}:fixed:{from}:{to}")
193 } else {
194 format!("series:{selector}:live:{range}")
195 };
196 let state = app.clone();
197 let network = name == "host.network";
198 let application = application.is_some();
199 app.cache
200 .get(key, Duration::from_secs(2), move || async move {
201 let base = endpoint(state.clone(), "victoria-metrics").await?;
202 let query = params(&[
203 ("query", selector),
204 ("start", from.to_string()),
205 ("end", to.to_string()),
206 ("step", ((to - from) / 300.0).round().max(2.0).to_string()),
207 ]);
208 let response = read(state, format!("{base}/api/v1/query_range?{query}"), None).await?;
209 if response.value["status"] != "success" {
210 return Err(Error::new(502, "The metrics query failed."));
211 }
212 let mut values = series(&response.value, application);
213 if network {
214 values
215 .as_array_mut()
216 .unwrap()
217 .sort_by_key(|s| if s["name"] == "down" { 0 } else { 1 });
218 }
219 Ok(values)
220 })
221 .await
222}
223pub async fn metric_names(app: Arc<App>, id: &str) -> Result<Value> {
224 let query = params(&["service", "service_name"].map(|label| {
225 (
226 "match[]",
227 format!(
228 "{{{label}={},__name__!~\"studio_.*\"}}",
229 serde_json::to_string(id).unwrap()
230 ),
231 )
232 }));
233 let url = format!(
234 "{}/api/v1/label/__name__/values?{query}",
235 endpoint(app.clone(), "victoria-metrics").await?
236 );
237 let response = read(app, url, None).await?;
238 let valid = regex::Regex::new(r"^[a-zA-Z_:][a-zA-Z0-9_:]*$").unwrap();
239 let mut values: Vec<_> = array(&response.value["data"])
240 .iter()
241 .filter(|v| valid.is_match(string(v)))
242 .cloned()
243 .collect();
244 values.sort_by(|a, b| string(a).cmp(string(b)));
245 Ok(json!(values))
246}
247
248#[derive(Debug, PartialEq)]
249struct Term {
250 field: Option<String>,
251 op: String,
252 value: String,
253 exclude: bool,
254}
255fn terms(query: &str, trace: bool) -> Result<Vec<Term>> {
256 let tokens =
257 regex::Regex::new(r#"(-?)(?:([\w.]+):(>=|<=|>|<)?)?(?:"([^"]*)"?|(\S+))"#).unwrap();
258 let wildcard = regex::Regex::new(r"(?i)^(\d+)x+$").unwrap();
259 let mut found = Vec::new();
260 for captures in tokens.captures_iter(query) {
261 let field = captures.get(2).map(|m| m.as_str());
262 let recognized = field.filter(|s| {
263 if trace {
264 ["status", "method", "path", "host", "client", "duration"].contains(s)
265 || s.contains('.')
266 } else {
267 ["container", "stream"].contains(s)
268 }
269 });
270 let value = if field.is_some() && recognized.is_none() {
271 captures[0].trim_start_matches('-').to_owned()
272 } else {
273 captures
274 .get(4)
275 .or(captures.get(5))
276 .map(|m| m.as_str())
277 .unwrap_or_default()
278 .to_owned()
279 };
280 let value = wildcard.replace(&value, "$1").into_owned();
281 if value.is_empty() {
282 continue;
283 }
284 found.push(Term {
285 field: recognized.map(str::to_owned),
286 op: if recognized.is_some() {
287 captures.get(3).map(|m| m.as_str()).unwrap_or(":")
288 } else {
289 ":"
290 }
291 .into(),
292 value,
293 exclude: &captures[1] == "-",
294 });
295 }
296 Ok(found)
297}
298pub async fn logs(app: Arc<App>, id: &str, query: &HashMap<String, String>) -> Result<Value> {
299 let limit = query_number(query, "limit", 200.0, 1.0, 5000.0)?;
300 let mut filters = vec![
301 "source:=nomad".to_owned(),
302 format!("job:={}", serde_json::to_string(id)?),
303 ];
304 if let Some(level) = query.get("level") {
305 filters.push(
306 match level.as_str() {
307 "error" => "level:=error",
308 "warn" => "(level:=warn OR level:=error)",
309 _ => return Err(Error::new(400, "Invalid log level.")),
310 }
311 .into(),
312 );
313 }
314 for term in terms(
315 query.get("q").map(String::as_str).unwrap_or_default(),
316 false,
317 )? {
318 let field = match term.field.as_deref() {
319 Some("container") => "task",
320 Some("stream") => "stream",
321 _ => "_msg",
322 };
323 filters.push(format!(
324 "{}{field}:~{}",
325 if term.exclude { "!" } else { "" },
326 serde_json::to_string(&format!(
327 "(?i){}{}",
328 if term.field.is_some() { "^" } else { "" },
329 regex::escape(&term.value)
330 ))?
331 ));
332 }
333 let mut form = vec![
334 ("query".into(), filters.join(" ")),
335 ("limit".into(), limit.to_string()),
336 ];
337 for (key, field, add) in [("after", "start", 0.000001), ("before", "end", 0.0)] {
338 if query.contains_key(key) {
339 form.push((
340 field.into(),
341 (query_number(query, key, 0.0, f64::NEG_INFINITY, f64::INFINITY)? + add)
342 .to_string(),
343 ));
344 }
345 }
346 let url = format!(
347 "{}/select/logsql/query",
348 endpoint(app.clone(), "victoria-logs").await?
349 );
350 let response = read(app, url, Some(form)).await?;
351 let mut rows = string(&response.value).lines().filter(|l| !l.trim().is_empty()).map(|line| {
352 let row: Value = serde_json::from_str(line)?;
353 Ok::<_,Error>(json!({"t":core::seconds(&row["_time"]),"container":row["task"].as_str().unwrap_or("app"),"stream":if row["stream"] == "stderr" {"stderr"} else {"stdout"},"level":if row["level"] == "error" || row["level"] == "warn" {row["level"].clone()} else {Value::Null},"text":row["_msg"].as_str().unwrap_or_default()}))
354 }).collect::<Result<Vec<_>>>()?;
355 rows.sort_by(|a, b| number(&b["t"]).total_cmp(&number(&a["t"])));
356 Ok(json!(rows))
357}
358
359fn span(row: &Value) -> Value {
360 let mut attributes: serde_json::Map<String, Value> = row
361 .as_object()
362 .into_iter()
363 .flat_map(|v| v.iter())
364 .filter_map(|(key, value)| {
365 key.strip_prefix("span_attr:")
366 .map(|key| (key.into(), value.clone()))
367 })
368 .collect();
369 for (from, to) in [
370 ("http.request.method", "http.request.method"),
371 ("http.response.status_code", "http.response.status_code"),
372 ("http.route", "url.path"),
373 ] {
374 if let Some(value) = row.get(format!("resource_attr:{from}")) {
375 attributes
376 .entry(to.to_owned())
377 .or_insert_with(|| value.clone());
378 }
379 }
380 let status = attributes
381 .get("http.response.status_code")
382 .or(attributes.get("http.status_code"))
383 .cloned()
384 .unwrap_or(Value::Null);
385 let service = attributes
386 .get("studio.service")
387 .cloned()
388 .unwrap_or_else(|| {
389 row["resource_attr:service.name"]
390 .as_str()
391 .map(|s| json!(s))
392 .unwrap_or(json!("unknown"))
393 });
394 let error = if row["status_code"] == "2" || number(&status) >= 500.0 {
395 if !status.is_null() {
396 status
397 } else {
398 json!(
399 row["status_message"]
400 .as_str()
401 .filter(|s| !s.is_empty())
402 .unwrap_or("request failed")
403 )
404 }
405 } else {
406 Value::Null
407 };
408 let name = if attributes.get("studio.kind").is_some_and(|v| v == "edge") {
409 format!("edge: {}", row["name"].as_str().unwrap_or("request"))
410 } else {
411 string(&row["name"]).into()
412 };
413 json!({"id":row["span_id"],"parent":row["parent_span_id"].as_str().filter(|s| !s.is_empty()),"service":service,"name":name,"start":number(&row["start_time_unix_nano"])/1e9,"duration":number(&row["duration"])/1e9,"error":error,"attributes":attributes})
414}
415async fn spans(app: Arc<App>, query: String) -> Result<Vec<Value>> {
416 let url = format!(
417 "{}/select/logsql/query",
418 endpoint(app.clone(), "victoria-traces").await?
419 );
420 let rows = read(app, url, Some(vec![("query".into(), query)])).await?;
421 let mut traces: HashMap<String, Vec<Value>> = HashMap::new();
422 for line in string(&rows.value).lines().filter(|l| !l.trim().is_empty()) {
423 let row: Value = serde_json::from_str(line)?;
424 if string(&row["trace_id"]).is_empty() || string(&row["span_id"]).is_empty() {
425 continue;
426 }
427 traces
428 .entry(string(&row["trace_id"]).to_owned())
429 .or_default()
430 .push(span(&row));
431 }
432 Ok(traces
433 .into_iter()
434 .map(|(id, mut spans)| {
435 let parents: HashMap<_, _> = spans
436 .iter()
437 .map(|s| (string(&s["id"]).to_owned(), string(&s["parent"]).to_owned()))
438 .collect();
439 let depth = |span: &Value| {
440 let mut parent = string(&span["parent"]);
441 let mut seen = std::collections::HashSet::new();
442 let mut n = 0;
443 while let Some(next) = parents.get(parent) {
444 if !seen.insert(parent) {
445 break;
446 }
447 n += 1;
448 parent = next;
449 }
450 n
451 };
452 spans.sort_by(|a, b| {
453 depth(a)
454 .cmp(&depth(b))
455 .then_with(|| number(&a["start"]).total_cmp(&number(&b["start"])))
456 });
457 json!({"id":id,"spans":spans})
458 })
459 .collect())
460}
461pub async fn trace(app: Arc<App>, id: &str) -> Result<Value> {
462 spans(app, format!("trace_id:={}", serde_json::to_string(id)?))
463 .await?
464 .into_iter()
465 .next()
466 .ok_or_else(|| {
467 Error::new(
468 404,
469 "That trace has aged out of the trace store. Pick a newer one.",
470 )
471 })
472}
473pub async fn has_traces(app: Arc<App>, service: &str) -> Result<bool> {
474 let url = format!(
475 "{}/select/logsql/query",
476 endpoint(app.clone(), "victoria-traces").await?
477 );
478 Ok(!string(
479 &read(
480 app,
481 url,
482 Some(vec![(
483 "query".into(),
484 format!(
485 "_time:7d \"resource_attr:service.name\":={} | limit 1",
486 serde_json::to_string(service)?
487 ),
488 )]),
489 )
490 .await?
491 .value,
492 )
493 .trim()
494 .is_empty())
495}
496pub async fn traces(
497 app: Arc<App>,
498 id: &str,
499 query: &HashMap<String, String>,
500 visible: impl Fn(&Value) -> bool,
501) -> Result<Value> {
502 let jobs = core::scan(app.clone()).await?;
503 let Some(service) = jobs.value[id]["job"]["Meta"]["studio_trace_service"]
504 .as_str()
505 .filter(|s| !s.is_empty())
506 else {
507 return Ok(json!([]));
508 };
509 let sort = query.get("sort").map(String::as_str).unwrap_or("newest");
510 let (key, descending) = match sort {
511 "newest" => ("_time", true),
512 "oldest" => ("_time", false),
513 "slowest" => ("duration", true),
514 "fastest" => ("duration", false),
515 _ => return Err(Error::new(400, "Invalid trace order.")),
516 };
517 let limit = query_number(query, "limit", 50.0, 1.0, 500.0)?;
518 let quote = |s: &str| serde_json::to_string(s).unwrap();
519 let like = |field: &str, pattern: &str| {
520 format!("{}:~{}", quote(field), quote(&format!("(?i){pattern}")))
521 };
522 let mut filters = vec![
523 "_time:7d".into(),
524 format!("\"resource_attr:service.name\":={}", quote(service)),
525 ];
526 if query.get("errors").is_some_and(|v| v == "1") {
527 filters.push("(status_code:=2 OR \"span_attr:http.response.status_code\":>=500)".into());
528 }
529 let duration = regex::Regex::new(r"^(\d+(?:\.\d+)?)(us|µs|ms|s|m)?$").unwrap();
530 for term in terms(query.get("q").map(String::as_str).unwrap_or_default(), true)? {
531 let escaped = regex::escape(&term.value);
532 let filter = match term.field.as_deref() {
533 Some("duration") => {
534 let captures = duration.captures(&term.value).ok_or_else(|| {
535 Error::new(
536 400,
537 format!("Write a duration like 500ms or 2s, not {}.", term.value),
538 )
539 })?;
540 let nanos = match captures.get(2).map(|m| m.as_str()).unwrap_or("ms") {
541 "us" | "µs" => 1e3,
542 "s" => 1e9,
543 "m" => 60e9,
544 _ => 1e6,
545 };
546 format!(
547 "duration:{}{}",
548 if term.op == ":" { ">=" } else { &term.op },
549 (captures[1].parse::<f64>().unwrap() * nanos).round()
550 )
551 }
552 None => format!(
553 "({})",
554 [
555 "name",
556 "span_attr:url.path",
557 "span_attr:http.request.method",
558 "span_attr:http.response.status_code",
559 "span_attr:server.address"
560 ]
561 .map(|f| like(f, &escaped))
562 .join(" OR ")
563 ),
564 Some(field) => {
565 let alias = match field {
566 "status" => "http.response.status_code",
567 "method" => "http.request.method",
568 "path" => "url.path",
569 "host" => "server.address",
570 "client" => "client.address",
571 _ => field,
572 };
573 if term.op == ":" {
574 like(&format!("span_attr:{alias}"), &format!("^{escaped}"))
575 } else {
576 let value = term
577 .value
578 .parse::<f64>()
579 .ok()
580 .filter(|n| n.is_finite())
581 .ok_or_else(|| {
582 Error::new(400, format!("Put a number after {field}:{}.", term.op))
583 })?;
584 format!(
585 "{}:{}{value}",
586 quote(&format!("span_attr:{alias}")),
587 term.op
588 )
589 }
590 }
591 };
592 filters.push(if term.exclude {
593 format!("!({filter})")
594 } else {
595 filter
596 });
597 }
598 let mut found = spans(
599 app,
600 format!(
601 "trace_id:in({} | sort by ({key}{}) | limit {limit} | fields trace_id)",
602 filters.join(" "),
603 if descending { " desc" } else { "" }
604 ),
605 )
606 .await?;
607 for trace in &mut found {
608 trace["spans"].as_array_mut().unwrap().retain(&visible);
609 }
610 found.retain(|trace| !array(&trace["spans"]).is_empty());
611 let value = |trace: &Value| {
612 array(&trace["spans"])
613 .iter()
614 .find(|s| s["service"] == service && s["attributes"]["studio.kind"] != "edge")
615 .map(|s| number(&s[if key == "_time" { "start" } else { "duration" }]))
616 .unwrap_or(0.0)
617 };
618 found.sort_by(|a, b| {
619 if descending {
620 value(b).total_cmp(&value(a))
621 } else {
622 value(a).total_cmp(&value(b))
623 }
624 });
625 Ok(json!(found.into_iter().map(|t| { let spans = array(&t["spans"]); let mut services: Vec<_> = spans.iter().map(|s| s["service"].clone()).collect(); services.sort_by(|a,b| string(a).cmp(string(b))); services.dedup(); json!({"id":t["id"],"root":spans.first(),"spans":spans.len(),"services":services,"error":spans.iter().find(|s| !s["error"].is_null()).map(|s| s["error"].clone())}) }).collect::<Vec<_>>()))
626}
627
628#[derive(Default)]
629struct Recorder {
630 signature: Value,
631 cpu: HashMap<String, (f64, f64)>,
632 vms: HashMap<String, (f64, f64)>,
633 network: Option<(f64, f64, f64)>,
634}
635impl Recorder {
636 async fn collect(&mut self, app: Arc<App>) -> Result<Vec<String>> {
637 let jobs = core::scan(app.clone()).await?;
638 let mut signature = Vec::new();
639 let mut owners = HashMap::new();
640 for (id, found) in jobs.value.as_object().unwrap() {
641 for alloc in array(&found["allocs"]) {
642 let alloc_id = string(&alloc["ID"]).to_owned();
643 owners.insert(alloc_id.clone(), id.clone());
644 for (name, task) in alloc["TaskStates"]
645 .as_object()
646 .into_iter()
647 .flat_map(|v| v.iter())
648 {
649 signature.push((alloc_id.clone(), name.clone(), task["StartedAt"].clone()));
650 }
651 }
652 }
653 signature.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
654 let signature = json!(signature);
655 let counters =
656 host::call(json!({"operation":"host.usage","refresh":signature != self.signature}))
657 .await?;
658 self.signature = signature;
659 let at = number(&counters["at"]);
660 let timestamp = (now() * 1000.0) as i64;
661 let mut next = HashMap::new();
662 let mut usage: HashMap<String, (f64, f64)> = HashMap::new();
663 for container in array(&counters["containers"]) {
664 let name = string(&container["name"]);
665 let alloc = name
666 .get(name.len().saturating_sub(36)..)
667 .unwrap_or_default();
668 let Some(service) = owners.get(alloc) else {
669 continue;
670 };
671 let usec = number(&container["cpu"]);
672 let memory = number(&container["memory"]);
673 let id = string(&container["id"]);
674 let cpu = self
675 .cpu
676 .get(id)
677 .filter(|(_, t)| at > *t)
678 .map(|(old, t)| ((usec - old) / ((at - t) * 1e6)).max(0.0))
679 .unwrap_or(0.0);
680 next.insert(id.to_owned(), (usec, at));
681 let item = usage.entry(service.clone()).or_default();
682 item.0 += cpu;
683 item.1 += memory;
684 }
685 self.cpu = next;
686 *app.usage.lock().unwrap() = usage
687 .iter()
688 .map(|(id, (cpu, memory))| (id.clone(), json!({"cpu":cpu,"memory":memory})))
689 .collect();
690 let bytes = app.live.borrow().clone();
691 let live: Value = if bytes.is_empty() {
692 json!({})
693 } else {
694 serde_json::from_slice(&bytes)?
695 };
696 let mut lines = vec![
697 format!(
698 "studio_host_cpu_percent {} {timestamp}",
699 number(&live["host"]["cpu"])
700 ),
701 format!(
702 "studio_host_memory_bytes {} {timestamp}",
703 number(&live["host"]["memory"])
704 ),
705 ];
706 for (id, (cpu, memory)) in usage {
707 let id = serde_json::to_string(&id)?;
708 lines.push(format!(
709 "studio_service_cpu_cores{{service={id}}} {cpu} {timestamp}"
710 ));
711 lines.push(format!(
712 "studio_service_memory_bytes{{service={id}}} {memory} {timestamp}"
713 ));
714 }
715 let sample = host::sample(app.clone()).await?;
716 let sample = &sample.value;
717 let rx = number(&sample["network"]["rx"]);
718 let tx = number(&sample["network"]["tx"]);
719 let network_at = number(&sample["at"]);
720 if let Some((old_rx, old_tx, t)) = self.network.filter(|(_, _, t)| network_at > *t) {
721 lines.push(format!(
722 "studio_host_network_bytes_per_second{{direction=\"down\"}} {} {timestamp}",
723 ((rx - old_rx) / (network_at - t)).max(0.0)
724 ));
725 lines.push(format!(
726 "studio_host_network_bytes_per_second{{direction=\"up\"}} {} {timestamp}",
727 ((tx - old_tx) / (network_at - t)).max(0.0)
728 ));
729 }
730 self.network = Some((rx, tx, network_at));
731 if let Some(value) = live["host"]["temperature"].as_f64() {
732 lines.push(format!(
733 "studio_host_temperature_celsius {value} {timestamp}"
734 ));
735 }
736 if let Some(value) = sample["gpu"].as_f64() {
737 lines.push(format!("studio_host_gpu_percent {value} {timestamp}"));
738 }
739 if let Ok(Ok(stats)) = tokio::time::timeout(
740 Duration::from_secs(8),
741 host::call(json!({"operation":"vm.stats"})),
742 )
743 .await
744 {
745 let mut next = HashMap::new();
746 let mut usage = HashMap::new();
747 for (id, stat) in stats.as_object().into_iter().flat_map(|v| v.iter()) {
748 let cpu = self
749 .vms
750 .get(id)
751 .filter(|(_, t)| at > *t)
752 .map(|(old, t)| {
753 ((number(&stat["cpu"]) - old) / (at - t) / number(&stat["vcpus"]) * 100.0)
754 .max(0.0)
755 })
756 .unwrap_or(0.0);
757 usage.insert(id.clone(), json!({"cpu":cpu,"memory":stat["memory"]}));
758 next.insert(id.clone(), (number(&stat["cpu"]), at));
759 lines.push(format!(
760 "studio_vm_cpu_percent{{vm={}}} {cpu} {timestamp}",
761 serde_json::to_string(id)?
762 ));
763 lines.push(format!(
764 "studio_vm_memory_bytes{{vm={}}} {} {timestamp}",
765 serde_json::to_string(id)?,
766 number(&stat["memory"])
767 ));
768 }
769 self.vms = next;
770 *app.vm_usage.lock().unwrap() = usage;
771 }
772 Ok(lines)
773 }
774}
775pub fn start(app: Arc<App>) {
776 tokio::spawn(async move {
777 let mut recorder = Recorder::default();
778 loop {
779 let result = async {
780 let base = endpoint(app.clone(), "victoria-metrics").await?;
781 if env("STUDIO_METRICS", "") == "follow" {
782 let names: Vec<_> = METRICS
783 .iter()
784 .filter(|(k, _)| k.starts_with("service.") || k.starts_with("vm."))
785 .map(|(_, v)| *v)
786 .collect();
787 let url = format!(
788 "{base}/api/v1/query?{}",
789 params(&[("query", format!("{{__name__=~\"{}\"}}", names.join("|")))])
790 );
791 let result = read(app.clone(), url, None).await?;
792 let mut services = HashMap::<String, Value>::new();
793 let mut vms = HashMap::<String, Value>::new();
794 for row in array(&result.value["data"]["result"]) {
795 let metric = &row["metric"];
796 let (map, id) = if metric["service"].is_string() {
797 (&mut services, string(&metric["service"]))
798 } else {
799 (&mut vms, string(&metric["vm"]))
800 };
801 let value = map
802 .entry(id.into())
803 .or_insert_with(|| json!({"cpu":0,"memory":0}));
804 value[if string(&metric["__name__"]).contains("_cpu_") {
805 "cpu"
806 } else {
807 "memory"
808 }] = json!(number(&row["value"][1]));
809 }
810 *app.usage.lock().unwrap() = services;
811 *app.vm_usage.lock().unwrap() = vms;
812 } else {
813 let lines = recorder.collect(app.clone()).await?;
814 app.request(Method::POST, &format!("{base}/api/v1/import/prometheus"))?
815 .body(lines.join("\n") + "\n")
816 .send()
817 .await?
818 .error_for_status()?;
819 let jobs = core::scan(app.clone()).await?;
820 let scrapes: Vec<_> = jobs
821 .value
822 .as_object()
823 .unwrap()
824 .iter()
825 .filter_map(|(id, job)| {
826 job["job"]["Meta"]["studio_metrics_path"]
827 .as_str()
828 .filter(|p| !p.is_empty())
829 .map(|p| (id.clone(), p.to_owned()))
830 })
831 .collect();
832 stream::iter(scrapes)
833 .for_each_concurrent(4, |(id, path)| {
834 let app = app.clone();
835 let base = base.clone();
836 async move {
837 let result = async {
838 let response = app
839 .request(
840 Method::GET,
841 &format!("{}{path}", endpoint(app.clone(), &id).await?),
842 )?
843 .timeout(Duration::from_secs(5))
844 .send()
845 .await?
846 .error_for_status()?;
847 app.request(
848 Method::POST,
849 &format!(
850 "{base}/api/v1/import/prometheus?{}",
851 params(&[("extra_label", format!("service={id}"))])
852 ),
853 )?
854 .body(response.text().await?)
855 .send()
856 .await?
857 .error_for_status()?;
858 Ok::<_, Error>(())
859 }
860 .await;
861 if let Err(e) = result {
862 eprintln!("metrics {id}: {}", e.message);
863 }
864 }
865 })
866 .await;
867 }
868 Ok::<_, Error>(())
869 }
870 .await;
871 if let Err(e) = result
872 && e.status != 501
873 {
874 eprintln!("metrics: {}", e.message);
875 }
876 tokio::time::sleep(Duration::from_secs(15)).await;
877 }
878 });
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884 #[test]
885 fn search_fields_comparisons_exclusions_and_unknown_fields() {
886 let parsed = terms(
887 r#"status:5xx -path:/health duration:>1s "GET /x" user:42 -noise"#,
888 true,
889 )
890 .unwrap();
891 assert_eq!(
892 parsed
893 .iter()
894 .map(|t| (
895 t.field.as_deref(),
896 t.op.as_str(),
897 t.value.as_str(),
898 t.exclude
899 ))
900 .collect::<Vec<_>>(),
901 vec![
902 (Some("status"), ":", "5", false),
903 (Some("path"), ":", "/health", true),
904 (Some("duration"), ">", "1s", false),
905 (None, ":", "GET /x", false),
906 (None, ":", "user:42", false),
907 (None, ":", "noise", true)
908 ]
909 );
910 assert_eq!(terms(r#" "" - "#, false).unwrap().len(), 1);
911 }
912 #[test]
913 fn non_finite_points_are_null() {
914 assert_eq!(
915 series(
916 &json!({"data":{"result":[{"metric":{"service":"a"},"values":[[1,"2"],[2,"NaN"],[3,"+Inf"]]}]}}),
917 false
918 ),
919 json!([{"name":"a","t":[1,2,3],"v":[2.0,null,null]}])
920 );
921 }
922 #[test]
923 fn span_error_status_is_preserved() {
924 let s = span(
925 &json!({"span_id":"a","parent_span_id":"a","start_time_unix_nano":"1000000000","duration":"20","status_code":"2","status_message":"broken"}),
926 );
927 assert_eq!(s["error"], "broken");
928 }
929}
dashboard/src/users.rs created+588
......@@ -0,0 +1,588 @@
1use crate::*;
2use axum::extract::{FromRequest, Multipart};
3use futures::{StreamExt, stream};
4
5async fn call(path: &str, method: Method, body: Option<Value>) -> Result<Value> {
6 host::call(json!({"operation":"iam.request", "path":path,
7 "method":method.as_str(), "body":body}))
8 .await
9}
10async fn get(path: &str) -> Result<Value> {
11 Ok(call(path, Method::GET, None).await?["body"].take())
12}
13async fn list() -> Result<Value> {
14 let list = get("/users?max=1000").await?;
15 let found = stream::iter(array(&list).iter().cloned())
16 .map(|mut user| async move {
17 user["groups"] = get(&format!(
18 "/users/{}/role-mappings/realm",
19 encoded(string(&user["id"]))
20 ))
21 .await?;
22 for key in ["email", "firstName", "lastName"] {
23 if user.get(key).is_none() {
24 user[key] = Value::Null;
25 }
26 }
27 Ok::<_, Error>(user)
28 })
29 .buffered(4)
30 .collect::<Vec<_>>()
31 .await
32 .into_iter()
33 .collect::<Result<Vec<_>>>()?;
34 Ok(json!(found))
35}
36async fn directory(app: Arc<App>) -> Result<Arc<Document>> {
37 app.cache
38 .get(
39 "users".into(),
40 Duration::from_secs(300),
41 move || async move {
42 let (list, groups) = tokio::try_join!(list(), get("/roles"))?;
43 let users = stream::iter(array(&list).iter().cloned())
44 .map(|mut user| async move {
45 user["sessions"] =
46 get(&format!("/users/{}/sessions", encoded(string(&user["id"]))))
47 .await?;
48 Ok::<_, Error>(user)
49 })
50 .buffered(4)
51 .collect::<Vec<_>>()
52 .await
53 .into_iter()
54 .collect::<Result<Vec<_>>>()?;
55 Ok(json!({"users":users,"groups":groups}))
56 },
57 )
58 .await
59}
60async fn found(id: &str) -> Result<Value> {
61 array(&list().await?)
62 .iter()
63 .find(|u| u["id"] == id)
64 .cloned()
65 .ok_or_else(|| Error::new(404, "No user with that id"))
66}
67async fn spare(me: &Value, id: &str, remove_role: Option<&str>) -> Result<()> {
68 let user = found(id).await?;
69 if user["username"] == me["name"] {
70 let keeps_admin = remove_role.is_some()
71 && array(&user["groups"])
72 .iter()
73 .any(|g| g["name"] == "infra-admin" && g["name"] != remove_role.unwrap());
74 if !keeps_admin {
75 return Err(Error::new(
76 400,
77 "That would lock you out of this page. Sign in as another admin to change it.",
78 ));
79 }
80 }
81 Ok(())
82}
83fn uuid(id: &str) -> Result<()> {
84 if regex::Regex::new(
85 r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
86 )
87 .unwrap()
88 .is_match(id)
89 {
90 Ok(())
91 } else {
92 Err(Error::new(400, "No user with that id"))
93 }
94}
95fn profile(body: &Value, full: bool) -> Result<Value> {
96 let mut profile = serde_json::Map::new();
97 let username_pattern = regex::Regex::new(r"^[a-z0-9][a-z0-9._@-]*$").unwrap();
98 for key in [
99 "username",
100 "email",
101 "firstName",
102 "lastName",
103 "enabled",
104 "emailVerified",
105 "requiredActions",
106 ] {
107 let Some(value) = body.get(key) else {
108 if full && ["username", "email", "firstName", "lastName"].contains(&key) {
109 return Err(Error::new(400, "Enter a user profile."));
110 }
111 continue;
112 };
113 let value = match key {
114 "username" => {
115 let v = string(value).trim().to_lowercase();
116 if !username_pattern.is_match(&v) {
117 return Err(Error::new(
118 400,
119 "Usernames use lowercase letters, digits, dots, dashes, and @",
120 ));
121 }
122 json!(v)
123 }
124 "email" | "firstName" | "lastName" => {
125 let v = value
126 .as_str()
127 .ok_or_else(|| Error::new(400, "Enter a name or email address."))?
128 .trim();
129 if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) {
130 return Err(Error::new(400, "Enter a full email address"));
131 }
132 if v.is_empty() { Value::Null } else { json!(v) }
133 }
134 "enabled" | "emailVerified" => {
135 if !value.is_boolean() {
136 return Err(Error::new(400, "Invalid profile."));
137 }
138 value.clone()
139 }
140 _ => {
141 if !value.is_array() || array(value).iter().any(|v| !v.is_string()) {
142 return Err(Error::new(400, "Invalid required actions."));
143 }
144 value.clone()
145 }
146 };
147 profile.insert(key.into(), value);
148 }
149 Ok(Value::Object(profile))
150}
151fn password(value: &Value) -> Result<&str> {
152 value
153 .as_str()
154 .filter(|s| s.chars().count() >= 8)
155 .ok_or_else(|| Error::new(400, "Use at least 8 characters"))
156}
157
158pub async fn route(
159 app: Arc<App>,
160 method: &Method,
161 parts: &[&str],
162 me: &Value,
163 body: Value,
164) -> Result<Response> {
165 if parts.is_empty() && method == Method::GET {
166 return Ok(directory(app).await?.response());
167 }
168 if let Some(id) = parts.first() {
169 uuid(id)?;
170 }
171 let value = match parts {
172 [] if method == Method::POST => {
173 let mut profile = profile(&body["profile"], true)?;
174 let setup = string(&body["setup"]["kind"]);
175 if setup == "email" && profile["email"].is_null() {
176 return Err(Error::new(400, "Add an email address to send a setup link"));
177 }
178 if setup != "email" && setup != "password" {
179 return Err(Error::new(400, "Choose how this user signs in."));
180 }
181 if setup == "password" {
182 password(&body["setup"]["password"])?;
183 }
184 if !body["groups"].is_array() {
185 return Err(Error::new(400, "Choose groups."));
186 }
187 for group in array(&body["groups"]) {
188 uuid(string(group))?;
189 }
190 let actions = if setup == "email" {
191 json!(["UPDATE_PASSWORD", "VERIFY_EMAIL"])
192 } else {
193 json!([])
194 };
195 profile["enabled"] = json!(true);
196 profile["emailVerified"] = json!(false);
197 profile["requiredActions"] = actions.clone();
198 let response = call("/users", Method::POST, Some(profile)).await?;
199 let id = response["id"]
200 .as_str()
201 .ok_or_else(|| {
202 Error::new(
203 502,
204 "Keycloak created the user but did not return its ID. Reload the page.",
205 )
206 })?
207 .to_owned();
208 for group in array(&body["groups"]) {
209 change_role(&id, string(group), Method::POST).await?;
210 }
211 if setup == "email" {
212 call(
213 &format!("/users/{id}/execute-actions-email"),
214 Method::PUT,
215 Some(actions),
216 )
217 .await?;
218 } else {
219 call(&format!("/users/{id}/reset-password"),Method::PUT,Some(json!({"type":"password","value":body["setup"]["password"],"temporary":true}))).await?;
220 }
221 app.cache.invalidate("users");
222 return Ok((StatusCode::CREATED, axum::Json(json!({"id":id}))).into_response());
223 }
224 [id] if method == Method::PATCH => {
225 let profile = profile(&body, false)?;
226 if profile["enabled"] == false {
227 spare(me, id, None).await?;
228 }
229 call(&format!("/users/{id}"), Method::PUT, Some(profile)).await?;
230 Value::Null
231 }
232 [id] if method == Method::DELETE => {
233 spare(me, id, None).await?;
234 call(&format!("/users/{id}"), Method::DELETE, None).await?;
235 Value::Null
236 }
237 [id, "groups", group] if method == Method::PUT || method == Method::DELETE => {
238 uuid(group)?;
239 if method == Method::DELETE {
240 let groups = get("/roles").await?;
241 let name = array(&groups)
242 .iter()
243 .find(|g| g["id"] == *group)
244 .map(|g| string(&g["name"]));
245 spare(me, id, name).await?;
246 }
247 change_role(
248 id,
249 group,
250 if method == Method::PUT {
251 Method::POST
252 } else {
253 Method::DELETE
254 },
255 )
256 .await?;
257 Value::Null
258 }
259 [id, "credentials"] if method == Method::GET => {
260 get(&format!("/users/{id}/credentials")).await?
261 }
262 [id, "logout"] if method == Method::POST => {
263 call(&format!("/users/{id}/logout"), Method::POST, None).await?;
264 Value::Null
265 }
266 [id, "actions-email"] if method == Method::POST => {
267 let user = found(id).await?;
268 if user["email"].is_null() {
269 return Err(Error::new(400, "Add an email address first"));
270 }
271 if array(&user["requiredActions"]).is_empty() {
272 return Err(Error::new(400, "Pick at least one required action first"));
273 }
274 call(
275 &format!("/users/{id}/execute-actions-email"),
276 Method::PUT,
277 Some(user["requiredActions"].clone()),
278 )
279 .await?;
280 Value::Null
281 }
282 [id, "password"] if method == Method::PUT => {
283 let password = password(&body["password"])?;
284 if !body["temporary"].is_boolean() {
285 return Err(Error::new(
286 400,
287 "Choose whether this password is temporary.",
288 ));
289 }
290 call(
291 &format!("/users/{id}/reset-password"),
292 Method::PUT,
293 Some(json!({"type":"password","value":password,"temporary":body["temporary"]})),
294 )
295 .await?;
296 Value::Null
297 }
298 _ => return Err(Error::new(404, "Not Found")),
299 };
300 if method != Method::GET {
301 app.cache.invalidate("users");
302 }
303 Ok(if value.is_null() {
304 StatusCode::NO_CONTENT.into_response()
305 } else {
306 Document::new(value).response()
307 })
308}
309async fn change_role(id: &str, group: &str, method: Method) -> Result<()> {
310 let roles = get("/roles").await?;
311 let role = array(&roles)
312 .iter()
313 .find(|g| g["id"] == group)
314 .ok_or_else(|| Error::new(404, "That role is no longer available. Reload the page."))?;
315 call(
316 &format!("/users/{id}/role-mappings/realm"),
317 method,
318 Some(json!([role])),
319 )
320 .await?;
321 Ok(())
322}
323pub async fn self_user(app: &App, me: &Value) -> Result<Value> {
324 let name = string(&me["name"]).to_owned();
325 let value = app
326 .cache
327 .coalesce(format!("identity:{name}"), move || async move {
328 let found = get(&format!("/users?username={}&exact=true", encoded(&name))).await?;
329 let mut user = array(&found)
330 .iter()
331 .find(|u| u["username"] == name)
332 .cloned()
333 .ok_or_else(|| {
334 Error::new(
335 404,
336 format!(
337 "Keycloak has no user named {}. Sign out, then sign in again.",
338 name
339 ),
340 )
341 })?;
342 user["groups"] = get(&format!(
343 "/users/{}/role-mappings/realm",
344 encoded(string(&user["id"]))
345 ))
346 .await?;
347 for key in ["email", "firstName", "lastName"] {
348 if user.get(key).is_none() {
349 user[key] = Value::Null;
350 }
351 }
352 Ok(user)
353 })
354 .await?;
355 Ok(value.value.clone())
356}
357fn image_type(bytes: &[u8]) -> Option<&'static str> {
358 if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
359 Some("image/webp")
360 } else if bytes.get(1..4) == Some(b"PNG") {
361 Some("image/png")
362 } else {
363 None
364 }
365}
366pub async fn picture(app: &App, parts: &[&str]) -> Result<Response> {
367 let ["account", "pictures", id] = parts else {
368 return Err(Error::new(404, "No picture here"));
369 };
370 if id.is_empty()
371 || !id
372 .bytes()
373 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
374 {
375 return Err(Error::new(404, "No picture here"));
376 }
377 let bytes = tokio::fs::read(app.data.join("pictures").join(id))
378 .await
379 .map_err(|_| Error::new(404, "No picture here"))?;
380 Ok((
381 [
382 (
383 "content-type",
384 image_type(&bytes).unwrap_or("application/octet-stream"),
385 ),
386 ("cache-control", "max-age=86400"),
387 ],
388 bytes,
389 )
390 .into_response())
391}
392pub async fn account(
393 app: Arc<App>,
394 request: Request,
395 parts: &[&str],
396 me: &Value,
397) -> Result<Response> {
398 let method = request.method().clone();
399 let headers = request.headers();
400 let origin = format!(
401 "{}://{}",
402 headers
403 .get("X-Forwarded-Proto")
404 .and_then(|h| h.to_str().ok())
405 .unwrap_or("http"),
406 headers
407 .get("X-Forwarded-Host")
408 .or(headers.get("Host"))
409 .and_then(|h| h.to_str().ok())
410 .unwrap_or("localhost")
411 );
412 let realm = || {
413 std::env::var("STUDIO_KEYCLOAK_URL")
414 .map(|s| format!("{s}/realms/master"))
415 .map_err(|_| {
416 Error::new(
417 501,
418 "Keycloak isn't connected to this home server. Connect it, then retry.",
419 )
420 })
421 };
422 if parts == ["sign-out"] && method == Method::GET {
423 let logout = format!(
424 "{}/protocol/openid-connect/logout?{}",
425 realm()?,
426 params(&[
427 ("client_id", "forward-auth".into()),
428 ("post_logout_redirect_uri", format!("{origin}/"))
429 ])
430 );
431 return Ok((
432 StatusCode::FOUND,
433 [(
434 "location",
435 format!("/snow.oauth2/sign_out?{}", params(&[("rd", logout)])),
436 )],
437 )
438 .into_response());
439 }
440 if let ["actions", action] = parts {
441 if method != Method::GET
442 || (![
443 "webauthn-register-passwordless",
444 "UPDATE_PASSWORD",
445 "UPDATE_EMAIL",
446 ]
447 .contains(action)
448 && !regex::Regex::new(r"^delete_credential:[\w-]+$")
449 .unwrap()
450 .is_match(action))
451 {
452 return Err(Error::new(
453 400,
454 "Keycloak can't start that action from here",
455 ));
456 }
457 return Ok((
458 StatusCode::FOUND,
459 [(
460 "location",
461 format!(
462 "{}/protocol/openid-connect/auth?{}",
463 realm()?,
464 params(&[
465 ("client_id", "forward-auth".into()),
466 ("redirect_uri", format!("{origin}/account")),
467 ("response_type", "code".into()),
468 ("scope", "openid".into()),
469 ("kc_action", action.to_string())
470 ])
471 ),
472 )],
473 )
474 .into_response());
475 }
476 let mut user = self_user(&app, me).await?;
477 let id = string(&user["id"]).to_owned();
478 let value = match parts {
479 [] if method == Method::GET => {
480 let attributes = user
481 .as_object_mut()
482 .unwrap()
483 .remove("attributes")
484 .unwrap_or(Value::Null);
485 user["picture"] = attributes["picture"][0].clone();
486 user["credentials"] = get(&format!("/users/{id}/credentials")).await?;
487 user["console"] = json!(format!("{}/account", realm()?));
488 user
489 }
490 [] if method == Method::PATCH => {
491 let body: Value = serde_json::from_slice(
492 &axum::body::to_bytes(request.into_body(), 1024 * 1024).await?,
493 )
494 .map_err(|_| Error::new(400, "Invalid profile."))?;
495 let mut value = serde_json::Map::new();
496 for key in ["firstName", "lastName"] {
497 if let Some(v) = body.get(key) {
498 let v = v
499 .as_str()
500 .ok_or_else(|| Error::new(400, "Enter a name."))?
501 .trim();
502 value.insert(
503 key.into(),
504 if v.is_empty() { Value::Null } else { json!(v) },
505 );
506 }
507 }
508 call(
509 &format!("/users/{id}"),
510 Method::PUT,
511 Some(Value::Object(value)),
512 )
513 .await?;
514 Value::Null
515 }
516 ["verify-email"] if method == Method::POST => {
517 call(
518 &format!("/users/{id}/execute-actions-email"),
519 Method::PUT,
520 Some(json!(["VERIFY_EMAIL"])),
521 )
522 .await?;
523 Value::Null
524 }
525 ["picture"] if method == Method::PUT => {
526 let (parts, body) = request.into_parts();
527 let bytes = axum::body::to_bytes(body, 512 * 1024)
528 .await
529 .map_err(|_| Error::new(413, "That picture is over 512 KB. Pick a smaller one."))?;
530 let request = Request::from_parts(parts, axum::body::Body::from(bytes));
531 let mut multipart = Multipart::from_request(request, &())
532 .await
533 .map_err(|_| Error::new(400, "Pick a picture to upload"))?;
534 let mut picture = None;
535 while let Some(field) = multipart
536 .next_field()
537 .await
538 .map_err(|_| Error::new(400, "Pick a picture to upload"))?
539 {
540 if field.name() == Some("picture") && field.file_name().is_some() {
541 picture = Some(
542 field
543 .bytes()
544 .await
545 .map_err(|_| Error::new(400, "Pick a picture to upload"))?,
546 );
547 break;
548 }
549 }
550 let bytes = picture.ok_or_else(|| Error::new(400, "Pick a picture to upload"))?;
551 if image_type(&bytes).is_none() {
552 return Err(Error::new(415, "Upload a WebP or PNG picture"));
553 }
554 tokio::fs::create_dir_all(app.data.join("pictures")).await?;
555 tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?;
556 let picture = format!(
557 "{origin}/api/account/pictures/{id}?v={}",
558 (now() * 1000.0) as u64
559 );
560 call(
561 &format!("/users/{id}"),
562 Method::PUT,
563 Some(json!({"attributes":{"picture":[picture]}})),
564 )
565 .await?;
566 json!({"picture":picture})
567 }
568 ["picture"] if method == Method::DELETE => {
569 call(
570 &format!("/users/{id}"),
571 Method::PUT,
572 Some(json!({"attributes":{"picture":null}})),
573 )
574 .await?;
575 let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await;
576 Value::Null
577 }
578 _ => return Err(Error::new(404, "Not Found")),
579 };
580 if method != Method::GET {
581 app.cache.invalidate("users");
582 }
583 Ok(if value.is_null() {
584 StatusCode::NO_CONTENT.into_response()
585 } else {
586 Document::new(value).response()
587 })
588}
dashboard/src/youtube.rs created+344
......@@ -0,0 +1,344 @@
1use crate::*;
2use tokio::{
3 io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
4 sync::{mpsc, oneshot},
5};
6
7pub struct Worker(mpsc::Sender<Call>);
8struct Call {
9 method: String,
10 args: Value,
11 reply: oneshot::Sender<Result<Value>>,
12}
13
14impl Worker {
15 pub fn new() -> Self {
16 let (send, mut receive) = mpsc::channel::<Call>(64);
17 tokio::spawn(async move {
18 let mut child: Option<(
19 tokio::process::Child,
20 tokio::process::ChildStdin,
21 tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
22 )> = None;
23 let mut id = 0u64;
24 while let Some(call) = receive.recv().await {
25 if call.reply.is_closed() {
26 continue;
27 }
28 let response = tokio::time::timeout(Duration::from_secs(25), async {
29 if child.is_none() {
30 if std::env::var("STUDIO_YT_STATE").is_err() {
31 return Err(Error::new(
32 501,
33 "YouTube isn't connected to this home server. Configure its archive state, then retry.",
34 ));
35 }
36 let python = env("STUDIO_YT_PYTHON", "python3");
37 let script = env("STUDIO_YT_WORKER", "server/youtube-worker.py");
38 let mut command = tokio::process::Command::new(python);
39 for (key, name) in [
40 ("SMTP_HOST", "smtp_host"),
41 ("SMTP_USER", "smtp_user"),
42 ("SMTP_PASS", "smtp_pass"),
43 ] {
44 if let Ok(secret) = host::call(
45 json!({"operation":"deploy.secret.get","service":"yt-feed","key":name}),
46 ).await {
47 if let Some(value) = secret.as_str() {
48 command.env(key, value);
49 }
50 }
51 }
52 let mut process = command
53 .arg(script)
54 .stdin(std::process::Stdio::piped())
55 .stdout(std::process::Stdio::piped())
56 .stderr(std::process::Stdio::inherit())
57 .kill_on_drop(true)
58 .spawn()?;
59 let input = process.stdin.take().unwrap();
60 let output = BufReader::new(process.stdout.take().unwrap()).lines();
61 child = Some((process, input, output));
62 }
63 id += 1;
64 let (_, input, output) = child.as_mut().unwrap();
65 input
66 .write_all(
67 format!(
68 "{}\n",
69 json!({"id":id,"method":call.method,"args":call.args})
70 )
71 .as_bytes(),
72 )
73 .await?;
74 input.flush().await?;
75 let line = output.next_line().await?.ok_or_else(|| {
76 Error::new(502, "YouTube processing stopped. Retry shortly.")
77 })?;
78 let response: Value = serde_json::from_str(&line)?;
79 if response["id"] != id {
80 return Err(Error::new(
81 502,
82 "YouTube processing stopped. Retry shortly.",
83 ));
84 }
85 if let Some(error) = response["error"].as_str() {
86 return Err(Error::new(
87 if error.contains("read-only") || error.contains("changed since") {
88 409
89 } else {
90 502
91 },
92 error,
93 ));
94 }
95 Ok(response["result"].clone())
96 })
97 .await
98 .unwrap_or_else(|_| {
99 Err(Error::new(
100 504,
101 "YouTube is taking too long. Retry shortly.",
102 ))
103 });
104 if response.as_ref().is_err_and(|e| {
105 e.status == 504
106 || e.status == 500
107 || (e.status == 502 && e.message.contains("stopped"))
108 }) {
109 child = None;
110 }
111 let _ = call.reply.send(response);
112 }
113 });
114 Self(send)
115 }
116 pub async fn call(&self, method: &str, args: Value) -> Result<Value> {
117 let (reply, result) = oneshot::channel();
118 tokio::time::timeout(Duration::from_secs(30), async {
119 self.0
120 .send(Call {
121 method: method.into(),
122 args,
123 reply,
124 })
125 .await
126 .map_err(|_| Error::new(502, "YouTube processing stopped. Retry shortly."))?;
127 result
128 .await
129 .map_err(|_| Error::new(502, "YouTube processing stopped. Retry shortly."))?
130 })
131 .await
132 .map_err(|_| Error::new(504, "YouTube is taking too long. Retry shortly."))?
133 }
134}
135
136pub async fn route(
137 app: Arc<App>,
138 method: &Method,
139 parts: &[&str],
140 body: Value,
141) -> Result<Response> {
142 if method == Method::GET {
143 let call = match parts {
144 [] => "snapshot",
145 ["channels"] => "channels",
146 ["configs"] => "configs",
147 ["library"] => "library",
148 _ => return Err(Error::new(404, "Not Found")),
149 };
150 let state = app.clone();
151 let document = app
152 .cache
153 .get(
154 format!("youtube:{call}"),
155 Duration::from_secs(if call == "library" { 30 } else { 2 }),
156 move || async move {
157 if call == "library" {
158 let _slot = state.heavy.acquire().await?;
159 let python = env("STUDIO_YT_PYTHON", "python3");
160 let script = env("STUDIO_YT_WORKER", "server/youtube-worker.py");
161 return Ok(serde_json::from_slice(
162 &command(&python, &[&script, "--library"], None).await?,
163 )?);
164 }
165 let worker = state.youtube.get_or_init(Worker::new);
166 let mut value = worker.call(call, json!({})).await?;
167 if call == "snapshot" {
168 let archive = core::read_json(
169 std::path::Path::new(&env(
170 "STUDIO_YT_ARCHIVE_STATUS",
171 "/srv/prod/ytdl-sub/config/archive-status.json",
172 )),
173 Value::Null,
174 )
175 .await
176 .unwrap_or(Value::Null);
177 if !archive.is_null() {
178 value["archive"] = archive;
179 }
180 for job in value["jobs"].as_array_mut().into_iter().flatten() {
181 job["folder"] = apps::file_link(string(&job["folder"]), false);
182 }
183 }
184 Ok(value)
185 },
186 )
187 .await?;
188 return Ok(document.response());
189 }
190 let worker = app.youtube.get_or_init(Worker::new);
191 let value = match parts {
192 ["configs", name] if method == Method::PUT => {
193 if !body["original"].is_string() || !body["body"].is_string() {
194 return Err(Error::new(400, "Invalid config."));
195 }
196 worker
197 .call(
198 "saveConfig",
199 json!({"name":name,"original":body["original"],"body":body["body"]}),
200 )
201 .await?;
202 Value::Null
203 }
204 ["library", "rename"] if method == Method::POST => {
205 if string(&body["path"]).is_empty() || string(&body["title"]).trim().is_empty() {
206 return Err(Error::new(400, "Enter a title."));
207 }
208 for key in ["season", "episode"] {
209 if !body[key].is_null()
210 && (number(&body[key]) < 1.0 || number(&body[key]).fract() != 0.0)
211 {
212 return Err(Error::new(400, "Invalid episode number."));
213 }
214 }
215 worker.call("rename", body).await?;
216 Value::Null
217 }
218 ["channels"] if method == Method::PUT => {
219 validate_channels(&body)?;
220 worker.call("setChannels", body).await?;
221 Value::Null
222 }
223 ["pending"] if method == Method::POST => {
224 let video = regex::Regex::new(
225 r"^https?://((www|m|music)\.)?(youtube\.com/(watch\?|shorts/|live/)|youtu\.be/)",
226 )
227 .unwrap();
228 if !body["urls"].is_array() || array(&body["urls"]).is_empty() {
229 return Err(Error::new(400, "Paste a YouTube video link."));
230 }
231 for url in array(&body["urls"]) {
232 if !video.is_match(string(url)) {
233 return Err(Error::new(
234 400,
235 format!("{} isn't a YouTube video link.", string(url)),
236 ));
237 }
238 }
239 worker.call("add", body).await?;
240 Value::Null
241 }
242 ["pending", key, "ingest"] if method == Method::POST => {
243 let dest = string(&body["dest"]);
244 if !["independent", "music", "indie"].contains(&dest) {
245 return Err(Error::new(400, "Choose where this video goes."));
246 }
247 if dest == "indie" {
248 if string(&body["show"]).trim().is_empty() || !body["title"].is_string() {
249 return Err(Error::new(400, "Enter a show name."));
250 }
251 for key in ["season", "episode"] {
252 let n = number(&body[key]);
253 if n < 1.0 || n.fract() != 0.0 {
254 return Err(Error::new(400, "Invalid episode number."));
255 }
256 }
257 }
258 worker
259 .call("ingest", json!({"key":key,"choice":body}))
260 .await?;
261 Value::Null
262 }
263 ["pending", key, "skip"] if method == Method::POST => {
264 worker.call("skip", json!({"key":key})).await?;
265 Value::Null
266 }
267 ["jobs", id, "retry"] if method == Method::POST => {
268 worker.call("retry", json!({"id":id})).await?;
269 Value::Null
270 }
271 ["upscaler"] if method == Method::PUT => {
272 if !body["enabled"].is_boolean() {
273 return Err(Error::new(400, "Choose whether upscaling is enabled."));
274 }
275 worker.call("setUpscaler", body).await?;
276 Value::Null
277 }
278 _ => return Err(Error::new(404, "Not Found")),
279 };
280 for call in ["snapshot", "channels", "configs", "library"] {
281 app.cache.invalidate(&format!("youtube:{call}"));
282 }
283 Ok(if value.is_null() {
284 StatusCode::NO_CONTENT.into_response()
285 } else {
286 Document::new(value).response()
287 })
288}
289fn validate_channels(body: &Value) -> Result<()> {
290 let channel = regex::Regex::new(
291 r"^https?://(www\.|m\.)?youtube\.com/(@[\w.-]+|channel/[\w-]+|c/[^/?#]+|user/[^/?#]+)/?$",
292 )
293 .unwrap();
294 let date_pattern = regex::Regex::new(r"^\d{8}$").unwrap();
295 for key in ["notify", "archive"] {
296 if !body[key].is_array() {
297 return Err(Error::new(400, "Enter channel lists."));
298 }
299 let mut names = std::collections::HashSet::new();
300 for item in array(&body[key]) {
301 let name = string(&item["name"]).trim();
302 if name.is_empty() {
303 return Err(Error::new(400, "Enter a name for the channel."));
304 }
305 if !names.insert(name) {
306 return Err(Error::new(
307 400,
308 format!("Two channels are named {name}. Rename one."),
309 ));
310 }
311 if !channel.is_match(string(&item["url"]).trim()) {
312 return Err(Error::new(
313 400,
314 "That isn't a YouTube channel link. Use one like https://www.youtube.com/@handle.",
315 ));
316 }
317 if key == "archive" {
318 let rules = &item["rules"];
319 if !rules.is_object() {
320 return Err(Error::new(400, "Enter channel rules."));
321 }
322 if let Some(date) = rules.get("download_after")
323 && !date_pattern.is_match(string(date))
324 {
325 return Err(Error::new(400, "Pick a date for the backlog."));
326 }
327 for rule in [
328 "title_include_keywords",
329 "title_exclude_keywords",
330 "description_include_keywords",
331 "description_exclude_keywords",
332 ] {
333 if let Some(words) = rules.get(rule)
334 && (!words.is_array()
335 || array(words).iter().any(|w| string(w).trim().is_empty()))
336 {
337 return Err(Error::new(400, "Enter keywords."));
338 }
339 }
340 }
341 }
342 }
343 Ok(())
344}
dashboard/tests/index-cases.json created+403
......@@ -0,0 +1,403 @@
1[
2 {
3 "name": "move out of a removed directory",
4 "before": {
5 "trash/keep": 5,
6 "trash/x": 7,
7 "o": 1
8 },
9 "ops": [
10 [
11 "renameSync",
12 "trash/keep",
13 "keep"
14 ],
15 [
16 "rmSync",
17 "trash",
18 {
19 "recursive": true
20 }
21 ]
22 ],
23 "diff": [
24 "R\tF\t/t/trash/keep\t/t/keep",
25 "-\tF\t/t/trash/x",
26 "-\t/\t/t/trash",
27 "M\t/\t/t"
28 ]
29 },
30 {
31 "name": "move into a new directory",
32 "before": {
33 "a": 5,
34 "o": 1
35 },
36 "ops": [
37 [
38 "mkdirSync",
39 "n"
40 ],
41 [
42 "renameSync",
43 "a",
44 "n/a"
45 ]
46 ],
47 "diff": [
48 "+\t/\t/t/n",
49 "R\tF\t/t/a\t/t/n/a",
50 "M\t/\t/t"
51 ]
52 },
53 {
54 "name": "rename a directory and a file inside it",
55 "before": {
56 "d/x": 5,
57 "d/z": 3
58 },
59 "ops": [
60 [
61 "renameSync",
62 "d",
63 "e"
64 ],
65 [
66 "renameSync",
67 "e/x",
68 "e/y"
69 ]
70 ],
71 "diff": [
72 "R\t/\t/t/d\t/t/e",
73 "R\tF\t/t/d/x\t/t/e/y",
74 "M\t/\t/t",
75 "M\t/\t/t/e"
76 ]
77 },
78 {
79 "name": "swap directories",
80 "before": {
81 "A/1": 5,
82 "B/2": 3
83 },
84 "ops": [
85 [
86 "renameSync",
87 "A",
88 "T"
89 ],
90 [
91 "renameSync",
92 "B",
93 "A"
94 ],
95 [
96 "renameSync",
97 "T",
98 "B"
99 ]
100 ],
101 "diff": [
102 "R\t/\t/t/A\t/t/B",
103 "R\t/\t/t/B\t/t/A",
104 "M\t/\t/t"
105 ]
106 },
107 {
108 "name": "invert nesting",
109 "before": {
110 "a/b/f": 5,
111 "a/g": 3
112 },
113 "ops": [
114 [
115 "renameSync",
116 "a/b",
117 "b"
118 ],
119 [
120 "renameSync",
121 "a",
122 "b/a"
123 ]
124 ],
125 "diff": [
126 "R\t/\t/t/a/b\t/t/b",
127 "R\t/\t/t/a\t/t/b/a",
128 "M\t/\t/t",
129 "M\t/\t/t/b"
130 ]
131 },
132 {
133 "name": "reuse a renamed directory's name",
134 "before": {
135 "d/x": 5
136 },
137 "ops": [
138 [
139 "renameSync",
140 "d",
141 "e"
142 ],
143 [
144 "mkdirSync",
145 "d"
146 ],
147 [
148 "writeFileSync",
149 "d/f",
150 9
151 ],
152 [
153 "writeFileSync",
154 "e/g",
155 4
156 ]
157 ],
158 "diff": [
159 "R\t/\t/t/d\t/t/e",
160 "+\t/\t/t/d",
161 "+\tF\t/t/d/f",
162 "+\tF\t/t/e/g",
163 "M\t/\t/t",
164 "M\t/\t/t/e"
165 ]
166 },
167 {
168 "name": "replace a file with a directory",
169 "before": {
170 "x": 5
171 },
172 "ops": [
173 [
174 "rmSync",
175 "x"
176 ],
177 [
178 "mkdirSync",
179 "x"
180 ],
181 [
182 "writeFileSync",
183 "x/f",
184 9
185 ]
186 ],
187 "diff": [
188 "-\tF\t/t/x",
189 "+\t/\t/t/x",
190 "+\tF\t/t/x/f",
191 "M\t/\t/t"
192 ]
193 },
194 {
195 "name": "rename over an empty directory",
196 "before": {
197 "x/f": 5,
198 "e/": 0
199 },
200 "ops": [
201 [
202 "rmSync",
203 "e",
204 {
205 "recursive": true
206 }
207 ],
208 [
209 "renameSync",
210 "x",
211 "e"
212 ]
213 ],
214 "diff": [
215 "-\t/\t/t/e",
216 "R\t/\t/t/x\t/t/e",
217 "M\t/\t/t"
218 ]
219 },
220 {
221 "name": "rename a directory onto a removed file's name",
222 "before": {
223 "x/f": 5,
224 "e": 3
225 },
226 "ops": [
227 [
228 "rmSync",
229 "e"
230 ],
231 [
232 "renameSync",
233 "x",
234 "e"
235 ]
236 ],
237 "diff": [
238 "-\tF\t/t/e",
239 "R\t/\t/t/x\t/t/e",
240 "M\t/\t/t"
241 ]
242 },
243 {
244 "name": "move a directory, then modify inside it",
245 "before": {
246 "m/a/f": 5
247 },
248 "ops": [
249 [
250 "renameSync",
251 "m/a",
252 "b"
253 ],
254 [
255 "writeFileSync",
256 "b/f",
257 50
258 ]
259 ],
260 "diff": [
261 "R\t/\t/t/m/a\t/t/b",
262 "M\tF\t/t/b/f",
263 "M\t/\t/t",
264 "M\t/\t/t/m"
265 ]
266 },
267 {
268 "name": "chain renames",
269 "before": {
270 "a": 1,
271 "b": 2,
272 "c": 3
273 },
274 "ops": [
275 [
276 "rmSync",
277 "c"
278 ],
279 [
280 "renameSync",
281 "b",
282 "c"
283 ],
284 [
285 "renameSync",
286 "a",
287 "b"
288 ]
289 ],
290 "diff": [
291 "-\tF\t/t/c",
292 "R\tF\t/t/b\t/t/c",
293 "R\tF\t/t/a\t/t/b",
294 "M\t/\t/t"
295 ]
296 },
297 {
298 "name": "remove a deep subtree",
299 "before": {
300 "a/b/c/d": 5,
301 "a/z": 1
302 },
303 "ops": [
304 [
305 "rmSync",
306 "a/b",
307 {
308 "recursive": true
309 }
310 ]
311 ],
312 "diff": [
313 "-\tF\t/t/a/b/c/d",
314 "-\t/\t/t/a/b/c",
315 "-\t/\t/t/a/b",
316 "M\t/\t/t/a"
317 ]
318 },
319 {
320 "name": "add a hard link",
321 "before": {
322 "a": 5,
323 "d/": 0
324 },
325 "ops": [
326 [
327 "linkSync",
328 "a",
329 "d/b"
330 ]
331 ],
332 "diff": [
333 "M\tF\t/t/a\t(+1)",
334 "M\t/\t/t/d"
335 ]
336 },
337 {
338 "name": "remove a hard link",
339 "before": {
340 "a": 5,
341 "d/b": "a"
342 },
343 "ops": [
344 [
345 "rmSync",
346 "d/b"
347 ]
348 ],
349 "diff": [
350 "M\tF\t/t/a\t(-1)",
351 "M\t/\t/t/d"
352 ]
353 },
354 {
355 "name": "rename a file and link it",
356 "before": {
357 "a": 5
358 },
359 "ops": [
360 [
361 "renameSync",
362 "a",
363 "b"
364 ],
365 [
366 "linkSync",
367 "b",
368 "c"
369 ]
370 ],
371 "diff": [
372 "M\tF\t/t/b\t(+1)",
373 "M\t/\t/t"
374 ]
375 },
376 {
377 "name": "link a new file into a new directory",
378 "before": {
379 "o": 1
380 },
381 "ops": [
382 [
383 "writeFileSync",
384 "n",
385 7
386 ],
387 [
388 "mkdirSync",
389 "d"
390 ],
391 [
392 "linkSync",
393 "n",
394 "d/m"
395 ]
396 ],
397 "diff": [
398 "+\tF\t/t/n",
399 "+\t/\t/t/d",
400 "M\t/\t/t"
401 ]
402 }
403]
dashboard/tsconfig.json created+30
......@@ -0,0 +1,30 @@
1{
2 "compilerOptions": {
3 "target": "ES2023",
4 "module": "ESNext",
5 "moduleResolution": "Bundler",
6 "allowImportingTsExtensions": true,
7 "noEmit": true,
8 "strict": true,
9 "noUncheckedIndexedAccess": true,
10 "jsx": "preserve",
11 "jsxImportSource": "solid-js",
12 "types": [
13 "node",
14 "vite/client"
15 ],
16 "lib": [
17 "ES2023",
18 "DOM",
19 "DOM.Iterable"
20 ],
21 "skipLibCheck": true,
22 "erasableSyntaxOnly": true,
23 "verbatimModuleSyntax": true
24 },
25 "include": [
26 "web",
27 "dev.ts",
28 "vite.config.ts"
29 ]
30}
dashboard/vite.config.ts created+20
......@@ -0,0 +1,20 @@
1import { defineConfig } from "vite";
2import solid from "vite-plugin-solid";
3
4export default defineConfig({
5 root: "web",
6 plugins: [solid()],
7 build: { outDir: "../dist", emptyOutDir: true },
8 server: {
9 host: true,
10 allowedHosts: [".ts.net", "sandwich"],
11 // Stands in for forward auth.
12 proxy: {
13 "/api/": {
14 target: "http://127.0.0.1:7070",
15 xfwd: true,
16 headers: { "User-Name": process.env.STUDIO_DEV_USER ?? "snow", "User-Groups": process.env.STUDIO_DEV_GROUPS ?? "infra-admin" },
17 },
18 },
19 },
20});
dashboard/web/api.contract.ts created+305
......@@ -0,0 +1,305 @@
1import type { Connections, Consent } from "./types/mcp.ts";
2import type { Pool, Vdev, Disk, Dataset, Snapshot } from "./types/storage.ts";
3import type { Torrent, TorrentFile, ServerState } from "./types/seedbox.ts";
4import type { Video, Show, Job, Wall, Archive, Upscaler, Channels, ConfigFile, LibraryEntry, Ingest } from "./types/youtube.ts";
5import type { User, Group, Session, Credential } from "./types/users.ts";
6import type { Hono } from "hono";
7import type { Health } from "./types/model.ts";
8import type { Domain, Image, History, NewDomain } from "./types/vms.ts";
9import type { Me, ServiceSummary, ServiceDetail, ServiceDefinition, HostInfo, Issue, Series, LogLine, TraceSummary, Trace } from "./types/model.ts";
10import type { MapNode } from "./types/storage.index.ts";
11import type { ProgressNode, PaperCloverStats } from "./types/paperClover.ts";
12
13type Endpoint<Output, Input = {}, Status extends number = 200, Format extends string = "json"> = {
14 input: Input; output: Output; status: Status; outputFormat: Format;
15};
16
17type FileEntry = { name: string; dir: boolean; size: number | null; alloc: number | null; items: number | null; modified: number; inode: number; dataset: string | null; download: string | null; };
18
19type Explorer = {
20 "/list": {
21 $get: Endpoint<{ host: string; link: string | null; zip: string | null; entries: FileEntry[]; }, { query: { path?: string | undefined; }; }>;
22 };
23 "/tree": {
24 $post: Endpoint<{ folders: { [x: string]: FileEntry[]; }; sizes: { [x: string]: { size: number; alloc: number; files: number; }; }; complete: boolean; updated: number | null; }, { json: { path: string; expanded: string[] | "all"; }; }>;
25 };
26 "/match": {
27 $get: Endpoint<{ count: number; size: number | null; sample: string[]; rows: { [x: string]: number | "self"; }; }, { query: { path: string; pattern: string; }; }>;
28 };
29 "/peek": {
30 $get: Endpoint<{ text: string; more: boolean; }, { query: { path: string; }; }>;
31 };
32 "/ops": {
33 $get: Endpoint<{ id: string; label: string; at: number; undone: boolean; sample: string[]; count: number; }[]>;
34 };
35 "/folder": {
36 $post: Endpoint<{ id: `${string}-${string}-${string}-${string}-${string}`; label: string; }, { json: { path: string; name: string; }; }>;
37 };
38 "/rename": {
39 $post: Endpoint<{ id: `${string}-${string}-${string}-${string}-${string}`; label: string; }, { json: { renames: { path: string; name: string; }[]; }; }>;
40 };
41 "/move": {
42 $post: Endpoint<{ id: `${string}-${string}-${string}-${string}-${string}`; label: string; }, { json: { paths: string[]; to: string; }; }>;
43 };
44 "/delete": {
45 $post: Endpoint<{ id: `${string}-${string}-${string}-${string}-${string}`; label: string; }, { json: { paths: string[]; }; }>;
46 };
47 "/bulk": {
48 $post: Endpoint<{ id: `${string}-${string}-${string}-${string}-${string}`; label: string; }, { json: { path: string; pattern: string; count: number; action: "move" | "delete"; to?: string | undefined; }; }>;
49 };
50 "/ops/:id/undo": {
51 $post: Endpoint<null, { param: { id: string; }; }, 204, "body">;
52 };
53};
54type ExplorerRoutes<Prefix extends string> = { [P in keyof Explorer as `${Prefix}${P}`]: Explorer[P] };
55
56export type Api = Hono<{}, {
57 "/mcp": { $get: Endpoint<Connections>; };
58 "/mcp/shale": { $post: Endpoint<{ redirect: string }, { json: { request?: string } }>; $delete: Endpoint<null, {}, 204>; };
59 "/mcp/consent/:id": {
60 $get: Endpoint<Consent, { param: { id: string } }>;
61 $post: Endpoint<{ redirect: string }, { param: { id: string }; json: { resources: string[] } | { deny: true } }>;
62 };
63 "/mcp/relay/pair": { $post: Endpoint<Connections["machines"][number], { json: { code: string } }>; };
64 "/mcp/relay/machines/:id": {
65 $patch: Endpoint<Connections["machines"][number], { param: { id: string }; json: { name: string } }>;
66 $delete: Endpoint<null, { param: { id: string } }, 204>;
67 };
68 "/mcp/relay/keys": { $post: Endpoint<{ key: string; id: string }, { json: { name: string; resources: string[]; write: boolean } }>; };
69 "/mcp/connections/:id": { $delete: Endpoint<null, { param: { id: string } }, 204>; };
70
71 "/me": {
72 $get: Endpoint<Me>;
73 };
74 "/launcher": {
75 $get: Endpoint<{ id: string; name: string; tagline: string | null; url: string; icon: { light: string; dark: string; } | null; health: Health | null; }[]>;
76 };
77 "/status": {
78 $get: Endpoint<{ load: number; issues: Issue[] | null }>;
79 };
80 "/host": {
81 $get: Endpoint<HostInfo>;
82 };
83 "/services": {
84 $get: Endpoint<ServiceSummary[]>;
85 };
86 "/services/:id": {
87 $get: Endpoint<ServiceDetail, { param: { id: string; }; }>;
88 };
89 "/services/:id/definition": {
90 $get: Endpoint<ServiceDefinition, { param: { id: string; }; }>;
91 };
92 "/services/:id/:action": {
93 $post: Endpoint<null, { param: { id: string; action: "stop" | "restart" | "start"; }; }, 204, "body">;
94 };
95 "/services/:id/restarts/acknowledge": {
96 $post: Endpoint<null, { param: { id: string; }; }, 204, "body">;
97 };
98 "/services/:id/secrets/:name": {
99 $get: Endpoint<{ value: string; }, { param: { name: string; } & { id: string; }; }>;
100 $put: Endpoint<null, { json: { value: string; }; } & { param: { name: string; } & { id: string; }; }, 204, "body">;
101 };
102 "/services/:id/secrets/:name/rotate": {
103 $post: Endpoint<null, { param: { name: string; } & { id: string; }; }, 204, "body">;
104 };
105 "/services/:id/logs": {
106 $get: Endpoint<LogLine[], { query: { q?: string | undefined; level?: "error" | "warn" | undefined; before?: string | string[] | undefined; after?: string | string[] | undefined; limit?: string | string[] | undefined; }; } & { param: { id: string; }; }>;
107 };
108 "/services/:id/metrics/:name": {
109 $get: Endpoint<Series[], { param: { id: string; name: string; }; } & { query: { range?: string | string[] | undefined; }; }>;
110 };
111 "/services/:id/traces": {
112 $get: Endpoint<TraceSummary[], { query: { q?: string | undefined; errors?: "1" | undefined; sort?: "newest" | "oldest" | "slowest" | "fastest" | undefined; limit?: string | string[] | undefined; }; } & { param: { id: string; }; }>;
113 };
114 "/traces/:id": {
115 $get: Endpoint<Trace, { param: { id: string; }; }>;
116 };
117 "/metrics/:metric": {
118 $get: Endpoint<Series[], { param: { metric: "host.cpu" | "host.memory" | "host.temperature" | "host.power" | "host.gpu" | "host.network" | "service.cpu" | "service.memory" | "vm.cpu" | "vm.memory"; }; } & { query: { range?: string | string[] | undefined; service?: string | undefined; }; }>;
119 };
120 "/live": {
121 $get: Endpoint<{}, {}, 200, string>;
122 };
123 "/icons/:id/:file": {
124 $get: Endpoint<Uint8Array, { param: { id: string; file: "icon.svg" | "icon-dark.svg" | "icon-light.svg"; }; }, 200, "body">;
125 };
126 "/storage": {
127 $get: Endpoint<{ pool: Omit<Pool, "vdevs"> & { vdevs: (Omit<Vdev, "disks"> & { disks: (Disk & { issue: string | null })[] })[] }; datasets: (Dataset & { link: string | null; media: string | null })[]; space: { live: number; held: number; free: number } }>;
128 };
129 "/storage/snapshots": {
130 $get: Endpoint<Snapshot[], { query: { dataset: string; }; }>;
131 };
132 "/storage/reclaim": {
133 $get: Endpoint<{ bytes: number; }, { query: { dataset: string; from: string; to: string; }; }>;
134 };
135 "/storage/removed": {
136 $get: Endpoint<{ count: number; largest: { path: string; size: number; }[]; }, { query: { dataset: string; snapshot: string; }; }>;
137 };
138 "/storage/destroy": {
139 $post: Endpoint<null, { json: { dataset: string; from: string; to: string; }; }, 204, "body">;
140 };
141 "/storage/files/map": {
142 $get: Endpoint<{ root: string; min: number; scanning: boolean; tree: MapNode | null }, { query: { width: string | string[]; height: string | string[]; path?: string | undefined; }; }>;
143 };
144 "/storage/files/largest": {
145 $get: Endpoint<{ link: string | null; path: string; size: number; alloc: number; mtime: number; }[]>;
146 };
147 "/media/refresh": {
148 $post: Endpoint<null, {}, 202, "body">;
149 };
150 "/seedbox": {
151 $get: Endpoint<{state: ServerState; downloads: string | null; torrents: (Torrent & {folder: string | null; zip: string | null})[]}>;
152 };
153 "/seedbox/history": {
154 $get: Endpoint<Series[]>;
155 };
156 "/seedbox/torrents": {
157 $post: Endpoint<null, { json: { url: string; }; }, 204, "body">;
158 };
159 "/seedbox/torrents/:hash/files": {
160 $get: Endpoint<(TorrentFile & {link: string | null})[], { param: { hash: string; }; }>;
161 };
162 "/seedbox/torrents/:hash/:action": {
163 $post: Endpoint<null, { param: { hash: string; action: "stop" | "start" | "topPrio" | "increasePrio" | "decreasePrio" | "bottomPrio"; }; }, 204, "body">;
164 };
165 "/seedbox/torrents/:hash": {
166 $delete: Endpoint<null, { param: { hash: string; }; } & { query: { files?: "0" | "1" | undefined; }; }, 204, "body">;
167 };
168 "/youtube": {
169 $get: Endpoint<{pending:Video[]; shows:Show[]; jobs:Job[]; wall:Wall; archive:Archive; upscaler:Upscaler}>;
170 };
171 "/youtube/channels": {
172 $get: Endpoint<Channels>;
173 $put: Endpoint<null, { json: Channels }, 204, "body">;
174 };
175 "/youtube/configs": {
176 $get: Endpoint<ConfigFile[]>;
177 };
178 "/youtube/configs/:name": {
179 $put: Endpoint<null, { json: { original: string; body: string; }; } & { param: { name: string; }; }, 204, "body">;
180 };
181 "/youtube/library": {
182 $get: Endpoint<LibraryEntry[]>;
183 };
184 "/youtube/library/rename": {
185 $post: Endpoint<null, { json: { path: string; title: string; season: number | null; episode: number | null; }; }, 204, "body">;
186 };
187 "/youtube/pending": {
188 $post: Endpoint<null, { json: { urls: string[]; }; }, 204, "body">;
189 };
190 "/youtube/pending/:key/ingest": {
191 $post: Endpoint<null, { json: Ingest; param: { key: string } }, 204, "body">;
192 };
193 "/youtube/pending/:key/skip": {
194 $post: Endpoint<null, { param: { key: string; }; }, 204, "body">;
195 };
196 "/youtube/jobs/:id/retry": {
197 $post: Endpoint<null, { param: { id: string; }; }, 204, "body">;
198 };
199 "/youtube/upscaler": {
200 $put: Endpoint<null, { json: { enabled: boolean; }; }, 204, "body">;
201 };
202 "/paper-clover": {
203 $get: Endpoint<PaperCloverStats & { browse: string | null }>;
204 };
205 "/paper-clover/activity": {
206 $get: Endpoint<ProgressNode[]>;
207 };
208 "/paper-clover/scan": {
209 $post: Endpoint<null, { json: { path: string | null; }; }, 202, "body">;
210 };
211 "/users": {
212 $get: Endpoint<{users:(User & {sessions:Session[]})[]; groups:Group[]}>;
213 $post: Endpoint<{ id: string; }, { json: { profile: { email: string; firstName: string; lastName: string; username: string; }; groups: string[]; setup: { kind: "email"; } | { kind: "password"; password: string; }; }; }, 201>;
214 };
215 "/users/:id": {
216 $patch: Endpoint<null, { param: { id: string; }; } & { json: { username?: string | undefined; email?: string | undefined; firstName?: string | undefined; lastName?: string | undefined; enabled?: boolean | undefined; emailVerified?: boolean | undefined; requiredActions?: string[] | undefined; }; }, 204, "body">;
217 $delete: Endpoint<null, { param: { id: string; }; }, 204, "body">;
218 };
219 "/users/:id/groups/:group": {
220 $put: Endpoint<null, { param: { id: string; group: string; }; }, 204, "body">;
221 $delete: Endpoint<null, { param: { id: string; group: string; }; }, 204, "body">;
222 };
223 "/users/:id/credentials": {
224 $get: Endpoint<Credential[], { param: { id: string; }; }>;
225 };
226 "/users/:id/logout": {
227 $post: Endpoint<null, { param: { id: string; }; }, 204, "body">;
228 };
229 "/users/:id/actions-email": {
230 $post: Endpoint<null, { param: { id: string; }; }, 204, "body">;
231 };
232 "/users/:id/password": {
233 $put: Endpoint<null, { param: { id: string; }; } & { json: { password: string; temporary: boolean; }; }, 204, "body">;
234 };
235 "/deploys": {
236 $get: Endpoint<{ current: string | null; main: { release: string; commit: string; description: string } | null; recorded: boolean; history: { n: number; changed: string[] | null; redeploys: string[] | null; release: string; source: string; time: number; legacy: boolean; }[]; stages: { files: string | null; base: number | null; changed: string[] | null; behind: string[]; id: string; service: string; release: string | null; ready: boolean; overrides: string[]; created: number; clone: string | null; mount: string; hostname: string | null; health: Health | null; }[]; run: { id: string; title: string; target: string; code: number | null; } | null; }>;
237 };
238 "/deploys/changes": {
239 $get: Endpoint<{ id: string; lines: [op: " " | "-" | "+", text: string][]; }[], { query: { to: string; from?: string | undefined; }; }>;
240 };
241 "/deploys/stages/:id/runtime": {
242 $get: Endpoint<{ from: number; to: number | null; jobs: { id: string; allocations: { id: string; state: "pending" | "running" | "complete" | "failed" | "lost" | "unknown"; healthy: boolean | null; created: number; ended: number | null; tasks: { name: string; restarts: number; lastRestart: { t: number; reason: string; } | null; }[]; checks: { name: string; passing: boolean; output: string; }[]; }[]; cpu: { name: string; t: number[]; v: (number | null)[]; } | null; memory: { name: string; t: number[]; v: (number | null)[]; } | null; }[]; }, { param: { id: string; }; }>;
243 };
244 "/deploys/history/:n/runtime": {
245 $get: Endpoint<{ from: number; to: number | null; jobs: { id: string; allocations: { id: string; state: "pending" | "running" | "complete" | "failed" | "lost" | "unknown"; healthy: boolean | null; created: number; ended: number | null; tasks: { name: string; restarts: number; lastRestart: { t: number; reason: string; } | null; }[]; checks: { name: string; passing: boolean; output: string; }[]; }[]; cpu: { name: string; t: number[]; v: (number | null)[]; } | null; memory: { name: string; t: number[]; v: (number | null)[]; } | null; }[]; }, { param: { n: string; }; }>;
246 };
247 "/deploys/stages/:id/logs": {
248 $get: Endpoint<LogLine[], { param: { id: string }; query: { job?: string; after?: string | string[]; before?: string | string[]; limit?: string | string[] } }>;
249 };
250 "/deploys/history/:n/logs": {
251 $get: Endpoint<LogLine[], { param: { n: string }; query: { job?: string; after?: string | string[]; before?: string | string[]; limit?: string | string[] } }>;
252 };
253 "/deploys/stages/:id/output": {
254 $get: Endpoint<{ lines: string[] | null; }, { param: { id: string; }; }>;
255 };
256 "/deploys/history/:n/output": {
257 $get: Endpoint<{ lines: string[] | null; }, { param: { n: string; }; }>;
258 };
259 "/deploys/main/:release/deploy": {
260 $post: Endpoint<{ id: string; title: string; target: string; }, { param: { release: string; }; }>;
261 };
262 "/deploys/stages/:id/destroy": {
263 $post: Endpoint<{ id: string; title: string; target: string; }, { param: { id: string; }; }>;
264 };
265 "/deploys/history/:n/rollback": {
266 $post: Endpoint<{ id: string; title: string; target: string; }, { param: { n: string; }; }>;
267 };
268 "/deploys/runs/:id": {
269 $get: Endpoint<{}, { param: { id: string; }; }, 200, string>;
270 };
271 "/vms": {
272 $get: Endpoint<{ node: { cpus: number; memory: number }; domains: Domain[]; images: Image[] }>;
273 $post: Endpoint<null, { json: NewDomain }, 204, "body">;
274 };
275 "/vms/history": {
276 $get: Endpoint<History, { query: { range?: string | string[]; name?: string } }>;
277 };
278 "/vms/:name": {
279 $patch: Endpoint<null, { param: { name: string; }; } & { json: { autostart?: boolean | undefined; description?: string | undefined; }; }, 204, "body">;
280 $delete: Endpoint<null, { param: { name: string; }; } & { query: { disks?: "0" | "1" | undefined; }; }, 204, "body">;
281 };
282 "/vms/:name/:action": {
283 $post: Endpoint<null, { param: { name: string; action: "start" | "destroy" | "shutdown" | "reboot" | "resume"; }; }, 204, "body">;
284 };
285 "/account": {
286 $get: Endpoint<User & {picture: string | null; credentials:Credential[]; console: string | null}>;
287 $patch: Endpoint<null, { json: { firstName?: string | undefined; lastName?: string | undefined; }; }, 204, "body">;
288 };
289 "/account/verify-email": {
290 $post: Endpoint<null, {}, 204, "body">;
291 };
292 "/account/picture": {
293 $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>;
294 $delete: Endpoint<null, {}, 204, "body">;
295 };
296 "/account/pictures/:id": {
297 $get: Endpoint<Uint8Array, { param: { id: string; }; }, 200, "body">;
298 };
299 "/account/actions/:action": {
300 $get: Endpoint<undefined, { param: { action: string; }; }, 302, "redirect">;
301 };
302 "/account/sign-out": {
303 $get: Endpoint<undefined, {}, 302, "redirect">;
304 };
305} & ExplorerRoutes<"/media"> & ExplorerRoutes<"/storage/files">>;
dashboard/web/api.ts created+89
......@@ -0,0 +1,89 @@
1import { DetailedError, hc, parseResponse } from "hono/client";
2import { createResource, untrack } from "solid-js";
3import type { Api } from "./api.contract.ts";
4import type { Metric } from "./types/model.ts";
5
6export const api = hc<Api>("/api", {
7 fetch: (input: RequestInfo | URL, init?: RequestInit) => {
8 if (init?.method !== "GET") return fetch(input, init);
9 const timeout = AbortSignal.timeout(15_000);
10 return fetch(input, { ...init, signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout });
11 },
12});
13
14/**
15 * The server has no source for this yet (HTTP 501); its message names what's missing. It stays missing until the
16 * server restarts, so polling it is pointless.
17 */
18export const unconnected = (error: unknown) => error instanceof DetailedError && error.statusCode === 501;
19
20/** A sentence for a failed request: the server's own text or JSON message when it sent one. */
21export function reason(error: unknown): string {
22 if (!(error instanceof DetailedError)) return "The dashboard didn't answer. Check your connection, then retry.";
23 const data = error.detail?.data;
24 const text = typeof data === "string" ? data.trim() : data?.message;
25 if (typeof text === "string" && text && !text.startsWith("<")) return text;
26 return error.statusCode >= 502
27 ? "The dashboard server isn't answering. It may be restarting, so retry in a moment."
28 : `The dashboard answered ${error.message}. Check its logs, then retry.`;
29}
30
31/** The last answer to each query, so a page opened again renders at once while it refetches. */
32const answers = new Map<string, unknown>();
33/** The one request under way per query, which every load joins, including a page opening after its link's hover. */
34const loading = new Map<string, Promise<unknown>>();
35
36/**
37 * A GET that every view showing it shares. `use` is `createResource` that renders the last answer at once and refetches
38 * it, and whose loads join one already under way; `preload` starts a first load early, for a route's link hover.
39 */
40export function query<T, A = void>(name: string, fetch: (arg: A) => Promise<T>) {
41 const keyOf = (arg: A) => JSON.stringify([name, arg ?? null]);
42 function request(key: string, arg: A) {
43 const answer = fetch(arg);
44 loading.set(key, answer);
45 answer.then((value) => answers.set(key, value), () => {}).finally(() => loading.delete(key));
46 return answer;
47 }
48 return {
49 preload(arg: A) {
50 const key = keyOf(arg);
51 if (!answers.has(key) && !loading.has(key)) request(key, arg).catch(() => {});
52 },
53 /** `arg` is reactive; while it's false nothing loads. */
54 use(arg: () => A | false = () => undefined as A) {
55 const key = () => {
56 const value = arg();
57 return value !== false && keyOf(value);
58 };
59 const [resource, actions] = createResource<T, string>(key, (key, { value, refetching }) => {
60 if (!refetching && answers.has(key)) {
61 queueMicrotask(() => actions.refetch());
62 return answers.get(key) as T;
63 }
64 // A new key with no answer yet shows its skeleton, since Solid keeps the previous key's value as `latest`.
65 if (!refetching && value !== undefined) actions.mutate(undefined);
66 // Solid drops an answer once a newer load starts, so a poll that restarted a slow request would never land.
67 return (loading.get(key) as Promise<T> | undefined) ?? request(key, untrack(arg) as A);
68 });
69 return [resource, actions] as const;
70 },
71 };
72}
73
74/** Queries that several pages share or a route preloads. */
75export const queries = {
76 services: query("services", () => parseResponse(api.services.$get())),
77 service: query("service", (id: string) => parseResponse(api.services[":id"].$get({ param: { id } }))),
78 launcher: query("launcher", () => parseResponse(api.launcher.$get())),
79 host: query("host", () => parseResponse(api.host.$get())),
80 storage: query("storage", () => parseResponse(api.storage.$get())),
81 deploys: query("deploys", () => parseResponse(api.deploys.$get())),
82 vms: query("vms", () => parseResponse(api.vms.$get())),
83 metric: query("metric", ({ metric, range, service }: { metric: Metric; range: number; service?: string }) =>
84 parseResponse(api.metrics[":metric"].$get({ param: { metric }, query: { range: String(range), ...(service ? { service } : {}) } }))),
85 seedbox: query("seedbox", () => parseResponse(api.seedbox.$get())),
86 seedboxHistory: query("seedbox history", () => parseResponse(api.seedbox.history.$get())),
87 paperClover: query("paper clover", () => parseResponse(api["paper-clover"].$get())),
88 paperCloverActivity: query("paper clover activity", () => parseResponse(api["paper-clover"].activity.$get())),
89};
dashboard/web/components/Ago.tsx created+6
......@@ -0,0 +1,6 @@
1import { ago, datetime } from "../format.ts";
2
3/** A relative time with the full date and time on hover. `t` is unix seconds. */
4export const Ago = (props: { t: number }) => (
5 <time datetime={new Date(props.t * 1000).toISOString()} data-tip={datetime(props.t)}>{ago(props.t)}</time>
6);
dashboard/web/components/AppIcon.tsx created+26
......@@ -0,0 +1,26 @@
1import { Show } from "solid-js";
2import type { ServiceSummary } from "../types/model.ts";
3
4function slot(id: string) {
5 let seed = 0;
6 for (const char of id) seed = (seed * 31 + char.charCodeAt(0)) % 997;
7 return (seed % 8) + 1;
8}
9
10/** The service's in-tree icon, or a lettered tile when it has none. */
11export function AppIcon(props: { id: string; name: string; icon: ServiceSummary["icon"] }) {
12 return (
13 <Show when={props.icon} fallback={
14 <span class="monogram" aria-hidden="true" style={{ background: `var(--series-${slot(props.id)})` }}>
15 {props.name.slice(0, 1).toUpperCase()}
16 </span>
17 }>
18 {(icon) => (
19 <picture>
20 <source srcset={icon().dark} media="(prefers-color-scheme: dark)" />
21 <img src={icon().light} alt="" />
22 </picture>
23 )}
24 </Show>
25 );
26}
dashboard/web/components/Checkbox.tsx created+37
......@@ -0,0 +1,37 @@
1import Check from "lucide-solid/icons/check";
2import Minus from "lucide-solid/icons/minus";
3import { createEffect, type JSX } from "solid-js";
4
5/**
6 * A checkbox drawn in the page's style around a real input, so Space, label clicks and forms behave natively. A string
7 * `disabled` is the reason, shown as the tooltip; `checked` without `onChange` is just the initial state in a form. The
8 * box flips at once, and once `onChange`'s promise settles it shows `checked` again, so a failed save flips it back.
9 */
10export function Checkbox(props: {
11 checked?: boolean;
12 indeterminate?: boolean;
13 disabled?: boolean | string;
14 name?: string;
15 value?: string;
16 onChange?: (checked: boolean) => unknown;
17 children: JSX.Element;
18}) {
19 const reason = () => (typeof props.disabled === "string" ? props.disabled : undefined);
20 let input!: HTMLInputElement;
21 createEffect(() => (input.indeterminate = !!props.indeterminate));
22 return (
23 <label class="checkbox" data-tip={reason()} tabIndex={reason() ? 0 : undefined}>
24 <input ref={input} type="checkbox" name={props.name} value={props.value} checked={props.checked} disabled={!!props.disabled}
25 onChange={async (event) => {
26 if (!props.onChange) return;
27 try {
28 await props.onChange(event.currentTarget.checked);
29 } finally {
30 input.checked = !!props.checked;
31 }
32 }} />
33 <span class="box" aria-hidden="true"><Check class="tick" /><Minus class="dash" /></span>
34 {props.children}
35 </label>
36 );
37}
dashboard/web/components/Copy.tsx created+22
......@@ -0,0 +1,22 @@
1import { createSignal, type JSX } from "solid-js";
2
3/**
4 * Text that copies `value` on click; the tooltip turns into the result. Shows `value` unless given children, and
5 * `label` names what's copied when that isn't what's shown.
6 */
7export function Copy(props: { value: string; label?: string; children?: JSX.Element }) {
8 const [result, setResult] = createSignal("");
9 const copy = () =>
10 Promise.resolve()
11 .then(() => navigator.clipboard.writeText(props.value))
12 .then(() => setResult("copied"), () => setResult("Couldn't copy. Select the text instead"));
13 return (
14 <>
15 <button type="button" class="copy" data-tip={result() || (props.label ? `copy ${props.label}` : "copy")} onClick={copy}
16 onPointerLeave={() => setResult("")} onBlur={() => setResult("")}>
17 {props.children ?? props.value}
18 </button>
19 <span class="sr-only" role="status">{result()}</span>
20 </>
21 );
22}
dashboard/web/components/Dialog.tsx created+131
......@@ -0,0 +1,131 @@
1import { createSignal, type JSX, onMount, Show } from "solid-js";
2import { render } from "solid-js/web";
3import { reason } from "../api.ts";
4
5type Content = JSX.Element | (() => JSX.Element);
6const build = (content: Content) => (typeof content === "function" ? content() : content);
7
8interface DialogOptions {
9 title: string;
10 /**
11 * The consequence, read out with the title. Pass a function when it reads signals or uses control flow, so it's
12 * built inside the dialog rather than in the click handler, which has no owner.
13 */
14 description?: Content;
15 /** Form fields and options under the description (and text field), built inside the dialog like `description`. */
16 body?: Content;
17 /** Names the action, like "restart"; never "ok". */
18 confirmLabel: string;
19 destructive?: boolean;
20 /** Takes focus once the dialog closes, instead of the element that opened it. */
21 returnFocus?: HTMLElement;
22 /**
23 * Gets the body's named fields. The dialog stays open with a spinner until this settles; a rejection shakes it
24 * and shows the reason.
25 */
26 onConfirm: (form: FormData) => unknown;
27}
28
29interface TextDialogOptions extends Omit<DialogOptions, "onConfirm"> {
30 /** Visible label for the field. */
31 label: string;
32 placeholder?: string;
33 initialValue?: string;
34 /** Gates the confirm button; defaults to non-empty. */
35 validateInput?: (value: string) => boolean;
36 onConfirm: (value: string, form: FormData) => unknown;
37}
38
39const SHAKE = [0, -8, 8, -8, 8, -8, 8, 0].map((x) => ({ transform: `translateX(${x}px)` }));
40const reduced = matchMedia("(prefers-reduced-motion: reduce)");
41let dialogs = 0;
42
43/**
44 * Enter confirms and Esc, the backdrop, and "back" cancel. Confirm starts focused unless the body has an
45 * `autofocus` field; the browser checks the body's constraints (`required`, `pattern`…) before `onConfirm` runs.
46 */
47export const showConfirmDialog = (options: DialogOptions) => open(options);
48
49/** Like showConfirmDialog with a text field; Enter submits once `validateInput` passes. */
50export const showTextDialog = (options: TextDialogOptions) =>
51 open({ ...options, onConfirm: (form) => options.onConfirm(String(form.get("value")), form) }, options);
52
53function open(options: DialogOptions, field?: TextDialogOptions) {
54 const host = document.body.appendChild(document.createElement("div"));
55 const id = `dialog-${++dialogs}`;
56 const dispose = render(() => {
57 const [value, setValue] = createSignal(field?.initialValue ?? "");
58 const [pending, setPending] = createSignal(false);
59 const [error, setError] = createSignal("");
60 const valid = () => !field || (field.validateInput ?? Boolean)(value());
61 let dialog!: HTMLDialogElement;
62 let pressedOutside = false;
63
64 const close = () => {
65 if (pending()) return;
66 dialog.classList.add("closing");
67 setTimeout(() => dialog.close(), reduced.matches ? 0 : 140);
68 };
69
70 const submit = async (event: SubmitEvent) => {
71 event.preventDefault();
72 if (pending() || !valid()) return;
73 setPending(true);
74 setError("");
75 try {
76 await options.onConfirm(new FormData(event.currentTarget as HTMLFormElement));
77 setPending(false);
78 close();
79 } catch (failure) {
80 setPending(false);
81 setError(reason(failure));
82 if (!reduced.matches) dialog.animate(SHAKE, { duration: 380, easing: "cubic-bezier(0.36, 0.07, 0.19, 0.97)" });
83 }
84 };
85
86 onMount(() => dialog.showModal());
87
88 return (
89 <dialog ref={dialog} class="dialog" aria-labelledby={`${id}-title`}
90 aria-describedby={options.description ? `${id}-description` : undefined}
91 onCancel={(event) => {
92 event.preventDefault();
93 close();
94 }}
95 onClose={() => {
96 dispose();
97 host.remove();
98 options.returnFocus?.focus();
99 }}
100 onPointerDown={(event) => (pressedOutside = event.target === dialog)}
101 onClick={(event) => pressedOutside && event.target === dialog && close()}>
102 <form onSubmit={submit}>
103 <h2 id={`${id}-title`}>{options.title}</h2>
104 <Show when={options.description}>
105 <div id={`${id}-description`} class="description">{build(options.description)}</div>
106 </Show>
107 <Show when={field}>
108 {(field) => (
109 <label class="field">
110 {field().label}
111 <input name="value" class="search" value={value()} placeholder={field().placeholder} autofocus
112 readOnly={pending()} autocomplete="off" spellcheck={false}
113 onInput={(event) => setValue(event.currentTarget.value)} />
114 </label>
115 )}
116 </Show>
117 {build(options.body)}
118 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
119 <div class="actions">
120 <button type="button" class="button" disabled={pending()} onClick={close}>back<kbd aria-hidden="true">esc</kbd></button>
121 {/* aria-disabled rather than disabled while pending, so focus stays put for a retry. */}
122 <button type="submit" class="button primary" classList={{ danger: options.destructive }}
123 autofocus={!field} disabled={!valid()} aria-disabled={pending()} aria-busy={pending()}>
124 {options.confirmLabel}<kbd aria-hidden="true">enter</kbd>
125 </button>
126 </div>
127 </form>
128 </dialog>
129 );
130 }, host);
131}
dashboard/web/components/Donut.tsx created+49
......@@ -0,0 +1,49 @@
1import { For } from "solid-js";
2
3/** Part-to-whole ring; slices are separated by a surface-colored gap rather than a stroke. Colors may be `var(--…)`. */
4export function Donut(props: {
5 slices: { label: string; value: number; color: string }[];
6 format: (value: number) => string;
7 center: string;
8 caption: string;
9}) {
10 const R = 52;
11 const C = 2 * Math.PI * R;
12 const arcs = () => {
13 const total = props.slices.reduce((sum, slice) => sum + slice.value, 0) || 1;
14 let offset = 0;
15 return props.slices.map((slice) => {
16 const length = (slice.value / total) * C;
17 const arc = { ...slice, dash: `${Math.max(0, length - 2)} ${C}`, offset: -offset };
18 offset += length;
19 return arc;
20 });
21 };
22 return (
23 <div class="donut-row">
24 <svg width="140" height="140" viewBox="0 0 140 140" role="img" aria-label={props.caption}>
25 <g transform="rotate(-90 70 70)">
26 <For each={arcs()}>
27 {(arc) => (
28 <circle cx="70" cy="70" r={R} fill="none" style={{ stroke: arc.color }} stroke-width="16"
29 stroke-dasharray={arc.dash} stroke-dashoffset={arc.offset} data-tip={`${arc.label}: ${props.format(arc.value)}`} />
30 )}
31 </For>
32 </g>
33 <text x="70" y="68" text-anchor="middle" fill="var(--text)" font-size="17" font-weight="650">{props.center}</text>
34 <text x="70" y="86" text-anchor="middle" fill="var(--muted)" font-size="11">{props.caption}</text>
35 </svg>
36 <div class="legend">
37 <For each={props.slices}>
38 {(slice) => (
39 <div class="legend-item">
40 <span class="swatch" style={{ background: slice.color }} />
41 <span>{slice.label}</span>
42 <span class="v">{props.format(slice.value)}</span>
43 </div>
44 )}
45 </For>
46 </div>
47 </div>
48 );
49}
dashboard/web/components/Explorer.css created+215
......@@ -0,0 +1,215 @@
1.explorer { display: flex; flex-direction: column; }
2.explorer > * { flex: none; }
3
4/* The chrome is spaced, not ruled: the pinned table header draws the one line between it and the list. */
5.explorer .bar { display: flex; align-items: center; gap: 6px; padding: 0 var(--gutter) 6px; min-height: 36px; }
6.crumbs { display: flex; align-items: center; flex-wrap: wrap; min-width: 0; margin-left: -9px; color: var(--muted); font-size: 14px; }
7.crumbs a {
8 min-width: 0;
9 height: 30px;
10 padding: 0 9px;
11 overflow: hidden;
12 border-radius: var(--radius);
13 color: var(--text-2);
14 line-height: 30px;
15 white-space: nowrap;
16 text-overflow: ellipsis;
17 transition: background-color 150ms;
18}
19.crumbs svg { flex: none; }
20.crumbs a:hover { background: var(--hover); color: var(--text); }
21.crumbs a[aria-current] { color: var(--text); font-weight: 600; }
22.explorer .bar .total { color: var(--text-2); font-size: 13px; font-variant-numeric: tabular-nums; white-space: nowrap; }
23.explorer .bar .count { margin-right: 4px; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
24.explorer .bar .button.icon-only[aria-pressed="true"] { color: var(--accent); }
25
26.explorer .history {
27 inset: auto;
28 top: anchor(bottom);
29 right: anchor(right);
30 width: min(460px, calc(100vw - 32px));
31 max-height: min(360px, 60vh);
32 margin: 6px 0 0;
33 padding: 4px 0;
34 overflow: auto;
35 border: 1px solid var(--line);
36 border-radius: var(--radius-lg);
37 background: var(--surface);
38 color: var(--text);
39 box-shadow: 0 8px 28px #0005;
40 font-size: 13px;
41}
42.explorer .history:popover-open { animation: menu-in 200ms var(--ease-out); }
43.explorer .history .none { margin: 0; padding: 10px 14px; color: var(--muted); }
44.explorer .history .empty.failed { padding: 8px 14px; }
45.explorer .history .op { display: grid; grid-template-columns: minmax(0, 1fr) auto 64px; gap: 10px; align-items: center; min-height: 32px; padding: 0 8px 0 14px; }
46.explorer .history .op:hover { background: var(--hover); }
47.explorer .history .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
48.explorer .history .when { color: var(--muted); font-size: 12px; white-space: nowrap; }
49.explorer .history .undone { color: var(--muted); font-size: 12px; text-align: center; }
50.explorer .history .button { justify-self: stretch; }
51
52.explorer .map-slot { padding: 0 var(--gutter) 8px; }
53.explorer .folder-map { position: relative; height: clamp(120px, 22vh, 220px); }
54.explorer .folder-map.empty { display: grid; place-items: center; color: var(--muted); font-size: 13px; border-radius: var(--radius); background: var(--well); }
55.explorer .folder-map .cell {
56 position: absolute;
57 overflow: hidden;
58 display: flex;
59 flex-direction: column;
60 padding: 4px 6px;
61 border: 1px solid var(--page);
62 border-radius: var(--radius);
63 background: color-mix(in srgb, var(--kind) 42%, var(--page));
64 font-size: 12px;
65 line-height: 1.3;
66 transition: background-color 150ms;
67}
68.explorer .folder-map .cell.dir { cursor: pointer; }
69.explorer .folder-map .cell.rest { background: repeating-linear-gradient(135deg, var(--raised) 0 4px, var(--page) 4px 8px); }
70.explorer .folder-map .cell.hover:not(.rest) { background: color-mix(in srgb, var(--kind) 62%, var(--page)); }
71.explorer .folder-map .cell.cursor { box-shadow: inset 0 0 0 2px var(--text); }
72.explorer .folder-map .cell > span { flex: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
73.explorer .folder-map .name { color: var(--text); font-weight: 550; }
74.explorer .folder-map .size { color: var(--text-2); font-variant-numeric: tabular-nums; }
75
76.explorer .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 var(--gutter) 8px; font-size: 13px; white-space: nowrap; }
77.explorer .toolbar .clip { display: flex; align-items: center; gap: 4px; }
78.explorer .toolbar .matched { font-variant-numeric: tabular-nums; margin-right: 4px; }
79.explorer .toolbar .error { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
80.explorer .toolbar .pattern { flex: 0 1 240px; min-width: 132px; gap: 6px; }
81.explorer .toolbar .pattern input { font: 12px var(--mono); }
82.explorer .toolbar .pattern.active { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); background: var(--accent-wash); }
83.explorer .toolbar .pattern .clear { display: grid; place-items: center; width: 20px; height: 20px; margin-right: -4px; padding: 0; border: 0; border-radius: 99px; background: none; color: var(--muted); cursor: pointer; }
84.explorer .toolbar .pattern .clear:hover { background: var(--hover); color: var(--text); }
85
86.explorer .files-scroll { flex: 1 1 0; min-height: 0; overflow: auto; }
87.explorer .files-skeleton { display: grid; gap: 14px; padding: 36px var(--gutter) 12px calc(var(--gutter) + 24px); }
88
89.explorer table.files { user-select: none; outline: none; }
90.explorer table.files th {
91 position: sticky;
92 top: 0;
93 z-index: 1;
94 height: 28px;
95 padding: 0 8px;
96 border-bottom: 0;
97 box-shadow: inset 0 -1px var(--line);
98 background: var(--page);
99}
100.explorer table.files .num { width: 1%; }
101.explorer table.files tr > :first-child { padding-left: var(--gutter); }
102.explorer table.files tr > :last-child { padding-right: var(--gutter); }
103.explorer table.files td { padding: 0 8px; height: 26px; border-bottom: 0; color: var(--text-2); }
104/* Lets the name column shrink below its text, so long names ellipsize instead of widening the table. */
105.explorer table.files td:first-child { max-width: 0; }
106.explorer table.files .name {
107 display: flex;
108 align-items: center;
109 gap: 6px;
110 padding-left: calc(var(--depth, 0) * 20px);
111 color: var(--text);
112 min-width: 0;
113}
114.explorer table.files .twisty {
115 display: grid;
116 place-items: center;
117 flex: none;
118 width: 18px;
119 height: 22px;
120 margin-left: -4px;
121 padding: 0;
122 border: 0;
123 border-radius: 4px;
124 background: none;
125 color: var(--muted);
126 cursor: pointer;
127}
128.explorer table.files .twisty:hover { background: var(--hover); color: var(--text); }
129.explorer table.files .twisty svg { transition: transform 150ms; }
130.explorer table.files .twisty.open svg { transform: rotate(90deg); }
131.explorer table.files .twisty[aria-busy="true"] svg { opacity: 0.4; }
132.explorer table.files tr.up td { color: var(--muted); cursor: pointer; }
133.explorer table.files tr.up .name { color: var(--text-2); }
134.explorer table.files tr.up:hover .name { color: var(--text); }
135.explorer table.files tr.empty-row td { height: 64px; color: var(--muted); text-align: center; }
136.explorer table.files tr.empty-row:hover { background: none; }
137.explorer table.files tr.cut .name > :not(.twisty) { opacity: 0.45; }
138.explorer table.files .name .text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
139.explorer table.files .icon { width: 15px; height: 15px; flex: none; }
140.explorer table.files tbody tr { cursor: default; }
141.explorer table.files tbody tr:hover { background: var(--hover); }
142.explorer table.files tbody tr.selected { background: var(--accent-wash); }
143.explorer table.files tbody tr.cursor td:first-child { box-shadow: inset 2px 0 var(--accent); }
144.explorer table.files:focus-visible tbody tr.cursor { outline: 2px solid var(--focus); outline-offset: -2px; }
145.explorer table.files .rename { flex: 1; height: 22px; padding: 0 6px; }
146.explorer table.files.lens tbody tr:not(.hit, .up) { opacity: 0.4; }
147.explorer table.files .badge {
148 flex: none;
149 padding: 0 6px;
150 border-radius: 5px;
151 background: var(--accent-wash);
152 color: var(--accent);
153 font-size: 11.5px;
154 line-height: 18px;
155 font-variant-numeric: tabular-nums;
156}
157
158.explorer table.files tbody tr.hover { background: var(--hover); }
159.explorer table.files .kind { display: grid; flex: none; }
160
161.explorer .legend {
162 display: flex;
163 flex-wrap: wrap;
164 gap: 4px 14px;
165 padding: 8px var(--gutter);
166 color: var(--muted);
167 font-size: 12px;
168}
169.explorer .legend span { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
170.explorer .legend kbd { margin-right: 2px; }
171
172.dialog .match-list {
173 list-style: none;
174 margin: 0 0 8px;
175 padding: 6px 8px;
176 max-height: 180px;
177 overflow: auto;
178 background: var(--well);
179 border: 1px solid var(--line);
180 border-radius: var(--radius);
181 font-size: 12px;
182 line-height: 1.6;
183}
184.dialog .match-list li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
185.dialog .match-list .more { color: var(--muted); font-family: var(--sans); }
186.dialog .description:has(.match-list) { min-width: 0; }
187.dialog .description:has(.match-list) p { margin: 0; }
188
189.dialog:has(.bulk-rename) { width: min(640px, calc(100% - 32px)); }
190.bulk-rename { display: grid; gap: 12px; }
191.bulk-rename .segmented { justify-self: start; }
192.bulk-rename .row.format { grid-auto-flow: row; grid-template-columns: 1fr 96px; row-gap: 6px; }
193.bulk-rename .row.format .hint { grid-column: 1 / -1; color: var(--muted); font-size: 12px; }
194.bulk-rename .preview { max-height: 240px; overflow: auto; background: var(--well); border: 1px solid var(--line); border-radius: var(--radius); }
195.bulk-rename .preview table { font-size: 12px; }
196.bulk-rename .preview td { height: 24px; padding: 0 8px; border-bottom: 0; white-space: nowrap; max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
197.bulk-rename .preview td:first-child { color: var(--text-2); }
198.bulk-rename .preview td.arrow { width: 1%; padding: 0; color: var(--muted); }
199.bulk-rename .preview tr.same td:last-child { color: var(--muted); font-family: var(--sans); }
200.bulk-rename .preview tr.clash td:last-child { color: var(--critical); }
201
202.dialog:has(.peek) { width: min(720px, calc(100% - 32px)); }
203.dialog .peek {
204 max-height: 55vh;
205 margin: 0 0 6px;
206 padding: 8px 10px;
207 overflow: auto;
208 background: var(--well);
209 border: 1px solid var(--line);
210 border-radius: var(--radius);
211 color: var(--text);
212 font: 12px/1.5 var(--mono);
213 white-space: pre-wrap;
214 overflow-wrap: anywhere;
215}
dashboard/web/components/Explorer.tsx created+1073
......@@ -0,0 +1,1073 @@
1import { useLocation, useSearchParams } from "@solidjs/router";
2import { Checkbox } from "./Checkbox.tsx";
3import { type InferResponseType, parseResponse } from "hono/client";
4import ChevronRight from "lucide-solid/icons/chevron-right";
5import CornerLeftUp from "lucide-solid/icons/corner-left-up";
6import Database from "lucide-solid/icons/database";
7import FileIcon from "lucide-solid/icons/file";
8import FileCode from "lucide-solid/icons/file-code";
9import FileImage from "lucide-solid/icons/file-image";
10import FileMusic from "lucide-solid/icons/file-music";
11import FilePlay from "lucide-solid/icons/file-play";
12import FileText from "lucide-solid/icons/file-text";
13import Folder from "lucide-solid/icons/folder";
14import History from "lucide-solid/icons/rotate-ccw-clock";
15import LayoutGrid from "lucide-solid/icons/layout-grid";
16import TextSearch from "lucide-solid/icons/text-search";
17import X from "lucide-solid/icons/x";
18import {
19 createEffect, createMemo, createResource, createSignal, For, type JSX, on, onCleanup, onMount, Show,
20} from "solid-js";
21import { api, queries, query, reason } from "../api.ts";
22import { ago, bytes, count, percent, plural } from "../format.ts";
23import { Ago } from "./Ago.tsx";
24import { showConfirmDialog, showTextDialog } from "./Dialog.tsx";
25import { lastGood, Loaded } from "./Loaded.tsx";
26import { OpenApp } from "./OpenApp.tsx";
27import { Reveal } from "./Reveal.tsx";
28import { type Sort, SortHeader } from "./SortHeader.tsx";
29import { TabBar } from "./TabBar.tsx";
30import { toast } from "./Toast.tsx";
31import "./Explorer.css";
32
33/** A file browser's routes: the library's, or the whole store's under storage. */
34type Client = typeof api.media | typeof api.storage.files;
35type Entry = InferResponseType<typeof api.media.list.$get, 200>["entries"][number];
36type Tree = InferResponseType<typeof api.media.tree.$post, 200>;
37type Key = "name" | "size" | "modified";
38type Op = { id: string; label: string };
39/** A visible line of the file tree; `path` is root-relative. */
40type Row = { path: string; parent: string; depth: number; entry: Entry };
41
42/** The pool's main areas by mountpoint; a folder takes the color of the last that holds it, so Media leaves clover. */
43export const REGIONS = [
44 { root: "/srv/prod", label: "prod", series: 2 },
45 { root: "/srv/clover", label: "clover", series: 1 },
46 { root: "/srv/clover/Media", label: "media", series: 5 },
47 { root: "/srv/staging", label: "staging", series: 3 },
48 { root: "/srv/vm", label: "vm", series: 7 },
49] as const;
50
51/** Squarified treemap (Bruls et al.) of items already sorted by size, largest first; cells are relative to the box. */
52export function squarify<T extends { size: number }>(items: T[], width: number, height: number) {
53 const total = items.reduce((sum, item) => sum + item.size, 0);
54 const cells: { item: T; x: number; y: number; w: number; h: number }[] = [];
55 let [x, y, w, h] = [0, 0, width, height];
56 let rest = items.map((item) => ({ item, area: (item.size / total) * width * height }));
57 while (rest.length) {
58 const side = Math.min(w, h);
59 const worst = (sum: number, smallest: number) =>
60 Math.max((side * side * rest[0]!.area) / (sum * sum), (sum * sum) / (side * side * smallest));
61 let sum = rest[0]!.area;
62 let taken = 1;
63 while (taken < rest.length && worst(sum + rest[taken]!.area, rest[taken]!.area) <= worst(sum, rest[taken - 1]!.area)) {
64 sum += rest[taken]!.area;
65 taken++;
66 }
67 const thickness = sum / side;
68 let offset = 0;
69 for (const { item, area } of rest.slice(0, taken)) {
70 const length = area / thickness;
71 cells.push(w >= h
72 ? { item, x, y: y + offset, w: thickness, h: length }
73 : { item, x: x + offset, y, w: length, h: thickness });
74 offset += length;
75 }
76 if (w >= h) [x, w] = [x + thickness, w - thickness];
77 else [y, h] = [y + thickness, h - thickness];
78 rest = rest.slice(taken);
79 }
80 return cells;
81}
82
83/** Folder links for `path` under a root labelled `root`, keeping the page's other parameters. */
84export function Crumbs(props: { root: string; path: string; tip?: string }) {
85 const location = useLocation();
86 const names = () => (props.path ? props.path.split("/") : []);
87 const href = (path: string) => {
88 const query = new URLSearchParams(location.search);
89 if (path) query.set("path", path);
90 else query.delete("path");
91 return `${location.pathname}${query.size ? `?${query}` : ""}`;
92 };
93 return (
94 <nav class="crumbs" aria-label="Folder">
95 <a href={href("")} aria-current={names().length ? undefined : "page"} data-tip={props.tip}>{props.root}</a>
96 <For each={names()}>
97 {(name, i) => (
98 <>
99 <ChevronRight size={14} aria-hidden="true" />
100 <a href={href(names().slice(0, i() + 1).join("/"))} aria-current={i() === names().length - 1 ? "page" : undefined}>
101 {name}
102 </a>
103 </>
104 )}
105 </For>
106 </nav>
107 );
108}
109
110const KINDS: [RegExp, (props: { class: string }) => JSX.Element, string][] = [
111 [/\.(mkv|mp4|m4v|avi|webm|mov|ts)$/i, FilePlay, "var(--series-1)"],
112 [/\.(flac|mp3|m4a|opus|ogg|wav|aac)$/i, FileMusic, "var(--series-2)"],
113 [/\.(jpe?g|png|webp|gif|bmp|tbn|heic)$/i, FileImage, "var(--series-3)"],
114 [/\.(nfo|xml|json)$/i, FileCode, "var(--series-4)"],
115 [/\.(srt|ass|ssa|vtt|sub|idx|txt)$/i, FileText, "var(--series-5)"],
116];
117const TEXT = /\.(nfo|xml|json|srt|ass|ssa|vtt|txt|md|log|cue|m3u8?|html?|ya?ml|ini|conf)$/i;
118
119/** Finder's keys on Apple platforms, Explorer's everywhere else. */
120const MAC = /^(Mac|iP)/.test(navigator.platform);
121
122/** Rows are keyed by entry object, so reading props once is safe. */
123function EntryIcon(props: { entry: Entry; color?: string }) {
124 if (props.entry.dataset) return <Database class="icon" style={{ color: props.color ?? "var(--text-2)" }} />;
125 if (props.entry.dir) return <Folder class="icon" style={{ color: props.color ?? "var(--text-2)" }} />;
126 const [, Icon, color] = KINDS.find(([pattern]) => pattern.test(props.entry.name)) ?? [null, FileIcon, "var(--muted)"];
127 return <Icon class="icon" style={{ color }} />;
128}
129
130const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
131const parentOf = (rel: string) => rel.split("/").slice(0, -1).join("/");
132/** Whether `rel` is strictly inside `dir`. */
133const within = (dir: string, rel: string) => rel !== dir && (!dir || rel.startsWith(dir + "/"));
134const under = (dir: string, abs: string) => abs === dir || abs.startsWith(dir + "/");
135
136/** Entries under this share of the folder merge into one cell, drawn at least `REST` large so they stay findable. */
137const TINY = 0.004;
138const REST = 0.04;
139
140/** The folder's entries as blocks sized by bytes, each colored as its row's icon. */
141function FolderMap(props: {
142 items: { name: string; size: number; dir: boolean; files: number; dataset: string | null }[];
143 total: number;
144 hover: string | undefined;
145 cursor: string | undefined;
146 color: (name: string) => string;
147 onHover: (name: string | undefined) => void;
148 onPick: (name: string, open: boolean) => void;
149}) {
150 let box!: HTMLDivElement;
151 const [size, setSize] = createSignal({ width: 0, height: 0 });
152 onMount(() => {
153 const resizes = new ResizeObserver(([entry]) => setSize({ width: entry!.contentRect.width, height: entry!.contentRect.height }));
154 resizes.observe(box);
155 onCleanup(() => resizes.disconnect());
156 });
157 const layout = createMemo(() => {
158 const sized = props.items.filter((item) => item.size > 0).sort((a, b) => b.size - a.size);
159 const total = sized.reduce((sum, item) => sum + item.size, 0);
160 const shown = sized.filter((item) => item.size >= total * TINY);
161 const tiny = sized.slice(shown.length);
162 const rest = { name: "", size: Math.max(total * REST, tiny.reduce((sum, item) => sum + item.size, 0)), dir: false, files: 0, dataset: null };
163 const { width, height } = size();
164 return { cells: width ? squarify(tiny.length ? [...shown, rest] : shown, width, height) : [], tiny };
165 });
166 return (
167 <div ref={box} class="folder-map" aria-hidden="true">
168 <For each={layout().cells}>
169 {({ item, x, y, w, h }) => (
170 <div class="cell" classList={{ dir: item.dir, rest: !item.name, hover: !!item.name && props.hover === item.name,
171 cursor: !!item.name && props.cursor === item.name }}
172 style={{ left: `${x}px`, top: `${y}px`, width: `${w}px`, height: `${h}px`, "--kind": `var(${props.color(item.name)})` }}
173 data-tip={item.name
174 ? `${item.name}${item.dataset ? ` (${item.dataset})` : ""}: ${bytes(item.size)} (${percent((item.size / props.total) * 100)})`
175 + (item.dir ? `, ${plural(item.files, "file")}` : "")
176 : layout().tiny.slice(0, 4).map((tiny) => tiny.name).join(", ")
177 + (layout().tiny.length > 4 ? ` and ${count(layout().tiny.length - 4)} more` : "")}
178 onMouseEnter={() => props.onHover(item.name || undefined)} onMouseLeave={() => props.onHover(undefined)}
179 onClick={() => item.name && props.onPick(item.name, item.dir)}>
180 <Show when={w > 50 && h > 22}><span class="name">{item.name || `${layout().tiny.length} more`}</span></Show>
181 <Show when={item.name && w > 50 && h > 40}><span class="size">{bytes(item.size)}</span></Show>
182 </div>
183 )}
184 </For>
185 </div>
186 );
187}
188
189/**
190 * A file browser over a root the server picked: a tree of the folder in `path` with Finder or Explorer keys, a map of
191 * what takes the space, and undoable renames, moves and deletes. `id` keys what it remembers per page.
192 */
193export function Explorer(props: { id: string; client: Client; root: string }) {
194 const [params, setParams] = useSearchParams<{ path?: string; pattern?: string }>();
195 const here = () => params.path ?? "";
196 const pattern = () => params.pattern ?? "";
197 const folderName = () => here().split("/").at(-1) || props.root;
198 const open = (path: string) => setParams({ path: path || undefined });
199
200 const [list, { refetch }] = query(`${props.id} folder`, (path: string) =>
201 parseResponse(props.client.list.$get({ query: { path } }))).use(here);
202 /** `latest` rethrows a failed load. */
203 const listing = () => (list.state === "errored" ? undefined : list.latest);
204 const apps = lastGood(queries.launcher.use()[0]);
205 const [ops, { refetch: refetchOps }] = createResource(() => parseResponse(props.client.ops.$get()));
206 const lastOps = lastGood(ops);
207 const [matches, { refetch: refetchMatches }] = createResource(
208 () => (pattern() ? { path: here(), pattern: pattern() } : false),
209 (query) => parseResponse(props.client.match.$get({ query })),
210 );
211
212 /** Listings of expanded folders and totals of every folder shown, both root-relative. */
213 const [folders, setFolders] = createSignal<Tree["folders"]>({});
214 const [sizes, setSizes] = createSignal<Tree["sizes"]>({});
215 /** The folder whose totals have arrived, so the map knows missing ones are unmeasurable, not pending. */
216 const [measured, setMeasured] = createSignal<string>();
217 /** Why the folder shown couldn't be measured, until it changes. */
218 const [unmeasured, setUnmeasured] = createSignal<string>();
219 const [updated, setUpdated] = createSignal<number | null>(null);
220 const [expanded, setExpanded] = createSignal(new Set<string>());
221 const [expanding, setExpanding] = createSignal<string>();
222 const [sort, setSort] = createSignal<Sort<Key>>({ key: "name", desc: false });
223 const [selected, setSelected] = createSignal(new Set<string>());
224 const [cursor, setCursor] = createSignal<string>();
225 const [hover, setHover] = createSignal<string>();
226 const [editing, setEditing] = createSignal<{ rename: string } | "folder">();
227 const renaming = () => {
228 const edit = editing();
229 return typeof edit === "object" ? edit.rename : undefined;
230 };
231 const [clipboard, setClipboard] = createSignal<string[]>();
232 const [undoing, setUndoing] = createSignal<string>();
233 const mapKey = `explorer.${props.id}.map`;
234 const [mapShown, setMapShown] = createSignal(localStorage.getItem(mapKey) !== "hidden");
235 let anchor: string | undefined;
236 let table: HTMLTableElement | undefined;
237 let patternInput!: HTMLInputElement;
238
239 const known = new WeakMap<Entry, Row>();
240 const rows = createMemo(() => {
241 const { key, desc } = sort();
242 const value = (row: Row) =>
243 key === "size" ? (row.entry.size ?? sizes()[row.path]?.size ?? 0) : key === "modified" ? row.entry.modified : 0;
244 const out: Row[] = [];
245 const walk = (dir: string, entries: Entry[], depth: number) => {
246 const level = entries.map((entry) => {
247 let row = known.get(entry);
248 if (!row) known.set(entry, row = { path: join(dir, entry.name), parent: dir, depth, entry });
249 return row;
250 }).sort((a, b) =>
251 Number(b.entry.dir) - Number(a.entry.dir)
252 || (desc ? -1 : 1) * (value(a) - value(b) || a.entry.name.localeCompare(b.entry.name, undefined, { numeric: true })));
253 for (const row of level) {
254 out.push(row);
255 const inner = expanded().has(row.path) && folders()[row.path];
256 if (inner) walk(row.path, inner, depth + 1);
257 }
258 };
259 walk(here(), listing()?.entries ?? [], 0);
260 return out;
261 });
262 const current = () => rows().find((row) => row.path === cursor());
263 /** The selection in view, without anything inside a selected folder. */
264 const chosen = () => rows().filter((row) => selected().has(row.path) && ![...selected()].some((rel) => within(rel, row.path)));
265 const only = () => (chosen().length === 1 ? chosen()[0] : undefined);
266 /** A dataset in the selection, which only zfs renames, moves or deletes. */
267 const dataset = () => chosen().find((row) => row.entry.dataset);
268 const found = () => (pattern() && matches.state !== "errored" ? matches.latest : undefined);
269 const expandable = (row: Row) => row.entry.dir && row.entry.items !== 0;
270 const sizeOf = (row: Row) => row.entry.size ?? sizes()[row.path]?.size;
271 const total = (chosen: Row[]) => chosen.reduce((sum, row) => sum + (sizeOf(row) ?? 0), 0);
272
273 /**
274 * A top folder's color: its region's, else the first free one from a hash of its identity, which renames and growth
275 * leave alone. Folders claim colors in inode order, so a new one rarely takes an existing one's. A mounted dataset's
276 * root inode repeats across datasets, so datasets go by name.
277 */
278 const colors = createMemo(() => {
279 const host = listing()?.host ?? "";
280 const dirs = (listing()?.entries ?? []).filter((entry) => entry.dir)
281 .map((entry) => ({ name: entry.name, identity: entry.dataset ?? String(entry.inode) }))
282 .sort((a, b) => a.identity.localeCompare(b.identity, undefined, { numeric: true }));
283 const picked = new Map(dirs.flatMap(({ name }) => {
284 const region = REGIONS.find((region) => region.root === `${host}/${name}`);
285 return region ? [[name, region.series as number] as const] : [];
286 }));
287 const inRegion = REGIONS.findLast((region) => under(region.root, host));
288 for (const { name, identity } of dirs.filter(({ name }) => !picked.has(name))) {
289 const hash = [...identity].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 7);
290 const free = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => ((hash + i) % 8) + 1)
291 .find((n) => ![...picked.values()].includes(n) && n !== inRegion?.series);
292 if (free) picked.set(name, free);
293 }
294 return picked;
295 });
296 const colorOf = (name: string) => (colors().has(name) ? `--series-${colors().get(name)}` : "--other");
297 const mapItems = () => measured() === here() ? rows().filter((row) => !row.depth && sizeOf(row) !== undefined).map((row) => ({
298 name: row.entry.name,
299 size: sizeOf(row)!,
300 dir: row.entry.dir,
301 files: row.entry.dir ? sizes()[row.path]?.files ?? 0 : 1,
302 dataset: row.entry.dataset,
303 })) : undefined;
304
305 /** Folds `tree` in, newest listing winning; drops expansions it was asked for but no longer finds. */
306 const merge = (tree: Tree, asked: Set<string> = new Set()) => {
307 setFolders((known) => ({ ...known, ...tree.folders }));
308 setSizes((known) => ({ ...known, ...tree.sizes }));
309 setExpanded((open) => new Set([...open].filter((rel) => !asked.has(rel) || rel in tree.folders)));
310 };
311 const refreshTree = async () => {
312 const path = here();
313 const asked = new Set(expanded());
314 const tree = await parseResponse(props.client.tree.$post({ json: { path, expanded: [...asked] } })).catch((failure) => {
315 if (here() === path) setUnmeasured(reason(failure));
316 return null;
317 });
318 if (tree && here() === path) {
319 merge(tree, asked);
320 setMeasured(path);
321 setUpdated(tree.updated);
322 }
323 };
324
325 createEffect(on(here, (now, before) => {
326 setFolders({});
327 setSizes({});
328 setMeasured();
329 setUnmeasured();
330 setExpanded(new Set<string>());
331 setEditing();
332 /** Coming up out of a folder lands on it, like Finder. */
333 const from = before !== undefined && within(now, before)
334 ? join(now, before.slice(now ? now.length + 1 : 0).split("/")[0]!)
335 : undefined;
336 setSelected(new Set(from ? [from] : []));
337 setCursor(from);
338 anchor = from;
339 refreshTree();
340 }));
341
342 createEffect(on([cursor, rows], ([path, all]) => {
343 const index = all.findIndex((row) => row.path === path);
344 if (index >= 0) queueMicrotask(() => table?.querySelector(`[data-row="${index}"]`)?.scrollIntoView({ block: "nearest" }));
345 }));
346
347 const select = (row: Row, how: "only" | "toggle" | "range") => {
348 setCursor(row.path);
349 setEditing();
350 if (how === "range") {
351 const all = rows();
352 const at = all.indexOf(row);
353 const from = all.findIndex((other) => other.path === anchor);
354 const [start, end] = from < 0 ? [at, at] : [Math.min(from, at), Math.max(from, at)];
355 setSelected(new Set(all.slice(start, end + 1).map((other) => other.path)));
356 return;
357 }
358 anchor = row.path;
359 const next = new Set(how === "toggle" ? selected() : []);
360 if (how === "toggle" && next.has(row.path)) next.delete(row.path);
361 else next.add(row.path);
362 setSelected(next);
363 };
364
365 const step = (delta: number, extend: boolean) => {
366 const all = rows();
367 const at = all.findIndex((row) => row.path === cursor());
368 const next = all[Math.max(0, Math.min(all.length - 1, at < 0 && delta < 0 ? 0 : at + delta))];
369 if (next) select(next, extend ? "range" : "only");
370 };
371
372 const expandAll = async (path: string) => {
373 setExpanding(path);
374 try {
375 const tree = await parseResponse(props.client.tree.$post({ json: { path, expanded: "all" } }));
376 if (path !== here() && !within(here(), path)) return;
377 merge(tree);
378 setExpanded((open) => new Set([...open, ...Object.keys(tree.folders).filter((rel) => rel !== here())]));
379 if (!tree.complete) {
380 if (Object.values(tree.folders).some((entries) => entries.some((entry) => entry.dir && entry.items === null))) {
381 toast("Some folder counts are unavailable. Expand folders one at a time.");
382 return;
383 }
384 const depth = (rel: string) => (rel ? rel.split("/").length : 0);
385 const levels = Math.max(...Object.keys(tree.folders).map(depth)) - depth(path);
386 toast(levels
387 ? `Expanded ${plural(levels, "level")}. Deeper folders hold too many items to open at once.`
388 : "These folders hold too many items to open at once. Expand them one at a time.");
389 }
390 } catch (failure) {
391 toast(reason(failure));
392 } finally {
393 setExpanding();
394 }
395 };
396
397 const expand = async (row: Row) => {
398 if (!expandable(row)) return;
399 setExpanded((open) => new Set(open).add(row.path));
400 if (!folders()[row.path]) {
401 const found = await parseResponse(props.client.list.$get({ query: { path: row.path } })).catch((failure) => {
402 toast(reason(failure));
403 collapse(row);
404 });
405 if (found && within(here(), row.path)) setFolders((known) => ({ ...known, [row.path]: found.entries }));
406 }
407 const tree = await parseResponse(props.client.tree.$post({ json: { path: row.path, expanded: [] } })).catch(() => null);
408 if (tree && within(here(), row.path)) merge(tree);
409 };
410
411 /** Keeps what's open inside, so expanding again shows it the same way, unless `deep`. */
412 const collapse = (row: Row, deep = false) => {
413 setExpanded((open) => new Set([...open].filter((rel) => rel !== row.path && !(deep && within(row.path, rel)))));
414 setSelected((picked) => new Set([...picked].filter((rel) => !within(row.path, rel))));
415 if (within(row.path, cursor() ?? "")) setCursor(row.path);
416 };
417
418 const activate = (row: Row) => {
419 if (row.entry.dir) open(row.path);
420 else if (row.entry.download) window.open(row.entry.download, "_blank", "noreferrer");
421 };
422 const up = () => here() && open(parentOf(here()));
423
424 const reload = () => {
425 refetch();
426 refetchOps();
427 refetchMatches();
428 refreshTree();
429 };
430 const undo = (op: Op) => parseResponse(props.client.ops[":id"].undo.$post({ param: { id: op.id } })).finally(reload);
431
432 const done = (op: Op) => {
433 toast(op.label, { label: "undo", run: () => undo(op) });
434 setSelected(new Set<string>());
435 setEditing();
436 reload();
437 };
438
439 /** For changes that can fail partway, so the folder reloads either way; the failure reaches the dialog. */
440 const act = (change: Promise<Op>) => change.then(done, (failure) => {
441 reload();
442 throw failure;
443 });
444
445 const paths = () => chosen().map((row) => row.path);
446 const what = () => (only() ? only()!.entry.name : plural(chosen().length, "item"));
447 const refuseDataset = () => {
448 const row = dataset();
449 if (row) toast(`${row.entry.name} is the ${row.entry.dataset} dataset. Rename, move or destroy it with zfs instead.`);
450 return !!row;
451 };
452
453 const remove = () => {
454 const selection = paths();
455 if (!selection.length || refuseDataset()) return;
456 showConfirmDialog({
457 title: `Delete ${what()}?`,
458 description: () => (
459 <>
460 <Show when={selection.length > 1}>
461 <ul class="match-list mono">
462 <For each={selection}>{(rel) => <li>{rel.slice(here() ? here().length + 1 : 0)}</li>}</For>
463 </ul>
464 </Show>
465 <p>You can undo this from recent changes.</p>
466 </>
467 ),
468 confirmLabel: "delete",
469 destructive: true,
470 returnFocus: table,
471 onConfirm: () => act(parseResponse(props.client.delete.$post({ json: { paths: selection } }))),
472 });
473 };
474
475 const moveTo = (title: string, description: () => JSX.Element, run: (to: string) => Promise<Op>) => showTextDialog({
476 title,
477 description,
478 returnFocus: table,
479 label: "Destination folder",
480 initialValue: here() ? here() + "/" : "",
481 validateInput: () => true,
482 confirmLabel: "move",
483 onConfirm: (to) => act(run(to.trim().replace(/^\/+|\/+$/g, ""))),
484 });
485
486 const move = () => {
487 const selection = paths();
488 if (!selection.length || refuseDataset()) return;
489 moveTo(`Move ${what()}`, () => `Folder paths start from ${props.root}.`,
490 (to) => parseResponse(props.client.move.$post({ json: { paths: selection, to } })));
491 };
492
493 /** Where ⌘V lands: inside the folder under the cursor when it's open, else beside the cursor. */
494 const pasteTarget = () => {
495 const row = current();
496 if (!row) return here();
497 return row.entry.dir && expanded().has(row.path) ? row.path : row.parent;
498 };
499 const nameOf = (rel: string) => rel.split("/").at(-1) || props.root;
500
501 const paste = () => {
502 const to = pasteTarget();
503 const moving = (clipboard() ?? []).filter((rel) => parentOf(rel) !== to);
504 if (!clipboard()) return;
505 if (!moving.length) return toast(`Already in ${nameOf(to)}`);
506 showConfirmDialog({
507 title: `Move ${moving.length === 1 ? nameOf(moving[0]!) : plural(moving.length, "item")} to ${nameOf(to)}?`,
508 description: () => {
509 const from = new Set(moving.map(parentOf));
510 return (
511 <>
512 <ul class="match-list mono"><For each={moving}>{(rel) => <li>{nameOf(rel)}</li>}</For></ul>
513 <p>
514 {from.size === 1 ? `From ${[...from][0] || props.root} into` : "Into"} {to || props.root}.
515 You can undo this from recent changes.
516 </p>
517 </>
518 );
519 },
520 confirmLabel: "move",
521 returnFocus: table,
522 onConfirm: () => act(parseResponse(props.client.move.$post({ json: { paths: moving, to } }))).then(() => setClipboard()),
523 });
524 };
525
526 const bulk = (action: "delete" | "move") => {
527 const shown = found();
528 if (!shown?.count) return;
529 const query = { path: here(), pattern: pattern(), count: shown.count };
530 const list = () => (
531 <ul class="match-list mono">
532 <For each={shown.sample}>{(path) => <li>{path}</li>}</For>
533 <Show when={shown.count > shown.sample.length}>
534 <li class="more">and {count(shown.count - shown.sample.length)} more</li>
535 </Show>
536 </ul>
537 );
538 if (action === "move") {
539 return moveTo(`Move ${plural(shown.count, "match", "matches")}`, () => <>{list()}<p>Each keeps its subfolders.</p></>,
540 (to) => parseResponse(props.client.bulk.$post({ json: { ...query, action, to } })));
541 }
542 showTextDialog({
543 title: `Delete ${plural(shown.count, "match", "matches")}?`,
544 description: () => (
545 <>{list()}<p>{shown.size === null ? "" : `${bytes(shown.size)} in ${folderName()}. `}You can undo this from recent changes.</p></>
546 ),
547 returnFocus: table,
548 label: `Type ${shown.count} to confirm`,
549 validateInput: (value) => value.trim() === String(shown.count),
550 confirmLabel: "delete",
551 destructive: true,
552 onConfirm: () => act(parseResponse(props.client.bulk.$post({ json: { ...query, action } }))),
553 });
554 };
555
556 const bulkRename = () => {
557 const targets = chosen();
558 const [mode, setMode] = createSignal<"replace" | "format">("replace");
559 const [find, setFind] = createSignal("");
560 const [replacement, setReplacement] = createSignal("");
561 const [regex, setRegex] = createSignal(false);
562 const [format, setFormat] = createSignal("{name}");
563 const [start, setStart] = createSignal(1);
564 const rename = (name: string, index: number, dir: boolean) => {
565 if (mode() === "format") {
566 const dot = dir ? -1 : name.lastIndexOf(".");
567 const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
568 return format().replace(/\{(n+)\}/g, (_, digits: string) => String(start() + index).padStart(digits.length, "0"))
569 .replaceAll("{name}", stem) + ext;
570 }
571 if (!find()) return name;
572 return regex() ? name.replace(new RegExp(find(), "g"), replacement()) : name.replaceAll(find(), replacement());
573 };
574 const preview = () => {
575 let failure = "";
576 const renamed = targets.map((row, index) => {
577 try {
578 return { row, name: rename(row.entry.name, index, row.entry.dir) };
579 } catch (error) {
580 failure = `Check the regex: ${(error as Error).message}.`;
581 return { row, name: row.entry.name };
582 }
583 });
584 const taken = (row: Row) => new Set(((row.parent === here() ? listing()?.entries : folders()[row.parent]) ?? [])
585 .map((entry) => entry.name).filter((name) => name !== row.entry.name));
586 const clashes = renamed.map(({ row, name }) => {
587 if (!name.trim() || name.includes("/") || name === "." || name === "..") return "names can't be empty or contain /";
588 if (name === row.entry.name) return undefined;
589 if (taken(row).has(name)) return `${name} already exists`;
590 const twin = renamed.some((other) => other.row !== row && other.row.parent === row.parent && other.name === name);
591 if (twin) return "two items get this name";
592 });
593 const problems = clashes.filter(Boolean).length;
594 return {
595 renamed: renamed.map((item, index) => ({ ...item, clash: clashes[index] })),
596 failure: failure
597 || (problems ? `${plural(problems, "name")} can't be used. Change the pattern so each name is new and unique.` : ""),
598 };
599 };
600 let field: HTMLInputElement | undefined;
601 const input = (props: { label: string; value: () => string; set: (value: string) => void; autofocus?: boolean }) => (
602 <label class="field">
603 {props.label}
604 <input class="search mono" value={props.value()} spellcheck={false} autocomplete="off"
605 autofocus={props.autofocus} ref={(input) => props.autofocus && (field = input)}
606 onInput={(event) => props.set(event.currentTarget.value)} />
607 </label>
608 );
609 showConfirmDialog({
610 title: `Rename ${plural(targets.length, "item")}`,
611 returnFocus: table,
612 confirmLabel: "rename",
613 body: () => {
614 createEffect(() => field?.setCustomValidity(preview().failure));
615 return (
616 <div class="bulk-rename">
617 <TabBar label="Rename by">
618 <button type="button" aria-pressed={mode() === "replace"} onClick={() => setMode("replace")}>find and replace</button>
619 <button type="button" aria-pressed={mode() === "format"} onClick={() => setMode("format")}>format</button>
620 </TabBar>
621 <Show when={mode() === "replace"} fallback={
622 <div class="row format">
623 {input({ label: "Name", value: format, set: setFormat, autofocus: true })}
624 <label class="field">
625 Start at
626 <input class="search" type="number" min="0" value={start()}
627 onInput={(event) => setStart(Math.max(0, event.currentTarget.valueAsNumber || 0))} />
628 </label>
629 <span class="hint">{"{name}"} is the old name and {"{n}"} counts up; {"{nn}"} pads it to 2 digits</span>
630 </div>
631 }>
632 <div class="row">
633 {input({ label: "Find", value: find, set: setFind, autofocus: true })}
634 {input({ label: "Replace with", value: replacement, set: setReplacement })}
635 </div>
636 <Checkbox checked={regex()} onChange={setRegex}>regex, with $1 for groups</Checkbox>
637 </Show>
638 <div class="preview">
639 <table class="data">
640 <tbody>
641 <For each={preview().renamed}>
642 {(item) => (
643 <tr classList={{ same: item.name === item.row.entry.name, clash: !!item.clash }}>
644 <td class="mono">{item.row.entry.name}</td>
645 <td class="arrow">→</td>
646 <td class="mono" data-tip={item.clash}>{item.name === item.row.entry.name ? "unchanged" : item.name}</td>
647 </tr>
648 )}
649 </For>
650 </tbody>
651 </table>
652 </div>
653 </div>
654 );
655 },
656 onConfirm: () => {
657 const renames = preview().renamed.filter((item) => item.name !== item.row.entry.name)
658 .map((item) => ({ path: item.row.path, name: item.name }));
659 return renames.length && act(parseResponse(props.client.rename.$post({ json: { renames } })));
660 },
661 });
662 };
663
664 const rename = () => {
665 if (refuseDataset()) return;
666 if (chosen().length > 1) bulkRename();
667 else if (only()) setEditing({ rename: only()!.path });
668 };
669
670 const preview = (row: Row | undefined) => {
671 if (!row || row.entry.dir) return;
672 if (!TEXT.test(row.entry.name)) return toast(`No preview for ${row.entry.name}. Open it with ${MAC ? "⌘O" : "enter"}.`);
673 showConfirmDialog({
674 title: row.entry.name,
675 description: () => {
676 const [peek, { refetch }] = createResource(() => parseResponse(props.client.peek.$get({ query: { path: row.path } })));
677 return (
678 <Loaded data={peek} what="this file" retry={refetch}>
679 {(file) => (
680 <>
681 <pre class="peek">{file().text || "This file is empty."}</pre>
682 <Show when={file().more}><p class="muted">The first 64 KiB of {bytes(row.entry.size!)}.</p></Show>
683 </>
684 )}
685 </Loaded>
686 );
687 },
688 confirmLabel: "open",
689 returnFocus: table,
690 onConfirm: () => row.entry.download && window.open(row.entry.download, "_blank", "noreferrer"),
691 });
692 };
693
694 const onKey = (event: KeyboardEvent) => {
695 if (event.target !== table && event.target !== document.body) return;
696 const mod = MAC ? event.metaKey : event.ctrlKey;
697 const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
698 const row = current();
699 if ((key === "ArrowDown" || key === "ArrowUp") && !mod && !event.altKey) step(key === "ArrowDown" ? 1 : -1, event.shiftKey);
700 else if (key === "Home" || key === "End") step(key === "Home" ? -Infinity : Infinity, event.shiftKey);
701 else if (MAC ? mod && key === "ArrowUp" : (event.altKey && key === "ArrowUp") || (key === "Backspace" && !mod)) up();
702 else if (MAC ? mod && (key === "o" || key === "ArrowDown") : key === "Enter") row && activate(row);
703 else if (MAC ? key === "Enter" : key === "F2") rename();
704 else if (MAC ? mod && key === "Backspace" : key === "Delete") {
705 if (chosen().length) remove();
706 else bulk("delete");
707 } else if (key === "ArrowRight" && row?.entry.dir) {
708 if (event.altKey) expandAll(row.path);
709 else if (!expanded().has(row.path)) expand(row);
710 else if (rows()[rows().indexOf(row) + 1]?.parent === row.path) step(1, false);
711 } else if (key === "ArrowLeft" && row) {
712 if (expanded().has(row.path)) collapse(row, event.altKey);
713 else if (row.depth) select(rows().find((other) => other.path === row.parent)!, "only");
714 } else if (mod && key === "a") setSelected(new Set(rows().map((row) => row.path)));
715 else if (mod && key === "x") setClipboard(chosen().length ? paths() : undefined);
716 else if (mod && key === "v") paste();
717 else if (mod && key === "z") {
718 const last = lastOps()?.find((op) => !op.undone);
719 if (!last) toast("Nothing to undo here");
720 else undo(last).then(() => toast(`Undone: ${last.label}`), (failure) => toast(reason(failure)));
721 }
722 else if (key === " ") preview(row);
723 else if (key === "Escape") {
724 if (selected().size) setSelected(new Set<string>());
725 else if (clipboard()) setClipboard();
726 else clearPattern();
727 } else if (key === "/") patternInput.focus();
728 else return;
729 event.preventDefault();
730 };
731 document.addEventListener("keydown", onKey);
732 onCleanup(() => document.removeEventListener("keydown", onKey));
733
734 let debounce: ReturnType<typeof setTimeout> | undefined;
735 onCleanup(() => clearTimeout(debounce));
736 const usePattern = (value: string) => {
737 clearTimeout(debounce);
738 setParams({ pattern: value.trim() ? value : undefined }, { replace: true });
739 };
740 const clearPattern = () => {
741 patternInput.value = "";
742 usePattern("");
743 };
744
745 /** Enter on an unchanged rename just closes the field; a new name lands selected in `dir`. */
746 const nameInput = (dir: string, initial: string, submit: (value: string) => Promise<Op>) => {
747 let saving = false;
748 return (
749 <input class="search rename" value={initial} aria-label="Name" ref={(input) => queueMicrotask(() => {
750 input.focus();
751 input.setSelectionRange(0, initial.lastIndexOf(".") > 0 ? initial.lastIndexOf(".") : initial.length);
752 })} onKeyDown={(event) => {
753 const value = event.currentTarget.value.trim();
754 if (event.key === "Enter" && value === initial && renaming()) {
755 setEditing();
756 table?.focus();
757 } else if (event.key === "Enter" && value && !saving) {
758 saving = true;
759 submit(value).then((op) => {
760 done(op);
761 setSelected(new Set([join(dir, value)]));
762 setCursor(join(dir, value));
763 anchor = join(dir, value);
764 table?.focus();
765 }, (failure) => toast(reason(failure))).finally(() => (saving = false));
766 } else if (event.key === "Escape") table?.focus();
767 }} onBlur={() => !saving && setEditing()} />
768 );
769 };
770
771 const selectionTip = (verb: string) =>
772 chosen().length ? dataset() && `${dataset()!.entry.name} is a dataset; zfs manages it` : `select items to ${verb}`;
773 const collapsedFolders = () => rows().some((row) => expandable(row) && !expanded().has(row.path));
774 const keys: [JSX.Element, string][] = MAC
775 ? [[<><kbd>←</kbd><kbd>→</kbd></>, "fold"], [<kbd>⏎</kbd>, "rename"],
776 [<kbd>⌘O</kbd>, "open"], [<kbd>⌘↑</kbd>, "up"], [<kbd>⌘⌫</kbd>, "delete"], [<><kbd>⌘X</kbd><kbd>⌘V</kbd></>, "cut, paste"], [<kbd>⌘Z</kbd>, "undo"],
777 [<kbd>space</kbd>, "preview"], [<kbd>/</kbd>, "pattern"]]
778 : [[<><kbd>←</kbd><kbd>→</kbd></>, "fold"], [<kbd>enter</kbd>, "open"],
779 [<kbd>F2</kbd>, "rename"], [<kbd>⌫</kbd>, "up"], [<kbd>del</kbd>, "delete"], [<><kbd>ctrl X</kbd><kbd>ctrl V</kbd></>, "cut, paste"], [<kbd>ctrl Z</kbd>, "undo"],
780 [<kbd>space</kbd>, "preview"], [<kbd>/</kbd>, "pattern"]];
781 const historyId = `${props.id}-history`;
782 const folderTotal = () => sizes()[here()];
783
784 return (
785 <div class="explorer fill">
786 <div class="bar">
787 <Crumbs root={props.root} path={here()} tip={listing() && !here() ? listing()!.host : undefined} />
788 <Show when={folderTotal()}>
789 {(total) => (
790 <span class="total" data-tip={[
791 plural(total().files, "file"),
792 `${bytes(total().alloc)} on disk`,
793 total().size > total().alloc * 1.02 && `${(total().size / total().alloc).toFixed(2)}× compressed`,
794 updated() && `indexed ${ago(updated()!)}`,
795 ].filter(Boolean).join(", ")}>{bytes(total().size)}</span>
796 )}
797 </Show>
798 <span class="spacer" />
799 <Show when={listing()}>
800 <span class="count" data-tip={chosen().length ? bytes(total(chosen())) : undefined}>
801 {chosen().length ? `${count(chosen().length)} selected` : plural(listing()!.entries.length, "item")}
802 </span>
803 </Show>
804 <button class="button icon-only small" popovertarget={historyId} style={{ "anchor-name": `--${historyId}` }}
805 aria-label="Recent changes" data-tip="recent changes">
806 <History size={14} />
807 </button>
808 <div id={historyId} popover class="history" style={{ "position-anchor": `--${historyId}` }}
809 onToggle={(event) => (event as ToggleEvent).newState === "open" && refetchOps()}>
810 <Loaded data={ops} what="recent changes" retry={refetchOps}>
811 {(recent) => (
812 <Show when={recent().length} fallback={<p class="none">No changes yet. Renames, moves and deletes show here to undo.</p>}>
813 <For each={recent()}>
814 {(op) => (
815 <div class="op">
816 <span class="label" data-tip={op.count > 1
817 ? `${op.sample.join(", ")}${op.count > op.sample.length ? ` and ${count(op.count - op.sample.length)} more` : ""}`
818 : op.sample[0]}>{op.label}</span>
819 <span class="when"><Ago t={op.at} /></span>
820 <Show when={!op.undone} fallback={<span class="undone">undone</span>}>
821 <button class="button small" disabled={!!undoing()} aria-busy={undoing() === op.id} onClick={async () => {
822 setUndoing(op.id);
823 await undo(op).catch((failure) => toast(reason(failure)));
824 setUndoing();
825 }}>undo</button>
826 </Show>
827 </div>
828 )}
829 </For>
830 </Show>
831 )}
832 </Loaded>
833 </div>
834 <button class="button icon-only small" aria-pressed={mapShown()} aria-label={mapShown() ? "Hide map" : "Show map"}
835 data-tip={mapShown() ? "hide map" : "show map"} onClick={() => {
836 setMapShown(!mapShown());
837 localStorage.setItem(mapKey, mapShown() ? "shown" : "hidden");
838 }}>
839 <LayoutGrid size={14} />
840 </button>
841 <Show when={listing()?.link}>
842 {(link) => <OpenApp app={apps()?.find((app) => app.id === "copyparty") ?? { id: "copyparty", name: "Copyparty", icon: null }}
843 href={link()} />}
844 </Show>
845 </div>
846
847 <Reveal when={mapShown()}>
848 <div class="map-slot">
849 <Show when={mapItems()} fallback={unmeasured()
850 ? <div class="folder-map empty">
851 <Show when={!list.error}><p><span class="error">Couldn't measure this folder.</span> {unmeasured()}</p></Show>
852 </div>
853 : <div class="skeleton folder-map" />}>
854 {(items) => (
855 <Show when={folderTotal()?.size} fallback={
856 <div class="folder-map empty">{measured() === here() && !folderTotal() && listing()?.entries.length
857 ? "Too many files to measure here without the file index."
858 : "Nothing here takes space"}</div>
859 }>
860 <FolderMap items={items()} total={folderTotal()!.size} hover={hover()} color={colorOf}
861 cursor={current() && !current()!.depth ? current()!.entry.name : undefined}
862 onHover={setHover} onPick={(name, dir) => {
863 const row = rows().find((row) => !row.depth && row.entry.name === name);
864 if (dir) open(join(here(), name));
865 else if (row) {
866 select(row, "only");
867 table?.focus();
868 }
869 }} />
870 </Show>
871 )}
872 </Show>
873 </div>
874 </Reveal>
875
876 <div class="toolbar">
877 <Show when={pattern()} fallback={
878 <>
879 <button class="button" onClick={() => setEditing("folder")}>new folder</button>
880 <button class="button" disabled={!chosen().length || !!dataset()} data-tip={selectionTip("rename")} onClick={rename}>
881 rename
882 </button>
883 <button class="button" disabled={!chosen().length || !!dataset()} data-tip={selectionTip("move")} onClick={move}>
884 move
885 </button>
886 <Show when={chosen().length ? only()?.entry.download : listing()?.zip} fallback={
887 <button class="button" disabled data-tip="select one item to download">download</button>
888 }>
889 {(href) => (
890 <a class="button" href={href()} target={only()?.entry.dir === false ? "_blank" : undefined} rel="noreferrer"
891 data-tip={chosen().length ? undefined : `download ${folderName()} as a zip`}>
892 {only()?.entry.dir === false ? "download" : "zip"}
893 </a>
894 )}
895 </Show>
896 <button class="button danger" disabled={!chosen().length || !!dataset()} data-tip={selectionTip("delete")}
897 onClick={remove}>delete</button>
898 <Show when={rows().some(expandable)}>
899 <button class="button" disabled={!!expanding()} aria-busy={expanding() === here()}
900 data-tip={collapsedFolders() ? `${MAC ? "⌥" : "alt"} → on a folder expands just that one` : undefined}
901 onClick={() => (collapsedFolders() ? expandAll(here()) : setExpanded(new Set<string>()))}>
902 {collapsedFolders() ? "expand all" : "collapse all"}
903 </button>
904 </Show>
905 </>
906 }>
907 <Show when={matches.state !== "errored"} fallback={<span class="error">{reason(matches.error)}</span>}>
908 <span class="matched" aria-live="polite">
909 {!found() ? "matching…" : found()!.count ? plural(found()!.count, "match", "matches") : "no matches"}
910 <Show when={found()?.count && found()!.size !== null}> <span class="muted">({bytes(found()!.size!)})</span></Show>
911 </span>
912 </Show>
913 <button class="button" disabled={!found()?.count} data-tip={found()?.count ? undefined : "nothing matches"}
914 onClick={() => bulk("move")}>move matches</button>
915 <button class="button danger" disabled={!found()?.count} data-tip={found()?.count ? undefined : "nothing matches"}
916 onClick={() => bulk("delete")}>delete matches</button>
917 </Show>
918 <span class="spacer" />
919 <Show when={clipboard()}>
920 {(cut) => (
921 <span class="clip">
922 <button class="button" onClick={paste} data-tip={`${cut().slice(0, 3).map(nameOf).join(", ")
923 + (cut().length > 3 ? ` and ${count(cut().length - 3)} more` : "")} into ${nameOf(pasteTarget())}`}>
924 paste {count(cut().length)}
925 </button>
926 <button class="button icon-only" aria-label="Clear cut items" data-tip="esc" onClick={() => setClipboard()}>
927 <X size={14} />
928 </button>
929 </span>
930 )}
931 </Show>
932 <label class="search-box pattern" classList={{ active: !!pattern() }}>
933 <TextSearch size={14} aria-hidden="true" />
934 <input placeholder="select by pattern, like **/*.nfo" aria-label={`Match files in ${folderName()}`}
935 spellcheck={false} autocomplete="off" ref={patternInput} value={pattern()}
936 onInput={(event) => {
937 clearTimeout(debounce);
938 const value = event.currentTarget.value;
939 debounce = setTimeout(() => usePattern(value), 250);
940 }}
941 onKeyDown={(event) => {
942 if (event.key === "Escape") clearPattern();
943 if (event.key === "Enter" || event.key === "Escape") table?.focus();
944 }} />
945 <Show when={pattern()}>
946 <button class="clear" aria-label="Clear pattern" data-tip="esc" onClick={clearPattern}><X size={13} /></button>
947 </Show>
948 </label>
949 </div>
950
951 <div class="files-scroll">
952 <Loaded data={list} what="this folder" retry={refetch} skeleton={
953 <div class="files-skeleton">
954 <For each={[62, 48, 71, 55, 66, 40, 58]}>
955 {(width) => <div class="skeleton" style={{ width: `${width}%` }} />}
956 </For>
957 </div>
958 }>
959 {() => (
960 <table class="data files" classList={{ lens: !!found()?.count }} tabindex="0" role="treegrid" aria-label="Files"
961 aria-multiselectable="true" ref={table}>
962 <thead>
963 <tr>
964 <SortHeader column="name" label="name" sort={sort()} onSort={setSort} />
965 <SortHeader column="size" label="size" sort={sort()} onSort={setSort} num />
966 <SortHeader column="modified" label="modified" sort={sort()} onSort={setSort} num />
967 </tr>
968 </thead>
969 <tbody>
970 <Show when={here()}>
971 <tr class="up" onClick={up}>
972 <td colSpan={3}><div class="name">
973 <span class="twisty" />
974 <CornerLeftUp class="icon" />
975 <span class="text">(up to {nameOf(parentOf(here()))})</span>
976 </div></td>
977 </tr>
978 </Show>
979 <Show when={editing() === "folder"}>
980 <tr class="cursor">
981 <td><div class="name"><span class="twisty" /><Folder class="icon" />{nameInput(here(), "New folder", (name) =>
982 parseResponse(props.client.folder.$post({ json: { path: here(), name } })))}</div></td>
983 <td /><td />
984 </tr>
985 </Show>
986 <For each={rows()} fallback={
987 <Show when={editing() !== "folder" && !list.error}>
988 <tr class="empty-row"><td colSpan={3}>This folder is empty</td></tr>
989 </Show>
990 }>
991 {(row, index) => {
992 const hit = () => found()?.rows[row.path.slice(here() ? here().length + 1 : 0)];
993 const isOpen = () => expanded().has(row.path);
994 const total = () => sizes()[row.path];
995 return (
996 <tr data-row={index()} aria-level={row.depth + 1} aria-selected={selected().has(row.path)}
997 aria-expanded={expandable(row) ? isOpen() : undefined}
998 classList={{
999 selected: selected().has(row.path),
1000 cursor: cursor() === row.path,
1001 hover: !row.depth && hover() === row.entry.name,
1002 hit: !!hit(),
1003 cut: !!clipboard()?.some((rel) => rel === row.path || within(rel, row.path)),
1004 }}
1005 onMouseEnter={() => !row.depth && setHover(row.entry.name)} onMouseLeave={() => setHover()}
1006 onClick={(event) =>
1007 select(row, event.shiftKey ? "range" : (MAC ? event.metaKey : event.ctrlKey) ? "toggle" : "only")}
1008 onDblClick={() => activate(row)}>
1009 <td><div class="name" style={{ "--depth": row.depth }}>
1010 <Show when={expandable(row)} fallback={<span class="twisty" />}>
1011 <button class="twisty" classList={{ open: isOpen() }} tabindex="-1"
1012 onMouseDown={(event) => event.preventDefault()}
1013 aria-label={isOpen() ? "Collapse" : "Expand"} aria-busy={expanding() === row.path}
1014 onClick={(event) => {
1015 event.stopPropagation();
1016 if (event.altKey) (isOpen() ? collapse(row, true) : expandAll(row.path));
1017 else if (isOpen()) collapse(row);
1018 else expand(row);
1019 }}>
1020 <ChevronRight size={14} />
1021 </button>
1022 </Show>
1023 <span class="kind" data-tip={row.entry.dataset ? `dataset ${row.entry.dataset}` : undefined}>
1024 <EntryIcon entry={row.entry} color={row.depth ? undefined : `var(${colorOf(row.entry.name)})`} />
1025 </span>
1026 <Show when={renaming() === row.path}
1027 fallback={<span class="text">{row.entry.name}</span>}>
1028 {nameInput(row.parent, row.entry.name, (name) =>
1029 parseResponse(props.client.rename.$post({ json: { renames: [{ path: row.path, name }] } })))}
1030 </Show>
1031 <Show when={hit()} keyed>
1032 {(hit) => hit === "self"
1033 ? <span class="badge">match</span>
1034 : <span class="badge" data-tip={`${plural(hit, "match", "matches")} inside`}>{count(hit)}</span>}
1035 </Show>
1036 </div></td>
1037 <Show when={row.entry.dir} fallback={
1038 <td class="num" data-tip={[
1039 row.entry.size! >= 1024 && `${count(row.entry.size!)} bytes`,
1040 row.entry.alloc! < row.entry.size! * 0.98 && `${bytes(row.entry.alloc!)} on disk`,
1041 ].filter(Boolean).join(", ") || undefined}>
1042 {bytes(row.entry.size!)}
1043 </td>
1044 }>
1045 <Show when={total()} fallback={<td class="num muted">{row.entry.items === null ? "–" : plural(row.entry.items, "item")}</td>}>
1046 {(total) => (
1047 <td class="num" data-tip={[
1048 row.entry.items !== null && plural(row.entry.items, "item"),
1049 total().files !== row.entry.items && `${plural(total().files, "file")} in all`,
1050 total().alloc < total().size * 0.98 && `${bytes(total().alloc)} on disk`,
1051 ].filter(Boolean).join(", ")}>
1052 {bytes(total().size)}
1053 </td>
1054 )}
1055 </Show>
1056 </Show>
1057 <td class="num"><Ago t={row.entry.modified} /></td>
1058 </tr>
1059 );
1060 }}
1061 </For>
1062 </tbody>
1063 </table>
1064 )}
1065 </Loaded>
1066 </div>
1067
1068 <div class="legend">
1069 <For each={keys}>{([key, label]) => <span>{key} {label}</span>}</For>
1070 </div>
1071 </div>
1072 );
1073}
dashboard/web/components/ListPage.tsx created+39
......@@ -0,0 +1,39 @@
1import ChevronUp from "lucide-solid/icons/chevron-up";
2import { children, createSignal, type JSX, Show } from "solid-js";
3
4/**
5 * A page whose head and summary stay put while the body scrolls under them; tables in the body keep their header
6 * row pinned. The summary folds away behind a handle, remembered per `id`. A body child with class `fill` takes the
7 * leftover height and scrolls on its own.
8 */
9export function ListPage(props: {
10 id: string;
11 class?: string;
12 head: JSX.Element;
13 summary?: JSX.Element;
14 /** Body children with class `fill` or `edge`, and tables, run edge to edge; the rest keep the page gutter. */
15 flush?: boolean;
16 children: JSX.Element;
17}) {
18 const key = `list-page.${props.id}.collapsed`;
19 const [collapsed, setCollapsed] = createSignal(localStorage.getItem(key) === "true");
20 const summary = children(() => props.summary);
21 return (
22 <div class={`page list-page ${props.class ?? ""}`}>
23 {props.head}
24 <Show when={summary.toArray().length}>
25 <div class="summary" classList={{ open: !collapsed() }} inert={collapsed()}>
26 <div>{summary()}</div>
27 </div>
28 <button class="summary-toggle" aria-expanded={!collapsed()} aria-label={collapsed() ? "Show summary" : "Hide summary"}
29 data-tip={collapsed() ? "show summary" : "hide summary"} onClick={() => {
30 setCollapsed(!collapsed());
31 localStorage.setItem(key, String(collapsed()));
32 }}>
33 <ChevronUp size={14} class={collapsed() ? "flipped" : ""} />
34 </button>
35 </Show>
36 <div class="list-body" classList={{ flush: props.flush }}>{props.children}</div>
37 </div>
38 );
39}
dashboard/web/components/Loaded.tsx created+70
......@@ -0,0 +1,70 @@
1import { type Accessor, createMemo, For, type JSX, type Resource, Show } from "solid-js";
2import Unplug from "lucide-solid/icons/unplug";
3import { reason, unconnected } from "../api.ts";
4
5/** The latest value that loaded, kept through later failures; reading an errored resource directly throws. */
6export function lastGood<T>(resource: Resource<T>): Accessor<T | undefined> {
7 return createMemo<T | undefined>((previous) => (resource.state === "errored" ? previous : resource.latest));
8}
9
10/** Placeholder rows of staggered widths, for a list or table. */
11export function SkeletonRows(props: { count: number }) {
12 return <For each={Array(props.count)}>{(_, i) => <div class="skeleton row-skeleton" style={{ width: `${35 + ((i() * 37) % 50)}%` }} />}</For>;
13}
14
15/**
16 * A skeleton, then `children` with the latest value, which stays up through a refetch and, with a note and a retry
17 * above it, through a failed one. Only a failure before anything loaded replaces the content with the error, and a
18 * source that isn't connected yet replaces it with the server's note instead, since retrying can't help.
19 */
20export function Loaded<T>(props: {
21 data: Resource<T>;
22 /** What failed to load, e.g. "pool status", read as "Couldn't load pool status." */
23 what: string;
24 retry: () => void;
25 /** Placeholder shaped like the content, built from `.skeleton` blocks; defaults to one block. */
26 skeleton?: JSX.Element;
27 children: (value: Accessor<NonNullable<T>>) => JSX.Element;
28}) {
29 const value = lastGood(props.data);
30 /** The last failure, kept through a refetch until one succeeds, so polling doesn't flash the skeleton. */
31 const error = createMemo<unknown>((previous) =>
32 props.data.state === "errored" ? props.data.error : props.data.state === "ready" ? undefined : previous);
33 const failure = () => error() !== undefined && reason(error());
34 return (
35 <Show when={value()} fallback={
36 <Show when={failure()} fallback={
37 <>
38 <span class="sr-only">Loading {props.what}…</span>
39 {props.skeleton ?? <div class="skeleton" style={{ height: "120px" }} />}
40 </>
41 }>
42 {(text) => unconnected(error()) ? (
43 <div class="empty unconnected" role="status">
44 <Unplug aria-hidden="true" />
45 <p>{text()}</p>
46 </div>
47 ) : (
48 <div class="empty failed" role="alert">
49 <p><span class="error">Couldn't load {props.what}.</span> {text()}</p>
50 <button class="button" onClick={props.retry}>retry</button>
51 </div>
52 )}
53 </Show>
54 }>
55 {(latest) => (
56 <>
57 <Show when={failure()}>
58 {(text) => (
59 <p class="stale-note" role="status">
60 <span class="error">Couldn't refresh {props.what}.</span> {text()}
61 <button class="button small" onClick={props.retry}>retry</button>
62 </p>
63 )}
64 </Show>
65 {props.children(latest)}
66 </>
67 )}
68 </Show>
69 );
70}
dashboard/web/components/LogView.css created+31
......@@ -0,0 +1,31 @@
1.log-view { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
2.log-view .logs {
3 flex: 1;
4 min-height: 0;
5 max-height: none;
6 margin: 0;
7 padding: 0 0 8px;
8 border: 0;
9 border-top: 1px solid var(--line);
10 border-radius: 0;
11 box-shadow: none;
12 background: var(--well);
13 /* Terminal colors lean toward the text color, more so on light backgrounds they were never picked for. */
14 --ansi-mix: 85%;
15}
16@media (prefers-color-scheme: light) { .log-view .logs { --ansi-mix: 55%; } }
17.log-view .logs:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
18.log-view .log { grid-template-columns: 62px 1fr; padding: 0 var(--gutter); }
19.log-view .log.warn:hover { background: color-mix(in srgb, var(--warning) 16%, transparent); }
20.log-view .log.error:hover { background: color-mix(in srgb, var(--critical) 20%, transparent); }
21.log-view .ansi {
22 color: color-mix(in srgb, var(--fg, currentColor) var(--ansi-mix), var(--text));
23 background: color-mix(in srgb, var(--bg, transparent) 45%, transparent);
24}
25.log-edge { padding: 6px var(--gutter); color: var(--muted); font: 12px var(--sans); }
26.log-edge.day { padding-block: 8px 2px; color: var(--text-2); font-weight: 600; }
27.log-edge.at { padding-block: 2px; border-block: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); color: var(--accent); }
28.log-view .latest { position: absolute; bottom: 16px; left: 50%; translate: -50%; background: var(--raised); box-shadow: var(--shadow); }
29.log-view .container { margin-right: 8px; padding: 0; border: 0; background: none; color: var(--muted); font: inherit; }
30.log-view button.container { cursor: pointer; }
31.log-view button.container:hover { color: var(--text); text-decoration: underline dotted; text-underline-offset: 3px; }
dashboard/web/components/LogView.tsx created+160
......@@ -0,0 +1,160 @@
1import ArrowDown from "lucide-solid/icons/arrow-down";
2import { createMemo, type JSX, Show } from "solid-js";
3import type { LogLine } from "../types/model.ts";
4import { clock, count, date, datetime } from "../format.ts";
5import { VirtualList } from "./VirtualList.tsx";
6import "./LogView.css";
7
8/** SGR sequences capture their parameters; every other escape and control character is dropped. */
9const ESCAPE = /\x1b\[([0-9;]*)m|\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]?|[\x00-\x08\x0b-\x1f\x7f]/g;
10/** The eight base colors in theme terms; the bright eight reuse them. */
11const ANSI = ["var(--muted)", "var(--critical)", "var(--good)", "var(--warning)", "var(--series-1)", "var(--series-5)",
12 "var(--series-3)", "var(--text)"];
13
14function xterm(n: number) {
15 if (n < 16) return ANSI[n % 8]!;
16 if (n >= 232) return `rgb(${Array(3).fill(8 + (n - 232) * 10).join(" ")})`;
17 return `rgb(${[36, 6, 1].map((step) => Math.floor((n - 16) / step) % 6).map((c) => (c ? 55 + c * 40 : 0)).join(" ")})`;
18}
19
20function sgr(style: JSX.CSSProperties, params: string): JSX.CSSProperties {
21 const codes = (params || "0").split(";").map(Number);
22 const next = { ...style };
23 const color = (key: "--fg" | "--bg", value: string | undefined) => (value ? (next[key] = value) : delete next[key]);
24 for (let i = 0; i < codes.length; i++) {
25 const code = codes[i]!;
26 if (code === 0) for (const key in next) delete next[key as keyof JSX.CSSProperties];
27 else if (code === 1) next["font-weight"] = 600;
28 else if (code === 2) next.opacity = 0.65;
29 else if (code === 3) next["font-style"] = "italic";
30 else if (code === 4) next["text-decoration"] = "underline";
31 else if (code === 22) {
32 delete next["font-weight"];
33 delete next.opacity;
34 }
35 else if (code === 23) delete next["font-style"];
36 else if (code === 24) delete next["text-decoration"];
37 else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) color("--fg", ANSI[code % 10]);
38 else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) color("--bg", ANSI[code % 10]);
39 else if (code === 39 || code === 49) color(code === 39 ? "--fg" : "--bg", undefined);
40 else if (code === 38 || code === 48) {
41 const rgb = codes[i + 1] === 5 ? xterm(codes[i + 2]!) : `rgb(${codes.slice(i + 2, i + 5).join(" ")})`;
42 color(code === 38 ? "--fg" : "--bg", rgb);
43 i += codes[i + 1] === 5 ? 2 : 4;
44 }
45 }
46 return next;
47}
48
49/** A log line as a terminal shows it: colored, each line from its last carriage return; each of `needles` is marked. */
50function ansi(raw: string, needles: string[]) {
51 const text = raw.replace(/[^\n]*\r(?=[^\n])/g, "");
52 if (!needles.length && !/[\x00-\x08\x0b-\x1f\x7f]/.test(text)) return text;
53 const segments: { text: string; style: JSX.CSSProperties }[] = [];
54 let style: JSX.CSSProperties = {};
55 let last = 0;
56 for (const match of text.matchAll(ESCAPE)) {
57 if (match.index > last) segments.push({ text: text.slice(last, match.index), style });
58 if (match[1] !== undefined) style = sgr(style, match[1]);
59 last = match.index + match[0].length;
60 }
61 segments.push({ text: text.slice(last), style });
62 const plain = segments.map((segment) => segment.text).join("").toLowerCase();
63 const found: [number, number][] = [];
64 for (const needle of needles.map((needle) => needle.toLowerCase())) {
65 for (let at = plain.indexOf(needle); at !== -1; at = plain.indexOf(needle, at + needle.length)) found.push([at, at + needle.length]);
66 }
67 const marks: [number, number][] = [];
68 for (const [from, to] of found.sort((a, b) => a[0] - b[0])) {
69 const last = marks.at(-1);
70 if (last && from <= last[1]) last[1] = Math.max(last[1], to);
71 else marks.push([from, to]);
72 }
73 let end = 0;
74 return segments.map((segment) => {
75 const start = end;
76 end += segment.text.length;
77 const parts: JSX.Element[] = [];
78 let cut = start;
79 for (const [from, to] of marks) {
80 if (to <= cut || from >= end) continue;
81 if (from > cut) parts.push(segment.text.slice(cut - start, from - start));
82 cut = Math.min(to, end);
83 parts.push(<mark>{segment.text.slice(Math.max(from, start) - start, cut - start)}</mark>);
84 }
85 if (cut < end) parts.push(segment.text.slice(cut - start));
86 return Object.keys(segment.style).length ? <span class="ansi" style={segment.style}>{parts}</span> : parts;
87 });
88}
89
90/** Line height and character width of `.logs` text, for estimating how a line wraps before it renders. */
91const LINE = 18.6;
92const CHAR = 7.2;
93/** Everything in a log row beside the text: the gutters, the time column, and the gap after it. */
94const ROW_CHROME = 2 * 28 + 62 + 12;
95
96function estimate(line: LogLine, width: number) {
97 const columns = Math.max(16, Math.floor((width - ROW_CHROME) / CHAR));
98 let rows = 0;
99 for (const part of line.text.replace(ESCAPE, "").split("\n")) rows += Math.max(1, Math.ceil(part.length / columns));
100 return rows * LINE;
101}
102
103/**
104 * Log lines, newest at the bottom, as a terminal shows them: colored, soft-wrapped, split by day, `marks` marked, and
105 * named by container when several are mixed. Follows new lines while scrolled to the end; away from it, a button leads back.
106 */
107export function LogView(props: {
108 lines: LogLine[];
109 follow: boolean;
110 onFollow: (atEnd: boolean) => void;
111 marks?: string[];
112 /** Makes a line's container name a button, when several containers are mixed. */
113 onContainer?: (container: string) => void;
114 /** Called while the view is near the oldest line. */
115 onStart?: () => void;
116 /** Lines that arrived while away from the end, counted on the button back to it. */
117 unseen?: number;
118 /** What the button back to the end does, if more than following again. */
119 onLatest?: () => void;
120 /** Rows above a line, after its day divider. */
121 before?: (line: LogLine, index: () => number) => JSX.Element;
122}) {
123 const mixed = createMemo(() => new Set(props.lines.map((line) => line.container)).size > 1);
124 return (
125 <div class="log-view">
126 <VirtualList class="logs" label="Log lines" items={props.lines} estimate={estimate} follow={props.follow}
127 onFollow={props.onFollow} onStart={props.onStart}>
128 {(line, index) => (
129 <>
130 {/* Rows can outlive a swapped-in list for a tick, so the previous line may not exist yet. */}
131 <Show when={index() > 0 && props.lines[index() - 1] && date(props.lines[index() - 1]!.t) !== date(line.t)}>
132 <div class="log-edge day">{date(line.t)}</div>
133 </Show>
134 {props.before?.(line, index)}
135 <div class={`log ${line.level ?? ""}`}>
136 <time data-tip={datetime(line.t)}>{clock(line.t)}</time>
137 <span>
138 <Show when={mixed()}>
139 <Show when={props.onContainer} fallback={<span class="container">{line.container}</span>}>
140 {(filter) => (
141 <button class="container" data-tip={`only ${line.container}`} onClick={() => filter()(line.container)}>
142 {line.container}
143 </button>
144 )}
145 </Show>
146 </Show>
147 {ansi(line.text, props.marks ?? [])}
148 </span>
149 </div>
150 </>
151 )}
152 </VirtualList>
153 <Show when={!props.follow}>
154 <button class="button small latest" onClick={() => (props.onLatest ?? (() => props.onFollow(true)))()}>
155 <ArrowDown size={14} />{props.unseen ? `${count(props.unseen)} new` : "latest"}
156 </button>
157 </Show>
158 </div>
159 );
160}
dashboard/web/components/Meter.tsx created+12
......@@ -0,0 +1,12 @@
1import { Show } from "solid-js";
2
3/** A thin progress bar; `failed` stacks a critical segment after the done one. */
4export function Meter(props: { value: number; max?: number; failed?: number }) {
5 const width = (part: number) => `${(part / (props.max || 1)) * 100}%`;
6 return (
7 <div class="meter" role="progressbar" aria-valuemin="0" aria-valuemax={props.max ?? 1} aria-valuenow={props.value}>
8 <span style={{ width: width(props.value) }} />
9 <Show when={props.failed}>{(failed) => <span class="failed" style={{ width: width(failed()) }} />}</Show>
10 </div>
11 );
12}
dashboard/web/components/Metric.tsx created+95
......@@ -0,0 +1,95 @@
1import { createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
2import type { Metric, Series } from "../types/model.ts";
3import { queries } from "../api.ts";
4import { lastGood, Loaded } from "./Loaded.tsx";
5import { TabBar } from "./TabBar.tsx";
6import { TimeChart, type ChartProps } from "./TimeChart.tsx";
7
8const RANGES = [["1h", 3600], ["6h", 21600], ["24h", 86400], ["7d", 604800]] as const;
9
10export function RangePicker(props: { value: number; onChange: (value: number) => void }) {
11 return (
12 <TabBar label="Time range">
13 <For each={RANGES}>
14 {([label, seconds]) => (
15 <button aria-pressed={props.value === seconds} onClick={() => props.onChange(seconds)}>{label}</button>
16 )}
17 </For>
18 </TabBar>
19 );
20}
21
22/** Keeps the heaviest series and folds the rest into "other" so no slot color repeats. */
23function fold(series: Series[], keep = 5) {
24 const mean = (item: Series) => item.v.reduce<number>((sum, v) => sum + (v ?? 0), 0) / (item.v.length || 1);
25 const ranked = [...series].sort((a, b) => mean(b) - mean(a));
26 const top = new Set(ranked.slice(0, keep));
27 const kept = series.filter((item) => top.has(item));
28 const rest = series.filter((item) => !top.has(item));
29 const colors = kept.map((_, i) => `var(--series-${i + 1})`);
30 if (!rest.length) return { series: kept, colors };
31 const other: Series = {
32 name: "other",
33 t: rest[0]!.t,
34 v: rest[0]!.t.map((_, j) => rest.reduce((sum, item) => sum + (item.v[j] ?? 0), 0)),
35 };
36 return { series: [...kept, other], colors: [...colors, "var(--other)"] };
37}
38
39export interface MetricQuery {
40 metric: Metric;
41 range: number;
42 service?: string;
43 /** Series per service, named by `names`, the heaviest kept and the rest folded into "other". */
44 split?: boolean;
45 names?: Record<string, string>;
46}
47
48/** A metric over the range, refreshed every 30 seconds; `shape` names and folds the loaded series for a chart. */
49export function useMetric(props: MetricQuery) {
50 const [data, { refetch }] = queries.metric.use(() => ({ metric: props.metric, range: props.range, service: props.service }));
51 const timer = setInterval(refetch, 30_000);
52 onCleanup(() => clearInterval(timer));
53 const shape = (loaded: Series[]) => {
54 const series = loaded.map((item) => ({ ...item, name: props.names?.[item.name] ?? item.name }));
55 return props.split ? fold(series) : { series, colors: undefined };
56 };
57 return { data, refetch, shape };
58}
59
60interface MetricCardProps extends Omit<ChartProps, "series" | "colors" | "onHover">, MetricQuery {
61 title: string;
62 sub?: string;
63 actions?: JSX.Element;
64}
65
66export function MetricCard(props: MetricCardProps) {
67 const { data, refetch, shape } = useMetric(props);
68 const [hover, setHover] = createSignal<number | null>(null);
69 const latest = lastGood(data);
70 // A single series has no legend, so its value, latest or hovered, sits in the title.
71 const reading = () => {
72 const series = latest();
73 if (series?.length !== 1) return;
74 const values = series[0]!.v;
75 const value = hover() === null ? values.findLast((v) => v !== null) : values[hover()!];
76 return value == null ? undefined : props.format(value);
77 };
78 return (
79 <div class="card metric">
80 <div class="card-title">
81 {props.title}
82 <Show when={reading()}>{(value) => <span class="reading">{value()}</span>}</Show>
83 <Show when={props.sub}><span class="sub">{props.sub}</span></Show>
84 <span class="spacer" />
85 {props.actions}
86 </div>
87 <Loaded data={data} what="this chart" retry={refetch} skeleton={<div class="skeleton" style={{ height: "196px" }} />}>
88 {(latest) => {
89 const shaped = createMemo(() => shape(latest()));
90 return <TimeChart series={shaped().series} colors={shaped().colors} format={props.format} stacked={props.stacked} max={props.max} onHover={setHover} />;
91 }}
92 </Loaded>
93 </div>
94 );
95}
dashboard/web/components/OpenApp.tsx created+27
......@@ -0,0 +1,27 @@
1import ArrowUpRight from "lucide-solid/icons/arrow-up-right";
2import type { JSX } from "solid-js";
3import type { ServiceSummary } from "../types/model.ts";
4import { AppIcon } from "./AppIcon.tsx";
5
6/**
7 * A chip out to the real app a page wraps, placed after the page title: the app's icon, then `children` if given.
8 * The tooltip says where it lands; with `icon={false}` the chip shows that address instead, for pages that already
9 * show the app's icon.
10 */
11export function OpenApp(props: {
12 app: Pick<ServiceSummary, "id" | "name" | "icon">;
13 href: string;
14 icon?: false;
15 children?: JSX.Element;
16}) {
17 // A query is noise in a tooltip, but a hash route like #/master/users says where it lands.
18 const host = () => props.href.replace(/^https?:\/\//, "").replace(/#?\/?\?.*$/, "").replace(/\/$/, "");
19 return (
20 <a class="open-app" href={props.href} target="_blank" rel="noreferrer" aria-label={`Open ${props.app.name}`}
21 data-tip={props.icon === false ? undefined : host()}>
22 {props.icon === false ? host() : <AppIcon id={props.app.id} name={props.app.name} icon={props.app.icon} />}
23 {props.children}
24 <ArrowUpRight size={12} class="arrow" aria-hidden="true" />
25 </a>
26 );
27}
dashboard/web/components/PaperCloverMark.tsx created+11
......@@ -0,0 +1,11 @@
1/** The paperclover.net clover, recolored to follow the nav text color. */
2export function PaperCloverMark(props: { class: string }) {
3 return (
4 <svg class={props.class} viewBox="2 2 124 124" fill="currentColor" aria-hidden="true">
5 <path opacity="0.3"
6 d="M47.7 61C18.3 54.1 17.2 36 30.7 23.8C41.5 14 52.5 18.4 52.9 30.5C53.4 23.2 55.9 3.9 72.2 9.8C92.5 17.2 78.1 46.9 71.3 55.3C81.2 44.7 96.8 25.2 113.7 36.9C130.7 48.6 113.2 61.3 109.2 62.4C112.9 63.2 124.8 71.9 113.5 84.4C104.8 93.5 92.6 94.4 67.6 77.5C69.9 85 71.4 93.8 76.4 102.6C79.5 107.9 83.7 112.7 89.4 115.9L89.1 117.5C83.2 112.7 79.2 107.6 76.4 102.6C71.2 93.8 69 83.7 67.6 77.6L67.6 77.5C65.5 87.1 52.5 112.6 37 113.2C20.7 113.9 22.7 99.2 26.7 93.6C19.7 96.9 6.6 98.5 9.7 78.1C12.7 58.1 33.6 57.9 47.7 61Z" />
7 <path stroke="currentColor" stroke-width="4" stroke-linejoin="round"
8 d="M61.6 7.7C64.7 6.4 68.4 6.3 72.8 8C78.5 10 81.8 13.7 83.4 18.2C85 22.7 84.8 27.7 83.8 32.6C83.2 36 82.1 39.3 80.8 42.5C83.3 40.1 86 37.9 88.8 36.1C92.6 33.6 96.7 31.7 101.1 31.3C105.6 31 110.2 32.1 114.8 35.2C119.3 38.3 121.8 41.7 122.7 45.2C123.5 48.7 122.6 51.9 121.1 54.6C119.5 57.3 117.3 59.5 115.2 61.2C114.7 61.6 114.1 62 113.6 62.4C113.8 62.5 114 62.7 114.3 62.9C115.9 64.1 117.6 65.9 118.8 68.1C119.9 70.3 120.6 73 120.1 76.1C119.6 79.1 118 82.4 115 85.7C115 85.7 115 85.7 114.9 85.7C110.2 90.7 104.4 93.5 96.4 92.7C89.6 92 81.4 88.6 71 82.1C72.7 88.3 74.3 94.9 78.1 101.6L78.7 102.5C81.6 107.2 85.4 111.4 90.3 114.1C91.1 114.6 91.5 115.4 91.3 116.2L91.1 117.8C90.9 118.5 90.5 119.1 89.8 119.4C89.1 119.6 88.4 119.5 87.8 119C81.7 114 77.6 108.8 74.7 103.6L74.2 102.7C70.8 96.7 68.7 90.2 67.3 84.8C65.4 89.4 62.5 94.9 58.9 99.8C56.1 103.8 52.9 107.5 49.2 110.3C45.6 113.1 41.5 115 37.1 115.2C32.7 115.4 29.3 114.5 26.8 112.9C24.3 111.2 22.9 108.8 22.3 106.2C21.6 103.2 21.9 99.9 22.8 97.1C22.2 97.2 21.6 97.3 20.9 97.4C18.6 97.8 15.9 97.7 13.6 96.6C11.2 95.5 9.3 93.4 8.2 90.3C7.1 87.2 6.9 83.1 7.7 77.8C8.5 72.5 10.5 68.3 13.5 65.2C16.4 62.1 20 60.1 24 59C26.8 58.2 29.7 57.7 32.7 57.6C26.6 54.2 22.8 49.8 21.2 45C18.6 37.1 22.3 28.7 29.3 22.3C35.1 17.1 41.3 15.3 46.3 17C48.8 17.9 50.8 19.4 52.2 21.5C52.8 19.1 53.7 16.5 54.9 14.3C56.4 11.5 58.5 9 61.6 7.7ZM71.5 11.7C67.8 10.3 65.1 10.5 63.2 11.4C61.2 12.2 59.6 13.9 58.4 16.2C55.9 20.8 55.2 27 54.9 30.7C54.9 31.7 54 32.5 52.9 32.5C51.9 32.5 51 31.7 51 30.6C50.7 25.1 48.2 21.9 45.1 20.8C41.8 19.7 37.1 20.7 32 25.3C25.6 31.1 23.1 37.9 25 43.8C26.9 49.7 33.7 55.7 48.1 59.1L48.5 59.1C48.6 59.2 48.8 59.2 48.9 59.2H48.9C48.9 59.2 48.9 59.2 48.9 59.2C50 59.5 50.6 60.6 50.4 61.6C50.2 62.7 49.1 63.4 48 63.1C48 63.1 47.9 63.1 47.9 63.1C47.6 63 47.4 63 47.2 62.9C40.3 61.4 32 60.8 25.1 62.8C21.7 63.8 18.7 65.5 16.4 67.9C14.1 70.4 12.4 73.8 11.7 78.4C10.9 83.4 11.2 86.7 12 89C12.7 91.2 13.9 92.3 15.3 93C16.7 93.6 18.5 93.8 20.4 93.5C22.3 93.2 24.2 92.6 25.8 91.8C26.6 91.4 27.6 91.6 28.2 92.3C28.8 93 28.8 94 28.3 94.7C26.6 97.2 25.3 101.7 26.2 105.3C26.6 107 27.5 108.5 29 109.5C30.6 110.6 33.1 111.4 36.9 111.2C40.2 111.1 43.6 109.6 46.8 107.1C50 104.6 53 101.3 55.7 97.5C61 90.1 64.7 81.5 65.6 77L65.7 76.8C65.9 76.3 66.3 75.8 66.8 75.6C67.4 75.4 68.1 75.4 68.7 75.8C81.1 84.2 90.1 88 96.9 88.7C103.4 89.4 108 87.2 112 83C114.7 80.1 115.8 77.6 116.2 75.5C116.5 73.4 116.1 71.5 115.2 69.9C114.4 68.3 113.1 67 111.8 66C110.5 65 109.3 64.5 108.8 64.3C107.9 64.1 107.2 63.3 107.2 62.4C107.2 61.5 107.8 60.7 108.7 60.5C109.3 60.3 110.9 59.5 112.8 58C114.6 56.6 116.4 54.7 117.6 52.6C118.8 50.5 119.3 48.3 118.8 46.1C118.2 43.9 116.5 41.3 112.6 38.5C108.7 35.8 105 35 101.5 35.3C97.9 35.6 94.4 37.1 91 39.4C84 43.9 77.8 51.2 72.8 56.6C72 57.4 70.8 57.5 70 56.8C69.2 56.1 69.1 54.8 69.8 54C73 50.1 78.1 40.9 79.9 31.8C80.8 27.3 80.9 23 79.7 19.6C78.5 16.2 76 13.4 71.5 11.7Z" />
9 </svg>
10 );
11}
dashboard/web/components/PirateFlag.tsx created+19
......@@ -0,0 +1,19 @@
1import { createUniqueId } from "solid-js";
2
3/** Lucide's flag, filled, with a skull and crossbones knocked out of it. */
4export function PirateFlag(props: { class: string }) {
5 const mask = createUniqueId();
6 return (
7 <svg class={props.class} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
8 stroke-linejoin="round" aria-hidden="true">
9 <mask id={mask}>
10 <rect width="24" height="24" fill="#fff" stroke="none" />
11 <circle cx="12" cy="7.3" r="2.2" fill="#000" stroke="none" />
12 <rect x="10.8" y="8.5" width="2.4" height="1.8" fill="#000" stroke="none" />
13 <path d="m9 10.2 6 2.6m0-2.6-6 2.6" stroke="#000" stroke-width="1.4" />
14 </mask>
15 <path fill="currentColor" mask={`url(#${mask})`}
16 d="M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528" />
17 </svg>
18 );
19}
dashboard/web/components/Reveal.tsx created+14
......@@ -0,0 +1,14 @@
1import type { JSX } from "solid-js";
2
3/**
4 * Grows `children` in from nothing along `axis` (default "y") while `when` holds, and folds them away after.
5 * Hidden children stay mounted but inert. The wrapper still takes a flex or grid gap, so space it from inside.
6 */
7export function Reveal(props: { when: boolean; axis?: "x" | "y"; children: JSX.Element }) {
8 return (
9 <div class={`reveal ${props.axis ?? "y"}`} classList={{ shown: props.when }} inert={!props.when}
10 aria-hidden={!props.when}>
11 <div>{props.children}</div>
12 </div>
13 );
14}
dashboard/web/components/Sidebar.tsx created+288
......@@ -0,0 +1,288 @@
1import { A, useLocation, useMatch } from "@solidjs/router";
2import ArrowUpRight from "lucide-solid/icons/arrow-up-right";
3import Boxes from "lucide-solid/icons/boxes";
4import ChevronRight from "lucide-solid/icons/chevron-right";
5import ChevronsUpDown from "lucide-solid/icons/chevrons-up-down";
6import Clapperboard from "lucide-solid/icons/clapperboard";
7import Eye from "lucide-solid/icons/eye";
8import HardDrive from "lucide-solid/icons/hard-drive";
9import House from "lucide-solid/icons/house";
10import LogOut from "lucide-solid/icons/log-out";
11import Monitor from "lucide-solid/icons/monitor";
12import Rocket from "lucide-solid/icons/rocket";
13import SquarePlay from "lucide-solid/icons/square-play";
14import UserRound from "lucide-solid/icons/user-round";
15import Plug from "lucide-solid/icons/plug";
16import Users from "lucide-solid/icons/users";
17import { createSignal, For, type JSX, Show } from "solid-js";
18import { type Health, type Me, type Section, trouble, VIEW_AS } from "../types/model.ts";
19import { queries } from "../api.ts";
20import snowflake from "../snowflake.svg";
21import { bytes, cores, plural } from "../format.ts";
22import { account, Avatar, displayName } from "../pages/Account.tsx";
23import { status } from "../pages/Overview.tsx";
24import { TABS as STORAGE_TABS } from "../pages/Storage.tsx";
25import { TABS as YOUTUBE_TABS } from "../pages/YouTube.tsx";
26import { stream } from "../live.ts";
27import { AppIcon } from "./AppIcon.tsx";
28import { lastGood, Loaded } from "./Loaded.tsx";
29import { PaperCloverMark } from "./PaperCloverMark.tsx";
30import { PirateFlag } from "./PirateFlag.tsx";
31import { Spark } from "./Spark.tsx";
32import { Status, StatusLabel } from "./Status.tsx";
33
34type Icon = (props: { class: string }) => JSX.Element;
35
36interface Page {
37 href: string;
38 label: string;
39 icon: Icon;
40 section: Section;
41 /** `?tab=` values, labels and icons, the page's default first. */
42 tabs?: readonly (readonly [string, string, Icon])[];
43}
44
45const BEFORE: Page[] = [{ href: "/", label: "overview", icon: House, section: "launcher" }];
46
47const AFTER: Page[] = [
48 { href: "/mcp", label: "mcp", icon: Plug, section: "launcher" },
49 {
50 href: "/storage", label: "storage", icon: HardDrive, section: "admin",
51 tabs: STORAGE_TABS,
52 },
53 { href: "/media", label: "media", icon: Clapperboard, section: "media" },
54 { href: "/seedbox", label: "seedbox", icon: PirateFlag, section: "media" },
55 {
56 href: "/youtube", label: "youtube", icon: SquarePlay, section: "media",
57 tabs: YOUTUBE_TABS,
58 },
59 { href: "/paper-clover", label: "paper clover", icon: PaperCloverMark, section: "admin" },
60 { href: "/users", label: "users", icon: Users, section: "admin" },
61 { href: "/deploys", label: "deploys", icon: Rocket, section: "admin" },
62 { href: "/vms", label: "vms", icon: Monitor, section: "vms" },
63];
64
65/** Every page and the section that opens it; admins also get the Services group between the two halves. */
66export const PAGES = [...BEFORE, ...AFTER];
67
68/** Groups an admin can preview the dashboard as. */
69const PREVIEW_GROUPS = ["media", "media-manage", "metrics", "vm"];
70
71/** How many apps need a look, on the overview's link, so it shows from every page. */
72function IssueCount() {
73 const issues = () => status.latest()?.issues ?? [];
74 return (
75 <Show when={issues().length}>
76 <span class="badge">
77 <span class="tally" data-tip={issues().map((issue) =>
78 `${issue.service.name} ${trouble(issue.health) ? issue.health : "restarted"}`).join(", ")}>
79 <StatusLabel health={issues().some((issue) => issue.health === "down") ? "down" : "degraded"}>{issues().length}</StatusLabel>
80 <span class="sr-only">need a look</span>
81 </span>
82 </span>
83 </Show>
84 );
85}
86
87/** A group's open state, remembered across visits. */
88function remembered(key: string, initial: boolean) {
89 const [open, setOpen] = createSignal((localStorage.getItem(key) ?? (initial ? "open" : "closed")) === "open");
90 const toggle = () => {
91 setOpen(!open());
92 localStorage.setItem(key, open() ? "open" : "closed");
93 };
94 return [open, toggle] as const;
95}
96
97function NavLink(props: { page: Page; children?: JSX.Element }) {
98 const link = (
99 <A href={props.page.href} end class="nav-item" activeClass="active">
100 <props.page.icon class="icon" />
101 <span class="label">{props.page.label}</span>
102 {props.children}
103 </A>
104 );
105 return (
106 <Show when={props.page.tabs} fallback={link}>
107 {(tabs) => {
108 const [open, toggle] = remembered(`sidebar.${props.page.label}`, false);
109 const location = useLocation();
110 const current = (tab: string) => location.pathname === props.page.href
111 && (new URLSearchParams(location.search).get("tab") ?? tabs()[0]![0]) === tab;
112 return (
113 <>
114 <div class="nav-row">
115 {link}
116 <button class="nav-toggle" aria-expanded={open()} aria-label={`${props.page.label} tabs`} onClick={toggle}>
117 <ChevronRight class={`chevron ${open() ? "open" : ""}`} />
118 </button>
119 </div>
120 <div class="nav-group" classList={{ open: open() }} inert={!open()}>
121 <div>
122 <For each={tabs()}>
123 {([tab, label, Icon]) => (
124 <a href={tab ? `${props.page.href}?tab=${tab}` : props.page.href} class="nav-sub"
125 aria-current={current(tab) ? "page" : undefined}>
126 <Icon class="icon" />{label}
127 </a>
128 )}
129 </For>
130 </div>
131 </div>
132 </>
133 );
134 }}
135 </Show>
136 );
137}
138
139/** Health states counted together in the collapsed group's summary, in order. */
140const TALLY: [Health, Health[], string][] = [
141 ["healthy", ["healthy"], "up"],
142 ["down", ["down"], "down"],
143 ["degraded", ["degraded"], "degraded"],
144 ["deploying", ["deploying", "restarting", "starting"], "changing"],
145 ["stopped", ["stopped"], "stopped"],
146];
147
148function Services() {
149 const [open, toggle] = remembered("sidebar.services", true);
150 const [services, { refetch }] = queries.services.use();
151 stream.start();
152
153 const health = (id: string, fallback: Health) => stream.latest()?.services[id]?.health ?? fallback;
154 const loaded = lastGood(services);
155 const tally = () => TALLY.map(([shape, states, word]) => {
156 const names = (loaded() ?? []).filter((service) => states.includes(health(service.id, service.health))).map((s) => s.name);
157 return { shape, word, names };
158 }).filter((group) => group.names.length);
159
160 return (
161 <>
162 <button class="nav-item" onClick={toggle} aria-expanded={open()}>
163 <Boxes class="icon" />
164 <span class="label">services</span>
165 <span class="badge">
166 <For each={tally()}>
167 {(group) => (
168 <span class="tally" data-tip={group.shape === "healthy" ? undefined : group.names.join(", ")}>
169 <StatusLabel health={group.shape}>
170 {group.names.length}<Show when={group.shape === "healthy"}> {group.word}</Show>
171 </StatusLabel>
172 <Show when={group.shape !== "healthy"}><span class="sr-only">{group.word}</span></Show>
173 </span>
174 )}
175 </For>
176 </span>
177 <ChevronRight class={`chevron ${open() ? "open" : ""}`} />
178 </button>
179 <div class="nav-group" classList={{ open: open(), stale: !stream.live() }} inert={!open()}>
180 <div>
181 <Loaded data={services} what="services" retry={refetch} skeleton={
182 <For each={Array(8)}>{() => <div class="service-row"><span /><span class="skeleton" /><span class="skeleton" /></div>}</For>
183 }>
184 {(all) => (
185 <For each={all()}>
186 {(service) => {
187 const trail = () => stream.trails()[service.id];
188 const sample = () => stream.latest()?.services[service.id];
189 const cpu = () => sample()?.cpu ?? service.cpu;
190 const memory = () => sample()?.memory ?? service.memory;
191 return (
192 <A href={`/services/${service.id}`} class="service-row" activeClass="active">
193 <Status health={health(service.id, service.health)} />
194 <AppIcon id={service.id} name={service.name} icon={service.icon} />
195 <span class="name">{service.name}</span>
196 <Spark values={trail()?.cpu ?? []} count={stream.count()} max={service.cpuLimit / 10} color="var(--series-1)"
197 label={`cpu ${cpu() === null ? "not measured" : cores(cpu()!)} of ${plural(service.cpuLimit, "core")}`} />
198 <Spark values={trail()?.memory ?? []} count={stream.count()} max={service.memoryLimit} color="var(--series-2)"
199 label={`memory ${memory() === null ? "not measured" : bytes(memory()!)} of ${bytes(service.memoryLimit)}`} />
200 <Show when={service.url} fallback={<span />}>
201 {(url) => (
202 <a class="row-icon" href={url()} target="_blank" rel="noreferrer" aria-label={`Open ${service.name}`}
203 data-tip={new URL(url()).host} onClick={(event) => event.stopPropagation()}>
204 <ArrowUpRight size={13} />
205 </a>
206 )}
207 </Show>
208 </A>
209 );
210 }}
211 </For>
212 )}
213 </Loaded>
214 </div>
215 </div>
216 </>
217 );
218}
219
220/** Previews the dashboard as someone in fewer groups; the server only honors it for admins. */
221export function viewAs(groups: string[] | null) {
222 document.cookie = groups ? `${VIEW_AS}=${encodeURIComponent(groups.join(","))}; path=/; samesite=strict` : `${VIEW_AS}=; path=/; max-age=0`;
223 location.reload();
224}
225
226/** The signed-in user's corner; it falls back to the forwarded name while Keycloak is out of reach. */
227function Whoami(props: { me: Me }) {
228 const user = lastGood(account);
229 const name = () => {
230 const known = user();
231 return known ? displayName(known) : props.me.name;
232 };
233 const here = useMatch(() => "/account");
234 const [picked, setPicked] = createSignal(props.me.viewing ? props.me.groups : []);
235 let menu!: HTMLDivElement;
236 return (
237 <div class="whoami">
238 <button class="whoami-button" classList={{ active: !!here() }} popovertarget="account-menu">
239 <Avatar picture={user()?.picture ?? null} name={name()} />
240 <span class="name">{name()}</span>
241 <ChevronsUpDown size={14} class="icon" />
242 </button>
243 <div ref={menu} id="account-menu" popover class="account-menu">
244 <A href="/account" class="nav-item" onClick={() => menu.hidePopover()}><UserRound class="icon" /><span class="label">profile</span></A>
245 <a href="/api/account/sign-out" class="nav-item"><LogOut class="icon" /><span class="label">sign out</span></a>
246 <Show when={props.me.viewing || props.me.sections.includes("admin")}>
247 <form class="view-as" onSubmit={(event) => {
248 event.preventDefault();
249 viewAs(picked());
250 }}>
251 <span class="muted">view as someone in</span>
252 <div class="chips">
253 <For each={PREVIEW_GROUPS}>
254 {(group) => (
255 <button type="button" class="chip" aria-pressed={picked().includes(group)}
256 onClick={() => setPicked(picked().includes(group) ? picked().filter((g) => g !== group) : [...picked(), group])}>
257 {group}
258 </button>
259 )}
260 </For>
261 </div>
262 <button class="button small"><Eye size={14} />{picked().length ? "preview" : "preview as friend"}</button>
263 </form>
264 </Show>
265 </div>
266 </div>
267 );
268}
269
270export function Sidebar(props: { me: Me }) {
271 const allowed = (page: Page) => props.me.sections.includes(page.section);
272 return (
273 <nav class="sidebar" aria-label="Main">
274 <div class="brand">
275 <img src={snowflake} alt="" />
276 the snow globe
277 </div>
278 <div class="nav">
279 <For each={BEFORE.filter(allowed)}>{(page) => <NavLink page={page}><IssueCount /></NavLink>}</For>
280 <Show when={props.me.sections.includes("admin")}>
281 <Services />
282 </Show>
283 <For each={AFTER.filter(allowed)}>{(page) => <NavLink page={page} />}</For>
284 </div>
285 <Whoami me={props.me} />
286 </nav>
287 );
288}
dashboard/web/components/SortHeader.tsx created+29
......@@ -0,0 +1,29 @@
1import ArrowDown from "lucide-solid/icons/arrow-down";
2import ArrowUp from "lucide-solid/icons/arrow-up";
3import { Show } from "solid-js";
4
5export interface Sort<K extends string> {
6 key: K;
7 desc: boolean;
8}
9
10/** A sortable column head; `num` columns right-align and sort largest first on the first click. */
11export function SortHeader<K extends string>(props: {
12 column: K;
13 label: string;
14 sort: Sort<K>;
15 onSort: (sort: Sort<K>) => void;
16 num?: boolean;
17 class?: string;
18}) {
19 const active = () => props.sort.key === props.column;
20 return (
21 <th class={props.class} classList={{ num: props.num }}
22 aria-sort={active() ? (props.sort.desc ? "descending" : "ascending") : undefined}>
23 <button class="sort" onClick={() => props.onSort({ key: props.column, desc: active() ? !props.sort.desc : !!props.num })}>
24 {props.label}
25 <Show when={active()}>{props.sort.desc ? <ArrowDown size={12} /> : <ArrowUp size={12} />}</Show>
26 </button>
27 </th>
28 );
29}
dashboard/web/components/Spark.tsx created+61
......@@ -0,0 +1,61 @@
1import { createEffect, createMemo, on } from "solid-js";
2import { LIVE_INTERVAL } from "../types/model.ts";
3
4const W = 44;
5const H = 14;
6/** Samples averaged into each point, aligned to the stream's sample count so a finished point never moves. */
7const PER_POINT = 7;
8const GAP = 2;
9const reduced = matchMedia("(prefers-reduced-motion: reduce)");
10
11/** Rounds up to 1, 2, or 5 times a power of ten, so the scale only changes on real swings. */
12function nice(value: number) {
13 const power = 10 ** Math.floor(Math.log10(value));
14 return [1, 2, 5, 10].map((m) => m * power).find((step) => step >= value)!;
15}
16
17/**
18 * A live trail that scrolls continuously between ticks. `values` are the latest samples and `count` is how many the
19 * stream has produced; the scale is at least `max`.
20 */
21export function Spark(props: { values: number[]; count: number; max: number; color: string; label: string }) {
22 let track!: SVGGElement;
23 const shape = createMemo(() => {
24 const { values, count } = props;
25 const first = count - values.length;
26 const base = Math.floor(first / PER_POINT);
27 const means: number[] = [];
28 for (let bucket = base; values.length && bucket * PER_POINT < count; bucket++) {
29 const slice = values.slice(Math.max(0, bucket * PER_POINT - first), (bucket + 1) * PER_POINT - first);
30 means.push(slice.reduce((sum, v) => sum + v, 0) / slice.length);
31 }
32 const peak = Math.max(0, ...means);
33 const top = peak > props.max ? nice(peak) : props.max;
34 const points = means.map((v, i) => [(i + 0.5) * GAP, H - 1 - Math.min(1, v / top) * (H - 2)] as const);
35 let line = points.length ? `M${points[0]}` : "";
36 for (let i = 1; i < points.length - 1; i++) {
37 const [x, y] = points[i]!;
38 const [nx, ny] = points[i + 1]!;
39 line += `Q${x},${y} ${(x + nx) / 2},${(y + ny) / 2}`;
40 }
41 if (points.length > 1) line += `L${points.at(-1)}`;
42 const area = points.length > 1 ? `${line}L${points.at(-1)![0]},${H}L${points[0]![0]},${H}Z` : "";
43 // Where the view's left edge sits for a given sample count, in this path's coordinates.
44 const offset = (samples: number) => `translateX(${W - (samples / PER_POINT - base) * GAP}px)`;
45 return { line, area, from: offset(count - 1), to: offset(count) };
46 });
47
48 createEffect(on(shape, ({ from, to }) => {
49 if (reduced.matches) track.style.transform = to;
50 else track.animate([{ transform: from }, { transform: to }], { duration: LIVE_INTERVAL * 1000, fill: "forwards" });
51 }));
52
53 return (
54 <svg class="spark" width={W} height={H} viewBox={`0 0 ${W} ${H}`} role="img" aria-label={props.label} data-tip={props.label}>
55 <g ref={track}>
56 <path d={shape().area} fill={props.color} opacity="0.25" />
57 <path d={shape().line} fill="none" stroke={props.color} stroke-width="1.25" stroke-linejoin="round" />
58 </g>
59 </svg>
60 );
61}
dashboard/web/components/Status.tsx created+36
......@@ -0,0 +1,36 @@
1import type { JSX } from "solid-js";
2import type { Health } from "../types/model.ts";
3
4/** A service's health, or routine background work (a scan, a download) that isn't a service changing state. */
5export type StatusKind = Health | "working";
6
7/** Each state has its own shape so it survives without color; the in-between states share a spinner. */
8function shape(health: StatusKind) {
9 switch (health) {
10 case "healthy": return <circle cx="5" cy="5" r="4" fill="currentColor" />;
11 case "degraded": return <path d="M5 1 9.2 9H.8z" fill="currentColor" />;
12 case "down": return <rect x="1.2" y="1.2" width="7.6" height="7.6" rx="1" fill="currentColor" />;
13 case "stopped": return <circle cx="5" cy="5" r="3.4" fill="none" stroke="currentColor" stroke-width="1.6" />;
14 case "deploying": case "restarting": case "starting": case "working":
15 return <path d="M5 1.2a3.8 3.8 0 1 1-3.8 3.8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />;
16 }
17}
18
19export function Status(props: { health: StatusKind; label?: string }) {
20 const label = () => props.label ?? props.health;
21 return (
22 <svg class={`status ${props.health}`} viewBox="0 0 10 10" role="img" aria-label={label()} data-tip={label()}>
23 {shape(props.health)}
24 </svg>
25 );
26}
27
28/** The shape beside visible text, which defaults to the state's name. */
29export function StatusLabel(props: { health: StatusKind; children?: JSX.Element }) {
30 return (
31 <span class="status-label">
32 <svg class={`status ${props.health}`} viewBox="0 0 10 10" aria-hidden="true">{shape(props.health)}</svg>
33 {props.children ?? props.health}
34 </span>
35 );
36}
dashboard/web/components/Strip.tsx created+59
......@@ -0,0 +1,59 @@
1import { createMemo, createSignal, type JSX, Show } from "solid-js";
2import type { Series } from "../types/model.ts";
3import { lastGood, Loaded } from "./Loaded.tsx";
4import { type MetricQuery, useMetric } from "./Metric.tsx";
5import { type ChartProps, TimeChart } from "./TimeChart.tsx";
6
7/** A chart's current value for its corner; `tip` is the next thing worth knowing about it. */
8export type Reading = { value: JSX.Element; tip?: string; stale?: boolean };
9
10/** A chart stacked with others on one time axis and crosshair, which only the `last` one labels. */
11export function Strip(props: Omit<ChartProps, "series" | "colors" | "inline" | "sync" | "timeAxis"> & MetricQuery & {
12 title: string;
13 height: number;
14 /** Multiplies every value, e.g. to turn cores into percent of the machine. */
15 scale?: number;
16 tabs?: JSX.Element;
17 now: (latest: Series[]) => Reading | undefined;
18 last?: boolean;
19}) {
20 const { data, refetch, shape } = useMetric(props);
21 const latest = lastGood(data);
22 const [hovered, setHovered] = createSignal(false);
23 return (
24 <section class="strip" aria-label={props.title}>
25 <div class="strip-head">
26 <h3>{props.title}</h3>
27 {props.tabs}
28 <span class="rule" />
29 <Show when={props.now(latest() ?? [])}>
30 {(now) => (
31 <span class="now" classList={{ stale: now().stale || hovered() }}
32 tabIndex={now().tip ? 0 : undefined} data-tip={now().tip}>
33 {now().value}
34 </span>
35 )}
36 </Show>
37 </div>
38 <Loaded data={data} what={`${props.title} history`} retry={refetch}
39 skeleton={<div class="skeleton" style={{ height: `${props.height}px` }} />}>
40 {(loaded) => {
41 const shaped = createMemo(() => {
42 const scale = props.scale ?? 1;
43 const shaped = shape(loaded());
44 // A lone line goes by the chart's name, so its hovered value reads "51% cpu".
45 const series = shaped.series.map((item) => ({
46 ...item, name: shaped.series.length === 1 ? props.title : item.name, v: item.v.map((v) => v && v * scale),
47 }));
48 return { ...shaped, series };
49 });
50 return (
51 <TimeChart series={shaped().series} colors={shaped().colors} format={props.format} stacked={props.stacked}
52 max={props.max} zero={props.zero} height={props.height} inline sync="strips" timeAxis={!!props.last}
53 onHover={(index) => setHovered(index !== null)} />
54 );
55 }}
56 </Loaded>
57 </section>
58 );
59}
dashboard/web/components/TabBar.tsx created+40
......@@ -0,0 +1,40 @@
1import { type JSX, onCleanup, onMount } from "solid-js";
2
3/**
4 * A `.segmented` group of `aria-pressed` buttons, or links marked `aria-current="page"`, whose highlight slides to
5 * the chosen one. A copy of the labels in the highlight's text color is clipped to the highlight as it moves, so
6 * each letter takes its color from whatever is under it at that moment.
7 */
8export function TabBar(props: { label: string; children: JSX.Element }) {
9 let root!: HTMLDivElement;
10 let pill!: HTMLSpanElement;
11 let ink!: HTMLDivElement;
12 const place = () => {
13 ink.replaceChildren(...[...root.children].filter((el) => el !== pill && el !== ink).map((el) => el.cloneNode(true)));
14 const chosen = root.querySelector<HTMLElement>(':scope > :is([aria-pressed="true"], [aria-current="page"])');
15 pill.hidden = ink.hidden = !chosen;
16 if (!chosen) return;
17 pill.style.left = `${chosen.offsetLeft}px`;
18 pill.style.width = `${chosen.offsetWidth}px`;
19 const right = root.clientWidth - chosen.offsetLeft - chosen.offsetWidth;
20 ink.style.clipPath = `inset(2px ${right}px 2px ${chosen.offsetLeft}px round 7px)`;
21 };
22 onMount(() => {
23 place();
24 const changes = new MutationObserver((records) => records.some((record) => !ink.contains(record.target)) && place());
25 changes.observe(root, { subtree: true, childList: true, characterData: true, attributeFilter: ["aria-pressed", "aria-current"] });
26 const resizes = new ResizeObserver(place);
27 resizes.observe(root);
28 onCleanup(() => {
29 changes.disconnect();
30 resizes.disconnect();
31 });
32 });
33 return (
34 <div ref={root} class="segmented" role="group" aria-label={props.label}>
35 <span ref={pill} class="pill" aria-hidden="true" />
36 {props.children}
37 <div ref={ink} class="ink" aria-hidden="true" inert />
38 </div>
39 );
40}
dashboard/web/components/TimeChart.tsx created+219
......@@ -0,0 +1,219 @@
1import { createEffect, createSignal, Index, onCleanup, onMount, Show } from "solid-js";
2import uPlot from "uplot";
3import type { Series } from "../types/model.ts";
4import { bytes, clock, rate } from "../format.ts";
5
6/** Card chart height; a legend takes its bottom 26px. */
7const HEIGHT = 196;
8const minute = (t: number) => new Date(t * 1000).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
9const day = (t: number) => new Date(t * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" });
10/** Byte axes step by powers of two (4 GiB, 8 GiB), matching the binary units they're labelled in. */
11const BINARY = Array.from({ length: 60 }, (_, i) => 2 ** i);
12/** Synced charts share one y-axis width so their times line up. */
13const GUTTER = 56;
14/** Line height of an inline value; closer values are pushed apart. */
15const MARK = 16;
16const token = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
17/** Canvas can't resolve CSS variables, so `var(--x)` is read from the current theme. */
18const resolve = (color: string) => (color.startsWith("var(") ? token(color.slice(4, -1)) : color);
19
20export interface ChartProps {
21 series: Series[];
22 format: (value: number) => string;
23 /** Colors per series, hex or `var(--…)`; defaults to categorical slots in order. */
24 colors?: string[];
25 stacked?: boolean;
26 max?: number;
27 /** Total height in px, legend included; defaults to the card chart's. */
28 height?: number;
29 /** Defaults to showing when there is more than one series. */
30 legend?: boolean;
31 /** The hovered sample's index, null once the pointer leaves. */
32 onHover?: (index: number | null) => void;
33 /** Charts sharing a key share one crosshair. */
34 sync?: string;
35 /** Values float in the plot, beside the crosshair or at the right edge, instead of a legend below. */
36 inline?: boolean;
37 /** False keeps the time grid but drops its labels, for a chart stacked above one that shows them. */
38 timeAxis?: boolean;
39 /** False fits the y range to the data instead of starting at zero. */
40 zero?: boolean;
41}
42
43interface Mark {
44 name: string;
45 value: string;
46 color: string;
47 y: number;
48}
49
50export function TimeChart(props: ChartProps) {
51 let root!: HTMLDivElement;
52 let plot: uPlot | undefined;
53 let shape = "";
54 let raw: (number | null)[][] = [];
55 let keys: string[] = [];
56 const [view, setView] = createSignal<{ marks: Mark[]; x: number | null; flip: boolean; t: number | null }>(
57 { marks: [], x: null, flip: false, t: null });
58 const scheme = matchMedia("(prefers-color-scheme: dark)");
59 const [dark, setDark] = createSignal(scheme.matches);
60
61 /** Floats each series' value at its line, or its band's middle, for the hovered sample or else the latest. */
62 function place(u: uPlot) {
63 if (!props.inline) return;
64 const index = u.cursor.idx ?? null;
65 const height = u.over.clientHeight;
66 // At rest the frame shows the latest values, so the plot only names the lines, and a lone line needs no naming.
67 const found = index === null && raw.length === 1 ? [] : raw.flatMap((values, i) => {
68 const j = index ?? values.findLastIndex((v) => v != null);
69 const value = values[j];
70 if (value == null) return [];
71 const top = u.data[i + 1]![j]!;
72 const middle = props.stacked ? (top + (i ? u.data[i]![j] ?? 0 : 0)) / 2 : top;
73 const label = index === null ? "" : props.format(value);
74 return [{ value, mark: { name: String(u.series[i + 1]!.label), value: label, color: keys[i]!, y: u.valToPos(middle, "y") } }];
75 });
76 // The biggest values win the room there is, then overlapping ones are pushed apart and back inside.
77 const marks = found.sort((a, b) => b.value - a.value).slice(0, Math.floor(height / MARK))
78 .map((item) => item.mark).sort((a, b) => a.y - b.y);
79 marks.forEach((mark, i) => (mark.y = Math.max(mark.y, i ? marks[i - 1]!.y + MARK : MARK / 2)));
80 for (let i = marks.length - 1; i >= 0; i--) marks[i]!.y = Math.min(marks[i]!.y, (marks[i + 1]?.y ?? height + MARK / 2) - MARK);
81 const t = index === null ? null : u.data[0][index]!;
82 const x = t === null ? null : u.valToPos(t, "x");
83 setView({ marks, x, flip: x !== null && x > u.over.clientWidth - 170, t });
84 }
85
86 function build(series: Series[], colors: string[], data: uPlot.AlignedData) {
87 const legend = !props.inline && (props.legend ?? series.length > 1);
88 const axis = {
89 stroke: token("--muted"),
90 grid: { stroke: token("--grid"), width: 1 },
91 ticks: { show: false },
92 font: `11px ${token("--sans")}`,
93 };
94 const hex = (color: string, alpha: string) => (color.startsWith("#") ? color + alpha : color);
95 // uPlot also calls fill for the legend marker, before the plot box exists.
96 const fade = (color: string) => (u: uPlot) => {
97 if (!u.bbox.height) return color;
98 const gradient = u.ctx.createLinearGradient(0, u.bbox.top, 0, u.bbox.top + u.bbox.height);
99 gradient.addColorStop(0, hex(color, props.inline ? "66" : "38"));
100 gradient.addColorStop(1, hex(color, props.inline ? "0c" : "00"));
101 return gradient;
102 };
103 const smooth = uPlot.paths.spline!();
104 // Read now, since uPlot calls back outside Solid's tracking; a change to either rebuilds the chart.
105 const { max: top, zero } = props;
106 return new uPlot({
107 width: root.clientWidth,
108 height: (props.height ?? HEIGHT) - (legend ? 26 : 0),
109 cursor: { points: { size: 8 }, drag: { x: false, y: false }, sync: props.sync ? { key: props.sync } : undefined },
110 // Fixed edges: synced charts must line up, and uPlot otherwise pads each for its own last time label.
111 padding: props.sync ? [7, 24, props.timeAxis === false ? 7 : null, null] : undefined,
112 legend: { show: legend, live: true },
113 hooks: {
114 setCursor: [(u) => props.onHover?.(u.cursor.idx ?? null), place],
115 draw: [place],
116 },
117 scales: {
118 y: {
119 range: (_u, min, max) => zero === false
120 ? [min - (max - min) * 0.1 - 1, max + (max - min) * 0.1 + 1]
121 : [0, top ?? (max > 0 ? max * 1.1 : 1)],
122 },
123 },
124 axes: [
125 {
126 ...axis,
127 size: props.timeAxis === false ? 0 : 24,
128 values: (u, splits) => splits.map((t) => props.timeAxis === false ? ""
129 : u.scales.x!.max! - u.scales.x!.min! > 2 * 86400 ? day(t) : minute(t)),
130 },
131 {
132 ...axis,
133 incrs: props.format === bytes || props.format === rate ? BINARY : undefined,
134 // A round tick reads "4 GiB", not "4.0 GiB".
135 values: (_u, values) => values.map((v) => props.format(v).replace(/\.0(?=\D|$)/, "")),
136 space: props.inline ? 28 : undefined,
137 size: props.sync ? GUTTER : (u, values) => {
138 // uPlot caches the canvas font it last set, so a changed one must go back or later redraws inherit it.
139 const font = u.ctx.font;
140 u.ctx.font = axis.font;
141 const width = Math.max(0, ...(values ?? []).map((v) => u.ctx.measureText(v).width));
142 u.ctx.font = font;
143 return Math.ceil(width) + 10;
144 },
145 },
146 ],
147 series: [
148 { value: (_u, t) => (t == null ? "" : clock(t)) },
149 ...series.map((item, i) => ({
150 label: item.name,
151 stroke: colors[i],
152 width: props.stacked ? 1 : 2,
153 fill: props.stacked ? hex(colors[i]!, "55") : series.length === 1 ? fade(colors[i]!) : undefined,
154 paths: smooth,
155 points: { show: false },
156 value: (_u: uPlot, _v: number | null, _si: number, index: number | null) => {
157 const value = index == null ? raw[i]!.at(-1) : raw[i]![index];
158 return value == null ? "–" : props.format(value);
159 },
160 })),
161 ],
162 bands: props.stacked ? series.slice(1).map((_, i) => ({ series: [i + 2, i + 1] as [number, number] })) : [],
163 }, data, root);
164 }
165
166 const values = (
167 <div class="values" classList={{ hover: view().x !== null, flip: view().flip }}
168 style={{ left: view().x === null ? undefined : `${view().x}px` }}>
169 <Index each={view().marks}>
170 {(mark) => (
171 <span style={{ top: `${mark().y}px`, "--key": mark().color }}>
172 <Show when={mark().value}><b>{mark().value}</b></Show>{mark().name}
173 </span>
174 )}
175 </Index>
176 <Show when={props.timeAxis !== false && view().t}>
177 {(t) => <time>{props.series[0]!.t.at(-1)! - props.series[0]!.t[0]! > 86400 ? `${day(t())}, ${minute(t())}` : minute(t())}</time>}
178 </Show>
179 </div>
180 ) as HTMLDivElement;
181
182 // New data only calls setData, which keeps the hover; a new set of series, colors, or theme rebuilds.
183 createEffect(() => {
184 dark();
185 const series = props.series;
186 const colors = (props.colors ?? series.map((_, i) => `var(--series-${i + 1})`)).map(resolve);
187 raw = series.map((item) => item.v);
188 keys = colors;
189 const shown = props.stacked
190 ? raw.map((_, i) => raw[0]!.map((_, j) => raw.slice(0, i + 1).reduce((sum, values) => sum + (values[j] ?? 0), 0)))
191 : raw;
192 const data = (series.length ? [series[0]!.t, ...shown] : []) as uPlot.AlignedData;
193 const next = JSON.stringify([series.map((item) => item.name), colors, props.stacked, props.height, props.legend, props.max,
194 props.inline, props.timeAxis, props.zero, props.sync, dark()]);
195 if (plot && next === shape) return plot.setData(data);
196 // Holds the old chart's height until uPlot sizes the new one, a microtask later, so a layout in between can't
197 // shrink the page and clamp its scroll (WebKit does, Chrome waits).
198 root.style.minHeight = `${root.offsetHeight}px`;
199 requestAnimationFrame(() => (root.style.minHeight = ""));
200 plot?.destroy();
201 plot = series.length ? build(series, colors, data) : undefined;
202 if (plot && props.inline) plot.over.append(values);
203 shape = next;
204 });
205
206 onMount(() => {
207 const observer = new ResizeObserver(() => plot?.setSize({ width: root.clientWidth, height: plot.height }));
208 observer.observe(root);
209 const change = () => setDark(scheme.matches);
210 scheme.addEventListener("change", change);
211 onCleanup(() => {
212 observer.disconnect();
213 scheme.removeEventListener("change", change);
214 plot?.destroy();
215 });
216 });
217
218 return <div class="chart" ref={root} />;
219}
dashboard/web/components/Toast.tsx created+61
......@@ -0,0 +1,61 @@
1import X from "lucide-solid/icons/x";
2import { createSignal, Show } from "solid-js";
3import { reason } from "../api.ts";
4
5interface Shown {
6 text: string;
7 action?: { label: string; run: () => Promise<unknown> };
8}
9
10const [shown, setShown] = createSignal<Shown>();
11let timer: ReturnType<typeof setTimeout> | undefined;
12
13const dismiss = () => {
14 clearTimeout(timer);
15 setShown();
16};
17const linger = () => {
18 clearTimeout(timer);
19 timer = setTimeout(dismiss, 8000);
20};
21
22/** Replaces the current toast; `action` is for undo and the like, and its failure replaces the toast with the reason. */
23export function toast(text: string, action?: Shown["action"]) {
24 setShown({ text, action });
25 linger();
26}
27
28/** The one live region toasts appear in; mount once. */
29export function Toasts() {
30 const [busy, setBusy] = createSignal(false);
31 const run = async (action: NonNullable<Shown["action"]>) => {
32 setBusy(true);
33 try {
34 await action.run();
35 dismiss();
36 } catch (failure) {
37 toast(reason(failure));
38 } finally {
39 setBusy(false);
40 }
41 };
42 return (
43 <div class="toasts" role="status" onPointerEnter={() => clearTimeout(timer)} onPointerLeave={() => shown() && linger()}>
44 <Show when={shown()} keyed>
45 {(current) => (
46 <div class="toast">
47 <span>{current.text}</span>
48 <Show when={current.action}>
49 {(action) => (
50 <button class="button small" disabled={busy()} aria-busy={busy()} onClick={() => run(action())}>
51 {action().label}
52 </button>
53 )}
54 </Show>
55 <button class="close" aria-label="Dismiss" onClick={dismiss}><X size={14} /></button>
56 </div>
57 )}
58 </Show>
59 </div>
60 );
61}
dashboard/web/components/Tooltip.tsx created+96
......@@ -0,0 +1,96 @@
1import { createSignal, onCleanup, onMount } from "solid-js";
2import { Portal } from "solid-js/web";
3
4type Anchor = HTMLElement | SVGElement;
5
6const GAP = 8;
7const EDGE = 6;
8/** The innermost tip wins; an empty one defers to the tip around it. */
9const TIPPED = "[data-tip]:not([data-tip=''])";
10
11/**
12 * Shows the `data-tip` of whatever is hovered or keyboard-focused, disabled buttons included; mount once. It's a
13 * popover so it stays above modal dialogs and menus in the top layer.
14 */
15export function Tooltips() {
16 const [text, setText] = createSignal("");
17 const [place, setPlace] = createSignal({ x: 0, y: 0, arrow: 0, side: "top" });
18 let tip!: HTMLDivElement;
19 let anchor: Anchor | null = null;
20 let described = false;
21 // Keeps following the last anchor after hiding, so a click that changes its tip ("Copied") shows it again.
22 const watch = new MutationObserver(() => {
23 if (anchor?.matches(":hover, :focus-visible")) show(anchor);
24 });
25
26 function show(target: Anchor) {
27 const next = target.dataset.tip;
28 if (!next) return hide();
29 if (target !== anchor) {
30 hide();
31 watch.disconnect();
32 watch.observe(target, { attributeFilter: ["data-tip"] });
33 anchor = target;
34 }
35 if (!described && !anchor.hasAttribute("aria-describedby") && anchor.getAttribute("aria-label") !== next) {
36 anchor.setAttribute("aria-describedby", "tooltip");
37 described = true;
38 }
39 setText(next);
40 if (!tip.matches(":popover-open")) tip.showPopover();
41 // Balanced lines leave the box as wide as the longest unbalanced one; shrink it to the widest line so it centers.
42 tip.style.width = "";
43 const lines = document.createRange();
44 lines.selectNodeContents(tip);
45 const scale = tip.getBoundingClientRect().width / tip.offsetWidth;
46 const widest = Math.max(...[...lines.getClientRects()].map((line) => line.width)) / scale;
47 tip.style.width = `${Math.ceil(widest)}px`;
48 const box = target.getBoundingClientRect();
49 const center = box.left + box.width / 2;
50 const x = Math.min(Math.max(EDGE, center - tip.offsetWidth / 2), innerWidth - tip.offsetWidth - EDGE);
51 const above = box.top - tip.offsetHeight - GAP;
52 setPlace({
53 x,
54 y: above >= EDGE ? above : box.bottom + GAP,
55 arrow: Math.min(Math.max(10, center - x), tip.offsetWidth - 10),
56 side: above >= EDGE ? "top" : "bottom",
57 });
58 }
59
60 function hide() {
61 if (tip.matches(":popover-open")) tip.hidePopover();
62 if (described) anchor?.removeAttribute("aria-describedby");
63 described = false;
64 }
65
66 onMount(() => {
67 const over = (event: Event) => {
68 const target = (event.target as Element).closest<Anchor>(TIPPED);
69 if (target) show(target);
70 else hide();
71 };
72 const out = (event: PointerEvent) => !event.relatedTarget && hide();
73 const focus = (event: Event) => {
74 const target = (event.target as Element).closest<Anchor>(TIPPED);
75 if (target?.matches(":focus-visible")) show(target);
76 };
77 const key = (event: KeyboardEvent) => event.key === "Escape" && hide();
78 const listeners = [["pointerover", over], ["pointerout", out], ["focusin", focus], ["focusout", hide], ["pointerdown", hide], ["keydown", key],
79 ["scroll", hide]] as const;
80 for (const [type, listener] of listeners) document.addEventListener(type, listener as EventListener, true);
81 onCleanup(() => {
82 for (const [type, listener] of listeners) document.removeEventListener(type, listener as EventListener, true);
83 watch.disconnect();
84 });
85 });
86
87 return (
88 <Portal>
89 <div ref={tip} id="tooltip" role="tooltip" class="tooltip" popover="manual" data-side={place().side}
90 style={{ left: `${place().x}px`, top: `${place().y}px`, "--arrow": `${place().arrow}px` }}>
91 {/* A path or URL has no spaces, so it may wrap after any slash, and balancing picks the middle one. */}
92 {/\s/.test(text()) ? text() : text().replaceAll("/", "/\u200b")}
93 </div>
94 </Portal>
95 );
96}
dashboard/web/components/VirtualList.tsx created+163
......@@ -0,0 +1,163 @@
1import { createEffect, createSignal, For, type JSX, on, onCleanup, onMount } from "solid-js";
2
3/** Rows rendered past each edge of the view, in px. */
4const OVERSCAN = 600;
5
6/**
7 * A scroller that renders only the rows near the view. Rows take any height: each is estimated until it renders,
8 * then measured, so soft-wrapped text works and resizing re-wraps. The row at the top of the view stays put while
9 * heights settle or rows arrive above it; with `follow`, the end stays in view instead. Focusable, so the keyboard
10 * scrolls it natively.
11 */
12export function VirtualList<T extends object>(props: {
13 items: readonly T[];
14 /** A row's height in px at the list's content width, used until the row has been measured at that width. */
15 estimate: (item: T, width: number) => number;
16 /** Keeps the end in view; the list reports scrolling to or away from the end through `onFollow`. */
17 follow: boolean;
18 onFollow: (atEnd: boolean) => void;
19 /** Called while the view is within a screen of the start. */
20 onStart?: () => void;
21 label: string;
22 class?: string;
23 children: (item: T, index: () => number) => JSX.Element;
24}) {
25 let scroller!: HTMLDivElement;
26 let spacer!: HTMLDivElement;
27 let rows!: HTMLDivElement;
28 let width = 0;
29 let heights = new Float64Array(0);
30 /** `tops[i]` is where row i starts; `tops[n]` is the total height. */
31 let tops = new Float64Array(1);
32 let anchor: { item: T; offset: number } | undefined;
33 /** Where `place` last scrolled to, so the scroll event it causes isn't taken for the user leaving the end. */
34 let placed = -1;
35 let frame = 0;
36 const measured = new WeakMap<T, { width: number; height: number }>();
37 const rowOf = new WeakMap<Element, { item: T; index: () => number }>();
38 /** Set only by `updateRange`, so rows never render from new items with a stale start. */
39 const [view, setView] = createSignal<{ start: number; items: readonly T[] }>({ start: 0, items: [] });
40
41 const sum = (from: number) => {
42 for (let i = from; i < heights.length; i++) tops[i + 1] = tops[i]! + heights[i]!;
43 };
44 const rebuild = () => {
45 const items = props.items;
46 heights = new Float64Array(items.length);
47 tops = new Float64Array(items.length + 1);
48 items.forEach((item, i) => {
49 const known = measured.get(item);
50 heights[i] = known?.width === width ? known.height : props.estimate(item, width);
51 });
52 sum(0);
53 };
54 /** The last row starting at or above `y`. */
55 const rowAt = (y: number) => {
56 let low = 0;
57 let high = heights.length - 1;
58 while (low < high) {
59 const mid = (low + high + 1) >> 1;
60 if (tops[mid]! <= y) low = mid;
61 else high = mid - 1;
62 }
63 return Math.max(0, low);
64 };
65 const updateRange = () => {
66 cancelAnimationFrame(frame);
67 const top = scroller.scrollTop;
68 const start = rowAt(top - OVERSCAN);
69 const end = Math.min(heights.length, rowAt(top + scroller.clientHeight + OVERSCAN) + 1);
70 rows.style.transform = `translateY(${tops[start]}px)`;
71 const { items } = view();
72 if (start === view().start && end - start === items.length && items[0] === props.items[start]) return;
73 setView({ start, items: props.items.slice(start, end) });
74 };
75 /** Scrolls to the end when following, else back to the anchor row, and lays out the rows for there. */
76 const place = () => {
77 spacer.style.height = `${tops[heights.length]}px`;
78 const index = anchor ? props.items.indexOf(anchor.item) : -1;
79 if (props.follow || index === -1) scroller.scrollTop = scroller.scrollHeight;
80 else scroller.scrollTop = tops[index]! + anchor!.offset;
81 placed = scroller.scrollTop;
82 // Rows rendered here get measured next frame; rendering them inside a resize callback would loop.
83 cancelAnimationFrame(frame);
84 frame = requestAnimationFrame(updateRange);
85 };
86
87 const observer = new ResizeObserver((entries) => {
88 let from = Infinity;
89 for (const entry of entries) {
90 if (entry.target === scroller) {
91 const next = entry.contentBoxSize[0]!.inlineSize;
92 if (next === width) continue;
93 width = next;
94 rebuild();
95 from = -1;
96 continue;
97 }
98 const row = rowOf.get(entry.target);
99 if (!row) continue;
100 const height = entry.borderBoxSize[0]!.blockSize;
101 measured.set(row.item, { width, height });
102 const index = row.index();
103 if (heights[index] === height) continue;
104 heights[index] = height;
105 from = Math.min(from, index);
106 }
107 if (from === Infinity) return;
108 if (from >= 0) sum(from);
109 place();
110 });
111
112 const onScroll = () => {
113 const top = scroller.scrollTop;
114 const index = rowAt(top);
115 anchor = props.items[index] && { item: props.items[index], offset: top - tops[index]! };
116 // Against the laid-out height, since rows not yet measured can overhang it.
117 const atEnd = tops[heights.length]! - top - scroller.clientHeight < 2;
118 updateRange();
119 const ours = top === placed;
120 placed = -1;
121 if (atEnd !== props.follow && !ours) props.onFollow(atEnd);
122 if (top < scroller.clientHeight) props.onStart?.();
123 };
124
125 onMount(() => {
126 observer.observe(scroller);
127 width = scroller.clientWidth;
128 });
129 createEffect(on(() => props.items, () => {
130 rebuild();
131 place();
132 updateRange();
133 }));
134 createEffect(on(() => props.follow, (follow) => follow && place(), { defer: true }));
135 onCleanup(() => {
136 observer.disconnect();
137 cancelAnimationFrame(frame);
138 });
139
140 return (
141 <div ref={scroller} class={props.class} style={{ "overflow-y": "auto", "overflow-anchor": "none" }} tabIndex={0}
142 aria-label={props.label} onScroll={onScroll}>
143 <div ref={spacer}>
144 <div ref={rows}>
145 <For each={view().items}>
146 {(item, i) => {
147 const index = () => view().start + i();
148 return (
149 <div ref={(el) => {
150 rowOf.set(el, { item, index });
151 observer.observe(el);
152 onCleanup(() => observer.unobserve(el));
153 }}>
154 {props.children(item, index)}
155 </div>
156 );
157 }}
158 </For>
159 </div>
160 </div>
161 </div>
162 );
163}
dashboard/web/format.ts created+51
......@@ -0,0 +1,51 @@
1const UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
2
3/** Binary units, matching what ZFS and the kernel report. */
4export function bytes(value: number) {
5 let unit = 0;
6 while (Math.abs(value) >= 1024 && unit < UNITS.length - 1) {
7 value /= 1024;
8 unit++;
9 }
10 return `${value.toFixed(unit === 0 ? 0 : value < 10 ? 1 : 0)} ${UNITS[unit]}`;
11}
12
13export const rate = (value: number) => bytes(value) + "/s";
14export const percent = (value: number) => `${value.toFixed(Number.isInteger(value) || value >= 10 ? 0 : 1)}%`;
15export const cores = (value: number) => value.toFixed(value < 10 ? 2 : 1);
16export const watts = (value: number) => `${Math.round(value)} W`;
17export const count = (value: number) => value.toLocaleString();
18export const plural = (n: number, one: string, many = one + "s") => `${count(n)} ${n === 1 ? one : many}`;
19export const celsius = (value: number) => `${Math.round(value)} °C`;
20
21const SPANS = [["mo", 30 * 86400], ["d", 86400], ["h", 3600], ["m", 60]] as const;
22
23/** Up to two units and no seconds past the first minute: "45s", "12m", "3h 5m", "19d 5h", "3mo 22d". */
24export function duration(seconds: number) {
25 const first = SPANS.findIndex(([, size]) => seconds >= size);
26 if (first === -1) return `${Math.floor(Math.max(0, seconds))}s`;
27 const [label, size] = SPANS[first]!;
28 const next = SPANS[first + 1];
29 const rest = next ? Math.floor((seconds % size) / next[1]) : 0;
30 return `${Math.floor(seconds / size)}${label}${rest ? ` ${rest}${next![0]}` : ""}`;
31}
32
33/** One unit while under a week old ("22h ago"), then the date. */
34export function ago(t: number) {
35 const seconds = Date.now() / 1000 - t;
36 if (seconds >= 7 * 86400) return date(t);
37 const unit = SPANS.find(([, size]) => seconds >= size);
38 return unit ? `${Math.floor(seconds / unit[1])}${unit[0]} ago` : "just now";
39}
40
41/** How long until `t`, from `now`: "in 3h 5m", or "soon" once it's due. */
42export function until(t: number, now = Date.now() / 1000) {
43 return t > now ? `in ${duration(t - now)}` : "soon";
44}
45
46export function clock(t: number) {
47 return new Date(t * 1000).toLocaleTimeString(undefined, { hour12: false });
48}
49
50export const date = (t: number) => new Date(t * 1000).toLocaleDateString(undefined, { dateStyle: "medium" });
51export const datetime = (t: number) => new Date(t * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
dashboard/web/index.html created+16
......@@ -0,0 +1,16 @@
1<!doctype html>
2<html lang="en">
3<head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <meta name="color-scheme" content="dark light">
7 <title>the snow globe</title>
8 <link rel="icon" href="./snowflake.svg">
9 <!-- Name Sans and Name Mono, copied locally and never committed; system fonts stand in without them. -->
10 <link rel="stylesheet" href="/fonts/font.css">
11</head>
12<body>
13 <div id="root"></div>
14 <script type="module" src="./main.tsx"></script>
15</body>
16</html>
dashboard/web/live.ts created+83
......@@ -0,0 +1,83 @@
1import { batch, createRoot, createSignal } from "solid-js";
2import { LIVE_INTERVAL, type Live, type Series } from "./types/model.ts";
3import { parseResponse } from "hono/client";
4import { api } from "./api.ts";
5
6/** Samples per sidebar sparkline: five minutes of ticks. */
7const WINDOW = 150;
8
9function push(values: number[], value: number | null) {
10 if (value === null) return values;
11 const next = values.length >= WINDOW ? values.slice(1) : values.slice();
12 next.push(value);
13 return next;
14}
15
16function seed(series: Series[]) {
17 return Object.fromEntries(series.map((item) => [item.name, item.v.map((v) => v ?? 0).slice(-WINDOW)]));
18}
19
20/** One shared stream for every view; starts on first use by an admin page and reconnects on its own. */
21export const stream = createRoot(() => {
22 const [latest, setLatest] = createSignal<Live | null>(null);
23 const [trails, setTrails] = createSignal<Record<string, { cpu: number[]; memory: number[] }>>({});
24 /** Samples appended to the trails so far, counting the seed; sparklines align their buckets to it. */
25 const [count, setCount] = createSignal(0);
26 /** False until the first tick arrives and again while reconnecting, so views can show the numbers are stale. */
27 const [live, setLive] = createSignal(false);
28 let started = false;
29 let failures = 0;
30 let stale: ReturnType<typeof setTimeout> | undefined;
31
32 function retry() {
33 setLive(false);
34 setTimeout(connect, Math.min(30, 2 ** failures++) * 1000);
35 }
36
37 // EventSource gives up for good on a non-200 answer, like Vite's 502 while the server restarts.
38 function connect() {
39 const events = new EventSource("/api/live");
40 events.onmessage = (event) => {
41 const tick: Live = JSON.parse(event.data);
42 failures = 0;
43 clearTimeout(stale);
44 const remaining = tick.t * 1000 + LIVE_INTERVAL * 3_000 - Date.now();
45 stale = setTimeout(() => setLive(false), Math.max(0, remaining));
46 batch(() => {
47 setLive(remaining > 0);
48 setLatest(tick);
49 setCount(count() + 1);
50 setTrails((previous) => Object.fromEntries(Object.entries(tick.services).map(([id, sample]) => {
51 const trail = previous[id] ?? { cpu: [], memory: [] };
52 return [id, { cpu: push(trail.cpu, sample.cpu), memory: push(trail.memory, sample.memory) }];
53 })));
54 });
55 };
56 events.onerror = () => {
57 events.close();
58 retry();
59 };
60 }
61
62 function start() {
63 if (started) return;
64 started = true;
65 connect();
66 const history = (metric: "service.cpu" | "service.memory") =>
67 parseResponse(api.metrics[":metric"].$get({ query: { range: String(WINDOW * LIVE_INTERVAL) }, param: { metric } })).catch(() => []);
68 void Promise.all([history("service.cpu"), history("service.memory")]).then(([cpu, memory]) => {
69 const cpuTrail = seed(cpu);
70 const memoryTrail = seed(memory);
71 batch(() => {
72 setTrails((previous) => ({ ...previous, ...Object.fromEntries(Object.keys(cpuTrail).map((id) =>
73 [id, {
74 cpu: [...cpuTrail[id]!, ...(previous[id]?.cpu ?? [])].slice(-WINDOW),
75 memory: [...(memoryTrail[id] ?? []), ...(previous[id]?.memory ?? [])].slice(-WINDOW),
76 }])) }));
77 setCount(count() + (Object.values(cpuTrail)[0]?.length ?? 0));
78 });
79 });
80 }
81
82 return { latest, trails, count, live, start };
83});
dashboard/web/main.tsx created+87
......@@ -0,0 +1,87 @@
1import { Route, Router, type RouteSectionProps, useLocation } from "@solidjs/router";
2import Eye from "lucide-solid/icons/eye";
3import { createResource, Show } from "solid-js";
4import { render } from "solid-js/web";
5import { parseResponse } from "hono/client";
6import { api, queries } from "./api.ts";
7import { Loaded } from "./components/Loaded.tsx";
8import { PAGES, Sidebar, viewAs } from "./components/Sidebar.tsx";
9import { Toasts } from "./components/Toast.tsx";
10import { Tooltips } from "./components/Tooltip.tsx";
11import { Overview } from "./pages/Overview.tsx";
12import { Service } from "./pages/Service.tsx";
13import { Storage } from "./pages/Storage.tsx";
14import { Media } from "./pages/Media.tsx";
15import { Seedbox } from "./pages/Seedbox.tsx";
16import { YouTube } from "./pages/YouTube.tsx";
17import { PaperClover } from "./pages/PaperClover.tsx";
18import { directory, Users } from "./pages/Users.tsx";
19import { Deploys } from "./pages/Deploys.tsx";
20import { Deploy } from "./pages/Deploy.tsx";
21import { VMs } from "./pages/VMs.tsx";
22import { MCP } from "./pages/MCP.tsx";
23import { Account } from "./pages/Account.tsx";
24import "./styles.css";
25
26const [me, { refetch }] = createResource(() => parseResponse(api.me.$get()));
27
28/** A route preload, which the router also runs on link hover, starting each query's first load. */
29const preload = (...list: { preload(): void }[]) => () => list.forEach((query) => query.preload());
30
31function Shell(props: RouteSectionProps) {
32 const location = useLocation();
33 const section = () => location.pathname.startsWith("/services/") ? "admin"
34 : PAGES.find((page) => page.href !== "/" && location.pathname.startsWith(page.href))?.section;
35 return (
36 <>
37 <Loaded data={me} what="your account" retry={refetch} skeleton={<div class="shell"><div class="sidebar" /><main class="main" /></div>}>
38 {(user) => (
39 <div class="shell">
40 <Show when={user().viewing}>
41 <div class="viewing-as" role="status">
42 <Eye size={15} />
43 <span>previewing as <b>{user().groups.length ? `someone in ${user().groups.join(", ")}` : "a friend in no groups"}</b></span>
44 <button class="button small" onClick={() => viewAs(null)}>back to admin</button>
45 </div>
46 </Show>
47 <Sidebar me={user()} />
48 <main class="main">
49 <Show when={!section() || user().sections.includes(section()!)} fallback={
50 <div class="page empty">Your account can't open this page. Ask an admin to add you to its group.</div>
51 }>
52 {props.children}
53 </Show>
54 </main>
55 </div>
56 )}
57 </Loaded>
58 <Tooltips />
59 <Toasts />
60 </>
61 );
62}
63
64render(() => (
65 <Router root={Shell}>
66 <Route path="/" component={() => <Overview me={me.latest!} />} preload={() => {
67 queries.launcher.preload();
68 if (me.latest?.sections.includes("metrics")) preload(queries.host, queries.storage, queries.services)();
69 }} />
70 <Route path="/services/:id/:tab?" component={Service} preload={({ params }) => {
71 queries.service.preload(params.id!);
72 queries.deploys.preload();
73 }} />
74 <Route path="/storage" component={Storage} preload={preload(queries.storage, queries.launcher)} />
75 <Route path="/media" component={Media} preload={preload(queries.launcher)} />
76 <Route path="/seedbox" component={Seedbox} preload={preload(queries.seedbox, queries.seedboxHistory, queries.launcher)} />
77 <Route path="/youtube" component={YouTube} />
78 <Route path="/paper-clover" component={PaperClover} preload={preload(queries.paperClover, queries.paperCloverActivity)} />
79 <Route path="/users/:name?" component={Users} preload={preload(directory)} />
80 <Route path="/deploys" component={Deploys} preload={preload(queries.deploys, queries.services)} />
81 <Route path="/deploys/:id/:tab?" component={Deploy} preload={preload(queries.deploys, queries.services)} />
82 <Route path="/vms" component={VMs} preload={preload(queries.vms)} />
83 <Route path="/mcp" component={MCP} />
84 <Route path="/account" component={Account} preload={preload(queries.launcher)} />
85 <Route path="*" component={() => <div class="empty">No page here</div>} />
86 </Router>
87), document.getElementById("root")!);
dashboard/web/pages/Account.css created+24
......@@ -0,0 +1,24 @@
1.avatar:is(img, .monogram) { flex: none; border-radius: 50%; object-fit: cover; }
2.avatar.monogram { background: var(--accent-wash); color: var(--accent); }
3
4.account-grid { display: flex; flex-wrap: wrap; gap: 8px; align-items: start; }
5.account-grid > * { flex: 1 1 320px; min-width: 0; }
6.account-grid .card { padding: 12px 14px; }
7.account-grid h2.card-title { margin: 0 0 6px; color: var(--text); }
8
9.picture-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
10.picture-row .avatar { width: 64px; height: 64px; margin-right: 6px; font-size: 26px; }
11.picture-row + .error { margin: -4px 0 10px; font-size: 13px; }
12
13.account-fields, .credentials { display: grid; align-items: center; font-size: 13px; }
14.account-fields { grid-template-columns: max-content 1fr; gap: 6px 14px; --control: 28px; }
15.account-fields .label, .credentials .label { color: var(--muted); }
16.account-fields .error { margin: 0; }
17.account-fields .email { display: flex; align-items: center; gap: 8px; min-height: var(--control); }
18.account-fields .email .address { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
19.account-fields .actions { display: flex; justify-content: flex-end; gap: 6px; }
20
21.credentials { grid-template-columns: max-content 1fr auto; gap: 4px 14px; }
22.credentials > .button { justify-self: end; }
23.credentials .passkey { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
24.picture-row .button:has(:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
dashboard/web/pages/Account.tsx created+233
......@@ -0,0 +1,233 @@
1import { useNavigate, useSearchParams } from "@solidjs/router";
2import { createResource, createSignal, For, onMount, Show } from "solid-js";
3import { parseResponse } from "hono/client";
4import type { User } from "../types/users.ts";
5import { api, queries, reason } from "../api.ts";
6import { Ago } from "../components/Ago.tsx";
7import { lastGood, Loaded } from "../components/Loaded.tsx";
8import { OpenApp } from "../components/OpenApp.tsx";
9import { Reveal } from "../components/Reveal.tsx";
10import { toast } from "../components/Toast.tsx";
11import "./Account.css";
12
13/** The signed-in user's Keycloak record, shared with the sidebar corner. */
14export const [account, { refetch: refetchAccount }] = createResource(() => parseResponse(api.account.$get()));
15
16export const displayName = (user: Pick<User, "username" | "firstName" | "lastName">) =>
17 [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username;
18
19export function Avatar(props: { picture: string | null; name: string }) {
20 return (
21 <Show when={props.picture} fallback={<span class="avatar monogram" aria-hidden="true">{props.name.slice(0, 1).toUpperCase()}</span>}>
22 {(src) => <img class="avatar" src={src()} alt="" />}
23 </Show>
24 );
25}
26
27const PICTURE_SIZE = 256;
28const FIELDS = ["firstName", "lastName"] as const;
29
30const DONE: Record<string, string> = {
31 "webauthn-register-passwordless": "Added a passkey",
32 UPDATE_PASSWORD: "Changed your password",
33 UPDATE_EMAIL: "Sent a link to confirm your new email",
34 delete_credential: "Removed the passkey",
35};
36
37/** Center-crops to a square and encodes WebP, or PNG where the browser can't encode WebP; null if it can't read the file. */
38async function square(file: File) {
39 const image = await createImageBitmap(file).catch(() => null);
40 if (!image) return null;
41 const side = Math.min(image.width, image.height);
42 const canvas = Object.assign(document.createElement("canvas"), { width: PICTURE_SIZE, height: PICTURE_SIZE });
43 canvas.getContext("2d")!.drawImage(image, (image.width - side) / 2, (image.height - side) / 2, side, side,
44 0, 0, PICTURE_SIZE, PICTURE_SIZE);
45 image.close();
46 const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/webp", 0.85));
47 return blob && new File([blob], "picture", { type: blob.type });
48}
49
50export function Account() {
51 const [params] = useSearchParams<{ kc_action?: string; kc_action_status?: string }>();
52 const navigate = useNavigate();
53 const apps = lastGood(queries.launcher.use()[0]);
54 onMount(() => {
55 const { kc_action: action, kc_action_status: status } = params;
56 if (!status) return;
57 if (status === "success" && action) toast(DONE[action] ?? "Done");
58 if (status === "error") toast("Keycloak couldn't finish that. Try again.");
59 navigate("/account", { replace: true });
60 });
61
62 return (
63 <div class="page account-page">
64 <div class="page-head">
65 <h1>profile</h1>
66 <Show when={!account.error && account.latest?.console}>
67 {(href) => <OpenApp app={apps()?.find((app) => app.id === "keycloak") ?? { id: "keycloak", name: "Keycloak", icon: null }} href={href()} />}
68 </Show>
69 </div>
70 <Loaded data={account} what="your profile" retry={refetchAccount} skeleton={
71 <div class="account-grid">
72 <div class="skeleton" style={{ height: "252px" }} />
73 <div class="skeleton" style={{ height: "160px" }} />
74 </div>
75 }>
76 {(user) => (
77 <div class="account-grid">
78 <Profile user={user()} />
79 <SignIn user={user()} />
80 </div>
81 )}
82 </Loaded>
83 </div>
84 );
85}
86
87type Self = NonNullable<typeof account.latest>;
88
89function Profile(props: { user: Self }) {
90 const [dirty, setDirty] = createSignal(false);
91 const [pending, setPending] = createSignal<"save" | "picture" | "verify">();
92 const [error, setError] = createSignal("");
93 const [pictureError, setPictureError] = createSignal("");
94 const inputs = {} as Record<(typeof FIELDS)[number], HTMLInputElement>;
95
96 const changes = () => Object.fromEntries(FIELDS.flatMap((name) => {
97 const value = inputs[name].value.trim();
98 return value === (props.user[name] ?? "") ? [] : [[name, value]];
99 }));
100 const reset = () => {
101 for (const name of FIELDS) inputs[name].value = props.user[name] ?? "";
102 setDirty(false);
103 setError("");
104 };
105
106 const busy = async (key: "save" | "picture" | "verify", work: () => Promise<unknown>, fail = setError) => {
107 setPending(key);
108 fail("");
109 try {
110 await work();
111 } catch (failure) {
112 fail(reason(failure));
113 } finally {
114 setPending();
115 }
116 };
117
118 const save = (event: SubmitEvent) => {
119 event.preventDefault();
120 if (pending() === "save") return;
121 const json = changes();
122 if (!Object.keys(json).length) return reset();
123 return busy("save", async () => {
124 await parseResponse(api.account.$patch({ json }));
125 await refetchAccount();
126 reset();
127 });
128 };
129
130 const upload = async (file: File) => {
131 const picture = await square(file);
132 if (!picture) return setPictureError("That file isn't an image this browser can open. Pick a PNG, JPEG, or WebP.");
133 await busy("picture", async () => {
134 await parseResponse(api.account.picture.$put({ form: { picture } }));
135 await refetchAccount();
136 }, setPictureError);
137 };
138
139 const removePicture = () => busy("picture", async () => {
140 await parseResponse(api.account.picture.$delete());
141 await refetchAccount();
142 }, setPictureError);
143
144 const resend = () => busy("verify", async () => {
145 await parseResponse(api.account["verify-email"].$post());
146 toast(`Sent a link to ${props.user.email}`);
147 });
148
149 return (
150 <section class="card">
151 <div class="picture-row">
152 <Avatar picture={props.user.picture} name={displayName(props.user)} />
153 <label class="button small" aria-busy={pending() === "picture"}>
154 {props.user.picture ? "replace picture" : "add picture"}
155 <input type="file" accept="image/*" class="sr-only" disabled={pending() === "picture"}
156 onChange={(event) => {
157 const file = event.currentTarget.files?.[0];
158 event.currentTarget.value = "";
159 if (file) upload(file);
160 }} />
161 </label>
162 <Show when={props.user.picture}>
163 <button class="button small" disabled={pending() === "picture"} onClick={removePicture}>remove</button>
164 </Show>
165 </div>
166 <Show when={pictureError()}><p class="error" role="alert">{pictureError()}</p></Show>
167
168 <form class="account-fields" onSubmit={save} onInput={() => setDirty(Object.keys(changes()).length > 0)}
169 onKeyDown={(event) => event.key === "Escape" && dirty() && reset()}>
170 <span class="label">username</span>
171 <span class="username">{props.user.username}</span>
172 <label class="label" for="first-name">first name</label>
173 <input id="first-name" ref={inputs.firstName} class="search" value={props.user.firstName ?? ""} autocomplete="given-name"
174 readOnly={pending() === "save"} />
175 <label class="label" for="last-name">last name</label>
176 <input id="last-name" ref={inputs.lastName} class="search" value={props.user.lastName ?? ""} autocomplete="family-name"
177 readOnly={pending() === "save"} />
178 <span class="label">email</span>
179 <span class="email">
180 <span class="address">{props.user.email ?? <span class="muted">none</span>}</span>
181 <Show when={props.user.email && !props.user.emailVerified}>
182 <span class="chip warn">unverified</span>
183 <button type="button" class="button small" disabled={pending() === "verify"} aria-busy={pending() === "verify"}
184 onClick={resend}>resend link</button>
185 </Show>
186 <a class="button small" href="/api/account/actions/UPDATE_EMAIL">{props.user.email ? "change" : "add email"}</a>
187 </span>
188 <Show when={error()}><span /><p class="error" role="alert">{error()}</p></Show>
189 <span />
190 <Reveal when={dirty()}>
191 <div class="actions">
192 <button type="button" class="button small" disabled={pending() === "save"} onClick={reset}>
193 reset<kbd aria-hidden="true">esc</kbd>
194 </button>
195 <button type="submit" class="button small primary" aria-disabled={pending() === "save"}
196 aria-busy={pending() === "save"}>
197 save<kbd aria-hidden="true">enter</kbd>
198 </button>
199 </div>
200 </Reveal>
201 </form>
202 </section>
203 );
204}
205
206function SignIn(props: { user: Self }) {
207 const password = () => props.user.credentials.find((credential) => credential.type === "password");
208 const passkeys = () => props.user.credentials.filter((credential) => credential.type.startsWith("webauthn"));
209 return (
210 <section class="card">
211 <h2 class="card-title">sign-in</h2>
212 <div class="credentials">
213 <span class="label">password</span>
214 <span>
215 <Show when={password()} fallback={<span class="muted">none</span>}>{(set) => <>set <Ago t={set().createdDate / 1000} /></>}</Show>
216 </span>
217 <a class="button small" href="/api/account/actions/UPDATE_PASSWORD">{password() ? "change" : "set password"}</a>
218 <span class="label">passkeys</span>
219 <span><Show when={!passkeys().length}><span class="muted">none</span></Show></span>
220 <a class="button small" href="/api/account/actions/webauthn-register-passwordless">add passkey</a>
221 <For each={passkeys()}>
222 {(passkey) => (
223 <>
224 <span />
225 <span class="passkey">{passkey.userLabel ?? "unnamed"} <span class="muted"><Ago t={passkey.createdDate / 1000} /></span></span>
226 <a class="button small" href={`/api/account/actions/delete_credential:${passkey.id}`}>remove</a>
227 </>
228 )}
229 </For>
230 </div>
231 </section>
232 );
233}
dashboard/web/pages/Deploy.css created+100
......@@ -0,0 +1,100 @@
1.deploy-page .page-head { flex-wrap: wrap; row-gap: 8px; margin-bottom: 12px; }
2.deploy-page .page-head .stats { order: 1; flex-basis: 100%; }
3.deploy-page .page-head h1 { white-space: nowrap; }
4.deploy-page .crumb {
5 display: inline-flex;
6 align-items: center;
7 height: var(--control);
8 margin: 0 -4px 0 -10px;
9 padding: 0 10px;
10 border-radius: 99px;
11 color: var(--muted);
12 font-size: 15px;
13 transition: background-color 150ms, color 150ms;
14}
15.deploy-page .crumb::after { content: "/"; margin-left: 12px; color: var(--axis); }
16.deploy-page .crumb:hover { background: var(--hover); color: var(--text); }
17.deploy-page .id { margin-left: -6px; color: var(--muted); }
18.deploy-page .button svg { margin-left: 6px; }
19
20.stat.warn { border-color: color-mix(in srgb, var(--warning) 40%, var(--line)); color: color-mix(in srgb, var(--warning) 65%, var(--text)); }
21.stat.production { border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); background: var(--accent-wash); color: var(--accent); }
22.stat.failed { border-color: color-mix(in srgb, var(--critical) 40%, var(--line)); color: color-mix(in srgb, var(--critical) 70%, var(--text)); }
23a.stat.warn:hover { background: color-mix(in srgb, var(--warning) 10%, transparent); }
24
25.deploy-page .tab-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
26.deploy-page .tab-row .segmented { margin-right: auto; }
27
28.deploy-page .run { margin-bottom: 10px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); overflow: hidden; }
29.deploy-page .run-head { display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 12px; font-size: 13px; }
30.deploy-page .run .output { max-height: 220px; border-top: 1px solid var(--line); }
31
32.deploy-page .output {
33 overflow: auto;
34 padding: 6px 0 8px;
35 background: var(--well);
36 font: 12px/18px var(--mono);
37 white-space: pre-wrap;
38 word-break: break-word;
39}
40.deploy-page .output > div { padding: 0 var(--gutter); }
41.deploy-page .run .output > div { padding: 0 12px; }
42.deploy-page .output .command { color: var(--muted); }
43.deploy-page .output.fill, .deploy-page .job-logs .empty { margin: 0; font-family: var(--sans); }
44.deploy-page .job-logs { display: flex; flex-direction: column; }
45.deploy-page .job-logs .empty a { color: var(--accent); text-decoration: underline dotted; text-underline-offset: 3px; }
46
47
48.deploy-page .file { border-top: 1px solid var(--line); }
49.deploy-page .file header {
50 position: sticky;
51 top: 0;
52 z-index: 1;
53 display: flex;
54 align-items: center;
55 gap: 8px;
56 padding: 6px var(--gutter);
57 background: var(--page);
58 font-size: 13px;
59}
60.deploy-page .file header .icon { display: grid; width: 16px; height: 16px; }
61.deploy-page .file header .icon :is(img, .monogram) { width: 100%; height: 100%; font-size: 10px; }
62.deploy-page .file header a { font-weight: 600; }
63.deploy-page .file header a:hover { color: var(--accent); }
64.deploy-page .counts ins { color: var(--good); text-decoration: none; }
65.deploy-page .counts del { color: var(--critical); text-decoration: none; }
66
67.deploy-page .diff { padding: 2px 0 10px; background: var(--well); line-height: 18px; }
68.deploy-page .diff .line { display: grid; grid-template-columns: 32px 32px 16px minmax(0, 1fr); padding-left: calc(var(--gutter) - 24px); }
69.deploy-page .diff .n { padding-right: 8px; color: var(--muted); text-align: right; user-select: none; opacity: 0.7; }
70.deploy-page .diff .op { color: var(--muted); user-select: none; }
71.deploy-page .diff .text { white-space: pre-wrap; word-break: break-all; padding-right: var(--gutter); }
72.deploy-page .diff .add { background: color-mix(in srgb, var(--good) 12%, transparent); }
73.deploy-page .diff .add .op { color: var(--good); }
74.deploy-page .diff .del { background: color-mix(in srgb, var(--critical) 12%, transparent); }
75.deploy-page .diff .del .op { color: var(--critical); }
76.deploy-page .fold {
77 display: block;
78 width: 100%;
79 padding: 1px 0 1px calc(var(--gutter) + 56px);
80 border: 0;
81 background: color-mix(in srgb, var(--accent) 6%, transparent);
82 color: var(--muted);
83 font: 12px/18px var(--sans);
84 text-align: left;
85 cursor: pointer;
86}
87.deploy-page .fold:hover { background: var(--accent-wash); color: var(--accent); }
88
89.deploy-page .usage { padding: 0 var(--gutter) 6px; }
90.deploy-page .strip-head .now { font-size: 15px; }
91.deploy-page .strip-head .now.peak { color: var(--muted); font-size: 13px; font-weight: 500; }
92.deploy-page .strip-head .now b { color: var(--accent); font-size: 15px; font-weight: 650; }
93.deploy-page .usage-empty { margin: 0 var(--gutter) 8px; }
94
95.deploy-page .allocations td { vertical-align: middle; }
96.deploy-page .allocations .chips { display: flex; flex-wrap: wrap; gap: 4px; }
97.deploy-page .tab-row select { width: auto; }
98
99.deploy-page .hint, .deploy-page .commit-message { margin: 12px var(--gutter); }
100.deploy-page .commit-message { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
dashboard/web/pages/Deploy.tsx created+695
......@@ -0,0 +1,695 @@
1import { A, useNavigate, useParams, useSearchParams } from "@solidjs/router";
2import { type InferResponseType, parseResponse } from "hono/client";
3import ExternalLink from "lucide-solid/icons/external-link";
4import {
5 createEffect, createMemo, createResource, createSignal, For, type JSX, Match, on, onCleanup, type Resource, Show, Switch,
6 untrack,
7} from "solid-js";
8import type { Health, LogLine } from "../types/model.ts";
9import { api, query } from "../api.ts";
10import { Ago } from "../components/Ago.tsx";
11import { AppIcon } from "../components/AppIcon.tsx";
12import { Copy } from "../components/Copy.tsx";
13import { showConfirmDialog } from "../components/Dialog.tsx";
14import { ListPage } from "../components/ListPage.tsx";
15import { LogView } from "../components/LogView.tsx";
16import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
17import { OpenApp } from "../components/OpenApp.tsx";
18import { StatusLabel } from "../components/Status.tsx";
19import { TabBar } from "../components/TabBar.tsx";
20import { TimeChart } from "../components/TimeChart.tsx";
21import { toast } from "../components/Toast.tsx";
22import { ago, bytes, clock, cores, count, datetime, duration, plural } from "../format.ts";
23import { confirmDestroyStage, type Deployed, EVENT, followRun, type StageInfo, useDeploys } from "./Deploys.tsx";
24import "./Deploy.css";
25
26interface Run {
27 id: string;
28 title: string;
29 lines: string[];
30 /** "lost" when the server forgot the run before it ended, e.g. after a restart. */
31 code: number | null | "lost";
32 /** What to do once it succeeds; unset for runs this page didn't start. */
33 done?: () => void;
34}
35
36/** Failed runs dismissed this session, so they stay gone across pages. */
37const [dismissed, setDismissed] = createSignal<string[]>([]);
38
39function RunCard(props: { run: Run }) {
40 let log!: HTMLDivElement;
41 createEffect(on(() => props.run.lines.length, () => (log.scrollTop = log.scrollHeight)));
42 const code = () => props.run.code;
43 return (
44 <div class="run">
45 <div class="run-head">
46 <span class="mono">{props.run.title}</span>
47 <span role="status">
48 <StatusLabel health={code() === null ? "deploying" : code() === "lost" ? "degraded" : "down"}>
49 {code() === null ? "running…" : code() === "lost" ? "output lost" : `failed with exit code ${code()}`}
50 </StatusLabel>
51 </span>
52 <span class="spacer" />
53 <Show when={code() !== null}>
54 <button class="button small" onClick={() => setDismissed([...dismissed(), props.run.id])}>dismiss</button>
55 </Show>
56 </div>
57 <div class="output" role="log" ref={log}>
58 <For each={props.run.lines}>{(line) => <div classList={{ command: line.startsWith("$ ") }}>{line}</div>}</For>
59 </div>
60 </div>
61 );
62}
63
64type Line = [op: " " | "-" | "+", text: string];
65
66/** One service file's diff, with long unchanged runs folded behind a button. */
67function FileDiff(props: { id: string; lines: Line[]; name: string; icon: JSX.Element; warn?: boolean }) {
68 const [open, setOpen] = createSignal<number[]>([]);
69 const CONTEXT = 3;
70 const blocks = createMemo(() => {
71 const out: { start: number; lines: [Line, number | null, number | null][]; fold: boolean }[] = [];
72 let a = 1;
73 let b = 1;
74 const numbered = props.lines.map((line): [Line, number | null, number | null] =>
75 line[0] === "+" ? [line, null, b++] : line[0] === "-" ? [line, a++, null] : [line, a++, b++]);
76 for (let i = 0; i < numbered.length;) {
77 let j = i;
78 while (j < numbered.length && numbered[j]![0][0] === " ") j++;
79 const head = i === 0 ? 0 : CONTEXT;
80 const tail = j === numbered.length ? 0 : CONTEXT;
81 if (j - i - head - tail >= 4) {
82 if (head) out.push({ start: i, lines: numbered.slice(i, i + head), fold: false });
83 out.push({ start: i + head, lines: numbered.slice(i + head, j - tail), fold: true });
84 if (tail) out.push({ start: j - tail, lines: numbered.slice(j - tail, j), fold: false });
85 } else if (j > i) out.push({ start: i, lines: numbered.slice(i, j), fold: false });
86 let k = j;
87 while (k < numbered.length && numbered[k]![0][0] !== " ") k++;
88 if (k > j) out.push({ start: j, lines: numbered.slice(j, k), fold: false });
89 i = k;
90 }
91 return out;
92 });
93 const added = () => props.lines.filter(([op]) => op === "+").length;
94 const removed = () => props.lines.filter(([op]) => op === "-").length;
95 return (
96 <section class="file edge">
97 <header>
98 <span class="icon">{props.icon}</span>
99 <A href={`/services/${props.id}`}>{props.name}</A>
100 <span class="mono muted">service/{props.id}/service.pkl</span>
101 <Show when={props.warn}>
102 <span class="chip warn" tabindex="0" data-tip="production changed this file after staging">may undo production</span>
103 </Show>
104 <span class="spacer" />
105 <span class="counts mono"><Show when={added()}><ins>+{added()}</ins></Show> <Show when={removed()}><del>−{removed()}</del></Show></span>
106 </header>
107 <div class="diff mono">
108 <For each={blocks()}>
109 {(block) => (
110 <Show when={block.fold && !open().includes(block.start)} fallback={
111 <For each={block.lines}>
112 {([[op, text], a, b]) => (
113 <div class={`line ${op === "+" ? "add" : op === "-" ? "del" : ""}`}>
114 <span class="n">{a}</span><span class="n">{b}</span><span class="op">{op}</span><span class="text">{text}</span>
115 </div>
116 )}
117 </For>
118 }>
119 <button class="fold" onClick={() => setOpen([...open(), block.start])}>
120 {count(block.lines.length)} unchanged lines
121 </button>
122 </Show>
123 )}
124 </For>
125 </div>
126 </section>
127 );
128}
129
130function Changes(props: { from: string | null; to: string | null; warn?: string[]; name: (id: string) => string;
131 icon: (id: string) => JSX.Element; empty: string; same: string; }) {
132 // A string key, since each poll hands over fresh objects that would otherwise count as a change and refetch.
133 const [changes, { refetch }] = createResource(() => props.to && `${props.from ?? ""}..${props.to}`, (key) => {
134 const [from, to] = key.split("..") as [string, string];
135 return parseResponse(api.deploys.changes.$get({ query: { to, ...(from ? { from } : {}) } }));
136 });
137 return (
138 <Show when={props.to} fallback={<p class="empty">{props.empty}</p>}>
139 <Loaded data={changes} what="changes" retry={refetch} skeleton={<div class="skeleton" style={{ height: "240px" }} />}>
140 {(files) => (
141 <For each={files()} fallback={<p class="empty">{props.same}</p>}>
142 {(file) => (
143 <FileDiff id={file.id} lines={file.lines} name={props.name(file.id)} icon={props.icon(file.id)}
144 warn={props.warn?.includes(file.id)} />
145 )}
146 </For>
147 )}
148 </Loaded>
149 </Show>
150 );
151}
152
153/** Recorded output of staging or of a deploy, kept by the host. */
154function Output(props: { fetch: () => Promise<{ lines: string[] | null }>; empty: string }) {
155 const [output, { refetch }] = createResource(props.fetch);
156 return (
157 <Loaded data={output} what="the output" retry={refetch} skeleton={<div class="fill"><SkeletonRows count={12} /></div>}>
158 {(result) => (
159 <Show when={result().lines} fallback={<p class="empty">{props.empty}</p>}>
160 {(lines) => (
161 <div class="output fill" role="log">
162 <For each={lines()}>{(line) => <div classList={{ command: line.startsWith("$ ") }}>{line}</div>}</For>
163 </div>
164 )}
165 </Show>
166 )}
167 </Loaded>
168 );
169}
170
171type Runtime = InferResponseType<(typeof api.deploys.stages)[":id"]["runtime"]["$get"], 200>;
172type Allocation = Runtime["jobs"][number]["allocations"][number];
173type LogQuery = { after?: string; before?: string; limit?: string };
174
175/** Allocations and usage of a stage or deploy, polled while its jobs still run. */
176function useRuntime(name: string, key: () => string, fetch: (key: string) => Promise<Runtime>) {
177 const [runtime, { refetch }] = query(name, fetch).use(key);
178 const latest = lastGood(runtime);
179 const timer = setInterval(() => latest()?.to === null && refetch(), 10000);
180 onCleanup(() => clearInterval(timer));
181 return { runtime, latest, refetch };
182}
183
184/** Every task restart across the jobs, newest first. */
185const restarts = (runtime: Runtime | undefined) => (runtime?.jobs ?? [])
186 .flatMap((job) => job.allocations.flatMap((allocation) => allocation.tasks))
187 .flatMap((task) => (task.lastRestart ? [{ ...task.lastRestart, count: task.restarts }] : []))
188 .sort((a, b) => b.t - a.t);
189
190function RestartStat(props: { runtime: Runtime | undefined; href: string }) {
191 return (
192 <Show when={restarts(props.runtime)[0]}>
193 {(last) => (
194 <A class="stat warn" href={props.href} data-tip={`${last().reason}, ${ago(last().t)}`}>
195 {plural(restarts(props.runtime).reduce((sum, restart) => sum + restart.count, 0), "restart")}
196 </A>
197 )}
198 </Show>
199 );
200}
201
202/** Cpu over memory on one time axis, stacked by job. */
203function Usage(props: { runtime: Runtime; name: (job: string) => string }) {
204 const STRIPS = [["cpu", cores], ["memory", bytes]] as const;
205 /** Usage is null until a metrics store records it. */
206 const jobs = () => props.runtime.jobs.flatMap(({ id, cpu, memory }) => (cpu && memory ? [{ id, cpu, memory }] : []));
207 const empty = () => jobs().length < props.runtime.jobs.length ? "No usage history on this host yet: it needs a metrics store."
208 : jobs().every((job) => job.memory.v.every((v) => v === null))
209 ? props.runtime.jobs.length ? "No usage. The job never started." : "No service changed here."
210 : null;
211 return (
212 <Show when={!empty()} fallback={<p class="empty usage-empty">{empty()}</p>}>
213 <div class="usage">
214 <For each={STRIPS}>
215 {([key, format], i) => {
216 const series = () => jobs().map((job) => ({ ...job[key], name: jobs().length === 1 ? key : props.name(job.id) }));
217 const totals = () => series()[0]!.t.map((_, index) =>
218 series().reduce<number | null>((sum, item) => (item.v[index] == null ? sum : (sum ?? 0) + item.v[index]!), null));
219 const peak = () => format(Math.max(...totals().map((v) => v ?? 0)));
220 return (
221 <section class="strip" aria-label={key}>
222 <div class="strip-head">
223 <h3>{key}</h3>
224 <span class="rule" />
225 <Show when={props.runtime.to === null} fallback={<span class="now peak">peak <b>{peak()}</b></span>}>
226 <span class="now" tabIndex={0} data-tip={`peak ${peak()}`}>
227 <b>{format(totals().findLast((v) => v !== null) ?? 0)}</b>
228 </span>
229 </Show>
230 </div>
231 <TimeChart series={series()} format={format} height={i() ? 80 : 56} inline sync="deploy" timeAxis={i() === 1}
232 stacked={series().length > 1} />
233 </section>
234 );
235 }}
236 </For>
237 </div>
238 </Show>
239 );
240}
241
242const ALLOCATION: Record<Allocation["state"], [Health, string]> = {
243 pending: ["starting", "pending"], running: ["healthy", "running"], complete: ["stopped", "replaced"],
244 failed: ["down", "failed"], lost: ["down", "lost"], unknown: ["degraded", "unknown"],
245};
246
247/** The jobs' allocations, newest first, with a service column when there's more than one job. */
248function Allocations(props: { runtime: Runtime; name: (job: string) => string }) {
249 const rows = () => props.runtime.jobs.flatMap((job) => job.allocations.map((allocation) => ({ job: job.id, ...allocation })))
250 .sort((a, b) => b.created - a.created);
251 const many = () => props.runtime.jobs.length > 1;
252 return (
253 <Show when={rows().length} fallback={
254 <p class="empty">No allocations. Nomad forgets old ones after a while, so older deploys may have none left.</p>
255 }>
256 <table class="data allocations">
257 <thead>
258 <tr>
259 <Show when={many()}><th>service</th></Show>
260 <th>allocation</th><th>state</th><th>checks</th><th class="num">restarts</th><th>started</th>
261 </tr>
262 </thead>
263 <tbody>
264 <For each={rows()}>
265 {(row) => {
266 const [health, label] = row.state === "running" && row.healthy !== true
267 ? row.healthy === null ? ["starting", "starting"] as const : ["degraded", "unhealthy"] as const
268 : row.state === "complete" && row.healthy === false ? ["degraded", "replaced"] as const
269 : ALLOCATION[row.state];
270 const last = row.tasks.map((task) => task.lastRestart).filter((restart) => restart !== null).sort((a, b) => b.t - a.t)[0];
271 return (
272 <tr>
273 <Show when={many()}><td><A href={`/services/${row.job}`}>{props.name(row.job)}</A></td></Show>
274 <td class="mono"><Copy value={row.id} label="allocation ID">{row.id.slice(0, 8)}</Copy></td>
275 <td tabIndex={row.ended ? 0 : undefined}
276 data-tip={row.ended ? `ran ${duration(row.ended - row.created)}, until ${datetime(row.ended)}` : undefined}>
277 <StatusLabel health={health}>{label}</StatusLabel>
278 </td>
279 <td class="chips">
280 <For each={row.checks} fallback={<span class="muted">–</span>}>
281 {(check) => (
282 <span class="chip" tabIndex={0} data-tip={check.output}>
283 <StatusLabel health={check.passing ? "healthy" : "degraded"}>{check.name}</StatusLabel>
284 </span>
285 )}
286 </For>
287 </td>
288 <td class="num" tabIndex={last ? 0 : undefined} data-tip={last && `${last.reason}, ${ago(last.t)}`}>
289 {count(row.tasks.reduce((sum, task) => sum + task.restarts, 0))}
290 </td>
291 <td class="nowrap"><Ago t={row.created} /></td>
292 </tr>
293 );
294 }}
295 </For>
296 </tbody>
297 </table>
298 </Show>
299 );
300}
301
302/**
303 * A job's lines in the page's window, newest at the bottom; older ones load near the top, and newer ones arrive
304 * while the job still runs. The last restart of each task is marked.
305 */
306function JobLogs(props: {
307 fetch: (query: LogQuery) => Promise<LogLine[]>;
308 runtime: Runtime | undefined;
309 empty: JSX.Element;
310}) {
311 const PAGE = 500;
312 const [lines, setLines] = createSignal<LogLine[]>([]);
313 const [start, setStart] = createSignal(false);
314 const [follow, setFollow] = createSignal(true);
315 const [first, { refetch }] = createResource(() => props.fetch({ limit: String(PAGE) }).then((got) => {
316 setLines(got.reverse());
317 setStart(got.length < PAGE);
318 setFollow(true);
319 return true;
320 }));
321 let loading = false;
322 const older = async () => {
323 const oldest = lines()[0];
324 if (loading || start() || !oldest) return;
325 loading = true;
326 const got = await props.fetch({ before: String(oldest.t), limit: String(PAGE) }).catch(() => null);
327 loading = false;
328 if (!got || oldest !== lines()[0]) return;
329 setStart(got.length < PAGE);
330 setLines([...got.reverse(), ...lines()]);
331 };
332 const timer = setInterval(async () => {
333 const last = lines().at(-1);
334 if (first.state !== "ready" || props.runtime?.to !== null) return;
335 const got = await props.fetch(last ? { after: String(last.t) } : {}).catch(() => []);
336 if (!got.length || last !== lines().at(-1)) return;
337 setLines([...lines(), ...got.reverse()].slice(-5000));
338 }, 3000);
339 onCleanup(() => clearInterval(timer));
340 const marked = createMemo(() => new Map(restarts(props.runtime).flatMap((restart) => {
341 const line = lines().find((item) => item.t >= restart.t);
342 return line ? [[line, restart] as const] : [];
343 })));
344 return (
345 <div class="job-logs fill">
346 <Loaded data={first} what="the logs" retry={refetch} skeleton={<SkeletonRows count={12} />}>
347 {() => (
348 <Show when={lines().length} fallback={<p class="logs empty">{props.empty}</p>}>
349 <LogView lines={lines()} follow={follow()} onFollow={setFollow} onStart={older}
350 before={(line) => (
351 <Show when={marked().get(line)}>
352 {(restart) => <div class="log-edge restart">restarted at {clock(restart().t)}: {restart().reason}</div>}
353 </Show>
354 )} />
355 </Show>
356 )}
357 </Loaded>
358 </div>
359 );
360}
361
362export function Deploy() {
363 const params = useParams<{ id: string; tab?: string }>();
364 const navigate = useNavigate();
365 const { state, refetch, service, name } = useDeploys();
366 const deploys = lastGood(state);
367 const app = (id: string) => service(id) ?? { id, name: id, icon: null };
368 const icon = (id: string) => <AppIcon {...app(id)} />;
369
370 const [run, setRun] = createSignal<Run | null>(null);
371 let events: EventSource | undefined;
372 onCleanup(() => events?.close());
373 const tail = (id: string, title: string, done?: () => void) => {
374 events?.close();
375 setRun({ id, title, lines: [], code: null, done });
376 events = followRun(id, {
377 open: () => setRun((r) => r && { ...r, lines: [] }),
378 line: (text) => setRun((r) => r && { ...r, lines: [...r.lines, text] }),
379 lost: () => setRun((r) => r && { ...r, code: "lost" }),
380 exit: async (code) => {
381 await refetch();
382 const finished = untrack(run);
383 if (code !== 0) return setRun((r) => r && { ...r, code });
384 setRun(null);
385 finished?.done?.();
386 },
387 });
388 };
389 // Picks up this page's run when it was started elsewhere or before the page opened, unless it succeeded or was dismissed.
390 createEffect(() => {
391 const active = deploys()?.run;
392 if (!active || (params.id === "main" ? active.title !== "deploy main" : active.target !== params.id) || active.code === 0 || dismissed().includes(active.id)) return;
393 if (untrack(run)?.id !== active.id) tail(active.id, active.title);
394 });
395 const shown = () => (run() && !dismissed().includes(run()!.id) ? run() : null);
396 const act = ({ id, title }: Pick<Run, "id" | "title">, done: () => void) => {
397 tail(id, title, done);
398 refetch();
399 };
400 const wait = () => (deploys()?.run?.code === null ? "Wait for the current run to finish" : "");
401
402 const redeploys = (ids: string[] | null, release: string) =>
403 !ids ? `Production switches to release ${release.slice(0, 8)}. Services that differ from it redeploy.`
404 : !ids.length ? `Production switches to release ${release.slice(0, 8)} without redeploying any service.`
405 : `${new Intl.ListFormat("en").format(ids.map(name))} ${ids.length === 1 ? "redeploys" : "redeploy"} `
406 + `on release ${release.slice(0, 8)}. Everything else keeps running.`;
407
408 /** The job's lines in the logs app, bounded to the window once its jobs stopped. */
409 const logsUrl = (job: string, runtime: Runtime | undefined) => {
410 const url = service("victoria-logs")?.url;
411 const iso = (t: number) => new Date(t * 1000).toISOString();
412 const window = runtime?.to ? ` _time:[${iso(runtime.from)}, ${iso(runtime.to)}]` : "";
413 return url && `${url}/select/vmui/#/?query=${encodeURIComponent(`{job="${job}"}${window}`)}`;
414 };
415 const OpenLogs = (props: { job: string; runtime: Runtime | undefined }) => (
416 <Show when={logsUrl(props.job, props.runtime)}>
417 {(href) => <a class="button small" href={href()} target="_blank" rel="noreferrer">open in logs<ExternalLink size={12} /></a>}
418 </Show>
419 );
420 const usage = (runtime: Resource<Runtime>, retry: () => void) => (
421 <Loaded data={runtime} what="usage" retry={retry} skeleton={<div class="skeleton" style={{ height: "184px" }} />}>
422 {(loaded) => <Usage runtime={loaded()} name={name} />}
423 </Loaded>
424 );
425 const status = (runtime: Resource<Runtime>, retry: () => void) => (
426 <Loaded data={runtime} what="allocations" retry={retry} skeleton={<div class="skeleton" style={{ height: "64px" }} />}>
427 {(loaded) => <Allocations runtime={loaded()} name={name} />}
428 </Loaded>
429 );
430
431 const Tabs = (props: { tabs: [string, string][] }) => (
432 <TabBar label="View">
433 <For each={props.tabs}>{([tab, label]) => <A href={`/deploys/${params.id}${tab ? "/" + tab : ""}`} end>{label}</A>}</For>
434 </TabBar>
435 );
436
437 const MainPage = () => {
438 const candidate = () => deploys()?.main;
439 const blocked = () => !candidate() ? "Upload main with python3 tools/deploy.py publish"
440 : candidate()!.release === deploys()?.current && deploys()?.recorded ? "Already deployed"
441 : wait();
442 const deploy = () => {
443 const main = candidate()!;
444 return showConfirmDialog({
445 title: "Deploy main?",
446 description: `Commit ${main.commit.slice(0, 12)} deploys the full repository configuration to production. Preview data stays in staging.`,
447 confirmLabel: "deploy main",
448 onConfirm: async () => act(await parseResponse(api.deploys.main[":release"].deploy.$post({ param: { release: main.release } })), () => toast("Deployed main")),
449 });
450 };
451 return (
452 <ListPage id="deploy" class="deploy-page" flush head={<>
453 <div class="page-head">
454 <A class="crumb" href="/deploys">deploys</A><h1>main</h1>
455 <Show when={candidate()}>{(main) => <span class="id mono"><Copy value={main().commit} label="commit ID">{main().commit.slice(0, 12)}</Copy></span>}</Show>
456 <span class="spacer" />
457 <button class="button" disabled={!!blocked()} data-tip={blocked() || undefined} onClick={deploy}>deploy main</button>
458 </div>
459 <Show when={shown()}>{(r) => <RunCard run={r()} />}</Show>
460 </>}>
461 <p class="hint">Upload main: <Copy value="python3 tools/deploy.py publish" label="upload command"><span class="mono">python3 tools/deploy.py publish</span></Copy></p>
462 <Show when={candidate()} fallback={<p class="empty">No main commit uploaded yet.</p>}>
463 {(main) => <>
464 <pre class="commit-message">{main().description}</pre>
465 <Changes from={deploys()?.current ?? null} to={main().release} name={name} icon={icon}
466 empty="This release is no longer on the host. Upload main again." same="Same service files as production." />
467 </>}
468 </Show>
469 </ListPage>
470 );
471 };
472
473 const StagePage = (props: { stage: StageInfo }) => {
474 const stage = () => props.stage;
475 const data = () => deploys()!;
476 const hex = () => stage().id.slice(stage().service.length + 1);
477 /** The deploy this was staged on, while production has moved past it. */
478 const base = () => (stage().base === data().history[0]?.n ? undefined : data().history.find((entry) => entry.n === stage().base));
479 const behind = () => stage().behind.filter((id) => stage().changed?.includes(id));
480 const { runtime, latest, refetch: retry } = useRuntime("stage runtime", () => stage().id,
481 (id) => parseResponse(api.deploys.stages[":id"].runtime.$get({ param: { id } })));
482 const destroy = () => {
483 const { id } = stage();
484 return confirmDestroyStage(stage(), (run) => act(run, () => {
485 navigate("/deploys");
486 toast(`Destroyed ${id}`);
487 }));
488 };
489 return (
490 <ListPage id="deploy" class="deploy-page" flush head={
491 <>
492 <div class="page-head">
493 <A class="crumb" href="/deploys">deploys</A>
494 <h1>{name(stage().service)}</h1>
495 <span class="id mono"><Copy value={stage().id} label="stage ID">{hex()}</Copy></span>
496 <Show when={stage().hostname} fallback={<span class="muted" data-tip="the stage file doesn't record its hostname yet">preview –</span>}>
497 {(hostname) => <OpenApp app={app(stage().service)} href={`https://${hostname()}`}>preview</OpenApp>}
498 </Show>
499 <Show when={stage().files}>{(href) => <OpenApp app={app("copyparty")} href={href()}>data</OpenApp>}</Show>
500 <div class="stats">
501 <Show when={stage().health} fallback={<span class="stat muted" data-tip="Nomad isn't connected">health –</span>}>
502 {(health) => (
503 <span class="stat" role="status">
504 <StatusLabel health={health()}>{health() === "healthy" ? "running" : undefined}</StatusLabel>
505 </span>
506 )}
507 </Show>
508 <Show when={stage().release && !stage().ready}><span class="stat warn">not ready</span></Show>
509 <RestartStat runtime={latest()} href={`/deploys/${stage().id}/status`} />
510 <span class="stat">staged <b><Ago t={stage().created} /></b></span>
511 <Show when={base()}>
512 {(entry) => (
513 <A class="stat warn" href={`/deploys/${entry().n}`}
514 data-tip={`staged on ${entry().release.slice(0, 8)}, production is ${data().current?.slice(0, 8)}`}>
515 behind production
516 </A>
517 )}
518 </Show>
519 <Show when={stage().overrides.length}>
520 <span class="stat warn" tabindex="0" data-tip={`staged with --env ${stage().overrides.join(", ")}`}>unsaved settings</span>
521 </Show>
522 <Show when={stage().release === data().current}>
523 <Show when={data().recorded} fallback={<span class="stat failed">deploy didn't finish</span>}>
524 <span class="stat production">in production</span>
525 </Show>
526 </Show>
527 </div>
528 <span class="spacer" />
529 <button class="button danger" disabled={!!wait()} data-tip={wait() || undefined} onClick={destroy}>destroy</button>
530 </div>
531 <Show when={shown()}>{(r) => <RunCard run={r()} />}</Show>
532 </>
533 } summary={usage(runtime, retry)}>
534 <p class="hint">Update this preview: <Copy value={`python3 tools/deploy.py stage ${stage().service}`} label="update command"><span class="mono">python3 tools/deploy.py stage {stage().service}</span></Copy></p>
535 <div class="tab-row">
536 <Tabs tabs={[["", "changes"], ["status", "status"], ["logs", "logs"], ["output", "output"]]} />
537 <Show when={params.tab === "logs"}><OpenLogs job={stage().id} runtime={latest()} /></Show>
538 </div>
539 <Switch>
540 <Match when={!params.tab}>
541 <Changes from={data().current} to={stage().release} warn={behind()} name={name} icon={icon}
542 empty="Staging didn't finish, so there's nothing to compare. The output tab shows where it stopped."
543 same="Same service files as production." />
544 </Match>
545 <Match when={params.tab === "status"}>{status(runtime, retry)}</Match>
546 <Match when={params.tab === "logs"}>
547 <JobLogs runtime={latest()}
548 fetch={(query) => parseResponse(api.deploys.stages[":id"].logs.$get({ param: { id: stage().id }, query }))}
549 empty={
550 <Show when={stage().health === "down"} fallback="No lines from this stage's job yet. They appear once it starts.">
551 Nothing from this stage's job. <A href={`/deploys/${stage().id}/output`}>Its staging output</A> shows why it didn't start.
552 </Show>
553 } />
554 </Match>
555 <Match when={params.tab === "output"}>
556 <Output fetch={() => parseResponse(api.deploys.stages[":id"].output.$get({ param: { id: stage().id } }))}
557 empty="The host kept no output from staging this." />
558 </Match>
559 </Switch>
560 </ListPage>
561 );
562 };
563
564 const DeployPage = (props: { entry: Deployed }) => {
565 const entry = () => props.entry;
566 const data = () => deploys()!;
567 const previous = () => data().history.find((item) => item.n === entry().n - 1);
568 const next = () => data().history.find((item) => item.n === entry().n + 1);
569 const live = () => entry().n === data().history[0]?.n && data().recorded;
570 const stage = () => data().stages.find((item) => item.id === entry().source);
571 const { runtime, latest, refetch: retry } = useRuntime("deploy runtime", () => String(entry().n),
572 (n) => parseResponse(api.deploys.history[":n"].runtime.$get({ param: { n } })));
573 const [search, setSearch] = useSearchParams<{ job?: string }>();
574 const jobs = () => latest()?.jobs.map((job) => job.id) ?? [];
575 const job = () => (search.job && jobs().includes(search.job) ? search.job : jobs()[0]);
576 const rollback = () => {
577 const { n, release, redeploys: ids } = entry();
578 return showConfirmDialog({
579 title: `Roll back to ${release.slice(0, 8)}?`,
580 description: redeploys(ids, release),
581 confirmLabel: "roll back",
582 onConfirm: async () => act(await parseResponse(api.deploys.history[":n"].rollback.$post({ param: { n: String(n) } })),
583 () => toast(`Rolled back to ${release.slice(0, 8)}`)),
584 });
585 };
586 return (
587 <ListPage id="deploy" class="deploy-page" flush head={
588 <>
589 <div class="page-head">
590 <A class="crumb" href="/deploys">deploys</A>
591 <h1>{EVENT[entry().source] ?? name(entry().source.replace(/-preview-[0-9a-f]+$/, ""))}</h1>
592 <span class="id mono"><Copy value={entry().release} label="release ID">{entry().release.slice(0, 8)}</Copy></span>
593 <div class="stats">
594 <Show when={live()} fallback={
595 <Show when={next()}>
596 {(after) => (
597 <A class="stat" href={`/deploys/${after().n}`} data-tip={`replaced ${datetime(after().time)}`}>
598 live <b>{duration(after().time - entry().time)}</b>
599 </A>
600 )}
601 </Show>
602 }>
603 <span class="stat production">production</span>
604 </Show>
605 <span class="stat">deployed <b><Ago t={entry().time} /></b></span>
606 <Show when={!EVENT[entry().source]}>
607 <Show when={stage()} fallback={<span class="stat">from <b class="mono">{entry().source}</b></span>}>
608 <A class="stat" href={`/deploys/${entry().source}`}>from <b class="mono">{entry().source}</b></A>
609 </Show>
610 </Show>
611 <Show when={entry().legacy}><span class="stat" data-tip="built with the old release format">legacy</span></Show>
612 <RestartStat runtime={latest()} href={`/deploys/${entry().n}/status`} />
613 </div>
614 <span class="spacer" />
615 <Show when={entry().release !== data().current}>
616 <button class="button" disabled={!!wait()} data-tip={wait() || undefined} onClick={rollback}>roll back</button>
617 </Show>
618 </div>
619 <Show when={shown()}>{(r) => <RunCard run={r()} />}</Show>
620 </>
621 } summary={usage(runtime, retry)}>
622 <div class="tab-row">
623 <Tabs tabs={[["", "changes"], ["status", "status"], ["logs", "logs"], ["output", "output"]]} />
624 <Show when={params.tab === "logs" && job()}>
625 {(id) => (
626 <>
627 <Show when={jobs().length > 1}>
628 <select class="search" aria-label="Service" value={id()}
629 onChange={(event) => setSearch({ job: event.currentTarget.value }, { replace: true })}>
630 <For each={jobs()}>{(item) => <option value={item}>{name(item)}</option>}</For>
631 </select>
632 </Show>
633 <OpenLogs job={id()} runtime={latest()} />
634 </>
635 )}
636 </Show>
637 </div>
638 <Switch>
639 <Match when={!params.tab}>
640 <Changes from={previous()?.release ?? null} to={entry().release} name={name} icon={icon}
641 empty="This release is no longer on the host." same="Same service files as the deploy before it." />
642 </Match>
643 <Match when={params.tab === "status"}>{status(runtime, retry)}</Match>
644 <Match when={params.tab === "logs" && job()} keyed>
645 {(id) => (
646 <JobLogs runtime={latest()}
647 fetch={(query) => parseResponse(api.deploys.history[":n"].logs.$get({
648 param: { n: String(entry().n) }, query: { ...query, job: id },
649 }))}
650 empty={`No lines from ${name(id)} while this deploy was live.`} />
651 )}
652 </Match>
653 <Match when={params.tab === "output"}>
654 <Output fetch={() => parseResponse(api.deploys.history[":n"].output.$get({ param: { n: String(entry().n) } }))}
655 empty="The host kept no output for this deploy." />
656 </Match>
657 </Switch>
658 </ListPage>
659 );
660 };
661
662 // Remounts per stage or deploy, so switching never shows the last one's usage, logs or output.
663 return (
664 <Show when={params.id} keyed>
665 <Loaded data={state} what="deploys" retry={refetch} skeleton={
666 <div class="page">
667 <div class="page-head">
668 <span class="skeleton" style={{ width: "240px", height: "24px" }} />
669 <For each={[72, 110]}>{(width) => <span class="skeleton stat-skeleton" style={{ width: `${width}px` }} />}</For>
670 </div>
671 <div class="skeleton" style={{ height: "184px" }} />
672 </div>
673 }>
674 {(data) => (
675 <Switch fallback={
676 <div class="page">
677 <div class="page-head"><A class="crumb" href="/deploys">deploys</A><h1>{params.id}</h1></div>
678 <p class="empty">
679 {/^\d+$/.test(params.id) ? `No deploy number ${params.id} yet.` : `No stage named ${params.id}. It may have been destroyed.`}
680 </p>
681 </div>
682 }>
683 <Match when={params.id === "main"}><MainPage /></Match>
684 <Match when={data().stages.find((stage) => stage.id === params.id)}>
685 {(stage) => <StagePage stage={stage()} />}
686 </Match>
687 <Match when={data().history.find((entry) => String(entry.n) === params.id)}>
688 {(entry) => <DeployPage entry={entry()} />}
689 </Match>
690 </Switch>
691 )}
692 </Loaded>
693 </Show>
694 );
695}
dashboard/web/pages/Deploys.css created+63
......@@ -0,0 +1,63 @@
1.deploys .graph { --lane: 16px; --y: 18px; --row-bg: var(--page); display: flex; flex-direction: column; padding-bottom: 24px; }
2.deploys .hint { margin: 0; padding: 8px var(--gutter); color: var(--muted); font-size: 13px; }
3
4.deploys .row {
5 position: relative;
6 display: grid;
7 grid-template-columns: calc(var(--lanes) * var(--lane) + 22px) minmax(0, 17rem) minmax(0, 1fr) 7rem;
8 align-items: start;
9 column-gap: 12px;
10 padding: 0 var(--gutter);
11 background: var(--row-bg);
12 transition: background-color 150ms;
13}
14.deploys .row.link:hover { --row-bg: var(--hover); }
15.deploys .row:has(a.title:focus-visible) { outline: 2px solid var(--focus); outline-offset: -2px; }
16.deploys .row > :not(.rail) { padding: 8px 0; line-height: 20px; }
17.deploys .row > .skeleton { height: 16px; margin: 10px 0; }
18
19.deploys .rail { position: relative; align-self: stretch; }
20.deploys .rail i { position: absolute; left: calc(6px + var(--x, 0) * var(--lane)); width: 2px; background: var(--axis); }
21.deploys .rail i.trunk { background: var(--accent); }
22.deploys .pending .rail i.trunk { background: repeating-linear-gradient(var(--accent) 0 3px, transparent 3px 7px); }
23.deploys .rail .start { top: var(--y); bottom: 0; }
24.deploys .rail .pass { top: 0; bottom: 0; }
25.deploys .rail .end { top: 0; height: var(--y); }
26.deploys .rail .lane.merge { top: 0; height: calc(var(--y) - 8px); }
27.deploys .rail i.curve {
28 left: 7px;
29 top: calc(var(--y) - 8px);
30 width: calc(var(--x) * var(--lane) + 1px);
31 height: 9px;
32 border: solid var(--axis);
33 border-width: 0 2px 2px 0;
34 border-bottom-right-radius: 8px;
35 background: none;
36}
37.deploys .node {
38 position: absolute;
39 left: calc(7px + var(--x, 0) * var(--lane));
40 top: var(--y);
41 translate: -50% -50%;
42 display: grid;
43 place-items: center;
44 width: 16px;
45 height: 16px;
46 border-radius: 50%;
47 background: var(--row-bg);
48 transition: background-color 150ms;
49}
50.deploys .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
51.deploys .dot.rollback { background: var(--row-bg); box-shadow: inset 0 0 0 2px var(--accent); }
52.deploys .dot.head { width: 12px; height: 12px; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); }
53
54.deploys .what { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; min-width: 0; }
55.deploys .title { display: inline-flex; align-items: baseline; gap: 8px; min-width: 0; }
56.deploys .title b { font-weight: 600; white-space: nowrap; }
57.deploys .row.link a.title::after { content: ""; position: absolute; inset: 0; }
58.deploys a.title:focus-visible { outline: none; }
59.deploys .row :is(.chips a, time, .chip[data-tip]) { position: relative; z-index: 1; }
60.deploys .when { text-align: right; color: var(--text-2); white-space: nowrap; }
61
62.chip.production { background: var(--accent-wash); color: var(--accent); }
63.chip.failed { background: color-mix(in srgb, var(--critical) 16%, transparent); color: color-mix(in srgb, var(--critical) 70%, var(--text)); }
dashboard/web/pages/Deploys.tsx created+221
......@@ -0,0 +1,221 @@
1import { A } from "@solidjs/router";
2import { type InferResponseType, parseResponse } from "hono/client";
3import { createMemo, createResource, For, type JSX, onCleanup, Show } from "solid-js";
4import { api, queries } from "../api.ts";
5import { Ago } from "../components/Ago.tsx";
6import { showConfirmDialog } from "../components/Dialog.tsx";
7import { ListPage } from "../components/ListPage.tsx";
8import { lastGood, Loaded } from "../components/Loaded.tsx";
9import { Status } from "../components/Status.tsx";
10import "./Deploys.css";
11
12type Overview = InferResponseType<typeof api.deploys.$get>;
13export type StageInfo = Overview["stages"][number];
14export type Deployed = Overview["history"][number];
15
16export function followRun(id: string, events: {
17 open?: () => void;
18 line?: (text: string) => void;
19 exit: (code: number) => void;
20 lost: () => void;
21}) {
22 const source = new EventSource(`/api/deploys/runs/${id}`);
23 source.onopen = () => events.open?.();
24 source.onmessage = (event) => events.line?.(event.data);
25 source.onerror = () => source.readyState === EventSource.CLOSED && events.lost();
26 source.addEventListener("exit", (event) => {
27 source.close();
28 events.exit(Number(event.data));
29 });
30 return source;
31}
32
33export function confirmDestroyStage(stage: StageInfo,
34 started: (run: InferResponseType<typeof api.deploys.stages[":id"]["destroy"]["$post"]>) => unknown,
35 returnFocus?: HTMLElement,
36) {
37 const { id, hostname, clone, mount } = stage;
38 return showConfirmDialog({
39 title: `Destroy ${id}?`,
40 description: () => <>
41 The preview{hostname ? <> at <span class="mono">{hostname}</span></> : ""} goes offline
42 {clone ? <> and <span class="mono">{mount}</span> is deleted with everything in it</> : ""}.
43 Production isn't touched.
44 </>,
45 confirmLabel: "destroy",
46 destructive: true,
47 returnFocus,
48 onConfirm: async () => started(await parseResponse(api.deploys.stages[":id"].destroy.$post({ param: { id } }))),
49 });
50}
51
52export const EVENT: Record<string, string> = { main: "main", bootstrap: "first release", rollback: "rolled back", previous: "adopted" };
53
54/** Deploy state, polled, and service names for the ids in it; both pages read the same shape. */
55export function useDeploys() {
56 const [state, { refetch }] = queries.deploys.use();
57 // Only for names, icons and links; ids stand in when it fails.
58 const services = lastGood(queries.services.use()[0]);
59 const timer = setInterval(refetch, 5000);
60 onCleanup(() => clearInterval(timer));
61 const service = (id: string) => services()?.find((item) => item.id === id);
62 return { state, refetch, service, name: (id: string) => service(id)?.name ?? id };
63}
64
65export function Changed(props: { ids: string[] | null; name: (id: string) => string; warn?: string[] }) {
66 return (
67 <Show when={props.ids} fallback={<span class="muted" data-tip="not known for this release">–</span>}>
68 {(ids) => (
69 <div class="chips">
70 <For each={ids().toSorted((a, b) => props.name(a).localeCompare(props.name(b)))} fallback={<span class="muted">nothing</span>}>
71 {(id) => (
72 <A class="chip" classList={{ warn: props.warn?.includes(id) }} href={`/services/${id}`}
73 data-tip={props.warn?.includes(id) ? "changed in production after staging" : undefined}>
74 {props.name(id)}
75 </A>
76 )}
77 </For>
78 </div>
79 )}
80 </Show>
81 );
82}
83
84/** What a lane draws in one row: a node that starts it, a line through, or the curve into production. */
85type Mark = "start" | "pass" | "merge";
86
87interface Row {
88 key: string;
89 href?: string;
90 /** Production's line through this row. */
91 trunk?: "start" | "pass" | "end" | "only";
92 node: JSX.Element;
93 marks: [lane: number, mark: Mark][];
94 title: JSX.Element;
95 flags?: JSX.Element;
96 changes?: JSX.Element;
97 time?: number;
98 class?: string;
99}
100
101export function Deploys() {
102 const { state, refetch, name } = useDeploys();
103 return (
104 <ListPage id="deploys" class="deploys" flush head={<div class="page-head"><h1>deploys</h1><span class="spacer" /><A class="button" href="/deploys/main">deploy main</A></div>}>
105 <Loaded data={state} what="deploys" retry={refetch} skeleton={
106 <div class="graph edge" style={{ "--lanes": 1 }}>
107 <For each={Array(8)}>{() => <div class="row"><span /><span class="skeleton" /><span class="skeleton" /></div>}</For>
108 </div>
109 }>
110 {(data) => {
111 // Polls mostly return what's already shown; rebuilding rows then would drop hover and focus.
112 const same = createMemo(data, undefined, { equals: (a, b) => JSON.stringify(a) === JSON.stringify(b) });
113 const rows = createMemo(() => {
114 const { stages, history, run, current, recorded } = same();
115 const stageRows = [...stages].sort((a, b) => b.created - a.created);
116 // Branches that rejoin production higher up sit nearer its line, so no curve crosses another branch.
117 const lanes = new Map([...stages].sort((a, b) => (b.base ?? 0) - (a.base ?? 0)).map((stage, i) => [stage.id, i + 1]));
118 const top: Row[] = [];
119 if (run?.code === null) {
120 top.push({
121 key: run.id, href: `/deploys/${run.title === "deploy main" ? "main" : run.target}`, node: <Status health="deploying" label="running" />, marks: [],
122 title: <b>{run.title}…</b>, class: "pending",
123 });
124 } else if (current && !recorded) {
125 const staged = stages.find((stage) => stage.release === current);
126 top.push({
127 key: current, href: staged ? `/deploys/${staged.id}` : run?.code ? `/deploys/${run.target}` : undefined, marks: [],
128 node: <Status health="degraded" label="unfinished" />,
129 title: <><b>{staged ? name(staged.service) : "deploy"}</b><span class="mono muted">{current.slice(0, 8)}</span></>,
130 flags: <span class="chip failed">didn't finish</span>,
131 });
132 }
133 const trunk: Row[] = [...top, ...history.map((entry, i): Row => ({
134 key: String(entry.n),
135 href: `/deploys/${entry.n}`,
136 node: <span class="dot" classList={{ head: i === 0 && recorded, rollback: entry.source === "rollback" }} />,
137 marks: [],
138 title: (
139 <>
140 <b>{EVENT[entry.source] ?? name(entry.source.replace(/-preview-[0-9a-f]+$/, ""))}</b>
141 <span class="mono muted">{entry.release.slice(0, 8)}</span>
142 </>
143 ),
144 flags: <>
145 <Show when={i === 0 && recorded}><span class="chip production">production</span></Show>
146 <Show when={entry.legacy}><span class="chip" data-tip="built with the old release format">legacy</span></Show>
147 </>,
148 changes: <Changed ids={entry.changed} name={name} />,
149 time: entry.time,
150 }))];
151 trunk.forEach((row, i) => (row.trunk = trunk.length === 1 ? "only" : i === 0 ? "start" : i === trunk.length - 1 ? "end" : "pass"));
152 const all: Row[] = [...stageRows.map((stage): Row => ({
153 key: stage.id,
154 href: `/deploys/${stage.id}`,
155 node: stage.health
156 ? <Status health={stage.health} label={stage.health === "healthy" ? "running" : stage.health} />
157 : <span class="dot" data-tip="health unknown without Nomad" />,
158 marks: [],
159 title: <><b>{name(stage.service)}</b><span class="mono muted">{stage.id.slice(stage.service.length + 1)}</span></>,
160 flags: <>
161 <Show when={!stage.release} fallback={<Show when={!stage.ready}><span class="chip warn">not ready</span></Show>}>
162 <span class="chip failed">staging failed</span>
163 </Show>
164 <Show when={stage.overrides.length}>
165 <span class="chip warn" tabindex="0" data-tip={`staged with --env ${stage.overrides.join(", ")}`}>unsaved settings</span>
166 </Show>
167 <Show when={stage.release === current && recorded}><span class="chip production">in production</span></Show>
168 </>,
169 changes: <Changed ids={stage.changed} name={name} warn={stage.behind} />,
170 time: stage.created,
171 })), ...trunk];
172 for (const stage of stages) {
173 const lane = lanes.get(stage.id)!;
174 const from = all.findIndex((row) => row.key === stage.id);
175 const to = stage.base ? all.findIndex((row) => row.key === String(stage.base)) : all.length;
176 all[from]!.marks.push([lane, "start"]);
177 for (let i = from + 1; i < Math.min(to, all.length); i++) all[i]!.marks.push([lane, "pass"]);
178 if (to < all.length) all[to]!.marks.push([lane, "merge"]);
179 }
180 return { all, lanes: stages.length, empty: !stages.length };
181 });
182 return (
183 <div class="graph edge" role="list" style={{ "--lanes": rows().lanes }}>
184 <Show when={rows().empty}>
185 <p class="hint">No stages. Run <span class="mono">deploy.py stage &lt;service&gt;</span> to preview a change here.</p>
186 </Show>
187 <For each={rows().all}>
188 {(row) => (
189 <div class={`row ${row.class ?? ""}`} classList={{ link: !!row.href }} role="listitem">
190 <div class="rail" aria-hidden="true">
191 <Show when={row.trunk && row.trunk !== "only"}><i class={`trunk ${row.trunk}`} /></Show>
192 <For each={row.marks}>
193 {([lane, mark]) => (
194 <>
195 <i class={`lane ${mark}`} style={{ "--x": lane }} />
196 <Show when={mark === "merge"}><i class="curve" style={{ "--x": lane }} /></Show>
197 </>
198 )}
199 </For>
200 <span class="node" style={{ "--x": row.trunk ? 0 : row.marks.find(([, mark]) => mark === "start")?.[0] }}>
201 {row.node}
202 </span>
203 </div>
204 <div class="what">
205 <Show when={row.href} fallback={<span class="title">{row.title}</span>}>
206 {(href) => <A class="title" href={href()}>{row.title}</A>}
207 </Show>
208 {row.flags}
209 </div>
210 <div class="changes">{row.changes}</div>
211 <div class="when"><Show when={row.time}>{(t) => <Ago t={t()} />}</Show></div>
212 </div>
213 )}
214 </For>
215 </div>
216 );
217 }}
218 </Loaded>
219 </ListPage>
220 );
221}
dashboard/web/pages/MCP.css created+12
......@@ -0,0 +1,12 @@
1.mcp-page h2 { margin-top: 2rem; }
2.mcp-connector, .mcp-consent { padding: 1.5rem; border: 1px solid var(--line); border-radius: 8px; margin: 1rem 0; }
3.mcp-connector h3, .mcp-consent h2 { margin-top: 0; }
4.mcp-resources { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; margin: 1.5rem 0; }
5.mcp-actions { display: flex; gap: .75rem; }
6.mcp-page table { width: 100%; text-align: left; border-collapse: collapse; }
7.mcp-page th, .mcp-page td { padding: .75rem; border-bottom: 1px solid var(--line); }
8
9.mcp-page form { margin: 1rem 0; }
10.mcp-page form label { display: flex; align-items: center; gap: .75rem; }
11.mcp-page input { padding: .5rem; }
12.mcp-page form > button { margin-top: .75rem; }
dashboard/web/pages/MCP.tsx created+133
......@@ -0,0 +1,133 @@
1import { useLocation } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js";
4import { api, reason } from "../api.ts";
5import { Ago } from "../components/Ago.tsx";
6import { Checkbox } from "../components/Checkbox.tsx";
7import { Copy } from "../components/Copy.tsx";
8import { Loaded } from "../components/Loaded.tsx";
9import { toast } from "../components/Toast.tsx";
10import "./MCP.css";
11
12export function MCP() {
13 const location = useLocation();
14 const request = () => new URLSearchParams(location.search).get("request");
15 const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get()));
16 const [consent, { refetch: retryConsent }] = createResource(() => request() || false,
17 (id) => parseResponse(api.mcp.consent[":id"].$get({ param: { id } })));
18 const [picked, setPicked] = createSignal<string[]>([]);
19 const [busy, setBusy] = createSignal(false);
20 const [code, setCode] = createSignal("");
21 const [keyName, setKeyName] = createSignal("");
22 const [keyMachines, setKeyMachines] = createSignal<string[]>([]);
23 const [control, setControl] = createSignal(false);
24 const [key, setKey] = createSignal("");
25 createEffect(() => { request(); setPicked([]); setBusy(false); });
26 let events: EventSource | undefined;
27 createEffect(() => {
28 if (!overview() || events) return;
29 events = new EventSource("/api/mcp/relay/live");
30 events.onmessage = (event) => {
31 const machines: NonNullable<ReturnType<typeof overview>>["machines"] = JSON.parse(event.data);
32 mutate((previous) => previous && { ...previous, machines });
33 };
34 });
35 onCleanup(() => events?.close());
36 const answer = async (deny: boolean) => {
37 setBusy(true);
38 try {
39 const result = await parseResponse(api.mcp.consent[":id"].$post({ param: { id: request()! }, json: deny ? { deny: true } : { resources: picked() } }));
40 window.location.assign(result.redirect);
41 } catch (error) { toast(reason(error)); setBusy(false); }
42 };
43 return <div class="page mcp-page">
44 <header class="page-header"><h1>MCP</h1></header>
45 <Show when={request()}>
46 <Loaded data={consent} what="connection request" retry={retryConsent}>
47 {(details) => <section class="mcp-consent">
48 <h2>Connect {details().client}</h2>
49 <p>{details().scopes.includes("shale:read") ? "Choose repositories this connection can read issues from." : details().scopes.includes("sessions:read") ? "Choose machines this connection can read sessions from." : "Choose services this connection can read logs and traces from."}</p>
50 <Show when={details().scopes.includes("sessions:write")}><p>This connection can send messages, start sessions, and interrupt turns on the selected machines.</p></Show>
51 <Show when={details().scopes.includes("shale:write")}><p>This connection can create issues, comment, and change issue status in the selected repositories.</p></Show>
52 <div class="mcp-resources"><For each={details().resources}>{(resource) =>
53 <Checkbox checked={picked().includes(resource.id)} onChange={(checked) => setPicked(checked ? [...picked(), resource.id] : picked().filter((item) => item !== resource.id))}>{resource.name}</Checkbox>
54 }</For></div>
55 <Show when={!details().linked}><p>Link your Shale account below to choose repositories.</p></Show>
56 <Show when={details().linked && !details().resources.length}><p>No resources are available to your account.</p></Show>
57 <Show when={details().scopes.includes("offline_access")}><p class="muted">This connection can refresh access without another sign-in.</p></Show>
58 <div class="mcp-actions"><button class="button" disabled={!picked().length || busy()} onClick={() => answer(false)}>Allow access</button>
59 <button class="button secondary" disabled={busy()} onClick={() => answer(true)}>Decline</button></div>
60 </section>}
61 </Loaded>
62 </Show>
63 <Loaded data={overview} what="MCP connections" retry={refetch}>
64 {(data) => <>
65 <h2>Connectors</h2>
66 <For each={data().catalogs}>{(catalog) => <section class="mcp-connector">
67 <h3>{catalog.name}</h3><p>Add this endpoint to your AI client. Access is granted when you connect.</p>
68 <Copy value={catalog.endpoint} label="connector endpoint" />
69 </section>}</For>
70 <section class="mcp-connector"><h3>Shale account</h3>
71 <Show when={data().shale} fallback={<p>Link your Shale account to grant clients access to its repositories.</p>}>
72 {(shale) => <p>Account linked <Ago t={shale().linkedAt} /></p>}
73 </Show>
74 <button class="button" disabled={busy()} onClick={async () => {
75 setBusy(true);
76 try { const result = await parseResponse(api.mcp.shale.$post({ json: { request: consent()?.scopes.includes("shale:read") ? request() || undefined : undefined } })); window.location.assign(result.redirect); }
77 catch (error) { toast(reason(error)); setBusy(false); }
78 }}>{data().shale ? "Relink account" : "Link account"}</button>
79 <Show when={data().shale}><button class="button secondary" disabled={busy()} onClick={async () => {
80 setBusy(true);
81 try { await parseResponse(api.mcp.shale.$delete()); await refetch(); }
82 catch (error) { toast(reason(error)); }
83 finally { setBusy(false); }
84 }}>Unlink</button></Show>
85 </section>
86 <h2>Local machines</h2>
87 <p>Run the Agent Relay local agent with this dashboard's origin, then enter its pairing code.</p>
88 <Copy value={`npm run agent -- run --server ${window.location.origin}`} label="local agent command" />
89 <form class="mcp-actions" onSubmit={async (event) => {
90 event.preventDefault(); setBusy(true);
91 try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); }
92 catch (error) { toast(reason(error)); }
93 finally { setBusy(false); }
94 }}><label>Pairing code <input value={code()} onInput={(event) => setCode(event.currentTarget.value)} maxLength={40} /></label>
95 <button class="button" disabled={!code().trim() || busy()}>Link machine</button></form>
96 <Show when={data().machines.length} fallback={<p class="muted">No machines linked yet. Pair a local agent to connect it.</p>}>
97 <table><thead><tr><th>Machine</th><th>Platform</th><th>Connection</th><th /></tr></thead><tbody>
98 <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td>
99 <button class="button small" onClick={async () => {
100 try { await parseResponse(api.mcp.relay.machines[":id"].$delete({ param: { id: machine.id } })); await refetch(); }
101 catch (error) { toast(reason(error)); }
102 }}>Unlink</button>
103 </td></tr>}</For>
104 </tbody></table>
105 <h3>API key</h3>
106 <form onSubmit={async (event) => {
107 event.preventDefault(); setBusy(true);
108 try { const result = await parseResponse(api.mcp.relay.keys.$post({ json: { name: keyName(), resources: keyMachines(), write: control() } })); setKey(result.key); setKeyName(""); setKeyMachines([]); setControl(false); await refetch(); }
109 catch (error) { toast(reason(error)); }
110 finally { setBusy(false); }
111 }}>
112 <label>Key name <input value={keyName()} onInput={(event) => setKeyName(event.currentTarget.value)} maxLength={100} /></label>
113 <div class="mcp-resources"><For each={data().machines}>{(machine) => <Checkbox checked={keyMachines().includes(machine.id)} onChange={(checked) => setKeyMachines(checked ? [...keyMachines(), machine.id] : keyMachines().filter((id) => id !== machine.id))}>{machine.name}</Checkbox>}</For></div>
114 <Checkbox checked={control()} onChange={setControl}>Allow session control</Checkbox>
115 <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button>
116 </form>
117 </Show>
118 <Show when={key()}><section class="mcp-connector"><h3>New API key</h3><p>Copy this key now. It won't be shown again.</p><Copy value={key()} label="API key" /><button class="button secondary" onClick={() => setKey("")}>Dismiss</button></section></Show>
119 <h2>Connections</h2>
120 <Show when={data().connections.length} fallback={<p class="muted">No clients connected yet. Add a connector endpoint to your AI client to get started.</p>}>
121 <table><thead><tr><th>Client</th><th>Access</th><th>Added</th><th /></tr></thead><tbody>
122 <For each={data().connections}>{(connection) => <tr><td>{connection.name}</td><td>{connection.resources.join(", ")}<Show when={connection.scopes.includes("sessions:write")}><span class="muted"> · Session control</span></Show><Show when={connection.scopes.includes("shale:write")}><span class="muted"> · Issue editing</span></Show></td><td><Ago t={connection.createdAt} /></td><td>
123 <button class="button small" onClick={async () => {
124 try { await parseResponse(api.mcp.connections[":id"].$delete({ param: { id: connection.id } })); await refetch(); toast("Connection revoked"); }
125 catch (error) { toast(reason(error)); }
126 }}>Revoke</button>
127 </td></tr>}</For>
128 </tbody></table>
129 </Show>
130 </>}
131 </Loaded>
132 </div>;
133}
dashboard/web/pages/Media.tsx created+53
......@@ -0,0 +1,53 @@
1import { useSearchParams } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import { createSignal, Show } from "solid-js";
4import { api, queries, reason } from "../api.ts";
5import { Explorer } from "../components/Explorer.tsx";
6import { ListPage } from "../components/ListPage.tsx";
7import { lastGood } from "../components/Loaded.tsx";
8import { OpenApp } from "../components/OpenApp.tsx";
9import { toast } from "../components/Toast.tsx";
10
11export function Media() {
12 const [params] = useSearchParams<{ path?: string }>();
13 const here = () => params.path ?? "";
14 const apps = lastGood(queries.launcher.use()[0]);
15 /** Jellyfin has no page per folder, so a show, movie or artist folder opens a search for its title. */
16 const jellyfin = () => {
17 const found = apps()?.find((app) => app.id === "jellyfin");
18 const [top, , title] = here().split("/");
19 if (!found) return null;
20 return {
21 ...found,
22 href: top === "jellyfin" && title
23 ? `${found.url}/web/#/search?query=${encodeURIComponent(title.replace(/ \(\d{4}\)$/, ""))}`
24 : `${found.url}/web/`,
25 };
26 };
27 const [refreshing, setRefreshing] = createSignal(false);
28 const refresh = async () => {
29 setRefreshing(true);
30 try {
31 await parseResponse(api.media.refresh.$post());
32 toast("Jellyfin started scanning all libraries.");
33 } catch (failure) {
34 toast(reason(failure));
35 } finally {
36 setRefreshing(false);
37 }
38 };
39
40 return (
41 <ListPage id="media" flush head={
42 <div class="page-head">
43 <h1>media</h1>
44 <Show when={jellyfin()}>{(link) => <OpenApp app={link()} href={link().href} />}</Show>
45 <span class="spacer" />
46 <button class="button" disabled={refreshing()} aria-busy={refreshing()} onClick={refresh}
47 data-tip="scans all Jellyfin libraries">scan jellyfin</button>
48 </div>
49 }>
50 <Explorer id="media" client={api.media} root="library" />
51 </ListPage>
52 );
53}
dashboard/web/pages/Overview.css created+99
......@@ -0,0 +1,99 @@
1/* home: the launcher everyone who isn't an admin lands on ------------------ */
2
3.home { display: grid; justify-items: center; align-content: start; max-width: 760px; padding-top: max(40px, 9vh); }
4.home .globe { width: 52px; height: 52px; }
5.page h1.greeting { margin: 0; font-size: 28px; font-weight: 250; letter-spacing: -0.01em; line-height: 1.2; }
6.page.home h1.greeting { margin: 14px 0 30px; text-align: center; text-wrap: balance; }
7.home .empty p { margin: 0 0 4px; }
8.home .load { display: flex; align-items: center; gap: 8px; margin: -18px 0 26px; color: var(--text-2); font-size: 13px; }
9.home .load .meter { width: 48px; min-width: 0; }
10.home .issues { width: 100%; max-width: 600px; margin-top: 16px; }
11.home .empty p:first-child { color: var(--text-2); }
12
13/* An app is a card that opens it; admins and friends see the same ones. */
14.apps { display: grid; grid-template-columns: repeat(auto-fill, minmax(136px, 1fr)); gap: 4px; align-content: start; }
15.apps > .skeleton { height: 40px; border-radius: var(--radius); }
16.app {
17 display: flex;
18 align-items: center;
19 gap: 9px;
20 min-width: 0;
21 height: 40px;
22 padding: 0 10px 0 8px;
23 border: 1px solid var(--line);
24 border-radius: var(--radius);
25 background: var(--surface);
26 color: var(--text);
27 font-size: 13px;
28 transition: background-color 150ms, border-color 150ms;
29}
30.app:hover { background: var(--hover); border-color: var(--axis); }
31.app :is(img, .monogram) { flex: none; width: 24px; height: 24px; font-size: 12px; transition: transform 200ms var(--ease-out); }
32.app:hover :is(img, .monogram) { transform: scale(1.08); }
33.app:active :is(img, .monogram) { transform: scale(0.94); transition-duration: 100ms; }
34.app .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
35.app .status { flex: none; }
36.app .status-label { flex: none; color: var(--text-2); font-size: 12px; }
37.app.out :is(img, .monogram) { filter: grayscale(1); opacity: 0.45; }
38.app.out .name { color: var(--text-2); }
39.app.down { border-color: color-mix(in srgb, var(--critical) 55%, var(--line)); }
40.app.degraded { border-color: color-mix(in srgb, var(--warning) 55%, var(--line)); }
41.app > .skeleton.icon { width: 24px; height: 24px; border-radius: 22%; }
42
43.home .apps { width: 100%; max-width: 600px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }
44.home .app { height: 52px; padding: 0 14px 0 12px; gap: 12px; border-radius: var(--radius-lg); font-size: 14px; }
45.home .app :is(img, .monogram) { width: 30px; height: 30px; font-size: 14px; }
46.home .app > .skeleton.icon { width: 30px; height: 30px; }
47
48/* admin overview ---------------------------------------------------------- */
49
50.overview .page-head { margin-bottom: 14px; }
51
52.staging { padding: 12px 14px; margin-bottom: 18px; border: 1px solid color-mix(in srgb, var(--warning) 50%, var(--line)); border-radius: var(--radius); background: color-mix(in srgb, var(--warning) 6%, var(--page)); }
53.staging-heading { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
54.staging-heading h2 { margin: 0; color: color-mix(in srgb, var(--warning) 65%, var(--text)); }
55.staging-heading span { color: var(--text-2); font-size: 12px; }
56.staging-list { display: flex; flex-wrap: wrap; gap: 6px; }
57.staging-list a { display: flex; align-items: center; gap: 7px; padding: 5px 9px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); font-size: 13px; }
58.staging-list a:hover { border-color: var(--warning); }
59.staging-list a .mono { font-size: 11px; }
60.staging-list a[aria-busy="true"] { opacity: 0.6; }
61.stage-menu {
62 position: fixed; inset: auto; margin: 0; padding: 4px; min-width: 140px;
63 border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
64 box-shadow: 0 8px 24px #0002;
65}
66.stage-menu button {
67 display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px;
68 border: 0; border-radius: 4px; background: none; color: var(--critical); text-align: left;
69}
70.stage-menu button:hover, .stage-menu button:focus-visible { background: var(--hover); }
71.stage-menu button:disabled { opacity: 0.5; }
72
73.glance { display: grid; grid-template-columns: minmax(290px, 2fr) minmax(0, 3fr); gap: 24px; align-items: start; }
74.changes { margin: 0; padding: 0; list-style: none; font-size: 13px; }
75.changes li { display: grid; grid-template-columns: 14px 1fr auto; gap: 10px; align-items: center; min-height: 32px; border-bottom: 1px solid var(--grid); }
76.changes li:last-child { border-bottom: 0; }
77.changes li > svg { color: var(--muted); }
78.changes li.warn > svg:not(.status) { color: var(--warning); }
79.changes .what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
80.changes a { border-radius: 3px; transition: color 150ms; }
81.changes .muted a { color: var(--text-2); text-decoration: underline dotted var(--axis); text-underline-offset: 3px; }
82.changes a[href]:hover { color: var(--accent); text-decoration-color: currentColor; }
83.changes.issues { border-bottom: 1px solid var(--grid); }
84.issues li:has(> button) { grid-template-columns: 14px 1fr auto auto; }
85.issues li > .status { justify-self: center; }
86.issues .button.icon-only { --control: 22px; width: 22px; }
87.changes time { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
88
89.section-head { display: flex; align-items: center; gap: 12px; margin: 22px 0 6px; }
90.section-head h2 { margin: 0; }
91.section-head > .segmented { margin-left: auto; }
92.section-head h2 a[href]:hover { text-decoration: underline; text-underline-offset: 3px; }
93.changes li > .skeleton { grid-column: 1 / -1; }
94
95.pool { margin-bottom: 4px; }
96.pool .donut-row svg { width: 120px; height: 120px; }
97.pool .changes .scanning { display: flex; align-items: center; gap: 8px; }
98.pool .changes .meter { flex: 1; max-width: 160px; }
99.pool .changes li > .muted { font-size: 12px; }
dashboard/web/pages/Overview.tsx created+527
......@@ -0,0 +1,527 @@
1import { type InferResponseType, parseResponse } from "hono/client";
2import ArrowDown from "lucide-solid/icons/arrow-down";
3import ArrowUp from "lucide-solid/icons/arrow-up";
4import BatteryWarning from "lucide-solid/icons/battery-warning";
5import HardDrive from "lucide-solid/icons/hard-drive";
6import Power from "lucide-solid/icons/power";
7import Rocket from "lucide-solid/icons/rocket";
8import RotateCcw from "lucide-solid/icons/rotate-ccw";
9import ShieldCheck from "lucide-solid/icons/shield-check";
10import Trash from "lucide-solid/icons/trash";
11import Undo2 from "lucide-solid/icons/undo-2";
12import X from "lucide-solid/icons/x";
13import { createMemo, createResource, createRoot, createSignal, For, type JSX, onCleanup, Show } from "solid-js";
14import { type Health, type HostInfo, type Live, type Me, type Series, type ServiceSummary, trouble } from "../types/model.ts";
15import { api, queries, reason } from "../api.ts";
16import { Ago } from "../components/Ago.tsx";
17import { AppIcon } from "../components/AppIcon.tsx";
18import { Donut } from "../components/Donut.tsx";
19import { lastGood, Loaded } from "../components/Loaded.tsx";
20import { Meter } from "../components/Meter.tsx";
21import { OpenApp } from "../components/OpenApp.tsx";
22import { RangePicker } from "../components/Metric.tsx";
23import { Status, StatusLabel } from "../components/Status.tsx";
24import { type Reading, Strip } from "../components/Strip.tsx";
25import { TabBar } from "../components/TabBar.tsx";
26import { toast } from "../components/Toast.tsx";
27import { ago, bytes, celsius, cores, duration, percent, plural, rate, watts } from "../format.ts";
28import { stream } from "../live.ts";
29import snowflake from "../snowflake.svg";
30import { confirmDestroyStage, followRun, type StageInfo } from "./Deploys.tsx";
31import "./Overview.css";
32
33/** How busy the machine is and what needs a look, polled for the overview and the sidebar's badge. */
34export const status = createRoot(() => {
35 const [data, { refetch }] = createResource(() => parseResponse(api.status.$get()));
36 setInterval(refetch, 30_000);
37 return { latest: lastGood(data), refetch };
38});
39
40/** Opens an app; its tagline is the tooltip, for people who don't know the app by name. */
41function AppCard(props: {
42 app: Pick<ServiceSummary, "id" | "name" | "icon" | "tagline" | "url">;
43 /** Null while it's unknown. */
44 health: Health | null;
45 children?: JSX.Element;
46}) {
47 return (
48 <a class="app" classList={{
49 out: props.health !== null && props.health !== "healthy" && props.health !== "degraded",
50 down: props.health === "down",
51 degraded: props.health === "degraded",
52 }}
53 href={props.app.url ?? undefined} target="_blank" rel="noreferrer" data-tip={props.app.tagline ?? undefined}>
54 <AppIcon id={props.app.id} name={props.app.name} icon={props.app.icon} />
55 <span class="name">{props.app.name}</span>
56 {props.children}
57 </a>
58 );
59}
60
61/** Snow gets her own line, except while previewing the dashboard as someone else. */
62function Greeting(props: { me: Me }) {
63 return (
64 <h1 class="greeting">
65 {props.me.name === "snow" && !props.me.viewing
66 ? "hi snow. keep it up."
67 : `hi ${props.me.name}… welcome to my cozy little snow globe…`}
68 </h1>
69 );
70}
71
72/** Apps that are down or degraded or restarted unacknowledged; admins reach each service and clear its restart. */
73function Issues(props: { admin: boolean }) {
74 const clear = (id: string) => parseResponse(api.services[":id"].restarts.acknowledge.$post({ param: { id } }))
75 .then(status.refetch, (failure) => toast(`Couldn't clear the restart. ${reason(failure)}`));
76 return (
77 <Show when={status.latest()?.issues?.length}>
78 <ul class="changes issues" aria-label="Needs a look">
79 <For each={status.latest()!.issues!}>
80 {(issue) => {
81 const [clearing, setClearing] = createSignal(false);
82 const name = () => props.admin ? <a href={`/services/${issue.service.id}`}>{issue.service.name}</a> : issue.service.name;
83 return (
84 <>
85 <Show when={trouble(issue.health)}>
86 <li class="warn">
87 <Status health={issue.health} />
88 <span class="what">{name()} {issue.health} <span class="muted">{issue.failing}</span></span>
89 </li>
90 </Show>
91 <Show when={issue.restart}>
92 {(restart) => (
93 <li class="warn">
94 <RotateCcw size={14} aria-hidden="true" />
95 <span class="what">{name()} restarted <span class="muted">{restart().reason}</span></span>
96 <Ago t={restart().t} />
97 <Show when={props.admin}>
98 <button class="button icon-only" aria-label={`Clear ${issue.service.name}'s restart`} data-tip="clear"
99 disabled={clearing()} aria-busy={clearing()} onClick={() => {
100 setClearing(true);
101 clear(issue.service.id).finally(() => setClearing(false));
102 }}>
103 <Show when={!clearing()}><X size={12} /></Show>
104 </button>
105 </Show>
106 </li>
107 )}
108 </Show>
109 </>
110 );
111 }}
112 </For>
113 </ul>
114 </Show>
115 );
116}
117
118/** The front door for everyone without machine metrics: their apps, a word when one is out, and how busy it all is. */
119function Home(props: { me: Me }) {
120 const [apps, { refetch }] = queries.launcher.use();
121 const timer = setInterval(refetch, 30_000);
122 onCleanup(() => clearInterval(timer));
123 return (
124 <div class="page home">
125 <img class="globe" src={snowflake} alt="" />
126 <Greeting me={props.me} />
127 <Show when={status.latest()}>
128 {(now) => (
129 <p class="load" tabIndex={0} data-tip={`${percent(now().load)} load over 5 min`}>
130 <Meter value={now().load} max={100} />
131 the snow globe is {now().load < 25 ? "quiet" : now().load < 60 ? "busy" : "very busy"}
132 </p>
133 )}
134 </Show>
135 <Loaded data={apps} what="your apps" retry={refetch} skeleton={
136 <div class="apps">
137 <For each={Array(4)}>
138 {() => <div class="app"><span class="skeleton icon" /><span class="skeleton" style={{ width: "72px" }} /></div>}
139 </For>
140 </div>
141 }>
142 {(list) => (
143 <Show when={list().length} fallback={
144 <div class="empty"><p>No apps shared with you yet.</p><p>They show up here once Clover gives you access.</p></div>
145 }>
146 <nav class="apps" aria-label="Your apps">
147 <For each={list()}>
148 {(app) => (
149 <AppCard app={app} health={app.health}>
150 <Show when={app.health !== "healthy" && app.health}>
151 {(health) => <StatusLabel health={health() === "stopped" ? "down" : trouble(health()) ? health() : "restarting"} />}
152 </Show>
153 </AppCard>
154 )}
155 </For>
156 </nav>
157 </Show>
158 )}
159 </Loaded>
160 <Issues admin={false} />
161 </div>
162 );
163}
164
165type Storage = InferResponseType<typeof api.storage.$get, 200>;
166
167const latestOf = (series: Series | undefined) => series?.v.findLast((v) => v != null) ?? undefined;
168
169/** The machine at a glance, for admins and the metrics group: apps, what changed, storage, and the machine's charts. */
170function Dashboard(props: { me: Me }) {
171 const admin = props.me.sections.includes("admin");
172 stream.start();
173 const [launcher, { refetch: refetchLauncher }] = queries.launcher.use();
174 const [host, { refetch: refetchHost }] = queries.host.use();
175 const [storage, { refetch: refetchStorage }] = queries.storage.use();
176 const [services, { refetch: refetchServices }] = queries.services.use();
177 const [deploys, { refetch: refetchDeploys }] = queries.deploys.use(() => (admin ? undefined : false));
178 const timer = setInterval(() => [refetchLauncher, refetchHost, refetchStorage, refetchServices, refetchDeploys]
179 .forEach((refetch) => refetch()), 60_000);
180 onCleanup(() => clearInterval(timer));
181 const info = lastGood(host);
182 const pool = lastGood(storage);
183 const apps = lastGood(launcher);
184 const history = lastGood(deploys);
185 const stages = () => (history()?.stages ?? []).filter((stage) =>
186 !stage.ready || (stage.health !== "down" && stage.health !== "stopped"));
187 const known = lastGood(services);
188 const names = () => Object.fromEntries((known() ?? []).map((service) => [service.id, service.name]));
189 const name = (id: string) => names()[id] ?? id;
190 const health = (service: { id: string; health: Health | null }) =>
191 stream.latest()?.services[service.id]?.health ?? service.health;
192 const metrics = () => apps()?.find((app) => app.id === "victoria-metrics");
193
194 const [menuId, setMenuId] = createSignal<string>();
195 const selectedStage = () => history()?.stages.find((stage) => stage.id === menuId());
196 const [destroying, setDestroying] = createSignal<string>();
197 let stageMenu!: HTMLDivElement;
198 let stageAnchor: HTMLAnchorElement;
199 let destroyRun: EventSource | undefined;
200 onCleanup(() => destroyRun?.close());
201 const showStageMenu = (stage: StageInfo, event: (MouseEvent | KeyboardEvent) & { currentTarget: HTMLAnchorElement }) => {
202 event.preventDefault();
203 stageAnchor = event.currentTarget;
204 stageAnchor.focus();
205 setMenuId(stage.id);
206 stageMenu.showPopover();
207 const rect = stageAnchor.getBoundingClientRect();
208 const x = event instanceof MouseEvent ? event.clientX : rect.left;
209 const y = event instanceof MouseEvent ? event.clientY : rect.bottom;
210 stageMenu.style.left = `${Math.max(8, Math.min(x, innerWidth - stageMenu.offsetWidth - 8))}px`;
211 stageMenu.style.top = `${Math.max(8, Math.min(y, innerHeight - stageMenu.offsetHeight - 8))}px`;
212 stageMenu.querySelector("button")?.focus();
213 };
214 const destroyStage = (stage: StageInfo) => {
215 stageMenu.hidePopover();
216 confirmDestroyStage(stage, (run) => {
217 setDestroying(stage.id);
218 destroyRun = followRun(run.id, {
219 exit: async (code) => {
220 setDestroying();
221 await refetchDeploys();
222 if (code !== 0) toast("Couldn't destroy the preview. Open its output, then retry.", {
223 label: "output", run: async () => location.assign(`/deploys/${stage.id}/output`),
224 });
225 },
226 lost: () => {
227 setDestroying();
228 toast("Lost destroy progress. Open its output to check the result.", {
229 label: "output", run: async () => location.assign(`/deploys/${stage.id}/output`),
230 });
231 },
232 });
233 refetchDeploys();
234 }, stageAnchor);
235 };
236
237 const [range, setRange] = createSignal(3600);
238 const [splitCpu, setSplitCpu] = createSignal(false);
239 const [splitMemory, setSplitMemory] = createSignal(false);
240 const groupBy = (value: () => boolean, set: (value: boolean) => void) => (
241 <TabBar label="Group by">
242 <button aria-pressed={!value()} onClick={() => set(false)}>total</button>
243 <button aria-pressed={value()} onClick={() => set(true)}>by service</button>
244 </TabBar>
245 );
246 const live = (read: (tick: Live, host: HostInfo) => Reading) => () => {
247 const tick = stream.latest();
248 const host = info();
249 return tick && host ? { ...read(tick, host), stale: !stream.live() } : undefined;
250 };
251
252 type Change = { t: number; icon: typeof Rocket; what: JSX.Element; detail?: JSX.Element; warn?: boolean };
253 const changes = createMemo(() => {
254 const list: Change[] = [];
255 const host = info();
256 for (const outage of host?.outages ?? []) {
257 list.push({
258 t: outage.start, icon: BatteryWarning, what: "on battery", warn: outage.end === null,
259 detail: outage.end === null ? "now" : `for ${duration(outage.end - outage.start)}`,
260 });
261 }
262 for (const entry of history()?.history ?? []) {
263 const rollback = entry.source === "rollback";
264 if (!rollback && entry.changed?.length === 0) continue;
265 list.push({
266 t: entry.time, icon: rollback ? Undo2 : Rocket,
267 what: <a href="/deploys" data-tip={`release ${entry.release.slice(0, 8)}`}>{rollback ? "rolled back" : "updated"}</a>,
268 detail: (
269 <For each={entry.changed ?? []}>
270 {(id, i) => <>{i() ? ", " : ""}<a href={`/services/${id}`}>{name(id)}</a></>}
271 </For>
272 ),
273 });
274 }
275 const recent = list.filter((change) => !host || change.t > host.bootedAt).sort((a, b) => b.t - a.t).slice(0, 7);
276 // Everything older than the boot is history; the boot itself always closes the list.
277 return host
278 ? [...recent, { t: host.bootedAt, icon: Power, what: "booted", detail: `up ${duration(Date.now() / 1000 - host.bootedAt)}` }]
279 : recent;
280 });
281
282 return (
283 <div class="page overview">
284 <div class="page-head"><Greeting me={props.me} /></div>
285
286 <Show when={stages().length}>
287 <section class="staging" aria-label="Staging instances">
288 <div class="staging-heading"><h2>staging</h2><span>{plural(stages().length, "preview")}</span></div>
289 <div class="staging-list">
290 <For each={stages().map((stage) => stage.id)}>
291 {(id) => <Show when={stages().find((stage) => stage.id === id)}>{(stage) => (
292 <a href={`/deploys/${id}`} aria-haspopup="menu" aria-controls="stage-menu"
293 aria-busy={destroying() === id}
294 onContextMenu={(event) => showStageMenu(stage(), event)}
295 onKeyDown={(event) => {
296 if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) showStageMenu(stage(), event);
297 }}>
298 <Show when={destroying() === id} fallback={
299 <Show when={stage().health} fallback={<span class="muted">unknown</span>}>
300 {(health) => <Status health={health()} />}
301 </Show>
302 }><Status health="deploying" label="destroying" /></Show>
303 <span>{name(stage().service)}</span>
304 <span class="mono muted">{id.slice(stage().service.length + "-preview-".length)}</span>
305 </a>
306 )}</Show>}
307 </For>
308 </div>
309 </section>
310 </Show>
311
312 <div ref={stageMenu} id="stage-menu" popover="auto" class="stage-menu" role="menu"
313 aria-label="Preview actions" onToggle={(event) => event.newState === "closed" && setMenuId()}
314 onKeyDown={(event) => {
315 if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) event.preventDefault();
316 }}>
317 <Show when={selectedStage()}>
318 {(stage) => <button role="menuitem" disabled={!!destroying() || history()?.run?.code === null}
319 onClick={() => destroyStage(stage())}><Trash size={14} aria-hidden="true" />destroy</button>}
320 </Show>
321 </div>
322
323 <div class="glance">
324 <Loaded data={launcher} what="apps" retry={refetchLauncher} skeleton={
325 <div class="apps"><For each={Array(10)}>{() => <span class="skeleton" />}</For></div>
326 }>
327 {(list) => (
328 <Show when={list().length} fallback={<p class="muted">No apps shared with you yet.</p>}>
329 <nav class="apps" aria-label="Apps">
330 <For each={list()}>
331 {(app) => (
332 <AppCard app={app} health={health(app)}>
333 <Show when={health(app) !== "healthy" && health(app)}>{(now) => <Status health={now()} />}</Show>
334 </AppCard>
335 )}
336 </For>
337 </nav>
338 </Show>
339 )}
340 </Loaded>
341 <section aria-label="Recent changes">
342 <Issues admin={admin} />
343 <Loaded data={host} what="recent changes" retry={refetchHost} skeleton={
344 <ul class="changes"><For each={Array(5)}>{() => <li><span class="skeleton" style={{ width: "60%" }} /></li>}</For></ul>
345 }>
346 {() => (
347 <ul class="changes">
348 <For each={changes()}>
349 {(change) => (
350 <li classList={{ warn: change.warn }}>
351 <change.icon size={14} aria-hidden="true" />
352 <span class="what">{change.what} <span class="muted">{change.detail}</span></span>
353 <Ago t={change.t} />
354 </li>
355 )}
356 </For>
357 </ul>
358 )}
359 </Loaded>
360 </section>
361 </div>
362
363 <div class="section-head"><h2><a href={admin ? "/storage" : undefined}>storage</a></h2></div>
364 <Loaded data={storage} what="the pool" retry={refetchStorage} skeleton={
365 <div class="glance"><div class="skeleton" style={{ height: "140px" }} /><div class="skeleton" style={{ height: "64px" }} /></div>
366 }>
367 {(data) => <Pool data={data()} disks={admin ? "/storage?tab=disks" : undefined} />}
368 </Loaded>
369
370 <div class="section-head">
371 <h2>machine</h2>
372 <Show when={metrics()}>{(app) => <OpenApp app={app()} href={`${app().url}/vmui/`} />}</Show>
373 <RangePicker value={range()} onChange={setRange} />
374 </div>
375 <div class="strips">
376 <Strip title="cpu" metric={splitCpu() ? "service.cpu" : "host.cpu"} range={range()} split={splitCpu()} names={names()}
377 stacked={splitCpu()} scale={splitCpu() ? 100 / (info()?.cores ?? 1) : undefined} format={percent} max={100} height={104}
378 tabs={groupBy(splitCpu, setSplitCpu)}
379 now={live((tick, host) => {
380 const [top] = Object.entries(tick.services).flatMap(([id, { cpu }]) => (cpu === null ? [] : [[id, cpu] as const]))
381 .sort((a, b) => b[1] - a[1]);
382 return {
383 value: percent(tick.host.cpu),
384 tip: `${cores((tick.host.cpu / 100) * host.cores)} / ${host.cores} cores`
385 + (top ? ` · top: ${name(top[0])} ${cores(top[1])}` : ""),
386 };
387 })} />
388 <Strip title="memory" metric={splitMemory() ? "service.memory" : "host.memory"} range={range()} split={splitMemory()}
389 names={names()} format={bytes} stacked max={splitMemory() ? undefined : info()?.memory} height={112}
390 tabs={groupBy(splitMemory, setSplitMemory)}
391 now={live((tick, host) => ({
392 value: bytes(tick.host.memory),
393 tip: `${bytes(tick.host.memory)} / ${bytes(host.memory)} (${percent((tick.host.memory / host.memory) * 100)})`
394 + (tick.host.arc === null ? "" : ` · ${bytes(tick.host.arc)} ZFS cache`),
395 }))} />
396 <Strip title="gpu" metric="host.gpu" range={range()} format={percent} max={100} height={80}
397 now={(series) => {
398 const value = latestOf(series[0]);
399 return value === undefined ? undefined : { value: percent(value) };
400 }} />
401 <Strip title="power" metric="host.power" range={range()} format={watts} height={80}
402 now={live(({ ups }, host) => {
403 if (!ups) return { value: "–", tip: "No UPS is connected to this host yet." };
404 if (ups.status === "battery") {
405 return {
406 value: <StatusLabel health="degraded">on battery, {duration(ups.runtime)} left</StatusLabel>,
407 tip: `${watts(ups.load)} · ${percent(ups.charge)} charged`,
408 };
409 }
410 const outage = host.outages?.at(-1);
411 return {
412 value: watts(ups.load),
413 tip: [
414 `${duration(ups.runtime)} on battery`,
415 ups.charge < 100 && `${percent(ups.charge)} charged`,
416 outage && `last outage ${ago(outage.start)}, ${duration((outage.end ?? Date.now() / 1000) - outage.start)}`,
417 ].filter(Boolean).join(" · "),
418 };
419 })} />
420 <Strip title="temperature" metric="host.temperature" range={range()} format={celsius} zero={false} height={120}
421 now={live((tick) => {
422 const hottest = pool()?.pool.vdevs.flatMap((vdev) => vdev.disks).flatMap((disk) => disk.smart ?? [])
423 .sort((a, b) => b.temperature - a.temperature)[0];
424 return {
425 value: tick.host.temperature === null ? "–" : celsius(tick.host.temperature),
426 tip: `${tick.host.temperature === null ? "no cpu sensor read yet" : "cpu"}`
427 + `${hottest ? ` · hottest drive ${celsius(hottest.temperature)}, ${hottest.model}` : ""}`,
428 };
429 })} />
430 <Strip title="network" metric="host.network" range={range()} format={rate} height={104 + 24} last
431 now={(series) => {
432 const [down, up] = series.map(latestOf);
433 return down === undefined || up === undefined ? undefined : {
434 value: (
435 <><ArrowDown size={14} aria-label="download" />{rate(down)}<ArrowUp size={14} aria-label="upload" />{rate(up)}</>
436 ),
437 };
438 }} />
439 </div>
440 </div>
441 );
442}
443
444/** How full the pool is, which disks need a look and why, and the latest scrub; `disks` links to the disk list. */
445function Pool(props: { data: Storage; disks?: string }) {
446 const space = () => props.data.space;
447 const used = () => space().live + space().held;
448 const disks = () => props.data.pool.vdevs.flatMap((vdev) => vdev.disks);
449 const ailing = () => disks().filter((disk) => disk.issue);
450 const scan = () => props.data.pool.scan;
451 return (
452 <div class="glance pool">
453 <Donut format={bytes} center={percent((used() / (used() + space().free)) * 100)} caption="full" slices={[
454 { label: "used", value: space().live, color: "var(--series-1)" },
455 { label: "snapshots", value: space().held, color: "var(--series-2)" },
456 { label: "free", value: space().free, color: "var(--other)" },
457 ]} />
458 <ul class="changes">
459 <Show when={ailing().length} fallback={
460 <li>
461 <HardDrive size={14} aria-hidden="true" />
462 <a class="what" href={props.disks}><StatusLabel health="healthy">all {disks().length} disks healthy</StatusLabel></a>
463 </li>
464 }>
465 <For each={ailing()}>
466 {(disk) => (
467 <li class="warn">
468 <HardDrive size={14} aria-hidden="true" />
469 <span class="what">
470 <a href={props.disks}
471 data-tip={disk.smart ? `${disk.smart.model}, ${celsius(disk.smart.temperature)}` : undefined}>
472 {disk.smart?.serial ?? disk.name}
473 </a> <span class="muted">{disk.issue}</span>
474 </span>
475 </li>
476 )}
477 </For>
478 </Show>
479 <Show when={scan()} fallback={
480 <li><ShieldCheck size={14} aria-hidden="true" /><span class="what muted">never scrubbed</span></li>
481 }>
482 {(scan) => (
483 <Show when={scan().state === "scanning"} fallback={
484 <li classList={{ warn: scan().errors > 0 }}>
485 <ShieldCheck size={14} aria-hidden="true" />
486 <span class="what"
487 data-tip={scan().end ? `${bytes(scan().examined)} checked in ${duration(scan().end! - scan().start)}` : undefined}>
488 {scan().kind === "scrub" ? "scrub" : "resilver"} {scan().state}{" "}
489 <span class="muted">
490 {scan().errors ? plural(scan().errors, "unrepaired error")
491 : scan().repaired ? `repaired ${bytes(scan().repaired)}` : scan().state === "finished" ? "no errors" : ""}
492 </span>
493 </span>
494 <Ago t={scan().end ?? scan().start} />
495 </li>
496 }>
497 {(() => {
498 const elapsed = () => Date.now() / 1000 - scan().start;
499 const left = () => ((scan().total - scan().examined) / scan().examined) * elapsed();
500 return (
501 <li>
502 <ShieldCheck size={14} aria-hidden="true" />
503 <span class="what scanning"
504 data-tip={`${bytes(scan().examined)} / ${bytes(scan().total)}, started ${ago(scan().start)}`}>
505 {scan().kind === "scrub" ? "scrubbing" : "resilvering"}
506 <Meter value={scan().examined} max={scan().total} />
507 {percent((scan().examined / scan().total) * 100)}
508 </span>
509 <span class="muted">{duration(left())} left</span>
510 </li>
511 );
512 })()}
513 </Show>
514 )}
515 </Show>
516 </ul>
517 </div>
518 );
519}
520
521export function Overview(props: { me: Me }) {
522 return (
523 <Show when={props.me.sections.includes("metrics")} fallback={<Home me={props.me} />}>
524 <Dashboard me={props.me} />
525 </Show>
526 );
527}
dashboard/web/pages/PaperClover.css created+152
......@@ -0,0 +1,152 @@
1.paper-clover > :is(.grid, .card) + :is(.grid, .card) { margin-top: 8px; }
2.paper-clover ul { list-style: none; margin: 0; padding: 0; }
3
4.paper-clover .page-head h1 { white-space: nowrap; }
5.paper-clover .page-head h1 a { border-radius: 4px; transition: color 150ms; }
6.paper-clover .page-head h1 a:hover { color: var(--accent); }
7
8.paper-clover .file-name { display: grid; min-width: 0; line-height: 1.3; }
9.paper-clover .file-name > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10.paper-clover .file-name a { justify-self: start; max-width: 100%; border-radius: 3px; transition: color 150ms; }
11.paper-clover .file-name a:hover { color: var(--accent); }
12.paper-clover .file-name .folder { color: var(--muted); font-size: 12px; }
13
14.paper-clover :is(.issues ul, .files) > li { border-top: 1px solid var(--grid); }
15.paper-clover :is(.issues ul, .files) > li:first-child { border-top: 0; }
16
17/* Rows share their parent's columns so the error and time line up down the card. */
18.paper-clover .issues ul { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) auto auto; column-gap: 12px; }
19.paper-clover .issues li {
20 grid-column: 1 / -1;
21 display: grid;
22 grid-template-columns: subgrid;
23 align-items: center;
24 padding: 5px 0;
25 min-height: 44px;
26}
27.paper-clover .issues li > .button { align-self: stretch; height: auto; }
28.paper-clover .issues li > time { color: var(--muted); font-size: 12px; white-space: nowrap; }
29.paper-clover .issues .what { font-weight: 500; }
30.paper-clover .issues .why { display: grid; min-width: 0; font-size: 12.5px; color: var(--text-2); }
31.paper-clover .issues button.why {
32 padding: 2px 6px;
33 margin: -2px -6px;
34 border: 0;
35 border-radius: 6px;
36 background: none;
37 text-align: left;
38 cursor: pointer;
39 transition: background-color 150ms;
40}
41.paper-clover .issues button.why:hover:not(:disabled) { background: var(--hover); }
42.paper-clover .issues button.why > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
43.paper-clover .issues .detail { grid-column: 1 / -1; display: grid; gap: 6px; margin-top: 6px; font-size: 12.5px; }
44.paper-clover .issues .detail p { margin: 0; color: var(--text-2); }
45.paper-clover .issues .trace {
46 margin: 0;
47 padding: 8px 10px;
48 max-height: 240px;
49 overflow: auto;
50 border-radius: var(--radius);
51 background: var(--well);
52 font: 12px/1.45 var(--mono);
53 color: var(--text-2);
54}
55
56.paper-clover .files li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 5px 0; }
57.paper-clover .files .num { font-size: 12.5px; line-height: 1.3; }
58
59
60/* The progress tree, drawn like @clo/lib/progress draws it in a terminal. */
61.paper-clover .running { padding: 10px 16px; font: 12.5px/20px var(--mono); }
62.paper-clover .running .empty { padding: 12px 0; font-family: var(--sans); }
63.paper-clover .running li { position: relative; }
64.paper-clover .running ul ul > li { padding-left: 3ch; }
65.paper-clover .running ul ul > li::before,
66.paper-clover .running ul ul > li:not(:last-child)::after {
67 content: "";
68 position: absolute;
69 top: 0;
70 left: 0.5ch;
71 border-left: 1px solid var(--axis);
72}
73.paper-clover .running ul ul > li::before { width: 1.6ch; height: 10px; border-bottom: 1px solid var(--axis); }
74.paper-clover .running ul ul > li:not(:last-child)::after { bottom: 0; }
75.paper-clover .running .line { display: flex; gap: 1ch; align-items: center; height: 20px; min-width: 0; white-space: nowrap; }
76.paper-clover .running .line > * { flex: none; }
77.paper-clover .running .dim { color: var(--muted); }
78.paper-clover .running .step { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; flex-shrink: 1; }
79.paper-clover .running a { border-radius: 3px; transition: color 150ms; }
80.paper-clover .running a:hover { color: var(--accent); }
81.paper-clover .running .path { display: flex; min-width: 0; flex-shrink: 1; }
82.paper-clover .running .path > .dim { overflow: hidden; text-overflow: ellipsis; }
83.paper-clover .running .path > :last-child { flex: none; }
84.paper-clover .running .log { padding-left: 1ch; }
85.paper-clover .running .log > :last-child { overflow: hidden; text-overflow: ellipsis; flex-shrink: 1; color: var(--text-2); }
86.paper-clover .running .warn { color: color-mix(in srgb, var(--warning) 75%, var(--text)); }
87.paper-clover .running .error { color: color-mix(in srgb, var(--critical) 75%, var(--text)); }
88.paper-clover .running .more { padding: 0; border: 0; background: none; color: var(--muted); font: inherit; cursor: pointer; }
89.paper-clover .running .more:hover { color: var(--text); }
90
91.paper-clover .spinner { width: 1ch; color: var(--series-1); text-align: center; }
92.paper-clover .spinner::before { content: "⠋"; animation: paper-clover-spinner 800ms infinite; }
93@keyframes paper-clover-spinner {
94 0% { content: "⠋"; } 10% { content: "⠙"; } 20% { content: "⠹"; } 30% { content: "⠸"; } 40% { content: "⠼"; }
95 50% { content: "⠴"; } 60% { content: "⠦"; } 70% { content: "⠧"; } 80% { content: "⠇"; } 90% { content: "⠏"; }
96}
97@media (prefers-reduced-motion: reduce) { .paper-clover .spinner::before { animation: none; } }
98
99.paper-clover .running .bar { width: 12ch; height: 12px; background: var(--raised); }
100.paper-clover .running .bar > span { display: block; height: 100%; background: var(--series-1); transition: width 300ms var(--ease-out); }
101
102.paper-clover .space { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 14px; row-gap: 8px; align-items: end; }
103.paper-clover .space .total { justify-self: end; margin-bottom: -3px; color: var(--muted); font-size: 12.5px; white-space: nowrap; }
104.paper-clover .space .total b { color: var(--text); font-weight: 600; }
105.paper-clover .space a.total[href] { border-radius: 4px; transition: color 150ms; }
106.paper-clover .space a.total[href]:hover { color: var(--accent); }
107.paper-clover .segments { display: flex; gap: 2px; }
108.paper-clover .segment { flex: 0 1 0; min-width: 1px; display: grid; gap: 3px; container-type: inline-size; border-radius: 3px; }
109/* A size that doesn't fit beside its name wraps onto a clipped second line. */
110.paper-clover .segment .label {
111 display: flex;
112 flex-wrap: wrap;
113 column-gap: 0.5ch;
114 height: 1lh;
115 overflow: hidden;
116 font-size: 11.5px;
117 line-height: 15px;
118 white-space: nowrap;
119 color: var(--text-2);
120}
121.paper-clover .segment .label > :first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
122.paper-clover .segment .mark { height: 10px; border-radius: 3px; transition: filter 150ms; }
123.paper-clover .segment:is(:hover, :focus-visible) .mark { filter: brightness(1.2); }
124@container (width < 30px) { .paper-clover .segment .label { visibility: hidden; } }
125
126.paper-clover .folders { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(52px, 1fr); gap: 4px; margin-top: 14px; overflow-x: auto; }
127.paper-clover .folders .folder {
128 display: grid;
129 justify-items: center;
130 min-width: 0;
131 padding: 4px 4px 2px;
132 border-radius: 6px;
133 font-size: 12px;
134 line-height: 1.35;
135 font-variant-numeric: tabular-nums;
136 transition: background-color 150ms;
137}
138.paper-clover .folders :is(.name, .muted) { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
139.paper-clover .folders .folder[href]:hover { background: var(--hover); }
140.paper-clover .folders .column { display: flex; align-items: flex-end; width: 100%; height: 48px; margin-bottom: 4px; border-bottom: 1px solid var(--axis); }
141.paper-clover .folders .column > span { width: 100%; min-height: 2px; border-radius: 3px 3px 0 0; background: var(--other); transition: background-color 150ms; }
142.paper-clover .folders .folder[href]:hover .column > span { background: var(--accent); }
143
144.paper-clover .processors table.data { font-size: 12.5px; }
145.paper-clover .processors table.data :is(th, td) { padding: 3px 6px; }
146.paper-clover .processors table.data :is(th, td):first-child { padding-left: 0; }
147.paper-clover .processors table.data :is(th, td):last-child { padding-right: 0; }
148.paper-clover .processors tbody tr:last-child td { border-bottom: 0; }
149.paper-clover .processors td:first-child { white-space: nowrap; }
150.paper-clover .processors .swatch { display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 2px; }
151.paper-clover .processors .failed { color: color-mix(in srgb, var(--critical) 75%, var(--text)); }
152.paper-clover .processors .status-label { justify-content: flex-end; gap: 4px; white-space: nowrap; }
dashboard/web/pages/PaperClover.tsx created+523
......@@ -0,0 +1,523 @@
1import RotateCw from "lucide-solid/icons/rotate-cw";
2import { createSignal, For, Index, onCleanup, type Resource, Show } from "solid-js";
3import { A } from "@solidjs/router";
4import { parseResponse } from "hono/client";
5import type { PaperCloverStats, ProgressNode } from "../types/paperClover.ts";
6import { api, queries, unconnected } from "../api.ts";
7import { Ago } from "../components/Ago.tsx";
8import { showTextDialog } from "../components/Dialog.tsx";
9import { lastGood, Loaded } from "../components/Loaded.tsx";
10import { type StatusKind, StatusLabel } from "../components/Status.tsx";
11import { TabBar } from "../components/TabBar.tsx";
12import { toast } from "../components/Toast.tsx";
13import { bytes, clock, count, datetime, duration, percent, plural, until } from "../format.ts";
14import "./PaperClover.css";
15
16const SITE = "https://paperclover.net/file";
17/** The runtime side of the source of truth: its logs, restarts and resources. */
18const SERVICE = "/services/clover-source-of-truth";
19/** Lines a progress node lists before folding the rest into "[N more]". */
20const SHOWN = 6;
21
22const scan = (path: string | null) => parseResponse(api["paper-clover"].scan.$post({ json: { path } }));
23const url = (base: string, path: string) => base + path.split("/").map(encodeURIComponent).join("/");
24
25/**
26 * The name opens the file on paperclover.net; the folder opens in copyparty when `browse`, its page for the Published
27 * folder, is known.
28 */
29function FileName(props: { path: string; browse: string | null; tip?: string }) {
30 const cut = () => props.path.lastIndexOf("/");
31 const folder = () => props.path.slice(0, cut()) || "/";
32 return (
33 <div class="file-name">
34 <a href={url(SITE, props.path)} target="_blank" rel="noreferrer" data-tip={props.tip}>{props.path.slice(cut() + 1)}</a>
35 <Show when={props.browse} fallback={<span class="folder">{folder()}</span>}>
36 {(browse) => <a class="folder" href={url(browse(), folder()) + "/"} target="_blank" rel="noreferrer">{folder()}</a>}
37 </Show>
38 </div>
39 );
40}
41
42function health(s: PaperCloverStats, failing: number, blocked: number): [StatusKind, string] {
43 if (!s.indexer.enabled) return ["stopped", "indexer off"];
44 if (s.lastSweep?.error) return ["down", "full scan failed"];
45 if (failing) return ["degraded", `${plural(failing, "file")} failing`];
46 if (blocked) return ["degraded", `${plural(blocked, "processor")} can't run`];
47 if (!s.indexer.watching) return ["degraded", "not watching for changes"];
48 if (s.lastSweep && !s.lastSweep.endedAt) return ["working", "scanning everything"];
49 return ["healthy", "healthy"];
50}
51
52function Issues(props: {
53 stats: PaperCloverStats;
54 browse: string | null;
55 blocked: PaperCloverStats["processors"];
56 busy: (path: string) => boolean;
57 rescan: (path: string | null) => Promise<void>;
58}) {
59 const title = (name: string) => props.stats.processors.find((p) => p.name === name)?.title ?? name;
60 const next = () => props.stats.indexer.nextSweepAt;
61 const [retryingAll, setRetryingAll] = createSignal(false);
62 const retryAll = async () => {
63 setRetryingAll(true);
64 try {
65 await Promise.all(props.stats.failures.map((file) => props.rescan(file.path)));
66 } finally {
67 setRetryingAll(false);
68 }
69 };
70
71 return (
72 <div class="card issues">
73 <div class="card-title">
74 needs attention
75 <Show when={props.stats.failures.length && next()}>
76 {(t) => <span class="sub">failed files retry on their own {until(t())}</span>}
77 </Show>
78 <span class="spacer" />
79 <Show when={props.stats.failures.length > 1}>
80 <button class="button small" disabled={retryingAll()} aria-busy={retryingAll()} onClick={retryAll}>
81 retry all
82 </button>
83 </Show>
84 </div>
85 <ul>
86 <Show when={props.stats.lastSweep?.error}>
87 {(error) => (
88 <li>
89 <div class="what">full scan</div>
90 <div class="why">{error().replace(/^Error: /, "")}</div>
91 <Ago t={props.stats.lastSweep!.startedAt} />
92 <RetryButton busy={!props.stats.lastSweep!.endedAt} run={() => props.rescan(null)} />
93 </li>
94 )}
95 </Show>
96 <Show when={props.stats.indexer.enabled && !props.stats.indexer.watching}>
97 <li>
98 <div class="what">not watching for changes</div>
99 <div class="why">New files wait for the next full scan{next() ? `, ${until(next()!)}` : ""}.</div>
100 </li>
101 </Show>
102 <Index each={props.blocked}>
103 {(p) => (
104 <li>
105 <div class="what">{p().title}</div>
106 <div class="why" data-tip={`Its tool is missing, or ${p().name} is in CLOVER_PROCESSORS_DISABLE`}>
107 Can't run on this machine. {plural(p().applicable - p().done, "file")} waiting.
108 </div>
109 </li>
110 )}
111 </Index>
112 <For each={props.stats.failures.map((file) => file.path)}>
113 {(path) => {
114 const [open, setOpen] = createSignal(false);
115 const file = () => props.stats.failures.find((f) => f.path === path);
116 const failures = () => file()?.errors ?? [];
117 return (
118 <li>
119 <FileName path={path} browse={props.browse} tip={file() && `${bytes(file()!.size)}, ${file()!.mime}`} />
120 <button class="why" aria-expanded={open()} data-tip={open() ? "hide details" : "show details"}
121 onClick={() => setOpen(!open())}>
122 <Index each={failures()}>
123 {(f) => (
124 <span>
125 <span class="muted">{title(f().processor)}:</span>{" "}
126 {f().error.split("\n")[0]!.replace(/^Error: /, "")}
127 </span>
128 )}
129 </Index>
130 </button>
131 <Ago t={Math.max(...failures().map((f) => f.at))} />
132 <RetryButton busy={props.busy(path)} run={() => props.rescan(path)} />
133 <Show when={open()}>
134 <div class="detail">
135 <p><span class="muted">made</span> {file()?.made.map(title).join(", ") || "nothing yet"}</p>
136 <pre class="trace">{failures().map((f) => `${title(f.processor)}: ${f.error}`).join("\n\n")}</pre>
137 </div>
138 </Show>
139 </li>
140 );
141 }}
142 </For>
143 </ul>
144 </div>
145 );
146}
147
148/** Spins while the request is out, then for as long as `busy` says the work is still going. */
149function RetryButton(props: { busy: boolean; run: () => Promise<void> }) {
150 const [sending, setSending] = createSignal(false);
151 const click = async () => {
152 setSending(true);
153 await props.run().finally(() => setSending(false));
154 };
155 return (
156 <button class="button small" disabled={sending() || props.busy} aria-busy={sending() || props.busy} onClick={click}>
157 retry
158 </button>
159 );
160}
161
162/** A port of @clo/lib/progress's terminal renderer (`formatAnsi`): guides, bars, dim counts and "[N more]". */
163function Tree(props: { nodes: ProgressNode[]; browse: string | null; top?: boolean }) {
164 const [all, setAll] = createSignal(false);
165 const folded = () => (all() || props.nodes.length <= SHOWN + 1 ? 0 : props.nodes.length - SHOWN);
166 return (
167 <ul>
168 <Index each={folded() ? props.nodes.slice(0, SHOWN) : props.nodes}>
169 {(node) => {
170 const text = () => /^(.*?)( \(.*\))?$/.exec(node().text)!;
171 const age = () => Date.now() / 1000 - node().since;
172 const cut = () => node().path!.lastIndexOf("/");
173 return (
174 <li>
175 <div class="line">
176 <Show when={props.top}><span class="spinner" aria-hidden="true" /></Show>
177 <Show when={node().progress !== null}>
178 <span class="bar" role="progressbar" aria-valuenow={Math.round(node().progress! * 100)} tabindex="0"
179 data-tip={[node().count, node().eta && `done at ${clock(node().eta!)}`].filter(Boolean).join(", ") || undefined}>
180 <span style={{ width: `${node().progress! * 100}%` }} />
181 </span>
182 <span class="dim">
183 [{percent(node().progress! * 100)}{node().eta ? `, ${duration(node().eta! - Date.now() / 1000)}` : ""}]
184 </span>
185 </Show>
186 <Show when={node().progress === null && node().count}>
187 <span class="dim">[{node().count}]</span>
188 </Show>
189 <Show when={node().path}>
190 {(path) => (
191 <span class="path">
192 <Show when={props.browse} fallback={<span class="dim">{path().slice(1, cut() + 1)}</span>}>
193 {(browse) => (
194 <a class="dim" href={url(browse(), path().slice(0, cut())) + "/"} target="_blank" rel="noreferrer">
195 {path().slice(1, cut() + 1)}
196 </a>
197 )}
198 </Show>
199 <a href={url(SITE, path())} target="_blank" rel="noreferrer">{path().slice(cut() + 1)}</a>
200 </span>
201 )}
202 </Show>
203 <span class={node().path ? "step" : "text"}>{text()[1]}</span>
204 <Show when={text()[2]}><span class="dim">{text()[2]}</span></Show>
205 <Show when={!props.top && node().progress === null && age() >= 60}>
206 <span class="dim" tabindex="0" data-tip={`since ${datetime(node().since)}`}>{duration(age())}</span>
207 </Show>
208 </div>
209 <Show when={node().children.length}><Tree nodes={node().children} browse={props.browse} /></Show>
210 <Index each={node().logs.slice(-3)}>
211 {(log) => (
212 <div class="line log">
213 <span class="dim">&gt;</span>
214 <Show when={log().level !== "info"}><span class={log().level}>{log().level}:</span></Show>
215 <span>{log().text}</span>
216 </div>
217 )}
218 </Index>
219 </li>
220 );
221 }}
222 </Index>
223 <Show when={folded()}>
224 <li><button class="line more" onClick={() => setAll(true)}>[{folded()} more]</button></li>
225 </Show>
226 </ul>
227 );
228}
229
230function Running(props: { activity: Resource<ProgressNode[]>; browse: string | null; retry: () => void }) {
231 return (
232 <div class="card running">
233 <Loaded data={props.activity} what="what's running" retry={props.retry}
234 skeleton={<div class="skeleton" style={{ height: "66px" }} />}>
235 {(nodes) => (
236 <Show when={nodes().length}
237 fallback={<div class="empty">Nothing running. Files show up here while they're indexed or processed.</div>}>
238 <Tree nodes={nodes()} browse={props.browse} top />
239 </Show>
240 )}
241 </Loaded>
242 </div>
243 );
244}
245
246/** A processor's family is its name up to the first dash: "av1" for "av1-au". */
247const family = (name: string) => name.split("-")[0]!;
248
249/**
250 * Series colors for the families with output, hashed from the family name so they stay put as processors come and
251 * go; a family whose slot is taken probes to the next free one, in name order.
252 */
253function familyColors(processors: PaperCloverStats["processors"]) {
254 const slots = new Map<string, number>();
255 for (const name of [...new Set(processors.filter((p) => p.bytes).map((p) => family(p.name)))].sort()) {
256 let slot = [...name].reduce((hash, c) => Math.imul(hash ^ c.charCodeAt(0), 16777619), 2166136261) >>> 29;
257 while (slots.size < 8 && [...slots.values()].includes(slot)) slot = (slot + 1) % 8;
258 slots.set(name, slot);
259 }
260 return (processor: string) => {
261 const slot = slots.get(family(processor));
262 return slot === undefined ? undefined : `var(--series-${slot + 1})`;
263 };
264}
265
266/** Published by type and derived by processor on one scale, then published by top-level folder. */
267function Archive(props: { stats: PaperCloverStats; browse: string | null }) {
268 const scale = () => Math.max(props.stats.bytes, props.stats.derived.bytes);
269 const types = () => {
270 const sorted = props.stats.types.toSorted((a, b) => b.bytes - a.bytes);
271 const rest = sorted.slice(6);
272 const other = props.stats.bytes - sorted.slice(0, 6).reduce((sum, type) => sum + type.bytes, 0);
273 return [
274 ...sorted.slice(0, 6).map((type, i) => ({
275 label: type.ext || "no extension",
276 bytes: type.bytes,
277 tip: `${bytes(type.bytes)} in ${plural(type.files, "file")} (${percent((type.bytes / props.stats.bytes) * 100)})`,
278 color: `var(--series-${i + 1})`,
279 })),
280 ...(other > 0
281 ? [{
282 label: "other",
283 bytes: other,
284 tip: `${bytes(other)}: ` + rest.slice(0, 4).map((type) => `${type.ext} ${bytes(type.bytes)}`).join(", ")
285 + (rest.length > 4 ? `, ${rest.length - 4} more` : ""),
286 color: "var(--other)",
287 }]
288 : []),
289 ];
290 };
291 const derived = () => {
292 const color = familyColors(props.stats.processors);
293 const made = props.stats.processors.flatMap((p) => p.bytes !== null && p.outputs !== null && p.bytes > 0
294 ? [{ ...p, bytes: p.bytes, outputs: p.outputs }] : []);
295 const total = (name: string) => made.reduce((sum, p) => sum + (family(p.name) === name ? p.bytes : 0), 0);
296 return made.toSorted((a, b) => total(family(b.name)) - total(family(a.name)) || b.bytes - a.bytes).map((p) => ({
297 label: p.title.replace(/^encode /, ""),
298 bytes: p.bytes,
299 tip: `${p.title}: ${bytes(p.bytes)} in ${plural(p.outputs, "file")} (${percent((p.bytes / props.stats.derived.bytes) * 100)})`,
300 color: color(p.name)!,
301 }));
302 };
303 const folders = () => props.stats.folders.toSorted((a, b) => a.name.localeCompare(b.name));
304 const tallest = () => Math.max(...props.stats.folders.map((folder) => folder.bytes));
305
306 const Row = (row: { label: string; total: number; href?: string | null; tip: string; segments: ReturnType<typeof types> }) => (
307 <>
308 <a class="total" href={row.href ?? undefined} tabindex={row.href ? undefined : 0} target="_blank" rel="noreferrer"
309 data-tip={row.tip}>
310 <b>{bytes(row.total)}</b> {row.label}
311 </a>
312 <div class="segments" style={{ width: `${(row.total / scale()) * 100}%` }}>
313 <Index each={row.segments}>
314 {(segment) => (
315 <div class="segment" tabindex="0" data-tip={segment().tip} style={{ "flex-grow": segment().bytes }}>
316 <span class="label"><span>{segment().label}</span><span class="muted">{bytes(segment().bytes)}</span></span>
317 <span class="mark" style={{ background: segment().color }} />
318 </div>
319 )}
320 </Index>
321 </div>
322 </>
323 );
324
325 return (
326 <div class="card archive">
327 <div class="space">
328 <Row label="published" total={props.stats.bytes} href={props.browse} segments={types()}
329 tip={`${plural(props.stats.files, "file")} in ${plural(props.stats.directories, "folder")}`} />
330 <Row label="derived" total={props.stats.derived.bytes} segments={derived()}
331 tip={`${plural(props.stats.derived.files, "file")}, ${(props.stats.derived.bytes / props.stats.bytes).toFixed(1)}× published`} />
332 </div>
333 <div class="folders">
334 <Index each={folders()}>
335 {(folder) => (
336 <a class="folder" href={props.browse ? url(props.browse, `/${folder().name}`) + "/" : undefined} target="_blank"
337 rel="noreferrer" data-tip={plural(folder().files, "file")}>
338 <span class="column"><span style={{ height: `${(folder().bytes / tallest()) * 100}%` }} /></span>
339 <span class="name">{folder().name}</span>
340 <span class="muted">{bytes(folder().bytes)}</span>
341 </a>
342 )}
343 </Index>
344 </div>
345 </div>
346 );
347}
348
349function Processors(props: { stats: PaperCloverStats }) {
350 const left = () => props.stats.processors.reduce((sum, p) => sum + p.applicable - p.done, 0);
351 const work = () => {
352 let total = 0;
353 for (const p of props.stats.processors) {
354 if (p.seconds === null) return null;
355 total += (p.applicable - p.done) * p.seconds;
356 }
357 return total;
358 };
359 const color = () => familyColors(props.stats.processors);
360 return (
361 <div class="card processors">
362 <div class="card-title">
363 processors
364 <span class="sub" tabindex={work() !== null && left() ? 0 : undefined} data-tip={work() !== null && left() ? `about ${duration(work() ?? 0)} of work` : undefined}>
365 {left() ? `${plural(left(), "job")} left` : "all caught up"}
366 </span>
367 </div>
368 <table class="data">
369 <thead><tr><th /><th class="num">per file</th><th class="num">output</th><th /></tr></thead>
370 <tbody>
371 <Index each={props.stats.processors}>
372 {(p) => (
373 <tr tabindex="0" data-tip={[`${count(p().done)} / ${plural(p().applicable, "file")} done`,
374 p().failed && `${count(p().failed)} failed`, p().stale && `${count(p().stale)} from an older version`,
375 `version ${p().version}`].filter(Boolean).join(", ")}>
376 <td><span class="swatch" style={{ background: color()(p().name) }} />{p().title}</td>
377 <td class="num muted">{p().seconds === null ? "" : (p().seconds ?? 0) < 10 ? `${(p().seconds ?? 0).toFixed(1)}s` : duration(p().seconds ?? 0)}</td>
378 <td class="num">{p().bytes !== null && (p().bytes ?? 0) > 0 ? bytes(p().bytes ?? 0) : ""}</td>
379 <td class="num">
380 <Show when={p().runnable} fallback={<StatusLabel health="degraded">can't run</StatusLabel>}>
381 <Show when={p().failed} fallback={<span class="muted">{p().done < p().applicable ? `${count(p().applicable - p().done)} left` : ""}</span>}>
382 <span class="failed">{count(p().failed)} failed</span>
383 </Show>
384 </Show>
385 </td>
386 </tr>
387 )}
388 </Index>
389 </tbody>
390 </table>
391 </div>
392 );
393}
394
395function Files(props: { stats: PaperCloverStats; browse: string | null }) {
396 const [largest, setLargest] = createSignal(false);
397 const files = (): { path: string; size: number; at?: number; duration?: number; dimensions?: string }[] =>
398 largest() ? props.stats.largest : props.stats.recent;
399 return (
400 <div class="card">
401 <div class="card-title">
402 <TabBar label="files">
403 <button aria-pressed={!largest()} onClick={() => setLargest(false)}>recent</button>
404 <button aria-pressed={largest()} onClick={() => setLargest(true)}>largest</button>
405 </TabBar>
406 </div>
407 <Show when={files().length} fallback={<div class="empty">No files indexed yet.</div>}>
408 <ul class="files">
409 <Index each={files()}>
410 {(file) => (
411 <li>
412 <FileName path={file().path} browse={props.browse} tip={file().dimensions?.replace("x", "×")} />
413 <div class="num">
414 <div>{bytes(file().size)}</div>
415 <div class="muted">
416 {file().at !== undefined ? <Ago t={file().at!} /> : file().duration ? duration(file().duration!) : ""}
417 </div>
418 </div>
419 </li>
420 )}
421 </Index>
422 </ul>
423 </Show>
424 </div>
425 );
426}
427
428export function PaperClover() {
429 const [stats, { refetch }] = queries.paperClover.use();
430 const [activity, activityControl] = queries.paperCloverActivity.use();
431 const timers = [
432 setInterval(() => unconnected(stats.error) || refetch(), 5000),
433 setInterval(() => unconnected(activity.error) || activityControl.refetch(), 2000),
434 ];
435 onCleanup(() => timers.forEach(clearInterval));
436
437 const current = lastGood(stats);
438 const now = lastGood(activity);
439 const blocked = () => current()?.processors.filter((p) => !p.runnable && p.done < p.applicable) ?? [];
440 const busy = (target: string) => {
441 const covers = (node: ProgressNode): boolean =>
442 Boolean(node.path && (target === node.path || target.startsWith(node.path + "/"))) || node.children.some(covers);
443 return now()?.some(covers) ?? false;
444 };
445
446 const rescan = async (target: string | null) => {
447 await scan(target);
448 activityControl.refetch();
449 refetch();
450 };
451
452 const rescanDialog = () => showTextDialog({
453 title: "Rescan",
454 description: "Leave it empty to rescan everything.",
455 label: "folder or file",
456 placeholder: "/2026/friends…",
457 validateInput: () => true,
458 confirmLabel: "rescan",
459 onConfirm: async (value) => {
460 const path = value.trim().replace(/^\/+|\/+$/g, "");
461 await rescan(path ? `/${path}` : null);
462 toast(path ? `Rescanning /${path}` : "Rescanning everything");
463 },
464 });
465
466 const off = () => current()?.indexer.enabled === false;
467
468 return (
469 <div class="page paper-clover">
470 <div class="page-head">
471 <h1><A href={SERVICE} data-tip="logs and runtime">paper clover</A></h1>
472 <div class="stats">
473 <Show when={current()}
474 fallback={stats.state !== "errored" && <For each={[120, 132]}>{(width) => <span class="skeleton stat-skeleton" style={{ width: `${width}px` }} />}</For>}>
475 {(s) => {
476 const state = () => health(s(), s().failures.length, blocked().length);
477 const sweep = () => s().lastSweep;
478 return (
479 <>
480 <span class="stat"><StatusLabel health={state()[0]}>{state()[1]}</StatusLabel></span>
481 <span class="stat" data-tip={[
482 sweep()?.endedAt && `took ${duration(sweep()!.endedAt! - sweep()!.startedAt)}`,
483 s().indexer.nextSweepAt && `next ${until(s().indexer.nextSweepAt!)}`,
484 ].filter(Boolean).join(", ") || undefined}>
485 <Show when={sweep()} fallback="never scanned">
486 {(last) => (last().endedAt ? <>scanned <b><Ago t={last().endedAt!} /></b></>
487 : <>scanning since <b><Ago t={last().startedAt} /></b></>)}
488 </Show>
489 <button class="button icon-only" aria-label="Rescan" disabled={off()}
490 data-tip={off() ? "the indexer is off" : "rescan…"} onClick={rescanDialog}>
491 <RotateCw size={12} />
492 </button>
493 </span>
494 </>
495 );
496 }}
497 </Show>
498 </div>
499 </div>
500 <Loaded data={stats} what="paper clover's index" retry={refetch} skeleton={
501 <div class="grid">
502 <div class="skeleton" style={{ height: "64px" }} />
503 <div class="skeleton" style={{ height: "150px" }} />
504 <div class="grid charts"><div class="skeleton" style={{ height: "260px" }} /><div class="skeleton" style={{ height: "260px" }} /></div>
505 </div>
506 }>
507 {(s) => (
508 <>
509 <Show when={s().failures.length || blocked().length || (s().indexer.enabled && !s().indexer.watching) || s().lastSweep?.error}>
510 <Issues stats={s()} browse={s().browse} blocked={blocked()} busy={busy} rescan={rescan} />
511 </Show>
512 <Running activity={activity} browse={s().browse} retry={activityControl.refetch} />
513 <Archive stats={s()} browse={s().browse} />
514 <div class="grid charts">
515 <Files stats={s()} browse={s().browse} />
516 <Processors stats={s()} />
517 </div>
518 </>
519 )}
520 </Loaded>
521 </div>
522 );
523}
dashboard/web/pages/Seedbox.css created+80
......@@ -0,0 +1,80 @@
1.quick-add { position: relative; flex: 1; display: flex; justify-content: flex-end; align-items: center; }
2.quick-add .search { flex: 0 1 280px; min-width: 110px; }
3.quick-add .reveal button { margin-left: 6px; }
4/* Floats below the field so an error doesn't push the page down. */
5.quick-add .error {
6 position: absolute;
7 top: calc(100% + 4px);
8 right: 0;
9 z-index: 2;
10 pointer-events: none;
11 width: max-content;
12 max-width: 320px;
13 padding: 3px 8px;
14 border-radius: 6px;
15 background: var(--raised);
16 font-size: 12px;
17}
18.quick-add .search[aria-invalid="true"] { border-color: color-mix(in srgb, var(--critical) 60%, var(--line)); }
19.stat:is(.down, .up) { color: var(--text); font-weight: 600; }
20.stat.down { background: color-mix(in srgb, var(--series-1) 14%, var(--surface)); border-color: color-mix(in srgb, var(--series-1) 35%, var(--line)); }
21.stat.up { background: color-mix(in srgb, var(--series-2) 14%, var(--surface)); border-color: color-mix(in srgb, var(--series-2) 35%, var(--line)); }
22.stat.down svg { color: var(--series-1); }
23.stat.up svg { color: var(--series-2); }
24
25.torrent-filters { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; }
26.torrent-filters .search { max-width: 220px; margin-left: auto; }
27
28.torrents.fill { overflow: auto; }
29.torrents > table.data { table-layout: fixed; }
30.torrents th.progress { width: 148px; }
31.torrents th.size { width: 72px; }
32.torrents th.speed { width: calc(92px + var(--gutter)); }
33.torrents tr.torrent > td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
34.torrents td.name button {
35 display: block;
36 width: 100%;
37 border: 0;
38 padding: 0;
39 background: none;
40 color: inherit;
41 font: inherit;
42 text-align: left;
43 overflow: hidden;
44 text-overflow: ellipsis;
45 cursor: pointer;
46}
47.torrents tr.open td.name button { white-space: normal; overflow-wrap: anywhere; }
48.torrents tr.torrent { cursor: pointer; }
49.torrents tr.torrent:hover td { background: var(--hover); }
50.torrents tr.torrent.open td { background: var(--accent-wash); border-bottom-color: transparent; }
51.torrents tr.paused td.name { color: var(--text-2); }
52.torrents tr.outside > td { opacity: 0.55; }
53.torrents td.progress > div { display: grid; grid-template-columns: 40px 1fr; gap: 8px; align-items: center; font-size: 12px; }
54.torrents td.progress .meter { min-width: 0; height: 5px; }
55.torrents td.progress .status-label { grid-column: 2; min-width: 0; }
56.torrents td.speed span { display: inline-flex; gap: 3px; align-items: center; }
57.torrents td.speed .down svg { color: var(--series-1); }
58.torrents td.speed .up svg { color: var(--series-2); }
59
60.torrents tr.expanded > td { background: var(--panel); padding: 8px 10px 10px; white-space: normal; }
61.torrent-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
62.torrent-actions .queue { display: flex; gap: 4px; align-items: center; margin-left: 10px; }
63.torrent-actions .queue span { color: var(--text-2); font-size: 12px; margin-right: 4px; }
64.torrent-detail .facts { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 8px 0 4px; font-size: 12px; }
65.torrent-detail .facts div { display: flex; gap: 6px; }
66.torrent-detail .facts dt { color: var(--muted); }
67.torrent-detail .facts dd { margin: 0; font-variant-numeric: tabular-nums; }
68.torrent-detail .empty { padding: 12px 0; margin: 0; font-size: 12px; }
69.torrent-detail table.files { table-layout: fixed; }
70.torrent-detail table.files td { border-bottom-color: var(--line); padding: 4px 6px; }
71.torrent-detail table.files td.name { overflow-wrap: anywhere; padding-left: 0; }
72.torrent-detail table.files td:nth-child(2) { width: 72px; }
73.torrent-detail table.files td.get { width: 48px; }
74.torrent-detail .download { display: inline-grid; place-items: center; width: 26px; height: 18px; border-radius: 5px; color: var(--accent); }
75.torrent-detail .download:hover { background: var(--accent-wash); }
76.torrent-detail .file-skeleton { height: 22px; margin-top: 6px; }
77.torrents .row-skeleton { height: 16px; margin: 11px var(--gutter); }
78
79.torrent-name { overflow-wrap: anywhere; }
80.torrent-actions a.button { gap: 5px; }
dashboard/web/pages/Seedbox.tsx created+423
......@@ -0,0 +1,423 @@
1import { useSearchParams } from "@solidjs/router";
2import { Checkbox } from "../components/Checkbox.tsx";
3import { type InferResponseType, parseResponse } from "hono/client";
4import ArrowDown from "lucide-solid/icons/arrow-down";
5import ArrowDownToLine from "lucide-solid/icons/arrow-down-to-line";
6import ArrowUp from "lucide-solid/icons/arrow-up";
7import ArrowUpToLine from "lucide-solid/icons/arrow-up-to-line";
8import ChevronDown from "lucide-solid/icons/chevron-down";
9import ChevronUp from "lucide-solid/icons/chevron-up";
10import Download from "lucide-solid/icons/download";
11import ArrowUpRight from "lucide-solid/icons/arrow-up-right";
12import HardDrive from "lucide-solid/icons/hard-drive";
13import Scale from "lucide-solid/icons/scale";
14import { createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js";
15import { UNKNOWN_ETA } from "../types/model.ts";
16import type { Torrent, TorrentState, TORRENT_ACTIONS } from "../types/seedbox.ts";
17import { api, queries, reason, unconnected } from "../api.ts";
18import { Ago } from "../components/Ago.tsx";
19import { Copy } from "../components/Copy.tsx";
20import { showConfirmDialog } from "../components/Dialog.tsx";
21import { ListPage } from "../components/ListPage.tsx";
22import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
23import { Meter } from "../components/Meter.tsx";
24import { OpenApp } from "../components/OpenApp.tsx";
25import { Reveal } from "../components/Reveal.tsx";
26import { type Sort, SortHeader } from "../components/SortHeader.tsx";
27import { type StatusKind, StatusLabel } from "../components/Status.tsx";
28import { TabBar } from "../components/TabBar.tsx";
29import { TimeChart } from "../components/TimeChart.tsx";
30import { toast } from "../components/Toast.tsx";
31import { bytes, count, duration, percent, rate } from "../format.ts";
32import "./Seedbox.css";
33
34type Listed = InferResponseType<typeof api.seedbox.$get>["torrents"][number];
35type Group = "downloading" | "seeding" | "paused" | "error";
36
37const STATES: Record<TorrentState, [StatusKind, string, Group]> = {
38 downloading: ["working", "downloading", "downloading"],
39 forcedDL: ["working", "downloading", "downloading"],
40 metaDL: ["working", "starting", "downloading"],
41 forcedMetaDL: ["working", "starting", "downloading"],
42 allocating: ["working", "starting", "downloading"],
43 checkingDL: ["working", "checking", "downloading"],
44 checkingResumeData: ["working", "checking", "downloading"],
45 moving: ["working", "moving", "downloading"],
46 stalledDL: ["degraded", "stalled", "downloading"],
47 queuedDL: ["stopped", "queued", "downloading"],
48 uploading: ["healthy", "seeding", "seeding"],
49 forcedUP: ["healthy", "seeding", "seeding"],
50 stalledUP: ["healthy", "seeding", "seeding"],
51 queuedUP: ["stopped", "queued", "seeding"],
52 checkingUP: ["working", "checking", "seeding"],
53 stoppedDL: ["stopped", "paused", "paused"],
54 stoppedUP: ["stopped", "paused", "paused"],
55 error: ["down", "error", "error"],
56 missingFiles: ["down", "missing files", "error"],
57 unknown: ["down", "unknown", "error"],
58};
59
60const GROUPS: [Group | "", string][] = [
61 ["", "all"], ["downloading", "downloading"], ["seeding", "seeding"], ["paused", "paused"], ["error", "errors"],
62];
63
64const COLUMNS = {
65 name: ["name", (t) => t.name.toLowerCase()],
66 progress: ["progress", (t) => t.progress],
67 size: ["size", (t) => t.size, true],
68 speed: ["speed", (t) => t.dlspeed + t.upspeed, true],
69} satisfies Record<string, [string, (torrent: Torrent) => string | number, true?]>;
70
71type Action = (typeof TORRENT_ACTIONS)[number];
72
73const PRIORITY: [Action, string, typeof ChevronUp][] = [
74 ["topPrio", "move to top", ArrowUpToLine], ["increasePrio", "move up", ChevronUp],
75 ["decreasePrio", "move down", ChevronDown], ["bottomPrio", "move to bottom", ArrowDownToLine],
76];
77
78function Details(props: { torrent: Listed; onChange: () => void }) {
79 const hash = () => props.torrent.hash;
80 const [files, { refetch }] = createResource(hash, (hash) =>
81 parseResponse(api.seedbox.torrents[":hash"].files.$get({ param: { hash } })));
82 const timer = setInterval(refetch, 5000);
83 onCleanup(() => clearInterval(timer));
84 const [pending, setPending] = createSignal<Action | null>(null);
85 const paused = () => STATES[props.torrent.state][2] === "paused";
86 const act = async (action: Action) => {
87 if (pending()) return;
88 setPending(action);
89 try {
90 await parseResponse(api.seedbox.torrents[":hash"][":action"].$post({ param: { hash: hash(), action } }));
91 props.onChange();
92 } catch (failure) {
93 toast(`Couldn't ${action === "stop" ? "pause" : action === "start" ? "resume" : "reorder"} the torrent. ${reason(failure)}`);
94 } finally {
95 setPending(null);
96 }
97 };
98 const remove = () => {
99 const [files, setFiles] = createSignal(false);
100 showConfirmDialog({
101 title: "Remove torrent",
102 description: () => (
103 <><strong class="torrent-name">{props.torrent.name}</strong> stops seeding and leaves the list.{" "}
104 {files() ? "Its files are deleted from disk." : "Its files stay on disk."}</>
105 ),
106 body: () => (
107 <Checkbox name="files" onChange={setFiles}>delete files too</Checkbox>
108 ),
109 confirmLabel: "remove",
110 destructive: true,
111 onConfirm: (form) => parseResponse(api.seedbox.torrents[":hash"].$delete({
112 param: { hash: hash() }, query: { files: form.has("files") ? "1" : "0" },
113 })).then(props.onChange),
114 });
115 };
116 return (
117 <div class="torrent-detail">
118 <div class="torrent-actions">
119 <button class="button small" aria-busy={pending() === "stop" || pending() === "start"}
120 onClick={() => act(paused() ? "start" : "stop")}>
121 {paused() ? "resume" : "pause"}
122 </button>
123 <Show when={props.torrent.priority > 0}>
124 <div class="queue" role="group" aria-label="Queue position">
125 <span>#{props.torrent.priority} in queue</span>
126 <For each={PRIORITY}>
127 {([action, label, Icon]) => (
128 <button class="button small icon-only" aria-label={label} data-tip={label}
129 aria-busy={pending() === action} onClick={() => act(action)}>
130 <Show when={pending() !== action}><Icon size={14} /></Show>
131 </button>
132 )}
133 </For>
134 </div>
135 </Show>
136 <span class="spacer" />
137 <Show when={props.torrent.folder}>
138 {(href) => <a class="button small" href={href()} target="_blank" rel="noreferrer">open folder<ArrowUpRight size={12} /></a>}
139 </Show>
140 <Show when={props.torrent.progress === 1 && props.torrent.zip}>
141 {(href) => <a class="button small" href={href()} target="_blank" rel="noreferrer">download zip<Download size={12} /></a>}
142 </Show>
143 <button class="button small danger" onClick={remove}>remove</button>
144 </div>
145 <dl class="facts">
146 <div><dt>added</dt><dd><Ago t={props.torrent.added_on} /></dd></div>
147 <Show when={props.torrent.category}><div><dt>category</dt><dd>{props.torrent.category}</dd></div></Show>
148 <div data-tip={`${bytes(props.torrent.uploaded)} uploaded`}><dt>ratio</dt><dd>{props.torrent.ratio.toFixed(2)}</dd></div>
149 <div><dt>seeders</dt><dd>{count(props.torrent.num_seeds)} of {count(props.torrent.num_complete)}</dd></div>
150 <div><dt>leechers</dt><dd>{count(props.torrent.num_leechs)} of {count(props.torrent.num_incomplete)}</dd></div>
151 <Show when={props.torrent.dlspeed && props.torrent.upspeed}><div><dt>up</dt><dd>{rate(props.torrent.upspeed)}</dd></div></Show>
152 <div><dt>magnet</dt><dd><Copy value={props.torrent.magnet_uri} label="magnet link">{hash().slice(0, 8)}</Copy></dd></div>
153 </dl>
154 <Loaded data={files} what="this torrent's files" retry={refetch}
155 skeleton={<For each={Array(2)}>{() => <div class="skeleton file-skeleton" />}</For>}>
156 {(files) => (
157 <Show when={files().length} fallback={<p class="empty">No files yet. They appear once the first peer connects.</p>}>
158 <table class="data files">
159 <tbody>
160 <For each={files()}>
161 {(file) => (
162 <tr>
163 <td class="mono name">{file.name.replace(`${props.torrent.name}/`, "")}</td>
164 <td class="num">{bytes(file.size)}</td>
165 <td class="num get">
166 <Show when={file.progress === 1} fallback={
167 <span class="muted" data-tip={`${bytes(file.size * file.progress)} / ${bytes(file.size)}`}>
168 {percent(file.progress * 100)}
169 </span>
170 }>
171 <Show when={file.link}>
172 {(href) => (
173 <a class="download" href={href()} target="_blank" rel="noreferrer" aria-label={`Download ${file.name}`}
174 data-tip="download"><Download size={14} /></a>
175 )}
176 </Show>
177 </Show>
178 </td>
179 </tr>
180 )}
181 </For>
182 </tbody>
183 </table>
184 </Show>
185 )}
186 </Loaded>
187 </div>
188 );
189}
190
191function QuickAdd(props: { onAdded: () => void }) {
192 const [url, setUrl] = createSignal("");
193 const [error, setError] = createSignal("");
194 const [busy, setBusy] = createSignal(false);
195 return (
196 <form class="quick-add" onSubmit={async (event) => {
197 event.preventDefault();
198 if (busy() || !url().trim()) return;
199 setBusy(true);
200 setError("");
201 try {
202 await parseResponse(api.seedbox.torrents.$post({ json: { url: url() } }));
203 setUrl("");
204 props.onAdded();
205 } catch (failure) {
206 setError(reason(failure));
207 } finally {
208 setBusy(false);
209 }
210 }}>
211 <input class="search" placeholder="magnet or .torrent link…" aria-label="Torrent link" aria-invalid={!!error()}
212 aria-errormessage="quick-add-error" value={url()} onInput={(event) => {
213 setUrl(event.currentTarget.value);
214 setError("");
215 }} />
216 <Reveal when={!!url().trim()} axis="x"><button class="button" aria-busy={busy()}>queue</button></Reveal>
217 <Show when={error()}><span id="quick-add-error" class="error" role="alert">{error()}</span></Show>
218 </form>
219 );
220}
221
222export function Seedbox() {
223 const [data, { refetch }] = queries.seedbox.use();
224 const seedbox = lastGood(data);
225 const [history, { refetch: refetchHistory }] = queries.seedboxHistory.use();
226 const lastHistory = lastGood(history);
227 const apps = lastGood(queries.launcher.use()[0]);
228 const timers = [
229 setInterval(() => unconnected(data.error) || refetch(), 3000),
230 setInterval(() => unconnected(history.error) || refetchHistory(), 5000),
231 ];
232 onCleanup(() => timers.forEach(clearInterval));
233
234 const [params, setParams] = useSearchParams<{ state?: string; open?: string }>();
235 const group = () => GROUPS.find(([value]) => value && value === params.state)?.[0] ?? "";
236 const [query, setQuery] = createSignal("");
237 const [sort, setSort] = createSignal<Sort<keyof typeof COLUMNS>>({ key: "progress", desc: false });
238 /** After a filter change, brings the open torrent back into view wherever it sorted to. */
239 const refilter = (change: () => void) => {
240 change();
241 requestAnimationFrame(() => document.querySelector(".torrents tr.torrent.open")?.scrollIntoView({ block: "nearest" }));
242 };
243
244 const [hover, setHover] = createSignal<number | null>(null);
245 /** The rate at the hovered point of the chart, or `now` when the pointer is elsewhere. */
246 const at = (series: number, now: number) => {
247 const index = hover();
248 return index === null ? now : lastHistory()?.[series]?.v[index] ?? now;
249 };
250
251 return (
252 <ListPage id="seedbox" flush
253 head={
254 <div class="page-head">
255 <h1>seedbox</h1>
256 <Show when={apps()?.find((app) => app.id === "qbittorrent")}>{(app) => <OpenApp app={app()} href={app().url} />}</Show>
257 <div class="stats">
258 <Show when={seedbox()}
259 fallback={data.state !== "errored" && <For each={[88, 88, 76, 92, 52]}>{(width) => <span class="skeleton stat-skeleton" style={{ width: `${width}px` }} />}</For>}>
260 {(latest) => {
261 const state = () => latest().state;
262 const vpn = (): [StatusKind, string, string] => {
263 const via = state().current_network_interface || "any interface";
264 if (state().connection_status === "disconnected") return ["down", "vpn offline", "no connection"];
265 if (state().connection_status === "firewalled") {
266 return ["degraded", "vpn firewalled", `peers can't reach port ${state().listen_port} on ${via}`];
267 }
268 return ["healthy", "vpn", `connected via ${via}`];
269 };
270 return (
271 <>
272 <span class="stat down" data-tip={`${bytes(state().alltime_dl)} downloaded all time`}>
273 <ArrowDown size={13} aria-label="download" />{rate(at(0, state().dl_info_speed))}
274 </span>
275 <span class="stat up" data-tip={`${bytes(state().alltime_ul)} uploaded all time`}>
276 <ArrowUp size={13} aria-label="upload" />{rate(at(1, state().up_info_speed))}
277 </span>
278 <span class="stat" data-tip="all-time share ratio">
279 <Scale size={13} aria-label="ratio" /><b>{state().global_ratio}</b>
280 </span>
281 <a class="stat" href={latest().downloads ?? undefined} target="_blank" rel="noreferrer"
282 data-tip="free space, open downloads in copyparty">
283 <HardDrive size={13} aria-label="free space" /><b>{state().free_space_on_disk < 0 ? "–" : bytes(state().free_space_on_disk)}</b>
284 </a>
285 <span class="stat" data-tip={vpn()[2]}><StatusLabel health={vpn()[0]}>{vpn()[1]}</StatusLabel></span>
286 </>
287 );
288 }}
289 </Show>
290 </div>
291 <QuickAdd onAdded={refetch} />
292 </div>
293 }
294 summary={
295 // Both come from qBittorrent, so the list alone reports it missing.
296 <Show when={seedbox() || !data.error}>
297 <Loaded data={history} what="transfer history" retry={refetchHistory} skeleton={<div class="skeleton" style={{ height: "132px" }} />}>
298 {(series) => {
299 onCleanup(() => setHover(null));
300 return <div class="transfer"><TimeChart series={series()} format={rate} height={132} legend={false} onHover={setHover} /></div>;
301 }}
302 </Loaded>
303 </Show>
304 }>
305 <Loaded data={data} what="the seedbox" retry={refetch} skeleton={
306 <>
307 <div class="torrent-filters"><div class="skeleton" style={{ height: "30px", width: "340px" }} /></div>
308 <div class="torrents fill">
309 <SkeletonRows count={10} />
310 </div>
311 </>
312 }>
313 {(latest) => {
314 const torrents = () => latest().torrents;
315 const byHash = createMemo(() => new Map(torrents().map((t) => [t.hash, t])));
316 const tally = (group: Group | "") => torrents().filter((t) => !group || STATES[t.state][2] === group).length;
317 const matches = (t: Listed) =>
318 (!group() || STATES[t.state][2] === group()) && t.name.toLowerCase().includes(query().toLowerCase());
319 const shown = createMemo(() => {
320 const key = COLUMNS[sort().key][1];
321 const sign = sort().desc ? -1 : 1;
322 return torrents()
323 // The open torrent stays, dimmed, when a filter or an action leaves it out.
324 .filter((t) => t.hash === params.open || matches(t))
325 .sort((a, b) => (key(a) < key(b) ? -sign : key(a) > key(b) ? sign : b.added_on - a.added_on))
326 .map((t) => t.hash);
327 });
328 return (
329 <>
330 <div class="torrent-filters">
331 <TabBar label="Filter by state">
332 <For each={GROUPS}>
333 {([value, label]) => (
334 <button aria-pressed={group() === value} classList={{ alarm: value === "error" && tally(value) > 0 }}
335 onClick={() => refilter(() => setParams({ state: value || undefined }))}>
336 {label} <span class="count">{count(tally(value))}</span>
337 </button>
338 )}
339 </For>
340 </TabBar>
341 <input class="search" type="search" placeholder="filter by name…" aria-label="Filter by name"
342 onInput={(event) => refilter(() => setQuery(event.currentTarget.value))} />
343 </div>
344 <div class="torrents fill">
345 <Show when={shown().length} fallback={
346 <div class="empty">
347 {torrents().length ? "No torrents match." : "No torrents yet. Paste a magnet link above to add one."}
348 </div>
349 }>
350 <table class="data">
351 <thead>
352 <tr>
353 <For each={Object.keys(COLUMNS) as (keyof typeof COLUMNS)[]}>
354 {(key) => (
355 <SortHeader column={key} label={COLUMNS[key][0]} num={COLUMNS[key][2]} class={key} sort={sort()}
356 onSort={setSort} />
357 )}
358 </For>
359 </tr>
360 </thead>
361 <tbody>
362 <For each={shown()}>
363 {(hash) => (
364 <Show when={byHash().get(hash)}>
365 {(torrent) => {
366 const info = () => STATES[torrent().state];
367 return (
368 <>
369 <tr class="torrent"
370 classList={{ open: params.open === hash, paused: info()[2] === "paused", outside: !matches(torrent()) }}
371 onClick={() => setParams({ open: params.open === hash ? undefined : hash }, { replace: true })}>
372 <td class="name">
373 <button aria-expanded={params.open === hash}>{torrent().name}</button>
374 </td>
375 <td class="progress" data-tip={torrent().progress < 1
376 ? `${bytes(torrent().size * torrent().progress)} / ${bytes(torrent().size)} (${percent(torrent().progress * 100)})`
377 : info()[2] === "seeding"
378 ? `${bytes(torrent().uploaded)} uploaded, ratio ${torrent().ratio.toFixed(2)}` : undefined}>
379 <div>
380 <Show when={torrent().progress < 1}><Meter value={torrent().progress} /></Show>
381 <StatusLabel health={info()[0]}>
382 {info()[2] === "downloading" && torrent().eta < UNKNOWN_ETA ? `${duration(torrent().eta)} left` : info()[1]}
383 </StatusLabel>
384 </div>
385 </td>
386 <td class="num">{bytes(torrent().size)}</td>
387 <td class="num speed" data-tip={info()[2] === "downloading" || info()[2] === "seeding"
388 ? `${count(torrent().num_seeds)} of ${count(torrent().num_complete)} seeders · `
389 + `${count(torrent().num_leechs)} of ${count(torrent().num_incomplete)} leechers`
390 : undefined}>
391 <Show when={torrent().dlspeed} fallback={
392 <Show when={torrent().upspeed}>
393 <span class="up"><ArrowUp size={12} aria-label="up" />{rate(torrent().upspeed)}</span>
394 </Show>
395 }>
396 <span class="down"><ArrowDown size={12} aria-label="down" />{rate(torrent().dlspeed)}</span>
397 </Show>
398 </td>
399 </tr>
400 <Show when={params.open === hash}>
401 <tr class="expanded">
402 <td colspan={Object.keys(COLUMNS).length}>
403 <Details torrent={torrent()} onChange={refetch} />
404 </td>
405 </tr>
406 </Show>
407 </>
408 );
409 }}
410 </Show>
411 )}
412 </For>
413 </tbody>
414 </table>
415 </Show>
416 </div>
417 </>
418 );
419 }}
420 </Loaded>
421 </ListPage>
422 );
423}
dashboard/web/pages/Service.css created+215
......@@ -0,0 +1,215 @@
1.service-page .page-head { margin-bottom: 10px; }
2.service-page .page-head { flex-wrap: wrap; row-gap: 8px; }
3.service-page .page-head h1 { white-space: nowrap; }
4.stat.image { padding: 0 3px; }
5.stat.image .copy { display: inline-flex; align-items: center; gap: 5px; height: 20px; margin: 0; padding: 0 7px 0 5px; border-radius: 99px; }
6.stat.image .version { max-width: 12ch; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); font-size: 11px; }
7
8.problem {
9 display: flex;
10 flex-wrap: wrap;
11 align-items: baseline;
12 gap: 4px 10px;
13 margin-bottom: 10px;
14 padding: 6px 12px;
15 border-radius: var(--radius);
16 background: color-mix(in srgb, var(--warning) 10%, transparent);
17 font-size: 13px;
18}
19.problem .status-label { color: var(--text); font-weight: 600; align-self: center; }
20.problem .output { flex: 1; min-width: 0; color: var(--text-2); }
21.problem a { color: var(--accent); text-decoration: underline dotted; text-underline-offset: 3px; }
22
23.service-strips { padding-bottom: 2px; }
24.service-strips .strip-head .now { font-size: 15px; }
25
26.tab-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; }
27.tab-row .search-box { position: relative; flex: 1 1 140px; min-width: 0; max-width: 240px; margin-left: auto; }
28.search-hints {
29 position: absolute;
30 top: calc(100% + 6px);
31 right: -2px;
32 z-index: 5;
33 display: grid;
34 min-width: 300px;
35 padding: 4px;
36 border: 1px solid var(--line);
37 border-radius: var(--radius-lg);
38 background: var(--surface);
39 box-shadow: 0 8px 28px #0005;
40 animation: menu-in 200ms var(--ease-out);
41}
42.search-hints[hidden] { display: none; }
43.search-hints button {
44 display: flex;
45 justify-content: space-between;
46 gap: 16px;
47 padding: 5px 8px;
48 border: 0;
49 border-radius: calc(var(--radius) - 2px);
50 background: none;
51 color: var(--text);
52 font: 12px var(--mono);
53 text-align: left;
54 cursor: pointer;
55}
56.search-hints button:hover { background: var(--hover); }
57.search-hints span { color: var(--muted); font-family: var(--sans); }
58.liveness { flex: none; font-size: 12px; color: var(--text-2); }
59.liveness.live .status { animation: breathe 2.4s ease-in-out infinite; }
60@keyframes breathe { 50% { opacity: 0.35; } }
61@media (prefers-reduced-motion: reduce) { .liveness.live .status { animation: none; } }
62
63.logs-tab { display: flex; flex-direction: column; }
64.logs-tab .empty { font-family: var(--sans); }
65.log-view .log .skeleton { margin: 4px 0; }
66
67.empty p { margin: 0 0 4px; }
68.empty strong { color: var(--text-2); font-weight: 600; }
69
70.traces.fill { display: flex; flex-direction: column; min-height: 320px; }
71.trace-list { flex: none; max-height: 185px; overflow: auto; border-block: 1px solid var(--line); }
72.trace-list thead th { position: sticky; top: 0; z-index: 1; padding-block: 4px; border: 0; box-shadow: inset 0 -1px var(--line); background: var(--page); }
73.trace-list table.data { table-layout: fixed; }
74.trace-list table.data td { padding-block: 3px; }
75.trace-list tr:last-child td { border-bottom: 0; }
76.trace-list :is(th, td):first-child { padding-left: var(--gutter); width: calc(76px + var(--gutter)); white-space: nowrap; }
77.trace-list td.when { color: var(--text-2); }
78.trace-list .code { width: 72px; }
79.trace-list .spans { width: 56px; color: var(--text-2); }
80.trace-list :is(th, td):last-child { padding-right: var(--gutter); width: calc(128px + var(--gutter)); }
81.trace-list td.code { color: var(--text-2); font: 12px var(--mono); }
82.trace-list td.code span { display: inline-flex; align-items: center; gap: 6px; }
83.trace-list td.code.client { color: color-mix(in srgb, var(--warning) 75%, var(--text)); }
84.trace-list .log-edge { border-top: 1px solid var(--line); }
85.trace-list tr { cursor: pointer; }
86.trace-list tr:hover td { background: var(--hover); }
87.trace-list tr.selected td { background: var(--accent-wash); }
88.trace-list td.request button {
89 display: block;
90 width: 100%;
91 border: 0;
92 padding: 0;
93 background: none;
94 color: inherit;
95 font: 12px var(--mono);
96 text-align: left;
97 white-space: nowrap;
98 overflow: hidden;
99 text-overflow: ellipsis;
100 cursor: pointer;
101}
102.trace-list div.duration { display: flex; justify-content: flex-end; align-items: center; gap: 8px; }
103.trace-list .track { flex: none; width: 56px; height: 6px; border-radius: 3px; background: var(--raised); overflow: hidden; }
104.trace-list .track .bar { display: block; height: 100%; background: var(--series-1); }
105.traces .row-skeleton { height: 14px; margin: 9px var(--gutter); }
106.traces .stale-note { margin-inline: var(--gutter); }
107.flame-skeleton { flex: 1; margin: 36px var(--gutter) 12px; }
108
109.flame { flex: 1; min-height: 0; display: flex; flex-direction: column; }
110.flame-bar { flex: none; display: flex; align-items: center; gap: 12px; padding: 6px var(--gutter) 4px; font-size: 12px; }
111.flame-path { flex: 1; min-width: 0; display: flex; align-items: center; gap: 2px; color: var(--muted); }
112.flame-path button {
113 min-width: 0;
114 max-width: max-content;
115 flex: 0 1 auto;
116 padding: 2px 5px;
117 border: 0;
118 border-radius: 4px;
119 background: none;
120 color: var(--text-2);
121 font: inherit;
122 white-space: nowrap;
123 overflow: hidden;
124 text-overflow: ellipsis;
125 cursor: pointer;
126}
127.flame-path button:hover { background: var(--hover); color: var(--text); }
128.flame-path button[aria-current] { flex-shrink: 0; max-width: 50%; color: var(--text); font-weight: 600; }
129.flame-bar .legend { display: flex; gap: 10px; }
130.flame-bar .legend a { display: inline-flex; align-items: center; gap: 5px; color: var(--text-2); }
131.flame-bar .legend a::before { content: ""; width: 8px; height: 8px; border-radius: 2px; background: var(--color); }
132.flame-bar .legend a:hover { color: var(--text); }
133.flame-bar .hint { flex: none; color: var(--muted); }
134.flame-bar kbd { margin-right: 3px; }
135.flame-canvas { position: relative; flex: 1; min-height: 120px; margin-inline: var(--gutter); }
136.flame-canvas canvas { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; }
137.flame-canvas canvas:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; }
138.flame-hover {
139 position: absolute;
140 z-index: 2;
141 max-width: 340px;
142 padding: 6px 10px;
143 translate: 14px 16px;
144 border-radius: 6px;
145 background: var(--text);
146 color: var(--page);
147 font-size: 12px;
148 line-height: 1.4;
149 pointer-events: none;
150 box-shadow: var(--shadow);
151}
152.flame-hover.left { translate: calc(-100% - 14px) 16px; }
153.flame-hover.up { translate: 14px calc(-100% - 10px); }
154.flame-hover.left.up { translate: calc(-100% - 14px) calc(-100% - 10px); }
155.flame-hover strong { display: block; overflow-wrap: anywhere; }
156.flame-hover dl { display: grid; grid-template-columns: max-content auto; gap: 0 10px; margin: 2px 0 0; }
157.flame-hover dt { opacity: 0.65; }
158.flame-hover dd { margin: 0; font-variant-numeric: tabular-nums; }
159.flame-hover .error { margin: 2px 0 0; color: color-mix(in srgb, var(--critical) 70%, var(--page)); }
160.span-facts {
161 flex: none;
162 display: flex;
163 flex-wrap: wrap;
164 gap: 2px 18px;
165 max-height: 72px;
166 overflow: auto;
167 margin: 0;
168 padding: 8px var(--gutter) 10px;
169 border-top: 1px solid var(--line);
170 font-size: 12px;
171}
172.span-facts > div { display: flex; gap: 6px; min-width: 0; }
173.span-facts dt { color: var(--muted); }
174.span-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
175.span-facts a { color: var(--accent); text-decoration: underline dotted; text-underline-offset: 3px; }
176
177.stat.restart { color: var(--text-2); }
178.stat.restart a { display: inline-flex; align-items: center; gap: 5px; }
179.stat.restart a:hover { color: var(--text); }
180.stat.restart .button { margin-left: 2px; background: none; color: var(--muted); }
181.stat.restart .button:hover { color: var(--text); }
182
183.definition { padding-bottom: 8px; font-size: 13px; }
184.definition dl { margin: 0; }
185.definition h2 { margin: 28px 0 10px; }
186.definition h2 .muted { font-weight: 400; font-size: 12px; }
187.definition .kv { max-width: 760px; }
188.definition .kv a.mono, .definition .kv > dd > div > a { color: var(--accent); text-decoration: underline dotted; text-underline-offset: 3px; }
189.definition .chips { align-items: center; }
190.definition .chip .status-label { gap: 5px; color: inherit; }
191.definition .chip.mono { display: inline-flex; align-items: center; gap: 4px; font: 11px/20px var(--mono); }
192.definition .chip svg { color: var(--muted); }
193.definition .tasks { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 440px), 1fr)); gap: 12px; align-items: start; }
194.definition .task { display: grid; gap: 10px; }
195.definition .task-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
196.definition .task-head h3 { margin: 0; font-size: 13px; font-weight: 650; }
197.definition .task-head .chip.main { background: var(--accent-wash); color: var(--accent); }
198.definition .task .state { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 12px; }
199.definition .task .about { margin: -4px 0 0; color: var(--text-2); }
200.definition .task .kv { gap: 6px 14px; }
201.definition .task .copy { overflow-wrap: anywhere; }
202.definition ul.plain { margin: 0; padding: 0; list-style: none; }
203.definition table.mounts { border-collapse: collapse; }
204.definition table.mounts td { padding: 1px 12px 1px 0; vertical-align: baseline; overflow-wrap: anywhere; }
205.definition table.mounts td:last-child { padding-right: 0; white-space: nowrap; }
206.definition pre.source {
207 margin: 0;
208 padding: 12px 14px;
209 border: 1px solid var(--line);
210 border-radius: var(--radius-lg);
211 background: var(--surface);
212 font: 12px/1.55 var(--mono);
213 overflow-x: auto;
214 tab-size: 2;
215}
dashboard/web/pages/Service.definition.tsx created+305
......@@ -0,0 +1,305 @@
1import { A } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import LockKeyhole from "lucide-solid/icons/lock-keyhole";
4import { For, Show } from "solid-js";
5import type { Container, ServiceDetail, ServiceLink, TaskDefinition } from "../types/model.ts";
6import { api, queries, query } from "../api.ts";
7import { Ago } from "../components/Ago.tsx";
8import { Copy } from "../components/Copy.tsx";
9import { Loaded } from "../components/Loaded.tsx";
10import { StatusLabel } from "../components/Status.tsx";
11import { ago, bytes, cores, duration, plural } from "../format.ts";
12import { stream } from "../live.ts";
13
14/** Null links come from a source the host doesn't expose yet. */
15function Links(props: { label: string; links: ServiceLink[] | null }) {
16 return (
17 <>
18 <dt>{props.label}</dt>
19 <Show when={props.links} fallback={<dd class="muted" data-tip="not connected yet">–</dd>}>
20 {(links) => (
21 <dd class="chips">
22 <For each={links()} fallback={<span class="muted">nothing</span>}>
23 {(link) => {
24 const health = () => stream.latest()?.services[link.id]?.health ?? link.health;
25 return (
26 <A class="chip" href={`/services/${link.id}`} data-tip={link.kind}>
27 <StatusLabel health={health()}>{link.name}</StatusLabel>
28 </A>
29 );
30 }}
31 </For>
32 </dd>
33 )}
34 </Show>
35 </>
36 );
37}
38
39/** When a task runs, as a chip label and a sentence. */
40function phase(task: TaskDefinition, service: ServiceDetail) {
41 if (!task.hook) return { label: "main", about: null };
42 if (task.sidecar) {
43 return {
44 label: "sidecar",
45 about: `Starts ${task.hook === "prestart" ? "before" : "after"} the main tasks and keeps running beside them.`,
46 };
47 }
48 if (task.hook === "prestart") {
49 // A wait task is named for what it waits on: wait-<service>-<its task>.
50 const waited = service.requirements?.find((link) => task.name.startsWith(`wait-${link.id}-`));
51 return {
52 label: "runs first",
53 about: waited
54 ? `Waits for ${waited.name} to answer, then exits. The main tasks start once it has.`
55 : "Runs once and exits before the main tasks start.",
56 };
57 }
58 return task.hook === "poststart"
59 ? { label: "after start", about: "Runs once after the main tasks start." }
60 : { label: "on stop", about: "Runs once after the main tasks stop." };
61}
62
63function State(props: { container: Container | undefined; hook: boolean }) {
64 return (
65 <Show when={props.container} fallback={<span class="muted">not in the current allocation</span>}>
66 {(container) => (
67 <span class="state">
68 {container().state === "running"
69 ? <StatusLabel health="healthy">running</StatusLabel>
70 : container().state === "pending"
71 ? <StatusLabel health="starting">starting</StatusLabel>
72 : <StatusLabel health="stopped">{props.hook ? "finished" : "stopped"}</StatusLabel>}
73 <Show when={container().startedAt}>{(t) => <span class="muted">started <Ago t={t()} /></span>}</Show>
74 <Show when={container().restarts}>
75 {(n) => <span class="muted" data-tip={container().lastRestart?.reason}>{plural(n(), "restart")}</span>}
76 </Show>
77 </span>
78 )}
79 </Show>
80 );
81}
82
83function Task(props: { task: TaskDefinition; service: ServiceDetail }) {
84 const task = () => props.task;
85 const about = () => phase(task(), props.service);
86 const container = () => props.service.containers.find((item) => item.name === task().name);
87 const list = (label: string, items: string[]) => (
88 <Show when={items.length}>
89 <dt>{label}</dt>
90 <dd class="mono">{items.join(", ")}</dd>
91 </Show>
92 );
93 return (
94 <section class="task card" aria-label={task().name}>
95 <div class="task-head">
96 <h3 class="mono">{task().name}</h3>
97 <span class="chip" classList={{ main: !task().hook }}>{about().label}</span>
98 <span class="spacer" />
99 <State container={container()} hook={!!task().hook} />
100 </div>
101 <Show when={about().about}>{(text) => <p class="about">{text()}</p>}</Show>
102 <dl class="kv">
103 <dt>image</dt>
104 <dd class="mono">
105 <Show when={task().image} fallback={<span class="muted">–</span>}>{(image) => <Copy value={image()} />}</Show>
106 </dd>
107 <dt>runs as</dt>
108 <dd class="mono">{task().user ?? <span class="muted">the image's user</span>}</dd>
109 <dt>reserves</dt>
110 <dd>
111 {plural(Number(cores(task().cpu)), "core")}, {bytes(task().memory)}
112 <Show when={task().memoryMax}>{(max) => <span class="muted"> (up to {bytes(max())})</span>}</Show>
113 </dd>
114 <Show when={task().hostNetwork || task().ports.length}>
115 <dt>ports</dt>
116 <dd>
117 <Show when={!task().hostNetwork} fallback="shares the host's network">
118 <ul class="plain">
119 <For each={task().ports}>
120 {(port) => (
121 <li>
122 <span class="mono">{port.container ?? "–"}</span> <span class="muted">as</span> {port.label}
123 <span class="muted">
124 , host port {port.host ?? "picked at start"}{port.network === "loopback" ? ", this machine only" : ""}
125 </span>
126 </li>
127 )}
128 </For>
129 </ul>
130 </Show>
131 </dd>
132 </Show>
133 <Show when={task().mounts.length}>
134 <dt>mounts</dt>
135 <dd>
136 <table class="mounts">
137 <tbody>
138 <For each={task().mounts}>
139 {(mount) => (
140 <tr>
141 <td class="mono">{mount.target}</td>
142 <td class="mono muted">{mount.source}</td>
143 <td class="muted">{mount.readOnly ? "read-only" : ""}</td>
144 </tr>
145 )}
146 </For>
147 </tbody>
148 </table>
149 </dd>
150 </Show>
151 {list("tmpfs", task().tmpfs)}
152 {list("devices", task().devices)}
153 {list("capabilities", task().capabilities)}
154 {list("extra hosts", task().extraHosts)}
155 <Show when={task().env.length}>
156 <dt>env</dt>
157 <dd class="chips">
158 <For each={task().env}>
159 {(env) => (
160 <span class="chip mono" data-tip={env.from === "secret" ? "from a secret" : env.from === "template" ? "filled in at start" : undefined}>
161 <Show when={env.from === "secret"}><LockKeyhole size={11} aria-label="secret" /></Show>
162 {env.name}
163 </span>
164 )}
165 </For>
166 </dd>
167 </Show>
168 </dl>
169 </section>
170 );
171}
172
173const definitions = query("definition", (id: string) => parseResponse(api.services[":id"].definition.$get({ param: { id } })));
174
175/** What the service runs right now, read from its Nomad job and the release's service file. */
176export function Definition(props: { service: ServiceDetail }) {
177 const [definition, { refetch }] = definitions.use(() => props.service.id);
178 const [deploys] = queries.deploys.use();
179 /** The newest deploy that changed this service, else the first, which deployed everything. */
180 const deploy = () => {
181 const history = deploys()?.history;
182 return history?.find((entry) => entry.changed?.includes(props.service.id)) ?? history?.at(-1);
183 };
184 const release = () => (props.service.release ?? deploy()?.release)?.slice(0, 8);
185 return (
186 <Loaded data={definition} what="the definition" retry={refetch} skeleton={
187 <div class="definition">
188 <div class="skeleton" style={{ height: "150px", "max-width": "560px" }} />
189 <div class="skeleton" style={{ height: "220px", "margin-top": "24px" }} />
190 </div>
191 }>
192 {(loaded) => {
193 const groups = () => loaded().groups;
194 const services = () => groups().flatMap((group) => group.services);
195 const routed = () => services().filter((service) => service.hostnames.length);
196 const checked = () => services().flatMap((service) => service.check ?? []);
197 return (
198 <div class="definition">
199 <dl class="kv">
200 <dt>address</dt>
201 <dd>
202 <For each={routed()} fallback={<span class="muted">none, only other services reach it</span>}>
203 {(service) => (
204 <div>
205 <For each={service.hostnames}>
206 {(host, i) => <>{i() ? ", " : ""}<a href={`https://${host}`} target="_blank" rel="noreferrer">{host}</a></>}
207 </For>
208 <span class="muted">
209 {" "}{service.authRole ? `sign-in required, ${service.authRole} group` : "no sign-in gate"}
210 </span>
211 </div>
212 )}
213 </For>
214 </dd>
215 <dt>release</dt>
216 <dd>
217 <Show when={release()} fallback={<span class="muted">–</span>}>
218 <A class="mono" href={deploy() ? `/deploys/${deploy()!.n}` : "/deploys"}>{release()}</A>
219 </Show>
220 <span class="muted">
221 {" "}deployed {ago(props.service.deployedAt)},{" "}
222 {props.service.rollout === "simple" ? "updates with a brief outage" : "updates without downtime"}
223 </span>
224 </dd>
225 <Links label="needs" links={props.service.requirements} />
226 <Links label="needed by" links={props.service.dependents} />
227 <Show when={props.service.datasets.length}>
228 <dt>data</dt>
229 <dd>
230 <For each={props.service.datasets}>
231 {(dataset) => (
232 <div>
233 <A class="mono" href={`/storage?${new URLSearchParams({ dataset: dataset.name })}`}>{dataset.mountpoint}</A>
234 <span class="muted"> {bytes(dataset.used)}, {bytes(dataset.snapshots)} in snapshots</span>
235 </div>
236 )}
237 </For>
238 </dd>
239 </Show>
240 <Show when={checked().length}>
241 <dt>health check</dt>
242 <dd>
243 <For each={checked()}>
244 {(check) => (
245 <div>
246 {check.type === "http" ? <>GET <span class="mono">{check.path}</span></> : check.type.toUpperCase()}
247 <span class="muted">
248 {check.task ? ` on ${check.task}` : ""} every {duration(check.interval)}, gives up after {duration(check.timeout)}
249 </span>
250 <Show when={check.restartAfter}>
251 {(after) => (
252 <div class="muted">
253 {plural(after().failures, "failure")} in a row restart it, once it's been up {duration(after().grace)}
254 </div>
255 )}
256 </Show>
257 </div>
258 )}
259 </For>
260 </dd>
261 </Show>
262 <For each={groups()}>
263 {(group) => (
264 <>
265 <dt>{groups().length > 1 ? `${group.name} restarts` : "restarts"}</dt>
266 <dd>
267 up to {group.restart.attempts} every {duration(group.restart.interval)}, {duration(group.restart.delay)} apart
268 <span class="muted">, then {group.restart.fail ? "gives up" : "waits and tries again"}</span>
269 </dd>
270 </>
271 )}
272 </For>
273 </dl>
274
275 <For each={groups()}>
276 {(group) => (
277 <>
278 <h2>
279 tasks
280 <Show when={groups().length > 1 || group.count > 1}>
281 <span class="muted"> {group.name}, {plural(group.count, "copy", "copies")}</span>
282 </Show>
283 </h2>
284 <div class="tasks">
285 <For each={[...group.tasks].sort((a, b) => Number(!!a.hook) - Number(!!b.hook))}>
286 {(task) => <Task task={task} service={props.service} />}
287 </For>
288 </div>
289 </>
290 )}
291 </For>
292
293 <h2>
294 service file
295 <span class="muted"> service/{props.service.id}/service.pkl{release() ? ` in release ${release()}` : ""}</span>
296 </h2>
297 <Show when={loaded().source} fallback={<p class="muted">This release has no service file for {props.service.name}.</p>}>
298 {(source) => <pre class="source">{source()}</pre>}
299 </Show>
300 </div>
301 );
302 }}
303 </Loaded>
304 );
305}
dashboard/web/pages/Service.flame.tsx created+481
......@@ -0,0 +1,481 @@
1import { A } from "@solidjs/router";
2import ChevronRight from "lucide-solid/icons/chevron-right";
3import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from "solid-js";
4import type { Span, Trace } from "../types/model.ts";
5import { Copy } from "../components/Copy.tsx";
6import { percent } from "../format.ts";
7
8/** Milliseconds under a second, else seconds; `seconds` may be a span's length or an offset into a trace. */
9export const ms = (seconds: number) => {
10 const value = seconds * 1000;
11 if (value >= 1000) return `${seconds.toFixed(seconds < 10 ? 2 : 1)} s`;
12 if (value >= 10 || value === 0) return `${Math.round(value)} ms`;
13 return `${value.toFixed(value < 1 ? 2 : 1)} ms`;
14};
15
16const ROW = 20;
17const AXIS = 20;
18/** Narrower bars go unlabelled. */
19const LABEL = 28;
20/** How much of a service's color each shade mixes into the page. */
21const SHADES = [0.36, 0.48, 0.6, 0.72];
22
23interface Bar {
24 span: Span;
25 /** Seconds from the trace's start. */
26 from: number;
27 to: number;
28 /** Time not covered by any child. */
29 self: number;
30 row: number;
31 service: number;
32 /** Spans sharing a name's first word ("index", "tokenize") share a shade of their service's color. */
33 shade: number;
34 parent: Bar | undefined;
35 /** By start. */
36 children: Bar[];
37}
38
39/**
40 * Bars in rows below their parents. Siblings that overlap in time, like concurrent calls, take separate lanes, and
41 * each keeps its whole subtree as one block under it so no bar ever sits beside a stranger's children.
42 */
43function layout(trace: Trace) {
44 const t0 = trace.spans[0]!.start;
45 const byId = new Map<string, Bar>();
46 const services: string[] = [];
47 const bars = trace.spans.map((span) => {
48 const parent = span.parent === null ? undefined : byId.get(span.parent);
49 if (!services.includes(span.service)) services.push(span.service);
50 const from = span.start - t0;
51 const word = span.name.split(" ")[0]!;
52 let shade = 0;
53 for (const char of word) shade = (shade * 31 + char.charCodeAt(0)) % SHADES.length;
54 const bar: Bar = {
55 span, from, to: from + span.duration, self: span.duration, row: 0, service: services.indexOf(span.service), shade, parent,
56 children: [],
57 };
58 byId.set(span.id, bar);
59 parent?.children.push(bar);
60 return bar;
61 });
62 const stack = (group: Bar[]) => {
63 group.sort((a, b) => a.from - b.from);
64 const lanes: [number, number][][] = [];
65 let height = 0;
66 let end = 0;
67 for (const bar of group) {
68 const below = stack(bar.children);
69 const rows = 1 + below.height;
70 const until = Math.max(bar.to, below.end);
71 let lane = 0;
72 while (lanes.slice(lane, lane + rows).some((row) => row.some(([a, b]) => a < until && bar.from < b))) lane++;
73 for (let i = lane; i < lane + rows; i++) (lanes[i] ??= []).push([bar.from, until]);
74 bar.row = lane;
75 height = Math.max(height, lane + rows);
76 end = Math.max(end, until);
77 }
78 return { height, end };
79 };
80 const { height, end } = stack(bars.filter((bar) => !bar.parent));
81 const rows: Bar[][] = Array.from({ length: height }, () => []);
82 for (const bar of bars) {
83 if (bar.parent) bar.row += bar.parent.row + 1;
84 rows[bar.row]!.push(bar);
85 let reach = bar.from;
86 for (const child of bar.children) {
87 const to = Math.min(child.to, bar.to);
88 bar.self -= Math.max(0, to - Math.max(child.from, reach));
89 reach = Math.max(reach, to);
90 }
91 }
92 for (const row of rows) row.sort((a, b) => a.from - b.from);
93 return { bars, rows, end, services };
94}
95
96/** A step of 1, 2 or 5 times a power of ten, at least `min`. */
97function niceStep(min: number) {
98 const power = 10 ** Math.floor(Math.log10(min));
99 return [1, 2, 5, 10].map((k) => k * power).find((step) => step >= min)!;
100}
101
102const token = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
103/** `a` over `b` at `amount`, both `#rrggbb`. */
104function mix(a: string, b: string, amount: number) {
105 const channel = (hex: string, i: number) => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16);
106 return `rgb(${[0, 1, 2].map((i) => Math.round(channel(a, i) * amount + channel(b, i) * (1 - amount))).join(" ")})`;
107}
108
109function theme() {
110 const page = token("--page");
111 const fills = (color: string) =>
112 ({ shades: SHADES.map((amount) => mix(color, page, amount)), hot: mix(color, page, 0.9), faint: mix(color, page, 0.2) });
113 return {
114 font: token("--sans"),
115 text: token("--text"),
116 muted: token("--muted"),
117 line: token("--line"),
118 series: Array.from({ length: 8 }, (_, i) => fills(token(`--series-${i + 1}`))),
119 failed: fills(token("--critical")),
120 };
121}
122
123/**
124 * A flame chart of one trace: time runs left to right and each span sits under its parent. Clicking a span zooms to
125 * it and shows its attributes; Esc zooms back out. Drag or scroll pans, pinch or ⌘/ctrl scroll zooms at the pointer.
126 * The arrow keys walk the tree. Spans holding all of `needles` stay bright.
127 */
128export function Flame(props: { trace: Trace; needles: string[]; job: string }) {
129 const job = (service: string) => service === props.trace.spans[0]?.service ? props.job : service;
130 let wrap!: HTMLDivElement;
131 let canvas!: HTMLCanvasElement;
132 const graph = createMemo(() => layout(props.trace));
133 const [focus, setFocus] = createSignal<Bar>();
134 const [hover, setHover] = createSignal<{ bar: Bar; x: number; y: number }>();
135 let view = { from: 0, to: 1 };
136 let scroll = 0;
137 let width = 0;
138 let height = 0;
139 let colors = theme();
140 const widths = new Map<string, number>();
141 let frame = 0;
142 let animation = 0;
143 const reduced = matchMedia("(prefers-reduced-motion: reduce)");
144 const mac = /Mac|iPhone|iPad/.test(navigator.platform);
145
146 const total = () => graph().end || 1e-6;
147 const scale = () => width / (view.to - view.from);
148 const maxScroll = () => Math.max(0, graph().rows.length * ROW - (height - AXIS));
149 const clampView = (from: number, to: number) => {
150 const span = Math.min(total(), Math.max(to - from, total() * 1e-5));
151 const start = Math.min(Math.max(0, from), total() - span);
152 return { from: start, to: start + span };
153 };
154 /** Bars matching the search, or null when there is none. */
155 const matching = createMemo(() => {
156 const needles = props.needles.map((needle) => needle.toLowerCase());
157 return needles.length ? new Set(graph().bars.filter(({ span }) => {
158 const values = [span.name, ...Object.values(span.attributes)].map((value) => String(value).toLowerCase());
159 return needles.every((needle) => values.some((value) => value.includes(needle)));
160 })) : null;
161 });
162
163 const measure = (text: string, ctx: CanvasRenderingContext2D) => {
164 let known = widths.get(text);
165 if (known === undefined) widths.set(text, (known = ctx.measureText(text).width));
166 return known;
167 };
168 const fit = (text: string, space: number, ctx: CanvasRenderingContext2D) => {
169 const full = measure(text, ctx);
170 if (full <= space) return text;
171 const chars = Math.floor((text.length * space) / full) - 1;
172 return chars < 2 ? "" : text.slice(0, chars) + "…";
173 };
174
175 const draw = () => {
176 frame = 0;
177 const ctx = canvas.getContext("2d");
178 if (!ctx || !width) return;
179 const dpr = devicePixelRatio;
180 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
181 ctx.clearRect(0, 0, width, height);
182 ctx.font = `11px ${colors.font}`;
183 ctx.textBaseline = "middle";
184 const k = scale();
185 const x = (t: number) => (t - view.from) * k;
186 const step = niceStep(90 / k);
187 const seconds = step >= 1;
188 const digits = Math.max(0, -Math.floor(Math.log10(seconds ? step : step * 1000) + 1e-9));
189 for (let t = Math.ceil(view.from / step) * step; t <= view.to; t += step) {
190 const at = Math.round(x(t));
191 ctx.fillStyle = colors.line;
192 ctx.fillRect(at, AXIS - 4, 1, height);
193 const label = seconds ? `${t.toFixed(digits)} s` : `${(t * 1000).toFixed(digits)} ms`;
194 if (at + 4 + measure(label, ctx) > width) continue;
195 ctx.fillStyle = colors.muted;
196 ctx.fillText(label, at + 4, AXIS / 2 - 1);
197 }
198
199 ctx.save();
200 ctx.beginPath();
201 ctx.rect(0, AXIS, width, height - AXIS);
202 ctx.clip();
203 const { rows } = graph();
204 const hovered = hover()?.bar;
205 const lit = matching();
206 const focused = focus();
207 const last = Math.min(rows.length, Math.ceil((scroll + height - AXIS) / ROW));
208 for (let r = Math.floor(scroll / ROW); r < last; r++) {
209 const y = AXIS + r * ROW - scroll;
210 for (const bar of rows[r]!) {
211 if (bar.to < view.from || bar.from > view.to) continue;
212 const left = Math.max(-1, x(bar.from));
213 const w = Math.max(1, Math.min(width + 1, x(bar.to)) - left);
214 const fills = bar.span.error ? colors.failed : colors.series[bar.service % 8]!;
215 const bright = !lit || lit.has(bar);
216 ctx.fillStyle = !bright ? fills.faint : bar === hovered ? fills.hot : fills.shades[bar.shade]!;
217 ctx.fillRect(left, y, w > 2 ? w - 1 : w, ROW - 1);
218 if (bar === focused) {
219 ctx.strokeStyle = colors.text;
220 ctx.lineWidth = 1.5;
221 ctx.strokeRect(left + 0.75, y + 0.75, Math.max(0, w - 2.5), ROW - 2.5);
222 }
223 if (w < LABEL) continue;
224 const text = Math.max(0, left) + 5;
225 const name = fit(bar.span.name, w - 10, ctx);
226 ctx.fillStyle = bright ? colors.text : colors.muted;
227 ctx.fillText(name, text, y + ROW / 2);
228 if (name !== bar.span.name) continue;
229 const time = ms(bar.span.duration);
230 const after = measure(name, ctx) + 6;
231 if (after + measure(time, ctx) + 10 > w) continue;
232 ctx.globalAlpha = 0.6;
233 ctx.fillText(time, text + after, y + ROW / 2);
234 ctx.globalAlpha = 1;
235 }
236 }
237 ctx.restore();
238 };
239 const redraw = () => (frame ||= requestAnimationFrame(draw));
240
241 /** Eases to a view and a scroll, width geometrically so deep zooms feel even. */
242 const animate = (target: { from: number; to: number }, targetScroll = scroll) => {
243 cancelAnimationFrame(animation);
244 const start = { ...view, scroll };
245 const began = performance.now();
246 const duration = reduced.matches ? 0 : 220;
247 const tick = (now: number) => {
248 const p = duration ? Math.min(1, (now - began) / duration) : 1;
249 const e = 1 - (1 - p) ** 3;
250 const [a, b] = [start.to - start.from, target.to - target.from];
251 const span = a * (b / a) ** e;
252 const center = (start.from + start.to) / 2 + (((target.from + target.to) / 2) - (start.from + start.to) / 2) * e;
253 view = p === 1 ? target : { from: center - span / 2, to: center + span / 2 };
254 scroll = start.scroll + (targetScroll - start.scroll) * e;
255 draw();
256 if (p < 1) animation = requestAnimationFrame(tick);
257 };
258 animation = requestAnimationFrame(tick);
259 };
260
261 const zoomTo = (bar: Bar) => {
262 setFocus(bar);
263 const pad = (bar.to - bar.from) * 0.03;
264 const top = bar.row * ROW;
265 const room = height - AXIS;
266 const targetScroll = top < scroll ? top : top + ROW > scroll + room ? top + ROW - room : scroll;
267 animate(clampView(bar.from - pad, bar.to + pad), Math.min(maxScroll(), Math.max(0, targetScroll)));
268 };
269
270 const zoomAt = (px: number, factor: number) => {
271 const t = view.from + px / scale();
272 const span = (view.to - view.from) * factor;
273 cancelAnimationFrame(animation);
274 view = clampView(t - (px / width) * span, t - (px / width) * span + span);
275 redraw();
276 };
277
278 const hit = (px: number, py: number) => {
279 if (py < AXIS) return undefined;
280 const row = graph().rows[Math.floor((py - AXIS + scroll) / ROW)] ?? [];
281 const t = view.from + px / scale();
282 const slop = 3 / scale();
283 let found: Bar | undefined;
284 for (const bar of row) {
285 if (bar.from - slop > t) break;
286 if (t <= bar.to + slop && (!found || (bar.from <= t && t <= bar.to))) found = bar;
287 }
288 return found;
289 };
290
291 let drag: { x: number; y: number; view: typeof view; scroll: number; moved: boolean } | undefined;
292 const point = (event: MouseEvent) => {
293 const box = canvas.getBoundingClientRect();
294 return [event.clientX - box.left, event.clientY - box.top] as const;
295 };
296 const onPointerDown = (event: PointerEvent) => {
297 if (event.button !== 0) return;
298 canvas.setPointerCapture(event.pointerId);
299 drag = { x: event.clientX, y: event.clientY, view, scroll, moved: false };
300 };
301 const onPointerMove = (event: PointerEvent) => {
302 const [px, py] = point(event);
303 if (drag) {
304 const dx = event.clientX - drag.x;
305 const dy = event.clientY - drag.y;
306 if (!drag.moved && Math.hypot(dx, dy) < 4) return;
307 if (!drag.moved) cancelAnimationFrame(animation);
308 drag.moved = true;
309 canvas.style.cursor = "grabbing";
310 setHover(undefined);
311 const shift = dx / scale();
312 view = clampView(drag.view.from - shift, drag.view.to - shift);
313 scroll = Math.min(maxScroll(), Math.max(0, drag.scroll - dy));
314 redraw();
315 return;
316 }
317 const bar = hit(px, py);
318 canvas.style.cursor = bar ? "pointer" : "";
319 setHover(bar && { bar, x: px, y: py });
320 };
321 const onPointerUp = (event: PointerEvent) => {
322 if (drag && !drag.moved) {
323 const bar = hit(...point(event));
324 if (bar) zoomTo(bar);
325 }
326 drag = undefined;
327 canvas.style.cursor = "";
328 };
329 const onWheel = (event: WheelEvent) => {
330 event.preventDefault();
331 const [px] = point(event);
332 const lines = event.deltaMode === 1 ? 16 : 1;
333 if (event.ctrlKey || event.metaKey) return zoomAt(px, Math.exp(event.deltaY * lines * 0.01));
334 const dx = (event.shiftKey ? event.deltaY : event.deltaX) * lines;
335 const dy = event.shiftKey ? 0 : event.deltaY * lines;
336 cancelAnimationFrame(animation);
337 view = clampView(view.from + dx / scale(), view.to + dx / scale());
338 scroll = Math.min(maxScroll(), Math.max(0, scroll + dy));
339 setHover(undefined);
340 redraw();
341 };
342 const onKeyDown = (event: KeyboardEvent) => {
343 const bar = focus();
344 if (!bar) return;
345 const siblings = bar.parent?.children ?? graph().bars.filter((other) => !other.parent);
346 const index = siblings.indexOf(bar);
347 const next = ({
348 Escape: bar.parent ?? graph().bars[0],
349 ArrowUp: bar.parent,
350 ArrowDown: bar.children[0],
351 ArrowLeft: siblings[index - 1],
352 ArrowRight: siblings[index + 1],
353 Home: graph().bars[0],
354 } as Record<string, Bar | undefined>)[event.key];
355 if (event.key === "+" || event.key === "=" || event.key === "-") {
356 event.preventDefault();
357 return zoomAt(width / 2, event.key === "-" ? 1.5 : 1 / 1.5);
358 }
359 if (!next) return;
360 event.preventDefault();
361 zoomTo(next);
362 };
363
364 onMount(() => {
365 const resize = new ResizeObserver(() => {
366 width = canvas.clientWidth;
367 height = canvas.clientHeight;
368 canvas.width = Math.round(width * devicePixelRatio);
369 canvas.height = Math.round(height * devicePixelRatio);
370 scroll = Math.min(scroll, maxScroll());
371 draw();
372 });
373 resize.observe(canvas);
374 const scheme = matchMedia("(prefers-color-scheme: dark)");
375 const retheme = () => {
376 colors = theme();
377 widths.clear();
378 redraw();
379 };
380 scheme.addEventListener("change", retheme);
381 document.fonts.addEventListener("loadingdone", retheme);
382 canvas.addEventListener("wheel", onWheel, { passive: false });
383 onCleanup(() => {
384 resize.disconnect();
385 scheme.removeEventListener("change", retheme);
386 document.fonts.removeEventListener("loadingdone", retheme);
387 cancelAnimationFrame(frame);
388 cancelAnimationFrame(animation);
389 });
390 });
391 createEffect(on(graph, (next) => {
392 cancelAnimationFrame(animation);
393 setFocus(next.bars[0]);
394 setHover(undefined);
395 view = { from: 0, to: total() };
396 scroll = 0;
397 redraw();
398 }));
399 createEffect(on([focus, hover, matching], redraw, { defer: true }));
400
401 const path = () => {
402 const chain: Bar[] = [];
403 for (let bar = focus(); bar; bar = bar.parent) chain.unshift(bar);
404 return chain;
405 };
406 const root = () => graph().bars[0]!;
407 const facts = (bar: Bar) => [
408 ["duration", `${ms(bar.span.duration)} (${percent((bar.span.duration / (root().span.duration || 1)) * 100)})`],
409 ...(bar.children.length ? [["self", ms(bar.self)]] : []),
410 ["start", `+${ms(bar.from)}`],
411 ...(bar.span.service !== root().span.service ? [["service", bar.span.service]] : []),
412 ];
413
414 return (
415 <div class="flame">
416 <div class="flame-bar">
417 <nav class="flame-path" aria-label="Zoom">
418 <For each={path()}>
419 {(bar, i) => (
420 <>
421 <Show when={i()}><ChevronRight size={12} aria-hidden="true" /></Show>
422 <button aria-current={bar === focus() ? "location" : undefined} onClick={() => zoomTo(bar)}>
423 {bar.span.name}
424 </button>
425 </>
426 )}
427 </For>
428 </nav>
429 <Show when={graph().services.length > 1}>
430 <span class="legend">
431 <For each={graph().services}>
432 {(service, i) => (
433 <A href={`/services/${job(service)}/traces?trace=${props.trace.id}`} style={{ "--color": `var(--series-${(i() % 8) + 1})` }}
434 data-tip={`${service}'s traces`}>
435 {service}
436 </A>
437 )}
438 </For>
439 </span>
440 </Show>
441 <span class="hint"><kbd>{mac ? "⌘" : "ctrl"}</kbd> scroll to zoom</span>
442 </div>
443 <div ref={wrap} class="flame-canvas">
444 <canvas ref={canvas} tabIndex={0} role="img"
445 aria-label={`Flame chart of ${graph().bars.length} spans. Arrow keys move between spans, Escape zooms out.`}
446 onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp}
447 onPointerCancel={() => (drag = undefined)} onPointerLeave={() => !drag && setHover(undefined)} onKeyDown={onKeyDown} />
448 <Show when={hover()}>
449 {(at) => (
450 <div class="flame-hover" classList={{ left: at().x > wrap.clientWidth / 2, up: at().y > wrap.clientHeight / 2 }}
451 style={{ left: `${at().x}px`, top: `${at().y}px` }}>
452 <strong>{at().bar.span.name}</strong>
453 <dl>
454 <For each={facts(at().bar)}>{([key, value]) => <><dt>{key}</dt><dd>{value}</dd></>}</For>
455 </dl>
456 <Show when={at().bar.span.error}>{(error) => <p class="error">{error()}</p>}</Show>
457 </div>
458 )}
459 </Show>
460 </div>
461 <Show when={focus()}>
462 {(bar) => (
463 <dl class="span-facts">
464 <For each={facts(bar())}>{([key, value]) => <div><dt>{key}</dt><dd>{value}</dd></div>}</For>
465 <Show when={bar().span.error}>{(error) => <div><dt>error</dt><dd class="error">{error()}</dd></div>}</Show>
466 <For each={Object.entries(bar().span.attributes)}>
467 {([key, value]) => <div><dt>{key}</dt><dd class="mono"><Copy value={String(value)} /></dd></div>}
468 </For>
469 <div><dt>span</dt><dd class="mono"><Copy value={bar().span.id} /></dd></div>
470 <div>
471 <A href={`/services/${job(bar().span.service)}?at=${Math.floor(bar().span.start)}`}
472 data-tip={`${bar().span.service} logs from this moment`}>
473 logs
474 </A>
475 </div>
476 </dl>
477 )}
478 </Show>
479 </div>
480 );
481}
dashboard/web/pages/Service.tsx created+745
......@@ -0,0 +1,745 @@
1import { A, useParams, useSearchParams } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import Eye from "lucide-solid/icons/eye";
4import EyeOff from "lucide-solid/icons/eye-off";
5import HardDrive from "lucide-solid/icons/hard-drive";
6import Package from "lucide-solid/icons/package";
7import RotateCcw from "lucide-solid/icons/rotate-ccw";
8import Search from "lucide-solid/icons/search";
9import X from "lucide-solid/icons/x";
10import {
11 createEffect, createMemo, createResource, createSignal, createUniqueId, For, type JSX, Match, onCleanup, Show, Switch,
12} from "solid-js";
13import { Portal } from "solid-js/web";
14import {
15 type Action, type LogLine, type Series, type ServiceDetail, type TraceSummary, unacknowledgedRestart,
16} from "../types/model.ts";
17import { logField, needles, parseSearch, traceField, type TraceSort } from "../types/search.ts";
18import { api, queries, query, reason } from "../api.ts";
19import { Ago } from "../components/Ago.tsx";
20import { AppIcon } from "../components/AppIcon.tsx";
21import { Copy } from "../components/Copy.tsx";
22import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx";
23import { ListPage } from "../components/ListPage.tsx";
24import { LogView } from "../components/LogView.tsx";
25import { OpenApp } from "../components/OpenApp.tsx";
26import { lastGood, Loaded } from "../components/Loaded.tsx";
27import { RangePicker } from "../components/Metric.tsx";
28import { TimeChart } from "../components/TimeChart.tsx";
29import { type Sort, SortHeader } from "../components/SortHeader.tsx";
30import { Status, StatusLabel } from "../components/Status.tsx";
31import { Strip } from "../components/Strip.tsx";
32import { TabBar } from "../components/TabBar.tsx";
33import { toast } from "../components/Toast.tsx";
34import { ago, bytes, clock, cores, datetime, duration, plural } from "../format.ts";
35import { stream } from "../live.ts";
36import { Definition } from "./Service.definition.tsx";
37import { Flame, ms } from "./Service.flame.tsx";
38import "./Service.css";
39
40const TABS = [["", "logs"], ["traces", "traces"], ["metrics", "metrics"], ["secrets", "secrets"], ["definition", "definition"]] as const;
41const LEVELS = [[undefined, "all"], ["warn", "warn"], ["error", "error"]] as const;
42
43/** URL state of the logs and traces tabs; `at` opens the logs at a moment instead of following. */
44type Query = { q?: string; level?: "warn" | "error"; at?: string; from?: string; to?: string; errors?: "1"; sort?: TraceSort; trace?: string };
45
46/** Each service's latest unfiltered lines, so returning to a service draws its logs at once. */
47const recent = new Map<string, LogLine[]>();
48
49/** Newest at the bottom; follows new lines while scrolled to the end, and loads older ones near the top. */
50function Logs(props: { service: ServiceDetail; tools: HTMLElement }) {
51 const [params, setParams] = useSearchParams<Query>();
52 const at = () => (params.at ? Number(params.at) : null);
53 const bounded = () => at() !== null || !!params.to;
54 const unfiltered = () => !params.q && !params.level && !params.from && !params.to && at() === null;
55 const dateValue = (value?: string) => {
56 if (!value) return "";
57 const date = new Date(Number(value) * 1000);
58 return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
59 };
60 const [older, setOlder] = createSignal<LogLine[]>([]);
61 const [newer, setNewer] = createSignal<LogLine[]>([]);
62 const [exhausted, setExhausted] = createSignal(false);
63 const [loadingOlder, setLoadingOlder] = createSignal(false);
64 const [follow, setFollow] = createSignal(true);
65 const [unseen, setUnseen] = createSignal(0);
66 /** Why the last poll failed, until one succeeds. */
67 const [stalled, setStalled] = createSignal<string>();
68 const fetchLines = (limit: number, extra: { before?: string; after?: string }) =>
69 parseResponse(api.services[":id"].logs.$get({
70 param: { id: props.service.id },
71 query: { q: params.q ?? "", ...(params.level ? { level: params.level } : {}), limit: String(limit),
72 ...(params.from ? { after: params.from } : {}), ...(params.to ? { before: params.to } : {}), ...extra },
73 })).then((lines) => lines.reverse());
74 const [first, { refetch }] = createResource<LogLine[], string>(
75 // A string key, since a fresh array from an unrelated rerun would count as a change and refetch.
76 () => JSON.stringify([props.service.id, params.q, params.level, params.at, params.from, params.to]),
77 (_, { refetching }) => {
78 setOlder([]);
79 setNewer([]);
80 setExhausted(false);
81 setFollow(!bounded());
82 setUnseen(0);
83 setStalled();
84 const cached = !refetching && unfiltered() && recent.get(props.service.id);
85 if (cached) {
86 queueMicrotask(() => poll());
87 return cached;
88 }
89 return fetchLines(1000, at() === null ? {} : { before: String(at()! + 30) });
90 },
91 );
92 const loaded = lastGood(first);
93 const all = createMemo(() => [...older(), ...(loaded() ?? []), ...newer()]);
94 createEffect(() => first.state === "ready" && unfiltered() && recent.set(props.service.id, all().slice(-1000)));
95
96 const loadOlder = async () => {
97 const oldest = all()[0];
98 if (!oldest || loadingOlder() || exhausted()) return;
99 if (params.from && oldest.t <= Number(params.from)) return setExhausted(true);
100 setLoadingOlder(true);
101 try {
102 const lines = await fetchLines(5000, { before: String(oldest.t) });
103 if (!lines.length) setExhausted(true);
104 setOlder([...lines, ...older()]);
105 } catch (failure) {
106 toast(`Couldn't load older lines. ${reason(failure)}`);
107 } finally {
108 setLoadingOlder(false);
109 }
110 };
111
112 const poll = async () => {
113 const last = all().at(-1);
114 if (bounded() || !last || first.loading) return;
115 let lines: LogLine[];
116 try {
117 lines = await fetchLines(1000, { after: String(last.t) });
118 setStalled();
119 } catch (failure) {
120 setStalled(reason(failure));
121 return;
122 }
123 // A search changed while this was under way, so the lines belong to the old one.
124 if (!lines.length || first.loading || last !== all().at(-1)) return;
125 setNewer([...newer(), ...lines]);
126 if (!follow()) setUnseen(unseen() + lines.length);
127 };
128 const timer = setInterval(poll, 2000);
129 onCleanup(() => clearInterval(timer));
130 const marked = createMemo(() => (at() === null ? undefined : all().find((line) => line.t > at()!)));
131 const hints = () => [
132 ...props.service.containers.filter((container) => !container.hook && props.service.containers.length > 1).slice(0, 1)
133 .map((container) => [`container:${container.name}`, "one container's lines"] as const),
134 ["stream:stderr", "error output only"],
135 ["-health", "hide lines with a word"],
136 ['"connection refused"', "an exact phrase"],
137 ] as const;
138
139 return (
140 <div class="logs-tab fill">
141 <Portal mount={props.tools} ref={(el) => (el.style.display = "contents")}>
142 <Filters placeholder="search logs…" hints={hints()}>
143 <TabBar label="Level">
144 <For each={LEVELS}>
145 {([level, label]) => (
146 <button aria-pressed={params.level === level} onClick={() => setParams({ level }, { replace: true })}
147 data-tip={level === "warn" ? "warnings and errors" : undefined}>
148 {label}
149 </button>
150 )}
151 </For>
152 </TabBar>
153 <label>from <input type="datetime-local" value={dateValue(params.from)}
154 onChange={(event) => setParams({ from: event.currentTarget.value ? String(Date.parse(event.currentTarget.value) / 1000) : undefined }, { replace: true })} /></label>
155 <label>to <input type="datetime-local" value={dateValue(params.to)}
156 onChange={(event) => setParams({ to: event.currentTarget.value ? String(Date.parse(event.currentTarget.value) / 1000) : undefined }, { replace: true })} /></label>
157 <Show when={props.service.logs}>{(link) => <OpenApp app={link().app} href={link().url}>{link().app.name}</OpenApp>}</Show>
158 <Liveness every="lines every 2 s" paused={bounded() ? "Showing the selected time range" : undefined}
159 failure={stalled() ?? (first.state === "errored" ? reason(first.error) : undefined)} />
160 </Filters>
161 </Portal>
162 <Loaded data={first} what="logs" retry={refetch} skeleton={
163 <div class="log-view">
164 <div class="logs">
165 <For each={Array(14)}>
166 {(_, i) => (
167 <div class="log"><span class="skeleton" /><span class="skeleton" style={{ width: `${40 + ((i() * 37) % 55)}%` }} /></div>
168 )}
169 </For>
170 </div>
171 </div>
172 }>
173 {() => (
174 <Show when={all().length} fallback={
175 <div class="empty">{params.q || params.level ? "No lines match." : "No recent logs."}</div>
176 }>
177 <LogView lines={all()} marks={needles(parseSearch(params.q ?? "", logField))} follow={follow()} unseen={unseen()}
178 onStart={loadOlder}
179 onContainer={(name) => setParams({ q: [params.q, `container:${name}`].filter(Boolean).join(" ") }, { replace: true })}
180 onFollow={(end) => {
181 setFollow(end && at() === null);
182 if (end) setUnseen(0);
183 }}
184 onLatest={() => (at() === null ? setFollow(true) : setParams({ at: undefined }))}
185 before={(line, index) => (
186 <>
187 <Show when={index() === 0}>
188 <div class="log-edge">
189 <Show when={!exhausted()} fallback="No older lines">
190 <button class="button small" disabled={loadingOlder()} aria-busy={loadingOlder()} onClick={loadOlder}>
191 load older
192 </button>
193 </Show>
194 </div>
195 </Show>
196 <Show when={line === marked()}><div class="log-edge at">{clock(at()!)}</div></Show>
197 </>
198 )} />
199 </Show>
200 )}
201 </Loaded>
202 </div>
203 );
204}
205
206const traceList = query("traces", ({ id, ...query }: { id: string; q: string; errors?: "1"; sort?: TraceSort; limit: string }) =>
207 parseResponse(api.services[":id"].traces.$get({ param: { id }, query })));
208const oneTrace = query("trace", (id: string) => parseResponse(api.traces[":id"].$get({ param: { id } })));
209const PAGE = 50;
210
211function AppMetrics(props: { service: ServiceDetail; range: number }) {
212 const [name, setName] = createSignal(props.service.applicationMetrics[0]!);
213 const [data, { refetch }] = createResource(
214 () => [props.service.id, name(), props.range] as const,
215 ([id, metric, range]) => parseResponse(api.services[":id"].metrics[":name"].$get({
216 param: { id, name: metric }, query: { range: String(range) },
217 })),
218 );
219 const timer = setInterval(refetch, 30_000);
220 onCleanup(() => clearInterval(timer));
221 return (
222 <div class="card metric">
223 <div class="card-title">
224 application metrics
225 <span class="spacer" />
226 <label>Metric <select value={name()} onChange={(event) => setName(event.currentTarget.value)}>
227 <For each={props.service.applicationMetrics}>{(metric) => <option value={metric}>{metric}</option>}</For>
228 </select></label>
229 </div>
230 <Loaded data={data} what="application metrics" retry={refetch} skeleton={<div class="skeleton" style={{ height: "196px" }} />}>
231 {(series) => <Show when={series().length} fallback={<div class="empty">No samples in this range.</div>}>
232 <TimeChart series={series()} format={(value) => new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} />
233 </Show>}
234 </Loaded>
235 </div>
236 );
237}
238
239function Traces(props: { service: ServiceDetail; tools: HTMLElement }) {
240 const [params, setParams] = useSearchParams<Query>();
241 const [limit, setLimit] = createSignal(PAGE);
242 const [traces, { refetch }] = traceList.use(() =>
243 ({ id: props.service.id, q: params.q ?? "", errors: params.errors, sort: params.sort, limit: String(limit()) }));
244 const timer = setInterval(refetch, 10_000);
245 onCleanup(() => clearInterval(timer));
246 const shown = lastGood(traces);
247 /** Rows keep an unchanged trace's summary, so a refresh doesn't rebuild them and take focus or scroll with it. */
248 const rows = createMemo<TraceSummary[]>((previous) => {
249 const known = new Map(previous.map((summary) => [summary.id, summary]));
250 return (shown() ?? []).map((summary) => {
251 const old = known.get(summary.id);
252 return old && old.spans === summary.spans && old.error === summary.error ? old : summary;
253 });
254 }, []);
255 const failing = createMemo<string | undefined>((previous) =>
256 traces.state === "errored" ? reason(traces.error) : traces.state === "ready" ? undefined : previous);
257 // Pinned once, so newer traces arriving above it don't swap the one being read; never from a search's old answer.
258 createEffect(() => {
259 const first = rows()[0];
260 if (!params.trace && first && traces.state === "ready") setParams({ trace: first.id }, { replace: true });
261 });
262 const [trace, { refetch: refetchTrace }] = oneTrace.use(() => params.trace ?? false);
263 const slowest = () => Math.max(...rows().map((summary) => summary.root.duration));
264 const sort = (): Sort<"when" | "duration"> => ({
265 key: params.sort === "slowest" || params.sort === "fastest" ? "duration" : "when",
266 desc: params.sort !== "oldest" && params.sort !== "fastest",
267 });
268 // A column's first click shows its most telling end: the newest, or the slowest.
269 const onSort = ({ key, desc }: Sort<"when" | "duration">) => setParams({
270 sort: key === "when" ? (desc || key !== sort().key ? undefined : "oldest") : desc || key !== sort().key ? "slowest" : "fastest",
271 trace: undefined,
272 }, { replace: true });
273 /** Up and down move the selection, keeping focus on the chosen row. */
274 const step = (event: KeyboardEvent) => {
275 const offset = ({ ArrowDown: 1, ArrowUp: -1 } as Record<string, number>)[event.key];
276 const next = offset && rows()[rows().findIndex((summary) => summary.id === params.trace) + offset];
277 if (!next) return;
278 event.preventDefault();
279 setParams({ trace: next.id }, { replace: true });
280 (event.currentTarget as HTMLElement).querySelector<HTMLButtonElement>(`[data-trace="${next.id}"]`)?.focus();
281 };
282 const hints = [
283 ["status:5xx", "by status code"],
284 ["path:/api", "paths that start with /api"],
285 ["duration:>1s", "slower than a second"],
286 ["method:post", "by method"],
287 ["-path:/health", "hide matching requests"],
288 ] as const;
289 return (
290 <>
291 <Portal mount={props.tools} ref={(el) => (el.style.display = "contents")}>
292 <Filters placeholder="search traces…" hints={hints}>
293 <TabBar label="Show">
294 <For each={[undefined, "1"] as const}>
295 {(errors) => (
296 <button aria-pressed={params.errors === errors} onClick={() => setParams({ errors, trace: undefined }, { replace: true })}>
297 {errors ? "errors" : "all"}
298 </button>
299 )}
300 </For>
301 </TabBar>
302 <Liveness every="traces every 10 s" failure={failing()} />
303 </Filters>
304 </Portal>
305 <Loaded data={traces} what="traces" retry={refetch} skeleton={
306 <div class="fill traces">
307 <For each={Array(6)}>
308 {(_, i) => <div class="skeleton row-skeleton" style={{ width: `${35 + ((i() * 37) % 45)}%` }} />}
309 </For>
310 </div>
311 }>
312 {() => (
313 <Show when={rows().length} fallback={
314 <Show when={!params.q && !params.errors} fallback={<div class="empty">No traces match in the last week.</div>}>
315 <div class="empty traces-empty">
316 <p><strong>No traces from {props.service.name} yet.</strong></p>
317 </div>
318 </Show>
319 }>
320 <div class="fill traces">
321 <div class="trace-list">
322 <table class="data">
323 <thead>
324 <tr>
325 <SortHeader column="when" label="when" sort={sort()} onSort={onSort} class="when" />
326 <th>request</th>
327 <th class="code">status</th>
328 <th class="num spans">spans</th>
329 <SortHeader column="duration" label="duration" sort={sort()} onSort={onSort} num />
330 </tr>
331 </thead>
332 <tbody onKeyDown={step}>
333 <For each={rows()}>
334 {(summary) => {
335 const root = summary.root;
336 const selected = () => params.trace === summary.id;
337 const status = root.attributes["http.response.status_code"];
338 return (
339 <tr ref={(row) => createEffect(() => selected() && row.scrollIntoView({ block: "nearest" }))}
340 classList={{ selected: selected() }} onClick={() => setParams({ trace: summary.id }, { replace: true })}>
341 <td class="when"><Ago t={root.start} /></td>
342 <td class="request">
343 <button data-trace={summary.id} aria-current={selected() || undefined}>
344 {root.attributes["http.request.method"] ?? ""} {root.attributes["url.path"] ?? root.name}
345 </button>
346 </td>
347 <td class="code" classList={{ client: String(status).startsWith("4") }}>
348 <span>
349 <Show when={summary.error}>{(error) => <Status health="down" label={error()} />}</Show>
350 {status}
351 </span>
352 </td>
353 <td class="num spans" data-tip={summary.services.length > 1 ? summary.services.join(", ") : undefined}>
354 {summary.spans}
355 </td>
356 <td class="num">
357 <div class="duration">
358 <span class="track">
359 <span class="bar" style={{ width: `${(root.duration / slowest()) * 100}%` }} />
360 </span>
361 {ms(root.duration)}
362 </div>
363 </td>
364 </tr>
365 );
366 }}
367 </For>
368 </tbody>
369 </table>
370 <Show when={rows().length === limit()}>
371 <div class="log-edge">
372 <button class="button small" disabled={traces.loading} aria-busy={traces.loading} onClick={() => setLimit(limit() + PAGE)}>
373 load more
374 </button>
375 </div>
376 </Show>
377 </div>
378 <Loaded data={trace} what="this trace" retry={refetchTrace} skeleton={<div class="skeleton flame-skeleton" />}>
379 {(loaded) => <Flame trace={loaded()} needles={needles(parseSearch(params.q ?? "", traceField))} job={props.service.id} />}
380 </Loaded>
381 </div>
382 </Show>
383 )}
384 </Loaded>
385 </>
386 );
387}
388
389/** Whether a tab still takes in new data; `paused` and `failure` say why it doesn't. */
390function Liveness(props: { every: string; paused?: string; failure?: string }) {
391 const health = () => (props.failure ? "degraded" : props.paused ? "stopped" : "healthy");
392 return (
393 <span class="liveness" classList={{ live: health() === "healthy" }} role="status" tabIndex={0}
394 data-tip={props.failure ?? props.paused ?? `Checks for new ${props.every}`}>
395 <StatusLabel health={health()}>{props.failure ? "reconnecting" : props.paused ? "paused" : "live"}</StatusLabel>
396 </span>
397 );
398}
399
400function Secrets(props: { service: ServiceDetail; onChange: () => unknown }) {
401 const [shown, setShown] = createSignal<Record<string, string>>({});
402 const [revealing, setRevealing] = createSignal<string>();
403 const param = (name: string) => ({ param: { id: props.service.id, name } });
404 const hide = (name: string) => setShown(({ [name]: _, ...rest }) => rest);
405 const reveal = async (name: string) => {
406 setRevealing(name);
407 try {
408 const { value } = await parseResponse(api.services[":id"].secrets[":name"].$get(param(name)));
409 setShown({ ...shown(), [name]: value });
410 } catch (failure) {
411 toast(`Couldn't reveal ${name}. ${reason(failure)}`);
412 } finally {
413 setRevealing();
414 }
415 };
416 const rotate = (name: string) => showConfirmDialog({
417 title: `Rotate ${name}?`,
418 description: `${props.service.name} gets a new random ${name} and restarts to use it. `
419 + "Anything still using the old value stops working until you update it.",
420 confirmLabel: "rotate",
421 destructive: true,
422 onConfirm: async () => {
423 await parseResponse(api.services[":id"].secrets[":name"].rotate.$post(param(name)));
424 hide(name);
425 await props.onChange();
426 },
427 });
428 const set = (name: string) => showTextDialog({
429 title: `Set ${name}`,
430 description: `${props.service.name} restarts to use the new value.`,
431 label: "New value",
432 confirmLabel: "set",
433 onConfirm: async (value) => {
434 await parseResponse(api.services[":id"].secrets[":name"].$put({ ...param(name), json: { value } }));
435 hide(name);
436 await props.onChange();
437 },
438 });
439 return (
440 <Show when={props.service.secrets} fallback={<div class="empty">Secrets aren't wired up to the dashboard yet.</div>}>
441 {(secrets) => (
442 <Show when={secrets().length} fallback={
443 <div class="empty">
444 <p><strong>{props.service.name} has no secrets.</strong></p>
445 <p>Secrets its service file declares appear here.</p>
446 </div>
447 }>
448 <table class="data secrets">
449 <thead><tr><th>secret</th><th>value</th><th /></tr></thead>
450 <tbody>
451 <For each={secrets()}>
452 {(secret) => (
453 <tr>
454 <td class="mono name">{secret.name}</td>
455 <td class="mono">
456 <Show when={shown()[secret.name]} fallback={
457 <button class="button small" disabled={revealing() === secret.name} aria-busy={revealing() === secret.name}
458 aria-label={`Reveal ${secret.name}`} onClick={() => reveal(secret.name)}>
459 <Eye size={14} />reveal
460 </button>
461 }>
462 {(value) => (
463 <span class="inline">
464 <Copy value={value()} />
465 <button class="button small icon-only" aria-label={`Hide ${secret.name}`} data-tip="hide"
466 onClick={() => hide(secret.name)}>
467 <EyeOff size={14} />
468 </button>
469 </span>
470 )}
471 </Show>
472 </td>
473 <td class="actions">
474 <div>
475 <Show when={secret.generated} fallback={<button onClick={() => set(secret.name)}>set</button>}>
476 <button onClick={() => rotate(secret.name)}>rotate</button>
477 </Show>
478 </div>
479 </td>
480 </tr>
481 )}
482 </For>
483 </tbody>
484 </table>
485 </Show>
486 )}
487 </Show>
488 );
489}
490
491/**
492 * The search box and filters that share the tab row with the tabs, kept in the URL. Focusing the empty box lists
493 * `hints`, examples of the search syntax that fill the box when picked.
494 */
495function Filters(props: { placeholder: string; hints: readonly (readonly [string, string])[]; children: JSX.Element }) {
496 const [params, setParams] = useSearchParams<Query>();
497 const [open, setOpen] = createSignal(false);
498 const hintsId = createUniqueId();
499 let input!: HTMLInputElement;
500 let debounce: ReturnType<typeof setTimeout> | undefined;
501 const search = (value: string) => {
502 clearTimeout(debounce);
503 setParams({ q: value || undefined, trace: undefined }, { replace: true });
504 };
505 const key = (event: KeyboardEvent) => {
506 if (event.key !== "/" || (event.target as Element).closest("input, textarea, [contenteditable]")) return;
507 event.preventDefault();
508 input.focus();
509 };
510 document.addEventListener("keydown", key);
511 onCleanup(() => {
512 document.removeEventListener("keydown", key);
513 clearTimeout(debounce);
514 });
515 return (
516 <>
517 <label class="search-box">
518 <Search size={14} />
519 <input ref={input} type="search" placeholder={props.placeholder} aria-label={props.placeholder.slice(0, -1)}
520 aria-describedby={hintsId} value={params.q ?? ""}
521 onFocus={(event) => setOpen(!event.currentTarget.value)}
522 onBlur={() => setOpen(false)}
523 onInput={(event) => {
524 clearTimeout(debounce);
525 const value = event.currentTarget.value;
526 setOpen(!value);
527 debounce = setTimeout(() => search(value), 200);
528 }}
529 onKeyDown={(event) => {
530 if (event.key !== "Escape") return;
531 if (!event.currentTarget.value) return setOpen(false);
532 event.currentTarget.value = "";
533 search("");
534 }} />
535 <kbd aria-hidden="true">/</kbd>
536 <div id={hintsId} class="search-hints" hidden={!open()}>
537 <For each={props.hints}>
538 {([example, meaning]) => (
539 <button type="button" tabIndex={-1} onMouseDown={(event) => event.preventDefault()}
540 onClick={() => {
541 input.value = example;
542 setOpen(false);
543 search(example);
544 }}>
545 {example}<span>{meaning}</span>
546 </button>
547 )}
548 </For>
549 </div>
550 </label>
551 {props.children}
552 </>
553 );
554}
555
556/** Remounts per service, so switching shows the new one's cached state or a skeleton, never the last one's. */
557export function Service() {
558 const params = useParams<{ id: string }>();
559 return <Show when={params.id} keyed><ServicePage /></Show>;
560}
561
562function ServicePage() {
563 const params = useParams<{ id: string; tab?: string }>();
564 const [service, { refetch }] = queries.service.use(() => params.id);
565 const timer = setInterval(refetch, 10_000);
566 onCleanup(() => clearInterval(timer));
567 // After an action the refetched detail is fresher than the stream until the stream's next tick.
568 const [since, setSince] = createSignal(0);
569 stream.start();
570 const refresh = async () => {
571 setSince(Date.now() / 1000);
572 await refetch();
573 };
574 const act = async (action: Action) => {
575 await parseResponse(api.services[":id"][":action"].$post({ param: { id: params.id, action } }));
576 await refresh();
577 };
578
579 const [range, setRange] = createSignal(3600);
580
581 return (
582 <Loaded data={service} what="this service" retry={refetch} skeleton={
583 <div class="page">
584 <div class="page-head">
585 <span class="skeleton" style={{ width: "32px", height: "32px" }} />
586 <span class="skeleton" style={{ width: "140px", height: "24px" }} />
587 <For each={[92, 96, 110]}>{(width) => <span class="skeleton stat-skeleton" style={{ width: `${width}px` }} />}</For>
588 </div>
589 <div class="skeleton" style={{ height: "112px" }} />
590 </div>
591 }>
592 {(data) => {
593 const tick = () => {
594 const latest = stream.latest();
595 return latest && latest.t > since() ? latest.services[data().id] : undefined;
596 };
597 const health = () => tick()?.health ?? data().health;
598 /** Nomad leaves usage unmeasured when the task driver reports none; the tip adds the reservation and the charted peak. */
599 const reading = (now: number | null, reserved: string, format: (value: number) => string) => (latest: Series[]) => {
600 const values = latest[0]?.v ?? [];
601 return {
602 value: now === null ? "–" : format(now),
603 tip: reserved + (values.length ? `, ${duration(range())} peak ${format(Math.max(...values.map((v) => v ?? 0)))}` : ""),
604 stale: !stream.live(),
605 };
606 };
607 const failing = () =>
608 (health() === "degraded" || health() === "down" ? data().checks.find((check) => !check.passing) : undefined);
609 const unacknowledged = () => unacknowledgedRestart(data());
610 const restarts = () => data().containers.reduce((sum, container) => sum + container.restarts, 0);
611 const main = () => data().containers.filter((container) => !container.hook);
612 const started = () => (health() === "healthy" ? main()[0]?.startedAt : undefined);
613 /** One pill per image the main tasks run, however many tasks share it. */
614 const images = () => [...new Set(main().flatMap((container) => container.image ?? []))].map((image) => ({
615 image, tasks: main().filter((container) => container.image === image).map((container) => container.name),
616 }));
617 const [clearing, setClearing] = createSignal(false);
618 const acknowledge = () => {
619 setClearing(true);
620 parseResponse(api.services[":id"].restarts.acknowledge.$post({ param: { id: params.id } }))
621 .then(refresh, (failure) => toast(`Couldn't clear the restart. ${reason(failure)}`))
622 .finally(() => setClearing(false));
623 };
624 const also = (verb: string) => data().dependents?.length
625 ? `, ${verb} ${new Intl.ListFormat("en").format(data().dependents!.map((link) => link.name))}` : "";
626 const restart = () => showConfirmDialog({
627 title: `Restart ${data().name}?`,
628 description: `${data().name} goes offline for a moment. Anyone using it is interrupted${also("as are")}.`,
629 confirmLabel: "restart",
630 onConfirm: () => act("restart"),
631 });
632 const stop = () => showConfirmDialog({
633 title: `Stop ${data().name}?`,
634 description: `${data().name} stays offline until you start it again. Anyone using it loses access${also("as do")}.`,
635 confirmLabel: "stop",
636 destructive: true,
637 onConfirm: () => act("stop"),
638 });
639 const start = () => showConfirmDialog({
640 title: `Start ${data().name}?`,
641 description: `${data().name} comes back online${data().release ? ` on release ${data().release!.slice(0, 8)}` : ""}.`,
642 confirmLabel: "start",
643 onConfirm: () => act("start"),
644 });
645 const tabHref = (tab: string) => `/services/${data().id}${tab ? "/" + tab : ""}`;
646 let tools!: HTMLDivElement;
647 return (
648 <ListPage id="service" class="service-page" flush head={
649 <>
650 <div class="page-head">
651 <span class="service-icon"><AppIcon id={data().id} name={data().name} icon={data().icon} /></span>
652 <h1>{data().name}</h1>
653 <Show when={data().url}>{(url) => <OpenApp app={data()} href={url()} icon={false} />}</Show>
654 <div class="stats">
655 <Show when={started()} fallback={<span class="stat" role="status"><StatusLabel health={health()} /></span>}>
656 {(t) => (
657 <span class="stat" role="status" tabIndex={0}
658 data-tip={[`since ${datetime(t())}`, ...data().checks.map((check) => `${check.name}: ${check.output}`)].join("; ")}>
659 <StatusLabel health="healthy">up {duration(Date.now() / 1000 - t())}</StatusLabel>
660 </span>
661 )}
662 </Show>
663 <For each={images()}>
664 {({ image, tasks }) => {
665 const [ref, digest] = image.split("@") as [string, string?];
666 const [name, tag] = ref.slice(ref.lastIndexOf("/") + 1).split(":") as [string, string?];
667 return (
668 <span class="stat image">
669 <Copy value={image} label={images().length > 1 ? `${tasks.join(" and ")} image` : "image"}>
670 <Package size={13} aria-hidden="true" /><b>{name}</b>
671 <span class="version">{digest ? digest.slice(digest.indexOf(":") + 1, digest.indexOf(":") + 9) : tag ?? "latest"}</span>
672 </Copy>
673 </span>
674 );
675 }}
676 </For>
677 <For each={data().datasets}>
678 {(dataset) => (
679 <A class="stat" href={`/storage?${new URLSearchParams({ dataset: dataset.name })}`}
680 data-tip={`${dataset.mountpoint}, ${bytes(dataset.snapshots)} more in snapshots`}>
681 <HardDrive size={13} aria-label="data" /><b>{bytes(dataset.used)}</b>
682 </A>
683 )}
684 </For>
685 <Show when={unacknowledged()}>
686 {(last) => (
687 <span class="stat restart">
688 <A href={`${tabHref("")}?at=${Math.floor(last().t)}`}
689 data-tip={`${last().reason}, ${plural(restarts(), "restart")} since deploy`}>
690 <RotateCcw size={13} aria-hidden="true" />restarted {ago(last().t)}
691 </A>
692 <button class="button icon-only" aria-label="Clear restart" data-tip="clear" disabled={clearing()}
693 aria-busy={clearing()} onClick={acknowledge}>
694 <Show when={!clearing()}><X size={12} /></Show>
695 </button>
696 </span>
697 )}
698 </Show>
699 </div>
700 <span class="spacer" />
701 <button class="button" onClick={restart}>restart</button>
702 <Show when={health() === "stopped"} fallback={<button class="button danger" onClick={stop}>stop</button>}>
703 <button class="button" onClick={start}>start</button>
704 </Show>
705 </div>
706 <Show when={failing()}>
707 {(check) => (
708 <div class="problem" role="status">
709 <StatusLabel health={health()}>{check().name}</StatusLabel>
710 <span class="output">{check().output}</span>
711 <A href={`${tabHref("")}?level=error`}>error logs</A>
712 </div>
713 )}
714 </Show>
715 </>
716 } summary={
717 <div class="service-strips">
718 <Strip title="cpu" metric="service.cpu" service={data().id} range={range()} format={cores}
719 height={64} tabs={<RangePicker value={range()} onChange={setRange} />}
720 now={reading(tick()?.cpu ?? data().cpu, `${cores(data().cpuLimit)} cores reserved`, cores)} />
721 <Strip title="memory" metric="service.memory" service={data().id} range={range()} format={bytes}
722 max={data().memoryLimit} height={64 + 24} last
723 now={reading(tick()?.memory ?? data().memory, `${bytes(data().memoryLimit)} limit`, bytes)} />
724 </div>
725 }>
726 <div ref={tools} class="tab-row">
727 <TabBar label="View">
728 <For each={TABS.filter(([tab]) => tab !== "traces" || data().applicationTraces).filter(([tab]) => tab !== "metrics" || data().applicationMetrics.length > 0)}>
729 {([tab, label]) => <A href={tabHref(tab)} end>{label}</A>}
730 </For>
731 </TabBar>
732 </div>
733 <Switch>
734 <Match when={!params.tab}><Logs service={data()} tools={tools} /></Match>
735 <Match when={params.tab === "traces" && data().applicationTraces}><Traces service={data()} tools={tools} /></Match>
736 <Match when={params.tab === "metrics" && data().applicationMetrics.length > 0}><AppMetrics service={data()} range={range()} /></Match>
737 <Match when={params.tab === "secrets"}><Secrets service={data()} onChange={refresh} /></Match>
738 <Match when={params.tab === "definition"}><Definition service={data()} /></Match>
739 </Switch>
740 </ListPage>
741 );
742 }}
743 </Loaded>
744 );
745}
dashboard/web/pages/Storage.css created+133
......@@ -0,0 +1,133 @@
1.storage .fill:not(.explorer) { overflow: auto; }
2.storage .list-body > .segmented { margin-bottom: 8px; }
3.storage table.data th { white-space: nowrap; }
4.storage .stats .mono { font-weight: 500; }
5.storage td .chip { margin-left: 8px; }
6.storage tr.pickable { cursor: pointer; }
7.storage .snapshots tr.pickable { user-select: none; }
8.storage tr.pickable:hover, .storage tr.hover { background: var(--hover); }
9.storage tr.selected, .storage tr.selected:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); }
10
11.storage tr.trouble > td { background: color-mix(in srgb, var(--critical) 13%, transparent); }
12
13.storage tr.vdev td { color: var(--text-2); background: var(--panel); padding-block: 4px; }
14.storage td.disk .muted { font-size: 12px; }
15
16.storage .datasets td .name { display: flex; align-items: center; min-width: 0; }
17.storage .expander {
18 display: inline-flex;
19 align-items: center;
20 gap: 4px;
21 border: 0;
22 background: none;
23 padding: 0;
24 cursor: pointer;
25}
26.storage .expander .chevron { color: var(--muted); }
27.storage td.action { width: 1%; padding-block: 0; }
28.storage td.action div { display: flex; justify-content: flex-end; gap: 6px; }
29.storage tr:is(.open, .hover) .row-icon { opacity: 1; }
30.storage .datasets tr.expansion > td { padding-block: 0 10px; background: var(--panel); }
31.storage .expansion .facts { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 6px 0 8px 16px; font-size: 12px; }
32.storage .expansion .facts div { display: flex; gap: 6px; align-items: baseline; }
33.storage .expansion .facts dt { color: var(--muted); }
34.storage .expansion .facts dd { margin: 0; }
35.storage td.disk .capacity { margin-left: 6px; }
36.storage .snapshots { border-left: 2px solid var(--line); margin-left: 14px; padding-left: 10px; }
37.storage .snapshots .empty { padding: 12px 0; text-align: left; }
38.storage .selection {
39 position: sticky;
40 bottom: 0;
41 display: flex;
42 align-items: center;
43 gap: 12px;
44 padding: 6px 8px;
45 min-height: 42px;
46 background: var(--panel);
47 font-size: 13px;
48}
49
50.dialog .description .target { overflow-wrap: anywhere; margin-bottom: 6px; color: var(--text); }
51
52.files-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 12px; }
53
54.storage .treemap { position: relative; margin: 0 var(--gutter) 6px; }
55.storage .tm-cell {
56 position: absolute;
57 overflow: hidden;
58 border: 1px solid var(--page);
59 border-radius: var(--radius);
60 background: color-mix(in srgb, var(--kind) 42%, var(--page));
61 padding: 4px 6px;
62 font-size: 12px;
63 line-height: 1.3;
64 display: flex;
65 flex-direction: column;
66 transition: background-color 150ms;
67}
68.storage .tm-cell.dir { cursor: pointer; }
69.storage .tm-cell.rest { background: repeating-linear-gradient(135deg, var(--raised) 0 4px, var(--page) 4px 8px); }
70.storage .tm-cell.hover:not(.rest) { background: color-mix(in srgb, var(--kind) 62%, var(--page)); }
71.storage .tm-cell > span { flex: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
72.storage .tm-cell .name { color: var(--text); font-weight: 550; }
73.storage .tm-cell .size { color: var(--text-2); font-variant-numeric: tabular-nums; }
74
75
76.storage td.path { overflow-wrap: anywhere; }
77
78.storage .map { display: flex; flex-direction: column; padding: 0 var(--gutter); }
79/* A fixed share, so hovering a long path can't reflow the crumbs and resize the map under the pointer. */
80.storage .map .map-status { display: flex; flex: none; justify-content: flex-end; gap: 8px; width: 40%; }
81.storage .map-status .path { overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; }
82.storage .map-status b { color: var(--text); font-weight: 600; }
83.storage .map-body { display: grid; grid-template-columns: 1fr 184px; gap: 12px; flex: 1; min-height: 0; padding-bottom: 12px; }
84.storage .map-frame { position: relative; overflow: hidden; border-radius: var(--radius); outline-offset: 2px; }
85.storage .map-frame.zoomable { cursor: zoom-in; }
86.storage .map-frame canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
87.storage .map-frame .skeleton { position: absolute; inset: 0; }
88.storage .map-frame .empty { position: absolute; inset: 0; display: grid; place-items: center; }
89.storage .map-frame .outline { position: absolute; pointer-events: none; box-sizing: border-box; }
90.storage .map-frame .outline.target { border: 1px solid color-mix(in srgb, white 70%, transparent); box-shadow: 0 0 0 1px rgb(0 0 0 / 0.5); }
91.storage .map-frame .outline.leaf { border: 2px solid white; box-shadow: 0 0 0 1px rgb(0 0 0 / 0.6); }
92.storage .map-legend { list-style: none; margin: 0; padding: 0; overflow: auto; font-size: 12px; }
93.storage .map-legend li {
94 display: grid;
95 grid-template-columns: 10px 1fr auto;
96 align-items: center;
97 gap: 8px;
98 padding: 3px 6px;
99 border-radius: var(--radius);
100}
101.storage .map-legend li[tabindex] { cursor: default; }
102.storage .map-legend li[tabindex]:is(:hover, :focus-visible) { background: var(--hover); }
103.storage .map-legend .swatch { width: 10px; height: 10px; border-radius: 3px; background: var(--other); }
104.storage .map-legend .swatch.small { background: color-mix(in srgb, var(--other) 50%, var(--page)); }
105.storage .map-legend .v { color: var(--text-2); font-variant-numeric: tabular-nums; }
106.storage td.num > span[data-tip] { padding-block: 8px; margin-block: -8px; }
107
108.storage .regions { display: flex; flex-wrap: wrap; gap: 2px; list-style: none; margin: 0 0 0 -8px; padding: 0; }
109.storage .regions li { display: flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: var(--radius); color: var(--text-2); }
110.storage .regions li:is(:hover, :focus-visible) { background: var(--hover); }
111.storage .regions b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
112.storage :is(.regions, .held-key) .swatch { flex: none; width: 10px; height: 10px; border-radius: 3px; }
113.storage .regions .swatch { background: color-mix(in srgb, var(--kind) 70%, var(--page)); }
114.storage .held-key { display: flex; align-items: center; gap: 6px; color: var(--text-2); }
115.storage .held-key .swatch { --kind: var(--other); }
116.storage .regions-map { flex: none; }
117.storage .region {
118 position: absolute;
119 border-radius: calc(var(--radius) + 2px);
120 background: color-mix(in srgb, var(--kind) 16%, var(--page));
121 transition: opacity 150ms;
122}
123.storage .region.dim { opacity: 0.3; }
124.storage :is(.regions-map .tm-cell.dir, .held-key .swatch) {
125 --fill: color-mix(in srgb, var(--kind) 42%, var(--page));
126 background:
127 linear-gradient(to top, transparent var(--held, 100%), var(--fill) var(--held, 100%)),
128 repeating-linear-gradient(135deg, var(--fill) 0 2px, color-mix(in srgb, var(--kind) 20%, var(--page)) 2px 5px);
129}
130.storage .regions-map .tm-cell.hover { --fill: color-mix(in srgb, var(--kind) 62%, var(--page)); }
131.storage .regions-map .tm-cell.open { box-shadow: inset 0 0 0 2px var(--text); }
132.storage .datasets tr { scroll-margin-top: 34px; }
133.storage .regions-map .tm-cell.rest { background: color-mix(in srgb, var(--kind) 26%, var(--page)); }
dashboard/web/pages/Storage.map.tsx created+299
......@@ -0,0 +1,299 @@
1import { useSearchParams } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js";
4import type { ServiceSummary } from "../types/model.ts";
5import type { MapNode } from "../types/storage.index.ts";
6import { api, reason } from "../api.ts";
7import { Crumbs, squarify } from "../components/Explorer.tsx";
8import { lastGood } from "../components/Loaded.tsx";
9import { OpenApp } from "../components/OpenApp.tsx";
10import { bytes, percent, plural } from "../format.ts";
11
12/** Extensions by type, in categorical color order: video, audio, image, archive, document, code, data, font. */
13const KINDS = [
14 /^(mkv|mp4|m4v|mov|webm|avi|mts|wmv)$/,
15 /^(flac|wav|aiff?|mp3|m4a|ogg|opus|als|alac)$/,
16 /^(jpe?g|png|gif|webp|heic|tiff?|dng|psd|raw|cr2|arw|svg|avif)$/,
17 /^(zip|tar|gz|tgz|xz|zst|7z|rar|dmg|iso|img|pack|part)$/,
18 /^(pdf|docx?|pages|key|txt|md|rtf|epub|srt|ass|xlsx?|numbers)$/,
19 /^(js|mjs|cjs|ts|tsx|jsx|py|rs|go|c|h|cpp|css|html?|json|ya?ml|toml|sh|lua|map)$/,
20 /^(db|sqlite3?|wal|shm|sql|parquet|csv|log|bin)$/,
21 /^(otf|ttf|woff2?|ttc)$/,
22];
23
24const extension = (name: string) => (name.lastIndexOf(".") > 0 ? name.slice(name.lastIndexOf(".") + 1).toLowerCase() : "");
25const colorOf = (ext: string) => {
26 const kind = KINDS.findIndex((pattern) => pattern.test(ext));
27 return kind === -1 ? "--other" : `--series-${kind + 1}`;
28};
29
30/** WinDirStat's cushion constants: ridge height, its falloff per level, ambient light, and a light at (-1, -1, 10). */
31const HEIGHT = 0.38;
32const FALLOFF = 0.91;
33const AMBIENT = 0.13;
34const LIGHT_XY = -1 / Math.hypot(1, 1, 10);
35const LIGHT_Z = 10 / Math.hypot(1, 1, 10);
36/** Flat ground shows a type's color a little brighter, as WinDirStat does, so the shaded rims stay readable. */
37const FLAT = (AMBIENT + (1 - AMBIENT) * LIGHT_Z) / 1.45;
38/** How much of the rims' darkening light mode keeps, so they don't read as black outlines on a pale page. */
39const LIGHT_RIMS = 0.5;
40
41/** A laid-out entry; `ext` is null for folders drawn whole and for the space a folder's small files take together. */
42interface Box {
43 name: string;
44 size: number;
45 files: number;
46 dir: boolean;
47 ext: string | null;
48 x: number;
49 y: number;
50 w: number;
51 h: number;
52 /** The cushion's surface coefficients: x², y², x and y. */
53 surface: [number, number, number, number];
54 children: Box[];
55}
56
57function layout(node: MapNode, x: number, y: number, w: number, h: number, parent: Box["surface"], height: number): Box {
58 const [name, size, files, children] = node;
59 const surface: Box["surface"] = [
60 parent[0] - (4 * height) / w, parent[1] - (4 * height) / h,
61 parent[2] + ((4 * height) / w) * (2 * x + w), parent[3] + ((4 * height) / h) * (2 * y + h),
62 ];
63 const box: Box = { name, size, files: files ?? 1, dir: !!children, ext: children ? null : extension(name), x, y, w, h, surface, children: [] };
64 if (!children?.length) return box;
65 const shown = children.reduce((sum, child) => sum + child[1], 0);
66 const shownFiles = children.reduce((sum, child) => sum + (child[2] ?? 1), 0);
67 const items = [...children, ...(size > shown ? [["", size - shown, files! - shownFiles, []] satisfies MapNode] : [])]
68 .filter((item) => item[1] > 0).map((item) => ({ item, size: item[1] })).sort((a, b) => b.size - a.size);
69 box.children = squarify(items, w, h)
70 .map((cell) => layout(cell.item.item, x + cell.x, y + cell.y, cell.w, cell.h, surface, height * FALLOFF));
71 return box;
72}
73
74const leaves = (box: Box): Box[] => (box.children.length ? box.children.flatMap(leaves) : [box]);
75
76/** The chain of boxes from `box` down to the leaf under the point. */
77function hit(box: Box, px: number, py: number): Box[] {
78 const inside = box.children.find((child) => px >= child.x && px < child.x + child.w && py >= child.y && py < child.y + child.h);
79 return inside ? [box, ...hit(inside, px, py)] : [box];
80}
81
82function rgb(canvas: HTMLCanvasElement, variable: string) {
83 const probe = document.createElement("canvas").getContext("2d")!;
84 probe.fillStyle = getComputedStyle(canvas).getPropertyValue(variable).trim();
85 const hex = probe.fillStyle;
86 return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
87}
88
89/** Every file in the pool as a cushion-shaded treemap, WinDirStat style, colored by type. */
90export function StorageMap(props: { copyparty: { href: string; app?: Pick<ServiceSummary, "id" | "name" | "icon"> } | null | undefined }) {
91 const [params, setParams] = useSearchParams<{ path?: string }>();
92 const current = () => params.path ?? "";
93 const go = (path: string) => setParams({ path: path || undefined });
94 let frame!: HTMLDivElement;
95 let canvas!: HTMLCanvasElement;
96 const [size, setSize] = createSignal({ width: 0, height: 0 });
97 const [scheme, setScheme] = createSignal(0);
98 onMount(() => {
99 const resizes = new ResizeObserver(([entry]) => setSize({ width: entry!.contentRect.width, height: entry!.contentRect.height }));
100 resizes.observe(frame);
101 const dark = matchMedia("(prefers-color-scheme: dark)");
102 const repaint = () => setScheme(scheme() + 1);
103 dark.addEventListener("change", repaint);
104 onCleanup(() => {
105 resizes.disconnect();
106 dark.removeEventListener("change", repaint);
107 });
108 });
109 // Buckets keep a window resize from refetching on every pixel.
110 const query = () => size().width ? {
111 path: current(),
112 width: String(Math.ceil(size().width / 128) * 128),
113 height: String(Math.ceil(size().height / 128) * 128),
114 } : null;
115 const [map, { refetch }] = createResource(query, (query) =>
116 parseResponse(api.storage.files.map.$get({ query })).then((map) => ({ ...map, path: query.path })));
117 const loaded = lastGood(map);
118 /** A resize redraws the map it has, but another folder's would zoom to the wrong paths. */
119 const data = () => (loaded()?.path === current() ? loaded() : undefined);
120 createEffect(() => {
121 if (!data()?.scanning || data()?.tree) return;
122 const timer = setTimeout(refetch, 2000);
123 onCleanup(() => clearTimeout(timer));
124 });
125
126 const root = createMemo(() => {
127 const tree = data()?.tree;
128 const { width, height } = size();
129 return tree && width && tree[1] > 0 ? layout(tree, 0, 0, width, height, [0, 0, 0, 0], HEIGHT) : null;
130 });
131 const legend = createMemo(() => {
132 const sizes = new Map<string, { size: number; files: number }>();
133 let small = 0;
134 for (const leaf of root() ? leaves(root()!) : []) {
135 if (leaf.ext === null) small += leaf.size;
136 else {
137 const total = sizes.get(leaf.ext) ?? { size: 0, files: 0 };
138 sizes.set(leaf.ext, { size: total.size + leaf.size, files: total.files + 1 });
139 }
140 }
141 const ranked = [...sizes].sort((a, b) => b[1].size - a[1].size);
142 return { top: ranked.slice(0, 14), rest: ranked.slice(14).reduce((sum, [, total]) => sum + total.size, 0), small };
143 });
144 const [focus, setFocus] = createSignal<string | null | undefined>(undefined);
145 const [pointer, setPointer] = createSignal<Box[] | null>(null);
146 const [picked, setPicked] = createSignal<number | null>(null);
147 /** The chain under the pointer, or the child of the view picked with the arrow keys. */
148 const chain = () => {
149 const child = picked() !== null && root()?.children[picked()!];
150 return pointer() ?? (child ? [root()!, child] : null);
151 };
152 /** The folder a click opens; the unnamed box is a folder's small files together. */
153 const target = () => {
154 const box = chain()?.[1];
155 return box?.dir && box.name ? box : null;
156 };
157
158 createEffect(() => {
159 const box = root();
160 const highlight = focus();
161 scheme();
162 const ratio = devicePixelRatio;
163 canvas.width = Math.round(size().width * ratio);
164 canvas.height = Math.round(size().height * ratio);
165 if (!box) return;
166 const context = canvas.getContext("2d")!;
167 const image = context.createImageData(canvas.width, canvas.height);
168 const colors = new Map<string, number[]>();
169 const page = rgb(canvas, "--page");
170 const rims = matchMedia("(prefers-color-scheme: dark)").matches ? 1 : LIGHT_RIMS;
171 for (const leaf of leaves(box)) {
172 const variable = leaf.ext === null ? "--other" : colorOf(leaf.ext);
173 let color = colors.get(variable) ?? rgb(canvas, variable);
174 colors.set(variable, color);
175 // Space under the cutoff reads as a quieter "other".
176 if (leaf.ext === null) color = color.map((c, i) => (c + page[i]!) / 2);
177 if (highlight !== undefined && leaf.ext !== highlight) color = color.map((c, i) => c * 0.25 + page[i]! * 0.75);
178 const [red = 0, green = 0, blue = 0] = color;
179 const [s0, s1, s2, s3] = leaf.surface;
180 const [x0, x1, y0, y1] = [leaf.x, leaf.x + leaf.w, leaf.y, leaf.y + leaf.h].map((v) => Math.round(v * ratio));
181 for (let py = y0!; py < y1!; py++) {
182 const y = (py + 0.5) / ratio;
183 const ny = -(2 * s1 * y + s3);
184 for (let px = x0!; px < x1!; px++) {
185 const x = (px + 0.5) / ratio;
186 const nx = -(2 * s0 * x + s2);
187 const light = AMBIENT + (1 - AMBIENT) * Math.max(0, ((nx + ny) * LIGHT_XY + LIGHT_Z) / Math.sqrt(nx * nx + ny * ny + 1));
188 const shade = light < FLAT ? 1 - (1 - light / FLAT) * rims : light / FLAT;
189 const at = (py * canvas.width + px) * 4;
190 image.data[at] = red * shade;
191 image.data[at + 1] = green * shade;
192 image.data[at + 2] = blue * shade;
193 image.data[at + 3] = 255;
194 }
195 }
196 }
197 context.putImageData(image, 0, 0);
198 });
199
200 const outline = (box: Box | null | undefined) =>
201 box ? { left: `${box.x}px`, top: `${box.y}px`, width: `${box.w}px`, height: `${box.h}px` } : { display: "none" };
202 const zoom = () => {
203 const box = target();
204 if (!box) return;
205 setPointer(null);
206 setPicked(null);
207 go(current() ? `${current()}/${box.name}` : box.name);
208 };
209 const up = () => current() && go(current().split("/").slice(0, -1).join("/"));
210
211 return (
212 <div class="map fill">
213 <div class="files-head">
214 <Crumbs root={data()?.root ?? ""} path={current()} />
215 <span class="spacer" />
216 <span class="muted nowrap map-status">
217 <Show when={chain()} fallback={root() && `${bytes(root()!.size)} in ${plural(root()!.files, "file")}`}>
218 {(boxes) => {
219 const leaf = () => boxes().at(-1)!;
220 return (
221 <>
222 <span class="path">
223 {[current(), ...boxes().slice(1).map((box) => box.name || "smaller files")].filter(Boolean).join("/")}
224 </span>
225 <b>{bytes(leaf().size)}</b>
226 <Show when={leaf().ext === null}>{plural(leaf().files, "file")}</Show>
227 </>
228 );
229 }}
230 </Show>
231 </span>
232 <Show when={props.copyparty}>
233 {(link) => <OpenApp app={link().app ?? { id: "copyparty", name: "Copyparty", icon: null }} href={link().href} />}
234 </Show>
235 </div>
236 <div class="map-body">
237 <div ref={frame} class="map-frame" tabindex="0" role="img" classList={{ zoomable: !!target() }}
238 aria-label={`Treemap of ${data()?.root ?? ""}${current() ? `/${current()}` : ""}. Arrow keys pick a folder, Enter opens it, Backspace goes up.`}
239 onPointerMove={(event) => {
240 const box = root();
241 const bounds = frame.getBoundingClientRect();
242 setPointer(box ? hit(box, event.clientX - bounds.left, event.clientY - bounds.top) : null);
243 }}
244 onPointerLeave={() => setPointer(null)}
245 onBlur={() => setPicked(null)}
246 onClick={zoom}
247 onKeyDown={(event) => {
248 const children = root()?.children.length || 1;
249 const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
250 if (step) setPicked(((picked() ?? (step > 0 ? -1 : 0)) + step + children) % children);
251 else if (event.key === "Enter") zoom();
252 else if (event.key === "Backspace") up();
253 else return;
254 event.preventDefault();
255 }}>
256 <canvas ref={canvas} />
257 <div class="outline target" style={outline(target())} />
258 <div class="outline leaf" style={outline(chain()?.at(-1))} />
259 <Show when={!data()?.tree && data()}>
260 <div class="empty">
261 {data()!.scanning ? "Indexing the pool. The map fills in when the first pass finishes." : "Nothing is indexed at this path."}
262 </div>
263 </Show>
264 <Show when={!data() && map.loading}><div class="skeleton" /></Show>
265 <Show when={!data() && map.error}><div class="empty">{reason(map.error)}</div></Show>
266 </div>
267 <ul class="map-legend" aria-label="Types">
268 <For each={legend().top}>
269 {([ext, total]) => (
270 <li tabindex="0" onPointerEnter={() => setFocus(ext)} onPointerLeave={() => setFocus(undefined)}
271 onFocus={() => setFocus(ext)} onBlur={() => setFocus(undefined)}
272 data-tip={`${plural(total.files, "file")}, ${percent((total.size / root()!.size) * 100)}`}>
273 <span class="swatch" style={{ background: `var(${colorOf(ext)})` }} />
274 <span class="mono">{ext ? `.${ext}` : "no extension"}</span>
275 <span class="v">{bytes(total.size)}</span>
276 </li>
277 )}
278 </For>
279 <Show when={legend().rest}>
280 <li class="muted">
281 <span class="swatch" />
282 <span>other types</span>
283 <span class="v">{bytes(legend().rest)}</span>
284 </li>
285 </Show>
286 <Show when={legend().small}>
287 <li tabindex="0" onPointerEnter={() => setFocus(null)} onPointerLeave={() => setFocus(undefined)}
288 onFocus={() => setFocus(null)} onBlur={() => setFocus(undefined)}
289 data-tip="Too small to draw at this zoom; open a folder to see them">
290 <span class="swatch small" />
291 <span>under {bytes(data()!.min)}</span>
292 <span class="v">{bytes(legend().small)}</span>
293 </li>
294 </Show>
295 </ul>
296 </div>
297 </div>
298 );
299}
dashboard/web/pages/Storage.tsx created+623
......@@ -0,0 +1,623 @@
1import { useSearchParams } from "@solidjs/router";
2import ArrowDownWideNarrow from "lucide-solid/icons/arrow-down-wide-narrow";
3import Disc3 from "lucide-solid/icons/disc-3";
4import FolderTree from "lucide-solid/icons/folder-tree";
5import LayoutGrid from "lucide-solid/icons/layout-grid";
6import { type InferResponseType, parseResponse } from "hono/client";
7import ChevronRight from "lucide-solid/icons/chevron-right";
8import Clapperboard from "lucide-solid/icons/clapperboard";
9import Database from "lucide-solid/icons/database";
10import ExternalLink from "lucide-solid/icons/external-link";
11import FolderOpen from "lucide-solid/icons/folder-open";
12import { createEffect, createMemo, createResource, createSignal, For, type JSX, Match, Show, Switch } from "solid-js";
13import type { Health } from "../types/model.ts";
14import type { VdevState } from "../types/storage.ts";
15import { api, queries, reason } from "../api.ts";
16import { Ago } from "../components/Ago.tsx";
17import { Copy } from "../components/Copy.tsx";
18import { Explorer, REGIONS, squarify } from "../components/Explorer.tsx";
19import { showConfirmDialog } from "../components/Dialog.tsx";
20import { ListPage } from "../components/ListPage.tsx";
21import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
22import { StatusLabel } from "../components/Status.tsx";
23import { TabBar } from "../components/TabBar.tsx";
24import { ago, bytes, celsius, count, date, datetime, duration, percent, plural } from "../format.ts";
25import "./Storage.css";
26import { StorageMap } from "./Storage.map.tsx";
27
28type Overview = InferResponseType<typeof api.storage.$get, 200>;
29type Disk = Overview["pool"]["vdevs"][number]["disks"][number];
30
31const HEALTH: Record<VdevState, Health> = {
32 ONLINE: "healthy", DEGRADED: "degraded", FAULTED: "down", UNAVAIL: "down", REMOVED: "down", OFFLINE: "stopped",
33 AVAIL: "stopped", INUSE: "degraded",
34};
35
36export const TABS = [
37 ["datasets", "datasets", Database], ["files", "files", FolderTree], ["map", "map", LayoutGrid],
38 ["largest", "largest files", ArrowDownWideNarrow], ["disks", "disks", Disc3],
39] as const;
40type Tab = (typeof TABS)[number][0];
41
42const href = (tab: Tab, path?: string) => `/storage?${new URLSearchParams(path ? { tab, path } : { tab })}`;
43
44const onActivate = (action: (event: KeyboardEvent) => void) => (event: KeyboardEvent) => {
45 if (event.key !== "Enter" && event.key !== " ") return;
46 event.preventDefault();
47 action(event);
48};
49
50const mediaHref = (path: string) => `/media?${new URLSearchParams(path ? { path } : {})}`;
51
52/** An icon link in a row's action cell; it doesn't also trigger the row. */
53function RowLink(props: { href: string; label: string; external?: boolean; children: JSX.Element }) {
54 return (
55 <a class="row-icon" href={props.href} aria-label={props.label} data-tip={props.label} onClick={(event) => event.stopPropagation()}
56 target={props.external ? "_blank" : undefined} rel={props.external ? "noreferrer" : undefined}>
57 {props.children}
58 </a>
59 );
60}
61
62const exact = (size: number) => (size >= 1024 ? `${count(size)} bytes` : undefined);
63
64/** ATA attributes 5 and 197 together; null for NVMe drives, which report neither. */
65const badSectors = (disk: Disk) => (disk.smart?.reallocated == null ? null : disk.smart.reallocated + (disk.smart.pending ?? 0));
66const errors = (disk: Disk) => disk.read + disk.write + disk.checksum;
67
68/** The pool's health as pills beside the title: space, the last scrub, and disks that need a look. */
69function Stats(props: { data: Overview }) {
70 const space = () => props.data.space;
71 const pool = () => props.data.pool;
72 const disks = () => pool().vdevs.flatMap((vdev) => vdev.disks);
73 const trouble = () => disks().filter((disk) => disk.issue);
74 const used = () => space().live + space().held;
75 const slices = () => {
76 let offset = 0;
77 return ([["used", space().live, "--series-1"], ["snapshots", space().held, "--series-2"], ["free", space().free, "--other"]] as const)
78 .map(([label, value, color]) => {
79 const share = (value / (used() + space().free)) * 100;
80 offset += share;
81 return { label, value, color: `var(${color})`, share, offset: offset - share };
82 });
83 };
84 const hottest = () => Math.max(...disks().map((disk) => disk.smart?.temperature ?? -Infinity));
85 return (
86 <>
87 <span class="stat space" data-tip={`${bytes(used())} / ${bytes(used() + space().free)} `
88 + `(${percent((used() / (used() + space().free)) * 100)}), ${pool().fragmentation}% fragmented`}>
89 <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
90 <For each={slices()}>
91 {(slice) => (
92 <circle cx="7" cy="7" r="3.5" fill="none" stroke-width="7" transform="rotate(-90 7 7)" style={{ stroke: slice.color }}
93 pathLength="100" stroke-dasharray={`${slice.share} 100`} stroke-dashoffset={-slice.offset} />
94 )}
95 </For>
96 </svg>
97 <For each={slices()}>
98 {(slice, i) => <>{i() ? ", " : ""}<b>{bytes(slice.value)}</b> {slice.label}</>}
99 </For>
100 </span>
101 <Show when={pool().errors !== "No known data errors"}>
102 <span class="stat" data-tip={pool().errors}><StatusLabel health="down">data errors</StatusLabel></span>
103 </Show>
104 <Show when={pool().scan} fallback={<span class="stat">never scrubbed</span>}>
105 {(scan) => (
106 <Show when={scan().state !== "scanning"} fallback={
107 <span class="stat" data-tip={`${bytes(scan().examined)} / ${bytes(scan().total)}, started ${ago(scan().start)}`}>
108 <StatusLabel health="working">{scan().kind === "scrub" ? "scrubbing" : "resilvering"}</StatusLabel>
109 <b>{percent((scan().examined / scan().total) * 100)}</b>
110 </span>
111 }>
112 <span class="stat" data-tip={[
113 datetime(scan().end ?? scan().start),
114 scan().end && `took ${duration(scan().end! - scan().start)}`,
115 scan().repaired || scan().errors
116 ? `repaired ${bytes(scan().repaired)}, ${count(scan().errors)} unrepairable` : `${bytes(scan().examined)} clean`,
117 ].filter(Boolean).join(", ")}>
118 <StatusLabel health={scan().repaired || scan().errors ? "degraded" : "healthy"}>
119 {scan().kind === "scrub" ? "scrubbed" : "resilvered"}
120 {scan().state === "canceled" ? " until canceled" : ""}
121 </StatusLabel>
122 <b>{ago(scan().end ?? scan().start)}</b>
123 </span>
124 </Show>
125 )}
126 </Show>
127 <a class="stat" href={href("disks")} data-tip={trouble().length
128 ? trouble().map((disk) => `${disk.smart?.serial ?? disk.name}: ${disk.issue}`).join("; ")
129 : hottest() > -Infinity ? `hottest ${celsius(hottest())}` : undefined}>
130 <Show when={trouble().length} fallback={<StatusLabel health="healthy">{plural(disks().length, "disk")}</StatusLabel>}>
131 <StatusLabel health="degraded">{trouble().length === 1 ? "1 disk needs" : `${trouble().length} disks need`} a look</StatusLabel>
132 </Show>
133 </a>
134 </>
135 );
136}
137
138function Disks(props: { data: Overview }) {
139 return (
140 <div class="edge">
141 <table class="data disks">
142 <thead>
143 <tr>
144 <th>disk</th><th>state</th><th class="num">temperature</th><th class="num">powered on</th>
145 <th class="num" data-tip="Reallocated and pending sectors">bad sectors</th>
146 <th class="num" data-tip="Read, write and checksum errors since the last scrub">errors</th><th>smart</th>
147 </tr>
148 </thead>
149 <tbody>
150 <For each={props.data.pool.vdevs}>
151 {(vdev) => (
152 <>
153 <tr class="vdev">
154 <td class="mono" data-tip={/^raidz(\d)/.test(vdev.name)
155 ? `${vdev.disks.length} disks, survives ${vdev.name[5]} failing` : vdev.name.startsWith("mirror")
156 ? `${vdev.disks.length}-way mirror` : undefined}>{vdev.name}</td>
157 <td colspan="6"><StatusLabel health={HEALTH[vdev.state]}>{vdev.state.toLowerCase()}</StatusLabel></td>
158 </tr>
159 <For each={vdev.disks}>
160 {(disk) => (
161 <tr classList={{ trouble: !!disk.issue }}>
162 <td class="disk">
163 <Copy value={disk.name} label="device ID"><span class="mono">{disk.smart?.serial ?? disk.name}</span></Copy>
164 <Show when={disk.smart}>
165 {(smart) => <div class="muted">{smart().model} <span class="capacity">{Math.round(smart().capacity / 1e11) / 10} TB</span></div>}
166 </Show>
167 </td>
168 <td><StatusLabel health={HEALTH[disk.state]}>{disk.state.toLowerCase()}</StatusLabel></td>
169 <td class="num"><span data-tip={disk.smart
170 ? `${disk.smart.temperatureRange[0]}–${celsius(disk.smart.temperatureRange[1])} over 24h` : undefined}>
171 {disk.smart ? celsius(disk.smart.temperature) : "–"}
172 </span></td>
173 <td class="num"><span data-tip={disk.smart ? `${count(disk.smart.powerOnHours)} hours` : undefined}>
174 {disk.smart ? `${(disk.smart.powerOnHours / 8766).toFixed(1)} years` : "–"}
175 </span></td>
176 <td class="num"><span data-tip={badSectors(disk)
177 ? `${count(disk.smart!.reallocated!)} reallocated, ${count(disk.smart!.pending ?? 0)} pending` : undefined}>
178 <Show when={badSectors(disk) !== null} fallback="–">
179 <Show when={badSectors(disk)} fallback={0}>
180 {(bad) => <StatusLabel health="degraded">{count(bad())}</StatusLabel>}
181 </Show>
182 </Show>
183 </span></td>
184 <td class="num"><span data-tip={errors(disk) ? `${disk.read} read, ${disk.write} write, ${disk.checksum} checksum` : undefined}>
185 <Show when={errors(disk)} fallback={0}>
186 {(total) => <StatusLabel health="degraded">{count(total())}</StatusLabel>}
187 </Show>
188 </span></td>
189 <td>
190 <Show when={disk.smart} fallback="–">
191 {(smart) => (
192 <StatusLabel health={smart().passed ? "healthy" : "down"}>{smart().passed ? "passed" : "failed"}</StatusLabel>
193 )}
194 </Show>
195 </td>
196 </tr>
197 )}
198 </For>
199 </>
200 )}
201 </For>
202 </tbody>
203 </table>
204 </div>
205 );
206}
207
208function Snapshots(props: { dataset: string; onDestroy: () => void }) {
209 const [list, { refetch }] = createResource(() => props.dataset, (dataset) =>
210 parseResponse(api.storage.snapshots.$get({ query: { dataset } })));
211 const [selection, setSelection] = createSignal<[anchor: number, end: number] | null>(null);
212 const pick = (i: number, extend: boolean) => {
213 const current = selection();
214 if (extend && current) setSelection([current[0], i]);
215 else setSelection(current && current[0] === i && current[1] === i ? null : [i, i]);
216 };
217
218 return (
219 <div class="snapshots">
220 <Loaded data={list} what={`snapshots of ${props.dataset}`} retry={refetch}
221 skeleton={<div class="skeleton" style={{ height: "90px" }} />}>
222 {(snaps) => {
223 const bounds = createMemo(() => {
224 const picked = selection();
225 return picked && ([Math.min(...picked), Math.max(...picked)] as const);
226 });
227 const range = createMemo(() => {
228 const picked = bounds();
229 return picked && { dataset: props.dataset, from: snaps()[picked[0]]!.name, to: snaps()[picked[1]]!.name };
230 });
231 const [reclaim] = createResource(range, (query) => parseResponse(api.storage.reclaim.$get({ query })));
232 const [removed] = createResource(() => range()?.from === range()?.to && range()?.from,
233 (snapshot) => parseResponse(api.storage.removed.$get({ query: { dataset: props.dataset, snapshot } })));
234 const size = () => bounds()![1] - bounds()![0] + 1;
235 const destroy = () => {
236 const [first, last] = bounds()!;
237 const query = range()!;
238 const one = first === last;
239 const target = one ? `${query.dataset}@${query.from}`
240 : `${query.dataset}, ${date(snaps()[first]!.creation)} to ${date(snaps()[last]!.creation)}`;
241 const consequence = `Frees ${bytes(reclaim()!.bytes)}. Files that exist only in ${one ? "this snapshot" : "these snapshots"} `
242 + "are gone for good.";
243 showConfirmDialog({
244 title: one ? "Destroy snapshot?" : `Destroy ${last - first + 1} snapshots?`,
245 description: <><div class="mono target">{target}</div><div>{consequence}</div></>,
246 confirmLabel: "destroy",
247 destructive: true,
248 onConfirm: async () => {
249 await parseResponse(api.storage.destroy.$post({ json: query }));
250 setSelection(null);
251 await refetch();
252 props.onDestroy();
253 },
254 });
255 };
256 return (
257 <Show when={snaps().length} fallback={<div class="empty">No snapshots of this dataset</div>}>
258 <table class="data">
259 <thead>
260 <tr><th>snapshot</th><th>taken</th><th class="num" data-tip="Space only this snapshot holds">held</th></tr>
261 </thead>
262 <tbody>
263 <For each={snaps()}>
264 {(snap, i) => {
265 const selected = () => {
266 const picked = bounds();
267 return !!picked && i() >= picked[0] && i() <= picked[1];
268 };
269 return (
270 <tr class="pickable" classList={{ selected: selected() }} tabindex="0" aria-selected={selected()}
271 onClick={(event) => pick(i(), event.shiftKey)}
272 onKeyDown={(event) => {
273 if (event.key === "Escape") setSelection(null);
274 else onActivate(() => pick(i(), event.shiftKey))(event);
275 }}>
276 <td class="mono">
277 {snap.name}
278 <For each={snap.clones}>
279 {(clone) => <span class="chip" data-tip={clone}>cloned</span>}
280 </For>
281 </td>
282 <td class="nowrap"><Ago t={snap.creation} /></td>
283 <td class="num"><span data-tip={`refers to ${bytes(snap.referenced)}`}>{bytes(snap.used)}</span></td>
284 </tr>
285 );
286 }}
287 </For>
288 </tbody>
289 </table>
290 <div class="selection" aria-live="polite">
291 <Show when={range()} fallback={
292 <span class="muted">select a snapshot to see what destroying it frees, <kbd>shift</kbd> + click for a range</span>
293 }>
294 <Show when={!reclaim.error} fallback={<span class="error">{reason(reclaim.error)}</span>}>
295 <span>
296 destroying {plural(size(), "snapshot")} frees{" "}
297 <strong>{reclaim.state === "ready" ? bytes(reclaim().bytes) : "…"}</strong>
298 </span>
299 </Show>
300 <Show when={removed.state === "ready" && removed()}>
301 {(gone) => (
302 <span class="muted" data-tip={gone().largest.map((file) => `${file.path} ${bytes(file.size)}`).join(", ")
303 + (gone().count > gone().largest.length ? ` and ${count(gone().count - gone().largest.length)} more` : "")}>
304 {gone().count ? `holds ${plural(gone().count, "deleted file")}` : "holds no deleted files"}
305 </span>
306 )}
307 </Show>
308 <span class="spacer" />
309 <button class="button danger small" disabled={!!reclaim.error || reclaim.state !== "ready"} onClick={destroy}>
310 destroy
311 </button>
312 </Show>
313 </div>
314 </Show>
315 );
316 }}
317 </Loaded>
318 </div>
319 );
320}
321
322const W = 1000;
323const H = 300;
324type Dataset = Overview["datasets"][number];
325/** What a dataset takes in the pool itself: its files and snapshots, without child datasets. */
326const within = (path: string, root: string) => path === root || path.startsWith(root + "/");
327const own = (dataset: Dataset) => dataset.usedbydataset + dataset.usedbysnapshots;
328/** Room between regions, and the area under which datasets merge into one block, in treemap units. */
329const GAP = 3;
330const CELL = 400;
331/** The smallest share a region is drawn at, so prod and staging keep room to point at. */
332const FLOOR = 0.1;
333
334function Datasets(props: { data: Overview; onDestroy: () => void }) {
335 const [params, setParams] = useSearchParams<{ dataset?: string }>();
336 const open = (dataset: string | undefined) => setParams({ dataset }, { replace: true });
337 /** A dataset picked on the treemap, whose row scrolls into view once it opens. */
338 const [reveal, setReveal] = createSignal(params.dataset);
339 const [hover, setHover] = createSignal<string | null>(null);
340 const [region, setRegion] = createSignal<(typeof REGIONS)[number] | null>(null);
341 const rel = (name: string) => name.slice(props.data.pool.name.length + 1);
342 const groups = createMemo(() => REGIONS.map((region) => {
343 const members = props.data.datasets
344 .filter((dataset) => own(dataset) > 0 && REGIONS.findLast((r) => within(dataset.mountpoint ?? "", r.root)) === region)
345 .sort((a, b) => own(b) - own(a));
346 const size = members.reduce((sum, dataset) => sum + own(dataset), 0);
347 return { region, members, size, held: members.reduce((sum, dataset) => sum + dataset.usedbysnapshots, 0) };
348 }).filter((group) => group.size > 0));
349 const blocks = createMemo(() => {
350 const total = groups().reduce((sum, group) => sum + group.size, 0);
351 const areas = groups().map((group) => ({ group, size: Math.max(group.size, total * FLOOR) })).sort((a, b) => b.size - a.size);
352 return squarify(areas, W, H).map(({ item: { group }, x, y, w, h }) => {
353 const scale = ((w - 2 * GAP) * (h - 2 * GAP)) / group.size;
354 const shown = group.members.filter((dataset) => own(dataset) * scale >= CELL);
355 const tiny = group.members.slice(shown.length);
356 const rest = tiny.reduce((sum, dataset) => sum + own(dataset), 0);
357 const items = [
358 ...shown.map((dataset) => ({ dataset, size: own(dataset) })),
359 ...(tiny.length ? [{ dataset: null, size: Math.max(rest, CELL / scale) }] : []),
360 ];
361 return { group, x, y, w, h, tiny, rest, cells: squarify(items, w - 2 * GAP, h - 2 * GAP) };
362 });
363 });
364
365 return (
366 <>
367 <div class="files-head">
368 <ul class="regions" aria-label="Regions">
369 <For each={groups()}>
370 {(group) => {
371 return (
372 <li tabindex="0" style={{ "--kind": `var(--series-${group.region.series})` }}
373 onPointerEnter={() => setRegion(group.region)} onPointerLeave={() => setRegion(null)}
374 onFocus={() => setRegion(group.region)} onBlur={() => setRegion(null)}
375 data-tip={`${group.region.root}${group.region.label === "clover" ? " without Media" : ""}: `
376 + `${bytes(group.size - group.held)} used, ${bytes(group.held)} snapshots, `
377 + `${percent((group.size / (props.data.space.live + props.data.space.held + props.data.space.free)) * 100)} of the pool`}>
378 <span class="swatch" />{group.region.label}<b>{bytes(group.size)}</b>
379 </li>
380 );
381 }}
382 </For>
383 </ul>
384 <span class="spacer" />
385 <span class="held-key"><span class="swatch" />snapshots</span>
386 </div>
387 <div class="treemap regions-map" aria-hidden="true" style={{ "aspect-ratio": `${W} / ${H}` }}>
388 <For each={blocks()}>
389 {(block) => (
390 <div class="region" classList={{ dim: !!region() && region() !== block.group.region }}
391 style={{
392 left: `${(block.x / W) * 100}%`, top: `${(block.y / H) * 100}%`,
393 width: `${(block.w / W) * 100}%`, height: `${(block.h / H) * 100}%`,
394 "--kind": `var(--series-${block.group.region.series})`,
395 }}>
396 <For each={block.cells}>
397 {({ item: { dataset }, x, y, w, h }) => (
398 <div class="tm-cell"
399 classList={{ dir: !!dataset, rest: !dataset, hover: !!dataset && hover() === dataset.name,
400 open: !!dataset && params.dataset === dataset.name }}
401 style={{
402 left: `${((GAP + x) / block.w) * 100}%`, top: `${((GAP + y) / block.h) * 100}%`,
403 width: `${(w / block.w) * 100}%`, height: `${(h / block.h) * 100}%`,
404 "--held": dataset ? `${(dataset.usedbysnapshots / own(dataset)) * 100}%` : undefined,
405 }}
406 data-tip={dataset
407 ? [
408 `${dataset.mountpoint ?? dataset.name}: ${bytes(dataset.usedbydataset)} used`,
409 dataset.usedbysnapshots && `${bytes(dataset.usedbysnapshots)} snapshots`,
410 dataset.quota && `${percent((dataset.used / dataset.quota) * 100)} of a ${bytes(dataset.quota)} quota`,
411 ].filter(Boolean).join(", ")
412 : `${block.tiny.slice(0, 4).map((tiny) => rel(tiny.name).split("/").at(-1)).join(", ")}`
413 + `${block.tiny.length > 4 ? ` and ${count(block.tiny.length - 4)} more` : ""}: ${bytes(block.rest)}`}
414 onMouseEnter={() => dataset && setHover(dataset.name)} onMouseLeave={() => setHover(null)}
415 onClick={() => {
416 if (!dataset) return;
417 open(dataset.name);
418 setReveal(dataset.name);
419 }}>
420 <Show when={w > 50 && h > 22}>
421 <span class="name">{dataset ? rel(dataset.name).split("/").at(-1) : `${block.tiny.length} more`}</span>
422 </Show>
423 <Show when={dataset && w > 50 && h > 44}><span class="size">{bytes(own(dataset!))}</span></Show>
424 </div>
425 )}
426 </For>
427 </div>
428 )}
429 </For>
430 </div>
431 <div class="fill">
432 <table class="data datasets">
433 <thead>
434 <tr>
435 <th>dataset</th>
436 <th class="num" data-tip="Including snapshots and child datasets">total</th>
437 <th class="num" data-tip="This dataset's own files, without snapshots or child datasets">live</th>
438 <th class="num">snapshots</th><th class="num">compression</th><th class="num">quota</th><th />
439 </tr>
440 </thead>
441 <tbody>
442 <For each={props.data.datasets}>
443 {(dataset) => {
444 /** Listed ancestors; staging is a plain folder, so a stage sits under srv as staging/<stage>. */
445 const above = props.data.datasets.filter((other) => dataset.name.startsWith(other.name + "/"));
446 const depth = above.length;
447 const expanded = () => params.dataset === dataset.name;
448 let row!: HTMLTableRowElement;
449 createEffect(() => {
450 if (!expanded() || reveal() !== dataset.name) return;
451 row.scrollIntoView({ block: "start", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
452 setReveal(undefined);
453 });
454 return (
455 <>
456 <tr ref={row} classList={{ open: expanded(), hover: hover() === dataset.name }}
457 onMouseEnter={() => setHover(dataset.name)} onMouseLeave={() => setHover(null)}>
458 <td>
459 <div class="name">
460 <button class="expander" style={{ "padding-left": `${depth * 16}px` }} aria-expanded={expanded()}
461 onClick={() => open(expanded() ? undefined : dataset.name)}>
462 <ChevronRight class="chevron" classList={{ open: expanded() }} />
463 <span class="mono">{depth ? dataset.name.slice(above.at(-1)!.name.length + 1) : dataset.mountpoint ?? dataset.name}</span>
464 </button>
465 <Show when={dataset.origin}>
466 {(origin) => <span class="chip" data-tip={origin()}>clone</span>}
467 </Show>
468 </div>
469 </td>
470 <td class="num"><span data-tip={dataset.used - dataset.usedbydataset - dataset.usedbysnapshots > 0
471 ? `${bytes(dataset.usedbydataset + dataset.usedbysnapshots)} own, `
472 + `${bytes(dataset.used - dataset.usedbydataset - dataset.usedbysnapshots)} in child datasets`
473 : undefined}>
474 {bytes(dataset.used)}
475 </span></td>
476 <td class="num">{bytes(dataset.usedbydataset)}</td>
477 <td class="num">{dataset.usedbysnapshots ? bytes(dataset.usedbysnapshots) : "–"}</td>
478 <td class="num"><span data-tip={dataset.compressratio >= 1.01
479 ? `${dataset.compression}, ${bytes(dataset.logicalused)} before` : dataset.compression}>
480 {dataset.compressratio >= 1.01 ? `${dataset.compressratio.toFixed(2)}×` : "–"}
481 </span></td>
482 <td class="num"><span data-tip={dataset.quota
483 ? `${bytes(dataset.used)} / ${bytes(dataset.quota)} (${percent((dataset.used / dataset.quota) * 100)})` : undefined}>
484 {dataset.quota ? bytes(dataset.quota) : "–"}
485 </span></td>
486 <td class="action">
487 <div>
488 <Show when={dataset.media !== null}>
489 <RowLink href={mediaHref(dataset.media!)} label="open in media"><Clapperboard size={14} /></RowLink>
490 </Show>
491 <RowLink href={href("files", rel(dataset.name))} label="browse files">
492 <FolderOpen size={14} />
493 </RowLink>
494 <Show when={dataset.link}>
495 {(link) => <RowLink href={link()} label="open in copyparty" external><ExternalLink size={14} /></RowLink>}
496 </Show>
497 </div>
498 </td>
499 </tr>
500 <Show when={expanded()}>
501 <tr class="expansion">
502 <td colspan="7">
503 <dl class="facts">
504 <div>
505 <dt>mounted at</dt>
506 <dd><Show when={dataset.mountpoint} fallback="nowhere">{(mount) => <Copy value={mount()} />}</Show></dd>
507 </div>
508 <div><dt>records</dt><dd>{bytes(dataset.recordsize)}</dd></div>
509 <div><dt>compression</dt><dd>{dataset.compression}</dd></div>
510 <Show when={dataset.origin}>
511 {(origin) => <div><dt>clone of</dt><dd class="mono">{origin()}</dd></div>}
512 </Show>
513 </dl>
514 <Snapshots dataset={dataset.name} onDestroy={props.onDestroy} />
515 </td>
516 </tr>
517 </Show>
518 </>
519 );
520 }}
521 </For>
522 </tbody>
523 </table>
524 </div>
525 </>
526 );
527}
528
529function Largest() {
530 const [, setParams] = useSearchParams();
531 const [list, { refetch }] = createResource(() => parseResponse(api.storage.files.largest.$get()));
532 return (
533 <Loaded data={list} what="the largest files" retry={refetch} skeleton={<div class="edge"><SkeletonRows count={12} /></div>}>
534 {(list) => (
535 <div class="fill">
536 <Show when={list().length}
537 fallback={<div class="empty">No files indexed yet. The largest show up here after the first scan.</div>}>
538 <table class="data largest">
539 <thead><tr><th>file</th><th class="num">size</th><th class="num">modified</th><th /></tr></thead>
540 <tbody>
541 <For each={list()}>
542 {(file) => {
543 const cut = file.path.lastIndexOf("/");
544 const open = () => setParams({ tab: "files", path: cut === -1 ? undefined : file.path.slice(0, cut) });
545 return (
546 <tr class="pickable" tabindex="0" onClick={open} onKeyDown={onActivate(open)}>
547 <td class="path"><span class="muted">{file.path.slice(0, cut + 1)}</span>{file.path.slice(cut + 1)}</td>
548 <td class="num"><span data-tip={`${exact(file.size)}, ${bytes(file.alloc)} on disk`}>{bytes(file.size)}</span></td>
549 <td class="num"><Ago t={file.mtime} /></td>
550 <td class="action">
551 <div>
552 <Show when={file.link}>
553 {(link) => <RowLink href={link()} label="open in copyparty" external><ExternalLink size={14} /></RowLink>}
554 </Show>
555 </div>
556 </td>
557 </tr>
558 );
559 }}
560 </For>
561 </tbody>
562 </table>
563 </Show>
564 </div>
565 )}
566 </Loaded>
567 );
568}
569
570export function Storage() {
571 const [params] = useSearchParams<{ tab?: Tab; path?: string }>();
572 const tab = () => params.tab ?? "datasets";
573 const [data, { refetch }] = queries.storage.use();
574 const overview = lastGood(data);
575 const apps = lastGood(queries.launcher.use()[0]);
576 /** Copyparty at the folder the map shows. */
577 const files = () => {
578 const root = overview()?.datasets.find((dataset) => dataset.name === overview()!.pool.name)?.link;
579 return root && root + (params.path ?? "").split("/").map(encodeURIComponent).join("/");
580 };
581 return (
582 <ListPage id="storage" class="storage" flush head={
583 <div class="page-head">
584 <h1>storage</h1>
585 <div class="stats" role="status">
586 <Show when={overview()}>{(data) => <Stats data={data()} />}</Show>
587 </div>
588 </div>
589 }>
590 <TabBar label="View">
591 <For each={TABS}>
592 {([id, label, Icon]) => (
593 <a href={href(id, params.path)} aria-current={tab() === id ? "page" : undefined}><Icon size={13} />{label}</a>
594 )}
595 </For>
596 </TabBar>
597 <Switch>
598 <Match when={tab() === "datasets"}>
599 <Loaded data={data} what="datasets" retry={refetch} skeleton={
600 <div class="edge">
601 <div class="skeleton treemap" style={{ height: "160px" }} />
602 <SkeletonRows count={6} />
603 </div>
604 }>
605 {(data) => <Datasets data={data()} onDestroy={refetch} />}
606 </Loaded>
607 </Match>
608 <Match when={tab() === "files"}>
609 <Explorer id="storage" client={api.storage.files} root="/srv" />
610 </Match>
611 <Match when={tab() === "map"}>
612 <StorageMap copyparty={files() ? { href: files()!, app: apps()?.find((app) => app.id === "copyparty") } : null} />
613 </Match>
614 <Match when={tab() === "largest"}><Largest /></Match>
615 <Match when={tab() === "disks"}>
616 <Loaded data={data} what="disks" retry={refetch} skeleton={<div class="edge"><SkeletonRows count={3} /></div>}>
617 {(data) => <Disks data={data()} />}
618 </Loaded>
619 </Match>
620 </Switch>
621 </ListPage>
622 );
623}
dashboard/web/pages/Users.css created+96
......@@ -0,0 +1,96 @@
1/* list ------------------------------------------------------------------- */
2
3.card.access { display: grid; padding: 4px; margin-bottom: 2px; }
4
5.access-row {
6 display: grid;
7 grid-template-columns: 112px 36px 1fr;
8 align-items: center;
9 gap: 12px;
10 min-height: 30px;
11 padding: 3px 10px;
12 border: 0;
13 border-radius: var(--radius);
14 background: none;
15 text-align: left;
16 cursor: pointer;
17 transition: background-color 150ms;
18}
19
20.access-row:hover { background: var(--hover); }
21.access-row[aria-pressed="true"] { background: var(--accent-wash); }
22.access-row .who { font-weight: 600; }
23.access-row[aria-pressed="true"] .who { color: var(--accent); }
24.access-row .num { color: var(--muted); }
25
26.grants { display: flex; flex-wrap: wrap; gap: 2px 14px; font-size: 12.5px; color: var(--text-2); }
27.grant { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
28.grant :is(img, .monogram, .icon) { flex: none; width: 15px; height: 15px; font-size: 9px; border-radius: 22%; }
29.grant.dash .icon { color: var(--muted); border-radius: 0; }
30
31.users-toolbar { display: flex; align-items: center; gap: 8px; margin: 6px 0 8px; }
32.users-toolbar .count { margin-left: 6px; opacity: 0.7; font-variant-numeric: tabular-nums; }
33
34.users-toolbar .search-box { width: 260px; }
35
36.chip.filter { display: inline-flex; align-items: center; gap: 4px; background: var(--accent-wash); color: var(--accent); }
37.chip.filter:hover { background: var(--raised); color: var(--text); }
38
39.users-table { overflow: auto; }
40.users-table table.data td { padding-block: 5px; white-space: nowrap; }
41.users-table tbody tr { cursor: pointer; }
42.users-table tbody tr:hover { background: var(--hover); }
43.users-table tr.disabled td { color: var(--muted); }
44.users-table td.username a { font-weight: 600; border-radius: 3px; }
45.users-table tr.disabled td.username a { font-weight: 500; }
46.users-table td.groups { width: 1%; }
47.users-table td.groups .chips { flex-wrap: nowrap; }
48
49/* user page -------------------------------------------------------------- */
50
51.user-page .page-head { margin-bottom: 12px; }
52.user-grid { display: flex; flex-wrap: wrap; gap: 8px; align-items: start; }
53.user-grid .column { flex: 1 1 320px; display: grid; gap: 8px; min-width: 0; }
54.user-grid .card { padding: 10px 14px 12px; }
55.user-grid h2.card-title { margin: 0 0 8px; color: var(--text); align-items: center; min-height: 26px; }
56.user-grid h2.card-title.opens { margin-top: 14px; }
57.user-grid p { margin: 0; font-size: 13px; }
58.user-grid table.data td { padding: 4px 6px; }
59.user-grid table.data tr:last-child td { border-bottom: 0; }
60
61.toggles { display: flex; flex-wrap: wrap; gap: 6px; }
62
63/* A group or step that's on or off: a button that acts at once, or a checkbox in a form. */
64.chip.toggle { position: relative; padding: 0 10px; border: 1px dashed var(--axis); background: none; color: var(--text-2); cursor: pointer; }
65.chip.toggle:hover:not(:disabled) { border-color: var(--accent); background: none; color: var(--text); }
66.chip.toggle:is([aria-pressed="true"], :has(:checked)) { border: 1px solid var(--accent); background: var(--accent-wash); color: var(--text); }
67.chip.toggle:disabled { opacity: 0.6; cursor: progress; }
68.chip.toggle input { position: absolute; opacity: 0; pointer-events: none; }
69.chip.toggle:has(:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
70
71.fields { display: grid; grid-template-columns: max-content 1fr; align-items: center; gap: 4px 14px; font-size: 13px; }
72.fields .label { color: var(--muted); }
73.fields .field { display: grid; gap: 2px; min-width: 0; }
74.field-error { color: color-mix(in srgb, var(--critical) 80%, var(--text)); font-size: 12px; }
75
76input.inline {
77 width: 100%;
78 height: 28px;
79 padding: 0 8px;
80 border: 1px solid var(--line);
81 border-radius: 6px;
82 background: var(--well);
83 color: var(--text);
84 font: inherit;
85 transition: border-color 150ms, box-shadow 150ms;
86}
87
88input.inline:hover { border-color: var(--axis); }
89input.inline:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-wash); }
90input.inline[aria-invalid="true"] { border-color: var(--critical); }
91input.inline[aria-busy="true"] { cursor: progress; opacity: 0.7; }
92input.inline::placeholder { color: var(--muted); }
93
94a.grant { border-radius: 3px; transition: color 150ms; }
95a.grant:hover { color: var(--accent); }
96.passkeys span + span::before { content: ", "; }
dashboard/web/pages/Users.tsx created+697
......@@ -0,0 +1,697 @@
1import { A, useNavigate, useParams, useSearchParams } from "@solidjs/router";
2import ArrowLeft from "lucide-solid/icons/arrow-left";
3import Search from "lucide-solid/icons/search";
4import UserPlus from "lucide-solid/icons/user-plus";
5import X from "lucide-solid/icons/x";
6import { type Accessor, createSignal, For, type Resource, Show } from "solid-js";
7import { Dynamic } from "solid-js/web";
8import { parseResponse } from "hono/client";
9import { canOpen, sectionsOf, type ServiceSummary } from "../types/model.ts";
10import type { Group } from "../types/users.ts";
11import { api, query, reason } from "../api.ts";
12import { Ago } from "../components/Ago.tsx";
13import { Checkbox } from "../components/Checkbox.tsx";
14import { AppIcon } from "../components/AppIcon.tsx";
15import { Copy } from "../components/Copy.tsx";
16import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx";
17import { ListPage } from "../components/ListPage.tsx";
18import { OpenApp } from "../components/OpenApp.tsx";
19import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
20import { Reveal } from "../components/Reveal.tsx";
21import { PAGES } from "../components/Sidebar.tsx";
22import { TabBar } from "../components/TabBar.tsx";
23import { toast } from "../components/Toast.tsx";
24import { ago, count, date, datetime, plural } from "../format.ts";
25import "./Users.css";
26
27const STEPS: [string, string][] = [
28 ["VERIFY_EMAIL", "verify email"],
29 ["UPDATE_PASSWORD", "new password"],
30 ["UPDATE_PROFILE", "check profile"],
31 ["CONFIGURE_TOTP", "add authenticator"],
32 ["webauthn-register-passwordless", "add passkey"],
33];
34
35const STATES = { all: "all", disabled: "disabled", pending: "setup pending" } as const;
36
37type Params = { q?: string; group?: string; state?: keyof typeof STATES };
38
39const load = () => Promise.all([parseResponse(api.users.$get()), parseResponse(api.services.$get())])
40 .then(([{ users, groups }, services]) => ({ users, groups, services }));
41type Data = Awaited<ReturnType<typeof load>>;
42export const directory = query("users", load);
43const credentialsOf = query("credentials", (id: string) => parseResponse(api.users[":id"].credentials.$get({ param: { id } })));
44type User = Data["users"][number];
45
46const fullName = (user: User) => [user.firstName, user.lastName].filter(Boolean).join(" ");
47const names = (user: User) => user.groups.map((group) => group.name);
48const inState = (user: User, state: keyof typeof STATES) =>
49 state === "all" || (state === "disabled" ? !user.enabled : user.enabled && user.requiredActions.length > 0);
50
51/** The apps a set of groups opens, and dashboard pages; `everything` when nothing is out of reach. */
52function reach(groups: string[], services: ServiceSummary[]) {
53 const apps = services.filter((app) => app.url);
54 const open = {
55 apps: apps.filter((app) => canOpen(groups, app.access)),
56 pages: PAGES.filter((page) => sectionsOf(groups).includes(page.section)),
57 };
58 return { ...open, everything: open.apps.length === apps.length && open.pages.length === PAGES.length };
59}
60
61/** What joining `group` opens beyond what every signed-in user already can. */
62function adds(group: string, services: ServiceSummary[]) {
63 const base = reach([], services);
64 const own = reach([group], services);
65 return {
66 everything: own.everything,
67 apps: own.apps.filter((app) => !base.apps.includes(app)),
68 pages: own.pages.filter((page) => !base.pages.includes(page)),
69 };
70}
71
72/** "30 members · opens Jellyfin, Navidrome" */
73function groupTip(group: string, d: Data) {
74 const more = adds(group, d.services);
75 const members = d.users.filter((user) => names(user).includes(group)).length;
76 const metrics = sectionsOf([group]).includes("metrics") ? ["machine metrics"] : [];
77 const opens = more.everything ? "everything"
78 : [...more.apps.map((app) => app.name), ...more.pages.map((page) => page.label), ...metrics].join(", ");
79 return `${plural(members, "member")} · opens ${opens}`;
80}
81
82/** The app behind a Keycloak `clientId`, which is its service id. */
83const appName = (clientId: string, services: ServiceSummary[]) =>
84 services.find((service) => service.id === clientId)?.name ?? clientId;
85
86/** "active 3h ago in Jellyfin, Shale", from their open sessions. */
87function seen(user: User, services: ServiceSummary[]) {
88 if (!user.sessions.length) return "not signed in";
89 const last = Math.max(...user.sessions.map((session) => session.lastAccess));
90 const used = [...new Set(user.sessions.flatMap((session) => Object.values(session.clients)))].map((id) => appName(id, services));
91 return `active ${ago(last / 1000)} in ${used.join(", ")}`;
92}
93
94/** Tailscale hands out 100.64.0.0/10; the rest of the private ranges are the home network. */
95function network(ip: string) {
96 const [a, b] = ip.split(".").map(Number) as [number, number];
97 if (a === 100 && b >= 64 && b < 128) return "via Tailscale";
98 if (a === 10 || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168)) return "local network";
99}
100
101/** Keycloak's admin console at `path` inside the realm. */
102function KeycloakLink(props: { services: ServiceSummary[] | undefined; path: string }) {
103 return (
104 <Show when={props.services?.find((service) => service.id === "keycloak" && service.url)}>
105 {(keycloak) => <OpenApp app={keycloak()} href={`${keycloak().url}/admin/master/console/#/master/${props.path}`} />}
106 </Show>
107 );
108}
109
110/** Where the list was scrolled when a user page opened, so going back lands in the same place. */
111let listScroll = 0;
112/** Whether the open user page was reached from the list, so "back" can return to it with its filters. */
113let fromList = false;
114
115export function Users() {
116 const params = useParams<{ name?: string }>();
117 const [data, { refetch }] = directory.use();
118 const ready = lastGood(data);
119 return (
120 <Show when={params.name} keyed fallback={<List data={data} ready={ready} refetch={refetch} />}>
121 {(name) => <Person name={name} data={data} refetch={refetch} />}
122 </Show>
123 );
124}
125
126function StateTag(props: { user: User }) {
127 return (
128 <Show when={props.user.enabled} fallback={<span class="chip">disabled</span>}>
129 <Show when={props.user.requiredActions.length}>
130 <span class="chip warn" tabindex="0"
131 data-tip={props.user.requiredActions.map((action) => STEPS.find(([step]) => step === action)?.[1] ?? action).join(", ")}>
132 setup pending
133 </span>
134 </Show>
135 </Show>
136 );
137}
138
139/** Apps and dashboard pages; `used` links each to its page and tips an app with when it was last used. */
140function Grants(props: { apps: ServiceSummary[]; pages: typeof PAGES; used?: Map<string, number> }) {
141 const tag = () => (props.used ? A : "span");
142 return (
143 <span class="grants">
144 <For each={props.apps}>
145 {(app) => (
146 <Dynamic component={tag()} class="grant" href={`/services/${app.id}`}
147 data-tip={props.used?.has(app.id) ? `used ${ago(props.used.get(app.id)! / 1000)}` : undefined}>
148 <AppIcon id={app.id} name={app.name} icon={app.icon} />{app.name}
149 </Dynamic>
150 )}
151 </For>
152 <For each={props.pages}>
153 {(page) => (
154 <Dynamic component={tag()} class="grant dash" href={page.href} data-tip="dashboard page">
155 <page.icon class="icon" />{page.label}
156 </Dynamic>
157 )}
158 </For>
159 </span>
160 );
161}
162
163function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>; refetch: () => void }) {
164 const [params, setParams] = useSearchParams<Params>();
165 const navigate = useNavigate();
166 const state = () => params.state ?? "all";
167 const ready = props.ready;
168 fromList = false;
169 const pick = (group?: string) => setParams({ group: group === params.group ? undefined : group });
170 const href = (user: User) => `/users/${user.username}`;
171 const open = (user: User) => {
172 fromList = true;
173 navigate(href(user));
174 };
175 const shown = (users: User[]) => {
176 const q = params.q?.trim().toLowerCase() ?? "";
177 return users
178 .filter((user) => !q || [user.username, fullName(user), user.email, ...names(user)].some((v) => v?.toLowerCase().includes(q)))
179 .filter((user) => !params.group || names(user).includes(params.group))
180 .filter((user) => inState(user, state()))
181 .toSorted((a, b) => a.username.localeCompare(b.username));
182 };
183 const create = (groups: Group[]) => showConfirmDialog({
184 title: "New user",
185 confirmLabel: "create user",
186 body: () => {
187 const [invite, setInvite] = createSignal(true);
188 return (
189 <>
190 <label class="field">username<input name="username" class="search" required autocomplete="off" spellcheck={false} autofocus /></label>
191 <label class="field">email<input name="email" type="email" class="search" autocomplete="off" spellcheck={false} /></label>
192 <div class="row">
193 <label class="field">first name<input name="firstName" class="search" autocomplete="off" /></label>
194 <label class="field">last name<input name="lastName" class="search" autocomplete="off" /></label>
195 </div>
196 <div class="field" role="group" aria-label="Groups">
197 groups
198 <span class="toggles">
199 <For each={groups}>
200 {(g) => <label class="chip toggle"><input type="checkbox" name="groups" value={g.id} />{g.name}</label>}
201 </For>
202 </span>
203 </div>
204 <div class="field">
205 first sign-in
206 <TabBar label="First sign-in">
207 <button type="button" aria-pressed={invite()} onClick={() => setInvite(true)}>email an invite</button>
208 <button type="button" aria-pressed={!invite()} onClick={() => setInvite(false)}>set a password</button>
209 </TabBar>
210 </div>
211 <Show when={!invite()}>
212 <label class="field">temporary password
213 <input name="password" class="search" required minLength={8} autocomplete="off" spellcheck={false} />
214 <span class="hint">They pick their own at first sign-in</span>
215 </label>
216 </Show>
217 </>
218 );
219 },
220 onConfirm: async (form) => {
221 const text = (name: string) => String(form.get(name) ?? "");
222 const { id } = await parseResponse(api.users.$post({
223 json: {
224 profile: { username: text("username"), email: text("email"), firstName: text("firstName"), lastName: text("lastName") },
225 groups: form.getAll("groups").map(String),
226 setup: form.has("password") ? { kind: "password", password: text("password") } : { kind: "email" },
227 },
228 }));
229 await props.refetch();
230 const user = ready()?.users.find((user) => user.id === id);
231 if (user) open(user);
232 },
233 });
234
235 return (
236 <ListPage id="users" flush head={
237 <div class="page-head">
238 <h1>users</h1>
239 <KeycloakLink services={ready()?.services} path="users" />
240 <span class="spacer" />
241 <button class="button primary" disabled={!ready()} onClick={() => create(ready()!.groups)}>
242 <UserPlus size={14} />new user
243 </button>
244 </div>
245 } summary={
246 <Show when={ready()} fallback={props.data.loading && (
247 <div class="card access" inert>
248 <For each={Array(8)}>
249 {(_, i) => (
250 <div class="access-row">
251 <span class="skeleton" style={{ height: "12px" }} />
252 <span />
253 <span class="skeleton" style={{ height: "12px", width: `${20 + ((i() * 37) % 50)}%` }} />
254 </div>
255 )}
256 </For>
257 </div>
258 )}>
259 {(d) => {
260 const everyone = () => reach([], d().services);
261 const rows = () => d().groups.map((group) => ({
262 group, ...adds(group.name, d().services),
263 members: d().users.filter((user) => names(user).includes(group.name)).map((user) => user.username).sort(),
264 }));
265 const preview = (members: string[]) =>
266 members.length > 6 ? `${members.slice(0, 5).join(", ")} +${members.length - 5} more` : members.join(", ");
267 return (
268 <div class="card access" role="group" aria-label="Filter by group">
269 <button class="access-row" aria-pressed={!params.group} onClick={() => pick()}>
270 <span class="who">everyone</span>
271 <span class="num">{count(d().users.length)}</span>
272 <Grants apps={everyone().apps} pages={everyone().pages} />
273 </button>
274 <For each={rows()}>
275 {(row) => (
276 <button class="access-row" aria-pressed={params.group === row.group.name} onClick={() => pick(row.group.name)}
277 data-tip={row.members.length ? preview(row.members) : undefined}>
278 <span class="who">{row.group.name}</span>
279 <span class="num">{count(row.members.length)}</span>
280 <Show when={!row.everything} fallback={<span class="grants muted">everything</span>}>
281 <Grants apps={row.apps} pages={row.pages} />
282 </Show>
283 </button>
284 )}
285 </For>
286 </div>
287 );
288 }}
289 </Show>
290 }>
291 <Loaded data={props.data} what="users" retry={props.refetch} skeleton={
292 <>
293 <div class="users-toolbar"><span class="skeleton" style={{ width: "260px", height: "30px" }} /></div>
294 <div class="edge"><SkeletonRows count={8} /></div>
295 </>
296 }>
297 {(d) => (
298 <>
299 <div class="users-toolbar">
300 <label class="search-box">
301 <Search size={14} aria-hidden="true" />
302 <input type="search" placeholder="mika, Tanaka, media…" aria-label="Search users" value={params.q ?? ""}
303 onInput={(event) => setParams({ q: event.currentTarget.value || undefined }, { replace: true })} />
304 </label>
305 <Reveal when={!!params.group} axis="x">
306 <button class="chip filter" aria-label={`Show every group, not just ${params.group}`} data-tip="show every group"
307 onClick={() => pick()}>
308 {params.group}<X size={12} />
309 </button>
310 </Reveal>
311 <span class="spacer" />
312 <TabBar label="Filter by state">
313 <For each={Object.entries(STATES) as [keyof typeof STATES, string][]}>
314 {([key, label]) => (
315 <button aria-pressed={state() === key} onClick={() => setParams({ state: key === "all" ? undefined : key })}>
316 {label}
317 <Show when={key !== "all"}>
318 <span class="count">{count(d().users.filter((user) => inState(user, key)).length)}</span>
319 </Show>
320 </button>
321 )}
322 </For>
323 </TabBar>
324 </div>
325 <div class="fill users-table" ref={(el) => requestAnimationFrame(() => (el.scrollTop = listScroll))}
326 onScroll={(event) => (listScroll = event.currentTarget.scrollTop)}>
327 <Show when={shown(d().users).length} fallback={
328 <div class="empty">
329 No users match.{" "}
330 <button class="button small" onClick={() => setParams({ q: undefined, group: undefined, state: undefined })}>
331 clear filters
332 </button>
333 </div>
334 }>
335 <table class="data">
336 <thead><tr><th>username</th><th>name</th><th>email</th><th>groups</th></tr></thead>
337 <tbody>
338 <For each={shown(d().users)}>
339 {(user) => (
340 <tr classList={{ disabled: !user.enabled }}
341 onClick={(event) => !(event.target as Element).closest("a, button") && open(user)}>
342 <td class="username">
343 <span class="inline">
344 <a class="name" href={href(user)} onClick={() => (fromList = true)} data-tip={seen(user, d().services)}>
345 {user.username}
346 </a>
347 <StateTag user={user} />
348 </span>
349 </td>
350 <td>{fullName(user) || "–"}</td>
351 <td class="email">
352 <Show when={user.email} fallback="–">{(email) => <Copy value={email()} />}</Show>
353 </td>
354 <td class="groups">
355 <div class="chips">
356 <For each={user.groups}>
357 {(g) => (
358 <button class="chip" aria-pressed={params.group === g.name} data-tip={groupTip(g.name, d())}
359 onClick={() => pick(g.name)}>
360 {g.name}
361 </button>
362 )}
363 </For>
364 </div>
365 </td>
366 </tr>
367 )}
368 </For>
369 </tbody>
370 </table>
371 </Show>
372 </div>
373 </>
374 )}
375 </Loaded>
376 </ListPage>
377 );
378}
379
380/** Back to the list, through history when it's the page behind this one, so its filters and scroll come back. */
381function useBack() {
382 const navigate = useNavigate();
383 return () => (fromList ? history.back() : navigate("/users"));
384}
385
386function Person(props: { name: string; data: Resource<Data>; refetch: () => unknown }) {
387 const back = useBack();
388 return (
389 <Loaded data={props.data} what="users" retry={props.refetch} skeleton={
390 <div class="page user-page">
391 <div class="page-head"><span class="skeleton" style={{ width: "180px", height: "26px" }} /></div>
392 <div class="user-grid">
393 <For each={[0, 1]}>
394 {() => (
395 <div class="column">
396 <div class="skeleton" style={{ height: "180px" }} />
397 <div class="skeleton" style={{ height: "140px" }} />
398 </div>
399 )}
400 </For>
401 </div>
402 </div>
403 }>
404 {(d) => (
405 <Show when={d().users.find((user) => user.username === props.name)} fallback={
406 <div class="page user-page">
407 <div class="empty">
408 No user named {props.name}.{" "}
409 <button class="button small" onClick={back}>back to users</button>
410 </div>
411 </div>
412 }>
413 {(user) => <Profile user={user()} data={d()} refetch={props.refetch} />}
414 </Show>
415 )}
416 </Loaded>
417 );
418}
419
420function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
421 const navigate = useNavigate();
422 const back = useBack();
423 const param = () => ({ id: props.user.id });
424 const [credentials, credentialActions] = credentialsOf.use(() => props.user.id);
425 const [busy, setBusy] = createSignal<string>();
426
427 const patch = async (json: Parameters<typeof api.users[":id"]["$patch"]>[0]["json"]) => {
428 await parseResponse(api.users[":id"].$patch({ param: param(), json }));
429 await props.refetch();
430 };
431 /** Runs a quick change, marking `key` busy and toasting the reason if it fails. */
432 const run = async (key: string, change: () => Promise<unknown>) => {
433 setBusy(key);
434 try {
435 await change();
436 } catch (failure) {
437 toast(reason(failure));
438 } finally {
439 setBusy();
440 }
441 };
442 const member = (group: Group) => props.user.groups.some((g) => g.id === group.id);
443 const toggleGroup = (group: Group) => run(group.id, async () => {
444 const route = api.users[":id"].groups[":group"];
445 const args = { param: { ...param(), group: group.id } };
446 await parseResponse(member(group) ? route.$delete(args) : route.$put(args));
447 await props.refetch();
448 });
449 const setEnabled = (enabled: boolean) => run("enabled", async () => {
450 await patch({ enabled });
451 if (!enabled) toast(`${props.user.username} can't sign in now`, { label: "undo", run: () => patch({ enabled: true }) });
452 });
453 const toggleStep = (step: string) => run(step, () => patch({
454 requiredActions: props.user.requiredActions.includes(step)
455 ? props.user.requiredActions.filter((a) => a !== step)
456 : [...props.user.requiredActions, step],
457 }));
458 const emailSteps = () => run("email", async () => {
459 await parseResponse(api.users[":id"]["actions-email"].$post({ param: param() }));
460 toast(`Emailed ${props.user.email} a link`);
461 });
462
463 const setPassword = () => showTextDialog({
464 title: `Set ${props.user.username}'s password`,
465 label: "new password, at least 8 characters",
466 body: <Checkbox name="temporary" checked>ask for a new one at next sign-in</Checkbox>,
467 confirmLabel: "set password",
468 validateInput: (value) => value.length >= 8,
469 onConfirm: async (password, form) => {
470 await parseResponse(api.users[":id"].password.$put({ param: param(), json: { password, temporary: form.has("temporary") } }));
471 await Promise.all([props.refetch(), credentialActions.refetch()]);
472 },
473 });
474 const signOut = (sessions: number) => showConfirmDialog({
475 title: `Sign ${props.user.username} out?`,
476 description: `${sessions === 1 ? "Ends their one session" : `Ends all ${sessions} sessions`}. They can sign in again.`,
477 confirmLabel: "sign out",
478 onConfirm: async () => {
479 await parseResponse(api.users[":id"].logout.$post({ param: param() }));
480 await props.refetch();
481 },
482 });
483 const remove = () => {
484 const name = props.user.username;
485 showTextDialog({
486 title: `Delete ${name}?`,
487 description: `${name} is signed out and loses access to everything. This can't be undone.`,
488 label: `type ${name} to confirm`,
489 validateInput: (value) => value === name,
490 confirmLabel: "delete user",
491 destructive: true,
492 onConfirm: async () => {
493 await parseResponse(api.users[":id"].$delete({ param: param() }));
494 back();
495 await props.refetch();
496 toast(`Deleted ${name}`);
497 },
498 });
499 };
500
501 const access = () => reach(props.user.enabled ? names(props.user) : [], props.data.services);
502 /** Last use of each app, by the Keycloak `clientId` its sessions went through. */
503 const used = () => {
504 const last = new Map<string, number>();
505 for (const session of props.user.sessions) {
506 for (const id of Object.values(session.clients)) last.set(id, Math.max(last.get(id) ?? 0, session.lastAccess));
507 }
508 return last;
509 };
510
511 return (
512 <div class="page user-page">
513 <div class="page-head">
514 <button class="button icon-only" aria-label="Back to users" data-tip="users" onClick={back}>
515 <ArrowLeft size={16} />
516 </button>
517 <h1>{props.user.username}</h1>
518 <span class="sub">{fullName(props.user)}</span>
519 <StateTag user={props.user} />
520 <KeycloakLink services={props.data.services} path={`users/${props.user.id}/settings`} />
521 <span class="spacer" />
522 <button class="button danger" onClick={remove}>delete user</button>
523 </div>
524
525 <div class="user-grid">
526 <div class="column">
527 <section class="card">
528 <h2 class="card-title">groups</h2>
529 <div class="toggles" role="group" aria-label="Groups">
530 <For each={props.data.groups}>
531 {(group) => (
532 <button class="chip toggle" aria-pressed={member(group)} disabled={busy() === group.id}
533 data-tip={groupTip(group.name, props.data)} onClick={() => toggleGroup(group)}>
534 {group.name}
535 </button>
536 )}
537 </For>
538 </div>
539 <h2 class="card-title opens">
540 can open
541 </h2>
542 <Show when={props.user.enabled} fallback={<p class="muted">nothing while disabled</p>}>
543 <Show when={!access().everything} fallback={<p class="muted">everything</p>}>
544 <Grants apps={access().apps} pages={access().pages} used={used()} />
545 </Show>
546 </Show>
547 </section>
548 <section class="card">
549 <h2 class="card-title">
550 sign-in
551 <span class="spacer" />
552 <button class="button small" onClick={setPassword}>set password</button>
553 </h2>
554 <Loaded data={credentials} what="sign-in methods" retry={credentialActions.refetch}
555 skeleton={<div class="skeleton" style={{ height: "60px" }} />}>
556 {(list) => {
557 const password = () => list().find((c) => c.type === "password");
558 const passkeys = () => list().filter((c) => c.type.startsWith("webauthn"));
559 const otp = () => list().find((c) => c.type === "otp");
560 return (
561 <dl class="kv">
562 <dt>password</dt>
563 <dd>{password() ? `set ${date(password()!.createdDate / 1000)}` : "none"}</dd>
564 <dt>passkeys</dt>
565 <dd class="passkeys">
566 <For each={passkeys()} fallback="none">
567 {(c) => <span tabindex="0" data-tip={`added ${date(c.createdDate / 1000)}`}>{c.userLabel ?? "unnamed"}</span>}
568 </For>
569 </dd>
570 <dt>authenticator</dt>
571 <dd>{otp() ? `added ${date(otp()!.createdDate / 1000)}` : "none"}</dd>
572 </dl>
573 );
574 }}
575 </Loaded>
576 <h2 class="card-title opens">
577 next sign-in
578 <span class="spacer" />
579 <button class="button small" disabled={!props.user.email || !props.user.requiredActions.length || busy() === "email"}
580 aria-busy={busy() === "email"}
581 data-tip={!props.user.email ? "Add an email address first" : !props.user.requiredActions.length
582 ? "Pick a step first" : `Send ${props.user.email} a link to do these steps now`}
583 onClick={emailSteps}>
584 send email
585 </button>
586 </h2>
587 <div class="toggles" role="group" aria-label="Steps at next sign-in">
588 <For each={STEPS}>
589 {([step, label]) => (
590 <button class="chip toggle" aria-pressed={props.user.requiredActions.includes(step)} disabled={busy() === step}
591 onClick={() => toggleStep(step)}>
592 {label}
593 </button>
594 )}
595 </For>
596 </div>
597 </section>
598 </div>
599
600 <div class="column">
601 <section class="card">
602 <h2 class="card-title">profile</h2>
603 <div class="fields">
604 <Field label="username" value={props.user.username} onSave={async (username) => {
605 await parseResponse(api.users[":id"].$patch({ param: param(), json: { username } }));
606 await props.refetch();
607 navigate(`/users/${username.trim().toLowerCase()}`, { replace: true });
608 }} />
609 <Field label="email" type="email" value={props.user.email} onSave={(email) => patch({ email })} />
610 <span />
611 <Checkbox checked={props.user.emailVerified} disabled={busy() === "verified"}
612 onChange={(emailVerified) => run("verified", () => patch({ emailVerified }))}>
613 verified
614 </Checkbox>
615 <Field label="first name" value={props.user.firstName} onSave={(firstName) => patch({ firstName })} />
616 <Field label="last name" value={props.user.lastName} onSave={(lastName) => patch({ lastName })} />
617 <span class="label">sign-in</span>
618 <Checkbox checked={props.user.enabled} disabled={busy() === "enabled"} onChange={setEnabled}>allowed</Checkbox>
619 <span class="label">created</span>
620 <span><Ago t={props.user.createdTimestamp / 1000} /></span>
621 </div>
622 </section>
623
624 <section class="card">
625 <h2 class="card-title">
626 sessions
627 <span class="spacer" />
628 <button class="button small" disabled={!props.user.sessions.length}
629 onClick={() => signOut(props.user.sessions.length)}>
630 sign out
631 </button>
632 </h2>
633 <Show when={props.user.sessions.length} fallback={<p class="muted">Not signed in anywhere</p>}>
634 <table class="data">
635 <tbody>
636 <For each={props.user.sessions}>
637 {(session) => (
638 <tr>
639 <td class="mono"><span tabindex="0" data-tip={network(session.ipAddress)}>{session.ipAddress}</span></td>
640 <td>{Object.values(session.clients).map((id) => appName(id, props.data.services)).join(", ")}</td>
641 <td class="num">
642 <time tabindex="0" datetime={new Date(session.lastAccess).toISOString()}
643 data-tip={`signed in ${datetime(session.start / 1000)}`}>
644 {ago(session.lastAccess / 1000)}
645 </time>
646 </td>
647 </tr>
648 )}
649 </For>
650 </tbody>
651 </table>
652 </Show>
653 </section>
654 </div>
655 </div>
656 </div>
657 );
658}
659
660/** A profile value that is always an input: saves on Enter or blur, Esc puts the old value back. */
661function Field(props: { label: string; value: string | null; type?: string; onSave: (value: string) => Promise<unknown> }) {
662 const [error, setError] = createSignal("");
663 const [pending, setPending] = createSignal(false);
664 const id = `field-${props.label.replace(/\W+/g, "-")}`;
665 return (
666 <>
667 <label class="label" for={id}>{props.label}</label>
668 <span class="field">
669 <input id={id} class="inline" type={props.type ?? "text"} value={props.value ?? ""} placeholder="–" autocomplete="off"
670 spellcheck={false} readOnly={pending()} aria-busy={pending()} aria-invalid={!!error()}
671 aria-describedby={error() ? `${id}-error` : undefined}
672 onChange={async (event) => {
673 const input = event.currentTarget;
674 if (input.value === (props.value ?? "")) return setError("");
675 setPending(true);
676 try {
677 await props.onSave(input.value);
678 setError("");
679 } catch (failure) {
680 setError(reason(failure));
681 } finally {
682 setPending(false);
683 }
684 }}
685 onKeyDown={(event) => {
686 if (event.key === "Enter") event.currentTarget.blur();
687 if (event.key === "Escape") {
688 event.currentTarget.value = props.value ?? "";
689 setError("");
690 event.currentTarget.blur();
691 }
692 }} />
693 <Show when={error()}><span id={`${id}-error`} class="field-error" role="alert">{error()}</span></Show>
694 </span>
695 </>
696 );
697}
dashboard/web/pages/VMs.css created+79
......@@ -0,0 +1,79 @@
1.vms-list th:not(:first-child), .vms-list tr.top > td:not(.title) { width: 1%; white-space: nowrap; }
2.vms-list .row-skeleton { height: 36px; margin: 14px var(--gutter); }
3
4/* Each VM is a tbody of two lines; the actions cell spans both. */
5.vms-list tbody.vm tr:not(.expanded) { cursor: pointer; }
6.vms-list tbody.vm tr:not(.expanded) > td { transition: background-color 150ms; }
7.vms-list tbody.vm:has(tr:not(.expanded):hover) tr:not(.expanded) > td { background: var(--hover); }
8.vms-list tbody.vm.open tr:not(.expanded) > td { background: var(--accent-wash); }
9.vms-list tr.top > td { padding-top: 8px; padding-bottom: 0; }
10.vms-list tr.bottom > td { padding-top: 2px; padding-bottom: 8px; font-size: 12px; color: var(--muted); }
11.vms-list tbody.off tr.bottom > td.num { color: var(--text-2); }
12.vms .vms-list table.data tbody.vm tr.bottom > td:last-child { padding-right: 8px; }
13/* A row-spanning cell loses its collapsed bottom border to the next row, so each VM's rule is drawn as a shadow. */
14.vms-list tbody.vm td { border-bottom: 0; }
15.vms-list tbody.vm:not(.open) :is(tr.bottom > td, td.actions) { box-shadow: inset 0 -1px var(--grid); }
16.vms-list td:focus-visible { outline-offset: -2px; }
17
18.vms-list td.title { max-width: 0; }
19.vms-list td.title button {
20 display: flex;
21 align-items: baseline;
22 gap: 8px;
23 width: 100%;
24 padding: 0;
25 border: 0;
26 background: none;
27 color: inherit;
28 font: inherit;
29 text-align: left;
30 white-space: nowrap;
31 cursor: pointer;
32}
33.vms-list td.title button:focus-visible { outline-offset: 2px; }
34.vm-name { font-weight: 600; }
35.vms-list tbody.off .vm-name { color: var(--text-2); }
36.vms-list .for { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--text-2); }
37.vms-list td.specs { max-width: 0; }
38.vms-list td.specs > div { display: flex; flex-wrap: wrap; gap: 2px 12px; cursor: default; }
39.vms-list td.specs span { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
40.vms-list td.specs svg { flex: none; }
41.vms-list td.specs .mono { color: var(--text-2); }
42
43
44.vms-list tr.expanded > td { background: var(--panel); padding-block: 10px 12px; }
45.vm-detail { display: grid; gap: 10px; }
46.vm-detail .off-hour { margin: 0; color: var(--muted); font-size: 12px; }
47.vm-charts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
48.vm-charts .skeleton { height: 104px; }
49.vm-detail .facts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 16px 28px; }
50.vm-detail .kv { align-content: start; }
51.vm-detail .settings { display: grid; gap: 12px; align-content: start; }
52.vm-detail .describe {
53 margin: 0 -4px;
54 padding: 0 4px;
55 border: 0;
56 border-radius: 4px;
57 background: none;
58 color: inherit;
59 font: inherit;
60 text-align: left;
61 cursor: text;
62 transition: background-color 150ms;
63}
64.vm-detail .describe:hover { background: var(--hover); }
65.vm-detail .describe.empty { color: var(--accent); cursor: pointer; }
66.vm-detail .lines { display: grid; gap: 4px 10px; align-items: center; }
67.vm-detail .lines > div { display: contents; }
68.vm-detail .lines.disks { grid-template-columns: auto minmax(0, 1fr) 72px auto; }
69.vm-detail .lines.disks .meter { min-width: 0; height: 5px; }
70.vm-detail .lines.nics { grid-template-columns: auto auto minmax(0, 1fr); }
71.vm-detail .lines.devices { grid-template-columns: auto minmax(0, 1fr); }
72.vm-detail .lines .source { overflow-wrap: anywhere; }
73.vm-actions { display: flex; gap: 6px; }
74
75.vms-empty { display: grid; justify-items: center; gap: 8px; }
76.vms-empty p { margin: 0; }
77
78.dialog .vm-sizes { align-items: start; }
79.dialog .vm-checks { display: flex; gap: 18px; }
dashboard/web/pages/VMs.tsx created+535
......@@ -0,0 +1,535 @@
1import { useSearchParams } from "@solidjs/router";
2import CircuitBoard from "lucide-solid/icons/circuit-board";
3import HardDrive from "lucide-solid/icons/hard-drive";
4import Network from "lucide-solid/icons/network";
5import Plus from "lucide-solid/icons/plus";
6import Power from "lucide-solid/icons/power";
7import Usb from "lucide-solid/icons/usb";
8import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js";
9import { LIVE_INTERVAL } from "../types/model.ts";
10import type { Domain, DOMAIN_ACTIONS, Hostdev, Image } from "../types/vms.ts";
11import { parseResponse } from "hono/client";
12import { api, queries, reason, unconnected } from "../api.ts";
13import { Checkbox } from "../components/Checkbox.tsx";
14import { Copy } from "../components/Copy.tsx";
15import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx";
16import { ListPage } from "../components/ListPage.tsx";
17import { lastGood, Loaded } from "../components/Loaded.tsx";
18import { Meter } from "../components/Meter.tsx";
19import { type StatusKind, StatusLabel } from "../components/Status.tsx";
20import { TimeChart } from "../components/TimeChart.tsx";
21import { toast } from "../components/Toast.tsx";
22import { bytes, datetime, duration, percent, plural } from "../format.ts";
23import "./VMs.css";
24
25const GiB = 2 ** 30;
26/** A guest whose agent hasn't answered this long after power-on counts as up anyway; many never install one. */
27const STARTING = 180;
28
29const REASONS: Record<string, string> = {
30 user: "paused manually",
31 ioerror: "paused after a disk error, check the vms pool's free space",
32 watchdog: "paused by its watchdog",
33 panicked: "the guest kernel panicked",
34};
35
36const isOff = (vm: Domain) => vm.state === "shutoff" || vm.state === "crashed";
37const running = (vm: Domain) => vm.state === "running" || vm.state === "blocked";
38
39function status(vm: Domain): [StatusKind, string, tip?: string] {
40 if (running(vm)) {
41 if (vm.startedAt === null) return ["healthy", "up"];
42 const up = Date.now() / 1000 - vm.startedAt!;
43 const started = `started ${datetime(vm.startedAt!)}`;
44 if (vm.agent !== "disconnected") return ["healthy", `up ${duration(up)}`, started];
45 if (up < STARTING) return ["starting", "starting", started];
46 return ["healthy", `up ${duration(up)}`, `${started}, guest agent not answering`];
47 }
48 const tip = vm.reason ? REASONS[vm.reason] ?? vm.reason : undefined;
49 switch (vm.state) {
50 case "paused": return ["stopped", "paused", tip];
51 case "pmsuspended": return ["stopped", "asleep", tip];
52 case "shutdown": return ["restarting", "stopping"];
53 case "crashed": return ["down", "crashed", tip];
54 default: return ["stopped", "off"];
55 }
56}
57
58/** "/dev/disk/by-id/nvme-Samsung_SSD_980_PRO_1TB_S5GX…" reads as "Samsung SSD 980 PRO 1TB": no bus, no serial. */
59const drive = (path: string) => path.split("/").at(-1)!.replace(/^[a-z]+-/, "").replace(/_[^_]+$/, "").replaceAll("_", " ");
60
61/** [8, 9, 10, 12] reads "8–10, 12". */
62const threads = (list: number[]) => list
63 .reduce<[number, number][]>((runs, n) => {
64 const last = runs.at(-1);
65 if (last && n === last[1] + 1) last[1] = n;
66 else runs.push([n, n]);
67 return runs;
68 }, [])
69 .map(([from, to]) => (from === to ? `${from}` : `${from}–${to}`))
70 .join(", ");
71
72/** Current and max memory, like "16 GiB", or "8–12 GiB" while the balloon holds some back. */
73const allocated = (vm: Domain) => (vm.balloon < vm.memory ? `${+(vm.balloon / GiB).toFixed(1)}–${bytes(vm.memory)}` : bytes(vm.memory));
74
75/** Remote desktop for Windows, the web UI for Home Assistant, ssh for the rest. */
76function remote(os: string, address: string): [href: string, label: string] {
77 const host = address.includes(":") ? `[${address}]` : address;
78 if (os.startsWith("Windows")) return [`rdp://full%20address=s:${host}:3389`, "rdp"];
79 if (os.startsWith("Home Assistant")) return [`http://${host}:8123`, "open"];
80 return [`ssh://${host}`, "ssh"];
81}
82
83/** Devices sharing an IOMMU group pass through together, so a GPU and its audio function read as one. */
84function groups(devices: Hostdev[]) {
85 const byGroup = new Map<number | Hostdev, Hostdev[]>();
86 for (const dev of devices) byGroup.set(dev.iommuGroup ?? dev, [...(byGroup.get(dev.iommuGroup ?? dev) ?? []), dev]);
87 return [...byGroup.values()];
88}
89
90async function act(vm: Domain, action: (typeof DOMAIN_ACTIONS)[number], refetch: () => unknown) {
91 await parseResponse(api.vms[":name"][":action"].$post({ param: { name: vm.name, action } }));
92 await refetch();
93}
94
95const stop = (vm: Domain, refetch: () => unknown) => showConfirmDialog({
96 title: `Stop ${vm.name}?`,
97 description: `${vm.name} shuts down as if its power button were pressed. If it hangs, force it off.`,
98 confirmLabel: "stop",
99 destructive: true,
100 onConfirm: () => act(vm, "shutdown", refetch),
101});
102
103const restart = (vm: Domain, refetch: () => unknown) => showConfirmDialog({
104 title: `Restart ${vm.name}?`,
105 description: `${vm.name} shuts down and boots again. Anyone using it is interrupted.`,
106 confirmLabel: "restart",
107 onConfirm: () => act(vm, "reboot", refetch),
108});
109
110const forceOff = (vm: Domain, refetch: () => unknown) => showConfirmDialog({
111 title: `Force off ${vm.name}?`,
112 description: `${vm.name} loses power without shutting down. Unsaved work in it is lost.`,
113 confirmLabel: "force off",
114 destructive: true,
115 onConfirm: () => act(vm, "destroy", refetch),
116});
117
118const describe = (vm: Domain, refetch: () => unknown) => showTextDialog({
119 title: `Describe ${vm.name}`,
120 label: "what it's for",
121 placeholder: "game streaming to the TV…",
122 initialValue: vm.description,
123 validateInput: () => true,
124 confirmLabel: "save",
125 onConfirm: async (description) => {
126 await parseResponse(api.vms[":name"].$patch({ param: { name: vm.name }, json: { description } }));
127 await refetch();
128 },
129});
130
131function remove(vm: Domain, refetch: () => unknown) {
132 const volumes = vm.disks.filter((disk) => disk.pool);
133 const size = bytes(volumes.reduce((sum, disk) => sum + disk.capacity, 0));
134 showTextDialog({
135 title: `Delete ${vm.name}?`,
136 description: () => (
137 <>
138 <p>{isOff(vm) ? "" : `${vm.name} is forced off first. `}This can't be undone.</p>
139 <Show when={vm.disks.length > volumes.length}><p>Passed-through drives are left as they are.</p></Show>
140 </>
141 ),
142 label: `type ${vm.name} to confirm`,
143 body: volumes.length ? () => (
144 <Checkbox name="disks" checked>
145 {volumes.length === 1 ? `delete its disk too, ${size}` : `delete its ${volumes.length} disks too, ${size}`}
146 </Checkbox>
147 ) : undefined,
148 validateInput: (value) => value === vm.name,
149 confirmLabel: "delete",
150 destructive: true,
151 onConfirm: async (_, form) => {
152 await parseResponse(api.vms[":name"].$delete({ param: { name: vm.name }, query: { disks: form.has("disks") ? "1" : "0" } }));
153 await refetch();
154 },
155 });
156}
157
158function create(node: { cpus: number; memory: number }, images: Image[], domains: Domain[], refetch: () => unknown) {
159 const free = node.memory - domains.filter((vm) => !isOff(vm)).reduce((sum, vm) => sum + vm.balloon, 0);
160 showConfirmDialog({
161 title: "New virtual machine",
162 confirmLabel: "create",
163 body: () => {
164 const [name, setName] = createSignal("");
165 const [image, setImage] = createSignal(images[0]);
166 const problem = () => (domains.some((vm) => vm.name === name()) ? `${name()} already exists. Pick another name` : "");
167 return (
168 <>
169 <label class="field">
170 name
171 <input name="name" class="search" autofocus required pattern="[a-z0-9][a-z0-9\-]{0,62}" placeholder="nixos-dev…"
172 autocomplete="off" spellcheck={false} aria-invalid={!!problem()} aria-describedby="vm-name-problem"
173 ref={(input) => createEffect(() => input.setCustomValidity(problem()))}
174 onInput={(event) => setName(event.currentTarget.value)} />
175 <span id="vm-name-problem" class={problem() ? "error" : "hint"}>{problem() || "lowercase letters, digits and dashes"}</span>
176 </label>
177 <label class="field">
178 what it's for
179 <input name="description" class="search" maxlength="200" placeholder="trying out configs…" autocomplete="off" />
180 </label>
181 <label class="field">
182 os
183 <select name="image" class="search"
184 onChange={(event) => setImage(images.find((item) => item.volume === event.currentTarget.value))}>
185 <For each={images}>{(item) => <option value={item.volume}>{item.volume === "blank" ? item.os : item.kind === "installer" ? `${item.os} installer` : item.os}</option>}</For>
186 </select>
187 </label>
188 <Show when={image()} keyed>
189 {(image) => (
190 <div class="row vm-sizes">
191 <label class="field">
192 cpus
193 <input name="vcpus" class="search" type="number" required min="1" max={node.cpus} value={image.recommended.vcpus} />
194 </label>
195 <label class="field">
196 memory, GiB
197 <input name="memory" class="search" type="number" required min="1" max={node.memory / GiB}
198 value={image.recommended.memory / GiB} aria-describedby="vm-memory-free" />
199 <span id="vm-memory-free" class="hint">{bytes(Math.max(0, free))} unallocated</span>
200 </label>
201 <label class="field">
202 disk, GiB
203 <input name="disk" class="search" type="number" required value={image.recommended.disk / GiB}
204 min={image.kind === "disk" ? Math.ceil(image.capacity / GiB) : 1} />
205 </label>
206 </div>
207 )}
208 </Show>
209 <div class="vm-checks">
210 <Checkbox name="start" checked>start now</Checkbox>
211 <Checkbox name="autostart">start with the host</Checkbox>
212 </div>
213 </>
214 );
215 },
216 onConfirm: async (form) => {
217 await parseResponse(api.vms.$post({
218 json: {
219 name: String(form.get("name")),
220 description: String(form.get("description")),
221 image: String(form.get("image")),
222 vcpus: Number(form.get("vcpus")),
223 memory: Math.round(Number(form.get("memory")) * GiB),
224 disk: Math.round(Number(form.get("disk")) * GiB),
225 autostart: form.has("autostart"),
226 start: form.has("start"),
227 },
228 }));
229 await refetch();
230 },
231 });
232}
233
234function Detail(props: { vm: Domain; refetch: () => unknown }) {
235 const [history, { refetch }] = createResource(() => props.vm.name, (name) =>
236 parseResponse(api.vms.history.$get({ query: { range: "3600", name } })));
237 const timer = setInterval(refetch, 30_000);
238 onCleanup(() => clearInterval(timer));
239 const [saving, setSaving] = createSignal(false);
240 const setAutostart = async (autostart: boolean) => {
241 setSaving(true);
242 try {
243 await parseResponse(api.vms[":name"].$patch({ param: { name: props.vm.name }, json: { autostart } }));
244 await props.refetch();
245 } catch (failure) {
246 toast(`Couldn't change autostart. ${reason(failure)}`);
247 } finally {
248 setSaving(false);
249 }
250 };
251 return (
252 <div class="vm-detail">
253 <Loaded data={history} what="usage history" retry={refetch} skeleton={
254 <div class="vm-charts"><div class="skeleton" /><div class="skeleton" /></div>
255 }>
256 {(latest) => {
257 const samples = () => latest().domains[props.vm.name];
258 return (
259 <Show when={samples()?.cpu.some((v) => v !== null)} fallback={<p class="off-hour">Off for the last hour</p>}>
260 <div class="vm-charts">
261 <TimeChart series={[{ name: "cpu", t: latest().t, v: samples()!.cpu }]} format={percent} max={100}
262 colors={["var(--series-1)"]} height={104} inline sync={`vm-${props.vm.name}`} />
263 <TimeChart series={[{ name: "memory", t: latest().t, v: samples()!.memory }]} format={bytes} max={props.vm.memory}
264 colors={["var(--series-2)"]} height={104} inline sync={`vm-${props.vm.name}`} />
265 </div>
266 </Show>
267 );
268 }}
269 </Loaded>
270 <div class="facts">
271 <div class="settings">
272 <dl class="kv">
273 <dt>for</dt>
274 <dd>
275 <button class="describe" classList={{ empty: !props.vm.description }} data-tip="edit"
276 onClick={() => describe(props.vm, props.refetch)}>
277 {props.vm.description || "add a description"}
278 </button>
279 </dd>
280 <dt>autostart</dt>
281 <dd>
282 <Checkbox checked={props.vm.autostart} disabled={saving()} onChange={setAutostart}>start with the host</Checkbox>
283 </dd>
284 <dt>cpu</dt>
285 <dd>{props.vm.pinned ? `pinned to threads ${threads(props.vm.pinned)}` : "floats over all threads"}</dd>
286 </dl>
287 <div class="vm-actions">
288 <Show when={running(props.vm)}>
289 <button class="button small" onClick={() => restart(props.vm, props.refetch)}>restart</button>
290 </Show>
291 <Show when={!isOff(props.vm)}>
292 <button class="button small danger" onClick={() => forceOff(props.vm, props.refetch)}>force off</button>
293 </Show>
294 <button class="button small danger" onClick={() => remove(props.vm, props.refetch)}>delete</button>
295 </div>
296 </div>
297 <dl class="kv">
298 <dt>disks</dt>
299 <dd class="lines disks">
300 <For each={props.vm.disks}>
301 {(disk) => (
302 <div>
303 <span class="muted mono">{disk.target}</span>
304 <span class="source" classList={{ mono: !!disk.pool }}>
305 <Copy value={disk.source} label={disk.pool ? "volume name" : "device path"}>
306 {disk.pool ? disk.source : drive(disk.source)}
307 </Copy>
308 </span>
309 <Show when={disk.pool} fallback={<span class="muted used">whole drive</span>}>
310 <span class="used" data-tip={`${bytes(disk.allocation)} / ${bytes(disk.capacity)} (${percent((disk.allocation / disk.capacity) * 100)})`}>
311 <Meter value={disk.allocation} max={disk.capacity} />
312 </span>
313 </Show>
314 <span class="num">{bytes(disk.capacity)}</span>
315 </div>
316 )}
317 </For>
318 </dd>
319 <dt>network</dt>
320 <dd class="lines nics">
321 <For each={props.vm.interfaces}>
322 {(nic) => (
323 <div>
324 <span class="muted mono">{nic.source}</span>
325 <span class="mono">
326 <Show when={nic.addresses[0]} fallback={<span class="muted">{running(props.vm) ? "no address" : ""}</span>}>
327 {(address) => <Copy value={address()} />}
328 </Show>
329 </span>
330 <span class="mono muted"><Copy value={nic.mac} label="MAC address" /></span>
331 </div>
332 )}
333 </For>
334 </dd>
335 <Show when={props.vm.hostdevs.length}>
336 <dt>passthrough</dt>
337 <dd class="lines devices">
338 <For each={props.vm.hostdevs}>
339 {(dev) => (
340 <div>
341 <span class="mono muted"><Copy value={dev.address}>{dev.address.replace(/^0000:/, "")}</Copy></span>
342 <span>{dev.name}</span>
343 </div>
344 )}
345 </For>
346 </dd>
347 </Show>
348 </dl>
349 </div>
350 </div>
351 );
352}
353
354function Row(props: {
355 vm: Domain;
356 hostCpus: number;
357 open: boolean;
358 onToggle: () => void;
359 refetch: () => unknown;
360}) {
361 const [pending, setPending] = createSignal(false);
362 const state = () => status(props.vm);
363 const run = async (action: "start" | "resume") => {
364 setPending(true);
365 try {
366 await act(props.vm, action, props.refetch);
367 } catch (failure) {
368 toast(`Couldn't ${action} ${props.vm.name}. ${reason(failure)}`);
369 } finally {
370 setPending(false);
371 }
372 };
373 const pinned = () => props.vm.pinned && `pinned to threads ${threads(props.vm.pinned)}`;
374 const cpuTip = () => {
375 if (!props.vm.usage) return pinned() || undefined;
376 const busy = (props.vm.usage.cpu / 100) * props.vm.vcpus;
377 return [`${busy.toFixed(1)} of ${props.vm.vcpus} vcpus busy, ${percent((busy / props.hostCpus) * 100)} of the host`, pinned()]
378 .filter(Boolean).join(", ");
379 };
380 const memoryTip = () => {
381 const used = props.vm.usage?.memory;
382 const grows = props.vm.balloon < props.vm.memory && `can grow to ${bytes(props.vm.memory)}`;
383 if (used === undefined) return grows ? `boots with ${bytes(props.vm.balloon)}, ${grows}` : undefined;
384 return [`${bytes(used)} / ${bytes(props.vm.balloon)} (${percent((used / props.vm.balloon) * 100)})`, grows]
385 .filter(Boolean).join(", ");
386 };
387 return (
388 <tbody class="vm" classList={{ open: props.open, off: isOff(props.vm) }} onClick={props.onToggle}>
389 <tr class="top">
390 <td class="title">
391 <button aria-expanded={props.open}>
392 <span class="vm-name">{props.vm.name}</span>
393 <Show when={props.vm.description}><span class="for">{props.vm.description}</span></Show>
394 </button>
395 </td>
396 <td class="state" tabindex={state()[2] ? 0 : undefined} data-tip={state()[2]}>
397 <StatusLabel health={state()[0]}>{state()[1]}</StatusLabel>
398 </td>
399 <td class="num" data-tip={cpuTip()}>{props.vm.usage && percent(props.vm.usage.cpu)}</td>
400 <td class="num" data-tip={memoryTip()}>{props.vm.usage && bytes(props.vm.usage.memory)}</td>
401 <td class="actions" rowspan="2" onClick={(event) => event.stopPropagation()}>
402 <div>
403 <Show when={running(props.vm) && props.vm.interfaces.flatMap((nic) => nic.addresses)[0]}>
404 {(value) => {
405 const target = () => remote(props.vm.os, value());
406 return <a href={target()[0]} target="_blank" rel="noreferrer">{target()[1]}</a>;
407 }}
408 </Show>
409 <Show when={isOff(props.vm)}>
410 <button disabled={pending()} aria-busy={pending()} onClick={() => run("start")}>start</button>
411 </Show>
412 <Show when={props.vm.state === "paused" || props.vm.state === "pmsuspended"}>
413 <button disabled={pending()} aria-busy={pending()} onClick={() => run("resume")}>resume</button>
414 </Show>
415 <Show when={props.vm.state === "shutdown"}>
416 <button class="danger" onClick={() => forceOff(props.vm, props.refetch)}>force off</button>
417 </Show>
418 <Show when={running(props.vm)}>
419 <button onClick={() => stop(props.vm, props.refetch)}>stop</button>
420 </Show>
421 </div>
422 </td>
423 </tr>
424 <tr class="bottom">
425 <td class="specs" colspan="2">
426 <div onClick={(event) => event.stopPropagation()}>
427 <span>{props.vm.os}</span>
428 <For each={props.vm.interfaces}>
429 {(nic) => (
430 <span>
431 <Network size={12} aria-label={nic.source} />
432 <Show when={nic.addresses[0]} fallback={nic.source}>{(value) => <span class="mono"><Copy value={value()} /></span>}</Show>
433 </span>
434 )}
435 </For>
436 <For each={groups(props.vm.hostdevs)}>
437 {(group) => (
438 <span tabindex="0" data-tip={[
439 group[0]!.address.replace(/^0000:/, ""),
440 group[0]!.iommuGroup !== null && `iommu group ${group[0]!.iommuGroup}`,
441 ...group.slice(1).map((dev) => `with ${dev.address.replace(/^0000:/, "")} ${dev.name}`),
442 ].filter(Boolean).join(", ")}>
443 {group[0]!.iommuGroup === null ? <Usb size={12} aria-label="usb" /> : <CircuitBoard size={12} aria-label="pci" />}
444 {group[0]!.name.match(/\[(.+)\]/)?.[1] ?? group[0]!.name}
445 </span>
446 )}
447 </For>
448 <For each={props.vm.disks}>
449 {(disk) => (
450 <span tabindex="0" data-tip={disk.pool
451 ? `${bytes(disk.allocation)} / ${bytes(disk.capacity)} (${percent((disk.allocation / disk.capacity) * 100)})`
452 : `${drive(disk.source)}, whole drive`}>
453 <HardDrive size={12} aria-label="disk" />{bytes(disk.capacity)}
454 </span>
455 )}
456 </For>
457 <Show when={props.vm.autostart}><span><Power size={12} aria-hidden="true" />autostart</span></Show>
458 </div>
459 </td>
460 <td class="num" tabindex={cpuTip() ? 0 : undefined} data-tip={cpuTip()}>{plural(props.vm.vcpus, "vcpu")}</td>
461 <td class="num" tabindex={memoryTip() ? 0 : undefined} data-tip={memoryTip()}>{allocated(props.vm)}</td>
462 </tr>
463 <Show when={props.open}>
464 <tr class="expanded" onClick={(event) => event.stopPropagation()}>
465 <td colspan="5"><Detail vm={props.vm} refetch={props.refetch} /></td>
466 </tr>
467 </Show>
468 </tbody>
469 );
470}
471
472export function VMs() {
473 const [data, { refetch }] = queries.vms.use();
474 const timer = setInterval(() => data.loading || unconnected(data.error) || refetch(), LIVE_INTERVAL * 1000);
475 onCleanup(() => clearInterval(timer));
476 const [params, setParams] = useSearchParams<{ vm?: string }>();
477 const loaded = lastGood(data);
478 const newVm = () => {
479 const latest = loaded();
480 if (latest) create(latest.node, latest.images, latest.domains, refetch);
481 };
482 const newButton = (label: string) => (
483 <button class="button primary" disabled={!loaded()} onClick={newVm}><Plus size={14} />{label}</button>
484 );
485
486 return (
487 <ListPage id="vms" class="vms" flush head={
488 <div class="page-head">
489 <h1>virtual machines</h1>
490 <span class="spacer" />
491 <Show when={loaded()?.domains.length !== 0}>{newButton("new vm")}</Show>
492 </div>
493 }>
494 <Loaded data={data} what="virtual machines" retry={refetch} skeleton={
495 <div class="edge vms-list">
496 <For each={Array(4)}>{() => <div class="skeleton row-skeleton" />}</For>
497 </div>
498 }>
499 {(latest) => (
500 <Show when={latest().domains.length} fallback={
501 <div class="empty vms-empty">
502 <p>No virtual machines yet.</p>
503 {newButton("create vm")}
504 </div>
505 }>
506 <div class="edge vms-list">
507 <table class="data">
508 <thead>
509 <tr>
510 <th>name</th>
511 <th>state</th>
512 <th class="num">cpu</th>
513 <th class="num">memory</th>
514 <th><span class="sr-only">actions</span></th>
515 </tr>
516 </thead>
517 <For each={latest().domains.map((vm) => vm.name)}>
518 {(name) => (
519 <Show when={latest().domains.find((vm) => vm.name === name)}>
520 {(vm) => (
521 <Row vm={vm()} hostCpus={latest().node.cpus} open={params.vm === name}
522 onToggle={() => setParams({ vm: params.vm === name ? undefined : name }, { replace: true })}
523 refetch={refetch} />
524 )}
525 </Show>
526 )}
527 </For>
528 </table>
529 </div>
530 </Show>
531 )}
532 </Loaded>
533 </ListPage>
534 );
535}
dashboard/web/pages/YouTube.css created+128
......@@ -0,0 +1,128 @@
1.yt-meta { color: var(--muted); font-size: 12px; }
2
3.yt-banner {
4 margin-bottom: 8px;
5 padding: 8px 12px;
6 border-left: 3px solid var(--warning);
7 border-radius: var(--radius);
8 background: color-mix(in srgb, var(--warning) 10%, transparent);
9 font-size: 13px;
10}
11
12.youtube .list-body > .segmented { margin-bottom: 8px; }
13
14.yt-config { padding: 16px; }
15.yt-config-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
16.yt-config-head label { display: flex; align-items: center; gap: 8px; }
17.yt-config textarea { box-sizing: border-box; display: block; width: 100%; min-height: 60vh; padding: 12px; font: 12px/1.5 var(--mono); resize: vertical; }
18
19.yt-queue { border-block: 1px solid var(--line); }
20.yt-caught-up { display: grid; gap: 4px; padding: 40px 0; }
21.yt-caught-up strong { color: var(--text); font-size: 15px; }
22
23.yt-item {
24 display: grid;
25 grid-template-columns: 128px 1fr;
26 gap: 12px;
27 padding-inline: var(--gutter);
28 border-bottom: 1px solid var(--grid);
29 transition: background-color 150ms, box-shadow 150ms;
30}
31.yt-item.yt-item:focus-visible { outline: none; }
32.yt-item:last-child { border-bottom: 0; }
33.yt-item:focus-within { background: var(--hover); box-shadow: inset 3px 0 var(--accent); }
34.yt-item.gone { display: none; }
35
36.yt-thumb {
37 position: relative;
38 display: grid;
39 place-items: center;
40 width: 128px;
41 aspect-ratio: 16 / 9;
42 align-self: start;
43 margin: 8px 0;
44 border-radius: 6px;
45 color: #fff9;
46 background: radial-gradient(circle at 25% 20%, hsl(var(--hue) 55% 45% / 0.9), transparent 60%),
47 linear-gradient(135deg, hsl(calc(var(--hue) + 40) 40% 28%), hsl(calc(var(--hue) + 90) 35% 14%));
48 background-size: cover;
49 background-position: center;
50}
51
52.yt-duration {
53 position: absolute;
54 right: 4px;
55 bottom: 4px;
56 padding: 0 4px;
57 border-radius: 3px;
58 background: #000b;
59 color: #fff;
60 font: 10px/16px var(--mono);
61}
62
63.yt-body { display: grid; gap: 2px; align-content: start; padding: 8px 0; min-width: 0; }
64.yt-title { font-weight: 600; line-height: 20px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
65.yt-title.mono { font-weight: 400; overflow-wrap: anywhere; }
66input.yt-title {
67 height: 22px;
68 width: 100%;
69 margin-left: -6px;
70 padding: 0 5px;
71 border: 1px dashed var(--axis);
72 border-radius: 5px;
73 background: none;
74 color: inherit;
75 font: inherit;
76 font-weight: 600;
77 transition: border-color 150ms, background-color 150ms;
78}
79input.yt-title:hover { border-style: solid; }
80input.yt-title:focus-visible { outline: none; border: 1px solid var(--accent); background: var(--well); }
81.yt-body .error { font-size: 12px; margin-top: 4px; }
82
83.yt-fields { --control: 26px; display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
84.yt-fields .search { font-size: 13px; padding: 0 8px; }
85.yt-fields .yt-dest { width: 150px; }
86.yt-fields .yt-season { width: 124px; }
87.yt-fields .yt-show { width: 140px; }
88.yt-episode { display: flex; align-items: center; gap: 4px; color: var(--muted); font-size: 12px; }
89.yt-episode .search { width: 52px; }
90
91.yt-actions { display: flex; gap: 6px; margin-left: auto; transition: opacity 150ms; }
92@media (hover: hover) {
93 .yt-item:not(:hover, :focus-within) .yt-actions,
94 .yt-channels tr:not(:hover, :focus-within) .actions > div { opacity: 0; }
95}
96
97.yt-jobs { border-top: 1px solid var(--line); }
98.yt-job-state { width: 1%; white-space: nowrap; }
99.yt-job-state > * { vertical-align: middle; }
100.yt-job-state .meter { display: inline-flex; width: 56px; min-width: 0; margin: 0 8px; }
101.yt-job-title { overflow-wrap: anywhere; }
102.yt-list-head { display: flex; align-items: baseline; gap: 8px; margin: 20px 0 8px; font-size: 12px; }
103.yt-list-head:first-child { margin-top: 4px; }
104.yt-list-head h2 { margin: 0; }
105.yt-list-head .button { margin-left: auto; align-self: center; }
106.yt-channels { border-top: 1px solid var(--line); }
107.yt-channel { font-weight: 550; white-space: nowrap; }
108.yt-handle { width: 100%; font-size: 12px; }
109.yt-rules { text-align: right; white-space: nowrap; }
110.yt-rules .chip { margin-left: 4px; }
111.yt-channels .actions > div { transition: opacity 150ms; }
112
113.yt-backlog { display: flex; gap: 8px; }
114.yt-backlog > * { flex: 1; }
115.yt-keywords { display: grid; grid-template-columns: auto 1fr 1fr; gap: 6px 8px; align-items: center; color: var(--text-2); font-size: 12px; }
116.yt-keywords .search { min-width: 0; }
117.yt-keywords .hint { grid-column: span 2; margin-top: -2px; color: var(--muted); }
118
119:is(.yt-meta, .yt-job-title) a { color: inherit; text-underline-offset: 3px; border-radius: 3px; }
120:is(.yt-meta, .yt-job-title) a:not(:hover) { text-decoration: none; }
121.yt-meta a:hover { color: var(--text); }
122
123.yt-library-search { margin: 12px; max-width: calc(100% - 24px); width: 320px; }
124.yt-library { width: 100%; }
125.yt-library-edit { display: flex; gap: 6px; align-items: center; }
126.yt-library-edit input { min-width: 0; }
127.yt-library-edit input:first-child { flex: 1; }
128.yt-library-edit input[type="number"] { width: 58px; }
dashboard/web/pages/YouTube.tsx created+705
......@@ -0,0 +1,705 @@
1import { A, useSearchParams } from "@solidjs/router";
2import Clock from "lucide-solid/icons/clock";
3import ListVideo from "lucide-solid/icons/list-video";
4import Tv from "lucide-solid/icons/tv";
5import Library from "lucide-solid/icons/library";
6import Code from "lucide-solid/icons/code";
7import Pause from "lucide-solid/icons/pause";
8import Play from "lucide-solid/icons/play";
9import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js";
10import { parseResponse } from "hono/client";
11import type { Channels, ConfigFile, Ingest, JobStatus, LibraryEntry, Rules, Show as IndieShow, Video } from "../types/youtube.ts";
12import { api, reason, unconnected } from "../api.ts";
13import { Ago } from "../components/Ago.tsx";
14import { showConfirmDialog } from "../components/Dialog.tsx";
15import { ListPage } from "../components/ListPage.tsx";
16import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
17import { Meter } from "../components/Meter.tsx";
18import { type StatusKind, StatusLabel } from "../components/Status.tsx";
19import { TabBar } from "../components/TabBar.tsx";
20import { toast } from "../components/Toast.tsx";
21import { ago, count, date, datetime, duration, plural, until } from "../format.ts";
22import "./YouTube.css";
23
24const JOB: Record<JobStatus, [StatusKind, string]> = {
25 queued: ["stopped", "queued"],
26 resolving: ["working", "looking up"],
27 downloading: ["working", "downloading"],
28 retrying: ["working", "retrying"],
29 waiting: ["degraded", "waiting"],
30 done: ["healthy", "done"],
31 error: ["down", "failed"],
32};
33
34export const TABS = [["", "queue", ListVideo], ["history", "history", Clock], ["channels", "channels", Tv], ["library", "library", Library], ["config", "YAML", Code]] as const;
35
36function RawConfigs(props: { files: ConfigFile[]; reload: () => unknown }) {
37 const [selected, setSelected] = createSignal(props.files[0]?.name ?? "");
38 const [draft, setDraft] = createSignal<string | null>(null);
39 const [saving, setSaving] = createSignal(false);
40 const file = () => props.files.find((item) => item.name === selected());
41 const save = async (event: SubmitEvent) => {
42 event.preventDefault();
43 const current = file();
44 if (!current || draft() === null) return;
45 setSaving(true);
46 try {
47 await parseResponse(api.youtube.configs[":name"].$put({ param: { name: current.name },
48 json: { original: current.body, body: draft()! } }));
49 await props.reload();
50 setDraft(null);
51 toast(`${current.name} saved`);
52 } catch (failure) {
53 toast(`Couldn't save ${current.name}. ${reason(failure)}`);
54 } finally {
55 setSaving(false);
56 }
57 };
58 return <form class="yt-config edge" onSubmit={save}>
59 <div class="yt-config-head">
60 <label>file <select value={selected()} onChange={(event) => { setSelected(event.currentTarget.value); setDraft(null); }}>
61 <For each={props.files}>{(item) => <option value={item.name}>{item.name}</option>}</For>
62 </select></label>
63 <button class="button" disabled={saving() || draft() === null || draft() === file()?.body} aria-busy={saving()} type="submit">save YAML</button>
64 </div>
65 <Show when={file()} fallback={<div class="empty">No YAML files in the YouTube config folder.</div>}>
66 {(current) => <textarea aria-label={`${current().name} contents`} spellcheck={false}
67 value={draft() ?? current().body} onInput={(event) => setDraft(event.currentTarget.value)} />}
68 </Show>
69 </form>;
70}
71
72function LibraryRow(props: { entry: LibraryEntry; onSaved: () => void }) {
73 const [title, setTitle] = createSignal(props.entry.title);
74 const [season, setSeason] = createSignal(props.entry.season ?? 1);
75 const [episode, setEpisode] = createSignal(props.entry.episode ?? 1);
76 const [busy, setBusy] = createSignal(false);
77 const save = async (event: SubmitEvent) => {
78 event.preventDefault();
79 setBusy(true);
80 try {
81 await parseResponse(api.youtube.library.rename.$post({ json: {
82 path: props.entry.path, title: title(),
83 season: props.entry.type === "indie" ? season() : null,
84 episode: props.entry.type === "indie" ? episode() : null,
85 } }));
86 props.onSaved();
87 } catch (failure) {
88 toast(`Couldn't rename ${props.entry.title}. ${reason(failure)}`);
89 } finally {
90 setBusy(false);
91 }
92 };
93 return <tr><td class="yt-meta">
94 <div>{props.entry.context}</div>
95 <Show when={props.entry.link}><a href={props.entry.link} target="_blank" rel="noopener">watch</a></Show>
96 </td><td><form onSubmit={save} class="yt-library-edit">
97 <input aria-label="Video title" value={title()} onInput={(event) => setTitle(event.currentTarget.value)} />
98 <Show when={props.entry.type === "indie"}>
99 <input aria-label="Season" type="number" min="1" value={season()} onInput={(event) => setSeason(Number(event.currentTarget.value))} />
100 <input aria-label="Episode" type="number" min="1" value={episode()} onInput={(event) => setEpisode(Number(event.currentTarget.value))} />
101 </Show>
102 <button disabled={busy()} aria-busy={busy()}>save</button>
103 </form></td></tr>;
104}
105
106const KEYWORDS = [
107 ["title_include_keywords", "title has"], ["title_exclude_keywords", "title lacks"],
108 ["description_include_keywords", "description has"], ["description_exclude_keywords", "description lacks"],
109] as const;
110
111const EVERYTHING = "19700101";
112
113function rules(r: Rules) {
114 const out = KEYWORDS.flatMap(([key, label]) => r[key]?.length ? [`${label} ${r[key].join(", ")}`] : []);
115 const after = r.download_after;
116 if (after) out.unshift(after === EVERYTHING ? "whole backlog" : `since ${date(local(isoDay(after)))}`);
117 return out;
118}
119
120const isoDay = (ymd: string) => `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6)}`;
121/** Seconds at local midnight of a YYYY-MM-DD day; a bare date would parse as UTC and land on the day before. */
122const local = (day: string) => Date.parse(`${day}T00:00`) / 1000;
123
124function length(seconds: number) {
125 const h = Math.floor(seconds / 3600);
126 const m = Math.floor(seconds / 60) % 60;
127 const s = String(seconds % 60).padStart(2, "0");
128 return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
129}
130
131function published(day: string) {
132 const days = Math.floor((Date.now() / 1000 - local(day)) / 86_400);
133 return days < 1 ? "today" : days < 2 ? "yesterday" : days < 7 ? `${days}d ago` : date(local(day));
134}
135
136const Linked = (props: { name: string; url?: string; tip?: string }) => (
137 <Show when={props.url} fallback={props.name}>
138 {(url) => <a href={url()} target="_blank" rel="noreferrer" data-tip={props.tip}>{props.name}</a>}
139 </Show>
140);
141
142const pad = (n: number) => String(n).padStart(2, "0");
143const rows = () => [...document.querySelectorAll<HTMLFormElement>(".yt-item:not(.gone)")];
144
145function Row(props: { video: Video; channel?: string; shows: IndieShow[]; onChange: () => unknown }) {
146 const [dest, setDest] = createSignal<string>();
147 const [newShow, setNewShow] = createSignal("");
148 const [title, setTitle] = createSignal<string>();
149 const [season, setSeason] = createSignal<number>();
150 const [episode, setEpisode] = createSignal<number>();
151 const [busy, setBusy] = createSignal<"ingest" | "skip">();
152 const [error, setError] = createSignal("");
153 const [gone, setGone] = createSignal(false);
154 const [active, setActive] = createSignal(false);
155 let form!: HTMLFormElement;
156
157 // A show named like the channel is where its uploads usually go.
158 const destValue = () => dest() ?? (props.shows.some((s) => s.name === props.video.channel) ? `show:${props.video.channel}` : "independent");
159 const show = () => destValue().startsWith("show:") ? destValue().slice(5) : destValue() === "new" ? newShow().trim() : null;
160 const seasons = () => props.shows.find((s) => s.name === show())?.seasons.toSorted((a, b) => a.number - b.number) ?? [];
161 const nextSeason = () => (seasons().at(-1)?.number ?? 0) + 1;
162 const seasonValue = () => season() ?? seasons().at(-1)?.number ?? 1;
163 const episodeValue = () => episode() ?? (seasons().find((s) => s.number === seasonValue())?.episodes ?? 0) + 1;
164
165 const act = async (kind: "ingest" | "skip", request: () => Promise<unknown>) => {
166 if (busy()) return;
167 setBusy(kind);
168 setError("");
169 try {
170 await request();
171 if (form.contains(document.activeElement)) {
172 const others = rows().filter((row) => row !== form);
173 const at = rows().indexOf(form);
174 (others[at] ?? others.at(-1))?.focus();
175 }
176 setGone(true);
177 props.onChange();
178 } catch (failure) {
179 setError(reason(failure));
180 } finally {
181 setBusy();
182 }
183 };
184
185 const ingest = (event: SubmitEvent) => {
186 event.preventDefault();
187 if (show() === "") return form.querySelector<HTMLInputElement>("[name=show]")?.focus();
188 if (!(episodeValue() >= 1)) return form.querySelector<HTMLInputElement>("[name=episode]")?.focus();
189 const choice: Ingest = show() === null
190 ? { dest: destValue() as "independent" | "music" }
191 : { dest: "indie", show: show()!, season: seasonValue(), episode: episodeValue(), title: title()?.trim() ?? "" };
192 act("ingest", () => parseResponse(api.youtube.pending[":key"].ingest.$post({ param: { key: props.video.key }, json: choice })));
193 };
194
195 const skip = () => {
196 const { title, link } = props.video;
197 act("skip", async () => {
198 await parseResponse(api.youtube.pending[":key"].skip.$post({ param: { key: props.video.key } }));
199 toast(`Skipped ${title}`, {
200 label: "undo",
201 run: async () => {
202 await parseResponse(api.youtube.pending.$post({ json: { urls: [link] } }));
203 props.onChange();
204 },
205 });
206 });
207 };
208
209 let hue = 0;
210 for (const char of props.video.link) hue = (hue * 31 + char.charCodeAt(0)) % 360;
211
212 return (
213 <form ref={form} class="yt-item" classList={{ gone: gone() }} tabindex="-1" aria-label={props.video.title} onSubmit={ingest}
214 onFocusIn={() => setActive(true)}
215 onFocusOut={(event) => setActive(form.contains(event.relatedTarget as Node | null))}
216 onKeyDown={(event) => {
217 const field = event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement;
218 if (event.metaKey || event.ctrlKey || event.altKey) return;
219 if (event.key === "Enter" && (event.target === form || event.target instanceof HTMLSelectElement)) {
220 event.preventDefault();
221 form.requestSubmit();
222 } else if (event.key === "s" && !field) {
223 event.preventDefault();
224 skip();
225 }
226 }}>
227 <a class="yt-thumb" href={props.video.link} target="_blank" rel="noreferrer" aria-label="Watch on YouTube"
228 data-tip="watch on YouTube"
229 style={{ "--hue": hue, "background-image": props.video.thumb ? `url(${props.video.thumb})` : undefined }}>
230 <Show when={!props.video.thumb}><Play size={22} /></Show>
231 <Show when={props.video.duration}>{(seconds) => <span class="yt-duration">{length(seconds())}</span>}</Show>
232 </a>
233 <div class="yt-body">
234 <Show when={show() !== null} fallback={<div class="yt-title" classList={{ mono: props.video.resolving }}>{props.video.title}</div>}>
235 <input class="yt-title" aria-label="Episode title" data-tip="episode title" autocomplete="off" spellcheck={false}
236 placeholder={props.video.resolving ? "episode title" : undefined}
237 value={title() ?? (props.video.resolving ? "" : props.video.title)}
238 onInput={(event) => setTitle(event.currentTarget.value)} />
239 </Show>
240 <div class="yt-meta">
241 <Show when={!props.video.resolving} fallback="looking up…">
242 <Linked name={props.video.channel} url={props.channel} />,{" "}
243 <span data-tip={date(local(props.video.published))}>{published(props.video.published)}</span>
244 </Show>
245 </div>
246 <div class="yt-fields">
247 <select class="search yt-dest" aria-label="Destination" onChange={(event) => {
248 setDest(event.currentTarget.value);
249 setSeason();
250 setEpisode();
251 }}>
252 <option value="independent" selected={destValue() === "independent"}>independent</option>
253 <option value="music" selected={destValue() === "music"}>music</option>
254 <optgroup label="shows">
255 <For each={props.shows}>
256 {(s) => <option value={`show:${s.name}`} selected={destValue() === `show:${s.name}`}>{s.name}</option>}
257 </For>
258 <option value="new" selected={destValue() === "new"}>new show…</option>
259 </optgroup>
260 </select>
261 <Show when={show() !== null}>
262 <Show when={destValue() === "new"}>
263 <input class="search yt-show" name="show" placeholder="show name" aria-label="Show name" autocomplete="off"
264 value={newShow()} onInput={(event) => setNewShow(event.currentTarget.value)} />
265 </Show>
266 <Show when={seasons().length}>
267 <select class="search yt-season" aria-label="Season" onChange={(event) => {
268 setSeason(Number(event.currentTarget.value));
269 setEpisode();
270 }}>
271 <For each={seasons()}>
272 {(s) => <option value={s.number} selected={s.number === seasonValue()}>season {s.number}</option>}
273 </For>
274 <option value={nextSeason()} selected={nextSeason() === seasonValue()}>new season {nextSeason()}</option>
275 </select>
276 </Show>
277 <label class="yt-episode" data-tip="episode">
278 <span aria-hidden="true">E</span>
279 <input class="search" name="episode" type="number" min="1" aria-label="Episode" value={episodeValue()}
280 onInput={(event) => setEpisode(event.currentTarget.valueAsNumber)} />
281 </label>
282 </Show>
283 <div class="yt-actions">
284 <button type="button" class="button" disabled={!!busy()} aria-busy={busy() === "skip"} onClick={skip}>
285 skip<Show when={active()}><kbd aria-hidden="true">s</kbd></Show>
286 </button>
287 <button class="button" classList={{ primary: active() }} disabled={!!busy()} aria-busy={busy() === "ingest"}
288 data-tip={show() ? `${show()} S${pad(seasonValue())}E${pad(episodeValue())}` : undefined}>
289 ingest<Show when={active()}><kbd aria-hidden="true">enter</kbd></Show>
290 </button>
291 </div>
292 </div>
293 <Show when={error()}><div class="error" role="alert">{error()}</div></Show>
294 </div>
295 </form>
296 );
297}
298
299const queueSkeleton = () => (
300 <div class="edge yt-queue">
301 <For each={[0, 1, 2, 3]}>
302 {() => (
303 <div class="yt-item">
304 <span class="skeleton yt-thumb" />
305 <div class="yt-body">
306 <span class="skeleton" style={{ width: "60%", height: "14px" }} />
307 <span class="skeleton" style={{ width: "30%", height: "12px" }} />
308 <span class="skeleton" style={{ width: "160px", height: "26px" }} />
309 </div>
310 </div>
311 )}
312 </For>
313 </div>
314);
315
316const handle = (url: string) => url.replace(/^https?:\/\/(www\.|m\.)?youtube\.com\//, "");
317
318/** Opens the editor for `list[index]`, or for a new channel without an index. */
319function editChannel(all: Channels, list: keyof Channels, save: (next: Channels) => Promise<unknown>, index?: number) {
320 const current = index === undefined ? undefined : all[list][index];
321 const rules: Rules = (list === "archive" && index !== undefined && all.archive[index]?.rules) || {};
322 const taken = all[list].filter((_, i) => i !== index).map((c) => c.name);
323 showConfirmDialog({
324 title: current ? `Edit ${current.name}` : list === "notify" ? "Add review channel" : "Add auto-download channel",
325 description: list === "notify" ? "New uploads land in the queue." : "ytdl-sub downloads every upload that passes the rules.",
326 confirmLabel: current ? "save" : "add",
327 body: () => {
328 const [name, setName] = createSignal(current?.name ?? "");
329 const [backlog, setBacklog] = createSignal(!rules.download_after ? "" : rules.download_after === EVERYTHING ? "all" : "since");
330 const problem = () => (taken.includes(name().trim()) ? `${name().trim()} is already on this list. Pick another name` : "");
331 return (
332 <>
333 <label class="field">
334 name
335 <input name="name" class="search" required autofocus autocomplete="off" spellcheck={false} value={name()}
336 aria-invalid={!!problem()} aria-describedby="yt-channel-problem"
337 ref={(input) => createEffect(() => input.setCustomValidity(problem()))}
338 onInput={(event) => setName(event.currentTarget.value)} />
339 <Show when={problem()}><span id="yt-channel-problem" class="error">{problem()}</span></Show>
340 </label>
341 <label class="field">
342 channel link
343 <input name="url" class="search" type="url" required autocomplete="off" spellcheck={false}
344 placeholder="https://www.youtube.com/@handle…" value={current?.url ?? ""} />
345 </label>
346 <Show when={list === "archive"}>
347 <div class="field">
348 <label for="yt-backlog">backlog</label>
349 <div class="yt-backlog">
350 <select id="yt-backlog" name="backlog" class="search" onChange={(event) => setBacklog(event.currentTarget.value)}>
351 <option value="" selected={backlog() === ""}>preset default</option>
352 <option value="all" selected={backlog() === "all"}>whole backlog</option>
353 <option value="since" selected={backlog() === "since"}>since a date</option>
354 </select>
355 <Show when={backlog() === "since"}>
356 <input name="after" class="search" type="date" required aria-label="Backlog start"
357 value={rules.download_after && rules.download_after !== EVERYTHING ? isoDay(rules.download_after) : ""} />
358 </Show>
359 </div>
360 </div>
361 <div class="yt-keywords" role="group" aria-label="Keyword rules">
362 <span /><span>title</span><span>description</span>
363 <For each={[["only if has", KEYWORDS[0], KEYWORDS[2]], ["skip if has", KEYWORDS[1], KEYWORDS[3]]] as const}>
364 {([row, ...cells]) => (
365 <>
366 <span>{row}</span>
367 <For each={cells}>
368 {([key, label]) => (
369 <input name={key} class="search" aria-label={label} autocomplete="off" spellcheck={false}
370 value={rules[key]?.join(", ") ?? ""} />
371 )}
372 </For>
373 </>
374 )}
375 </For>
376 <span /><span class="hint">separate words with commas</span>
377 </div>
378 </Show>
379 </>
380 );
381 },
382 onConfirm: async (form) => {
383 const channel = { name: String(form.get("name")).trim(), url: String(form.get("url")).trim() };
384 const at = index ?? all[list].length;
385 const replace = index === undefined ? 0 : 1;
386 const next = structuredClone(all);
387 if (list === "notify") next.notify.splice(at, replace, channel);
388 else {
389 const backlog = form.get("backlog");
390 const rules: Rules = {
391 download_after: backlog === "all" ? EVERYTHING : backlog === "since" ? String(form.get("after")).replaceAll("-", "") : undefined,
392 };
393 for (const [key] of KEYWORDS) {
394 const words = String(form.get(key)).split(",").map((word) => word.trim()).filter(Boolean);
395 if (words.length) rules[key] = words;
396 }
397 next.archive.splice(at, replace, { ...channel, rules });
398 }
399 await save(next);
400 },
401 });
402}
403
404function ChannelLists(props: { channels: Channels; pending: Video[]; onSaved: () => unknown }) {
405 const save = async (next: Channels) => {
406 await parseResponse(api.youtube.channels.$put({ json: next }));
407 await props.onSaved();
408 };
409 const remove = async (list: keyof Channels, index: number) => {
410 const before = props.channels;
411 const next = structuredClone(before);
412 const [gone] = next[list].splice(index, 1);
413 try {
414 await save(next);
415 toast(`Removed ${gone!.name}`, { label: "undo", run: () => save(before) });
416 } catch (failure) {
417 toast(`Couldn't remove ${gone!.name}. ${reason(failure)}`);
418 }
419 };
420 const queued = (channel: string) => props.pending.filter((v) => v.channel === channel);
421
422 const section = (list: keyof Channels, label: string, file: string) => (
423 <>
424 <div class="yt-list-head">
425 <h2 data-tip={file}>{label}</h2>
426 <span class="muted">{plural(props.channels[list].length, "channel")}</span>
427 <button class="button small" onClick={() => editChannel(props.channels, list, save)}>add channel</button>
428 </div>
429 <table class="data edge yt-channels">
430 <tbody>
431 <For each={props.channels[list]}>
432 {(channel, index) => (
433 <tr>
434 <td class="yt-channel">{channel.name}</td>
435 <td class="yt-handle"><a href={channel.url} target="_blank" rel="noreferrer">{handle(channel.url)}</a></td>
436 <td class="yt-rules">
437 <Show when={"rules" in channel && channel.rules} fallback={
438 <Show when={queued(channel.name).length}>
439 {(n) => (
440 <A class="yt-meta" href="/youtube" data-tip={queued(channel.name)[0]!.title + (n() > 1 ? `, and ${n() - 1} more` : "")}>
441 {n()} in queue
442 </A>
443 )}
444 </Show>
445 }>
446 {(r) => <For each={rules(r())}>{(rule) => <span class="chip">{rule}</span>}</For>}
447 </Show>
448 </td>
449 <td class="actions">
450 <div>
451 <button onClick={() => editChannel(props.channels, list, save, index())}>edit</button>
452 <button onClick={() => remove(list, index())}>remove</button>
453 </div>
454 </td>
455 </tr>
456 )}
457 </For>
458 </tbody>
459 </table>
460 </>
461 );
462
463 return (
464 <>
465 {section("notify", "review", "feed.yaml")}
466 {section("archive", "auto-download", "subscriptions.yaml")}
467 </>
468 );
469}
470
471export function YouTube() {
472 const [params, setParams] = useSearchParams<{ tab?: string }>();
473 const tab = () => TABS.find(([id]) => id === params.tab)?.[0] ?? "";
474 const [data, { refetch }] = createResource(() => parseResponse(api.youtube.$get()));
475 const [channels, { refetch: refetchChannels }] = createResource(() => parseResponse(api.youtube.channels.$get()));
476 const [library, { refetch: refetchLibrary }] = createResource(
477 () => tab() === "library" ? "library" : undefined,
478 () => parseResponse(api.youtube.library.$get()),
479 );
480 const [configs, { refetch: refetchConfigs }] = createResource(
481 () => tab() === "config" ? "config" : undefined,
482 () => parseResponse(api.youtube.configs.$get()),
483 );
484 const [librarySearch, setLibrarySearch] = createSignal("");
485 const [now, setNow] = createSignal(Date.now() / 1000);
486 const timers = [setInterval(() => { if (!data.loading && !unconnected(data.error)) refetch(); }, 4000), setInterval(() => setNow(Date.now() / 1000), 1000)];
487 onCleanup(() => timers.forEach(clearInterval));
488
489 const feed = lastGood(data);
490 const pendingByKey = createMemo(() => new Map(feed()?.pending.map((v) => [v.key, v])));
491 const jobById = createMemo(() => new Map(feed()?.jobs.map((j) => [j.id, j])));
492 const failed = () => feed()?.jobs.filter((j) => j.status === "error").length ?? 0;
493 const lists = lastGood(channels);
494 const channelUrl = createMemo(() => new Map([...lists()?.notify ?? [], ...lists()?.archive ?? []].map((c) => [c.name, c.url])));
495
496 const loaded = createMemo(() => feed() !== undefined && tab() === "");
497 createEffect(() => loaded() && rows()[0]?.focus({ preventScroll: true }));
498
499 const onKey = (event: KeyboardEvent) => {
500 const target = event.target as HTMLElement;
501 if (event.metaKey || event.ctrlKey || event.altKey || target.closest("input, select, textarea, dialog")) return;
502 const row = target.closest<HTMLFormElement>(".yt-item");
503 const step = { j: 1, k: -1, ...(row ? { ArrowDown: 1, ArrowUp: -1 } : {}) }[event.key];
504 if (!step) return;
505 event.preventDefault();
506 const all = rows();
507 all[Math.max(0, Math.min(all.length - 1, (row ? all.indexOf(row) : -1) + step))]?.focus();
508 };
509 const onPaste = async (event: ClipboardEvent) => {
510 if ((event.target as HTMLElement).closest("input, select, textarea, dialog, [contenteditable]")) return;
511 const urls = event.clipboardData?.getData("text").split(/[\s,]+/).filter((word) => /^https?:\/\//.test(word)) ?? [];
512 if (!urls.length) return;
513 event.preventDefault();
514 try {
515 await parseResponse(api.youtube.pending.$post({ json: { urls } }));
516 setParams({ tab: undefined });
517 await refetch();
518 rows().at(-urls.length)?.focus();
519 } catch (failure) {
520 toast(reason(failure));
521 }
522 };
523 document.addEventListener("keydown", onKey);
524 document.addEventListener("paste", onPaste);
525 onCleanup(() => {
526 document.removeEventListener("keydown", onKey);
527 document.removeEventListener("paste", onPaste);
528 });
529
530 const [toggling, setToggling] = createSignal(false);
531 const toggle = async (enabled: boolean) => {
532 setToggling(true);
533 try {
534 await parseResponse(api.youtube.upscaler.$put({ json: { enabled } }));
535 await refetch();
536 } catch (failure) {
537 toast(`Couldn't ${enabled ? "resume" : "pause"} the upscaler. ${reason(failure)}`);
538 } finally {
539 setToggling(false);
540 }
541 };
542
543 const stats = (d: NonNullable<ReturnType<typeof feed>>) => (
544 <>
545 <Show when={(d.archive.started ?? 0) > (d.archive.finished ?? 0)} fallback={
546 <A class="stat" href="/services/ytdl-sub" data-tip={d.archive.finished ? d.archive.walled
547 ? `YouTube blocked the last pass ${ago(d.archive.finished)}`
548 : `last pass ${ago(d.archive.finished)}, took ${duration(d.archive.finished - d.archive.started!)}` : undefined}>
549 <StatusLabel health={!d.archive.finished ? "stopped" : d.archive.walled ? "degraded" : "healthy"}>auto-download</StatusLabel>
550 <b>{d.archive.finished ? until(d.archive.next, now()) : "no pass yet"}</b>
551 </A>
552 }>
553 <A class="stat" href="/services/ytdl-sub" data-tip={`checking channels since ${datetime(d.archive.started!)}`}>
554 <StatusLabel health="working">auto-download</StatusLabel>
555 </A>
556 </Show>
557 <span class="stat" data-tip={d.upscaler.current ? `upscaling ${d.upscaler.current}` : undefined}>
558 <StatusLabel health={!d.upscaler.enabled ? "stopped" : d.upscaler.running ? "working" : "healthy"}>thumbnails</StatusLabel>
559 <b>{count(d.upscaler.done)}/{count(d.upscaler.total)}</b>
560 <Show when={d.upscaler.errors}>{(errors) => <span class="error">{count(errors())} failed</span>}</Show>
561 <button class="button icon-only" disabled={toggling()} aria-busy={toggling()}
562 aria-label={d.upscaler.enabled ? "Pause the upscaler" : "Resume the upscaler"}
563 data-tip={d.upscaler.enabled ? "pause" : "resume"} onClick={() => toggle(!d.upscaler.enabled)}>
564 <Show when={!toggling()}>{d.upscaler.enabled ? <Pause size={12} /> : <Play size={12} />}</Show>
565 </button>
566 </span>
567 </>
568 );
569
570 return (
571 <ListPage id="youtube" class="youtube" flush head={
572 <div class="page-head">
573 <h1>YouTube</h1>
574 <div class="stats"><Show when={feed()}>{(d) => stats(d())}</Show></div>
575 </div>
576 }>
577 <Show when={feed()?.wall.walled && tab() !== "channels"}>
578 <div class="yt-banner" role="status">
579 <strong>YouTube is blocking downloads.</strong> Ingested videos wait in line.
580 Next check {until(feed()!.wall.nextProbe ?? now(), now())}.
581 </div>
582 </Show>
583 <TabBar label="View">
584 <For each={TABS}>
585 {([id, label, Icon]) => (
586 <a href={id ? `/youtube?tab=${id}` : "/youtube"} aria-current={tab() === id ? "page" : undefined}
587 classList={{ alarm: id === "history" && failed() > 0 }}>
588 <Icon size={13} />{label}
589 <Show when={id === "" ? feed()?.pending.length : id === "history" ? failed() : 0}>
590 {(n) => <span class="count">{count(n())}</span>}
591 </Show>
592 </a>
593 )}
594 </For>
595 </TabBar>
596 <Switch>
597 <Match when={tab() === ""}>
598 <Loaded data={data} what="the YouTube queue" retry={refetch} skeleton={queueSkeleton()}>
599 {(d) => (
600 <div class="edge yt-queue">
601 <For each={[...pendingByKey().keys()]} fallback={
602 <div class="empty yt-caught-up">
603 <strong>All caught up!</strong>
604 <span>New uploads from review channels land here. Paste a video link anywhere to queue it.</span>
605 </div>
606 }>
607 {(key) => (
608 <Show when={pendingByKey().get(key)}>
609 {(video) => <Row video={video()} channel={channelUrl().get(video().channel)} shows={d().shows} onChange={refetch} />}
610 </Show>
611 )}
612 </For>
613 </div>
614 )}
615 </Loaded>
616 </Match>
617 <Match when={tab() === "history"}>
618 <Loaded data={data} what="download history" retry={refetch} skeleton={<div class="edge"><SkeletonRows count={8} /></div>}>
619 {() => (
620 <Show when={jobById().size} fallback={<div class="empty">No history yet. Ingested videos show up here.</div>}>
621 <table class="data edge yt-jobs">
622 <tbody>
623 <For each={[...jobById().keys()]}>
624 {(id) => (
625 <Show when={jobById().get(id)}>
626 {(job) => {
627 const [busy, setBusy] = createSignal(false);
628 const retry = async () => {
629 setBusy(true);
630 try {
631 await parseResponse(api.youtube.jobs[":id"].retry.$post({ param: { id } }));
632 await refetch();
633 } catch (failure) {
634 toast(`Couldn't retry ${job().title}. ${reason(failure)}`);
635 } finally {
636 setBusy(false);
637 }
638 };
639 return (
640 <tr>
641 <td class="yt-job-state"
642 data-tip={job().status === "waiting" ? "starts once YouTube stops blocking downloads" : undefined}>
643 <StatusLabel health={JOB[job().status][0]}>{JOB[job().status][1]}</StatusLabel>
644 <Show when={job().progress !== null}>
645 <Meter value={job().progress!} />
646 <span class="yt-meta num">{Math.floor(job().progress! * 100)}%</span>
647 </Show>
648 </td>
649 <td>
650 <div class="yt-job-title">
651 <Linked name={job().title} url={job().url} tip="watch on YouTube" />
652 </div>
653 <div class="yt-meta">
654 <Linked name={job().channel} url={channelUrl().get(job().channel)} /> →{" "}
655 <Linked name={job().destination} url={job().status === "done" ? job().folder ?? undefined : undefined}
656 tip="open in copyparty" />
657 </div>
658 </td>
659 <td class="num yt-meta"><Ago t={job().queuedAt} /></td>
660 <td class="actions">
661 <Show when={job().status === "error"}>
662 <div><button disabled={busy()} aria-busy={busy()} onClick={retry}>retry</button></div>
663 </Show>
664 </td>
665 </tr>
666 );
667 }}
668 </Show>
669 )}
670 </For>
671 </tbody>
672 </table>
673 </Show>
674 )}
675 </Loaded>
676 </Match>
677 <Match when={tab() === "channels"}>
678 <Loaded data={channels} what="the channel lists" retry={refetchChannels}>
679 {(c) => <ChannelLists channels={c()} pending={feed()?.pending ?? []} onSaved={refetchChannels} />}
680 </Loaded>
681 </Match>
682 <Match when={tab() === "library"}>
683 <Loaded data={library} what="the YouTube library" retry={refetchLibrary} skeleton={<div class="edge"><SkeletonRows count={8} /></div>}>
684 {(entries) => <div class="edge">
685 <input class="search yt-library-search" aria-label="Search videos" placeholder="Search videos" value={librarySearch()}
686 onInput={(event) => setLibrarySearch(event.currentTarget.value)} />
687 <Show when={entries().length} fallback={<div class="empty">No downloaded videos yet.</div>}>
688 <table class="data yt-library"><tbody>
689 <For each={entries().filter((entry) => `${entry.context} ${entry.title}`.toLowerCase().includes(librarySearch().toLowerCase()))}>
690 {(entry) => <LibraryRow entry={entry} onSaved={() => void refetchLibrary()} />}
691 </For>
692 </tbody></table>
693 </Show>
694 </div>}
695 </Loaded>
696 </Match>
697 <Match when={tab() === "config"}>
698 <Loaded data={configs} what="the YouTube config files" retry={refetchConfigs}>
699 {(files) => <RawConfigs files={files()} reload={refetchConfigs} />}
700 </Loaded>
701 </Match>
702 </Switch>
703 </ListPage>
704 );
705}
dashboard/web/public/fonts/font.css created+42
......@@ -0,0 +1,42 @@
1@font-face {
2 font-family: "Name Mono";
3 src: url("./nm.woff2") format("woff2");
4 font-weight: 400;
5 font-style: normal;
6 font-display: swap;
7}
8
9@font-face {
10 font-family: "Name Mono";
11 src: url("./nmb.woff2") format("woff2");
12 font-weight: 700;
13 font-style: normal;
14 font-display: swap;
15}
16
17@font-face {
18 font-family: "Name Mono";
19 src: url("./nmi.woff2") format("woff2");
20 font-weight: 400;
21 font-style: italic;
22 font-display: swap;
23}
24
25@font-face {
26 font-family: "Name Sans";
27 src: url("./nsv.woff2") format("woff2") tech(variations);
28 font-weight: 100 900;
29 font-style: normal;
30 font-display: swap;
31 font-stretch: 75% 125%;
32}
33
34@font-face {
35 font-family: "Name Sans";
36 src: url("./nsvi.woff2") format("woff2") tech(variations);
37 font-weight: 100 900;
38 font-style: italic;
39 font-display: swap;
40 font-stretch: 75% 125%;
41}
42
dashboard/web/public/fonts/nm.woff2 created
Binary files /dev/null and b/dashboard/web/public/fonts/nm.woff2 differ
dashboard/web/public/fonts/nmb.woff2 created
Binary files /dev/null and b/dashboard/web/public/fonts/nmb.woff2 differ
dashboard/web/public/fonts/nmi.woff2 created
Binary files /dev/null and b/dashboard/web/public/fonts/nmi.woff2 differ
dashboard/web/public/fonts/nsv.woff2 created
Binary files /dev/null and b/dashboard/web/public/fonts/nsv.woff2 differ
dashboard/web/public/fonts/nsvi.woff2 created
Binary files /dev/null and b/dashboard/web/public/fonts/nsvi.woff2 differ
dashboard/web/snowflake.svg created+15
......@@ -0,0 +1,15 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="-2 -2 28 28" fill="none" stroke-linecap="round" stroke-linejoin="round">
2 <defs>
3 <path id="flake" d="M10 20L8.75 17.5L6 18M10 4L8.75 6.5L6 6M14 20L15.25 17.5L18 18M14 4L15.25 6.5L18 6M17 21L14 15M14 15H10M14 15L15.5 12M10 15L7 21M10 15L8.5 12M17 3L14 9M14 9L15.5 12M14 9H10M15.5 12H22M2 12H8.5M8.5 12L10 9M10 9L7 3M20 10L18.5 12L20 14M4 10L5.5 12L4 14" />
4 <linearGradient id="rim" x1="4" y1="-2" x2="18.5" y2="25" gradientUnits="userSpaceOnUse">
5 <stop stop-color="#223D99" />
6 <stop offset="1" stop-color="#154392" />
7 </linearGradient>
8 <linearGradient id="core" x1="10" y1="-3" x2="18" y2="27" gradientUnits="userSpaceOnUse">
9 <stop stop-color="#F2E3FF" />
10 <stop offset="1" stop-color="#F2F8FF" />
11 </linearGradient>
12 </defs>
13 <use href="#flake" stroke="url(#rim)" stroke-width="3" />
14 <use href="#flake" stroke="url(#core)" stroke-width="2" />
15</svg>
dashboard/web/styles.css created+1047
......@@ -0,0 +1,1047 @@
1@import "uplot/dist/uPlot.min.css";
2
3:root {
4 color-scheme: dark;
5 --page: #151529;
6 --panel: #0f0f20;
7 --rim: #0a0a17;
8 --surface: #1c1c37;
9 --raised: #29294b;
10 --hover: #232342;
11 --well: #00000033;
12 --line: #2f2f53;
13 --grid: #262645;
14 --axis: #454571;
15 --text: #efeeff;
16 --text-2: #c0bee2;
17 --muted: #8b89b8;
18 --accent: #938cff;
19 --accent-wash: #938cff26;
20 --on-accent: #10112a;
21 --focus: #a9a3ff;
22 --shadow: 0 1px 2px #0005, 0 4px 14px #0003;
23 --shimmer: color-mix(in srgb, var(--raised) 55%, var(--text-2));
24
25 --series-1: #3987e5;
26 --series-2: #d95926;
27 --series-3: #199e70;
28 --series-4: #c98500;
29 --series-5: #d55181;
30 --series-6: #008300;
31 --series-7: #9085e9;
32 --series-8: #e66767;
33 --other: #605e8a;
34
35 --good: #0ca30c;
36 --warning: #fab219;
37 --serious: #ec835a;
38 --critical: #d03b3b;
39 --stopped: #605e8a;
40
41 --sans: "Name Sans", system-ui, -apple-system, "Segoe UI Variable Text", "Segoe UI", "Noto Sans", sans-serif;
42 --mono: "Name Mono", ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace;
43 --radius: 8px;
44 /** Height of buttons and inputs; set it on a container to shrink every control inside. */
45 --control: 30px;
46 --radius-lg: 12px;
47 --sidebar: 300px;
48 --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
49 --ease-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1);
50
51 scrollbar-color: var(--axis) transparent;
52}
53
54@media (prefers-color-scheme: light) {
55 :root {
56 color-scheme: light;
57 --page: #f7f6fd;
58 --panel: #ebeaf7;
59 --rim: #dcdaee;
60 --surface: #ffffff;
61 --raised: #e9e7f8;
62 --hover: #eeedfa;
63 --well: #1f1a4d0a;
64 --line: #dad8ee;
65 --grid: #ebe9f6;
66 --axis: #bbb8da;
67 --text: #18163a;
68 --text-2: #484575;
69 --muted: #625f8c;
70 --accent: #1a46cd;
71 --accent-wash: #1a46cd14;
72 --on-accent: #ffffff;
73 --focus: #1a46cd;
74 --shadow: 0 1px 2px #1f1a4d12, 0 4px 14px #1f1a4d0a;
75 --shimmer: var(--surface);
76 --series-1: #2a78d6;
77 --series-2: #eb6834;
78 --series-3: #1baf7a;
79 --series-4: #eda100;
80 --series-5: #e87ba4;
81 --series-6: #008300;
82 --series-7: #4a3aa7;
83 --series-8: #e34948;
84 --other: #b0aed0;
85 --stopped: #b0aed0;
86 }
87}
88
89* { box-sizing: border-box; }
90
91html, body, #root { height: 100%; margin: 0; }
92
93body {
94 background: var(--page);
95 color: var(--text);
96 font: 14px/1.4 var(--sans);
97 -webkit-font-smoothing: antialiased;
98}
99
100a { color: inherit; text-decoration: none; }
101button { font: inherit; color: inherit; }
102:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; border-radius: 4px; }
103::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); }
104
105.shell {
106 display: grid;
107 grid-template: auto minmax(0, 1fr) / var(--sidebar) 1fr;
108 height: 100%;
109}
110.shell > :is(.sidebar, .main) { grid-row: 2; }
111
112/* The preview an admin started from the account menu; loud so it can't be forgotten. */
113.viewing-as {
114 grid-column: 1 / -1;
115 display: flex;
116 align-items: center;
117 gap: 8px;
118 padding: 4px 8px 4px 16px;
119 background: var(--accent);
120 color: var(--on-accent);
121 font-size: 13px;
122}
123.viewing-as .button { margin-left: auto; --control: 24px; border: 0; background: var(--on-accent); color: var(--accent); font-weight: 600; }
124.viewing-as .button:hover:not(:disabled) { background: color-mix(in srgb, var(--on-accent) 85%, var(--accent)); }
125
126/* sidebar ---------------------------------------------------------------- */
127
128.sidebar {
129 background: var(--panel);
130 border-right: 4px solid var(--rim);
131 display: flex;
132 flex-direction: column;
133 overflow: hidden;
134 font-size: 13px;
135}
136
137.brand {
138 display: flex;
139 align-items: center;
140 gap: 10px;
141 padding: 16px 16px 14px;
142 font-weight: 700;
143 font-size: 18px;
144 letter-spacing: -0.01em;
145 color: var(--accent);
146}
147
148.brand img { flex: none; width: 38px; height: 38px; }
149
150.nav {
151 flex: 1;
152 overflow-y: auto;
153 padding: 2px 8px 12px;
154}
155
156.nav-item {
157 display: flex;
158 align-items: center;
159 gap: 10px;
160 height: 30px;
161 padding: 0 10px;
162 border: 0;
163 border-radius: var(--radius);
164 color: var(--text-2);
165 cursor: pointer;
166 user-select: none;
167 width: 100%;
168 background: none;
169 text-align: left;
170 transition: background-color 150ms, color 150ms;
171}
172
173.nav-item:hover { background: var(--hover); color: var(--text); }
174.nav-item.active { background: var(--accent-wash); color: var(--accent); font-weight: 600; }
175.nav-item .icon { width: 16px; height: 16px; flex: none; opacity: 0.9; }
176.nav-item .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
177.nav-item .badge { display: flex; gap: 8px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
178.nav-item .tally .status-label { gap: 4px; color: var(--text-2); }
179.nav-item .tally .status { width: 8px; height: 8px; }
180
181.chevron { width: 14px; height: 14px; flex: none; color: var(--muted); transition: transform 350ms var(--ease-overshoot); }
182.chevron.open { transform: rotate(90deg); }
183
184/* A page link with its tabs folded under it; the toggle sits over the link's right end. */
185.nav-row { position: relative; }
186.nav-row .nav-item { padding-right: 36px; }
187.nav-toggle {
188 position: absolute;
189 top: 4px;
190 right: 6px;
191 display: grid;
192 place-items: center;
193 width: 22px;
194 height: 22px;
195 padding: 0;
196 border: 0;
197 border-radius: 99px;
198 background: none;
199 cursor: pointer;
200 transition: background-color 150ms;
201}
202.nav-toggle:hover { background: var(--raised); }
203.nav-toggle:hover .chevron { color: var(--text); }
204.nav-sub {
205 display: flex;
206 align-items: center;
207 gap: 8px;
208 height: 26px;
209 padding-left: 40px;
210 border-radius: var(--radius);
211 color: var(--text-2);
212 transition: background-color 150ms, color 150ms;
213}
214.nav-sub .icon { width: 14px; height: 14px; flex: none; opacity: 0.75; }
215.nav-sub:hover { background: var(--hover); color: var(--text); }
216.nav-sub[aria-current="page"] { background: var(--accent-wash); color: var(--accent); font-weight: 600; }
217/* With a tab lit below it, the page link keeps its color but drops the fill. */
218.nav-row:has(+ .nav-group.open [aria-current="page"]) .nav-item.active { background: none; }
219
220.nav-group {
221 display: grid;
222 grid-template-rows: 0fr;
223 opacity: 0;
224 transition: grid-template-rows 350ms var(--ease-out), opacity 200ms;
225}
226
227.nav-group.open { grid-template-rows: 1fr; opacity: 1; }
228.nav-group > div { min-height: 0; overflow: hidden; }
229
230.service-row {
231 display: grid;
232 grid-template-columns: 12px 16px 1fr 44px 44px 34px;
233 align-items: center;
234 gap: 6px;
235 height: 26px;
236 padding-left: 16px;
237 border-radius: var(--radius);
238 color: var(--text-2);
239 transition: background-color 150ms, color 150ms;
240}
241
242.service-row:hover { background: var(--hover); color: var(--text); }
243.service-row.active { background: var(--accent-wash); color: var(--accent); font-weight: 600; }
244.service-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
245/* Hover targets span the whole row height, not just the drawn pixels. */
246.service-row .spark { height: 100%; }
247
248
249.whoami { border-top: 2px solid var(--rim); padding: 6px 8px; }
250
251.whoami-button {
252 display: flex;
253 align-items: center;
254 gap: 10px;
255 width: 100%;
256 height: 40px;
257 padding: 0 10px 0 7px;
258 border: 0;
259 border-radius: var(--radius);
260 background: none;
261 color: var(--text-2);
262 font: inherit;
263 text-align: left;
264 cursor: pointer;
265 transition: background-color 150ms, color 150ms;
266}
267
268.whoami-button:is(:hover, :has(+ :popover-open)) { background: var(--hover); color: var(--text); }
269.whoami-button.active { background: var(--accent-wash); color: var(--accent); }
270.whoami-button .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
271.whoami-button .icon { flex: none; color: var(--muted); }
272.whoami .avatar { width: 26px; height: 26px; font-size: 12px; }
273
274/* Pinned just above the corner; a popover sits in the top layer, so the sidebar can't clip it. */
275.account-menu {
276 inset: auto auto 54px 8px;
277 width: calc(var(--sidebar) - 20px);
278 margin: 0;
279 padding: 4px;
280 border: 1px solid var(--line);
281 border-radius: var(--radius-lg);
282 background: var(--surface);
283 color: var(--text);
284 box-shadow: 0 8px 28px #0005;
285}
286
287.account-menu:popover-open { animation: menu-in 200ms var(--ease-out); }
288.view-as { display: grid; gap: 6px; margin-top: 4px; padding: 8px 10px 6px; border-top: 1px solid var(--line); font-size: 12px; }
289.view-as .button { justify-self: start; }
290@keyframes menu-in { from { opacity: 0; transform: translateY(6px); } }
291
292/* status ------------------------------------------------------------------ */
293
294.status { width: 10px; height: 10px; display: block; transition: color 300ms; }
295.status[data-tip] { box-sizing: content-box; padding: 4px; margin: -4px; }
296.status.healthy { color: var(--good); }
297.status.degraded { color: var(--warning); }
298.status.down { color: var(--critical); }
299.status.stopped { color: var(--stopped); }
300.status.working { color: var(--text-2); }
301.status:is(.deploying, .restarting, .starting) { color: var(--accent); }
302.status:is(.deploying, .restarting, .starting, .working) { animation: spin 1.1s cubic-bezier(0.55, 0.15, 0.45, 0.85) infinite; }
303@keyframes spin { to { transform: rotate(360deg); } }
304
305.status-label { display: inline-flex; align-items: center; gap: 6px; color: var(--text-2); }
306
307/* main -------------------------------------------------------------------- */
308
309.main {
310 overflow-y: auto;
311 background: radial-gradient(900px 320px at 50% -160px, var(--accent-wash), transparent);
312}
313
314.page {
315 max-width: 1400px;
316 margin: 0 auto;
317 --gutter: 28px;
318 padding: 22px var(--gutter) 48px;
319 animation: rise 450ms var(--ease-out) backwards;
320}
321
322@keyframes rise { from { opacity: 0; transform: translateY(8px); } }
323
324.list-page { height: 100%; display: flex; flex-direction: column; padding-bottom: 0; }
325.list-page > * { flex: none; }
326.list-page .summary { display: grid; grid-template-rows: 0fr; opacity: 0; transition: grid-template-rows 350ms var(--ease-out), opacity 200ms; }
327.list-page .summary.open { grid-template-rows: 1fr; opacity: 1; }
328.list-page .summary > div { min-height: 0; overflow: hidden; }
329.summary-toggle {
330 display: grid;
331 place-items: center;
332 height: 16px;
333 margin: 4px 0;
334 border: 0;
335 border-radius: 4px;
336 background: linear-gradient(var(--line), var(--line)) center / 100% 1px no-repeat;
337 color: var(--muted);
338 cursor: pointer;
339}
340.summary-toggle svg { padding: 0 6px; box-sizing: content-box; background: var(--page); border-radius: 4px; transition: transform 350ms var(--ease-overshoot); }
341.summary-toggle:hover { color: var(--text); }
342.summary-toggle .flipped { transform: rotate(180deg); }
343/* Negative margins leave room for card shadows the scroller would otherwise clip. */
344.list-body {
345 flex: 1;
346 min-height: 0;
347 overflow: auto;
348 display: flex;
349 flex-direction: column;
350 margin: 0 -8px;
351 padding: 2px 8px 24px;
352 transition: padding-bottom 200ms var(--ease-out);
353}
354.list-body > * { flex: none; }
355.list-body > .fill { flex: 1 1 0; min-height: 240px; max-height: none; }
356:is(.list-page, .list-body) > .segmented { align-self: flex-start; }
357.list-page > .segmented { margin-bottom: 12px; }
358/* Pinned headers draw their rule as a shadow, since a sticky cell's background covers the collapsed border. */
359:is(.list-body, .list-body > *) > table.data > thead th {
360 position: sticky;
361 top: 0;
362 z-index: 1;
363 background: var(--surface);
364 border-bottom: 0;
365 box-shadow: inset 0 -1px var(--line);
366}
367.list-body.flush { margin: 0 calc(-1 * var(--gutter)); padding: 0; }
368.list-body.flush > :not(.fill, .edge, table) { margin-inline: var(--gutter); }
369.list-body.flush > :last-child:not(.fill) { margin-bottom: 24px; }
370:is(.list-body.flush, .list-body.flush > :is(.fill, .edge)) > table.data > * > tr > :first-child { padding-left: var(--gutter); }
371:is(.list-body.flush, .list-body.flush > :is(.fill, .edge)) > table.data > * > tr > :last-child { padding-right: var(--gutter); }
372.list-body.flush > :is(table.data, :not(.card) > table.data) > thead th { background: var(--page); }
373/* Room for a toast, so it never covers the end of a list. */
374body:has(.toast) .list-body { padding-bottom: 64px; }
375
376.page-head {
377 display: flex;
378 align-items: center;
379 gap: 12px;
380 margin-bottom: 18px;
381 min-height: 36px;
382}
383
384.page-head h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; margin: 0; }
385.page-head .sub { color: var(--muted); font-size: 13px; }
386
387h2 {
388 font-size: 14px;
389 font-weight: 650;
390 color: var(--accent);
391 margin: 28px 0 10px;
392}
393
394:is(td, dd, p) > a:not([class]), .page-head a.sub {
395 color: var(--accent);
396 text-decoration: underline dotted;
397 text-underline-offset: 3px;
398 border-radius: 3px;
399 transition: background-color 150ms;
400}
401
402:is(td, dd, p) > a:not([class]):hover, .page-head a.sub:hover {
403 text-decoration-style: solid;
404 background: var(--accent-wash);
405}
406
407.card {
408 background: var(--surface);
409 border: 1px solid var(--line);
410 border-radius: var(--radius-lg);
411 box-shadow: var(--shadow);
412 padding: 14px 16px;
413 min-width: 0;
414}
415
416.card-title { font-size: 13px; font-weight: 650; margin-bottom: 6px; display: flex; gap: 8px; align-items: baseline; }
417.card-title .sub { color: var(--muted); font-weight: 400; }
418.card-title .reading { font-weight: 500; font-variant-numeric: tabular-nums; }
419
420.grid { display: grid; gap: 8px; }
421.grid.charts { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
422.grid.tiles { grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); }
423
424.tile { padding: 12px 14px; }
425.tile .label { color: var(--text-2); font-size: 12px; }
426.tile .value { white-space: nowrap; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; margin-top: 2px; font-variant-numeric: tabular-nums; }
427.tile .detail { color: var(--muted); font-size: 12px; margin-top: 2px; }
428
429.segmented {
430 position: relative;
431 display: inline-flex;
432 gap: 2px;
433 background: var(--well);
434 border: 1px solid var(--line);
435 border-radius: 10px;
436 padding: 2px;
437}
438
439/* One weight for every label, so choosing one never nudges its neighbours. */
440.segmented :is(button, a) {
441 position: relative;
442 display: inline-flex;
443 align-items: center;
444 gap: 5px;
445 border: 0;
446 background: none;
447 padding: 3px 10px;
448 border-radius: 7px;
449 color: var(--text-2);
450 cursor: pointer;
451 font-size: 12px;
452 font-weight: 550;
453 white-space: nowrap;
454 transition: background-color 200ms, color 200ms, transform 200ms var(--ease-overshoot);
455}
456.segmented > :is(button, a):hover { color: var(--text); background: var(--hover); }
457.segmented > :is(button, a):active { transform: scale(0.94); }
458.segmented :is(.pill, .ink) { position: absolute; pointer-events: none; }
459.segmented .pill {
460 z-index: 1;
461 top: 2px;
462 bottom: 2px;
463 border-radius: 7px;
464 background: var(--accent);
465 transition: left 300ms var(--ease-out), width 300ms var(--ease-out);
466}
467.segmented .ink { z-index: 2; inset: 0; display: flex; gap: 2px; padding: 2px; transition: clip-path 300ms var(--ease-out); }
468.segmented .ink > * { color: var(--on-accent); transition: none; }
469/* A tab's item count, after a space; `alarm` marks a tab whose count is trouble. */
470.segmented .count { opacity: 0.6; font-variant-numeric: tabular-nums; }
471.segmented > .alarm { color: color-mix(in srgb, var(--critical) 80%, var(--text)); }
472
473.button {
474 display: inline-flex;
475 align-items: center;
476 justify-content: center;
477 gap: 6px;
478 height: var(--control);
479 padding: 0 12px;
480 border-radius: var(--radius);
481 border: 2px solid var(--line);
482 background: var(--well);
483 cursor: pointer;
484 white-space: nowrap;
485 font-size: 13px;
486 font-weight: 500;
487 transition: background-color 150ms, border-color 150ms, color 150ms, transform 200ms var(--ease-overshoot);
488}
489
490.button:hover:not(:disabled) { background: var(--raised); border-color: var(--axis); }
491.button:active:not(:disabled) { transform: scale(0.95); }
492.button.danger { color: color-mix(in srgb, var(--critical) 80%, var(--text)); }
493.button.danger:hover:not(:disabled) {
494 background: color-mix(in srgb, var(--critical) 14%, transparent);
495 border-color: color-mix(in srgb, var(--critical) 60%, var(--line));
496}
497.button:disabled { opacity: 0.5; cursor: default; }
498.button[aria-busy="true"] { cursor: progress; }
499.button.icon-only { width: var(--control); padding: 0; border-radius: 99px; }
500
501/* The pending spinner slides in when a button gets aria-busy. */
502.button::before {
503 content: "";
504 flex: none;
505 width: 0;
506 height: 14px;
507 margin-right: -6px;
508 opacity: 0;
509 background: conic-gradient(transparent 15%, currentColor) right / 14px 14px no-repeat;
510 mask: radial-gradient(7px at calc(100% - 7px) 50%, transparent 5px, #000 5.5px 6.5px, transparent 7px);
511 transition: width 250ms var(--ease-out), margin 250ms var(--ease-out), opacity 200ms;
512}
513.button[aria-busy="true"]::before { width: 14px; margin-right: 0; opacity: 1; animation: spin 700ms linear infinite; }
514
515.button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; }
516.button.primary:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 85%, var(--text)); border-color: transparent; }
517.button.primary.danger { background: var(--critical); border-color: var(--critical); color: #fff; }
518.button.primary.danger:hover:not(:disabled) { background: color-mix(in srgb, var(--critical) 85%, #000); border-color: transparent; }
519
520kbd {
521 display: inline-grid;
522 place-items: center;
523 min-width: 18px;
524 height: 18px;
525 padding: 0 5px;
526 border-radius: 5px;
527 background: var(--raised);
528 box-shadow: inset 0 -1px 0 var(--line);
529 color: var(--muted);
530 font: 500 11px/1 var(--sans);
531}
532/* Inset 3px from the button's inner edge on top, bottom and right, with the radius shrunk to match. */
533.button kbd {
534 height: calc(var(--control) - 10px);
535 margin-right: -9px;
536 border-radius: calc(var(--radius) - 5px);
537 background: color-mix(in srgb, currentColor 16%, transparent);
538 box-shadow: none;
539 color: inherit;
540 opacity: 0.8;
541}
542
543/* A round icon link or button in a list row; it shows while the row is hovered or holds focus. */
544.row-icon {
545 display: inline-grid;
546 place-items: center;
547 flex: none;
548 justify-self: center;
549 width: 22px;
550 height: 22px;
551 padding: 0;
552 border: 0;
553 border-radius: 99px;
554 background: var(--raised);
555 color: var(--text-2);
556 cursor: pointer;
557 opacity: 0;
558 transition: opacity 150ms, background-color 150ms, color 150ms;
559}
560:is(tr, .service-row):is(:hover, :focus-within) .row-icon { opacity: 1; }
561.row-icon:hover { background: var(--accent); color: var(--on-accent); }
562/* In the sidebar the link fills its whole cell so it's easy to hit; the 20px circle is drawn behind the icon. */
563.service-row .row-icon { position: relative; isolation: isolate; width: 100%; height: 100%; background: none; }
564.service-row .row-icon::before {
565 content: "";
566 position: absolute;
567 inset: 50% auto auto 50%;
568 width: 20px;
569 height: 20px;
570 translate: -50% -50%;
571 border-radius: 99px;
572 background: var(--raised);
573 z-index: -1;
574 transition: background-color 150ms;
575}
576.service-row .row-icon:hover { background: none; }
577.service-row .row-icon:hover::before { background: var(--accent); }
578
579/* app icons --------------------------------------------------------------- */
580
581/* The way out to the real app a page wraps; it follows the page title. */
582.open-app {
583 --control: 24px;
584 display: inline-flex;
585 align-items: center;
586 gap: 5px;
587 height: 24px;
588 padding: 0 6px 0 4px;
589 border: 1px solid var(--line);
590 border-radius: 99px;
591 background: var(--surface);
592 color: var(--text-2);
593 font-size: 12px;
594 font-weight: 500;
595 white-space: nowrap;
596 transition: border-color 150ms, background-color 150ms, color 150ms;
597}
598.open-app:hover { border-color: var(--axis); background: var(--hover); color: var(--text); }
599.open-app :is(img, .monogram) { width: 16px; height: 16px; }
600.open-app .monogram { font-size: 9px; border-radius: 4px; }
601.open-app .arrow { color: var(--muted); }
602.open-app:hover .arrow { color: var(--text); }
603
604.monogram {
605 display: grid;
606 place-items: center;
607 border-radius: 22%;
608 font-weight: 700;
609 color: #fff;
610}
611
612.service-row .monogram { width: 16px; height: 16px; font-size: 9px; border-radius: 4px; }
613.service-row img { width: 16px; height: 16px; }
614picture { display: flex; }
615
616/* charts ------------------------------------------------------------------ */
617
618.chart { position: relative; }
619.chart .u-legend { font-size: 12px; color: var(--text-2); text-align: left; margin-top: 4px; }
620.chart .u-legend th { font-weight: 400; }
621.chart .u-legend .u-marker { width: 10px; height: 4px; border-radius: 2px; }
622.chart .u-legend .u-series > * { padding: 1px 6px 1px 0; }
623.chart .u-legend .u-value { font-variant-numeric: tabular-nums; color: var(--text); }
624.chart .u-legend .u-series:first-child { display: none; }
625.chart .u-cursor-x { border-right: 1px dashed var(--axis); }
626.chart .u-cursor-y { display: none; }
627
628.donut-row { display: flex; gap: 20px; align-items: center; }
629.legend { display: grid; gap: 6px; font-size: 13px; }
630.legend-item { display: grid; grid-template-columns: 10px 1fr auto; gap: 8px; align-items: center; color: var(--text-2); }
631.legend-item .swatch { width: 10px; height: 10px; border-radius: 3px; }
632.legend-item .v { color: var(--text); font-variant-numeric: tabular-nums; }
633
634/* tables & logs ----------------------------------------------------------- */
635
636table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
637table.data th { text-align: left; font-weight: 500; color: var(--muted); padding: 6px 8px; border-bottom: 1px solid var(--line); }
638table.data th.num { text-align: right; }
639table.data td { padding: 6px 8px; border-bottom: 1px solid var(--grid); font-variant-numeric: tabular-nums; }
640table.data tbody tr { transition: background-color 150ms; }
641
642/* The shared text input and select. */
643.search {
644 width: 100%;
645 height: var(--control);
646 padding: 0 10px;
647 border-radius: var(--radius);
648 border: 2px solid var(--line);
649 background: var(--well);
650 color: var(--text);
651 font: inherit;
652 transition: border-color 150ms, box-shadow 150ms;
653}
654
655.search::placeholder { color: var(--muted); }
656/* A search field with a leading icon; the label is the box. */
657.search-box {
658 display: flex;
659 align-items: center;
660 gap: 8px;
661 height: var(--control);
662 padding: 0 10px;
663 border: 2px solid var(--line);
664 border-radius: var(--radius);
665 background: var(--well);
666 color: var(--muted);
667 transition: border-color 150ms, box-shadow 150ms;
668}
669.search-box:hover { border-color: var(--axis); }
670.search-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-wash); }
671.search-box input { flex: 1; min-width: 0; height: 100%; border: 0; padding: 0; background: none; color: var(--text); font: inherit; outline: none; }
672.search-box input::placeholder { color: var(--muted); font-family: var(--sans); }
673.search:hover { border-color: var(--axis); }
674.search:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-wash); }
675
676.logs {
677 font: 12px/1.55 var(--mono);
678 background: var(--surface);
679 border: 1px solid var(--line);
680 border-radius: var(--radius-lg);
681 box-shadow: var(--shadow);
682 padding: 8px 0;
683 margin-top: 8px;
684 max-height: 70vh;
685 overflow: auto;
686}
687
688.log { display: grid; grid-template-columns: 76px 1fr; gap: 12px; padding: 0 12px; white-space: pre-wrap; word-break: break-word; }
689.log:hover { background: var(--hover); }
690.log time { color: var(--muted); }
691.log.warn { background: color-mix(in srgb, var(--warning) 7%, transparent); }
692.log.error { background: color-mix(in srgb, var(--critical) 10%, transparent); color: inherit; }
693.log mark { background: var(--accent-wash); color: var(--accent); border-radius: 3px; }
694
695.empty { color: var(--muted); padding: 24px 0; text-align: center; }
696.empty.failed { display: grid; justify-items: center; gap: 10px; padding: 24px 12px; color: var(--text-2); }
697.empty.failed p { margin: 0; max-width: 60ch; }
698.empty.unconnected { display: grid; justify-items: center; gap: 8px; padding: 24px 12px; }
699.empty.unconnected svg { width: 20px; height: 20px; }
700.empty.unconnected p { margin: 0; max-width: 60ch; }
701.stale-note { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; grid-column: 1 / -1; margin: 0 0 8px; font-size: 12px; color: var(--text-2); }
702.metric .empty { height: 196px; display: grid; place-content: center; }
703.card.metric { padding: 10px 10px 4px 12px; }
704
705/* shared pieces ----------------------------------------------------------- */
706
707.spacer { flex: 1; }
708.muted { color: var(--muted); }
709.error { color: color-mix(in srgb, var(--critical) 80%, var(--text)); }
710.nowrap { white-space: nowrap; }
711.tile .warning { color: color-mix(in srgb, var(--warning) 70%, var(--text)); }
712.inline { display: inline-flex; align-items: center; gap: 6px; }
713.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
714/* Live numbers the stream stopped updating. */
715.tile.stale, .stale .spark { opacity: 0.45; transition: opacity 300ms; }
716.small { --control: 26px; }
717.reveal {
718 display: grid;
719 flex: none;
720 opacity: 0;
721 transition: grid-template-columns 200ms var(--ease-out), grid-template-rows 200ms var(--ease-out), opacity 200ms;
722}
723.reveal.x { grid-template-columns: 0fr; }
724.reveal.y { grid-template-rows: 0fr; }
725.reveal.shown { opacity: 1; }
726.reveal.x.shown { grid-template-columns: 1fr; }
727.reveal.y.shown { grid-template-rows: 1fr; }
728/* The clip margin keeps focus rings and press effects from being cut off. */
729.reveal > div { min-width: 0; min-height: 0; overflow: clip; overflow-clip-margin: 4px; }
730
731.sort {
732 display: inline-flex;
733 align-items: center;
734 gap: 4px;
735 border: 0;
736 background: none;
737 padding: 0;
738 color: inherit;
739 font-weight: inherit;
740 white-space: nowrap;
741 cursor: pointer;
742}
743.sort:hover { color: var(--text); }
744
745.meter { display: flex; gap: 2px; height: 6px; min-width: 80px; border-radius: 3px; background: var(--raised); overflow: hidden; }
746.meter span { background: var(--series-1); transition: width 300ms var(--ease-out); }
747.meter span.failed { background: var(--critical); }
748
749.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; font-size: 13px; }
750.kv dt { color: var(--muted); }
751.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
752.mono { font-family: var(--mono); font-size: 12px; }
753.chip {
754 display: inline-block;
755 padding: 0 8px;
756 border: 0;
757 border-radius: 99px;
758 background: var(--raised);
759 color: inherit;
760 font: 12px/20px var(--sans);
761 white-space: nowrap;
762 transition: background-color 150ms, color 150ms;
763}
764button.chip { cursor: pointer; }
765:is(a, button).chip:hover { background: var(--accent-wash); color: var(--accent); }
766.chip[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); }
767.chip.warn { background: color-mix(in srgb, var(--warning) 16%, transparent); color: color-mix(in srgb, var(--warning) 65%, var(--text)); }
768.chips { display: flex; flex-wrap: wrap; gap: 4px; }
769
770/* Headline numbers as small pills beside the page title; what each one is lives in its tooltip. */
771.stats { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
772.stat {
773 display: inline-flex;
774 align-items: center;
775 gap: 5px;
776 height: 24px;
777 padding: 0 10px 0 8px;
778 border: 1px solid var(--line);
779 border-radius: 99px;
780 background: var(--surface);
781 color: var(--muted);
782 font-size: 12px;
783 white-space: nowrap;
784 font-variant-numeric: tabular-nums;
785}
786.stat b { color: var(--text); font-weight: 600; }
787.stat svg { flex: none; }
788.stat .status-label { gap: 5px; color: var(--text-2); }
789a.stat { transition: border-color 150ms, background-color 150ms; }
790a.stat:hover { border-color: var(--axis); background: var(--hover); }
791.stat .button { --control: 20px; margin-right: -8px; padding: 0 6px; border: 0; border-radius: 99px; font-size: 12px; }
792.stat .button.icon-only { width: 20px; padding: 0; }
793.stat-skeleton { height: 24px; border-radius: 99px; }
794
795.check input { accent-color: var(--accent); margin: 0; }
796.check:has(input:disabled) { cursor: progress; }
797
798/* Checkbox.tsx: the real input sits invisibly under the drawn box, which takes its states. */
799label.checkbox { position: relative; display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text-2); cursor: pointer; }
800.checkbox input { position: absolute; left: 0; width: 15px; height: 15px; margin: 0; opacity: 0; pointer-events: none; }
801.checkbox .box {
802 flex: none;
803 display: grid;
804 place-items: center;
805 width: 15px;
806 height: 15px;
807 border: 1px solid var(--axis);
808 border-radius: 4px;
809 background: var(--surface);
810 color: var(--on-accent);
811 transition: background-color 150ms, border-color 150ms;
812}
813.checkbox .box svg { grid-area: 1 / 1; width: 11px; height: 11px; stroke-width: 3.5; opacity: 0; transform: scale(0.5); transition: opacity 150ms, transform 200ms var(--ease-overshoot); }
814.checkbox:hover .box { border-color: var(--muted); }
815.checkbox input:is(:checked, :indeterminate) + .box { border-color: var(--accent); background: var(--accent); }
816.checkbox input:checked:not(:indeterminate) + .box .tick, .checkbox input:indeterminate + .box .dash { opacity: 1; transform: none; }
817.checkbox input:focus-visible + .box { outline: 2px solid var(--focus); outline-offset: 2px; }
818.checkbox:has(input:disabled) { color: var(--muted); cursor: default; }
819.checkbox input:disabled + .box { opacity: 0.45; }
820
821/* Row buttons fill the row's height; the cell's 1px height lets the inner div resolve 100%. */
822td.actions { width: 1%; height: 1px; padding: 0; }
823td.actions > div { display: flex; justify-content: flex-end; height: 100%; }
824td.actions :is(button, a) {
825 display: inline-flex;
826 text-decoration: none;
827 align-items: center;
828 gap: 6px;
829 min-height: 36px;
830 padding: 0 14px;
831 border: 0;
832 background: none;
833 color: var(--accent);
834 font-size: 13px;
835 font-weight: 500;
836 white-space: nowrap;
837 cursor: pointer;
838 transition: background-color 150ms, color 150ms, opacity 150ms;
839}
840td.actions :is(button, a):hover:not(:disabled) { background: var(--raised); }
841td.actions :is(button, a).danger { color: color-mix(in srgb, var(--critical) 80%, var(--text)); }
842td.actions :is(button, a).danger:hover:not(:disabled) { background: color-mix(in srgb, var(--critical) 12%, transparent); }
843td.actions button:disabled { opacity: 0.45; cursor: default; }
844td.actions button[aria-busy="true"] { opacity: 1; cursor: progress; }
845td.actions button[aria-busy="true"]::before {
846 content: "";
847 width: 12px;
848 height: 12px;
849 border: 1.5px solid currentColor;
850 border-right-color: transparent;
851 border-radius: 50%;
852 animation: spin 700ms linear infinite;
853}
854td.actions :is(button, a):focus-visible { outline-offset: -2px; }
855.service-icon { display: grid; }
856.service-icon img, .service-icon .monogram { width: 32px; height: 32px; font-size: 16px; }
857
858.copy {
859 border: 0;
860 border-radius: 4px;
861 margin: 0 -3px;
862 padding: 0 3px;
863 background: none;
864 font: inherit;
865 text-align: inherit;
866 cursor: copy;
867 transition: background-color 150ms;
868}
869.copy:hover { background: var(--accent-wash); }
870
871.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
872
873.skeleton {
874 display: block;
875 min-height: 12px;
876 border-radius: var(--radius);
877 background: linear-gradient(100deg, var(--raised) 40%, var(--shimmer) 50%, var(--raised) 60%) 0 0 / 300% 100%;
878 opacity: 0.6;
879 animation: shimmer 1.6s ease-in-out infinite;
880}
881.row-skeleton { height: 14px; margin: 11px var(--gutter); }
882@keyframes shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
883
884/* overlays ---------------------------------------------------------------- */
885
886.dialog {
887 width: min(440px, calc(100% - 32px));
888 padding: 0;
889 border: 1px solid var(--line);
890 border-radius: var(--radius-lg);
891 background: var(--surface);
892 color: var(--text);
893 box-shadow: 0 24px 64px #0007;
894 animation: dialog-in 260ms var(--ease-overshoot);
895}
896.dialog::backdrop { background: #06061280; animation: fade-in 200ms; }
897.dialog.closing { animation: dialog-out 140ms ease-in forwards; }
898.dialog.closing::backdrop { animation: fade-in 140ms reverse forwards; }
899.dialog form { display: grid; gap: 12px; padding: 18px 20px 16px; }
900.dialog h2 { margin: 0; color: var(--text); font-size: 16px; }
901.dialog .description { min-width: 0; color: var(--text-2); font-size: 13.5px; }
902.dialog .description p { margin: 0 0 6px; }
903.dialog .description p:last-child { margin-bottom: 0; }
904.dialog .field { display: grid; gap: 5px; color: var(--text-2); font-size: 12px; }
905.dialog .field .hint { color: var(--muted); }
906.dialog .segmented { justify-self: start; }
907.dialog .row { display: grid; grid-auto-columns: 1fr; grid-auto-flow: column; gap: 10px; }
908.dialog .field select.search { padding-right: 4px; }
909.dialog .field .search:user-invalid { border-color: color-mix(in srgb, var(--critical) 60%, var(--line)); }
910.dialog .error { margin: 0; font-size: 13px; }
911.dialog .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
912@keyframes dialog-in { from { opacity: 0; transform: translateY(8px) scale(0.95); } }
913@keyframes dialog-out { to { opacity: 0; transform: scale(0.97); } }
914@keyframes fade-in { from { opacity: 0; } }
915
916.tooltip {
917 position: fixed;
918 inset: auto;
919 margin: 0;
920 border: 0;
921 overflow: visible;
922 pointer-events: none;
923 box-sizing: content-box;
924 width: max-content;
925 max-width: 300px;
926 padding: 5px 9px;
927 border-radius: 6px;
928 background: var(--text);
929 color: var(--page);
930 font-size: 12px;
931 line-height: 1.35;
932 text-align: center;
933 text-wrap: balance;
934 overflow-wrap: anywhere;
935 animation: tip-in 240ms var(--ease-overshoot);
936}
937.tooltip::after {
938 content: "";
939 position: absolute;
940 top: 100%;
941 left: var(--arrow);
942 width: 9px;
943 height: 9px;
944 border-radius: 2px;
945 background: inherit;
946 transform: translate(-50%, -60%) rotate(45deg);
947 z-index: -1;
948}
949.tooltip[data-side="bottom"] { animation-name: tip-in-below; }
950.tooltip[data-side="bottom"]::after { top: 0; transform: translate(-50%, -40%) rotate(45deg); }
951@keyframes tip-in { from { opacity: 0; transform: translateY(5px) scale(0.94); } }
952@keyframes tip-in-below { from { opacity: 0; transform: translateY(-5px) scale(0.94); } }
953
954.toasts {
955 position: fixed;
956 z-index: 50;
957 left: var(--sidebar);
958 right: 0;
959 bottom: 20px;
960 display: grid;
961 justify-items: center;
962 pointer-events: none;
963}
964.toast {
965 display: flex;
966 align-items: center;
967 gap: 12px;
968 max-width: min(640px, calc(100% - 32px));
969 padding: 6px 6px 6px 14px;
970 border: 1px solid var(--line);
971 border-radius: var(--radius);
972 background: var(--raised);
973 box-shadow: 0 8px 28px #0005;
974 font-size: 13px;
975 pointer-events: auto;
976 animation: toast-in 300ms var(--ease-overshoot);
977}
978.toast .close { display: grid; place-items: center; width: 24px; height: 24px; border: 0; border-radius: var(--radius); background: none; color: var(--muted); cursor: pointer; }
979.toast .close:hover { background: var(--hover); color: var(--text); }
980@keyframes toast-in { from { opacity: 0; transform: translateY(12px) scale(0.96); } }
981
982@media (prefers-reduced-motion: reduce) {
983 *, ::before, ::after {
984 animation-duration: 1ms !important;
985 animation-iteration-count: 1 !important;
986 transition-duration: 1ms !important;
987 }
988}
989
990/* TimeChart `inline`: values floating inside the plot instead of a legend. */
991.chart .values { position: absolute; inset: 0; pointer-events: none; font-size: 12px; font-variant-numeric: tabular-nums; }
992.chart .values > span {
993 position: absolute;
994 right: 4px;
995 display: flex;
996 align-items: center;
997 gap: 5px;
998 height: 16px;
999 padding: 0 5px;
1000 border-radius: 4px;
1001 background: color-mix(in srgb, var(--page) 78%, transparent);
1002 color: var(--text-2);
1003 white-space: nowrap;
1004 transform: translateY(-50%);
1005}
1006.chart .values > span::before { content: ""; width: 8px; height: 2px; border-radius: 1px; background: var(--key); }
1007.chart .values b { color: var(--text); font-weight: 600; }
1008.chart .values.hover { inset: 0 auto 0 auto; }
1009.chart .values.hover > span { right: auto; left: 8px; }
1010.chart .values.hover.flip > span { left: auto; right: 8px; }
1011.chart .values time {
1012 position: absolute;
1013 top: 100%;
1014 left: 0;
1015 margin-top: 3px;
1016 padding: 1px 6px;
1017 border-radius: 4px;
1018 background: var(--raised);
1019 color: var(--text);
1020 font-size: 11px;
1021 white-space: nowrap;
1022 transform: translateX(-50%);
1023}
1024
1025/* Charts stacked flush on one time axis and crosshair; each title sits in a gap in its divider. */
1026.strip-head { display: flex; align-items: center; gap: 8px; height: 24px; }
1027.strip-head::before, .strip-head .rule { content: ""; height: 0; border-top: 1px solid var(--line); }
1028.strip-head::before { width: 10px; }
1029.strip-head .rule { flex: 1; }
1030.strip-head h3 { margin: 0; color: var(--text-2); font-size: 13px; font-weight: 650; }
1031.strip-head .segmented { --control: 24px; font-size: 12px; }
1032.strip-head .now {
1033 display: inline-flex;
1034 align-items: center;
1035 gap: 3px;
1036 color: var(--accent);
1037 font-size: 18px;
1038 font-weight: 650;
1039 white-space: nowrap;
1040 transition: opacity 300ms;
1041}
1042.strip-head .now svg { color: var(--muted); }
1043.strip-head .now svg:not(:first-child) { margin-left: 8px; }
1044.strip-head .now.stale { opacity: 0.4; }
1045.strip-head .now .status-label { font-size: 14px; }
1046.strip .empty.failed { min-height: 64px; padding: 8px 0; }
1047
dashboard/web/types/mcp.ts created+12
......@@ -0,0 +1,12 @@
1export interface Connections {
2 catalogs: { name: string; endpoint: string }[];
3 connections: { id: string; name: string; resources: string[]; scopes: string[]; createdAt: number }[];
4 machines: { id: string; name: string; platform: string; online: boolean }[];
5 shale: { linkedAt: number } | null;
6}
7export interface Consent {
8 linked: boolean;
9 client: string;
10 scopes: string[];
11 resources: { id: string; name: string }[];
12}
dashboard/web/types/model.ts created+261
......@@ -0,0 +1,261 @@
1/** Shapes and rules shared by the server and the web app; keep this free of Node imports. */
2
3export type Health = "healthy" | "degraded" | "down" | "stopped" | "deploying" | "restarting" | "starting";
4
5/** Columnar series, the layout uPlot consumes directly. Times are unix seconds. */
6export interface Series {
7 name: string;
8 t: number[];
9 v: (number | null)[];
10}
11
12export interface Me {
13 name: string;
14 groups: string[];
15 sections: Section[];
16 /** An admin previewing the dashboard as `groups`. */
17 viewing: boolean;
18}
19
20/** Cookie holding the comma-separated groups an admin previews the dashboard as. */
21export const VIEW_AS = "view-as";
22
23/** The Keycloak group that opens each dashboard section; null opens it to everyone. */
24const SECTION_GROUPS = { launcher: null, admin: "infra-admin", metrics: "metrics", media: "media-manage", vms: "vm" } as const;
25export type Section = keyof typeof SECTION_GROUPS;
26
27/** Admins reach everything; `access` null is open to every signed-in user. */
28export const canOpen = (groups: string[], access: string | null) =>
29 access === null || groups.includes("infra-admin") || groups.includes(access);
30
31export const sectionsOf = (groups: string[]) =>
32 (Object.keys(SECTION_GROUPS) as Section[]).filter((section) => canOpen(groups, SECTION_GROUPS[section]));
33
34export interface ServiceSummary {
35 id: string;
36 name: string;
37 health: Health;
38 url: string | null;
39 /** Versioned image URLs per color scheme; the same URL twice when the service has one image. */
40 icon: { light: string; dark: string } | null;
41 /** Keycloak group that sees this service in the launcher; null means everyone. */
42 access: string | null;
43 /** What the app is for, in a few words, for people who don't know it by name. */
44 tagline: string | null;
45 /** Cores in use and reserved; use is null while Nomad doesn't measure it. */
46 cpu: number | null;
47 cpuLimit: number;
48 /** Bytes in use and reserved; use is null while Nomad doesn't measure it. */
49 memory: number | null;
50 memoryLimit: number;
51}
52
53/** States that need someone to look, unlike stopped (on purpose) or the passing ones like restarting. */
54export const trouble = (health: Health) => health === "down" || health === "degraded";
55
56/** A service that needs a look: down or degraded, or restarted since restarts were last acknowledged. */
57export interface Issue {
58 service: Pick<ServiceSummary, "id" | "name" | "icon" | "url">;
59 health: Health;
60 /** The failing check's output while it's down or degraded. */
61 failing: string | null;
62 restart: { t: number; reason: string } | null;
63}
64
65/** When a task runs relative to the main ones; null for a main task. */
66export type Hook = "prestart" | "poststart" | "poststop" | null;
67
68/** A Nomad task; its restarts count within the current allocation. */
69export interface Container {
70 name: string;
71 /** As configured; null for tasks that don't run an image. */
72 image: string | null;
73 hook: Hook;
74 state: "running" | "pending" | "dead";
75 restarts: number;
76 lastRestart: { t: number; reason: string } | null;
77 startedAt: number | null;
78}
79
80/** The service on the other end of a dependency, and what it provides ("database", "sign-in"). */
81export interface ServiceLink {
82 id: string;
83 name: string;
84 kind: string;
85 health: Health;
86}
87
88/** The latest restart, unless it happened before restarts were last acknowledged. */
89export function unacknowledgedRestart(service: Pick<ServiceDetail, "containers" | "restartsAcknowledged">) {
90 const last = service.containers.flatMap((container) => container.lastRestart ?? []).sort((a, b) => b.t - a.t)[0];
91 return last && (service.restartsAcknowledged === null || last.t > service.restartsAcknowledged) ? last : null;
92}
93
94/** Lists that are null come from a source the host doesn't expose yet. */
95export interface ServiceDetail extends ServiceSummary {
96 /** Application spans observed in the trace store, excluding Caddy's edge spans. */
97 applicationTraces: boolean;
98 /** Metric names ingested for this service. */
99 applicationMetrics: string[];
100 /** The release this service's job was rendered from. */
101 release: string | null;
102 /** When Nomad was last handed this job. */
103 deployedAt: number;
104 rollout: "simple" | "overlapped";
105 containers: Container[];
106 checks: { name: string; passing: boolean; output: string }[];
107 requirements: ServiceLink[] | null;
108 dependents: ServiceLink[] | null;
109 /** Generated secrets can be rotated; the rest are supplied by hand. */
110 secrets: { name: string; generated: boolean }[] | null;
111 /** `used` excludes what snapshots hold. */
112 datasets: { name: string; mountpoint: string; used: number; snapshots: number }[];
113 /** When restarts were last acknowledged; ones before it no longer need attention. */
114 restartsAcknowledged: number | null;
115 /** This service's logs in the Logs web UI; null when that UI isn't set up. */
116 logs: AppLink | null;
117}
118
119/** A service's Nomad job as submitted, one entry per task group. Durations are seconds. */
120export interface ServiceDefinition {
121 groups: {
122 name: string;
123 count: number;
124 /** Restarts allowed per `interval`, `delay` apart; once spent, `fail` gives up instead of waiting out the interval. */
125 restart: { attempts: number; interval: number; delay: number; fail: boolean };
126 services: {
127 name: string;
128 port: string;
129 /** Hostnames the router sends here; empty for a service only other services reach. */
130 hostnames: string[];
131 /** The Keycloak group that must sign in first; null for no sign-in gate. */
132 authRole: string | null;
133 /** `restartAfter` failures in a row restart `task`, counted once `grace` has passed since it started. */
134 check: {
135 task: string;
136 type: string;
137 path: string;
138 interval: number;
139 timeout: number;
140 restartAfter: { failures: number; grace: number } | null;
141 } | null;
142 }[];
143 tasks: TaskDefinition[];
144 }[];
145 /** The service file in the current release; null when the release has none. */
146 source: string | null;
147}
148
149export interface TaskDefinition {
150 name: string;
151 hook: Hook;
152 /** A hook task that keeps running beside the main ones. */
153 sidecar: boolean;
154 image: string | null;
155 /** `uid:gid`; null runs as the image's user. */
156 user: string | null;
157 /** Cores and bytes reserved; `memoryMax` is null without room to burst past `memory`. */
158 cpu: number;
159 memory: number;
160 memoryMax: number | null;
161 /** `host` is null for a port Nomad picks at each start; `network` is "loopback" or "default". */
162 ports: { label: string; container: number | null; host: number | null; network: string }[];
163 mounts: { source: string; target: string; readOnly: boolean }[];
164 tmpfs: string[];
165 devices: string[];
166 capabilities: string[];
167 hostNetwork: boolean;
168 extraHosts: string[];
169 /** Variable names only; values never leave the server. `secret` and `template` ones are filled in as the task starts. */
170 env: { name: string; from: "job" | "secret" | "template" }[];
171}
172
173/** A deep link into another app's web UI. */
174export interface AppLink {
175 app: Pick<ServiceSummary, "id" | "name" | "icon">;
176 url: string;
177}
178
179/** An OpenTelemetry span; times are unix seconds. */
180export interface Span {
181 id: string;
182 parent: string | null;
183 service: string;
184 name: string;
185 start: number;
186 duration: number;
187 /** The status message of a failed span. */
188 error: string | null;
189 attributes: Record<string, string | number>;
190}
191
192/** Spans depth first, so the root comes first and every parent precedes its children. */
193export interface Trace {
194 id: string;
195 spans: Span[];
196}
197
198/** A trace as a list shows it, without the spans under its root. */
199export interface TraceSummary {
200 id: string;
201 root: Span;
202 spans: number;
203 services: string[];
204 /** The first failed span's status message. */
205 error: string | null;
206}
207
208export interface LogLine {
209 t: number;
210 container: string;
211 stream: "stdout" | "stderr";
212 level: "debug" | "info" | "warn" | "error" | null;
213 text: string;
214}
215
216export interface HostInfo {
217 cores: number;
218 memory: number;
219 bootedAt: number;
220 /**
221 * Stretches the UPS ran on battery, oldest first, from upsmon's ONBATT and ONLINE events; `end` is null until mains
222 * returns. Null while no UPS is connected.
223 */
224 outages: { start: number; end: number | null }[] | null;
225}
226
227export interface Ups {
228 status: "online" | "battery" | "charging" | "unknown";
229 /** Seconds of battery at the current load. */
230 runtime: number;
231 /** Watts. */
232 load: number;
233 /** Battery charge in percent. */
234 charge: number;
235}
236
237/** Seconds between ticks of the live stream. */
238export const LIVE_INTERVAL = 2;
239
240/** qBittorrent's `eta` when it has no estimate. */
241export const UNKNOWN_ETA = 8640000;
242
243/** One tick of the live stream; every value is the latest sample. */
244export interface Live {
245 t: number;
246 /** `cpu` in percent of the machine; `arc` is null without ZFS; `temperature` is the CPU package's in °C, null without a sensor. */
247 host: { cpu: number; memory: number; arc: number | null; temperature: number | null };
248 /** Empty while Nomad can't be reached. */
249 services: Record<string, Pick<ServiceSummary, "cpu" | "memory" | "health">>;
250 /** Null while no UPS is connected. */
251 ups: Ups | null;
252}
253
254export const METRICS = [
255 "host.cpu", "host.memory", "host.temperature", "host.power", "host.gpu", "host.network",
256 "service.cpu", "service.memory", "vm.cpu", "vm.memory",
257] as const;
258export type Metric = (typeof METRICS)[number];
259
260export const ACTIONS = ["restart", "stop", "start"] as const;
261export type Action = (typeof ACTIONS)[number];
dashboard/web/types/paperClover.ts created+66
......@@ -0,0 +1,66 @@
1/** Times are unix seconds; paths have a leading slash and no `/file` prefix. */
2export interface ProcessorStats {
3 name: string;
4 /** What it does, like "encode av1 high". */
5 title: string;
6 version: number;
7 /** False when this host lacks the tool it needs or lists it in CLOVER_PROCESSORS_DISABLE. */
8 runnable: boolean;
9 /** Files whose extension this processor applies to. */
10 applicable: number;
11 /** Done at the current version. */
12 done: number;
13 failed: number;
14 /** Done at an older version; re-run on the next sweep. */
15 stale: number;
16 /** Files and bytes in the derived store produced by this processor; none when it only records metadata. */
17 outputs: number | null;
18 bytes: number | null;
19 /** Mean time one file takes. */
20 seconds: number | null;
21}
22
23export interface PaperCloverStats {
24 indexer: { enabled: boolean; watching: boolean; nextSweepAt: number | null };
25 /** The latest full sweep; `endedAt` is null while it runs. */
26 lastSweep: { startedAt: number; endedAt: number | null; error: string | null } | null;
27 files: number;
28 directories: number;
29 bytes: number;
30 /** Top-level folders, like "2026". */
31 folders: { name: string; files: number; bytes: number }[];
32 types: { ext: string; files: number; bytes: number }[];
33 processors: ProcessorStats[];
34 derived: { files: number; bytes: number };
35 /** Files with a failed processor; `made` names the processors that did produce their output. */
36 failures: {
37 path: string;
38 size: number;
39 mime: string;
40 made: string[];
41 errors: { processor: string; error: string; at: number }[];
42 }[];
43 recent: { path: string; size: number; at: number }[];
44 largest: { path: string; size: number; duration: number; dimensions: string }[];
45}
46
47/**
48 * A line of upstream's live progress tree (@clo/lib/progress `ReadOnlyNode`, decoded from its `/progress` stream),
49 * with hidden nodes and childless passive ones dropped the way its terminal renderer drops them.
50 */
51export interface ProgressNode {
52 /** The published file or folder a line is about, split off the front of upstream's "path - step" text. */
53 path: string | null;
54 text: string;
55 /** Upstream's `valueFormatter(value, total)`, like "7/12"; empty when there's nothing to count. */
56 count: string;
57 /** `value / total` when upstream draws a bar. */
58 progress: number | null;
59 /** Estimated finish. */
60 eta: number | null;
61 /** When the adapter first saw the node. */
62 since: number;
63 /** ANSI stripped. */
64 logs: { level: "error" | "warn" | "info" | "debug"; text: string }[];
65 children: ProgressNode[];
66}
\ No newline at end of file
dashboard/web/types/search.test.ts created+26
......@@ -0,0 +1,26 @@
1import assert from "node:assert/strict";
2import { test } from "node:test";
3import { logField, matches, needles, parseSearch, traceField } from "./search.ts";
4
5test("parseSearch reads fields, comparisons, exclusions and quotes, and leaves unknown fields as text", () => {
6 assert.deepEqual(parseSearch(`status:5xx -path:/health duration:>1s "GET /x" user:42 -noise`, traceField), [
7 { field: "status", op: ":", value: "5", exclude: false },
8 { field: "path", op: ":", value: "/health", exclude: true },
9 { field: "duration", op: ">", value: "1s", exclude: false },
10 { field: null, op: ":", value: "GET /x", exclude: false },
11 { field: null, op: ":", value: "user:42", exclude: false },
12 { field: null, op: ":", value: "noise", exclude: true },
13 ]);
14 assert.deepEqual(parseSearch(`container:web http.route:/a`, logField).map((term) => term.field), ["container", null]);
15 assert.deepEqual(parseSearch(` "" - `, logField), [{ field: null, op: ":", value: "-", exclude: false }]);
16});
17
18test("matches takes fields by prefix, free text anywhere, and comparisons as numbers", () => {
19 const [status, text, above] = parseSearch("status:4xx GET status:>=500", traceField);
20 assert.ok(matches(status!, "404"));
21 assert.ok(!matches(status!, "204"));
22 assert.ok(matches(text!, "a get b"));
23 assert.ok(matches(above!, "503") && !matches(above!, "499"));
24 assert.ok(!matches(text!, undefined));
25 assert.deepEqual(needles(parseSearch("a -b container:c", logField)), ["a"]);
26});
dashboard/web/types/search.ts created+58
......@@ -0,0 +1,58 @@
1/** One term of a search box's query; every term must hold for an item to match. */
2export interface Term {
3 /** Null for free text, which matches anywhere in the item's text. */
4 field: string | null;
5 /** `:` matches a field starting with `value`; the rest compare numbers. */
6 op: ":" | ">" | ">=" | "<" | "<=";
7 value: string;
8 exclude: boolean;
9}
10
11/** Fields a log search can name; any other `word:` stays free text, since log lines are full of colons. */
12export const logField = (field: string) => field === "container" || field === "stream";
13
14/** Short names for the request attributes Caddy records on every trace. */
15export const TRACE_ALIASES: Record<string, string> = {
16 status: "http.response.status_code",
17 method: "http.request.method",
18 path: "url.path",
19 host: "server.address",
20 client: "client.address",
21};
22
23/** Beside the aliases and `duration`, any dotted name is taken for a span attribute, like `http.route`. */
24export const traceField = (field: string) => field in TRACE_ALIASES || field === "duration" || field.includes(".");
25
26export const TRACE_SORTS = ["newest", "oldest", "slowest", "fastest"] as const;
27export type TraceSort = (typeof TRACE_SORTS)[number];
28
29/**
30 * Splits a search into terms: `-` excludes one, `"quoted text"` keeps its spaces, `field:value` matches a field that
31 * starts with the value (`status:5xx` reads as `status:5`), and `field:>value` compares.
32 */
33export function parseSearch(query: string, known: (field: string) => boolean): Term[] {
34 const terms: Term[] = [];
35 for (const match of query.matchAll(/(-?)(?:([\w.]+):(>=|<=|>|<)?)?(?:"([^"]*)"?|(\S+))/g)) {
36 const [raw, minus, field, op, quoted, bare] = match;
37 const value = (quoted ?? bare ?? "").replace(/^(\d+)x+$/i, "$1");
38 if (!value) continue;
39 terms.push(field && known(field)
40 ? { field, op: (op ?? ":") as Term["op"], value, exclude: !!minus }
41 : { field: null, op: ":", value: field ? raw.slice(minus!.length) : value, exclude: !!minus });
42 }
43 return terms;
44}
45
46/** Whether `value` satisfies `term`, ignoring `exclude`; text compares without case. */
47export function matches(term: Term, value: string | undefined) {
48 if (value === undefined) return false;
49 if (term.op !== ":") {
50 const [a, b] = [Number(value), Number(term.value)];
51 return term.op === ">" ? a > b : term.op === ">=" ? a >= b : term.op === "<" ? a < b : a <= b;
52 }
53 const [text, needle] = [value.toLowerCase(), term.value.toLowerCase()];
54 return term.field === null ? text.includes(needle) : text.startsWith(needle);
55}
56
57/** The free text a search looks for, to mark where it's found. */
58export const needles = (terms: Term[]) => terms.flatMap((term) => (term.field === null && !term.exclude ? [term.value] : []));
dashboard/web/types/seedbox.ts created+53
......@@ -0,0 +1,53 @@
1/** Field names follow the qBittorrent WebUI API v2 so the real adapter passes them through. */
2export type TorrentState =
3 | "error" | "missingFiles" | "uploading" | "stoppedUP" | "queuedUP" | "stalledUP" | "checkingUP" | "forcedUP"
4 | "allocating" | "downloading" | "metaDL" | "forcedMetaDL" | "stoppedDL" | "queuedDL" | "stalledDL"
5 | "checkingDL" | "forcedDL" | "checkingResumeData" | "moving" | "unknown";
6
7export interface Torrent {
8 hash: string;
9 name: string;
10 size: number;
11 progress: number;
12 dlspeed: number;
13 upspeed: number;
14 /** Seconds; {@link import("./model.ts").UNKNOWN_ETA} when unknown. */
15 eta: number;
16 ratio: number;
17 state: TorrentState;
18 category: string;
19 added_on: number;
20 /** Queue position; -1 when seeding or queueing is off. */
21 priority: number;
22 uploaded: number;
23 /** Seeders and leechers connected, and in the whole swarm. */
24 num_seeds: number;
25 num_complete: number;
26 num_leechs: number;
27 num_incomplete: number;
28 magnet_uri: string;
29 save_path: string;
30 /** The torrent's top folder; empty for a single-file torrent. */
31 root_path: string;
32}
33
34export interface TorrentFile {
35 name: string;
36 size: number;
37 progress: number;
38}
39
40export interface ServerState {
41 dl_info_speed: number;
42 up_info_speed: number;
43 alltime_dl: number;
44 alltime_ul: number;
45 global_ratio: string;
46 free_space_on_disk: number;
47 connection_status: "connected" | "firewalled" | "disconnected";
48 save_path: string;
49 listen_port: number;
50 current_network_interface: string;
51}
52
53export const TORRENT_ACTIONS = ["stop", "start", "topPrio", "increasePrio", "decreasePrio", "bottomPrio"] as const;
\ No newline at end of file
dashboard/web/types/storage.index.ts created+4
......@@ -0,0 +1,4 @@
1/** A file, or a folder with its file count and the children big enough to keep. */
2export type MapNode = [name: string, size: number] | [name: string, size: number, files: number, children: MapNodes];
3
4export interface MapNodes extends Array<MapNode> {}
dashboard/web/types/storage.ts created+85
......@@ -0,0 +1,85 @@
1/** AVAIL and INUSE are spares, idle or standing in for a failed disk. */
2export type VdevState = "ONLINE" | "DEGRADED" | "FAULTED" | "OFFLINE" | "UNAVAIL" | "REMOVED" | "AVAIL" | "INUSE";
3
4export interface Disk {
5 /** /dev/disk/by-id name, as zpool status prints it. */
6 name: string;
7 state: VdevState;
8 read: number;
9 write: number;
10 checksum: number;
11 smart: {
12 model: string;
13 serial: string;
14 capacity: number;
15 passed: boolean;
16 temperature: number;
17 /** Lowest and highest over the last 24 hours, from the metrics store. */
18 temperatureRange: [low: number, high: number];
19 powerOnHours: number;
20 /** ATA attributes 5 and 197; NVMe drives have neither. */
21 reallocated: number | null;
22 pending: number | null;
23 } | null;
24}
25
26/** A top-level vdev such as raidz2-0, or a support class (logs, cache, spares) listed the same way. */
27export interface Vdev {
28 name: string;
29 state: VdevState;
30 disks: Disk[];
31}
32
33export interface Pool {
34 name: string;
35 state: VdevState;
36 /** Raw bytes including parity, as zpool list reports them. */
37 size: number;
38 allocated: number;
39 free: number;
40 fragmentation: number;
41 scan: {
42 kind: "scrub" | "resilver";
43 state: "scanning" | "finished" | "canceled";
44 start: number;
45 end: number | null;
46 examined: number;
47 total: number;
48 repaired: number;
49 errors: number;
50 } | null;
51 vdevs: Vdev[];
52 errors: string;
53}
54
55/** Named after the zfs properties they come from. */
56export interface Dataset {
57 name: string;
58 used: number;
59 usedbydataset: number;
60 usedbysnapshots: number;
61 referenced: number;
62 available: number;
63 compressratio: number;
64 /** Bytes before compression. */
65 logicalused: number;
66 /** e.g. "lz4" or "zstd". */
67 compression: string;
68 recordsize: number;
69 /** Null for `none` and unmounted datasets. */
70 mountpoint: string | null;
71 /** 0 means no quota. */
72 quota: number;
73 /** The snapshot a clone was made from. */
74 origin: string | null;
75}
76
77export interface Snapshot {
78 /** The part after @. */
79 name: string;
80 creation: number;
81 /** Space only this snapshot holds. */
82 used: number;
83 referenced: number;
84 clones: string[];
85}
\ No newline at end of file
dashboard/web/types/users.ts created+36
......@@ -0,0 +1,36 @@
1/** Field names and millisecond timestamps follow Keycloak's admin representations. */
2export interface User {
3 id: string;
4 username: string;
5 email: string | null;
6 firstName: string | null;
7 lastName: string | null;
8 enabled: boolean;
9 emailVerified: boolean;
10 createdTimestamp: number;
11 requiredActions: string[];
12 groups: Group[];
13 /** Keycloak replaces the whole map on update, so send it merged. */
14 attributes?: Record<string, string[]>;
15}
16
17export interface Group {
18 id: string;
19 name: string;
20}
21
22export interface Credential {
23 id: string;
24 type: "password" | "webauthn-passwordless" | "webauthn" | "otp";
25 userLabel: string | null;
26 createdDate: number;
27}
28
29export interface Session {
30 id: string;
31 ipAddress: string;
32 start: number;
33 lastAccess: number;
34 /** Client UUID to `clientId`. */
35 clients: Record<string, string>;
36}
dashboard/web/types/vms.ts created+70
......@@ -0,0 +1,70 @@
1/** libvirt's virDomainState, minus "nostate". "shutdown" means a shutdown is under way. */
2export type DomainState = "running" | "blocked" | "paused" | "shutdown" | "shutoff" | "crashed" | "pmsuspended";
3
4export interface Disk {
5 /** Guest device, like "vda". */
6 target: string;
7 /** Storage pool of a volume disk; null for a host block device passed through. */
8 pool: string | null;
9 /** Volume name, or the host device path. */
10 source: string;
11 capacity: number;
12 allocation: number;
13}
14
15export interface Hostdev {
16 /** Product name from the host's device database, like "GA104 [GeForce RTX 3070]". */
17 name: string;
18 /** A PCI address like "0000:01:00.0", or a USB "vendor:product". */
19 address: string;
20 /** Devices in one IOMMU group can only pass through together; null for USB. */
21 iommuGroup: number | null;
22}
23
24export interface Domain {
25 name: string;
26 /** What it's for, from the domain's `<description>`; empty when unset. */
27 description: string;
28 state: DomainState;
29 /** libvirt's reason for a paused or crashed state, like "user", "ioerror" or "panicked"; null otherwise. */
30 reason: string | null;
31 /** libosinfo's name for the guest OS, like "Windows 11". */
32 os: string;
33 vcpus: number;
34 /** Host threads the vCPUs are pinned to; null when they float over all of them. */
35 pinned: number[] | null;
36 /** The most memory the guest can have. */
37 memory: number;
38 /** What the balloon currently gives the guest, at most `memory`. */
39 balloon: number;
40 autostart: boolean;
41 /** Unix seconds the QEMU process started; null while shut off. */
42 startedAt: number | null;
43 /** The latest sample: cpu as percent of its vCPUs, memory as bytes the guest uses; null while off. */
44 usage: { cpu: number; memory: number } | null;
45 /** The guest agent channel's state; null when the domain has none. */
46 agent: "connected" | "disconnected" | null;
47 disks: Disk[];
48 /** `source` is the bridge or libvirt network; addresses come from the guest agent. */
49 interfaces: { mac: string; source: string; addresses: string[] }[];
50 hostdevs: Hostdev[];
51}
52
53/** A volume in the images pool; installers attach as a CD, disk images are cloned into the new disk. */
54export interface Image {
55 volume: string;
56 os: string;
57 kind: "installer" | "disk";
58 capacity: number;
59 /** libosinfo's recommended resources. */
60 recommended: { vcpus: number; memory: number; disk: number };
61}
62
63/** Samples on one time axis; cpu is percent of the domain's vCPUs, memory is bytes the guest uses. */
64export interface History {
65 t: number[];
66 domains: Record<string, { cpu: (number | null)[]; memory: (number | null)[] }>;
67}
68
69export const DOMAIN_ACTIONS = ["start", "shutdown", "reboot", "destroy", "resume"] as const;
70export type NewDomain = {name:string;description:string;image:string;vcpus:number;memory:number;disk:number;autostart:boolean;start:boolean};
dashboard/web/types/youtube.ts created+74
......@@ -0,0 +1,74 @@
1export interface Video {
2 /** Survives a pasted URL resolving into a real video id. */
3 key: string;
4 title: string;
5 channel: string;
6 /** YYYY-MM-DD; empty while resolving. */
7 published: string;
8 /** Seconds. */
9 duration: number | null;
10 thumb: string | null;
11 link: string;
12 resolving: boolean;
13}
14
15export interface Show {
16 name: string;
17 seasons: { number: number; episodes: number }[];
18}
19
20export interface LibraryEntry {
21 type: "indie" | "independent";
22 path: string;
23 context: string;
24 title: string;
25 link: string;
26 season: number | null;
27 episode: number | null;
28}
29
30export type JobStatus = "queued" | "resolving" | "downloading" | "retrying" | "waiting" | "done" | "error";
31
32export interface Job {
33 id: string;
34 title: string;
35 channel: string;
36 /** Where it lands, e.g. "Night Signals S02E07" or "Independent". */
37 destination: string;
38 status: JobStatus;
39 /** The video on YouTube. */
40 url: string;
41 /** Download folder in Files; null when unavailable. */
42 folder: string | null;
43 /** 0–1 while downloading. */
44 progress: number | null;
45 queuedAt: number;
46}
47
48/** Downloads pause while YouTube bot-walls the IP and resume after a successful probe. */
49export interface Wall {
50 walled: boolean;
51 nextProbe: number | null;
52}
53
54/** ytdl-sub's archive loop: a pass every 6h, or 24h after a pass that hit the bot wall. */
55export interface Archive {
56 started: number | null;
57 finished: number | null;
58 walled: boolean;
59 next: number;
60}
61
62export interface Upscaler {
63 enabled: boolean;
64 running: boolean;
65 done: number;
66 total: number;
67 current: string;
68 errors: number;
69}
70
71export type ConfigFile = { name: string; body: string };
72export type Ingest = {dest:"independent"}|{dest:"music"}|{dest:"indie";show:string;season:number;episode:number;title:string};
73export interface Rules {download_after?:string;title_include_keywords?:string[];title_exclude_keywords?:string[];description_include_keywords?:string[];description_exclude_keywords?:string[];}
74export interface Channels {notify:{name:string;url:string}[];archive:{name:string;url:string;rules:Rules}[];}
flake.lock created+27
......@@ -0,0 +1,27 @@
1{
2 "nodes": {
3 "nixpkgs": {
4 "locked": {
5 "lastModified": 1790113641,
6 "narHash": "sha256-D2aaQetafdpHxbwA4ldAxs51UX0b11pXL+JL/5L8VrM=",
7 "owner": "NixOS",
8 "repo": "nixpkgs",
9 "rev": "1bc55b9def8165e82073919945c3239903fe4dc2",
10 "type": "github"
11 },
12 "original": {
13 "owner": "NixOS",
14 "ref": "nixos-26.05",
15 "repo": "nixpkgs",
16 "type": "github"
17 }
18 },
19 "root": {
20 "inputs": {
21 "nixpkgs": "nixpkgs"
22 }
23 }
24 },
25 "root": "root",
26 "version": 7
27}
flake.nix created+22
......@@ -0,0 +1,22 @@
1{
2 description = "Clover infrastructure prototype";
3
4 inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
5
6 outputs = { self, nixpkgs, ... }: {
7 packages.x86_64-linux.dashboard = nixpkgs.legacyPackages.x86_64-linux.callPackage ./nixos/dashboard.nix { };
8 packages.x86_64-linux.dashboard-image = self.packages.x86_64-linux.dashboard.image;
9
10 packages.aarch64-darwin.qemu = nixpkgs.legacyPackages.aarch64-darwin.qemu;
11
12 nixosConfigurations.vm = nixpkgs.lib.nixosSystem {
13 system = "x86_64-linux";
14 modules = [ ./nixos/configuration.nix ./nixos/vm.nix ];
15 };
16
17 nixosConfigurations.zenith = nixpkgs.lib.nixosSystem {
18 system = "x86_64-linux";
19 modules = [ ./nixos/configuration.nix ./nixos/zenith.nix ./nixos/hardware-configuration.nix ];
20 };
21 };
22}
nixos/configuration.nix created+262
......@@ -0,0 +1,262 @@
1{ config, lib, pkgs, ... }:
2let
3 dashboard = pkgs.callPackage ./dashboard.nix { };
4 dashboardPort = "7072";
5 internalPort = 8448;
6 proxyToken = "/var/lib/studio/dashboard-proxy.token";
7 hostTools = lib.fileset.toSource {
8 root = ../.;
9 fileset = lib.fileset.unions [ ../tools/dashboard-host.py ../tools/vms.py ../tools/dashboard-run.py ../tools/release.py ../service/keycloak/api.py ];
10 };
11 nativePkl = pkgs.callPackage ./pkl.nix { };
12in
13{
14 nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "nomad" "nvidia-x11" "nvidia-kernel-modules" ];
15
16 networking.firewall = {
17 enable = true;
18 allowedTCPPorts = [ 22 ];
19 interfaces.podman0.allowedTCPPorts = [ 443 internalPort 10428 15432 18428 ];
20 interfaces.podman0.allowedTCPPortRanges = [{ from = 20000; to = 32000; }];
21 interfaces.tailscale0.allowedTCPPorts = [ 80 443 445 8081 8082 ];
22 };
23
24 services.openssh.enable = true;
25 services.caddy = {
26 enable = true;
27 package = pkgs.caddy.withPlugins {
28 plugins = [ "github.com/caddyserver/replace-response@v0.0.0-20250618171559-80962887e4c6" ];
29 hash = "sha256-2tcvl9ZDpe7SAy3tZCPNwmw0KoIIakqggVaRtVAg9R0=";
30 };
31 extraConfig = "import /var/lib/caddy/routes.caddy";
32 virtualHosts = {
33 ":8082".extraConfig = "reverse_proxy 127.0.0.1:4646";
34 };
35 };
36 systemd.services.caddy.environment = {
37 OTEL_SERVICE_NAME = "studio-caddy";
38 OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = "http://127.0.0.1:10428/insert/opentelemetry/v1/traces";
39 OTEL_TRACES_SAMPLER = "parentbased_traceidratio";
40 OTEL_TRACES_SAMPLER_ARG = "0.25";
41 };
42
43 boot.supportedFilesystems = [ "zfs" ];
44 virtualisation.podman.enable = true;
45 virtualisation.libvirtd.enable = true;
46 systemd.services.podman.environment.LOGGING = "--log-level=warn";
47
48 services.nomad = {
49 enable = true;
50 enableDocker = false;
51 dropPrivileges = false;
52 extraSettingsPlugins = [ pkgs.nomad-driver-podman ];
53 extraPackages = [ pkgs.cni-plugins ];
54 settings = {
55 datacenter = "clover";
56 bind_addr = "0.0.0.0";
57 addresses.http = "127.0.0.1";
58 advertise.http = "127.0.0.1";
59 server = {
60 enabled = true;
61 bootstrap_expect = 1;
62 };
63 acl.enabled = true;
64 client = {
65 enabled = true;
66 host_network.loopback.cidr = "127.0.0.1/8";
67 };
68 plugin.nomad-driver-podman.config.socket_path = "unix:///run/podman/podman.sock";
69 telemetry.collection_interval = "15s";
70 ui.enabled = true;
71 };
72 };
73
74 systemd.services.studio-log-shipper = {
75 wantedBy = [ "multi-user.target" ];
76 after = [ "nomad.service" ];
77 wants = [ "nomad.service" ];
78 unitConfig.ConditionPathExists = "/opt/studio/current/tools/log-shipper.py";
79 path = [ pkgs.systemd ];
80 serviceConfig = {
81 ExecStart = "${pkgs.python3}/bin/python3 /opt/studio/current/tools/log-shipper.py";
82 Restart = "always";
83 RestartSec = 5;
84 };
85 };
86
87 systemd.services.nomad = {
88 after = [ "podman.socket" ];
89 requires = [ "podman.socket" ];
90 };
91
92 systemd.services.studio-router = {
93 wantedBy = [ "multi-user.target" ];
94 wants = [ "nomad.service" "caddy.service" ];
95 after = [ "nomad.service" "caddy.service" ];
96 unitConfig.ConditionPathExists = "/var/lib/studio/router.token";
97 environment = {
98 STUDIO_DOMAIN = config.environment.variables.STUDIO_DOMAIN;
99 STUDIO_DASHBOARD_PORT = dashboardPort;
100 STUDIO_INTERNAL_PORT = toString internalPort;
101 STUDIO_PROXY_TOKEN_FILE = proxyToken;
102 };
103 serviceConfig = {
104 ExecStart = "${pkgs.python3}/bin/python3 ${../tools/router.py}";
105 Restart = "always";
106 RestartSec = 5;
107 };
108 };
109
110 users.groups.studio-dashboard = { };
111 users.users.studio-dashboard = {
112 isSystemUser = true;
113 group = "studio-dashboard";
114 };
115 systemd.services.studio-host = {
116 wantedBy = [ "multi-user.target" ];
117 wants = [ "podman.socket" ];
118 after = [ "podman.socket" ];
119 path = [ pkgs.zfs pkgs.util-linux pkgs.python3 pkgs.libvirt pkgs.qemu pkgs.podman pkgs.nomad pkgs.systemd pkgs.rsync pkgs.nixos-rebuild nativePkl ]
120 ++ lib.optional (builtins.elem "nvidia" config.services.xserver.videoDrivers) (lib.getBin config.hardware.nvidia.package);
121 environment = (lib.filterAttrs (name: _: lib.hasPrefix "STUDIO_" name) config.environment.variables) // {
122 STUDIO_POOL = lib.attrByPath [ "STUDIO_POOL" ] "studio-demo" config.environment.variables;
123 STUDIO_DASHBOARD_USER = "studio-dashboard";
124 STUDIO_VM_ACCEL = lib.attrByPath [ "STUDIO_VM_ACCEL" ] "kvm" config.environment.variables;
125 };
126 serviceConfig = {
127 ExecStart = "${pkgs.python3}/bin/python3 ${hostTools}/tools/dashboard-host.py";
128 Type = "notify";
129 Group = "studio-dashboard";
130 StateDirectory = "studio/host";
131 StateDirectoryMode = "0700";
132 RuntimeDirectory = "studio-host";
133 RuntimeDirectoryMode = "0750";
134 RuntimeDirectoryPreserve = "yes";
135 UMask = "0077";
136 ProtectSystem = "strict";
137 ReadWritePaths = [ "/srv/vm" ];
138 ProtectHome = true;
139 PrivateTmp = true;
140 NoNewPrivileges = true;
141 CapabilityBoundingSet = [ "CAP_SYS_ADMIN" "CAP_DAC_READ_SEARCH" ];
142 MemoryMax = "256M";
143 TasksMax = 32;
144 DevicePolicy = "closed";
145 DeviceAllow = [ "/dev/zfs rw" ] ++ lib.optional (builtins.elem "nvidia" config.services.xserver.videoDrivers) "char-nvidia* rw";
146 RestrictAddressFamilies = [ "AF_UNIX" ];
147 IPAddressDeny = "any";
148 Restart = "always";
149 RestartSec = 2;
150 };
151 };
152 systemd.services.studio-dashboard = let
153 environment = (lib.filterAttrs (name: _: lib.hasPrefix "STUDIO_" name) config.environment.variables) // {
154 STUDIO_DATA_DIR = "/data";
155 STUDIO_PROXY_TOKEN_FILE = "/run/secrets/dashboard-proxy.token";
156 STUDIO_NOMAD_TOKEN_FILE = "/run/secrets/dashboard-nomad.token";
157 STUDIO_CA_BUNDLE = "/run/secrets/ca-bundle.crt";
158 STUDIO_INDEX_DIR = "/data/index";
159 STUDIO_INTERNAL_URL = "https://dashboard.internal.${config.environment.variables.STUDIO_DOMAIN}:${toString internalPort}";
160 STUDIO_FILES_URL = "https://file.${config.environment.variables.STUDIO_DOMAIN}";
161 STUDIO_PUBLIC_ORIGIN = "https://globe.${config.environment.variables.STUDIO_DOMAIN}";
162 STUDIO_KEYCLOAK_URL = "https://keycloak.${config.environment.variables.STUDIO_DOMAIN}";
163 STUDIO_JELLYFIN_URL = "https://jelly.${config.environment.variables.STUDIO_DOMAIN}";
164 STUDIO_SHALE_URL = "https://shale.${config.environment.variables.STUDIO_DOMAIN}";
165 STUDIO_PUBLISHED_ROOT = "/srv/clover/Published";
166 STUDIO_DOMAIN = config.environment.variables.STUDIO_DOMAIN;
167 STUDIO_POOL = lib.attrByPath [ "STUDIO_POOL" ] "studio-demo" config.environment.variables;
168 STUDIO_NODE_NAME = config.networking.hostName;
169 STUDIO_MEDIA_READ_ONLY = lib.attrByPath [ "STUDIO_MEDIA_READ_ONLY" ] "false" config.environment.variables;
170 STUDIO_CLOVER_READ_ONLY = lib.attrByPath [ "STUDIO_CLOVER_READ_ONLY" ] "false" config.environment.variables;
171 };
172 in {
173 inherit environment;
174 wantedBy = [ "multi-user.target" ];
175 wants = [ "nomad.service" "studio-router.service" "studio-host.service" ];
176 after = [ "nomad.service" "studio-host.service" ];
177 unitConfig.ConditionPathExists = [ "/var/lib/studio/dashboard.token" "/var/lib/studio/ca-bundle.crt" "/opt/studio/current" ];
178 path = [ pkgs.podman pkgs.coreutils pkgs.curl pkgs.openssl pkgs.systemd ];
179 serviceConfig = {
180 Type = "notify";
181 NotifyAccess = "all";
182 ExecStart = pkgs.writeShellScript "studio-dashboard-start" ''
183 uid=$(id -u studio-dashboard)
184 gid=$(id -g studio-dashboard)
185 optional=()
186 if test -d /srv/prod/yt-feed/data && test -d '/srv/clover/Documents/Config/Youtube Downloader'; then
187 optional+=(--volume=/srv/prod/yt-feed/data:/srv/prod/yt-feed/data:rw
188 --env=STUDIO_YT_STATE=/srv/prod/yt-feed/data
189 '--env=STUDIO_YT_CONFIG=/srv/clover/Documents/Config/Youtube Downloader'
190 --env=STUDIO_YT_MEDIA=/srv/clover/Media)
191 fi
192 exec podman run --rm --replace --name=studio-dashboard --pull=never \
193 --sdnotify=conmon --cgroups=no-conmon --log-driver=journald \
194 --user="$uid:$gid" \
195 --group-add=${toString config.users.groups.chloe.gid} --network=podman \
196 --read-only --read-only-tmpfs=false --cap-drop=ALL --security-opt=no-new-privileges \
197 --memory=2g --cpus=4 --pids-limit=256 --cpu-shares=1024 \
198 --publish=127.0.0.1:${dashboardPort}:7072 \
199 --tmpfs=/tmp:rw,noexec,nosuid,nodev,size=256m,mode=1777 \
200 --tmpfs=/run:rw,noexec,nosuid,nodev,size=16m,mode=755 \
201 --env=HOME=/data --env=XDG_CACHE_HOME=/data/cache \
202 --volume=/var/lib/studio/dashboard:/data:rw \
203 --volume=/run/studio-host:/run/studio-host:ro \
204 --volume=${proxyToken}:/run/secrets/dashboard-proxy.token:ro \
205 --volume=/var/lib/studio/dashboard.token:/run/secrets/dashboard-nomad.token:ro \
206 --volume=/var/lib/studio/ca-bundle.crt:/run/secrets/ca-bundle.crt:ro \
207 --mount=type=bind,src=/srv/clover,dst=/srv/clover,bind-nonrecursive,bind-propagation=rslave,ro="$STUDIO_CLOVER_READ_ONLY" \
208 --mount=type=bind,src=/srv/clover/Media,dst=/srv/clover/Media,bind-nonrecursive,bind-propagation=rslave,ro="$STUDIO_MEDIA_READ_ONLY" \
209 ${lib.concatMapStringsSep " " (name: lib.escapeShellArg "--env=${name}") (builtins.attrNames environment)} \
210 ${lib.concatMapStringsSep " " (name: lib.escapeShellArg "--add-host=${name}.${config.environment.variables.STUDIO_DOMAIN}:host-gateway") [ "dashboard.internal" "keycloak" "jelly" "db" "shale" ]} \
211 "''${optional[@]}" ${lib.escapeShellArg "${dashboard.image.imageName}:${dashboard.image.imageTag}"}
212 '';
213 ExecStop = "${pkgs.podman}/bin/podman stop --ignore --time=8 studio-dashboard";
214 ExecStopPost = "${pkgs.podman}/bin/podman rm --ignore --force studio-dashboard";
215 CPUWeight = 1000;
216 KillMode = "mixed";
217 TimeoutStartSec = 180;
218 TimeoutStopSec = 30;
219 Restart = "always";
220 RestartSec = 5;
221 };
222 preStart = ''
223 umask 0077
224 if ! test -e ${proxyToken}; then
225 openssl rand -hex 32 > ${proxyToken}.pending
226 mv ${proxyToken}.pending ${proxyToken}
227 fi
228 chown root:studio-dashboard ${proxyToken} /var/lib/studio/dashboard.token
229 chmod 0640 ${proxyToken} /var/lib/studio/dashboard.token
230 chown -R studio-dashboard:studio-dashboard /var/lib/studio/dashboard
231 systemd-tmpfiles --create --prefix=/srv/prod/yt-feed/data
232 if ! podman image exists ${lib.escapeShellArg "${dashboard.image.imageName}:${dashboard.image.imageTag}"}; then
233 podman load --quiet --input ${dashboard.image}
234 fi
235 '';
236 postStart = ''
237 for attempt in $(seq 1 30); do
238 if printf 'header = "Studio-Proxy-Token: %s"\n' "$(cat ${proxyToken})" | curl --config - --fail --silent http://127.0.0.1:${dashboardPort}/ >/dev/null; then
239 exit 0
240 fi
241 sleep 1
242 done
243 exit 1
244 '';
245 };
246
247 systemd.tmpfiles.rules = [
248 "d /srv/prod 0755 root root -"
249 "d /srv/staging 0755 root root -"
250 "d /srv/vm 0755 root root -"
251 "A+ /srv/prod/yt-feed/data - - - - g:chloe:rwX,d:g:chloe:rwx"
252 "d /var/lib/studio 0700 root root -"
253 "d /var/lib/studio/dashboard 0700 studio-dashboard studio-dashboard -"
254 "d /var/lib/studio/routes 0700 root root -"
255 "d /var/lib/caddy/studio 0755 caddy caddy -"
256 "f /var/lib/caddy/routes.caddy 0640 caddy caddy -"
257 ];
258
259 environment.variables.STUDIO_NODE_NAME = config.networking.hostName;
260 environment.variables.STUDIO_DOMAIN = lib.mkDefault "studio.test";
261 environment.systemPackages = with pkgs; [ acl curl jq openssl python3 rsync zfs qemu ] ++ [ nativePkl ];
262}
nixos/dashboard.nix created+66
......@@ -0,0 +1,66 @@
1{ stdenv, lib, rustPlatform, runCommand, nodejs_24, pnpm_10, fetchPnpmDeps, pnpmConfigHook, sqlite, pkg-config, dockerTools, coreutils, callPackage, python3, yt-dlp, ffmpeg }:
2let
3 nativePkl = callPackage ./pkl.nix { };
4 youtubePython = python3.withPackages (packages: [ packages.pyyaml ]);
5 source = lib.fileset.toSource {
6 root = ../.;
7 fileset = lib.fileset.unions [ ../config ../service ];
8 };
9 web = stdenv.mkDerivation (finalAttrs: {
10 pname = "home-dashboard-web";
11 version = "0.1.0";
12 src = lib.fileset.toSource {
13 root = ../dashboard;
14 fileset = lib.fileset.unions [
15 ../dashboard/web ../dashboard/package.json ../dashboard/pnpm-lock.yaml
16 ../dashboard/tsconfig.json ../dashboard/vite.config.ts
17 ];
18 };
19 pnpmDeps = fetchPnpmDeps {
20 inherit (finalAttrs) pname version src;
21 pnpm = pnpm_10;
22 fetcherVersion = 3;
23 hash = "sha256-xQbdTZIAiwM7Ox+6BsTD57LEJzj10hvqYEpA03W9OGs=";
24 };
25 nativeBuildInputs = [ nodejs_24 pnpm_10 pnpmConfigHook ];
26 buildPhase = "pnpm run build";
27 installPhase = "cp -r dist $out";
28 });
29 server = rustPlatform.buildRustPackage {
30 pname = "home-dashboard-server";
31 version = "0.1.0";
32 src = lib.fileset.toSource {
33 root = ../dashboard;
34 fileset = lib.fileset.unions [
35 ../dashboard/src ../dashboard/tests ../dashboard/Cargo.toml ../dashboard/Cargo.lock
36 ];
37 };
38 cargoLock.lockFile = ../dashboard/Cargo.lock;
39 nativeBuildInputs = [ pkg-config ];
40 buildInputs = [ sqlite ];
41 LIBSQLITE3_SYS_USE_PKG_CONFIG = "1";
42 };
43 dashboard = runCommand "home-dashboard-0.1.0" {
44 passthru.image = dockerTools.buildLayeredImage {
45 name = "studio-dashboard";
46 contents = [ dashboard coreutils nativePkl youtubePython yt-dlp ffmpeg dockerTools.caCertificates ];
47 config = {
48 User = "65534:65534";
49 Cmd = [ "${dashboard}/bin/home-dashboard" ];
50 WorkingDir = "${dashboard}/lib/home-dashboard";
51 Env = [
52 "STUDIO_LISTEN_ADDRESS=0.0.0.0" "PORT=7072"
53 "STUDIO_PROXY_TOKEN_FILE=/run/secrets/dashboard-proxy.token"
54 "STUDIO_REPO=${source}"
55 "STUDIO_YT_PYTHON=${youtubePython}/bin/python3"
56 "PATH=${lib.makeBinPath [ coreutils nativePkl youtubePython yt-dlp ffmpeg ]}"
57 ];
58 };
59 };
60 } ''
61 mkdir -p $out/bin $out/lib/home-dashboard
62 ln -s ${server}/bin/home-dashboard $out/bin/home-dashboard
63 ln -s ${web} $out/lib/home-dashboard/dist
64 ln -s ${../dashboard/server} $out/lib/home-dashboard/server
65'';
66in dashboard
nixos/hardware-configuration.nix created+1
......@@ -0,0 +1 @@
1throw "Generate nixos/hardware-configuration.nix from the NixOS installer after partitioning Zenith's NVMe"
nixos/nomad-podman-stats.patch created+234
......@@ -0,0 +1,234 @@
1--- a/api/container_stats.go
2+++ b/api/container_stats.go
3@@ -10,6 +10,8 @@
4 "fmt"
5 "io"
6 "net/http"
7+ "net/url"
8+ "strings"
9 )
10
11 var ContainerNotFound = errors.New("No such Container")
12@@ -19,44 +21,52 @@
13 func (c *API) ContainerStats(ctx context.Context, name string) (Stats, error) {
14
15 var stats Stats
16- res, err := c.Get(ctx, fmt.Sprintf("/v1.0.0/libpod/containers/%s/stats?stream=false", name))
17+ res, err := c.Get(ctx, "/v4.0.0/libpod/containers/stats?stream=false&containers="+url.QueryEscape(name))
18 if err != nil {
19 return stats, err
20 }
21
22 defer ignoreClose(res.Body)
23
24- if res.StatusCode == http.StatusNotFound {
25- return stats, ContainerNotFound
26- }
27-
28- if res.StatusCode == http.StatusConflict {
29- return stats, ContainerWrongState
30- }
31- if res.StatusCode != http.StatusOK {
32- return stats, fmt.Errorf("cannot get stats of container, status code: %d", res.StatusCode)
33- }
34-
35 body, err := io.ReadAll(res.Body)
36 if err != nil {
37 return stats, err
38 }
39
40- // Since podman 4.1.1, an empty 200 response is returned for stopped containers.
41- if len(body) == 0 {
42- return stats, ContainerNotFound
43+ if res.StatusCode != http.StatusOK {
44+ var failure Error
45+ _ = json.Unmarshal(body, &failure)
46+ if res.StatusCode == http.StatusNotFound && strings.Contains(failure.Cause, "no such container") {
47+ return stats, ContainerNotFound
48+ }
49+ if strings.Contains(failure.Cause, "container is stopped") || strings.Contains(failure.Cause, "container state improper") {
50+ return stats, ContainerWrongState
51+ }
52+ return stats, fmt.Errorf("cannot get stats of container, status code: %d, cause: %s", res.StatusCode, failure.Cause)
53 }
54
55- // Since podman 4.6.0, a 200 response with `container is stopped` is returned for stopped containers.
56- var errResponse Error
57- if _ = json.Unmarshal(body, &errResponse); errResponse.Cause == "container is stopped" {
58- return stats, ContainerNotFound
59+ var report struct {
60+ Stats []struct {
61+ CPUNano, CPUSystemNano, SystemNano, MemUsage, MemLimit uint64
62+ }
63 }
64-
65- err = json.Unmarshal(body, &stats)
66- if err != nil {
67+ if err = json.Unmarshal(body, &report); err != nil {
68 return stats, err
69 }
70-
71+ if len(report.Stats) != 1 {
72+ return stats, fmt.Errorf("expected one container stats record, got %d", len(report.Stats))
73+ }
74+ value := report.Stats[0]
75+ if value.SystemNano == 0 {
76+ return stats, ContainerWrongState
77+ }
78+ if value.CPUSystemNano > value.CPUNano {
79+ return stats, fmt.Errorf("container system CPU time exceeds total CPU time")
80+ }
81+ stats.CPUStats.CPUUsage.TotalUsage = value.CPUNano
82+ stats.CPUStats.CPUUsage.UsageInKernelmode = value.CPUSystemNano
83+ stats.CPUStats.CPUUsage.UsageInUsermode = value.CPUNano - value.CPUSystemNano
84+ stats.MemoryStats.Usage = value.MemUsage
85+ stats.MemoryStats.Limit = value.MemLimit
86 return stats, nil
87 }
88--- a/handle.go
89+++ b/handle.go
90@@ -104,7 +104,9 @@
91 func (h *TaskHandle) runStatsEmitter(ctx context.Context, statsChannel chan *drivers.TaskResourceUsage, interval time.Duration) {
92 timer := time.NewTimer(0)
93 h.logger.Debug("Starting statsEmitter", "container", h.containerID)
94+ h.stateLock.Lock()
95 h.collectionInterval = interval
96+ h.stateLock.Unlock()
97 for {
98 select {
99 case <-ctx.Done():
100@@ -201,10 +203,12 @@
101 return
102
103 case <-timer.C:
104- timer.Reset(h.collectionInterval)
105 }
106
107 containerStats, statsErr := h.podmanClient.ContainerStats(h.driver.ctx, h.containerID)
108+ h.stateLock.RLock()
109+ timer.Reset(h.collectionInterval)
110+ h.stateLock.RUnlock()
111 if statsErr != nil {
112 gone := false
113 if errors.Is(statsErr, api.ContainerNotFound) {
114--- /dev/null
115+++ b/api/native_stats_test.go
116@@ -0,0 +1,58 @@
117+package api
118+
119+import (
120+ "context"
121+ "errors"
122+ "net/http"
123+ "net/http/httptest"
124+ "testing"
125+
126+ "github.com/hashicorp/go-hclog"
127+)
128+
129+func TestNativeContainerStats(t *testing.T) {
130+ for _, test := range []struct {
131+ name string
132+ status int
133+ body string
134+ gone error
135+ fails bool
136+ }{
137+ {"running", 200, `{"Error":null,"Stats":[{"CPUNano":100,"CPUSystemNano":25,"SystemNano":1000,"MemUsage":64,"MemLimit":256}]}`, nil, false},
138+ {"idle", 200, `{"Error":null,"Stats":[{"CPUNano":0,"CPUSystemNano":0,"SystemNano":1000}]}`, nil, false},
139+ {"exited", 200, `{"Error":null,"Stats":[{"CPUNano":0,"CPUSystemNano":0,"SystemNano":0,"MemUsage":0,"MemLimit":0}]}`, ContainerWrongState, true},
140+ {"missing", 404, `{"cause":"no such container"}`, ContainerNotFound, true},
141+ {"stopped", 404, `{"cause":"container is stopped"}`, ContainerWrongState, true},
142+ {"invalid_state", 404, `{"cause":"container state improper"}`, ContainerWrongState, true},
143+ {"storage_failure", 404, `{"cause":"input/output error"}`, nil, true},
144+ {"upstream_failure", 500, `{"cause":"input/output error"}`, nil, true},
145+ {"truncated", 200, `{"Stats":[`, nil, true},
146+ {"empty", 200, `{"Stats":[]}`, nil, true},
147+ {"invalid_cpu", 200, `{"Stats":[{"CPUNano":10,"CPUSystemNano":20,"SystemNano":1000}]}`, nil, true},
148+ } {
149+ t.Run(test.name, func(t *testing.T) {
150+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151+ if r.URL.Path != "/v4.0.0/libpod/containers/stats" || r.URL.Query().Get("containers") != "x/y ?" || r.URL.Query().Get("stream") != "false" {
152+ t.Errorf("unexpected stats request: %s", r.URL)
153+ }
154+ w.WriteHeader(test.status)
155+ _, _ = w.Write([]byte(test.body))
156+ }))
157+ defer server.Close()
158+ client := NewClient(hclog.NewNullLogger(), ClientConfig{SocketPath: server.URL})
159+ stats, err := client.ContainerStats(context.Background(), "x/y ?")
160+ if (err != nil) != test.fails {
161+ t.Fatalf("stats error: %v", err)
162+ }
163+ if test.gone != nil && !errors.Is(err, test.gone) {
164+ t.Fatalf("expected %v, got %v", test.gone, err)
165+ }
166+ if test.gone == nil && (errors.Is(err, ContainerNotFound) || errors.Is(err, ContainerWrongState)) {
167+ t.Fatalf("transient failure treated as container exit: %v", err)
168+ }
169+ if test.name == "running" && (stats.CPUStats.CPUUsage.TotalUsage != 100 || stats.CPUStats.CPUUsage.UsageInKernelmode != 25 || stats.CPUStats.CPUUsage.UsageInUsermode != 75 || stats.MemoryStats.Usage != 64 || stats.MemoryStats.Limit != 256) {
170+ t.Fatalf("incorrect resource counters: %+v", stats)
171+ }
172+ })
173+ }
174+}
175--- /dev/null
176+++ b/native_monitor_test.go
177@@ -0,0 +1,57 @@
178+package main
179+
180+import (
181+ "context"
182+ "net/http"
183+ "net/http/httptest"
184+ "testing"
185+ "time"
186+
187+ "github.com/hashicorp/go-hclog"
188+ "github.com/hashicorp/nomad-driver-podman/api"
189+)
190+
191+func TestMonitorWaitsAfterSlowStats(t *testing.T) {
192+ started := make(chan time.Time, 4)
193+ finished := make(chan time.Time, 4)
194+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
195+ started <- time.Now()
196+ time.Sleep(100 * time.Millisecond)
197+ _, _ = w.Write([]byte(`{"Stats":[{"CPUNano":100,"CPUSystemNano":25,"SystemNano":1000}]}`))
198+ finished <- time.Now()
199+ }))
200+ defer server.Close()
201+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
202+ defer cancel()
203+ logger := hclog.NewNullLogger()
204+ handle := TaskHandle{driver: &Driver{ctx: ctx}, logger: logger,
205+ podmanClient: api.NewClient(logger, api.ClientConfig{SocketPath: server.URL}),
206+ collectionInterval: 40 * time.Millisecond}
207+ done := make(chan struct{})
208+ go func() { handle.runContainerMonitor(); close(done) }()
209+ select {
210+ case <-started:
211+ case <-ctx.Done():
212+ t.Fatal("monitor did not start")
213+ }
214+ var firstEnd time.Time
215+ select {
216+ case firstEnd = <-finished:
217+ case <-ctx.Done():
218+ t.Fatal("stats did not finish")
219+ }
220+ select {
221+ case secondStart := <-started:
222+ if secondStart.Sub(firstEnd) < 30*time.Millisecond {
223+ t.Fatal("slow stats caused immediate polling")
224+ }
225+ case <-ctx.Done():
226+ t.Fatal("monitor did not poll again")
227+ }
228+ cancel()
229+ select {
230+ case <-done:
231+ case <-time.After(time.Second):
232+ t.Fatal("monitor did not stop")
233+ }
234+}
nixos/pkl.nix created+15
......@@ -0,0 +1,15 @@
1{ stdenv, fetchurl, autoPatchelfHook, zlib }:
2stdenv.mkDerivation {
3 pname = "pkl-native";
4 version = "0.31.1";
5 src = fetchurl {
6 url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-amd64";
7 sha256 = "618f13955d755cafbfe8c9cba1d27635848cd49dbc6abffd398d2751db1231bf";
8 };
9 dontUnpack = true;
10 nativeBuildInputs = [ autoPatchelfHook ];
11 buildInputs = [ zlib ];
12 installPhase = ''
13 install -Dm755 $src $out/bin/pkl
14 '';
15}
nixos/vm.nix created+123
......@@ -0,0 +1,123 @@
1{ lib, pkgs, ... }:
2{
3 networking.hostName = "clover-demo";
4 networking.hostId = "d3c10e01";
5 networking.useDHCP = true;
6 networking.dhcpcd.denyInterfaces = [ "veth*" "podman*" ];
7 environment.variables.STUDIO_JAVA_OPTIONS = "-XX:TieredStopAtLevel=1";
8 environment.variables.STUDIO_INDEX_POOL = "studio-demo";
9 environment.variables.STUDIO_API_TIMEOUT = "600";
10 environment.variables.STUDIO_MEDIA_READ_ONLY = "true";
11 environment.variables.STUDIO_VM_ACCEL = "qemu";
12 services.nomad.settings.client.cpu_total_compute = 16000;
13 services.nomad.settings.client.preferred_address_family = "ipv4";
14 services.nomad.settings.plugin.nomad-driver-podman.config.client_http_timeout = "10m";
15 services.nomad.extraSettingsPlugins = lib.mkForce [
16 (pkgs.nomad-driver-podman.overrideAttrs (old: {
17 patches = (old.patches or [ ]) ++ [ ./nomad-podman-stats.patch ];
18 doCheck = true;
19 checkPhase = ''
20 runHook preCheck
21 go test . ./api -run 'TestNativeContainerStats|TestMonitorWaitsAfterSlowStats'
22 runHook postCheck
23 '';
24 }))
25 ];
26 services.dnsmasq = {
27 enable = true;
28 settings = {
29 interface = "tailscale0";
30 bind-dynamic = true;
31 address = "/studio.test/100.95.50.31";
32 };
33 };
34 services.caddy.virtualHosts."*.studio.test".extraConfig = ''
35 tls internal
36 respond 404
37 '';
38 networking.firewall.interfaces.tailscale0.allowedUDPPorts = [ 53 ];
39 boot.loader.systemd-boot.enable = true;
40 boot.loader.efi.canTouchEfiVariables = true;
41 boot.initrd.availableKernelModules = [ "virtio_pci" "virtio_blk" "virtio_net" ];
42 # Emulation delays skew HPET watchdog reads and trigger a costly clocksource fallback.
43 boot.kernelParams = [ "tsc=nowatchdog" ];
44 boot.zfs.forceImportRoot = false;
45 fileSystems."/" = {
46 device = "/dev/disk/by-label/nixos";
47 fsType = "ext4";
48 };
49 fileSystems."/boot" = {
50 device = "/dev/disk/by-label/EFI";
51 fsType = "vfat";
52 };
53 services.getty.autologinUser = "root";
54 users.groups.chloe.gid = 3000;
55 systemd.services.studio-demo-pool = {
56 wantedBy = [ "multi-user.target" ];
57 before = [ "studio-media-view.service" "nomad.service" ];
58 serviceConfig.Type = "oneshot";
59 script = ''
60 ${pkgs.coreutils}/bin/mkdir -p /var/lib/studio /srv/clover
61 if ! ${pkgs.zfs}/bin/zpool list studio-demo >/dev/null 2>&1; then
62 if test -e /var/lib/studio/zpool.img; then
63 ${pkgs.zfs}/bin/zpool import -d /var/lib/studio studio-demo
64 else
65 ${pkgs.coreutils}/bin/truncate -s 16G /var/lib/studio/zpool.img
66 ${pkgs.zfs}/bin/zpool create -f -o ashift=12 -O mountpoint=none studio-demo /var/lib/studio/zpool.img
67 fi
68 fi
69 if test "$(${pkgs.coreutils}/bin/stat -c %s /var/lib/studio/zpool.img)" -lt 17179869184; then
70 ${pkgs.coreutils}/bin/truncate -s 16G /var/lib/studio/zpool.img
71 ${pkgs.zfs}/bin/zpool online -e studio-demo /var/lib/studio/zpool.img
72 fi
73 if ! ${pkgs.zfs}/bin/zfs list studio-demo/clover >/dev/null 2>&1; then
74 ${pkgs.zfs}/bin/zfs create -o mountpoint=/srv/clover -o acltype=posixacl studio-demo/clover
75 fi
76 test "$(${pkgs.zfs}/bin/zfs get -H -o value mountpoint studio-demo/clover)" = /srv/clover
77 ${pkgs.zfs}/bin/zfs mount studio-demo/clover 2>/dev/null ||
78 ${pkgs.util-linux}/bin/findmnt -n -o SOURCE --mountpoint /srv/clover | ${pkgs.gnugrep}/bin/grep -qx studio-demo/clover
79 ${pkgs.coreutils}/bin/chown 3000:3000 /srv/clover
80 ${pkgs.coreutils}/bin/chmod 2770 /srv/clover
81 '';
82 };
83 systemd.services.studio-media-source = {
84 serviceConfig = {
85 Type = "oneshot";
86 RemainAfterExit = true;
87 TimeoutStartSec = "240s";
88 ExecStop = "${pkgs.util-linux}/bin/umount /mnt/media-source";
89 };
90 script = ''
91 ${pkgs.coreutils}/bin/mkdir -p /mnt/media-source
92 if ${pkgs.util-linux}/bin/findmnt --mountpoint /mnt/media-source >/dev/null; then
93 exit 0
94 fi
95 for attempt in $(${pkgs.coreutils}/bin/seq 1 90); do
96 if ${pkgs.util-linux}/bin/mount -t 9p -o trans=virtio,version=9p2000.L,access=any,ro media /mnt/media-source; then
97 exit 0
98 fi
99 ${pkgs.coreutils}/bin/sleep 2
100 done
101 exit 1
102 '';
103 };
104 systemd.services.studio-media-view = {
105 after = [ "studio-demo-pool.service" "studio-media-source.service" ];
106 requires = [ "studio-demo-pool.service" "studio-media-source.service" ];
107 serviceConfig = {
108 Type = "oneshot";
109 RemainAfterExit = true;
110 CPUWeight = 1000;
111 ExecStartPre = "${pkgs.coreutils}/bin/mkdir -p /srv/clover/Media";
112 ExecStart = "${pkgs.bindfs}/bin/bindfs -r --multithreaded --perms=a+rX /mnt/media-source /srv/clover/Media";
113 ExecStop = "${pkgs.util-linux}/bin/umount /srv/clover/Media";
114 };
115 };
116 systemd.services.nomad = {
117 wants = [ "studio-demo-pool.service" ];
118 requires = [ "studio-media-view.service" ];
119 after = [ "studio-demo-pool.service" "studio-media-view.service" ];
120 };
121
122 system.stateVersion = "26.05";
123}
nixos/zenith.nix created+55
......@@ -0,0 +1,55 @@
1{ pkgs, ... }:
2let
3 adminKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMpxNpkRLTUijhd3HSaOvKYn2MWXEY+YEFdsPNZhBROn clo@sandwich.local";
4in
5{
6 networking.hostName = "zenith";
7 networking.hostId = "4fa19ccb";
8 networking.useDHCP = false;
9 networking.interfaces.enp4s0.useDHCP = false;
10 networking.interfaces.enp4s0.ipv4.addresses = [
11 { address = "10.0.0.1"; prefixLength = 24; }
12 { address = "192.168.0.1"; prefixLength = 24; }
13 ];
14 networking.defaultGateway = { address = "10.0.0.2"; interface = "enp4s0"; };
15 networking.nameservers = [ "1.1.1.1" "1.0.0.1" ];
16 networking.firewall.allowedTCPPorts = [ 80 443 ];
17 networking.firewall.interfaces.enp4s0.allowedTCPPorts = [ 445 ];
18
19 boot.loader.grub.enable = true;
20 boot.loader.grub.device = "/dev/disk/by-id/nvme-WD_Blue_SN5000_500GB_24261Z806200";
21 boot.zfs.forceImportRoot = false;
22 boot.zfs.extraPools = [ "storage1" ];
23 boot.zfs.requestEncryptionCredentials = false;
24
25 services.xserver.videoDrivers = [ "nvidia" ];
26 hardware.nvidia.open = false;
27 hardware.nvidia.nvidiaPersistenced = true;
28 hardware.nvidia.nvidiaSettings = false;
29
30 systemd.services.nomad = {
31 requires = [ "zfs-import-storage1.service" ];
32 after = [ "zfs-import-storage1.service" "zfs-mount.service" ];
33 preStart = ''
34 test "$(${pkgs.util-linux}/bin/findmnt -n -o SOURCE --mountpoint /srv)" = storage1
35 test "$(${pkgs.util-linux}/bin/findmnt -n -o SOURCE --mountpoint /srv/clover)" = storage1/clover
36 test "$(${pkgs.util-linux}/bin/findmnt -n -o SOURCE --mountpoint /srv/clover/Media)" = storage1/clover/Media
37 test "$(${pkgs.util-linux}/bin/findmnt -n -o SOURCE --mountpoint /srv/prod)" = storage1/prod
38 test "$(${pkgs.util-linux}/bin/findmnt -n -o SOURCE --mountpoint /srv/staging)" = storage1/staging
39 '';
40 };
41
42 users.groups.chloe.gid = 3000;
43 users.users.root.openssh.authorizedKeys.keys = [ adminKey ];
44 users.users.clo = {
45 isNormalUser = true;
46 uid = 3000;
47 group = "chloe";
48 extraGroups = [ "wheel" ];
49 openssh.authorizedKeys.keys = [ adminKey ];
50 };
51 environment.variables.STUDIO_POOL = "storage1";
52 environment.variables.STUDIO_DOMAIN = "paperclover.net";
53
54 system.stateVersion = "26.05";
55}
readme.md created+117
......@@ -0,0 +1,117 @@
1# clover's snow globe
2
3This codebase contains declarative configuration to run my home server, a
4computer sitting in my closet used daily to power my activities. It first and
5foremost acts as a file store for my ongoing and archived projects for
6[paper clover]. Secondly, it contains a ton of apps for me and friends to use,
7from Jellyfin and Navidrome for media consumption to git hosting and render
8farms.
9
10The system is built out of a NixOS base (just to install the system itself),
11using managed docker containers (managed via Nomad) and virtual machines to run
12all the real services. There is a lot of custom tooling to spawn interactive
13testing environments. For example, without even pushing any code, I can edit the
14configuration of a service or upgrade it, then spin up an isolated domain like
15`shale-4c84f9da.staging.paperclover.net` to verify changes. Then, pushing to
16production ensures that the new deployment is healthy before routing traffic to
17it -- It's kind of like "easy kubernetes."
18
19[paper clover]: https://paperclover.net
20
21## deployment loop
22
23```sh
24python3 tools/deploy.py stage shale # preview the working files, even without a commit message
25python3 tools/deploy.py stage shale # update the same URL and staging data after another edit
26python3 tools/deploy.py publish # upload the described, conflict-free main commit for GUI review
27python3 tools/deploy.py prod # upload and deploy main directly
28```
29
30Production always deploys a snapshot exported from `main`, never working files
31or a stage. The dashboard's **deploy main** page shows the uploaded commit and
32its message; deploying applies the full repository configuration and retains
33production data. A newer upload invalidates an older deployment confirmation.
34Sibling application build sources declared in `build-source.json` are bundled
35at upload time. Their frozen contents are part of the release digest.
36
37`main` joins the infra-2 and home-infra histories. Its tree contains infra-2;
38the retired configuration remains available in the home-infra parent history.
39
40## filesystem layout
41
42The computer mounts the ZFS root dataset under `/srv`, meaning "server," loosely
43following the linux convention. Within, it's a unique structure:
44
45```
46srv/
47+--- clover/ [dataset] user-data storage, what I mount as /Volumes/clover
48 +--- Archive/<year> personal archive. one folder per year, scrambled within.
49 +--- Asset/ resources, sample packs, audio plugins, stock videos
50 +--- Blender/
51 +--- Font/ (moved from zenith Documents/Font, will be stable index)
52 +--- Samples/
53 +--- Texture/
54 +--- Video/
55 +--- Documents/
56 +--- Project/ currently active project files
57 +--- Media/ [dataset] files from the world-wide-web. managed partially manually
58 +--- jellyfin/
59 +--- mirror/
60 +--- music/ (used by navidrome)
61 +--- music-intake/ (to be manually indexed)
62 +--- seedbox/
63 +--- vm/ (virtual machine images)
64 +--- Published/ source of truth for `paperclover.net/file`
65+--- prod/ [dataset] production application data
66 +--- shale/ [dataset] (each service is its own dataset)
67 +--- ...
68+--- staging/ staging deployments use this space for temporary clones
69 +--- postgres-9f06f74b/ [dataset]
70+--- vm/ [dataset] virtual machines
71 +--- clover-sandbox/ [dataset]
72```
73
74Media is it's own dataset so it can be snapshotted independently of my personal
75data (less frequent, lower retention), and all my personal files are on the same
76dataset to allow fast move/copying between top level folders. Services use their
77own datasets to implement copy-on-write forks.
78
79## testing domains on a Mac
80
81[tools/mac-domains.py](tools/mac-domains.py) routes `.studio.test` and its nested
82subdomains through the rehearsal VM's HTTPS, DNS, and login services. Start the
83rehearsal SSH forwards first, then run:
84
85```sh
86sudo /usr/bin/python3 tools/mac-domains.py start /path/to/rehearsal-ca.crt
87```
88
89The relay binds loopback ports, drops administrator privileges, and passes TLS
90through to the VM. Local UDP DNS queries use the SSH tunnel's TCP DNS connection.
91Existing certificate trust is preserved. Mac DNS and hosts
92settings are backed up before editing; `sudo /usr/bin/python3 tools/mac-domains.py
93stop` restores them and stops the relay. Undo refuses to overwrite later edits.
94After a Mac restart, run stop and start again to restart the relay.
95
96The dashboard package uses `home-dashboard`. Existing state paths, environment
97names, service IDs, and telemetry names keep their old names for compatibility.
98
99## dashboard boundary
100
101NixOS runs `studio-dashboard` in a non-root Podman container with a read-only
102root, private network, resource limits, and explicit data mounts. The small
103`studio-host` service authenticates the dashboard UID on its Unix socket and
104performs bounded ZFS, VM, deployment, host-sampling, and identity operations.
105Host control sockets and management credentials stay outside the container.
106
107The MCP tab manages separate observability, agent, and Shale catalogs through
108the existing Keycloak realm. Each connection has explicit service, machine, or
109repository grants; Shale credentials belong to the signed-in user. Agent Relay's
110existing outbound client protocol connects to the Rust server.
111
112Build the image with `nix build .#dashboard-image` on Linux. Run
113`python3 tools/dashboard-unit-test.py --output /tmp/dashboard-checks.json` on the
114rehearsal VM to exercise the generated NixOS units, containment, IAM, and MCP
115connectors with disposable fixtures. `--relay-agent-dir` includes the existing
116Agent Relay client interoperability check; `--browser-ready-file` temporarily
117routes the public dashboard to the fixture for browser and SSO load checks.
service/clover-source-of-truth/build-source.json created+15
......@@ -0,0 +1,15 @@
1{
2 "source": "../sitegen",
3 "dockerfile": "src/source-of-truth.dockerfile",
4 "include": [
5 "package.json",
6 "package-lock.json",
7 "run.js",
8 "tsconfig.json",
9 "framework",
10 "lib",
11 "src/source-of-truth.ts",
12 "src/friend-auth.ts",
13 "src/file-viewer"
14 ]
15}
service/clover-source-of-truth/icon-dark.svg created+4
......@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="2 2 124 124">
2<path fill="#efeeff" opacity="0.3" d="M47.7 61C18.3 54.1 17.2 36 30.7 23.8C41.5 14 52.5 18.4 52.9 30.5C53.4 23.2 55.9 3.9 72.2 9.8C92.5 17.2 78.1 46.9 71.3 55.3C81.2 44.7 96.8 25.2 113.7 36.9C130.7 48.6 113.2 61.3 109.2 62.4C112.9 63.2 124.8 71.9 113.5 84.4C104.8 93.5 92.6 94.4 67.6 77.5C69.9 85 71.4 93.8 76.4 102.6C79.5 107.9 83.7 112.7 89.4 115.9L89.1 117.5C83.2 112.7 79.2 107.6 76.4 102.6C71.2 93.8 69 83.7 67.6 77.6L67.6 77.5C65.5 87.1 52.5 112.6 37 113.2C20.7 113.9 22.7 99.2 26.7 93.6C19.7 96.9 6.6 98.5 9.7 78.1C12.7 58.1 33.6 57.9 47.7 61Z"/>
3<path fill="#efeeff" stroke="#efeeff" stroke-width="4" stroke-linejoin="round" d="M61.6 7.7C64.7 6.4 68.4 6.3 72.8 8C78.5 10 81.8 13.7 83.4 18.2C85 22.7 84.8 27.7 83.8 32.6C83.2 36 82.1 39.3 80.8 42.5C83.3 40.1 86 37.9 88.8 36.1C92.6 33.6 96.7 31.7 101.1 31.3C105.6 31 110.2 32.1 114.8 35.2C119.3 38.3 121.8 41.7 122.7 45.2C123.5 48.7 122.6 51.9 121.1 54.6C119.5 57.3 117.3 59.5 115.2 61.2C114.7 61.6 114.1 62 113.6 62.4C113.8 62.5 114 62.7 114.3 62.9C115.9 64.1 117.6 65.9 118.8 68.1C119.9 70.3 120.6 73 120.1 76.1C119.6 79.1 118 82.4 115 85.7C115 85.7 115 85.7 114.9 85.7C110.2 90.7 104.4 93.5 96.4 92.7C89.6 92 81.4 88.6 71 82.1C72.7 88.3 74.3 94.9 78.1 101.6L78.7 102.5C81.6 107.2 85.4 111.4 90.3 114.1C91.1 114.6 91.5 115.4 91.3 116.2L91.1 117.8C90.9 118.5 90.5 119.1 89.8 119.4C89.1 119.6 88.4 119.5 87.8 119C81.7 114 77.6 108.8 74.7 103.6L74.2 102.7C70.8 96.7 68.7 90.2 67.3 84.8C65.4 89.4 62.5 94.9 58.9 99.8C56.1 103.8 52.9 107.5 49.2 110.3C45.6 113.1 41.5 115 37.1 115.2C32.7 115.4 29.3 114.5 26.8 112.9C24.3 111.2 22.9 108.8 22.3 106.2C21.6 103.2 21.9 99.9 22.8 97.1C22.2 97.2 21.6 97.3 20.9 97.4C18.6 97.8 15.9 97.7 13.6 96.6C11.2 95.5 9.3 93.4 8.2 90.3C7.1 87.2 6.9 83.1 7.7 77.8C8.5 72.5 10.5 68.3 13.5 65.2C16.4 62.1 20 60.1 24 59C26.8 58.2 29.7 57.7 32.7 57.6C26.6 54.2 22.8 49.8 21.2 45C18.6 37.1 22.3 28.7 29.3 22.3C35.1 17.1 41.3 15.3 46.3 17C48.8 17.9 50.8 19.4 52.2 21.5C52.8 19.1 53.7 16.5 54.9 14.3C56.4 11.5 58.5 9 61.6 7.7ZM71.5 11.7C67.8 10.3 65.1 10.5 63.2 11.4C61.2 12.2 59.6 13.9 58.4 16.2C55.9 20.8 55.2 27 54.9 30.7C54.9 31.7 54 32.5 52.9 32.5C51.9 32.5 51 31.7 51 30.6C50.7 25.1 48.2 21.9 45.1 20.8C41.8 19.7 37.1 20.7 32 25.3C25.6 31.1 23.1 37.9 25 43.8C26.9 49.7 33.7 55.7 48.1 59.1L48.5 59.1C48.6 59.2 48.8 59.2 48.9 59.2H48.9C48.9 59.2 48.9 59.2 48.9 59.2C50 59.5 50.6 60.6 50.4 61.6C50.2 62.7 49.1 63.4 48 63.1C48 63.1 47.9 63.1 47.9 63.1C47.6 63 47.4 63 47.2 62.9C40.3 61.4 32 60.8 25.1 62.8C21.7 63.8 18.7 65.5 16.4 67.9C14.1 70.4 12.4 73.8 11.7 78.4C10.9 83.4 11.2 86.7 12 89C12.7 91.2 13.9 92.3 15.3 93C16.7 93.6 18.5 93.8 20.4 93.5C22.3 93.2 24.2 92.6 25.8 91.8C26.6 91.4 27.6 91.6 28.2 92.3C28.8 93 28.8 94 28.3 94.7C26.6 97.2 25.3 101.7 26.2 105.3C26.6 107 27.5 108.5 29 109.5C30.6 110.6 33.1 111.4 36.9 111.2C40.2 111.1 43.6 109.6 46.8 107.1C50 104.6 53 101.3 55.7 97.5C61 90.1 64.7 81.5 65.6 77L65.7 76.8C65.9 76.3 66.3 75.8 66.8 75.6C67.4 75.4 68.1 75.4 68.7 75.8C81.1 84.2 90.1 88 96.9 88.7C103.4 89.4 108 87.2 112 83C114.7 80.1 115.8 77.6 116.2 75.5C116.5 73.4 116.1 71.5 115.2 69.9C114.4 68.3 113.1 67 111.8 66C110.5 65 109.3 64.5 108.8 64.3C107.9 64.1 107.2 63.3 107.2 62.4C107.2 61.5 107.8 60.7 108.7 60.5C109.3 60.3 110.9 59.5 112.8 58C114.6 56.6 116.4 54.7 117.6 52.6C118.8 50.5 119.3 48.3 118.8 46.1C118.2 43.9 116.5 41.3 112.6 38.5C108.7 35.8 105 35 101.5 35.3C97.9 35.6 94.4 37.1 91 39.4C84 43.9 77.8 51.2 72.8 56.6C72 57.4 70.8 57.5 70 56.8C69.2 56.1 69.1 54.8 69.8 54C73 50.1 78.1 40.9 79.9 31.8C80.8 27.3 80.9 23 79.7 19.6C78.5 16.2 76 13.4 71.5 11.7Z"/>
4</svg>
service/clover-source-of-truth/icon-light.svg created+4
......@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="2 2 124 124">
2<path fill="#18163a" opacity="0.3" d="M47.7 61C18.3 54.1 17.2 36 30.7 23.8C41.5 14 52.5 18.4 52.9 30.5C53.4 23.2 55.9 3.9 72.2 9.8C92.5 17.2 78.1 46.9 71.3 55.3C81.2 44.7 96.8 25.2 113.7 36.9C130.7 48.6 113.2 61.3 109.2 62.4C112.9 63.2 124.8 71.9 113.5 84.4C104.8 93.5 92.6 94.4 67.6 77.5C69.9 85 71.4 93.8 76.4 102.6C79.5 107.9 83.7 112.7 89.4 115.9L89.1 117.5C83.2 112.7 79.2 107.6 76.4 102.6C71.2 93.8 69 83.7 67.6 77.6L67.6 77.5C65.5 87.1 52.5 112.6 37 113.2C20.7 113.9 22.7 99.2 26.7 93.6C19.7 96.9 6.6 98.5 9.7 78.1C12.7 58.1 33.6 57.9 47.7 61Z"/>
3<path fill="#18163a" stroke="#18163a" stroke-width="4" stroke-linejoin="round" d="M61.6 7.7C64.7 6.4 68.4 6.3 72.8 8C78.5 10 81.8 13.7 83.4 18.2C85 22.7 84.8 27.7 83.8 32.6C83.2 36 82.1 39.3 80.8 42.5C83.3 40.1 86 37.9 88.8 36.1C92.6 33.6 96.7 31.7 101.1 31.3C105.6 31 110.2 32.1 114.8 35.2C119.3 38.3 121.8 41.7 122.7 45.2C123.5 48.7 122.6 51.9 121.1 54.6C119.5 57.3 117.3 59.5 115.2 61.2C114.7 61.6 114.1 62 113.6 62.4C113.8 62.5 114 62.7 114.3 62.9C115.9 64.1 117.6 65.9 118.8 68.1C119.9 70.3 120.6 73 120.1 76.1C119.6 79.1 118 82.4 115 85.7C115 85.7 115 85.7 114.9 85.7C110.2 90.7 104.4 93.5 96.4 92.7C89.6 92 81.4 88.6 71 82.1C72.7 88.3 74.3 94.9 78.1 101.6L78.7 102.5C81.6 107.2 85.4 111.4 90.3 114.1C91.1 114.6 91.5 115.4 91.3 116.2L91.1 117.8C90.9 118.5 90.5 119.1 89.8 119.4C89.1 119.6 88.4 119.5 87.8 119C81.7 114 77.6 108.8 74.7 103.6L74.2 102.7C70.8 96.7 68.7 90.2 67.3 84.8C65.4 89.4 62.5 94.9 58.9 99.8C56.1 103.8 52.9 107.5 49.2 110.3C45.6 113.1 41.5 115 37.1 115.2C32.7 115.4 29.3 114.5 26.8 112.9C24.3 111.2 22.9 108.8 22.3 106.2C21.6 103.2 21.9 99.9 22.8 97.1C22.2 97.2 21.6 97.3 20.9 97.4C18.6 97.8 15.9 97.7 13.6 96.6C11.2 95.5 9.3 93.4 8.2 90.3C7.1 87.2 6.9 83.1 7.7 77.8C8.5 72.5 10.5 68.3 13.5 65.2C16.4 62.1 20 60.1 24 59C26.8 58.2 29.7 57.7 32.7 57.6C26.6 54.2 22.8 49.8 21.2 45C18.6 37.1 22.3 28.7 29.3 22.3C35.1 17.1 41.3 15.3 46.3 17C48.8 17.9 50.8 19.4 52.2 21.5C52.8 19.1 53.7 16.5 54.9 14.3C56.4 11.5 58.5 9 61.6 7.7ZM71.5 11.7C67.8 10.3 65.1 10.5 63.2 11.4C61.2 12.2 59.6 13.9 58.4 16.2C55.9 20.8 55.2 27 54.9 30.7C54.9 31.7 54 32.5 52.9 32.5C51.9 32.5 51 31.7 51 30.6C50.7 25.1 48.2 21.9 45.1 20.8C41.8 19.7 37.1 20.7 32 25.3C25.6 31.1 23.1 37.9 25 43.8C26.9 49.7 33.7 55.7 48.1 59.1L48.5 59.1C48.6 59.2 48.8 59.2 48.9 59.2H48.9C48.9 59.2 48.9 59.2 48.9 59.2C50 59.5 50.6 60.6 50.4 61.6C50.2 62.7 49.1 63.4 48 63.1C48 63.1 47.9 63.1 47.9 63.1C47.6 63 47.4 63 47.2 62.9C40.3 61.4 32 60.8 25.1 62.8C21.7 63.8 18.7 65.5 16.4 67.9C14.1 70.4 12.4 73.8 11.7 78.4C10.9 83.4 11.2 86.7 12 89C12.7 91.2 13.9 92.3 15.3 93C16.7 93.6 18.5 93.8 20.4 93.5C22.3 93.2 24.2 92.6 25.8 91.8C26.6 91.4 27.6 91.6 28.2 92.3C28.8 93 28.8 94 28.3 94.7C26.6 97.2 25.3 101.7 26.2 105.3C26.6 107 27.5 108.5 29 109.5C30.6 110.6 33.1 111.4 36.9 111.2C40.2 111.1 43.6 109.6 46.8 107.1C50 104.6 53 101.3 55.7 97.5C61 90.1 64.7 81.5 65.6 77L65.7 76.8C65.9 76.3 66.3 75.8 66.8 75.6C67.4 75.4 68.1 75.4 68.7 75.8C81.1 84.2 90.1 88 96.9 88.7C103.4 89.4 108 87.2 112 83C114.7 80.1 115.8 77.6 116.2 75.5C116.5 73.4 116.1 71.5 115.2 69.9C114.4 68.3 113.1 67 111.8 66C110.5 65 109.3 64.5 108.8 64.3C107.9 64.1 107.2 63.3 107.2 62.4C107.2 61.5 107.8 60.7 108.7 60.5C109.3 60.3 110.9 59.5 112.8 58C114.6 56.6 116.4 54.7 117.6 52.6C118.8 50.5 119.3 48.3 118.8 46.1C118.2 43.9 116.5 41.3 112.6 38.5C108.7 35.8 105 35 101.5 35.3C97.9 35.6 94.4 37.1 91 39.4C84 43.9 77.8 51.2 72.8 56.6C72 57.4 70.8 57.5 70 56.8C69.2 56.1 69.1 54.8 69.8 54C73 50.1 78.1 40.9 79.9 31.8C80.8 27.3 80.9 23 79.7 19.6C78.5 16.2 76 13.4 71.5 11.7Z"/>
4</svg>
service/clover-source-of-truth/service.pkl created+36
......@@ -0,0 +1,36 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5meta { name = "Clover DB" }
6
7local isTest = site.domain.endsWith(".test")
8
9secrets = if (isTest) new { ["key"] {} } else new {}
10requiredSecrets = if (isTest) new {} else new { "key" }
11
12container {
13 build = "build"
14 cpu = 1000
15 memory = 1024
16
17 http {
18 containerPort = 8080
19 subdomain = "db"
20 }
21
22 volumes {
23 ["/data"] {}
24 ["/published"] { src = "\(site.cloverRoot)/Published" }
25 }
26
27 env {
28 ["PORT"] = "8080"
29 ["CLOVER_DB"] = "/data"
30 ["CLOVER_FILE_RAW"] = "/published"
31 ["CLOVER_FILE_DERIVED"] = "/data/derived"
32 ["CLOVER_SOT_KEY"] = "${secret.own.key}"
33 ["CLOVER_INDEX_CORES"] = "4"
34 ["CLOVER_INDEXER"] = if (site.pool == "studio-demo") "0" else "1"
35 }
36}
service/copyparty/copyparty.conf created+57
......@@ -0,0 +1,57 @@
1[global]
2 ansi
3 p: 3923
4 shr: /shr
5 hist: /cfg/hists
6 df: STUDIO_MIN_FREE_GB
7 ver
8 theme: 2
9 name: clover's nas
10 stats, nos-dup
11 dav-auth
12 ah-alg: argon2
13 xbu: c,/hooks/reject-apple-cruft.py
14
15 xff-src: lan
16 rproxy: 1
17 idp-h-usr: user-name
18 idp-h-grp: user-groups
19 idp-h-key: STUDIO_IDP_HEADER
20 idp-login: /snow.oauth2/sign_in?rd={dst}
21 idp-logout: /snow.oauth2/sign_out
22 idp-login-t: with sso (snow sign on)
23 html-head: <link rel="stylesheet" href="/.static/copyparty.css">
24
25[/]
26 /w
27 accs:
28 A: snow
29[/clover]
30 /w/clover
31 accs:
32 A: snow
33 flags:
34 chmod_f: 664
35 chmod_d: 775
36[/media]
37 /w/media
38 accs:
39 rwmd.: snow, rain, fish, akira, @media-manage
40 r: @media
41 flags:
42 chmod_f: 664
43 chmod_d: 775
44[/clover/Public]
45 /w/clover/Public
46 accs:
47 r.: *
48 rwm: @acct
49 rwmd: snow
50 flags:
51 chmod_f: 664
52 chmod_d: 775
53[/logs]
54 /w/logs
55 accs:
56 rwmda.: snow
57 g: *
service/copyparty/icon.svg created+210
......@@ -0,0 +1,210 @@
1<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2<svg
3 width="300mm"
4 height="207mm"
5 viewBox="0 0 300 207"
6 version="1.1"
7 id="svg1"
8 inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
9 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
10 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
11 xmlns:xlink="http://www.w3.org/1999/xlink"
12 xmlns="http://www.w3.org/2000/svg"
13 xmlns:svg="http://www.w3.org/2000/svg"
14 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
15 xmlns:cc="http://creativecommons.org/ns#"
16 xmlns:dc="http://purl.org/dc/elements/1.1/">
17 <title
18 id="title1">copyparty_logo</title>
19 <defs
20 id="defs1">
21 <linearGradient
22 inkscape:collect="always"
23 id="linearGradient1">
24 <stop
25 style="stop-color:#ffcc55;stop-opacity:1"
26 offset="0"
27 id="stop1" />
28 <stop
29 style="stop-color:#ffcc00;stop-opacity:1"
30 offset="0.2"
31 id="stop2" />
32 <stop
33 style="stop-color:#ff8800;stop-opacity:1"
34 offset="1"
35 id="stop3" />
36 </linearGradient>
37 <linearGradient
38 inkscape:collect="always"
39 xlink:href="#linearGradient1"
40 id="linearGradient2"
41 x1="15"
42 y1="15"
43 x2="15"
44 y2="143"
45 gradientUnits="userSpaceOnUse" />
46 </defs>
47 <metadata
48 id="metadata5">
49 <rdf:RDF>
50 <cc:Work
51 rdf:about="">
52 <dc:format>image/svg+xml</dc:format>
53 <dc:type
54 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
55 <dc:title>copyparty_logo</dc:title>
56 <dc:source>github.com/9001/copyparty</dc:source>
57 </cc:Work>
58 </rdf:RDF>
59 </metadata>
60 <g
61 inkscape:groupmode="layer"
62 id="layer1"
63 inkscape:label="kassett">
64 <rect
65 style="fill:#333333"
66 id="rect1"
67 width="300"
68 height="205"
69 x="0"
70 y="0"
71 rx="12"
72 ry="12" />
73 <rect
74 style="fill:url(#linearGradient2)"
75 id="rect2"
76 width="270"
77 height="128"
78 x="15"
79 y="15"
80 rx="8"
81 ry="8" />
82 <rect
83 style="fill:#333333"
84 id="rect3"
85 width="172"
86 height="52"
87 x="64"
88 y="72"
89 rx="26"
90 ry="26" />
91 <circle
92 style="fill:#cccccc"
93 id="circle1"
94 cx="91"
95 cy="98"
96 r="18" />
97 <circle
98 style="fill:#cccccc"
99 id="circle2"
100 cx="209"
101 cy="98"
102 r="18" />
103 <path
104 style="fill:#737373;stroke-width:1px"
105 d="m 48,207 10,-39 c 1.79,-6.2 5.6,-7.8 12,-8 60,-1 100,-1 160,0 6.4,0.2 10,1.8 12,8 l 10,39 z"
106 id="path1"
107 sodipodi:nodetypes="ccccccc" />
108 </g>
109 <g
110 inkscape:groupmode="layer"
111 id="layer3"
112 inkscape:label="tekst"
113 style="display:none">
114 <text
115 xml:space="preserve"
116 style="font-size:38.8056px;line-height:1.25;font-family:Akbar;-inkscape-font-specification:Akbar;letter-spacing:3.70417px;word-spacing:0px;fill:#333333"
117 x="47.153069"
118 y="55.548954"
119 id="text1"><tspan
120 sodipodi:role="line"
121 id="tspan1"
122 x="47.153069"
123 y="55.548954"
124 style="-inkscape-font-specification:Akbar"
125 rotate="0 0">copyparty</tspan></text>
126 </g>
127 <g
128 inkscape:groupmode="layer"
129 id="layer4"
130 inkscape:label="stensatt">
131 <path
132 d="m 63.5,50.9 q -0.85,0.93 -4.73,2.3 -3.6,1.3 -4.4,1.3 -3.3,0 -5.1,-2.1 -1.75,-2 -1.75,-5.36 0,-4.6 3.76,-7.64 3.3,-2.7 7.3,-2.7 0.4,0 0.93,0.74 0.54,0.7 0.54,1.16 0,2.06 -2.2,2.7 -1.36,0.4 -4.04,1.16 -2.2,1.16 -2.2,4.4 0,3.2 2.9,3.2 0.85,0 0.85,0 0.54,0 1.44,-0.16 1.1,-0.23 2.9,-0.74 1.8,-0.54 2.13,-0.54 0.4,0 1.75,0.6 z"
133 style="fill:#333333"
134 id="path11" />
135 <path
136 d="m 87.6,45 q 0,4.2 -3.7,6.95 -3.2,2.3 -6.87,2.3 -3.4,0 -6,-2.6 -2.5,-2.6 -2.5,-6 0,-3.6 3.14,-6.64 3.2,-3 6.8,-3 3.5,0 6.3,2.76 2.83,2.76 2.83,6.25 z m -3.4,0.16 q 0,-2.25 -1.75,-3.7 -1.7,-1.5 -4,-1.5 -0.1,0 -1.6,1.6 -1.44,1.55 -2.44,1.55 -0.6,0 -0.8,-0.3 -1.16,2.3 -1.16,3 0,2.25 2.13,3.4 1.6,0.9 3.6,0.9 2,0 3.76,-1.1 2.25,-1.4 2.25,-3.84 z"
137 style="fill:#333333"
138 id="path12" />
139 <path
140 d="m 112.8,46.8 q 0,2.8 -1.9,4.4 -1.8,1.5 -4.7,1.5 -0.7,0 -2.7,-0.4 -1.9,-0.4 -2.6,-0.4 -2.1,0 -2.1,2.64 0,0.85 0.23,2.6 0.2,1.75 0.2,2.6 0,1.9 -0.77,2.83 -1.44,0 -3,-0.85 -1.46,-9.5 -1.46,-12 0,-3.65 1.75,-8.1 2.37,-6.05 6.45,-6.05 3.7,0 7.3,4.1 3.3,3.84 3.3,7.14 z m -3.8,0.2 q -0.6,-2.2 -2.6,-4.4 -2.3,-2.5 -4.3,-2.5 -1.3,0 -2.33,2.2 -0.9,1.8 -0.9,3.26 0,0.47 0.38,1.24 0.43,0.8 0.85,0.8 1.1,0 3.2,0.3 2.1,0.3 3.2,0.3 0.3,0 1.3,-0.4 1,-0.47 1.3,-0.74 z"
141 style="fill:#333333"
142 id="path13" />
143 <path
144 d="m 133,40 q -2.1,4.1 -3.2,7 -0.1,0.3 -1.6,4.5 -0.4,1.36 -1,4.2 -0.5,2.83 -1,4.2 -1,2.83 -2.3,2.64 -1.4,-0.2 -1.6,-1.6 0,-0.2 0,-0.5 0,-0.16 0.3,-1.5 1,-5.04 1,-6.44 0,-0.54 -0.1,-0.74 -1.4,-2.44 -4.1,-7.4 -2.7,-4.97 -2.4,-7.7 1.5,-1.36 2.1,-1.36 0.4,0 1.1,0.6 0.6,0.6 0.7,1.1 0.8,6.2 4.9,11.1 1,-1.8 1.8,-4.04 0.5,-1.4 1.6,-4.15 1.9,-4.46 3.4,-4.46 0.2,0 0.4,0.1 0.9,0.3 1.3,2.8 z"
145 style="fill:#333333"
146 id="path14" />
147 <path
148 d="m 157.5,48 q 0,2.8 -1.9,4.4 -1.8,1.5 -4.7,1.5 -0.7,0 -2.7,-0.4 -1.9,-0.4 -2.6,-0.4 -2,0 -2,2.64 0,0.85 0.2,2.6 0.2,1.75 0.2,2.6 0,1.9 -0.7,2.83 -1.5,0 -3,-0.85 -1.5,-9.5 -1.5,-11.95 0,-3.65 1.8,-8.1 2.3,-6.05 6.4,-6.05 3.7,0 7.2,4.1 3.3,3.84 3.3,7.14 z m -3.8,0.2 q -0.6,-2.2 -2.6,-4.4 -2.3,-2.5 -4.3,-2.5 -1.3,0 -2.3,2.2 -0.9,1.8 -0.9,3.26 0,0.47 0.4,1.24 0.4,0.8 0.8,0.8 1.1,0 3.2,0.3 2.1,0.3 3.2,0.3 0.3,0 1.3,-0.4 1,-0.47 1.3,-0.74 z"
149 style="fill:#333333"
150 id="path15" />
151 <path
152 d="m 182,53.3 q 0,0.9 -0.6,1.5 -0.6,0.6 -1.4,0.6 -1.6,0 -3,-0.9 -1.4,-0.93 -2.1,-2.3 -0.7,-0.1 -1.5,0.85 -0.9,1.16 -1.1,1.24 -1.2,0.54 -3.9,0.54 -2.2,0 -3.9,-2.44 -1.5,-2.13 -1.5,-4 0,-3.4 3.4,-6.4 3.2,-2.9 6.7,-2.9 0.9,0 1.7,0.6 0.8,0.6 0.8,1.44 0,0.54 -0.4,1.1 2.4,0.9 2.4,2.83 0,0.35 -0.1,1.05 -0.1,0.7 -0.1,1.05 0,0.4 0.1,0.6 0.5,1.3 2.5,3.4 1.9,1.9 1.9,2.2 z m -8.1,-10.1 q -0.4,0 -1.1,-0.1 -0.8,-0.16 -1.1,-0.16 -1.3,0 -3.2,1.94 -1.9,1.94 -1.9,3.3 0,0.8 0.7,1.8 0.9,1.3 2.2,1.3 2.6,0 3.5,-2.9 0.5,-2.6 1,-5.16 z"
153 style="fill:#333333"
154 id="path16" />
155 <path
156 d="m 203.8,42.4 q -0.4,0.4 -1.5,0.4 -0.9,0 -2.5,-0.3 -1.7,-0.3 -2.5,-0.3 -4.7,0 -5.5,6.9 -0.3,3.1 -0.4,3.3 -0.4,1 -1.7,2.3 h -1.1 q -0.7,-1.2 -1.3,-4.1 -0.6,-2.76 -0.6,-4.27 0,-1.16 0.1,-1.5 0.2,-0.54 1,-0.54 0.3,0 0.6,0.3 0.4,0.3 0.4,0.3 1.9,-3.53 3.1,-4.6 1.8,-1.7 5.1,-1.7 1.4,0 3.6,0.9 2.8,1.16 3.3,2.8 z"
157 style="fill:#333333"
158 id="path17" />
159 <path
160 d="m 229.5,37.16 q 0.3,0.8 0.3,1.44 0,1.86 -2.4,1.86 -1,0 -3.5,-0.5 -2.5,-0.54 -3.4,-0.54 -1.3,0 -1.5,0.1 -0.4,0.2 -0.4,1.2 0,2.2 0.6,6.9 0.7,5.86 1.6,6.13 -0.4,0.35 -0.4,1.1 -1.2,0.7 -2.6,0.7 -1.4,0 -2,-3.9 -0.2,-1.36 -0.5,-7.76 -0.2,-4.6 -0.8,-5.5 -0.3,-0.47 -4.3,-0.35 -1,0 -1.6,0.1 -0.5,0 -0.3,0 -0.8,0 -1.2,-0.7 -0.5,-1.3 -0.5,-1.4 0,-1.44 4.1,-2 1.6,-0.16 4.7,-0.5 0,-0.85 -0.1,-2.56 0,-1.75 0,-2.6 0,-4.35 2.1,-4.35 0.5,0 1.1,0.6 0.6,0.6 0.6,1.1 v 7.9 q 1.1,1.2 5,1.7 3.9,0.5 5.3,1.86 z"
161 style="fill:#333333"
162 id="path18" />
163 <path
164 d="m 251.2,40.2 q -2,4.1 -3.2,7 -0.1,0.3 -1.5,4.5 -0.5,1.36 -1,4.2 -0.5,2.83 -1,4.2 -1,2.83 -2.4,2.64 -1.4,-0.2 -1.5,-1.6 -0.1,-0.2 -0.1,-0.5 0,-0.16 0.3,-1.5 1.1,-5.04 1.1,-6.44 0,-0.54 -0.1,-0.74 -1.4,-2.44 -4.1,-7.4 -2.7,-4.97 -2.4,-7.7 1.4,-1.36 2.1,-1.36 0.4,0 1,0.6 0.6,0.6 0.7,1.1 0.9,6.2 4.9,11.1 1,-1.8 1.9,-4.04 0.5,-1.4 1.6,-4.15 1.8,-4.46 3.4,-4.46 0.2,0 0.4,0.1 0.8,0.3 1.2,2.8 z"
165 style="fill:#333333"
166 id="path19" />
167 </g>
168 <g
169 inkscape:groupmode="layer"
170 id="layer5"
171 inkscape:label="tagger">
172 <g
173 id="g1">
174 <path
175 id="path4"
176 style="fill:#333333"
177 d="m 111.4,83.335 -9.526,5.5 2.5,4.33 9.526,-5.5 z m -33.775,19.5 -9.526,5.5 2.5,4.33 9.526,-5.5 z"
178 sodipodi:nodetypes="cccccccccc" />
179 <path
180 id="path5"
181 style="fill:#333333"
182 d="M 88.5,73 V 84 h 5 V 73 Z m 0,39 v 11 h 5 V 112 Z"
183 sodipodi:nodetypes="cccccccccc" />
184 <path
185 id="path6"
186 style="fill:#333333"
187 d="m 68.1,87.665 9.526,5.5 2.5,-4.33 -9.526,-5.5 z m 33.775,19.5 9.527,5.5 2.5,-4.33 -9.527,-5.5 z"
188 sodipodi:nodetypes="cccccccccc" />
189 </g>
190 <g
191 id="g2"
192 transform="rotate(30,150,318.19)">
193 <path
194 id="path7"
195 style="fill:#333333"
196 d="m 111.4,83.335 -9.526,5.5 2.5,4.33 9.526,-5.5 z m -33.775,19.5 -9.526,5.5 2.5,4.33 9.526,-5.5 z"
197 sodipodi:nodetypes="cccccccccc" />
198 <path
199 id="path8"
200 style="fill:#333333"
201 d="M 88.5,73 V 84 h 5 V 73 Z m 0,39 v 11 h 5 V 112 Z"
202 sodipodi:nodetypes="cccccccccc" />
203 <path
204 id="path9"
205 style="fill:#333333"
206 d="m 68.1,87.665 9.526,5.5 2.5,-4.33 -9.526,-5.5 z m 33.775,19.5 9.527,5.5 2.5,-4.33 -9.527,-5.5 z"
207 sodipodi:nodetypes="cccccccccc" />
208 </g>
209 </g>
210</svg>
service/copyparty/prepare.py created+39
......@@ -0,0 +1,39 @@
1#!/usr/bin/env python3
2import json
3import os
4from pathlib import Path
5import re
6import subprocess
7import sys
8
9
10data = json.load(sys.stdin)
11root = Path(data["hostRoot"]) / "cfg"
12logs = Path(data["hostRoot"]) / "w/logs"
13logs.mkdir(parents=True, exist_ok=True)
14os.chown(logs, data["uid"], data["uid"])
15service = Path(data["hostRoot"]).name
16secret = json.loads(subprocess.check_output(
17 ["nomad", "var", "get", "-out=json", f"nomad/jobs/{service}"],
18 text=True, stderr=subprocess.DEVNULL,
19))["Items"]["idp_header"]
20if not re.fullmatch(r"[0-9a-f]{32}", secret):
21 raise ValueError("invalid Copyparty identity secret")
22config = Path(__file__).with_name("copyparty.conf").read_text()
23if config.count("STUDIO_IDP_HEADER") != 1:
24 raise ValueError("expected one Copyparty identity header marker")
25config = config.replace("STUDIO_IDP_HEADER", "X-Studio-Idp-" + secret)
26if config.count("STUDIO_MIN_FREE_GB") != 1:
27 raise ValueError("expected one Copyparty free-space marker")
28capacity = os.statvfs(data["hostRoot"])
29reserve = max(1, min(16, capacity.f_blocks * capacity.f_frsize // (4 * 10**9)))
30config = config.replace("STUDIO_MIN_FREE_GB", str(reserve))
31target = root / "copyparty.conf"
32if not target.exists() or target.read_text() != config:
33 pending = root / ".copyparty.conf.pending"
34 pending.write_text(config)
35 os.chmod(pending, 0o600)
36 os.chown(pending, data["uid"], data["uid"])
37 os.replace(pending, target)
38os.chmod(target, 0o600)
39os.chown(target, data["uid"], data["uid"])
service/copyparty/reject-apple-cruft.py created+14
......@@ -0,0 +1,14 @@
1#!/usr/bin/env python3
2import os
3import sys
4
5
6BAD_EXACT = {
7 ".DS_Store", ".localized", ".Spotlight-V100", ".TemporaryItems",
8 ".Trashes", ".fseventsd", ".apdisk", ".metadata_never_index",
9 ".metadata_never_index_unless_rootfs", ".metadata_direct_scope_only",
10 ".hidden",
11}
12
13name = os.path.basename(sys.argv[1])
14sys.exit(1 if name.startswith("._") or name in BAD_EXACT else 0)
service/copyparty/service.pkl created+41
......@@ -0,0 +1,41 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5meta { name = "Copyparty" }
6rollout = "simple"
7prepare = "prepare.py"
8
9secrets {
10 ["idp_header"] { bytes = 16 }
11}
12
13container {
14 image = "docker.io/copyparty/ac@sha256:9658d60c7fec0bffb968cacc677712be3c8a6e73bb388b6b47b7f2b178a754a6"
15 cpu = 200
16 memory = 512
17
18 http {
19 containerPort = 3923
20 subdomain = "file"
21 checkPath = "/?h"
22 identityHeaders {
23 ["User-Name"] = "X-Auth-Request-Preferred-Username"
24 ["User-Groups"] = "X-Auth-Request-Groups"
25 }
26 identityProofSecret = "idp_header"
27 plainHostnames { "dav.\(site.domain)" }
28 overrideFiles {
29 ["/.static/"] = "static"
30 }
31 }
32
33 volumes {
34 ["/cfg"] {}
35 ["/w"] {}
36 ["/hooks/reject-apple-cruft.py"] { config = "reject-apple-cruft.py" }
37 ["/w/clover"] { src = site.cloverRoot; readOnly = site.cloverReadOnly }
38 ["/w/clover/Media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
39 ["/w/media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
40 }
41}
service/copyparty/static/copyparty.css created+9
......@@ -0,0 +1,9 @@
1@import "/.static/font.css";
2#opa_msg, #repl {
3 display: none;
4}
5:root {
6 --font-main: "Name Sans", sans-serif;
7 --font-serif: "Name Sans", serif;
8 --font-mono: "Name Mono", monospace;
9}
service/copyparty/static/font.css created+45
......@@ -0,0 +1,45 @@
1@font-face {
2 font-family: "Name Mono";
3 src: url("./nm.woff2") format("woff2");
4 font-weight: 400;
5 font-style: normal;
6 font-display: swap;
7}
8
9@font-face {
10 font-family: "Name Mono";
11 src: url("./nmb.woff2") format("woff2");
12 font-weight: 700;
13 font-style: normal;
14 font-display: swap;
15}
16
17@font-face {
18 font-family: "Name Mono";
19 src: url("./nmi.woff2") format("woff2");
20 font-weight: 400;
21 font-style: italic;
22 font-display: swap;
23}
24
25@font-face {
26 font-family: "Name Sans";
27 src: url("./nsv.woff2") format("woff2") tech(variations);
28 font-weight: 100 900;
29 font-style: normal;
30 font-display: swap;
31 font-stretch: 75% 125%;
32}
33
34@font-face {
35 font-family: "Name Sans";
36 src: url("./nsvi.woff2") format("woff2") tech(variations);
37 font-weight: 100 900;
38 font-style: italic;
39 font-display: swap;
40 font-stretch: 75% 125%;
41}
42
43body {
44 font-family: "Name Sans", sans-serif;
45}
service/copyparty/static/nm.woff2 created
Binary files /dev/null and b/service/copyparty/static/nm.woff2 differ
service/copyparty/static/nmb.woff2 created
Binary files /dev/null and b/service/copyparty/static/nmb.woff2 differ
service/copyparty/static/nmi.woff2 created
Binary files /dev/null and b/service/copyparty/static/nmi.woff2 differ
service/copyparty/static/nsv.woff2 created
Binary files /dev/null and b/service/copyparty/static/nsv.woff2 differ
service/copyparty/static/nsvi.woff2 created
Binary files /dev/null and b/service/copyparty/static/nsvi.woff2 differ
service/dawarich/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="94.2 0.12 323.76 511.98"><style>.st44{fill:#87a3bd}</style><path d="M303.4 237.8v-5.9c0-3.1 0-6.2-.1-9.3v-9c0-3.2-.1-6.3-.2-9.5-1.5-1.4-1.5-1.4-2.3-4-.4-1.1-.7-2.2-1.1-3.3-.5-1.7-.5-1.7-1.1-3.3-.3-.9-.5-1.7-.8-2.5h-2.6c0-5.3 0-5.3-3.1-9.1-1.2-1.3-2.3-2.7-3.4-4-1.7-4.2-2.8-6.8-5.7-10.3-1.9-2.1-3.5-4.1-4.9-6.6-1.2-2.3-2.5-4.5-3.8-6.7-1.3-.4-2.6-.9-3.9-1.3v-2.6c-2.7-1.8-2.7-1.8-6.6-3.9-3.2-1.6-6.3-3.2-9.2-5.3-3.4-4.7-4.3-6.3-10.1-7.9-3.2-.6-5.4-1.2-8.2-2.8-7.2-4.7-13.1-5.9-21.6-6.2h-4.4c-12.6-.3-27.2-2.1-38.9 3.1-3.5 2.3-3.5 2.3-8.5 2.9-1.4.1-2.7.3-4.1.4-.4 1.3-.9 2.6-1.3 3.9-7.9 0-12.8 4-18.4 9.2-2.2 2.2-4.3 4.4-6.6 6.6-1.3.4-2.6.9-3.9 1.3-.4.9-.9 1.8-1.3 2.6-3.4 1.5-5 2.1-6.9 5.4-.3.8-.7 1.6-1 2.5h-2.6c-2.7 3-4 5.4-5.3 9.2-.4 1.3-.9 2.6-1.3 3.9-2.6 3.5-4.7 6.7-6.5 10.7-.9 2.1-1.8 4.3-2.7 6.4-4.5 9.1-5.9 16.7-5.8 26.8 0 1.6.1 3.2.1 4.8 0 5.7 0 11.3.4 17 1.6 1.3 1.6 1.3 1.8 4.1 1 6.5 2.2 12.4 4.7 18.5 1.3 3 2.5 5.9 3.5 9 .6 2.2 1.2 4.3 1.8 6.5.9 1.3 1.8 2.6 2.6 3.9 1.2 3 2.4 6.1 3.9 9 1.9 3.6 3.7 6.8 4.4 10.8.4 4.9.8 7.7 3.4 11.9 2.9 3.7 2.9 3.7 4.2 8.1.6 1.8.6 1.8 1.1 3.5.9.4 1.8.9 2.6 1.3 2 6.6 4.9 11.9 8.5 17.8.7 1.1 1.4 2.2 2 3.2l3.9 6.6c3.7 5.4 6.8 9.8 7.6 16.4.1 1.6.2 3.1.2 4.6h2.6c.5 3.9.5 3.9 1.3 7.9.9.4 1.8.9 2.6 1.3.4 1.3.9 2.6 1.3 3.9.9.4 1.8.9 2.6 1.3.4 2.2.9 4.4 1.3 6.6.9.4 1.8.9 2.6 1.3.8 2.4.8 2.4 1.2 5.3.5 2.9.5 2.9 1.4 5.3 1.3.4 2.6.9 3.9 1.3.4.9.9 1.8 1.3 2.6 1 3.6 2.1 7 4 10.2 4.4 6.7 7.5 13.5 10.5 20.9.6 1.6.6 1.6 1.2 3.1h3.9c.7 3.3.7 3.3 1.3 6.6h6.6c1.3-2.7 2.6-5.3 3.9-7.9 3.1-4.7 6-9.3 8.7-14.2 1-1.9 2-3.7 3.1-5.5.9-.4 1.8-.9 2.6-1.3.4-1.6.9-3.1 1.3-4.6.4-1.6.9-3.1 1.3-4.6.9-.4 1.8-.9 2.6-1.3.2-1.1.4-2.2.7-3.3.2-1.1.4-2.2.7-3.3.9-.4 1.8-.9 2.6-1.3.8-4.4 1.6-7.1 3.9-11.1 1.3-2.1 1.3-2.1 2.7-4.7 1.4-4.1 2.7-8.1 4.5-12 .7-1.4 1.4-2.8 2.1-4.1q1.8-3.75 3.9-7.5c.9-.4 1.8-.9 2.6-1.3.2-1.1.4-2.1.7-3.2.2-1.1.4-2.3.7-3.4 5.4-5.4 8.5-13.3 10.6-20.5 2.1-7.4 5.7-13.8 9.8-20.3 2.1-3.2 4-6.4 5.9-9.8 1.4-2.5 2.8-4.9 4.3-7.2 2.7-4.1 4.8-7.7 6.4-12.4.4-1.1.7-2.3 1.1-3.4 1.3-.7 1.3-.7 2.6-1.3.3-1.4.5-2.7.8-4 .2-1.9.2-1.9.5-3.9h2.6c.1-1.5.1-1.5.2-3.1.1-1.4.2-2.7.3-4 .2-3.9.2-3.9.8-7.4 2.6-2.6 2.6-2.6 3.3-7.2.2-1.6.4-3.1.7-4.6 2.9-2.9 2.9-2.9 3-9.5.5-.9.4-1.8.4-2.8m-72.5-.8c-2 4.9-4.4 9.2-7.9 13.1h-2.6v2.6c-6.7 5.9-12.7 6.3-21.4 6.2h-3.7c-7.4-.2-12.2-1-17.9-6.1-4.2-4.7-4.2-4.7-4.2-8h-2.6v-2.6H168c-3.4-5.1-3-10.5-3-16.4v-3c0-7.5.9-11.1 5.9-16.6 1.2-1.1 2.4-2.2 3.6-3.4 1.2-1.1 2.4-2.3 3.6-3.4 10.8-8.8 26.4-10 38.4-3.1.7 2 .7 2 1.3 3.9 1.3.4 2.6.9 3.9 1.3 1.7 2.2 1.7 2.2 3.4 4.9.5.9 1.1 1.8 1.6 2.7 4.9 9.2 5.5 17.7 4.2 27.9" style="fill:#508ac7"/><path d="M241.4 348.7h2.6c-2.6 5.3-2.6 5.3-3.9 7.6-1.5 3.1-1.5 3.1-2.6 7.6-1.3 4.4-1.3 4.4-3.9 7.1-.2.9-.4 1.8-.7 2.7-.2.8-.4 1.7-.7 2.5-.9.4-1.7.9-2.6 1.3-2.4 4.1-3.9 7.4-4.6 12.1-.7 4.1-1.4 5-4.6 7.6l-2.9 2.9-2.4 2.4c2.5-7.6 2.5-7.6 3.9-10.5h2.6l.3-3.3c.8-3.8 1.6-6.1 3.3-9.5l1.4-2.8c.5-.9 1-1.9 1.5-2.9.9-1.8 1.8-3.7 2.7-5.5 1.3-2.7 1.3-2.7 2.7-5.4.5-.9.9-1.8 1.4-2.8 2.1-3.8 4.1-7.5 6.5-11.1" style="fill:#487bb6"/><path d="M292.6 192.4c.4.8.7 1.5 1.1 2.3 1.4 2.9 1.4 2.9 3.2 5.5 6.8 11 5.7 23.1 4.9 35.6l-1.3 1.3c-.7 4.7-1.3 9.4-1.8 14.2-.8 4.2-.8 4.2-3.4 6.9-.1 1.5-.3 3-.4 4.6-.6 6.1-3.1 10.5-5.9 15.8-1.4 2.9-2.2 5.2-2.8 8.4-2 9.4-7.3 18.8-14.5 25h-2.6c.1 1.2.2 2.4.2 3.7-.2 4.2-.2 4.2-2 6.2-.7.7-1.5 1.3-2.2 2-2.6 4.6-4.3 9-5.7 14-1.5 4.7-1.8 5.4-6.2 8.1-1.3.5-2.5 1-3.9 1.5-.9 1.3-1.7 2.6-2.6 3.9l-2.7 1.2c-2.8 1.2-2.8 1.2-3.5 4.2-.1.8-.2 1.6-.3 2.5.9-1.7 1.7-3.5 2.6-5.3h3.9v6.6c-.9.4-1.7.9-2.6 1.3-.2 1.1-.4 2.2-.7 3.3-.2 1.1-.4 2.2-.7 3.3-.9.4-1.7.9-2.6 1.3-.9 4-.9 4-1.3 7.9h-3.9c.1 1.3.2 2.5.3 3.9 0 5.4-1.8 9.4-4.2 14.3-1.5 2.9-1.5 2.9-2.7 6.2-1.4 3.3-2.9 5.8-5 8.7-4.6 6.7-8.4 13.7-12.1 20.8h-2.6l-.6 2.4c-.6 2.3-1.3 4.6-2.1 6.8H202c-.4 2.6-.9 5.2-1.3 7.9h-3.9c-.4-2.6-.9-5.2-1.3-7.9h-2.6c-1.5-5.6-1-10.1 0-15.8l6.6 6.6c.4-.9.9-1.8 1.4-2.7 6.1-12.2 12.2-24.5 18.3-36.7h2.6l.3-3.3c.8-3.8 1.6-6.1 3.3-9.5.5-.9.9-1.8 1.4-2.8.5-.9 1-1.9 1.5-2.9.9-1.8 1.8-3.7 2.7-5.5l2.7-5.4c.5-.9.9-1.8 1.4-2.8 1.9-3.9 3.9-7.6 6.3-11.2h2.6c.5-1.3.5-1.3 1-2.7 1.9-4.6 4.2-8.9 6.5-13.3.5-.9.9-1.7 1.4-2.6 2.7-4.9 5.4-9.8 8.4-14.6 6.5-10.7 11.1-21.4 15.2-33.2 1.9-5.1 4.2-9.7 6.8-14.5 8.3-17.8 9.1-39 8-58.3l-1.3-1.3c-.2-3.5-.1-7 0-10.5h2.6z" style="fill:#47739e"/><path d="M123.2 296.1c2.1 2 2.1 2 3.9 5.3 0 3.6-.7 6.9-1.3 10.5h2.6c.4 3 .9 6.1 1.3 9.2 1.3.4 2.6.9 3.9 1.3v6.6h3.9v3.9h2.6c.4-1.3.9-2.6 1.3-3.9 10.5 16.4 10.5 16.4 14.4 22.7 1.2 1.9 2.4 3.8 3.7 5.7 1.9 3.7 2.5 6.9 3 11-4.2-2.1-4.9-6.3-6.6-10.5-1.1-2.9-1.1-2.9-3.9-4-.2 4.5-.2 4.5 0 9.2l2.6 2.6v3.9h2.6c.5 1.7 1 3.3 1.6 5 4.1 13.6 4.1 13.6 12.9 23.9.9 2.2 1.8 4.4 2.6 6.6 1.3 1.3 2.6 2.7 3.9 3.9v2.6h2.6V405h2.6v2.6h2.6c3.9 7.4 3.9 7.4 3.9 11.8h2.6c1.1 7.4 1.4 13.7 0 21h2.6c.4 2.6.9 5.2 1.3 7.9h3.9c.4-2.6.9-5.2 1.3-7.9h3.9l.6-2.4c.6-2.3 1.3-4.6 2.1-6.8h2.6c.4-1.2.7-2.3 1.1-3.5 1.8-5.1 4.3-9.1 7.3-13.6 8-11.7 13.5-22.5 15.3-36.7h3.9v-7.9c1.3-.4 2.6-.9 3.9-1.3v-6.6c1.3-.4 2.6-.9 3.9-1.3-1.3-2.6-1.3-2.6-2.6-5.3l-6.6 6.6c2.3-7.6 2.3-7.6 5.3-10.5h3.9c.3-.8.6-1.6.9-2.5 1.7-2.8 1.7-2.8 4.9-3.8 3.6-1.3 3.6-1.3 5-4.7.4-1.2.7-2.5 1.1-3.8 1.9-5.9 4.3-10.3 7.8-15.4.4-1.6.9-3.1 1.3-4.8 1.4-4.7 2.8-6.6 6.6-9.7 1.3.7 1.3.7 2.6 1.3-1.3 2.4-2.5 4.7-3.8 7.1-.7 1.3-1.4 2.6-2.1 4-2 3.4-2 3.4-4.6 6-2.1 4.5-3.8 8.7-5.1 13.5-2.2 7.3-5.2 15.2-10.7 20.6-.2 1.1-.4 2.2-.7 3.4-.2 1.1-.4 2.1-.7 3.2-1.3.7-1.3.7-2.6 1.3q-2.1 3.6-3.9 7.5c-1 2-1 2-2.1 4.1-1.8 4-3.1 7.9-4.5 12-1.4 2.5-1.4 2.5-2.7 4.7-2.3 4-3.1 6.7-3.9 11.1-1.3.7-1.3.7-2.6 1.3-.2 1.1-.4 2.2-.7 3.3-.2 1.1-.4 2.2-.7 3.3-1.3.7-1.3.7-2.6 1.3-.4 1.5-.9 3-1.3 4.6-.4 1.5-.9 3-1.3 4.6-1.3.7-1.3.7-2.6 1.3-1.1 1.8-2.1 3.6-3.1 5.5-2.7 4.9-5.6 9.5-8.7 14.2-1.4 2.6-2.7 5.2-3.9 7.9h-6.6c-.4-2.2-.9-4.3-1.3-6.6h-3.9c-.4-1-.8-2-1.2-3.1-3-7.4-6.1-14.2-10.5-20.9-2-3.2-3-6.6-4-10.2-.4-.9-.9-1.7-1.3-2.6-1.3-.4-2.6-.9-3.9-1.3-.9-2.4-.9-2.4-1.4-5.3-.5-2.8-.5-2.8-1.2-5.3-1.3-.7-1.3-.7-2.6-1.3-.4-2.2-.9-4.4-1.3-6.6-1.3-.7-1.3-.7-2.6-1.3-.4-1.3-.9-2.6-1.3-3.9-.9-.4-1.7-.9-2.6-1.3-.8-4-.8-4-1.3-7.9h-2.6c-.1-1.5-.2-3-.2-4.6-.8-6.6-3.9-11-7.6-16.4l-3.9-6.6c-.7-1.1-1.3-2.1-2-3.2-3.6-5.9-6.5-11.2-8.5-17.8-.9-.4-1.7-.9-2.6-1.3-.4-1.2-.8-2.3-1.1-3.5-1.3-4.5-1.3-4.5-4.3-8.3-2.8-4.4-2.9-6.7-2.5-11.8h2.6c.5-1.9 1-4 1.4-6.3" style="fill:#436e99"/><path d="M200 182.9c1.7 0 3.4 0 5.2-.1 4.4.3 7.4 1 11.3 2.9v2.6c1.4.2 2.7.5 4.1.7 6.2 1.8 9.7 5.6 13 11.1 1.3 4.9 1.3 4.9 1.3 9.2-.9 1.3-1.7 2.6-2.6 3.9-2.6-2.6-2.6-2.6-2.8-6.1.1-1 .1-2.1.2-3.1-1.3-.4-2.6-.9-3.9-1.3-.7-1.3-.7-1.3-1.3-2.6-1.1-.7-2.2-1.3-3.4-2-1.1-.7-2.1-1.3-3.2-2v-2.6c-6.3-2.5-12-3.1-18.6-3-.9 0-1.9 0-2.8-.1-5.3-.1-5.3-.1-9.8 2.3-.5.7-1.1 1.4-1.6 2.1-1.5.8-2.9 1.5-4.4 2.3-5.4 2.9-8.3 6.9-11.3 12.1-.5.7-1 1.5-1.5 2.2-1.8 4.6-1.5 9.3-1.4 14.2v3c0 4.7.3 8.9 1.6 13.4h2.6v2.6h2.6c1.9 2.2 1.9 2.2 3.9 5 1 1.4 1 1.4 2.1 2.8l4.5 6.6c3 .5 6.1 1 9.2 1.3l1.3 1.3c2 .1 3.9.2 5.9.2h3.2c2.7.1 2.7.1 4-1.5 1.4-.2 2.9-.3 4.4-.5 4.8-.8 6.5-1.7 10.1-4.8h2.6c.2-1.2.2-1.2.5-2.5.8-2.8.8-2.8 3.4-5.4.8-2.7.8-2.7 1.3-5.3h3.9c-.1-1.6-.2-3.1-.2-4.8 0-5.5 1.7-8.7 4.2-13.6.7-3.9 1.1-7.8 1.3-11.8h1.3c1.2 7.1 1.5 13.8 1.3 21v4.4c-2.9 10.4-10.9 19.7-19.7 25.8h-2.6v2.6c-.8.3-1.7.5-2.5.8-1.1.4-2.2.7-3.3 1.1s-2.2.7-3.3 1.1c-2.7.8-2.7.8-4 2.3-7 .4-11.8-.2-18.4-2.6-3.1-.5-6.1-.9-9.2-1.3v-3.9c-1.2-.6-1.2-.6-2.4-1.1-2.9-1.5-2.9-1.5-6.8-4.1v-2.6h-2.6c-1.3-2.6-2.6-5.2-3.9-7.9-1.3.7-1.3.7-2.6 1.3l-.6-4.5c-.2-4.4-.2-4.4-2.1-6-.8-11.6-.8-23.3 3.9-34.1 1.3-1.3 2.6-2.7 3.9-3.9.4-1.3.9-2.6 1.3-3.9 3.1-3.8 6.8-5.8 11.1-8 1.9-1 1.9-1 3.8-2.1 6.2-2.8 11.4-3 18.5-3.1" style="fill:#4e7ca8"/><path d="M210.5 124.3c2.2 0 2.2 0 4.5.1 13.9.5 13.9.5 19.8 6.2v3.9l3.6.4c4.3.9 6.3 2 9.5 4.8h-2.6c-.4 1.7-.9 3.5-1.3 5.3v-2.6c-2.6-.4-5.2-.9-7.9-1.3v-3.9l-4.6-.6q-5.4-.6-10.8-1.5c-15.6-2.2-30.6-1.7-46.3-.6v1.3c.9.4 1.7.9 2.6 1.3-3.2 1.8-6.1 2.9-9.7 3.9-8 2.3-14.4 6.3-20.9 11.4-.7.6-1.5 1.2-2.2 1.7-10.6 8.4-19.2 17.3-26.2 28.9-1.3-2.6-1.3-2.6-.6-5.2.4-.9.8-1.9 1.2-2.9s.8-1.9 1.1-2.9c.3-.7.6-1.5.9-2.2h-1.3l-2.6 2.6h-3.9l-.3 2.1c-1.3 4.3-3.6 7.4-6.2 11-1.3.7-1.3.7-2.6 1.3 1.2-3.4 2.4-6.2 4.6-9.1 2.2-2.7 2.2-2.7 3.3-6.6 1.3-3.8 2.5-6.2 5.3-9.2h2.6c.3-.8.7-1.6 1-2.5 1.9-3.3 3.5-3.9 6.9-5.4.4-.9.9-1.7 1.3-2.6 1.3-.4 2.6-.9 3.9-1.3l6.6-6.6c5.6-5.2 10.5-9.2 18.4-9.2.4-1.3.9-2.6 1.3-3.9 1.4-.1 2.7-.3 4.1-.4 5-.6 5-.6 8.5-2.9 11.6-5 26.3-3.1 39-2.8" style="fill:#5789b8"/><path d="M295.3 411.7c.9.4 1.7.9 2.6 1.3 1.1 3.3 1.3 5.7 1.3 9.2-2.7 2.5-5.4 4.7-8.3 7l-2.7 2.1c-11.1 8.6-22.4 16.9-33.7 25.1l-3.3 2.4c-3.2 2.3-6.4 4.7-9.7 7-1 .7-2 1.4-3 2.2-10.1 7.3-10.1 7.3-14.3 9.4-3-1.1-3-1.1-5.3-2.6 2.6-.7 2.6-.7 5.3-1.3v-4c1-6.9 5.9-9.9 11.2-13.9l3-2.4 9.3-7.2 2.7-2.1c6-4.6 12.1-9.2 18.3-13.7 1-.8 2.1-1.5 3.2-2.3 3.3-2.4 6.5-4.8 9.8-7.1 1.5-1.1 1.5-1.1 3.1-2.2 1.4-1 1.4-1 2.9-2.1.8-.6 1.7-1.2 2.6-1.8 2.3-1.7 2.3-1.7 5-3" style="fill:#4176be"/><path d="M121.9 166.1c0 5.1-1.9 7.6-4.4 12-3.9 6.9-6.1 13.1-7.4 20.8-.8 3.1-1.7 6.1-2.6 9.2h-2.6c-1 20.2-1 20.2 3.8 39.6 2.3 5.9 3.3 12.1 4.6 18.3.7 3.9.7 3.9 2.1 7.8-.6 2.9-.6 2.9-1.3 5.3-1.7-.4-3.5-.9-5.3-1.3v-6.6c-1.3-.4-2.6-.9-3.9-1.3-4.4-7.6-6.1-16.2-7.4-24.8-.2-2.8-.2-2.8-1.8-4.1-.4-5.7-.4-11.3-.4-17 0-1.6-.1-3.2-.1-4.8-.1-16.4 5.2-27.5 14.9-40.4 1.7-2.3 1.7-2.3 2.7-6.1q3.15-2.1 6.6-3.9c.8-.9 1.6-1.8 2.5-2.7" style="fill:#5583ad"/><path d="m415.4 324.1-3.3-3c-4.7-4.8-8.8-9.5-15.8-10.4-1.3 0-2.6-.1-3.9-.1V308h3.9l2.6-2.6c1.5-8.7 1.5-8.7 1.5-42.5v-13.2c0-17.7-.3-35.4-1.3-53.1-.6-10.3-2.6-20.2 3.7-29 1.4-1.7 2.7-3.5 4-5.3 2.3-6.4 1.5-12.3-1.1-18.4-3.2-5.4-7.5-10.8-13.6-12.7-.8-.1-1.6-.3-2.3-.4v-5.3c-7.8-3.7-15.6-7.1-23.7-9.9-52.3-18-100.3-47.6-147-76.7 1.7-8.1 4.2-16.1.7-23.9-1.7-1.9-1.7-1.9-3.4-3.7-3.1-4-6-6.8-10.5-9.2-8.1-2-16.8-3.4-24.6.5-5.6 4.7-10.4 10.4-11.4 17.9 0 1.4 0 2.7.1 4 0 4 0 4 .4 7.9 2.8 8.5 7.7 17.9 15.8 22.3 6.4 1.2 11.6.5 17.4-2.5 3.8-2.2 7-3.5 11.5-4 1.1 4.3 1.8 7.2 5.2 10.1 1.2.6 2.3 1.3 3.5 1.9 2.3 1.2 4.5 2.5 6.8 3.6 1 .5 1.9 1 2.9 1.5 22.5 11.6 45.4 22.5 68.2 33.4 2.5 1.2 5 2.4 7.6 3.6 1.3.6 2.5 1.2 3.7 1.8 10.8 5.1 21.5 10.4 32.2 15.8 6.3 3.2 12.5 6.5 19.2 8.9l3 .9c-1.3 2-1.3 2-2.6 3.9-6.7 8.1-10.3 13.6-9.6 24.4 1 7.3 3 12.6 8.7 17.7l3.4 2.4c3.7 2.7 5.8 4.1 10.4 4.4 1-.1 1.9-.2 2.9-.3 0 2.1 0 4.3-.1 6.4v2.7c0 5.4 0 10.7.4 16.1 1.4 28.1 1.4 28.1 1.7 56.3.1 5 .1 10 .2 15 .2 10.7.3 21.5.4 32.2-3.9 0-3.9 0-7.9 2.6-5.9 5.9-8.3 11.7-8.6 19.9v2.8c0 3.5.2 6.6.7 10.1 2 3.5 3.3 4.9 6.8 6.9.8.3 1.6.7 2.4 1v2.6c-2.9 0-2.9 0-8.7 3.8-.9.6-1.7 1.1-2.5 1.7-9.9 6.6-19.5 13.6-29.1 20.5-10.3 7.5-20.6 15-31.2 22-11.3 7.5-22.4 15.3-33.4 23.4-1 .7-1.9 1.4-2.9 2.1-6.1 4.5-12.2 9-18.3 13.7l-2.7 2.1c-3.1 2.4-6.3 4.8-9.3 7.2l-3 2.4c-5.3 4-10.2 7-11.2 13.9v4h-6.6c-.7-2.8-.7-2.8-2.6-6.6-4.8-5-9.5-8.4-16.6-8.8-5 .4-8 1.9-11.7 5.5-1.9 2-1.9 2-4.3 3.8-5.4 4.3-9.9 8.5-12.1 15.2-1.1 7.7-.9 13.3 3.9 19.7 8.6 6.1 17.6 8.8 28 9.7 4.7-.7 6.7-2.5 10.1-5.8 7-8.4 10.3-15.3 9.5-26.2l-.3-2.7h3.9c2.4-2.6 2.4-2.6 4.8-4.2.9-.6 1.7-1.2 2.6-1.7 1-.7 2-1.3 3-2 13.1-8.8 25.7-18.1 38.2-27.8 5.1-3.9 10.2-7.8 15.3-11.6 5.3-4 10.7-7.9 15.9-12.1 22.6-18.4 47.9-33.5 73.9-46.7 3.6-1.8 7-3.6 10.5-5.6.4-1.3.9-2.6 1.3-3.9 5.5.6 10.8 1.1 15.9-1.3 4.7-3.6 7.1-7.3 9-12.8 2-7.9 3.6-16.3-.5-23.6M208.2 35c-3.1 4.3-5.7 7.3-10.1 10.3-3.8.6-3.8.6-7.9 0-5.1-4-8.3-8.3-10.5-14.4.1-7.4 2.2-12.5 6.6-18.4 4.8-1.6 8.4-1.7 13.1 0l4.8 1.6c4.4 2.3 4.4 2.3 6.4 5.3 1 5.9.7 10.4-2.4 15.6M206 499.7c-4.5 3-6.5 2.8-11.6 2-6.3-1.6-11-4.4-14.6-9.9-.7-5-.2-7.5 2.1-12 6.2-7.5 10.5-10.3 19.9-7.6 4.8 2 7.4 3.7 10.8 7.7.2 7.2-2.7 13.9-6.6 19.8m173.3-327c-6.8-2.1-9.9-5.7-13.3-11.7-2.2-5.2-2-9.7-.1-15 2.4-3.4 4.9-4.7 8.9-5.7 5.4-.6 10.8-.6 16.3-.5.5 1.1.5 1.1 1.1 2.3.5 1 1 1.9 1.4 2.9.5 1 .9 1.9 1.4 2.9 1.1 2.4 1.1 2.4 2.6 3.7 0 7.8-2.7 12-8 17.6-3.4 3-5.7 3.8-10.3 3.5m25.8 175.5c-2.6 3.1-3.9 4.3-8 4.9-7.9-.6-13.6-2.6-18.9-8.5-2.5-6.5-1.6-13.5 1.1-19.9 3.9-6.3 13.6-3.4 19.8-2.3 4.1 1.8 7.1 3.8 9.2 7.9 1 6.7.4 12-3.2 17.9" style="fill:#4076be"/><path d="M292.6 192.4c.5 1.1.5 1.1 1.1 2.3 1.4 2.9 1.4 2.9 3.2 5.5 6.8 11 5.7 23.1 4.9 35.6l-1.3 1.3c-.7 4.7-1.3 9.4-1.8 14.2-.8 4.2-.8 4.2-3.4 6.9l-.4 4.6c-.6 6.1-3.1 10.5-5.9 15.8-1.4 2.9-2.2 5.3-2.9 8.5-1.4 6.7-4.7 12.4-7.9 18.4-1.3-3.5-1.5-4.8 0-8.3.9-1.6.9-1.6 1.9-3.3 3.1-5.8 4.1-9.5 3.4-16-.9-.4-1.7-.9-2.6-1.3.7-4.9 1.1-7.6 3.9-11.8 5.8-18.3 7.6-36.1 6.6-55.2l-1.3-1.3c-.2-3.5-.1-7 0-10.5h2.6c-.1-1.9-.1-3.6-.1-5.4" style="fill:#5883aa"/><path d="M123.2 296.1c2.1 2 2.1 2 3.9 5.3 0 3.6-.7 6.9-1.3 10.5h2.6c.4 3 .9 6.1 1.3 9.2 1.3.4 2.6.9 3.9 1.3v6.6h3.9v3.9h2.6c.4-1.3.9-2.6 1.3-3.9 10.5 16.4 10.5 16.4 14.4 22.7 1.2 1.9 2.4 3.8 3.7 5.7 1.9 3.7 2.5 6.9 3 11-4.2-2.1-4.9-6.3-6.6-10.5-1.1-2.9-1.1-2.9-3.9-4-.2 4.5-.2 4.5 0 9.2l2.6 2.6v3.9h2.6c.4 2.6.9 5.2 1.3 7.9-4.5-4-6.6-7.7-9.2-13.1-4.3-8.3-8.9-16.1-14.4-23.6-3-4.5-4.2-7.9-5.3-13.1-.9-.4-1.7-.9-2.6-1.3-.4-1.2-.8-2.3-1.1-3.5-1.3-4.5-1.3-4.5-4.3-8.3-2.8-4.4-2.9-6.7-2.5-11.8h2.6c.6-2.3 1.1-4.4 1.5-6.7" style="fill:#4e79a3"/><path d="M199 189.4c.9 0 1.9-.1 2.8-.1 6.3 0 10.5.9 16 4.4.9 1.3 1.7 2.6 2.6 3.9 1.7.9 3.5 1.8 5.3 2.6 4.2 3.2 5.1 6.9 6.6 11.8l1.3 1.3c.1 2.1.1 4.3.1 6.4v12.2c0 3.4 0 6.8-.1 10.2h-3.9c-.2 1.4-.2 1.4-.4 2.9-1.5 6-3.7 9.3-8.5 13.2-4.6 2.5-8.3 3.1-13.5 3.6l-1.3 1.3c-2 .1-3.9.2-5.9.2h-3.2c-2.7-.2-2.7-.2-4-1.5-1.5-.1-2.9-.2-4.4-.3-4.8-1-4.8-1-7.3-4.4-.5-1.1-.9-2.1-1.4-3.2.7.4 1.5.9 2.2 1.3 5.4 2.4 11 1.8 16.8 1.7 1.2 0 2.5 0 3.8.1h6.9c3.7-.6 5.4-2.1 8.3-4.4h2.6V250c.8-.8 1.6-1.5 2.4-2.3 5.8-5.9 7.4-11.7 7.4-19.9-.2-8.1-1.6-13.1-5.8-19.9-.7-1.9-.7-1.9-1.3-3.9-.7-1.3-.7-1.3-1.3-2.7-2.7-.9-2.7-.9-5.3-1.3-.7-2-.7-2-1.3-3.9-7.7-3.7-16.4-3.9-24.5-1.6-8.8 3.3-14.9 8-21.4 14.8 2.2-8.2 8.7-12.5 15.6-16.8.9-.5 1.8-1 2.8-1.6 2.5-2.5 7.7-1.4 11.3-1.5" style="fill:#9bb5cd"/><path d="M152.1 353.9c2.4.7 2.4.7 5.3 2.6 1.2 2.6 2 5.3 2.9 8 1.4 3.3 3.4 4.5 6.3 6.4v5.3h2.6v3.9h2.6c.4 2.6.9 5.2 1.3 7.9h2.6c.4 2.6.9 5.2 1.3 7.9h2.6c1.9 5.8 3.1 9.8 1.3 15.8h-2.6c-4.1-4.4-6.6-7.2-7.9-13.1-1.3-.9-2.6-1.7-3.9-2.6-5.2-8.4-8.8-16.4-9.2-26.3h-2.6c-3.4-4.2-4-6.1-3.5-11.6z" style="fill:#4774a1"/><path d="M115.3 276.4c2.4.8 2.4.8 5.3 2.6 1.8 3.7 1.8 3.7 3.4 8.1.6 1.5 1.1 3.1 1.7 4.7.5 1.4 1 2.8 1.5 4.3.7 1.8 1.5 3.5 2.2 5.3 1.1 2.5 2.1 5.1 3 7.6 1.2 3.3 1.2 3.3 5.3 5.5 1.5 3 1.5 3 2.8 6.5.4 1.1.9 2.3 1.3 3.5.8 2.4 1.7 4.7 2.5 7.1h2.6c.4 2.2.9 4.3 1.3 6.6l-6.6-6.6c-2 .7-2 .7-3.9 1.3V329h-3.9l-.4-3c-.6-3.6-.6-3.6-3-5.5-1.9-2-1.9-2-2.1-5.5.1-1 .2-2 .2-3h-2.6c.1-4 .1-4 .1-8 0-2.7 0-2.7-1.4-5.2-.9 1.3-1.7 2.6-2.6 3.9h-2.6c-3-4.5-3.2-7.8-3.9-13.1l-1.3-1.3q.15-3 .6-6c.1-1.1.3-2.2.4-3.3-.1-.9 0-1.7.1-2.6" style="fill:#4a78a6"/><path d="M238.8 212.1h1.3c1.2 7.1 1.5 13.8 1.3 21v4.4c-2.5 9-9.1 18.1-17.1 23.1-4.3.9-4.3.9-7.9 1.3-.4 1.3-.9 2.6-1.3 3.9-5.3 1.3-10.1 1.4-15.5 1.4h-8.1c3.5-2.4 5.4-3.1 9.4-4 4.8-.7 4.8-.7 6.4-2.5 1.4-.2 2.9-.3 4.4-.5 4.8-.8 6.5-1.7 10.1-4.8h2.6c.2-.8.3-1.6.5-2.5.8-2.8.8-2.8 3.4-5.4.8-2.7.8-2.7 1.3-5.3h3.9c-.1-1.6-.2-3.1-.2-4.8 0-5.5 1.7-8.7 4.2-13.6.7-3.8 1-7.7 1.3-11.7" style="fill:#4776a2"/><path d="M292.6 192.4c.4.8.8 1.7 1.2 2.5 1.2 2.6 1.2 2.6 2.7 4 .1 4 .2 7.9.1 11.9v22.9c0 6.4 0 12.7-.1 19.1h-2.6c-.4 4.3-.9 8.7-1.3 13.1-2.2.9-4.3 1.7-6.6 2.6.2-1.4.2-1.4.5-2.7 3.3-18.8 6.1-37.2 4.8-56.4l-1.3-1.3c-.2-3.5-.1-7 0-10.5h2.6z" style="fill:#4e7fae"/><path d="M241.4 348.7h2.6c-2.6 5.3-2.6 5.3-3.9 7.6-1.5 3.1-1.5 3.1-2.6 7.6-1.3 4.4-1.3 4.4-3.9 7.1-.2.9-.4 1.8-.7 2.7-.2.8-.4 1.7-.7 2.5-.9.4-1.7.9-2.6 1.3-3.1 5.3-4.3 9.8-5.3 15.8-1.3.7-1.3.7-2.6 1.3-.9 2.6-1.8 5.2-2.6 7.9-2.1 4.5-4.4 8.7-7.1 12.8-3.3 5.1-5.4 10.3-7.4 16-1.2 2.8-1.2 2.8-3.8 5.4-2.6-.1-2.6-.1-5.3-1.3-1.7-3.1-1.7-3.1-2.6-6.6.4-1.3.9-2.6 1.3-3.9 1.7 2.2 3.5 4.3 5.3 6.6.4-.9.9-1.8 1.4-2.7 6.1-12.2 12.2-24.5 18.3-36.7h2.6l.3-3.3c.8-3.8 1.6-6.1 3.3-9.5.5-.9.9-1.8 1.4-2.8.5-.9 1-1.9 1.5-2.9.9-1.8 1.8-3.7 2.7-5.5l2.7-5.4c.5-.9.9-1.8 1.4-2.8 1.9-3.9 3.9-7.6 6.3-11.2" style="fill:#4375ae"/><path d="M274.2 306.6c.9.4 1.7.9 2.6 1.3-1.3 2.4-2.5 4.7-3.8 7.1-.7 1.3-1.4 2.6-2.1 4-2 3.4-2 3.4-4.6 6-2.1 4.5-3.8 8.7-5.1 13.5-2.9 9.6-7.2 17.5-14.1 24.9-3.2 3.9-5 8.2-7.1 12.8h-1.3v-6.6c2-.7 2-.7 3.9-1.3v-6.6c2-.7 2-.7 3.9-1.3-.9-1.7-1.7-3.5-2.6-5.3l-6.6 6.6c2.3-7.6 2.3-7.6 5.3-10.5h3.9c.3-.8.6-1.6.9-2.5 1.7-2.8 1.7-2.8 4.9-3.8 3.6-1.3 3.6-1.3 5-4.7.4-1.2.7-2.5 1.1-3.8 1.9-5.9 4.3-10.3 7.8-15.4.4-1.6.9-3.1 1.3-4.8 1.6-4.6 3-6.5 6.7-9.6" style="fill:#527ca4"/><path d="M220.4 402.5c.8 2.5.8 2.5 1.3 5.3l-2.6 2.6c-.8 2.7-.8 2.7-1.3 5.3h-2.6c-.1.8-.1 1.6-.2 2.4-.1 1.1-.2 2.1-.3 3.2s-.2 2.1-.3 3.2c-.5 3-.5 3-3.2 6.9h-2.6l-.6 2.4c-.6 2.3-1.3 4.6-2.1 6.8H202c-.4 2.6-.9 5.2-1.3 7.9h-3.9c-.4-2.6-.9-5.2-1.3-7.9h-2.6c-1.5-6.2-1.5-6.2 0-9.2h2.6c.4 1.3.9 2.6 1.3 3.9 2.5-.2 2.5-.2 5.3-1.3 1.8-2.9 1.8-2.9 3.1-6.5 3.7-9 7.9-18.3 15.2-25" style="fill:#416d98"/><path d="M295.3 191.1h2.6c.3.8.5 1.7.8 2.5.5 1.6.5 1.6 1.1 3.3.4 1.1.7 2.2 1.1 3.3.8 2.7.8 2.7 2.3 4 .1 3.2.2 6.3.2 9.5v9c0 3.1 0 6.2.1 9.3v8.7c0 6.6 0 6.6-3 9.5-.2 1.5-.4 3-.7 4.6-.7 4.6-.7 4.6-3.3 7.2-.6 3.5-.6 3.5-.8 7.4-.1 1.3-.2 2.6-.3 4-.1 1-.1 2-.2 3.1h-2.6c-.2 1.3-.3 2.5-.5 3.9-.3 1.3-.5 2.7-.8 4-1.3.7-1.3.7-2.6 1.3-.4 1.1-.7 2.2-1.1 3.4-1.9 5.6-4.9 10.1-8.1 15 0-4.1.5-5.4 2.5-8.9 2.5-4.8 3.9-9.5 5.4-14.8.9-2 1.8-3.9 2.7-5.8 1.9-4 2.9-7.4 3.4-11.8.5-4.7.5-4.7 3.1-7.3.6-3.1 1-6.2 1.4-9.3 1.1-9 1.1-9 2.5-10.4.3-4.1.2-8.2.2-12.2v-9.9c-.4-3.2-1.4-5.3-2.9-8.1-.6-2.5-1.1-5-1.5-7.6-.2-1.3-.4-2.6-.7-3.9 0-1-.2-2-.3-3" style="fill:#9eb7cc"/><path d="M207 122.1c5.3 1.3 10.9 1.2 16.4 1.4 7.2.4 7.2.4 10.1 3.3 1.4.1 2.8.3 4.2.4 5.5 1 7.9 2.6 12.1 5.9 2.1 1.7 2.1 1.7 5 3 2.3 1.2 2.3 1.2 4.2 3.9 1.9 2.9 1.9 2.9 5.3 3.9 4.3 1.8 6 4.2 8.6 8-.9.4-1.7.9-2.6 1.3v-2.6c-.7-.1-1.5-.2-2.2-.3-4-1.3-6.4-3.6-9.6-6.2-1.1-.7-2.2-1.3-3.4-2-3.2-2-3.2-2-5.7-4.7-3.7-3.4-7.2-3.9-12-5.2-2.2-1.3-2.2-1.3-4.5-2.6-7.3-3.7-14.6-3.6-22.6-3.7-1.5 0-3-.1-4.6-.1-12.4-.3-24.4-.1-36.5 2.5 2.6-3.9 2.6-3.9 6.1-4.9 23.3-3.5 23.3-3.5 31.7-1.3" style="fill:#b0c8de"/><path d="M248 360.5c.7 2.6.7 2.6 1.3 5.3-.9.4-1.7.9-2.6 1.3q-2.1 3.6-3.9 7.5c-1 2-1 2-2.1 4.1-1.8 4-3.1 7.9-4.5 12-1.4 2.5-1.4 2.5-2.7 4.7-2.3 4-3.1 6.7-3.9 11.1-1.3.7-1.3.7-2.6 1.3-.2 1.1-.4 2.2-.7 3.3-.2 1.1-.4 2.2-.7 3.3-1.3.7-1.3.7-2.6 1.3-.4 1.5-.9 3-1.3 4.6-.4 1.5-.9 3-1.3 4.6-1.3.7-1.3.7-2.6 1.3-1.1 1.8-2.1 3.6-3.1 5.5-7.3 13.5-7.3 13.5-11.3 15.5.4-.7.7-1.4 1.1-2.1 2.7-5.6 5-11.4 7.4-17.1 2.7-6 6.1-11.3 9.8-16.7 7.1-10.7 11.7-20.9 13.2-33.7 1.3-.4 2.6-.9 3.9-1.3 1.8-2.6 1.8-2.6 3.4-5.7 1.8-3.6 3.6-6.8 5.8-10.1" style="fill:#c8d5e1"/><path d="M169.2 202.9c-.8 3.9-1.6 5.8-3.9 9.2-.4 3.2-.4 3.2-.3 6.9v4c0 1.4 0 2.8.1 4.2v4.2c0 2.6.1 5.2.1 7.7.1 7 .1 7-1.3 9.7H160l-.6-4.5c-.2-4.4-.2-4.4-2.1-6-1.6-24.7-1.6-24.7 5.8-33.8 2.1-1.6 2.1-1.6 6.1-1.6" style="fill:#4978a5"/><path d="M200.1 182.9h2.7c4.9 0 6.9.2 11 2.9-2.2 0-2.2 0-4.4-.1-1.9 0-3.8 0-5.8-.1-1 0-1.9 0-2.9-.1-5 0-8.9 0-13.2 2.9v3.9c-3 2.5-3 2.5-7 5.3-6.1 4.2-6.1 4.2-11.4 9.2v-3.9c-2.6 1.3-5.2 2.6-7.9 3.9.9-1.8 1.8-3.5 2.6-5.3l1.2-2.7c2.9-5.3 7.2-7.7 12.5-10.6 1.2-.7 2.5-1.4 3.8-2.1 6.5-3 11.7-3.1 18.8-3.2" style="fill:#5283b0"/><path d="M97 201.6c0 4 0 7.9-.1 11.8 0 3.2 0 6.4-.1 9.7 0 1.6 0 3.2-.1 4.9v9c.3 4 1.3 6.8 2.9 10.5.1 1.4.3 2.7.4 4.1 1.1 5.9 3.3 10.6 5.9 16 1.4 3.2 2.4 6.2 3.1 9.6.7 4.6.7 4.6 3.7 7.1 1 2.3 1.9 4.7 2.8 7.1.5 1.3.9 2.5 1.4 3.9 1.1 3.5 1.1 3.5 1.1 7.4-3.9-6.1-3.9-6.1-3.9-10.5h-2.6q-2.1-3.15-3.9-6.6l-1.3-1.3-.6-4.5c-.3-4.7-.3-4.7-3.4-7.3-.8-2.7-1.3-5.4-1.9-8.2-.2-.8-.5-1.5-.7-2.3-.9-.4-1.7-.9-2.6-1.3v-9.2h-2.6c-.1-6.7-.1-13.4-.1-20.1 0-2.3 0-4.6-.1-6.8 0-3.3 0-6.5-.1-9.8v-3.1q0-3.75.3-7.5c.7-.9 1.6-1.7 2.5-2.6" style="fill:#bdcedd"/><path d="M100.9 234.4c.4.8.8 1.6 1.2 2.5l2.7 5.1c1.6 3.6 2.4 7.1 3.2 11 .7 2.9.7 2.9 3.4 6.4.8 2.4 1.5 4.7 2.2 7.1.4 1.2.7 2.5 1.1 3.8.6 3.5.5 5.5-.7 8.8-1.7-.4-3.5-.9-5.3-1.3v-6.6c-1.3-.4-2.6-.9-3.9-1.3-.2-.9-.3-1.9-.5-2.8-.2-1.2-.5-2.4-.7-3.7-.2-1.2-.5-2.4-.7-3.6-.4-2.9-.4-2.9-2-4.3-.1-3.5-.1-7-.1-10.6v-3c.1-2.6.1-5 .1-7.5" style="fill:#49759f"/><path d="M123.2 296.1c2.1 2 2.1 2 3.9 5.3 0 3.6-.7 6.9-1.3 10.5h2.6c.4 3 .9 6.1 1.3 9.2 1.3.4 2.6.9 3.9 1.3.2 1.5.3 2.9.5 4.4.4 4.8.4 4.8 3.4 7.4 1.1 2.9 1.9 5.9 2.8 8.9 1.2 3.1 2.7 4.5 5.1 6.8-.4 2.9-.4 2.9-1.3 5.3-.6-.9-1.1-1.9-1.7-2.9l-4.2-6.9c-3.6-5.9-6.5-11.2-8.5-17.8-.9-.4-1.7-.9-2.6-1.3-.4-1.2-.8-2.3-1.1-3.5-1.3-4.5-1.3-4.5-4.3-8.3-2.8-4.4-2.9-6.7-2.5-11.8h2.6c.5-2.2 1-4.3 1.4-6.6" style="fill:#4f779e"/><path d="M152.1 369.7c6.2 7.5 10.8 14.7 14.4 23.7 1.3 3 1.3 3 5.3 5.2.9 2.2 1.8 4.4 2.6 6.6 1.7 2.2 3.5 4.4 5.3 6.6 2.1 3.6 3.2 7 4 11.1.2 1 .4 2.1.7 3.1.2 1 .4 1.9.6 2.9.3 1.3.5 2.7.8 4 .2.8.3 1.7.5 2.5-4-5-6.7-10.3-9.2-16.2-.4-1.1-.9-2.1-1.3-3.2-.6-1.5-.6-1.5-1.2-3.1-1.3-2.9-1.3-2.9-5.3-3.7-.9-2.4-.9-2.4-1.4-5.3-.5-2.8-.5-2.8-1.2-5.3-.9-.4-1.7-.9-2.6-1.3-.4-2.2-.9-4.4-1.3-6.6-.9-.4-1.7-.9-2.6-1.3-.4-1.3-.9-2.6-1.3-3.9-.9-.4-1.7-.9-2.6-1.3-.8-4-.8-4-1.3-7.9h-2.6c-.3-2.2-.3-4.4-.3-6.6" style="fill:#bfcedc"/><path d="M296.6 248.9h1.3c.2 3.1.2 3.1 0 6.6l-2.6 2.6-.4 4.6c-.6 6.1-3.1 10.5-5.9 15.8-1.4 2.9-2.2 5.3-2.9 8.5-1.4 6.7-4.7 12.4-7.9 18.4-1.3-3.5-1.5-4.8 0-8.3.6-1.1 1.2-2.1 1.9-3.3 3.1-5.8 4.1-9.5 3.4-16-.9-.4-1.7-.9-2.6-1.3.7-4.9 1.1-7.6 3.9-11.8.9-3 1.8-6.1 2.6-9.2 2 4 .7 7.5 0 11.8 1.7-.4 3.5-.9 5.3-1.3 0-1.4-.1-2.9-.1-4.4.1-4.8.1-4.8 1.4-8.8h2.6z" style="fill:#4f7aa4"/><path d="M177.1 200.3c.9.4 1.7.9 2.6 1.3-1 1.1-2.1 2.1-3.1 3.2-3.7 4.4-4.7 8.5-5.6 14.1-.5 2.4-.5 2.4-1.8 3.7-.2 3.3-.2 3.3 0 6.6l1.3 1.3c.2 3.5.1 7 0 10.5h3.9c.4 2.6.9 5.2 1.3 7.9-.9-.4-1.7-.9-2.6-1.3V245h-2.6v-2.6h-2.6c-3.4-5.1-3-10.5-3-16.4v-3c0-7.8 1-11.3 6.3-16.8 1.1-1 2.2-2.1 3.3-3.1.8-1.1 1.7-1.9 2.6-2.8" style="fill:#d7e1eb"/><path d="M224.3 251.5v3.9c-5.5 4.6-10 5.9-17.1 6.6l-1.3 1.3c-2 .1-3.9.2-5.9.2h-3.2c-2.7-.2-2.7-.2-4-1.5-1.5-.1-2.9-.2-4.4-.3-4.8-1-4.8-1-7.3-4.4-.5-1.1-.9-2.1-1.4-3.2.7.4 1.5.9 2.2 1.3 5.4 2.4 11 1.8 16.8 1.7 1.2 0 2.5 0 3.8.1h6.9c3.7-.6 5.4-2.1 8.3-4.4 4-1.3 4-1.3 6.6-1.3" style="fill:#7899b7"/><path d="M199 189.4c.9 0 1.9-.1 2.8-.1 6.3 0 10.5.9 16 4.4.9 1.3 1.7 2.6 2.6 3.9 1.7.9 3.5 1.8 5.3 2.6 2.4 1.8 2.4 1.8 3.9 3.9-.4 2.9-.4 2.9-1.3 5.3v-2.6c-1.3-.4-2.6-.9-3.9-1.3-.7-2-.7-2-1.3-3.9-2.9-2.1-2.9-2.1-6.6-3.9-1.4-.7-2.8-1.5-4.2-2.2-8.4-3.4-16.7-2.6-25.1.6-7.3 3.2-12.5 7.7-18 13.5 2.2-8.2 8.7-12.5 15.6-16.8.9-.5 1.8-1 2.8-1.6 2.6-2.8 7.8-1.7 11.4-1.8" style="fill:#608bb2"/><path d="M169.2 129.4c-1.3 1.3-1.3 1.3-5.9 1.9-1.5.2-3 .5-4.6.7-.4.9-.9 1.7-1.3 2.6-1.8.8-3.6 1.6-5.4 2.3-5.5 2.3-9.4 5.2-13.7 9.3-1.9 1.5-1.9 1.5-4.5 1.5-.4 1.3-.9 2.6-1.3 3.9h-3.9c-.4 1.3-.9 2.6-1.3 3.9-.9-.4-1.7-.9-2.6-1.3.9-2.2 1.7-4.3 2.6-6.6h2.6V145h5.3c.4-1.7.9-3.5 1.3-5.3h2.6v-2.6c2.6-.4 5.2-.9 7.9-1.3.4-1.3.9-2.6 1.3-3.9 3.4-1.3 3.4-1.3 7.6-2.3 1.4-.3 2.8-.7 4.2-1 3.6-.5 5.6-.5 9.1.8" style="fill:#c6d7e6"/><path d="M208.6 431.4h2.6c-1.3 3.5-2.6 6.5-4.6 9.7-2.3 4.1-3.4 8.1-4.6 12.6h-6.6c-.4-2.2-.9-4.3-1.3-6.6h-3.9c-1.3-3.9-2.6-7.8-3.9-11.8 1.7.4 3.5.9 5.3 1.3v3.9h3.9c.4 2.6.9 5.2 1.3 7.9h3.9c.4-2.6.9-5.2 1.3-7.9h3.9l.6-2.4c.6-2.2 1.3-4.5 2.1-6.7" style="fill:#6e90ae"/><path d="M152.1 353.9c3.4 1.3 5 2.2 6.9 5.4.4 1 .8 2.1 1.3 3.2.4 1 .8 2.1 1.3 3.2.5 1.4.5 1.4 1.1 2.7.4.9.9 1.7 1.3 2.6.1 2.6.1 5.3 0 7.9-1.7-.4-3.5-.9-5.3-1.3-.4-2.6-.9-5.2-1.3-7.9h-2.6c-3.4-4.2-4-6.1-3.5-11.6.2-1.4.5-2.8.8-4.2" style="fill:#45719a"/><path d="M274.2 306.6c.9.4 1.7.9 2.6 1.3-1.3 2.4-2.5 4.7-3.8 7.1-.7 1.3-1.4 2.6-2.1 4-2 3.4-2 3.4-4.6 6-2.1 4.5-3.8 8.7-5.1 13.5-2.4 7.8-5.1 16-12 20.6 1.7-5.8 3.5-11.4 6.6-16.6 2.7-4.3 2.7-4.3 3.9-9.1 1.4-4.9 3.7-8.1 6.6-12.4.7-2.4.7-2.4 1.3-4.8 1.5-4.6 2.9-6.5 6.6-9.6" style="fill:#7999b5"/><path d="M295.3 233.1h1.3v19.7H294c-.4 4.3-.9 8.7-1.3 13.1-2.2.9-4.3 1.7-6.6 2.6 1.4-8.5 3.6-16.2 6.7-24.3 1.3-3.6 2-7.2 2.5-11.1" style="fill:#44729e"/><path d="M210.6 124.4c1.5 0 3 .1 4.6.1l4.4.2c1.3 0 2.6.1 3.9.1 3.9.7 5.8 1.9 8.7 4.5-2.6.1-5.3.1-7.9 0L223 128c-3.2-.1-6.3-.2-9.5-.2h-8.9c-3.1 0-6.2 0-9.3-.1h-8.7c-4.8 0-6.8.2-11 3-4.2.2-4.2.2-7.9 0 5.4-5.9 11.4-5.9 19-6.4 8.1-.4 16-.1 23.9.1" style="fill:#78a1c7"/><path d="M127.2 305.3c.4.9.9 1.7 1.3 2.6h2.6c.3.9.6 1.7.9 2.6 1.5 3.4 3.1 6 5.2 9.1 3 4.6 3.8 7.7 3.1 13.2h-2.6v-3.9h-3.9c-.1-1-.3-2-.4-3-.6-3.6-.6-3.6-3-5.5-1.9-2-1.9-2-2.1-5.5.1-1 .2-2 .2-3h-2.6c.4-2.2.8-4.3 1.3-6.6" style="fill:#436f99"/><path d="M242.7 353.9h3.9v6.6c-1.3.7-1.3.7-2.6 1.3-.2 1.1-.4 2.2-.7 3.3-.2 1.1-.4 2.2-.7 3.3-1.3.7-1.3.7-2.6 1.3-.9 4-.9 4-1.3 7.9h-5.3c1.4-8.3 4.3-17 9.3-23.7m-13.1 25c1.3.7 1.3.7 2.6 1.3-1.3.7-1.3.7-2.6 1.3z" style="fill:#426f99"/><path d="M219.1 260.7c2 .7 2 .7 3.9 1.3-1.3.4-2.6.9-3.9 1.3v2.6c-1.3.4-1.3.4-2.5.8-1.1.4-2.2.7-3.3 1.1-1.6.5-1.6.5-3.3 1.1-2.7.8-2.7.8-4 2.3-7 .4-11.8-.2-18.4-2.6-3.1-.5-6.1-.9-9.2-1.3v-3.9h2.6v2.6c4.5 0 8.9.1 13.4.1h10.9c6.6-.3 6.6-.3 12.2-3.5.5-.7 1.1-1.3 1.6-1.9" style="fill:#4f81b2"/><path d="M216.5 196.3v1.3c-6.8.1-13-.1-19.7-1.3l-1.3 1.3c-2 .1-4 .1-6 .1h-5.8v2.6c-1.3-.4-2.6-.9-3.9-1.3 11.9-7.8 23.9-9.3 36.7-2.7" style="fill:#d1dde9"/><path d="M237.5 128c5.2.8 8.8 2.3 13.3 5 1.1.7 2.2 1.3 3.4 2 3.1 2.2 4.8 4.4 7 7.4 1.1.4 2.1.8 3.2 1.2 4.3 1.8 6 4.2 8.6 8-.9.4-1.7.9-2.6 1.3v-2.6c-.7-.1-1.5-.2-2.2-.3-4-1.3-6.4-3.6-9.6-6.2-1.1-.7-2.2-1.3-3.4-2-3.2-2-3.2-2-5.8-4.7-2.9-2.8-5.5-3.7-9.2-5.2-1-1.2-1.8-2.5-2.7-3.9" style="fill:#dde7f1"/><path d="M272.9 153c2.5 1 2.5 1 5.3 2.6.5 1.7.9 3.5 1.3 5.3 1.3.4 2.6.9 3.9 1.3.2.8.4 1.6.6 2.5.5 3 .5 3 3.3 4.6 3.6 2.8 4 5.7 5.3 10 .9.4 1.7.9 2.6 1.3-.4 1.7-.9 3.5-1.3 5.3-1.3-1.4-2.5-2.8-3.8-4.2l-2.1-2.4c-2-2.7-2-2.7-3.3-5.9-2-4.8-5.2-8.3-8.5-12.3-1.9-2.8-2.7-4.7-3.3-8.1" style="fill:#c8d8e6"/><path d="M169.2 397.2c2.6 1.3 2.6 1.3 3.8 4.3 1.7 4.1 4 6.8 6.7 10.2 2.1 3.6 3.2 7 4 11.1.3 1.5.3 1.5.7 3.1.3 1.4.3 1.4.6 2.9.3 1.3.5 2.7.8 4 .2.8.3 1.7.5 2.5-8.6-10.6-13.8-24.9-17.1-38.1" class="st44"/><path d="M292.6 268.6h2.6v7.9h-2.6c-.2 1.3-.3 2.5-.5 3.9-.3 1.3-.5 2.7-.8 4-1.3.7-1.3.7-2.6 1.3-.5 1.7-.5 1.7-1.1 3.4-1.9 5.6-4.9 10.1-8.1 15 0-4.1.5-5.4 2.5-8.9 2.5-4.8 3.9-9.5 5.4-14.8 1.6-4 3.4-7.9 5.2-11.8" style="fill:#c8d5e2"/><path d="M276.9 305.3h1.3c.7 5.6-1 8.8-3.9 13.5-1.5 2.3-1.5 2.3-2.5 5.6-1.9 4.4-4.8 7.4-8 11.1-.9 1.3-1.7 2.6-2.6 3.9 1.4-9 5.4-16.2 10.2-23.7 2-3.3 3.8-6.8 5.5-10.4" style="fill:#c9d6e2"/><path d="M102.2 262c3.8 5.7 5.7 10.6 7.1 17.3.6 2.7.6 2.7 3.4 5 1 2.3 1.9 4.7 2.8 7.1.5 1.3.9 2.5 1.4 3.9 1.1 3.5 1.1 3.5 1.1 7.4-3.9-6.1-3.9-6.1-3.9-10.5h-2.6q-2.1-3.15-3.9-6.6l-1.3-1.3-.6-4.5c-.3-4.7-.3-4.7-3.4-7.3-.4-3.5-.2-7-.1-10.5" style="fill:#b2c5d6"/><path d="M189 119.6h24c4 .6 5.2 1.3 7.6 4.4-1.6 0-3.3.1-4.9.1h-2.8c-2.8-.1-2.8-.1-5.3-.8-9.6-2.3-20.6-.6-30.3.7 3.2-4.5 6.2-4.3 11.7-4.4" style="fill:#dee8f1"/><path d="M230.9 392c.4.9.9 1.7 1.3 2.6-3.1 7.1-7 13.3-11.3 19.6-2 3-3.9 6.1-5.8 9.3-1.5-4.9-1.5-4.9 0-7.9h2.6c.1-.9.3-1.9.4-2.9 1-4.1 2.4-6.9 4.8-10.3h2.6l.4-3.8c1.1-4 1.1-4 5-6.6" style="fill:#6085a7"/><path d="M190.2 183.2c-1.1.5-1.1.5-2.3 1.1-3.8 2-7.4 4.4-11 6.8-2.5 1.3-2.5 1.3-6.4 1.3-.4 1.3-.9 2.6-1.3 3.9h-2.6c-.2.8-.3 1.5-.5 2.3-.9 3.1-2.1 4.9-4.1 7.5-2.4 3.2-4.4 6.2-6 9.8-.6-8 3.3-12.2 7.9-18.4 1.3-1.8 1.3-1.8 2.5-3.7 3.6-3.8 7.2-5.7 11.9-8.1.9-.5 1.8-1.1 2.8-1.6 3.2-1.2 5.8-1.1 9.1-.9" style="fill:#5d91c2"/><path d="M108.8 187.1c1.3 3.9 1.3 3.9.2 6.5l-1.5 2.7c-.5 1.9-.9 3.8-1.3 5.7q-1.65 7.8-3.9 15.3H101c-.1-2.8-.1-5.6-.2-8.4 0-1.6-.1-3.1-.1-4.7.3-4 .3-4 2.9-6.6.4-1.3.9-2.6 1.3-3.9 1.3-3.9 1.3-3.9 3.9-6.6" style="fill:#4879a6"/><path d="M182.3 422.2c2 1.3 2 1.3 3.9 2.6v2.6h2.6c.4-1.3.9-2.6 1.3-3.9.9.4 1.7.9 2.6 1.3 1.8 5.5 1 10.2 0 15.8-.9-1.3-1.7-2.6-2.6-3.9-1.3-.4-2.6-.9-3.9-1.3-1.2-3-1.2-3-2.2-6.6-.3-1.2-.7-2.4-1-3.7-.2-1-.4-1.9-.7-2.9" style="fill:#44709b"/><path d="M123.2 155.6c0 5.8-2.8 9.1-6.4 13.5-3.1 4.1-5.4 8.3-7.7 12.8-1.6 2.6-1.6 2.6-5.6 5.2 1.2-3.4 2.4-6.2 4.6-9.1 2.2-2.7 2.2-2.7 3.3-6.6 1.3-3.8 2.5-6.2 5.3-9.2h2.6c.4-.8.8-1.7 1.2-2.5 1.4-2.8 1.4-2.8 2.7-4.1" style="fill:#8faecb"/><path d="M127.2 321.1c7.2 6.8 10.6 12.8 13.3 22.2 1.2 3 2.8 4.4 5 6.7-.4 2.9-.4 2.9-1.3 5.3-.6-.9-1.1-1.9-1.7-2.9l-4.2-6.9c-3.6-5.9-6.5-11.2-8.5-17.8-.9-.4-1.7-.9-2.6-1.3z" style="fill:#aec1d3"/><path d="M104.8 208.1h1.3c0 1.4-.1 2.9-.1 4.3 0 1.9 0 3.8-.1 5.7 0 1.4 0 1.4-.1 2.8-.1 5.9.8 10.4 2.9 16 .3 3.4.3 3.4.2 6.4 0 1 0 2.1-.1 3.1 0 1.1 0 1.1-.1 2.3-4.6-8.5-5.6-15.7-5.5-25.2v-3.2c.1-4.4.2-7.8 1.6-12.2" style="fill:#5588ba"/><path d="M198.5 190.8c1.5 0 1.5 0 2.9.1 2.4 0 4.8.1 7.1.2.4.9.9 1.7 1.3 2.6l-3-.2c-11.5-.4-20.2 1.4-29.7 8.1-2.7 2.6-5.4 5.2-8 7.9 1.8-6.9 6.3-10.4 11.8-14.4 1-.7 1.9-1.4 2.9-2.2 5-2.4 9.4-2.3 14.7-2.1" style="fill:#8eadc9"/><path d="M110.1 166.1c1.3.4 2.6.9 3.9 1.3-2.1 5.2-4.4 10.1-7.2 15-3 5.4-5.2 10.7-7.2 16.5h-1.3c-.9-7.1 1.5-11.5 4.8-17.6 2.5-4.9 4.8-10 7-15.2" style="fill:#d1deea"/><path d="M219.1 414.3c.8 2.4.8 2.4 1.3 5.3-1.3 2-1.3 2-2.6 3.9-.3 1.1-.7 2.2-1 3.4-2.8 7.7-6.6 15.7-13.5 20.3.4-.7.7-1.4 1.1-2.1 2.7-5.6 5-11.4 7.4-17.1 1.8-3.9 3.7-7.4 6-11 .4-.9.8-1.8 1.3-2.7" style="fill:#a7bbcf"/><path d="M175.8 248.9h5.3c.7 1.1 1.4 2.1 2.2 3.2 3.5 3.8 4.2 3.9 9.1 4.3 2.3.1 4.6.1 6.8.1 1.7 0 1.7 0 3.5.1 2.9.1 5.7.1 8.6.1v-2.6c2.2-.4 4.3-.9 6.6-1.3-2.3 3.3-4.1 5-8.1 5.8-3.6.2-7.1.3-10.7.3-1.2 0-2.4.1-3.7.1-8 0-12.3-1.4-18-7-1.6-1.8-1.6-1.8-1.6-3.1" style="fill:#cedae6"/><path d="M248 360.5c.4 1.7.9 3.5 1.3 5.3-1.3.7-1.3.7-2.6 1.3-1.3 2.5-2.6 4.9-3.8 7.5-2.8 5.7-5.6 11-9.4 16.2 0-4.5.6-8.7 1.3-13.1 1.3-.4 2.6-.9 3.9-1.3 1.8-2.6 1.8-2.6 3.4-5.7 1.9-3.7 3.7-6.9 5.9-10.2" style="fill:#a5bacd"/><path d="M302.6 410c3.2 1.7 3.2 1.7 4.3 4.5.1.8.2 1.6.2 2.5-3.3 2-3.3 2-6.6 3.9-2-1.6-2-1.6-3.9-3.9-.2-3.5-.2-3.5 0-6.6 2.6-1.3 2.6-1.3 6-.4" style="fill:#818cdf"/><path d="M102.2 188.4h1.3c.3 7.4.3 7.4-2.6 11.8-.6 3.5-.6 3.5-.7 7.3-.4 6.9-.4 6.9-1.9 9.8h-2.6c-.6-9.7 1.3-16.2 5.3-25 .3-1.2.8-2.5 1.2-3.9" style="fill:#83a4c2"/><path d="M142.9 138.5c.9.4 1.7.9 2.6 1.3-1.5 1.3-2.9 2.6-4.4 3.9-1.2 1.1-1.2 1.1-2.5 2.2-2.3 1.7-2.3 1.7-4.9 1.7-.4 1.3-.9 2.6-1.3 3.9h-3.9c-.4 1.3-.9 2.6-1.3 3.9-.9-.4-1.7-.9-2.6-1.3.9-2.2 1.7-4.3 2.6-6.6h2.6v-2.6c1.2-.4 2.4-.7 3.6-1.1 3.9-1.2 6.4-2.6 9.5-5.3" style="fill:#b7ccdf"/><path d="M222.5 465.4c1 .1 2.1.1 3.1.2.1 2.6.1 5.3 0 7.9-1.3 1.3-1.3 1.3-4.7 1.4-1.6 0-1.6 0-3.2-.1-.8-3.1-.8-3.1-1.3-6.6 2.7-2.7 2.7-2.7 6.1-2.8" style="fill:#d2d7ef"/><path d="M182.3 405.1c.4.9.9 1.7 1.3 2.6h2.6c3.9 7.4 3.9 7.4 3.9 11.8h2.6v5.3c-3.6-1.3-5-2.2-6.9-5.6-.6-1.7-.6-1.7-1.3-3.5-.4-1.1-.9-2.3-1.3-3.5-.9-3.1-.9-3.1-.9-7.1" style="fill:#4376ae"/><path d="M167.9 209.5h1.3l-.3 4.5c-.1 2-.3 4-.4 6l-.2 3c-.4 6.7-.1 12.8 1 19.4-3.5-3.5-4-5.4-4.4-10.5 0-2 0-4 .1-6v-3c-.1-5.1 0-9.2 2.9-13.4" style="fill:#a2bbd1"/><path d="M295.3 191.1h2.6c.3.8.5 1.7.8 2.5.4 1.1.7 2.2 1.1 3.3s.7 2.2 1.1 3.3c.8 2.7.8 2.7 2.3 4 .1 2.9.1 5.7.1 8.6v8.5H302c-.3-2.6-.5-5.1-.8-7.7-.4-2.8-.4-2.8-1.8-5.3-1.7-3.4-2.2-6.6-2.8-10.3-.2-1.3-.4-2.6-.7-3.9q-.45-1.5-.6-3" style="fill:#a8bfd3"/><path d="M107.5 271.2h1.3v6.6c2.6.7 2.6.7 5.3 1.3.4-1.7.9-3.5 1.3-5.3v10.6c.1 3.6.6 6.9 1.4 10.4-3.1-3.8-4.8-7.3-6.6-11.8-.9-1.3-1.7-2.6-2.6-3.9-.3-4.2-.3-4.2-.1-7.9" style="fill:#5980a3"/><path d="M167.9 206.8c.4.9.9 1.7 1.3 2.6-.6 1-.6 1-1.2 2.1-2.1 4.7-1.7 9.3-1.7 14.3v3c0 4.7.3 8.9 1.6 13.4h-2.6c-1.9-5.6-1.6-11.1-1.6-17 0-1.2-.1-2.4-.1-3.6v-6.7c.4-3.5 1.9-5.3 4.3-8.1" style="fill:#688fb2"/><path d="M213.8 418.3c.5 8.6.5 8.6-2 11.7-.7.5-1.3.9-2 1.4-.4-1.3-.9-2.6-1.3-3.9-1.3.9-2.6 1.7-3.9 2.6 1.9-8.7 1.9-8.7 5.3-11.1 2.6-.7 2.6-.7 3.9-.7" style="fill:#48739c"/><path d="M145.5 355.2c4.9 3.3 7.1 6.6 8.8 12.2.1.7.3 1.5.4 2.2h2.6c.4 2.6.9 5.2 1.3 7.9-4.4-4-6.7-7.8-9.2-13.1l-2.2-3.6c-1.7-2.9-1.7-2.9-1.7-5.6" class="st44"/><path d="M251.9 138.5c9 2.8 15.5 5.3 21 13.1-1.3.7-1.3.7-2.6 1.3v-2.6c-.7-.1-1.5-.2-2.2-.3-4-1.3-6.4-3.6-9.6-6.2-1.2-.7-2.4-1.5-3.7-2.2-.9-.6-1.9-1.1-2.9-1.7z" style="fill:#c2d4e4"/><path d="M234.8 130.7c2.4.6 4.7 1.1 7.1 1.7 1.3.3 2.6.6 4 1 3.4 1.2 3.4 1.2 6 5.2-6.9.5-11-1-17.1-3.9z" style="fill:#87abcb"/><path d="M139 347.3c4.5 2.9 6.5 5.7 8.9 10.5.6 1.1 1.1 2.3 1.7 3.4 1.3 3.1 1.3 3.1 1.3 7.1-1.3-.9-2.6-1.7-3.9-2.6v-5.3c-1.3-.4-2.6-.9-3.9-1.3-2-3.8-3.2-7.6-4.1-11.8" style="fill:#d6e0e9"/><path d="M379.3 355.2c2.2.4 4.3.9 6.6 1.3-.4 2.2-.9 4.3-1.3 6.6-2.2-.4-4.3-.9-6.6-1.3.4-2.2.9-4.3 1.3-6.6" style="fill:#dee4f5"/><path d="M215.1 124.1c15.1-1.4 15.1-1.4 22 3.9 1.5 1.3 2.9 2.6 4.3 3.9-4.2 0-5.2-.5-8.7-2.5-5.8-3-11.1-3.7-17.6-4z" style="fill:#c6d8e7"/><path d="M211.2 436.6c.8 2.5.8 2.5 1.3 5.3l-2.6 2.6c-.4 1.1-.8 2.2-1.2 3.4-.5 1.1-.9 2.1-1.4 3.2-1.3.4-2.6.9-3.9 1.3.5-6.6 3.9-10.7 7.8-15.8" style="fill:#e2e9ef"/><path d="M259.8 339.5c0 4.5-1 6.6-3 10.6-.6 1.2-1.1 2.3-1.7 3.5-1.8 3-1.8 3-5.8 5.6 2.2-8.1 4.7-13.6 10.5-19.7" style="fill:#afc2d3"/><path d="M296.6 248.9h1.3c.2 3.1.2 3.1 0 6.6l-2.6 2.6c-.3 2.5-.6 5.1-.9 7.6-.7 4.4-2.5 8-4.4 12-.8-2.3-.8-2.3-1.3-5.3.4-.8.8-1.5 1.2-2.3 1.7-3.5 2.3-6.7 2.9-10.5.2-1.3.4-2.6.7-3.9.2-1 .3-2 .5-3h2.6z" style="fill:#6a8eaf"/><path d="M186.3 435.3c1.7.4 3.5.9 5.3 1.3 4.1 7.6 4.1 7.6 3.5 12.4l-.9 2.1v-3.9h-3.9c-1.4-4-2.7-7.9-4-11.9" style="fill:#9ab2c8"/><path d="M192.8 447.1c2.6 3.9 2.6 3.9 2.6 6.6 2.6-.4 5.2-.9 7.9-1.3v5.3h-7.9c-2.3-4.1-2.6-5.6-2.6-10.6" style="fill:#d0dce5"/><path d="M244 360.5h3.9c-2.6 5.3-5.3 10.5-7.9 15.8h-1.3v-6.6c1.3-.4 2.6-.9 3.9-1.3 0-1.1-.1-2.1-.1-3.2.2-3.4.2-3.4 1.5-4.7" style="fill:#6387a8"/><path d="M290 181.9c3.1 1.6 4.9 3.1 6.2 6.4 1.3 5 1.9 8.3.3 13.3-2.9-6.5-4.9-12.7-6.5-19.7" style="fill:#7ca0c0"/><path d="M119.3 302.7c5.1 5.5 6.4 9.9 7.9 17.1-4.3-2.9-5.7-4.6-7.9-9.2-.3-4.4-.3-4.4 0-7.9" style="fill:#a9bdd0"/><path d="M284.8 284.3c1.6 3.9 1.3 5.2-.3 9.2-1.1 2.1-1.1 2.1-2.3 4.2-.7 1.4-1.5 2.8-2.3 4.3l-1.8 3.3c-1.6-3.9-1.3-5.2.3-9.2.7-1.4 1.5-2.8 2.3-4.2.7-1.4 1.5-2.8 2.3-4.3.6-1.1 1.1-2.2 1.8-3.3" style="fill:#88a4bd"/><path d="M110.1 288.2c.4 1.3.9 2.6 1.3 3.9h2.6c3.8 8.7 3.8 8.7 5.3 13.1-2.5-.2-2.5-.2-5.3-1.3-2.2-5-3.1-10.2-3.9-15.7" style="fill:#dae3ec"/><path d="M179.7 254.1c2.6 1.3 2.6 1.3 5.3 2.6v2.6c6.5.4 13 .9 19.7 1.3v1.3c-14.8.3-14.8.3-21-1.3-2.6-3.3-2.6-3.3-4-6.5" style="fill:#6b8fb1"/><path d="m263.7 147.7 6.6 2.6v2.6c1.3.4 2.6.9 3.9 1.3.9 2.7.9 2.7 1.3 5.3-1.3-.4-2.6-.9-3.9-1.3v-2.6c-1.1-.3-2.1-.7-3.2-1-3.4-1.6-3.4-1.6-4.4-4.4-.1-.8-.2-1.6-.3-2.5" style="fill:#86a9c9"/><path d="M215.1 195c1.8.9 3.5 1.8 5.3 2.6l2.7 1.2c2.8 1.5 4.6 2.8 6.5 5.3-.4 2.9-.4 2.9-1.3 5.3v-2.6c-1.3-.4-2.6-.9-3.9-1.3-.4-1.3-.9-2.6-1.3-3.9q-3.3-2.1-6.6-3.9c-.5-.9-.9-1.8-1.4-2.7" style="fill:#7b9fc0"/><path d="M182.3 422.2c2 1.3 2 1.3 3.9 2.6v2.6h2.6c.4 2.6.9 5.2 1.3 7.9h-3.9c-1.2-4.3-2.5-8.6-3.9-13.1" style="fill:#6689aa"/><path d="M241.4 348.7h2.6c-3.1 6.1-6.1 12.3-9.2 18.4-.4-.9-.9-1.7-1.3-2.6 1.6-3.7 1.6-3.7 3.9-8 .7-1.4 1.5-2.9 2.3-4.4.5-1.2 1.1-2.3 1.7-3.4" style="fill:#4d81bc"/><path d="M209.9 125.4c2.7-.1 5.5-.2 8.2-.2 2.3-.1 2.3-.1 4.6-.1 4.5.4 6.2 1.2 9.5 4.3-2.6.1-5.3.1-7.9 0l-1.3-1.3c-2.2-.3-4.4-.5-6.6-.7-1.2-.1-2.4-.2-3.7-.3l-2.8-.2z" style="fill:#90b2d0"/><path d="M230.9 392c.4.9.9 1.7 1.3 2.6-.9 2.5-.9 2.5-2.4 5.3-.5.9-.9 1.9-1.4 2.8-1.5 2.3-1.5 2.3-4.1 3.6.3-1.9.7-3.8 1.1-5.7.2-1.1.4-2.1.6-3.2 1-2.8 1-2.8 4.9-5.4" style="fill:#819fb9"/><path d="m286.1 171.4 5.3 5.3c.8.7 1.6 1.3 2.4 2 .5.7 1 1.3 1.6 2-.6 2.7-.6 2.7-1.3 5.3-1.3-1.4-2.6-2.8-3.9-4.3-.7-.8-1.5-1.6-2.2-2.4-1.9-2.7-1.9-2.7-1.9-7.9" style="fill:#b4cadd"/><path d="M111.4 260.7c5.6 8.4 5.6 8.4 5.3 14.4h2.6v2.6c-3.9-1.3-3.9-1.3-5.2-3.6-.3-.9-.6-1.8-1-2.8-.3-.9-.7-1.8-1-2.7-.7-2.7-.7-2.7-.7-7.9" style="fill:#4f82b4"/><path d="M103.5 181.9c.9.4 1.7.9 2.6 1.3-3.3 7.8-3.3 7.8-6.6 15.8h-1.3c-.6-7.4 1.5-11.1 5.3-17.1" style="fill:#c4d6e4"/><path d="M190.2 120.2c-.4.9-.9 1.7-1.3 2.6-2.7.5-2.7.5-6 .8l-3.3.3c-1.3.1-1.3.1-2.5.2 4-4.7 7-4.3 13.1-3.9" style="fill:#e6edf5"/><path d="M173.1 410.4c4.1 3.3 5.1 6.8 6.6 11.8.4 1.3.9 2.6 1.3 3.9-1.3-.4-2.6-.9-3.9-1.3-.4-3-.9-6.1-1.3-9.2h-2.6c-.1-1.7-.1-3.4-.1-5.2" style="fill:#d8e1ea"/><path d="M125.9 315.8h2.6c.1.8.3 1.7.4 2.5l.9 2.7c1.3.4 2.6.9 3.9 1.3-.4 1.7-.9 3.5-1.3 5.3-5.8-6.7-5.8-6.7-6.5-11.8" style="fill:#7394b1"/><path d="M265.1 332.9c1.3 3.9 1.3 3.9.3 6.3-1.4 2.3-2.8 4.5-4.2 6.8h-2.6c1.6-4.7 3.8-8.8 6.5-13.1" style="fill:#eaeff5"/><path d="M102.2 188.4h1.3c.5 6.4-.8 10.2-3.9 15.8-1.5-4.3-1.2-6.6.6-10.8.4-.9.8-1.9 1.1-2.8.3-.7.6-1.4.9-2.2" style="fill:#8cabc8"/><path d="M179.7 126.7c-1.5 2-1.5 2-3.9 3.9-4.2.2-4.2.2-7.9 0 3.4-4.5 6.3-4.2 11.8-3.9" style="fill:#719cc2"/><path d="M234.8 377.5h3.9c-.6 1.9-1.2 3.9-1.8 5.8-.3 1.1-.7 2.2-1 3.3-1.1 2.7-1.1 2.7-3.7 4 .9-4.3 1.8-8.6 2.6-13.1" style="fill:#7898b4"/><path d="M158.7 377.5c3.9 1.3 3.9 1.3 5.6 4.4 1 3.4 1 3.4-.3 7.4-5.3-8.8-5.3-8.8-5.3-11.8" style="fill:#668aaa"/><path d="M107.5 272.2h1.3v6.6c1.3.4 2.6.9 3.9 1.3-.4 1.7-.9 3.5-1.3 5.3-3.1-3.3-3.9-4.9-4.2-9.5.1-1.3.2-2.5.3-3.7" style="fill:#7697b4"/></svg>
\ No newline at end of file
service/dawarich/redis-insight.pkl created+36
......@@ -0,0 +1,36 @@
1amends "../../config/Service.pkl"
2
3meta { name = "Redis Insight" }
4dependsOn { "dawarich" }
5
6secrets {
7 ["encryption_key"] {}
8}
9
10container {
11 image = "docker.io/redis/redisinsight@sha256:0131987fd5fefe3828ed33666f9742bd071b729f5a95cf923c593c14a8995c2c"
12 cpu = 150
13 memory = 512
14
15 http {
16 containerPort = 5540
17 subdomain = "redis"
18 authRole = "infra-admin"
19 checkPath = "/api/health/"
20 }
21
22 volumes {
23 ["/data"] {}
24 }
25
26 env {
27 ["RI_ACCEPT_TERMS_AND_CONDITIONS"] = "true"
28 ["RI_ENCRYPTION_KEY"] = "${secret.own.encryption_key}"
29 ["RI_REDIS_ALIAS"] = "Dawarich Redis"
30 }
31
32 envTemplate = """
33 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "dawarich-redis" }}RI_REDIS_HOST={{ .Address }}
34 RI_REDIS_PORT={{ .Port }}{{ end }}
35 """
36}
service/dawarich/redis-insight/icon.svg created+1
......@@ -0,0 +1 @@
1<svg fill="#dc382d" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Redis</title><path d="M22.71 13.145c-1.66 2.092-3.452 4.483-7.038 4.483-3.203 0-4.397-2.825-4.48-5.12.701 1.484 2.073 2.685 4.214 2.63 4.117-.133 6.94-3.852 6.94-7.239 0-4.05-3.022-6.972-8.268-6.972-3.752 0-8.4 1.428-11.455 3.685C2.59 6.937 3.885 9.958 4.35 9.626c2.648-1.904 4.748-3.13 6.784-3.744C8.12 9.244.886 17.05 0 18.425c.1 1.261 1.66 4.648 2.424 4.648.232 0 .431-.133.664-.365a100.49 100.49 0 0 0 5.54-6.765c.222 3.104 1.748 6.898 6.014 6.898 3.819 0 7.604-2.756 9.33-8.965.2-.764-.73-1.361-1.261-.73zm-4.349-5.013c0 1.959-1.926 2.922-3.685 2.922-.941 0-1.664-.247-2.235-.568 1.051-1.592 2.092-3.225 3.21-4.973 1.972.334 2.71 1.43 2.71 2.619z"/></svg>
service/dawarich/service.pkl created+130
......@@ -0,0 +1,130 @@
1extends "../../config/Service.pkl"
2
3import "../../config/Service.pkl" as service
4import "../keycloak/service.pkl" as keycloak
5import "../postgres/service.pkl" as postgres
6
7local dawarichImage = "docker.io/freikin/dawarich@sha256:76ec5fa62f414a5ca9e6dd71a9a5b09088c0011075c41644ba35bbb67627a943"
8
9local database = new postgres.Database {
10 name = "dawarich"
11 extensions { "postgis" }
12}
13
14local commonEnv: Mapping<String, String> = new {
15 ["RAILS_ENV"] = "production"
16 ["DATABASE_USERNAME"] = "${secret.database.username}"
17 ["DATABASE_PASSWORD"] = "${secret.database.password}"
18 ["DATABASE_NAME"] = "${secret.database.name}"
19 ["PGCONNECT_TIMEOUT"] = "10"
20 ["SECRET_KEY_BASE"] = "${secret.own.secret_key_base}"
21 ["APPLICATION_HOSTS"] = "localhost,127.0.0.1,\(module.containers["web"].http.hostname)"
22 ["APPLICATION_PROTOCOL"] = "https"
23 ["SELF_HOSTED"] = "true"
24 ["STORE_GEODATA"] = "true"
25 ["TIME_ZONE"] = "America/Los_Angeles"
26 ["PUID"] = "\(module.uid)"
27 ["PGID"] = "\(module.uid)"
28}
29
30local sharedVolumes: Mapping<String, service.Volume> = new {
31 ["/var/app/public"] {}
32 ["/var/app/storage"] {}
33 ["/var/app/tmp/imports/watched"] {}
34 ["/etc/ssl/certs/ca-certificates.crt"] {
35 src = "/var/lib/studio/ca-bundle.crt"
36 readOnly = true
37 }
38}
39
40local databaseEnv = """
41 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "postgres" }}DATABASE_HOST={{ .Address }}
42 DATABASE_PORT={{ .Port }}{{ end }}
43 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "\(module.id)-redis" }}REDIS_URL=redis://\(module.nomadHostPort){{ end }}
44 """
45
46meta { name = "Dawarich" }
47healthyDeadline = "20m"
48
49requirements {
50 database
51 new keycloak.OpenIDClient {
52 clientId = module.id
53 name = module.meta.name
54 }
55}
56
57secrets {
58 ["secret_key_base"] { bytes = 64 }
59}
60
61containers {
62 ["migrate"] {
63 image = dawarichImage
64 entrypoint = "web-entrypoint.sh"
65 args { "ruby"; "-e"; "exit 0" }
66 imageUser = true
67 lifecycle = "prestart"
68 cpu = 200
69 memory = 1024
70 volumes = sharedVolumes
71 env = commonEnv
72 envTemplate = databaseEnv
73 }
74
75 ["web"] {
76 image = dawarichImage
77 extraHosts { "\(keycloak.container.http.hostname):host-gateway" }
78 entrypoint = "web-entrypoint.sh"
79 args { "bin/rails"; "server"; "-p"; "3000"; "-b"; "::" }
80 imageUser = true
81 cpu = 400
82 memory = 2048
83
84 http {
85 containerPort = 3000
86 subdomain = "dawarich"
87 checkPath = "/api/v1/health"
88 checkHeaders { ["X-Forwarded-Proto"] = "https" }
89 }
90
91 volumes = sharedVolumes
92 env = (commonEnv) {
93 ["DOMAIN"] = module.containers["web"].http.hostname
94 ["WEB_CONCURRENCY"] = "1"
95 ["OIDC_CLIENT_ID"] = "${secret.oidc.clientId}"
96 ["OIDC_CLIENT_SECRET"] = "${secret.oidc.clientSecret}"
97 ["OIDC_ISSUER"] = "https://\(keycloak.container.http.hostname)/realms/master"
98 ["OIDC_REDIRECT_URI"] = "https://\(module.containers["web"].http.hostname)/users/auth/openid_connect/callback"
99 ["ALLOW_EMAIL_PASSWORD_REGISTRATION"] = "false"
100 }
101 envTemplate = databaseEnv
102 }
103
104 ["worker"] {
105 image = dawarichImage
106 entrypoint = "sidekiq-entrypoint.sh"
107 args { "sidekiq" }
108 imageUser = true
109 cpu = 200
110 memory = 1024
111 volumes = sharedVolumes
112 env = (commonEnv) {
113 ["BACKGROUND_PROCESSING_CONCURRENCY"] = "3"
114 }
115 envTemplate = databaseEnv
116 }
117
118 ["redis"] {
119 image = "docker.io/library/redis@sha256:718f745deb7dfefeac6eed7041fc7ec9476b50e61b247932682457c41adafa0e"
120 lifecycle = "prestartSidecar"
121 args { "redis-server"; "--save"; "900"; "1"; "--appendonly"; "no" }
122 memory = 256
123 tcp {
124 name = "redis"
125 containerPort = 6379
126 loopback = false
127 }
128 volumes { ["/data"] {} }
129 }
130}
service/ddns-updater/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#4b83c4"/><g fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 31a18 18 0 1 1 33 9"/><path d="M47 29v12H35"/><path d="M50 39a18 18 0 0 1-33-9"/><path d="M17 41V29h12"/><circle cx="32" cy="32" r="5"/></g></svg>
service/ddns-updater/service.pkl created+27
......@@ -0,0 +1,27 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5meta { name = "DDNS" }
6enabled = !site.preview && !site.domain.endsWith(".test")
7
8requiredSecrets { "cloudflare_zone_id"; "cloudflare_api_token" }
9
10container {
11 image = "docker.io/qmcgaw/ddns-updater@sha256:68f3ea596a66e09d137c997d818d8ad50f780eaa73b4e8a6263f680318070da9"
12 cpu = 100
13 memory = 128
14
15 http {
16 containerPort = 8000
17 subdomain = "ddns"
18 authRole = "infra-admin"
19 }
20
21 volumes { ["/updater/data"] {} }
22
23 env {
24 ["PERIOD"] = "5m"
25 ["CONFIG"] = "{\"settings\":[{\"provider\":\"cloudflare\",\"zone_identifier\":\"${secret.own.cloudflare_zone_id}\",\"domain\":\"*.\(site.domain)\",\"ttl\":1,\"proxied\":false,\"ip_version\":\"ipv4\",\"token\":\"${secret.own.cloudflare_api_token}\"}]}"
26 }
27}
service/evil-forgejo/api.py created+33
......@@ -0,0 +1,33 @@
1import json
2import os
3import subprocess
4import urllib.request
5
6
7def nomad(path):
8 request = urllib.request.Request(
9 "http://127.0.0.1:4646/v1/" + path,
10 headers={"X-Nomad-Token": os.environ["NOMAD_TOKEN"]},
11 )
12 with urllib.request.urlopen(request, timeout=5) as response:
13 return json.load(response)
14
15
16def instance(job="evil-forgejo"):
17 running = [item["ID"] for item in nomad(f"job/{job}/allocations")
18 if item["ClientStatus"] == "running" and item["DesiredStatus"] == "run"]
19 if len(running) != 1:
20 raise ValueError(f"{job} needs one running allocation")
21 allocation = running[0]
22 services = [item for item in nomad(f"service/{job}-forgejo") if item["AllocID"] == allocation]
23 if len(services) != 1:
24 raise ValueError(f"{job} has no Forgejo listener")
25 result = subprocess.run(
26 ["podman", "--url", "unix:///run/podman/podman.sock", "ps", "--format", "{{.ID}} {{.Names}}"],
27 check=True, capture_output=True, text=True,
28 )
29 containers = [parts[0] for line in result.stdout.splitlines()
30 if len(parts := line.split()) == 2 and parts[1] == "forgejo-" + allocation]
31 if len(containers) != 1:
32 raise ValueError(f"{job} Forgejo task is unavailable")
33 return containers[0], f"http://{services[0]['Address']}:{services[0]['Port']}"
service/evil-forgejo/app.ini created+115
......@@ -0,0 +1,115 @@
1APP_NAME="evil inc"
2APP_SLOGAL="be evil. take control back."
3RUN_MODE=prod
4RUN_USER=git
5WORK_PATH=/data/work
6
7[repository]
8MAX_CREATION_LIMIT=20000
9ROOT=/data/git/repositories
10DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH=true
11DEFAULT_REPO_UNITS=repo.code,repo.issues,repo.pulls
12DISABLE_STARS=true
13DEFAULT_BRANCH=master
14
15[repository.local]
16LOCAL_COPY_PATH=/data/work/tmp/local-repo
17
18[repository.upload]
19TEMP_PATH=/data/work/uploads
20
21[ui]
22EXPLORE_PAGING_NUM=100
23ISSUE_PAGING_NUM=500
24MEMBERS_PAGING_NUM=100
25THEMES=evil,evil-dark,evil-light
26DEFAULT_THEME=evil
27
28[server]
29APP_DATA_PATH=/data/work
30DOMAIN=$GIT_HOST
31SSH_DOMAIN=$GIT_HOST
32HTTP_ADDR=0.0.0.0
33HTTP_PORT=$FORGEJO_HTTP_PORT
34ROOT_URL=https://$GIT_HOST/
35LFS_JWT_SECRET=$FORGEJO_SERVER_LFS_JWT_SECRET
36LFS_START_SERVER=true
37OFFLINE_MODE=true
38LANDING_PAGE=/evil
39START_SSH_SERVER=false
40SSH_CREATE_AUTHORIZED_KEYS_FILE=false
41
42[database]
43DB_TYPE=postgres
44HOST=$DB_HOST
45NAME=$DB_NAME
46USER=$DB_USER
47PASSWD=$DB_PASSWORD
48LOG_SQL=false
49SCHEMA=
50SSL_MODE=disable
51
52[indexer]
53ISSUE_INDEXER_PATH=/data/work/indexers/issues.bleve
54
55[session]
56PROVIDER_CONFIG=/data/work/sessions
57PROVIDER=file
58
59[picture]
60AVATAR_UPLOAD_PATH=/data/work/avatars
61REPOSITORY_AVATAR_UPLOAD_PATH=/data/work/repo-avatars
62
63[admin]
64DISABLE_REGULAR_ORG_CREATION=true
65
66[attachment]
67PATH=/data/work/attachments
68
69[log]
70MODE=console
71LEVEL=info
72ROOT_PATH=/data/work/log
73
74[security]
75INSTALL_LOCK=true
76REVERSE_PROXY_LIMIT=1
77REVERSE_PROXY_TRUSTED_PROXIES=*
78PASSWORD_HASH_ALGO=pbkdf2_hi
79SECRET_KEY=$FORGEJO_SECURITY_SECRET_KEY
80INTERNAL_TOKEN=$FORGEJO_SECURITY_INTERNAL_TOKEN
81
82[service]
83ENABLE_INTERNAL_SIGNIN=true
84DISABLE_REGISTRATION=false
85ALLOW_ONLY_EXTERNAL_REGISTRATION=true
86SHOW_REGISTRATION_BUTTON=false
87REQUIRE_SIGNIN_VIEW=false
88REGISTER_EMAIL_CONFIRM=true
89ENABLE_NOTIFY_MAIL=true
90DEFAULT_KEEP_EMAIL_PRIVATE=false
91DEFAULT_ALLOW_CREATE_ORGANIZATION=true
92DEFAULT_ENABLE_TIMETRACKING=false
93
94[lfs]
95PATH = /data/git/lfs
96
97[mailer]
98ENABLED=$MAILER_ENABLED
99; TODO: get evil mailing credentials. we likely will not need this
100; for a while and can just use clover's email. its not that deep
101FROM=noreply.evil-git@paperclover.net
102PROTOCOL=smtps
103SMTP_PORT=465
104SMTP_ADDR=$MAILER_ADDRESS
105USER=$MAILER_USERNAME
106PASSWD=$MAILER_PASSWORD
107
108[repository.pull-request]
109DEFAULT_MERGE_STYLE=squash
110
111[repository.signing]
112DEFAULT_TRUST_MODEL=committer
113
114[oauth2]
115JWT_SECRET=$FORGEJO_OAUTH2_JWT_SECRET
service/evil-forgejo/icon.svg created+1
......@@ -0,0 +1 @@
1<svg fill="#fb923c" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Forgejo</title><path d="M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z"/></svg>
service/evil-forgejo/provide.py created+67
......@@ -0,0 +1,67 @@
1#!/usr/bin/env python3
2import json
3import sys
4import urllib.error
5import urllib.request
6
7from api import instance
8
9
10data = json.load(sys.stdin)
11request = data["request"]
12if request["kind"] != "oauth-client":
13 raise ValueError("unsupported Forgejo input")
14stage = data.get("stageId")
15existing = data.get("existing") or {}
16if data.get("operation") == "delete" and not stage:
17 raise ValueError("refusing to delete a production OAuth client")
18_, address = instance()
19authorization = "token " + data["providerSecrets"]["automation_token"]
20
21
22def api(path, method="GET", body=None, missing_ok=False):
23 query = urllib.request.Request(
24 address + "/api/v1" + path,
25 data=json.dumps(body).encode() if body is not None else None,
26 method=method,
27 headers={"Authorization": authorization, "Content-Type": "application/json"},
28 )
29 try:
30 with urllib.request.urlopen(query, timeout=10) as response:
31 return json.load(response) if response.status != 204 else None
32 except urllib.error.HTTPError as error:
33 if error.code == 404 and (method == "DELETE" or missing_ok):
34 return None
35 raise RuntimeError(f"Forgejo OAuth API returned HTTP {error.code}") from error
36
37
38application_id = existing.get("applicationId")
39if data.get("operation") == "delete":
40 if application_id:
41 api("/user/applications/oauth2/" + application_id, "DELETE")
42 sys.exit(0)
43
44suffix = request["name"] + (":" + stage if stage else "")
45name = "Snow Globe: " + suffix
46desired = {"name": name, "redirect_uris": request["redirectUris"], "confidential_client": True}
47if application_id:
48 current = api("/user/applications/oauth2/" + application_id, missing_ok=True)
49 if current and current["client_id"] == existing.get("clientId") and existing.get("clientSecret"):
50 if any(current[key] != value for key, value in desired.items()):
51 api("/user/applications/oauth2/" + application_id, "PATCH", desired)
52 client_id = existing["clientId"]
53 client_secret = existing["clientSecret"]
54 else:
55 application_id = None
56if not application_id:
57 applications = api("/user/applications/oauth2?limit=100")
58 if any(item["name"] in (name, "studio:" + suffix) for item in applications):
59 raise ValueError("Forgejo OAuth client exists without its stored secret")
60 created = api("/user/applications/oauth2", "POST", desired)
61 application_id = str(created["id"])
62 client_id = created["client_id"]
63 client_secret = created["client_secret"]
64if not client_id or not client_secret:
65 raise ValueError("Forgejo OAuth client has no credentials")
66print(json.dumps({"applicationId": application_id, "clientId": client_id,
67 "clientSecret": client_secret, "providerUrl": "https://" + data["host"]}))
service/evil-forgejo/public/assets/css/theme-evil-dark.css created+118
......@@ -0,0 +1,118 @@
1@import "theme-forgejo-dark.css";
2
3:root {
4 --f: 0.75;
5 --l: 4;
6 --steel-900: lch(from #10161d l calc(c * var(--f)) 320);
7 --steel-850: lch(from #131a21 l calc(c * var(--f)) 320);
8 --steel-800: lch(from #171e26 l calc(c * var(--f)) 320);
9 --steel-750: lch(from #1d262f l calc(c * var(--f)) 320);
10 --steel-700: lch(from #242d38 l calc(c * var(--f)) 320);
11 --steel-650: lch(from #2b3642 l calc(c * var(--f)) 320);
12 --steel-600: lch(from #374351 l calc(c * var(--f)) 320);
13 --steel-550: lch(from #445161 l calc(c * var(--f)) 320);
14 --steel-500: lch(from #515f70 l calc(c * var(--f)) 320);
15 --steel-450: lch(from #5f6e80 l calc(c * var(--f)) 320);
16 --steel-400: lch(from #6d7d8f l calc(c * var(--f)) 320);
17 --steel-350: lch(from #7c8c9f l calc(c * var(--f)) 320);
18 --steel-300: lch(from #8c9caf l calc(c * var(--f)) 320);
19 --steel-250: lch(from #9dadc0 l calc(c * var(--f)) 320);
20 --steel-200: lch(from #aebed0 l calc(c * var(--f)) 320);
21 --steel-150: lch(from #c0cfe0 l calc(c * var(--f)) 320);
22 --steel-100: lch(from #d2e0f0 l calc(c * var(--f)) 320);
23 --zinc-50: lch(from #fafafa l calc(c * var(--f)) 320);
24 --zinc-100: lch(from #f4f4f5 l calc(c * var(--f)) 320);
25 --zinc-150: lch(from #ececee l calc(c * var(--f)) 320);
26 --zinc-200: lch(from #e4e4e7 l calc(c * var(--f)) 320);
27 --zinc-250: lch(from #dcdce0 l calc(c * var(--f)) 320);
28 --zinc-300: lch(from #d4d4d8 l calc(c * var(--f)) 320);
29 --zinc-350: lch(from #babac1 l calc(c * var(--f)) 320);
30 --zinc-400: lch(from #a1a1aa l calc(c * var(--f)) 320);
31 --zinc-450: lch(from #898992 l calc(c * var(--f)) 320);
32 --zinc-500: lch(from #71717a l calc(c * var(--f)) 320);
33 --zinc-550: lch(from #61616a l calc(c * var(--f)) 320);
34 --zinc-600: lch(from #52525b l calc(c * var(--f)) 320);
35 --zinc-650: lch(from #484850 l calc(c * var(--f)) 320);
36 --zinc-700: lch(from #3f3f46 l calc(c * var(--f)) 320);
37 --zinc-750: lch(from #333338 l calc(c * var(--f)) 320);
38 --zinc-800: lch(from #27272a l calc(c * var(--f)) 320);
39 --zinc-850: lch(from #1f1f23 l calc(c * var(--f)) 320);
40 --zinc-900: lch(from #18181b l calc(c * var(--f)) 320);
41 --color-primary: #e286f3;
42 --color-primary-contrast: lch(from var(--color-primary) 95% 0 h);
43 --color-primary-dark-1: lch(from var(--color-primary) l c h);
44 --color-primary-dark-2: lch(from var(--color-primary) calc(l * 0.85) c h);
45 --color-primary-dark-3: lch(from var(--color-primary) calc(l * 0.85) c h);
46 --color-primary-dark-4: lch(from var(--color-primary) calc(l * 0.72) c h);
47 --color-primary-dark-5: lch(from var(--color-primary) calc(l * 0.72) c h);
48 --color-primary-dark-6: lch(from var(--color-primary) calc(l * 0.72) c h);
49 --color-primary-dark-7: lch(from var(--color-primary) calc(l * 0.72) c h);
50 --color-primary-light-1: lch(from var(--color-primary) calc(l * 1.15) c h);
51 --color-primary-light-2: lch(from var(--color-primary) calc(l * 1.28) c h);
52 --color-primary-light-3: lch(from var(--color-primary) calc(l * 1.48) c h);
53 --color-primary-light-4: lch(from var(--color-primary) calc(l * 1.68) c h);
54 --color-primary-light-5: lch(
55 from
56 var(--color-primary)
57 calc(l * 1.82)
58 calc(c * 0.6)
59 h
60 );
61 --color-primary-light-6: lch(
62 from
63 var(--color-primary)
64 calc(l * 1.92)
65 calc(c * 0.3)
66 h
67 );
68 --color-primary-light-7: lch(
69 from
70 var(--color-primary)
71 calc(l * 1.98)
72 calc(c * 0.15)
73 h
74 );
75 --color-primary-alpha-10: lch(from var(--color-primary) l c h / 0.1);
76 --color-primary-alpha-20: lch(from var(--color-primary) l c h / 0.2);
77 --color-primary-alpha-30: lch(from var(--color-primary) l c h / 0.3);
78 --color-primary-alpha-40: lch(from var(--color-primary) l c h / 0.4);
79 --color-primary-alpha-50: lch(from var(--color-primary) l c h / 0.5);
80 --color-primary-alpha-60: lch(from var(--color-primary) l c h / 0.6);
81 --color-primary-alpha-70: lch(from var(--color-primary) l c h / 0.7);
82 --color-primary-alpha-80: lch(from var(--color-primary) l c h / 0.8);
83 --color-primary-alpha-90: lch(from var(--color-primary) l c h / 0.9);
84 --color-active: var(--steel-600);
85 --color-secondary-alpha-10: lch(from var(--color-secondary) l c h / 0.1);
86 --color-secondary-alpha-20: lch(from var(--color-secondary) l c h / 0.2);
87 --color-secondary-alpha-30: lch(from var(--color-secondary) l c h / 0.3);
88 --color-secondary-alpha-40: lch(from var(--color-secondary) l c h / 0.4);
89 --color-secondary-alpha-50: lch(from var(--color-secondary) l c h / 0.5);
90 --color-secondary-alpha-60: lch(from var(--color-secondary) l c h / 0.6);
91 --color-secondary-alpha-70: lch(from var(--color-secondary) l c h / 0.7);
92 --color-secondary-alpha-80: lch(from var(--color-secondary) l c h / 0.8);
93 --color-secondary-alpha-90: lch(from var(--color-secondary) l c h / 0.9);
94}
95
96.organization.profile:has(.overflow-menu-items > a[href="/evil"]) {
97 .org-avatar, .org-title {
98 clip: rect(1px, 1px, 1px, 1px);
99 clip-path: inset(50%);
100 height: 1px;
101 width: 1px;
102 margin: -1px;
103 overflow: hidden;
104 padding: 0;
105 position: absolute;
106 }
107 #org-info .render-content p {
108 display: none;
109 }
110 .org-header::before {
111 content: " ";
112 height: 60px;
113 width: 215px;
114 margin-left: 10px;
115 margin-bottom: 10px;
116 background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAxNDUzIDQwNiI+PHBhdGggZD0iTTQ2IDQ2YzIgMTctNyAxMzUtMiAxNDBzMTI1LTQgMTQyLTJjMjYgNSAyNiA0MS0xIDQ1LTE3IDItMTM2LTgtMTQxLTMtNCA0IDUgMTA1IDMgMTE5IDE0LTQgMjktNiA0My03IDIzLTEgODgtNCAxMDggMCAyMiA0IDI0IDM5LTEgNDQtMzAgNi03NCAwLTEwNyAzLTI3IDItODYgMjYtODgtMTgtMi00MiAxNC05OSA2LTE0MiAwLTQtNi03LTctMTItNC0xOSA1LTE3IDctMjYgOC0zOC0xMS0xNDQgMi0xNzFDMTQgOSAyMCA1IDI4IDRjMTktMyAxMjQtNSAxNDEtMSAyMyA1IDE4IDQzLTMgNDNINDZabTEwMzYgMTc2Yy0xNS0xMC0zMyAzNC0zNyA0NC01IDE0LTEwIDMxLTEzIDQ2cy02IDU3LTExIDY3Yy0xMCAyMC00MSAxNC00My0xMi0xLTE3IDAtMTQxIDQtMTUxIDQtMTMgMjItMTUgMzMtOGw3IDljMSAwIDEyLTE5IDE2LTIyIDQ2LTUxIDc4IDMgMTAzIDQwIDE5IDI4IDc4IDExNCA4OCAxNDAgNyAyMS0xMCAzOC0yOSAyOS05LTUtMzctNjAtNDUtNzNsLTczLTEwOVptMzI2LTEwMWMyNS0xIDMyIDMwIDEwIDM5LTM0IDE0LTU4IDgtOTEgMzgtNjggNjAtMTIgMTIzIDU0IDE0NiAxOCA2IDY3IDkgNzEgMjcgMTAgNDUtODUgMTItMTAzIDUtMTUyLTY0LTExNS0yMDkgMjktMjQ4IDctMiAyMy03IDMwLTdaTTYzNiA1YzcgNyA1IDQyIDQgNTMgMCA3OC04IDE1Ni02IDIzNCAwIDI0IDkgNjYgNSA4Ni0zIDE5LTMxIDIxLTM0LTEtNC0yNiA1LTcwIDUtMTAwIDEtNzgtMTQtMTg0LTUtMjU4IDItMTYgMTktMjYgMzEtMTRaTTMxMyAzMTBjMjEtMzIgMzQtNzAgNTktOTkgMTAtMTEgMzItMzYgNDgtMjQgMjQgMTktMTMgMzgtMjMgNDgtMjAgMjMtMzkgNTctNTAgODYtNSAxMS0xNyA1Ni0yMyA2MS0xMyAxMC0yNiAzLTMyLTExLTktMTctNDQtMTIwLTQ3LTEzNy0zLTI1IDIzLTM2IDM2LTE5czIwIDc0IDMyIDk1Wm0xNzAtMTEzYzEyLTEyIDMyLTQgMzQgMTIgNSAyOC0xIDg0LTEgMTE3IDAgMTQgNyA1Mi00IDYyLTggNy0yMiA3LTI5LTItMTEtMTEtMy02OC0zLTg1IDAtMjctNi03MC0xLTk1IDAtMyAyLTYgNC05Wm0xNS01MmEyMSAyMSAwIDEgMCAwLTQyIDIxIDIxIDAgMCAwIDAgNDJaIiBmaWxsPSIjZTZkYmU3Ii8+PHBhdGggZmlsbD0iI0UxODZGNCIgZD0iTTk1MSA0MGMyMyAyMS0xIDUzLTE2IDcwLTEzIDE1LTQ1IDUyLTY0IDUxLTYgMC00Ni0yMi01My0yNy0zOS0yNy0zNS0xMDIgMjctODkgOCAyIDI4IDE1IDMwIDE1IDMtMSAxMi0xNSAxNy0xOCAxNC0xMSA0NS0xNSA1OS0yWiIvPjxwYXRoIGQ9Ik04NjAgMzg3Yy0zLTQtNS05LTYtMTQtMy0yMi0zLTE1NSAwLTE3NiA0LTI0IDM3LTI1IDQwIDIgMyAyMCAzIDE1NiAwIDE3NS0yIDE3LTIyIDI0LTM0IDEzWiIgZmlsbD0iI2U2ZGJlNyIvPjwvc3ZnPg);
117 }
118}
service/evil-forgejo/public/assets/css/theme-evil-light.css created+175
......@@ -0,0 +1,175 @@
1@import "theme-forgejo-light.css";
2
3:root {
4 --f: 5;
5 --l: 4;
6 --steel-900: lch(from #10161d l calc(c * var(--f)) 320);
7 --steel-850: lch(from #131a21 l calc(c * var(--f)) 320);
8 --steel-800: lch(from #171e26 l calc(c * var(--f)) 320);
9 --steel-750: lch(from #1d262f l calc(c * var(--f)) 320);
10 --steel-700: lch(from #242d38 l calc(c * var(--f)) 320);
11 --steel-650: lch(from #2b3642 l calc(c * var(--f)) 320);
12 --steel-600: lch(from #374351 l calc(c * var(--f)) 320);
13 --steel-550: lch(from #445161 l calc(c * var(--f)) 320);
14 --steel-500: lch(from #515f70 l calc(c * var(--f)) 320);
15 --steel-450: lch(from #5f6e80 l calc(c * var(--f)) 320);
16 --steel-400: lch(from #6d7d8f l calc(c * var(--f)) 320);
17 --steel-350: lch(from #7c8c9f l calc(c * var(--f)) 320);
18 --steel-300: lch(from #8c9caf l calc(c * var(--f)) 320);
19 --steel-250: lch(from #9dadc0 l calc(c * var(--f)) 320);
20 --steel-200: lch(from #aebed0 l calc(c * var(--f)) 320);
21 --steel-150: lch(from #c0cfe0 l calc(c * var(--f)) 320);
22 --steel-100: lch(from #d2e0f0 l calc(c * var(--f)) 320);
23 --zinc-50: lch(from #fafafa l calc(c * var(--f)) 320);
24 --zinc-100: lch(from #f4f4f5 l calc(c * var(--f)) 320);
25 --zinc-150: lch(from #ececee l calc(c * var(--f)) 320);
26 --zinc-200: lch(from #e4e4e7 l calc(c * var(--f)) 320);
27 --zinc-250: lch(from #dcdce0 l calc(c * var(--f)) 320);
28 --zinc-300: lch(from #d4d4d8 l calc(c * var(--f)) 320);
29 --zinc-350: lch(from #babac1 l calc(c * var(--f)) 320);
30 --zinc-400: lch(from #a1a1aa l calc(c * var(--f)) 320);
31 --zinc-450: lch(from #898992 l calc(c * var(--f)) 320);
32 --zinc-500: lch(from #71717a l calc(c * var(--f)) 320);
33 --zinc-550: lch(from #61616a l calc(c * var(--f)) 320);
34 --zinc-600: lch(from #52525b l calc(c * var(--f)) 320);
35 --zinc-650: lch(from #484850 l calc(c * var(--f)) 320);
36 --zinc-700: lch(from #3f3f46 l calc(c * var(--f)) 320);
37 --zinc-750: lch(from #333338 l calc(c * var(--f)) 320);
38 --zinc-800: lch(from #27272a l calc(c * var(--f)) 320);
39 --zinc-850: lch(from #1f1f23 l calc(c * var(--f)) 320);
40 --zinc-900: lch(from #18181b l calc(c * var(--f)) 320);
41 --color-primary: #b734cf;
42 --color-primary-contrast: lch(from var(--color-primary) 95% 0 h);
43 --color-primary-dark-1: lch(from var(--color-primary) l c h);
44 --color-primary-dark-2: lch(from var(--color-primary) calc(l * 0.85) c h);
45 --color-primary-dark-3: lch(from var(--color-primary) calc(l * 0.85) c h);
46 --color-primary-dark-4: lch(from var(--color-primary) calc(l * 0.72) c h);
47 --color-primary-dark-5: lch(from var(--color-primary) calc(l * 0.72) c h);
48 --color-primary-dark-6: lch(from var(--color-primary) calc(l * 0.72) c h);
49 --color-primary-dark-7: lch(from var(--color-primary) calc(l * 0.72) c h);
50 --color-primary-light-1: lch(from var(--color-primary) calc(l * 1.15) c h);
51 --color-primary-light-2: lch(from var(--color-primary) calc(l * 1.28) c h);
52 --color-primary-light-3: lch(from var(--color-primary) calc(l * 1.48) c h);
53 --color-primary-light-4: lch(from var(--color-primary) calc(l * 1.68) c h);
54 --color-primary-light-5: lch(
55 from
56 var(--color-primary)
57 calc(l * 1.82)
58 calc(c * 0.6)
59 h
60 );
61 --color-primary-light-6: lch(
62 from
63 var(--color-primary)
64 calc(l * 1.92)
65 calc(c * 0.3)
66 h
67 );
68 --color-primary-light-7: lch(
69 from
70 var(--color-primary)
71 calc(l * 1.98)
72 calc(c * 0.15)
73 h
74 );
75 --color-primary-alpha-10: lch(from var(--color-primary) l c h / 0.1);
76 --color-primary-alpha-20: lch(from var(--color-primary) l c h / 0.2);
77 --color-primary-alpha-30: lch(from var(--color-primary) l c h / 0.3);
78 --color-primary-alpha-40: lch(from var(--color-primary) l c h / 0.4);
79 --color-primary-alpha-50: lch(from var(--color-primary) l c h / 0.5);
80 --color-primary-alpha-60: lch(from var(--color-primary) l c h / 0.6);
81 --color-primary-alpha-70: lch(from var(--color-primary) l c h / 0.7);
82 --color-primary-alpha-80: lch(from var(--color-primary) l c h / 0.8);
83 --color-primary-alpha-90: lch(from var(--color-primary) l c h / 0.9);
84 --color-active: var(--steel-600);
85 --color-secondary-alpha-10: lch(from var(--color-secondary) l c h / 0.1);
86 --color-secondary-alpha-20: lch(from var(--color-secondary) l c h / 0.2);
87 --color-secondary-alpha-30: lch(from var(--color-secondary) l c h / 0.3);
88 --color-secondary-alpha-40: lch(from var(--color-secondary) l c h / 0.4);
89 --color-secondary-alpha-50: lch(from var(--color-secondary) l c h / 0.5);
90 --color-secondary-alpha-60: lch(from var(--color-secondary) l c h / 0.6);
91 --color-secondary-alpha-70: lch(from var(--color-secondary) l c h / 0.7);
92 --color-secondary-alpha-80: lch(from var(--color-secondary) l c h / 0.8);
93 --color-secondary-alpha-90: lch(from var(--color-secondary) l c h / 0.9);
94 --color-body: lch(from #fff l calc(c * var(--f) + var(--l)) h);
95 --color-text-dark: lch(from #000 l calc(c * var(--f) + var(--l)) h);
96 --color-input-background: lch(from #fff l calc(c * var(--f) + var(--l)) h);
97 --color-input-toggle-background: lch(
98 from
99 #fff
100 l
101 calc(c * var(--f) + var(--l))
102 h
103 );
104 --color-header-wrapper-transparent: lch(
105 from
106 #d2e0f000
107 l
108 calc(c * var(--f) + var(--l))
109 h
110 );
111 --color-light-border: lch(from #0000001d l calc(c * var(--f) + var(--l)) h);
112 --color-hover: lch(from #e4e4e4aa l calc(c * var(--f) + var(--l)) h);
113 --color-active: lch(from #e2e2e5 l calc(c * var(--f) + var(--l)) 320);
114 --color-markup-table-row: lch(
115 from
116 #ffffff06
117 l
118 calc(c * var(--f) + var(--l))
119 h
120 );
121 --color-shadow: lch(from #00000060 l calc(c * var(--f) + var(--l)) h);
122 --color-text-focus: lch(from #fff l calc(c * var(--f) + var(--l)) h);
123 --color-reaction-bg: lch(from #0000000a l calc(c * var(--f) + var(--l)) h);
124 --color-tooltip-text: lch(from #ffffff l calc(c * var(--f) + var(--l)) h);
125 --color-tooltip-bg: lch(from #000000f0 l calc(c * var(--f) + var(--l)) h);
126 --color-label-bg: lch(from #cacaca7b l calc(c * var(--f) + var(--l)) h);
127 --color-label-hover-bg: lch(
128 from
129 #cacacaa0
130 l
131 calc(c * var(--f) + var(--l))
132 h
133 );
134 --color-label-active-bg: lch(
135 from
136 #cacacaff
137 l
138 calc(c * var(--f) + var(--l))
139 h
140 );
141 --color-label-bg-alt: lch(from #cacacaff l calc(c * var(--f) + var(--l)) h);
142 --color-overlay-backdrop: lch(
143 from
144 #080808c0
145 l
146 calc(c * var(--f) + var(--l))
147 h
148 );
149 --checkerboard-color-1: lch(from #ffffff l calc(c * var(--f) + var(--l)) h);
150 --checkerboard-color-2: lch(from #e5e5e5 l calc(c * var(--f) + var(--l)) h);
151}
152
153.organization.profile:has(.overflow-menu-items > a[href="/evil"]) {
154 .org-avatar, .org-title {
155 clip: rect(1px, 1px, 1px, 1px);
156 clip-path: inset(50%);
157 height: 1px;
158 width: 1px;
159 margin: -1px;
160 overflow: hidden;
161 padding: 0;
162 position: absolute;
163 }
164 #org-info .render-content p {
165 display: none;
166 }
167 .org-header::before {
168 content: " ";
169 height: 60px;
170 width: 215px;
171 margin-left: 10px;
172 margin-bottom: 10px;
173 background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAxNDUzIDQwNiI+PHBhdGggZmlsbD0iIzIwMTQyMiIgZD0iTTQ2IDQ2YzIgMTctNyAxMzUtMiAxNDBzMTI1LTQgMTQyLTJjMjYgNSAyNiA0MS0xIDQ1LTE3IDItMTM2LTgtMTQxLTMtNCA0IDUgMTA1IDMgMTE5IDE0LTQgMjktNiA0My03IDIzLTEgODgtNCAxMDggMCAyMiA0IDI0IDM5LTEgNDQtMzAgNi03NCAwLTEwNyAzLTI3IDItODYgMjYtODgtMTgtMi00MiAxNC05OSA2LTE0MiAwLTQtNi03LTctMTItNC0xOSA1LTE3IDctMjYgOC0zOC0xMS0xNDQgMi0xNzFDMTQgOSAyMCA1IDI4IDRjMTktMyAxMjQtNSAxNDEtMSAyMyA1IDE4IDQzLTMgNDNINDZabTEwMzYgMTc2Yy0xNS0xMC0zMyAzNC0zNyA0NC01IDE0LTEwIDMxLTEzIDQ2cy02IDU3LTExIDY3Yy0xMCAyMC00MSAxNC00My0xMi0xLTE3IDAtMTQxIDQtMTUxIDQtMTMgMjItMTUgMzMtOGw3IDljMSAwIDEyLTE5IDE2LTIyIDQ2LTUxIDc4IDMgMTAzIDQwIDE5IDI4IDc4IDExNCA4OCAxNDAgNyAyMS0xMCAzOC0yOSAyOS05LTUtMzctNjAtNDUtNzNsLTczLTEwOVptMzI2LTEwMWMyNS0xIDMyIDMwIDEwIDM5LTM0IDE0LTU4IDgtOTEgMzgtNjggNjAtMTIgMTIzIDU0IDE0NiAxOCA2IDY3IDkgNzEgMjcgMTAgNDUtODUgMTItMTAzIDUtMTUyLTY0LTExNS0yMDkgMjktMjQ4IDctMiAyMy03IDMwLTdaTTYzNiA1YzcgNyA1IDQyIDQgNTMgMCA3OC04IDE1Ni02IDIzNCAwIDI0IDkgNjYgNSA4Ni0zIDE5LTMxIDIxLTM0LTEtNC0yNiA1LTcwIDUtMTAwIDEtNzgtMTQtMTg0LTUtMjU4IDItMTYgMTktMjYgMzEtMTRaTTMxMyAzMTBjMjEtMzIgMzQtNzAgNTktOTkgMTAtMTEgMzItMzYgNDgtMjQgMjQgMTktMTMgMzgtMjMgNDgtMjAgMjMtMzkgNTctNTAgODYtNSAxMS0xNyA1Ni0yMyA2MS0xMyAxMC0yNiAzLTMyLTExLTktMTctNDQtMTIwLTQ3LTEzNy0zLTI1IDIzLTM2IDM2LTE5czIwIDc0IDMyIDk1Wm0xNzAtMTEzYzEyLTEyIDMyLTQgMzQgMTIgNSAyOC0xIDg0LTEgMTE3IDAgMTQgNyA1Mi00IDYyLTggNy0yMiA3LTI5LTItMTEtMTEtMy02OC0zLTg1IDAtMjctNi03MC0xLTk1IDAtMyAyLTYgNC05Wm0xNS01MmEyMSAyMSAwIDEgMCAwLTQyIDIxIDIxIDAgMCAwIDAgNDJaIi8+PHBhdGggZmlsbD0iI0UxODZGNCIgZD0iTTk1MSA0MGMyMyAyMS0xIDUzLTE2IDcwLTEzIDE1LTQ1IDUyLTY0IDUxLTYgMC00Ni0yMi01My0yNy0zOS0yNy0zNS0xMDIgMjctODkgOCAyIDI4IDE1IDMwIDE1IDMtMSAxMi0xNSAxNy0xOCAxNC0xMSA0NS0xNSA1OS0yWiIvPjxwYXRoIGZpbGw9IiMyMDE0MjIiIGQ9Ik04NjAgMzg3Yy0zLTQtNS05LTYtMTQtMy0yMi0zLTE1NSAwLTE3NiA0LTI0IDM3LTI1IDQwIDIgMyAyMCAzIDE1NiAwIDE3NS0yIDE3LTIyIDI0LTM0IDEzWiIvPjwvc3ZnPg);
174 }
175}
service/evil-forgejo/public/assets/css/theme-evil.css created+189
......@@ -0,0 +1,189 @@
1@import "theme-forgejo-light.css";
2@import "theme-forgejo-dark.css" (prefers-color-scheme: dark);
3
4:root {
5 --f: 5;
6 --l: 4;
7 --steel-900: lch(from #10161d l calc(c * var(--f)) 320);
8 --steel-850: lch(from #131a21 l calc(c * var(--f)) 320);
9 --steel-800: lch(from #171e26 l calc(c * var(--f)) 320);
10 --steel-750: lch(from #1d262f l calc(c * var(--f)) 320);
11 --steel-700: lch(from #242d38 l calc(c * var(--f)) 320);
12 --steel-650: lch(from #2b3642 l calc(c * var(--f)) 320);
13 --steel-600: lch(from #374351 l calc(c * var(--f)) 320);
14 --steel-550: lch(from #445161 l calc(c * var(--f)) 320);
15 --steel-500: lch(from #515f70 l calc(c * var(--f)) 320);
16 --steel-450: lch(from #5f6e80 l calc(c * var(--f)) 320);
17 --steel-400: lch(from #6d7d8f l calc(c * var(--f)) 320);
18 --steel-350: lch(from #7c8c9f l calc(c * var(--f)) 320);
19 --steel-300: lch(from #8c9caf l calc(c * var(--f)) 320);
20 --steel-250: lch(from #9dadc0 l calc(c * var(--f)) 320);
21 --steel-200: lch(from #aebed0 l calc(c * var(--f)) 320);
22 --steel-150: lch(from #c0cfe0 l calc(c * var(--f)) 320);
23 --steel-100: lch(from #d2e0f0 l calc(c * var(--f)) 320);
24 --zinc-50: lch(from #fafafa l calc(c * var(--f)) 320);
25 --zinc-100: lch(from #f4f4f5 l calc(c * var(--f)) 320);
26 --zinc-150: lch(from #ececee l calc(c * var(--f)) 320);
27 --zinc-200: lch(from #e4e4e7 l calc(c * var(--f)) 320);
28 --zinc-250: lch(from #dcdce0 l calc(c * var(--f)) 320);
29 --zinc-300: lch(from #d4d4d8 l calc(c * var(--f)) 320);
30 --zinc-350: lch(from #babac1 l calc(c * var(--f)) 320);
31 --zinc-400: lch(from #a1a1aa l calc(c * var(--f)) 320);
32 --zinc-450: lch(from #898992 l calc(c * var(--f)) 320);
33 --zinc-500: lch(from #71717a l calc(c * var(--f)) 320);
34 --zinc-550: lch(from #61616a l calc(c * var(--f)) 320);
35 --zinc-600: lch(from #52525b l calc(c * var(--f)) 320);
36 --zinc-650: lch(from #484850 l calc(c * var(--f)) 320);
37 --zinc-700: lch(from #3f3f46 l calc(c * var(--f)) 320);
38 --zinc-750: lch(from #333338 l calc(c * var(--f)) 320);
39 --zinc-800: lch(from #27272a l calc(c * var(--f)) 320);
40 --zinc-850: lch(from #1f1f23 l calc(c * var(--f)) 320);
41 --zinc-900: lch(from #18181b l calc(c * var(--f)) 320);
42 --color-primary: #b734cf;
43 --color-primary-contrast: lch(from var(--color-primary) 95% 0 h);
44 --color-primary-dark-1: lch(from var(--color-primary) l c h);
45 --color-primary-dark-2: lch(from var(--color-primary) calc(l * 0.85) c h);
46 --color-primary-dark-3: lch(from var(--color-primary) calc(l * 0.85) c h);
47 --color-primary-dark-4: lch(from var(--color-primary) calc(l * 0.72) c h);
48 --color-primary-dark-5: lch(from var(--color-primary) calc(l * 0.72) c h);
49 --color-primary-dark-6: lch(from var(--color-primary) calc(l * 0.72) c h);
50 --color-primary-dark-7: lch(from var(--color-primary) calc(l * 0.72) c h);
51 --color-primary-light-1: lch(from var(--color-primary) calc(l * 1.15) c h);
52 --color-primary-light-2: lch(from var(--color-primary) calc(l * 1.28) c h);
53 --color-primary-light-3: lch(from var(--color-primary) calc(l * 1.48) c h);
54 --color-primary-light-4: lch(from var(--color-primary) calc(l * 1.68) c h);
55 --color-primary-light-5: lch(
56 from
57 var(--color-primary)
58 calc(l * 1.82)
59 calc(c * 0.6)
60 h
61 );
62 --color-primary-light-6: lch(
63 from
64 var(--color-primary)
65 calc(l * 1.92)
66 calc(c * 0.3)
67 h
68 );
69 --color-primary-light-7: lch(
70 from
71 var(--color-primary)
72 calc(l * 1.98)
73 calc(c * 0.15)
74 h
75 );
76 --color-primary-alpha-10: lch(from var(--color-primary) l c h / 0.1);
77 --color-primary-alpha-20: lch(from var(--color-primary) l c h / 0.2);
78 --color-primary-alpha-30: lch(from var(--color-primary) l c h / 0.3);
79 --color-primary-alpha-40: lch(from var(--color-primary) l c h / 0.4);
80 --color-primary-alpha-50: lch(from var(--color-primary) l c h / 0.5);
81 --color-primary-alpha-60: lch(from var(--color-primary) l c h / 0.6);
82 --color-primary-alpha-70: lch(from var(--color-primary) l c h / 0.7);
83 --color-primary-alpha-80: lch(from var(--color-primary) l c h / 0.8);
84 --color-primary-alpha-90: lch(from var(--color-primary) l c h / 0.9);
85 --color-active: var(--steel-600);
86 --color-secondary-alpha-10: lch(from var(--color-secondary) l c h / 0.1);
87 --color-secondary-alpha-20: lch(from var(--color-secondary) l c h / 0.2);
88 --color-secondary-alpha-30: lch(from var(--color-secondary) l c h / 0.3);
89 --color-secondary-alpha-40: lch(from var(--color-secondary) l c h / 0.4);
90 --color-secondary-alpha-50: lch(from var(--color-secondary) l c h / 0.5);
91 --color-secondary-alpha-60: lch(from var(--color-secondary) l c h / 0.6);
92 --color-secondary-alpha-70: lch(from var(--color-secondary) l c h / 0.7);
93 --color-secondary-alpha-80: lch(from var(--color-secondary) l c h / 0.8);
94 --color-secondary-alpha-90: lch(from var(--color-secondary) l c h / 0.9);
95}
96@media (not (prefers-color-scheme: dark)) {
97 :root {
98 --color-body: lch(from #fff l calc(c * var(--f) + var(--l)) h);
99 --color-text-dark: lch(from #000 l calc(c * var(--f) + var(--l)) h);
100 --color-input-background: lch(from #fff l calc(c * var(--f) + var(--l)) h);
101 --color-input-toggle-background: lch(
102 from
103 #fff
104 l
105 calc(c * var(--f) + var(--l))
106 h
107 );
108 --color-header-wrapper-transparent: lch(
109 from
110 #d2e0f000
111 l
112 calc(c * var(--f) + var(--l))
113 h
114 );
115 --color-light-border: lch(from #0000001d l calc(c * var(--f) + var(--l)) h);
116 --color-hover: lch(from #e4e4e4aa l calc(c * var(--f) + var(--l)) h);
117 --color-active: lch(from #e2e2e5 l calc(c * var(--f) + var(--l)) 320);
118 --color-markup-table-row: lch(
119 from
120 #ffffff06
121 l
122 calc(c * var(--f) + var(--l))
123 h
124 );
125 --color-shadow: lch(from #00000060 l calc(c * var(--f) + var(--l)) h);
126 --color-text-focus: lch(from #fff l calc(c * var(--f) + var(--l)) h);
127 --color-reaction-bg: lch(from #0000000a l calc(c * var(--f) + var(--l)) h);
128 --color-tooltip-text: lch(from #ffffff l calc(c * var(--f) + var(--l)) h);
129 --color-tooltip-bg: lch(from #000000f0 l calc(c * var(--f) + var(--l)) h);
130 --color-label-bg: lch(from #cacaca7b l calc(c * var(--f) + var(--l)) h);
131 --color-label-hover-bg: lch(
132 from
133 #cacacaa0
134 l
135 calc(c * var(--f) + var(--l))
136 h
137 );
138 --color-label-active-bg: lch(
139 from
140 #cacacaff
141 l
142 calc(c * var(--f) + var(--l))
143 h
144 );
145 --color-label-bg-alt: lch(from #cacacaff l calc(c * var(--f) + var(--l)) h);
146 --color-overlay-backdrop: lch(
147 from
148 #080808c0
149 l
150 calc(c * var(--f) + var(--l))
151 h
152 );
153 --checkerboard-color-1: lch(from #ffffff l calc(c * var(--f) + var(--l)) h);
154 --checkerboard-color-2: lch(from #e5e5e5 l calc(c * var(--f) + var(--l)) h);
155 }
156}
157@media (prefers-color-scheme: dark) {
158 :root {
159 --f: 0.75;
160 --color-primary: #e286f3;
161 }
162}
163
164.organization.profile:has(.overflow-menu-items > a[href="/evil"]) {
165 .org-avatar, .org-title {
166 clip: rect(1px, 1px, 1px, 1px);
167 clip-path: inset(50%);
168 height: 1px;
169 width: 1px;
170 margin: -1px;
171 overflow: hidden;
172 padding: 0;
173 position: absolute;
174 }
175 #org-info .render-content p {
176 display: none;
177 }
178 .org-header::before {
179 content: " ";
180 height: 60px;
181 width: 215px;
182 margin-left: 10px;
183 margin-bottom: 10px;
184 background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAxNDUzIDQwNiI+PHBhdGggZmlsbD0iIzIwMTQyMiIgZD0iTTQ2IDQ2YzIgMTctNyAxMzUtMiAxNDBzMTI1LTQgMTQyLTJjMjYgNSAyNiA0MS0xIDQ1LTE3IDItMTM2LTgtMTQxLTMtNCA0IDUgMTA1IDMgMTE5IDE0LTQgMjktNiA0My03IDIzLTEgODgtNCAxMDggMCAyMiA0IDI0IDM5LTEgNDQtMzAgNi03NCAwLTEwNyAzLTI3IDItODYgMjYtODgtMTgtMi00MiAxNC05OSA2LTE0MiAwLTQtNi03LTctMTItNC0xOSA1LTE3IDctMjYgOC0zOC0xMS0xNDQgMi0xNzFDMTQgOSAyMCA1IDI4IDRjMTktMyAxMjQtNSAxNDEtMSAyMyA1IDE4IDQzLTMgNDNINDZabTEwMzYgMTc2Yy0xNS0xMC0zMyAzNC0zNyA0NC01IDE0LTEwIDMxLTEzIDQ2cy02IDU3LTExIDY3Yy0xMCAyMC00MSAxNC00My0xMi0xLTE3IDAtMTQxIDQtMTUxIDQtMTMgMjItMTUgMzMtOGw3IDljMSAwIDEyLTE5IDE2LTIyIDQ2LTUxIDc4IDMgMTAzIDQwIDE5IDI4IDc4IDExNCA4OCAxNDAgNyAyMS0xMCAzOC0yOSAyOS05LTUtMzctNjAtNDUtNzNsLTczLTEwOVptMzI2LTEwMWMyNS0xIDMyIDMwIDEwIDM5LTM0IDE0LTU4IDgtOTEgMzgtNjggNjAtMTIgMTIzIDU0IDE0NiAxOCA2IDY3IDkgNzEgMjcgMTAgNDUtODUgMTItMTAzIDUtMTUyLTY0LTExNS0yMDkgMjktMjQ4IDctMiAyMy03IDMwLTdaTTYzNiA1YzcgNyA1IDQyIDQgNTMgMCA3OC04IDE1Ni02IDIzNCAwIDI0IDkgNjYgNSA4Ni0zIDE5LTMxIDIxLTM0LTEtNC0yNiA1LTcwIDUtMTAwIDEtNzgtMTQtMTg0LTUtMjU4IDItMTYgMTktMjYgMzEtMTRaTTMxMyAzMTBjMjEtMzIgMzQtNzAgNTktOTkgMTAtMTEgMzItMzYgNDgtMjQgMjQgMTktMTMgMzgtMjMgNDgtMjAgMjMtMzkgNTctNTAgODYtNSAxMS0xNyA1Ni0yMyA2MS0xMyAxMC0yNiAzLTMyLTExLTktMTctNDQtMTIwLTQ3LTEzNy0zLTI1IDIzLTM2IDM2LTE5czIwIDc0IDMyIDk1Wm0xNzAtMTEzYzEyLTEyIDMyLTQgMzQgMTIgNSAyOC0xIDg0LTEgMTE3IDAgMTQgNyA1Mi00IDYyLTggNy0yMiA3LTI5LTItMTEtMTEtMy02OC0zLTg1IDAtMjctNi03MC0xLTk1IDAtMyAyLTYgNC05Wm0xNS01MmEyMSAyMSAwIDEgMCAwLTQyIDIxIDIxIDAgMCAwIDAgNDJaIi8+PHBhdGggZmlsbD0iI0UxODZGNCIgZD0iTTk1MSA0MGMyMyAyMS0xIDUzLTE2IDcwLTEzIDE1LTQ1IDUyLTY0IDUxLTYgMC00Ni0yMi01My0yNy0zOS0yNy0zNS0xMDIgMjctODkgOCAyIDI4IDE1IDMwIDE1IDMtMSAxMi0xNSAxNy0xOCAxNC0xMSA0NS0xNSA1OS0yWiIvPjxwYXRoIGZpbGw9IiMyMDE0MjIiIGQ9Ik04NjAgMzg3Yy0zLTQtNS05LTYtMTQtMy0yMi0zLTE1NSAwLTE3NiA0LTI0IDM3LTI1IDQwIDIgMyAyMCAzIDE1NiAwIDE3NS0yIDE3LTIyIDI0LTM0IDEzWiIvPjwvc3ZnPg);
185 @media (prefers-color-scheme: dark) {
186 background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAxNDUzIDQwNiI+PHBhdGggZD0iTTQ2IDQ2YzIgMTctNyAxMzUtMiAxNDBzMTI1LTQgMTQyLTJjMjYgNSAyNiA0MS0xIDQ1LTE3IDItMTM2LTgtMTQxLTMtNCA0IDUgMTA1IDMgMTE5IDE0LTQgMjktNiA0My03IDIzLTEgODgtNCAxMDggMCAyMiA0IDI0IDM5LTEgNDQtMzAgNi03NCAwLTEwNyAzLTI3IDItODYgMjYtODgtMTgtMi00MiAxNC05OSA2LTE0MiAwLTQtNi03LTctMTItNC0xOSA1LTE3IDctMjYgOC0zOC0xMS0xNDQgMi0xNzFDMTQgOSAyMCA1IDI4IDRjMTktMyAxMjQtNSAxNDEtMSAyMyA1IDE4IDQzLTMgNDNINDZabTEwMzYgMTc2Yy0xNS0xMC0zMyAzNC0zNyA0NC01IDE0LTEwIDMxLTEzIDQ2cy02IDU3LTExIDY3Yy0xMCAyMC00MSAxNC00My0xMi0xLTE3IDAtMTQxIDQtMTUxIDQtMTMgMjItMTUgMzMtOGw3IDljMSAwIDEyLTE5IDE2LTIyIDQ2LTUxIDc4IDMgMTAzIDQwIDE5IDI4IDc4IDExNCA4OCAxNDAgNyAyMS0xMCAzOC0yOSAyOS05LTUtMzctNjAtNDUtNzNsLTczLTEwOVptMzI2LTEwMWMyNS0xIDMyIDMwIDEwIDM5LTM0IDE0LTU4IDgtOTEgMzgtNjggNjAtMTIgMTIzIDU0IDE0NiAxOCA2IDY3IDkgNzEgMjcgMTAgNDUtODUgMTItMTAzIDUtMTUyLTY0LTExNS0yMDkgMjktMjQ4IDctMiAyMy03IDMwLTdaTTYzNiA1YzcgNyA1IDQyIDQgNTMgMCA3OC04IDE1Ni02IDIzNCAwIDI0IDkgNjYgNSA4Ni0zIDE5LTMxIDIxLTM0LTEtNC0yNiA1LTcwIDUtMTAwIDEtNzgtMTQtMTg0LTUtMjU4IDItMTYgMTktMjYgMzEtMTRaTTMxMyAzMTBjMjEtMzIgMzQtNzAgNTktOTkgMTAtMTEgMzItMzYgNDgtMjQgMjQgMTktMTMgMzgtMjMgNDgtMjAgMjMtMzkgNTctNTAgODYtNSAxMS0xNyA1Ni0yMyA2MS0xMyAxMC0yNiAzLTMyLTExLTktMTctNDQtMTIwLTQ3LTEzNy0zLTI1IDIzLTM2IDM2LTE5czIwIDc0IDMyIDk1Wm0xNzAtMTEzYzEyLTEyIDMyLTQgMzQgMTIgNSAyOC0xIDg0LTEgMTE3IDAgMTQgNyA1Mi00IDYyLTggNy0yMiA3LTI5LTItMTEtMTEtMy02OC0zLTg1IDAtMjctNi03MC0xLTk1IDAtMyAyLTYgNC05Wm0xNS01MmEyMSAyMSAwIDEgMCAwLTQyIDIxIDIxIDAgMCAwIDAgNDJaIiBmaWxsPSIjZTZkYmU3Ii8+PHBhdGggZmlsbD0iI0UxODZGNCIgZD0iTTk1MSA0MGMyMyAyMS0xIDUzLTE2IDcwLTEzIDE1LTQ1IDUyLTY0IDUxLTYgMC00Ni0yMi01My0yNy0zOS0yNy0zNS0xMDIgMjctODkgOCAyIDI4IDE1IDMwIDE1IDMtMSAxMi0xNSAxNy0xOCAxNC0xMSA0NS0xNSA1OS0yWiIvPjxwYXRoIGQ9Ik04NjAgMzg3Yy0zLTQtNS05LTYtMTQtMy0yMi0zLTE1NSAwLTE3NiA0LTI0IDM3LTI1IDQwIDIgMyAyMCAzIDE1NiAwIDE3NS0yIDE3LTIyIDI0LTM0IDEzWiIgZmlsbD0iI2U2ZGJlNyIvPjwvc3ZnPg);
187 }
188 }
189}
service/evil-forgejo/public/assets/img/apple-touch-icon.png created
Binary files /dev/null and b/service/evil-forgejo/public/assets/img/apple-touch-icon.png differ
service/evil-forgejo/public/assets/img/avatar_default.png created
Binary files /dev/null and b/service/evil-forgejo/public/assets/img/avatar_default.png differ
service/evil-forgejo/public/assets/img/favicon.png created
Binary files /dev/null and b/service/evil-forgejo/public/assets/img/favicon.png differ
service/evil-forgejo/public/assets/img/favicon.svg created+14
......@@ -0,0 +1,14 @@
1<svg width="78" height="78" viewBox="0 0 78 78" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
2<g clip-path="url(#clip0_555_6)">
3<rect x="-0.5" y="8" width="78.7516" height="61" fill="url(#pattern0_555_6)"/>
4</g>
5<defs>
6<pattern id="pattern0_555_6" patternContentUnits="objectBoundingBox" width="1" height="1">
7<use xlink:href="#image0_555_6" transform="scale(0.000717875 0.000926784)"/>
8</pattern>
9<clipPath id="clip0_555_6">
10<rect width="78" height="78" fill="white"/>
11</clipPath>
12<image id="image0_555_6" width="1393" height="1079" preserveAspectRatio="none" xlink:href="data:image/png;base64,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"/>
13</defs>
14</svg>
service/evil-forgejo/public/assets/img/logo.png created
Binary files /dev/null and b/service/evil-forgejo/public/assets/img/logo.png differ
service/evil-forgejo/public/assets/img/logo.svg created+14
......@@ -0,0 +1,14 @@
1<svg width="78" height="78" viewBox="0 0 78 78" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
2<g clip-path="url(#clip0_555_6)">
3<rect x="-0.5" y="8" width="78.7516" height="61" fill="url(#pattern0_555_6)"/>
4</g>
5<defs>
6<pattern id="pattern0_555_6" patternContentUnits="objectBoundingBox" width="1" height="1">
7<use xlink:href="#image0_555_6" transform="scale(0.000717875 0.000926784)"/>
8</pattern>
9<clipPath id="clip0_555_6">
10<rect width="78" height="78" fill="white"/>
11</clipPath>
12<image id="image0_555_6" width="1393" height="1079" preserveAspectRatio="none" xlink:href="data:image/png;base64,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"/>
13</defs>
14</svg>
service/evil-forgejo/service.pkl created+114
......@@ -0,0 +1,114 @@
1extends "../../config/Service.pkl"
2
3import "../../config/Service.pkl" as service
4import "../../config/site.pkl" as site
5import "../postgres/service.pkl" as postgres
6
7class OAuthClient extends service.Requirement {
8 alias: String = "oauth"
9 fixed provider = "evil-forgejo"
10 fixed kind = "oauth-client"
11 name: String(isNotEmpty)
12 redirectUris: Listing<String>
13}
14
15local gitHost = if (site.domain.endsWith(".test")) "git.evil.\(site.domain)" else "git.evil.inc"
16
17meta { name = "evil.inc Git" }
18healthyDeadline = "20m"
19healthRestartGrace = "2m"
20setup = "setup.py"
21provide = "provide.py"
22
23requirements { new postgres.Database { name = "evil_forgejo" } }
24
25secrets {
26 ["lfs_jwt"] {}
27 ["oauth_jwt"] {}
28 ["security_key"] {}
29 ["internal_token"] {}
30 ["anubis_key"] {}
31 ["automation_password"] {}
32}
33
34requiredSecrets = if (site.domain.endsWith(".test")) new Listing {} else new Listing {
35 "mailer_address"
36 "mailer_username"
37 "mailer_password"
38}
39
40containers {
41 ["forgejo"] {
42 image = "code.forgejo.org/forgejo/forgejo@sha256:3d0ba60633e5ce50a4ac8afce06797eda5728411db047373e7d7d68cecb5bfe8"
43 entrypoint = "/bin/sh"
44 args { "/studio/start.sh" }
45 hostNetwork = true
46 imageUser = true
47 cpu = 300
48 memory = 1024
49
50 http {
51 containerPort = 3000
52 checkPath = "/api/healthz"
53 }
54
55 volumes {
56 ["/data"] {}
57 ["/studio/start.sh"] { config = "start.sh" }
58 ["/studio/app.ini"] { config = "app.ini" }
59 ["/custom/public"] { config = "public" }
60 }
61
62 env {
63 ["FORGEJO_CUSTOM"] = "/custom"
64 ["USER_UID"] = "\(module.uid)"
65 ["USER_GID"] = "\(module.uid)"
66 ["GIT_HOST"] = gitHost
67 ["DB_NAME"] = "${secret.database.name}"
68 ["DB_USER"] = "${secret.database.username}"
69 ["DB_PASSWORD"] = "${secret.database.password}"
70 ["FORGEJO_SERVER_LFS_JWT_SECRET"] = "${secret.own.lfs_jwt}"
71 ["FORGEJO_OAUTH2_JWT_SECRET"] = "${secret.own.oauth_jwt}"
72 ["FORGEJO_SECURITY_SECRET_KEY"] = "${secret.own.security_key}"
73 ["FORGEJO_SECURITY_INTERNAL_TOKEN"] = "${secret.own.internal_token}"
74 ["MAILER_ENABLED"] = if (site.domain.endsWith(".test")) "false" else "true"
75 ["MAILER_ADDRESS"] = if (site.domain.endsWith(".test")) "127.0.0.1" else "${secret.own.mailer_address}"
76 ["MAILER_USERNAME"] = if (site.domain.endsWith(".test")) "" else "${secret.own.mailer_username}"
77 ["MAILER_PASSWORD"] = if (site.domain.endsWith(".test")) "" else "${secret.own.mailer_password}"
78 }
79
80 envTemplate = """
81 FORGEJO_HTTP_PORT={{ env "NOMAD_PORT_forgejo_http" }}
82 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "postgres" }}DB_HOST=\(module.nomadHostPort){{ end }}
83 """
84 }
85
86 ["anubis"] {
87 image = "ghcr.io/techarohq/anubis@sha256:2babd956e4eb6daa06a41a9b3cbde394a0b2dae1872395f02a380436637c1bfa"
88 hostNetwork = true
89 imageUser = true
90 cpu = 100
91 memory = 256
92
93 http {
94 containerPort = 80
95 hostname = gitHost
96 checkPath = "/api/healthz"
97 checkHeaders { ["X-Real-Ip"] = "127.0.0.1" }
98 forwardRealIp = true
99 }
100
101 env {
102 ["COOKIE_DOMAIN"] = gitHost
103 ["REDIRECT_DOMAINS"] = gitHost
104 ["ED25519_PRIVATE_KEY_HEX"] = "${secret.own.anubis_key}"
105 ["COOKIE_PREFIX"] = "anubis_v1"
106 ["DIFFICULTY"] = "5"
107 }
108
109 envTemplate = """
110 BIND=:{{ env "NOMAD_PORT_anubis_http" }}
111 TARGET=http://127.0.0.1:{{ env "NOMAD_PORT_forgejo_http" }}
112 """
113 }
114}
service/evil-forgejo/setup.py created+70
......@@ -0,0 +1,70 @@
1#!/usr/bin/env python3
2import json
3import os
4import secrets
5import subprocess
6import sys
7import tempfile
8import urllib.error
9import urllib.request
10
11from api import instance
12
13
14data = json.load(sys.stdin)
15container, address = instance(data["serviceId"])
16script = """
17IFS= read -r password
18cd /app/gitea
19su-exec git /usr/local/bin/gitea admin user create \
20 --username studio-automation --email studio-automation@users.invalid \
21 --password "$password" --must-change-password=false >/dev/null 2>&1 || true
22su-exec git /usr/local/bin/gitea admin user change-password \
23 --username studio-automation --password "$password" >/dev/null
24su-exec git /usr/local/bin/gitea admin user must-change-password \
25 --unset studio-automation >/dev/null
26"""
27result = subprocess.run(
28 ["podman", "--url", "unix:///run/podman/podman.sock", "exec", "-i", container,
29 "/bin/sh", "-ec", script],
30 input=data["automation_password"] + "\n", text=True, capture_output=True,
31)
32if result.returncode:
33 raise RuntimeError("could not prepare Forgejo automation account")
34
35path = "nomad/jobs/" + data["serviceId"]
36request = urllib.request.Request(
37 "http://127.0.0.1:4646/v1/var/" + path,
38 headers={"X-Nomad-Token": os.environ["NOMAD_TOKEN"]},
39)
40with urllib.request.urlopen(request, timeout=5) as response:
41 variable = json.load(response)
42current = variable["Items"].get("automation_token")
43if current:
44 check = urllib.request.Request(address + "/api/v1/user", headers={"Authorization": "token " + current})
45 try:
46 with urllib.request.urlopen(check, timeout=5) as response:
47 response.read()
48 except urllib.error.HTTPError as error:
49 if error.code not in (401, 403):
50 raise
51 current = None
52if not current:
53 generated = subprocess.run(
54 ["podman", "--url", "unix:///run/podman/podman.sock", "exec", container,
55 "/bin/sh", "-ec", "cd /app/gitea; su-exec git /usr/local/bin/gitea admin user generate-access-token --username studio-automation --token-name studio-" + secrets.token_hex(8) + " --raw"],
56 check=True, capture_output=True, text=True,
57 ).stdout.strip()
58 if len(generated) < 20:
59 raise ValueError("Forgejo did not generate an automation token")
60 with tempfile.NamedTemporaryFile("w", suffix=".nv.hcl", delete=False) as file:
61 file.write("items {\n" + "".join(
62 f" {key} = {json.dumps(value)}\n"
63 for key, value in {**variable["Items"], "automation_token": generated}.items()
64 ) + "}\n")
65 filename = file.name
66 try:
67 subprocess.run(["nomad", "var", "put", "-in=hcl", "-out=none",
68 f"-check-index={variable['ModifyIndex']}", path, "@" + filename], check=True)
69 finally:
70 os.unlink(filename)
service/evil-forgejo/start.sh created+10
......@@ -0,0 +1,10 @@
1#!/bin/sh
2set -eu
3
4mkdir -p /custom/conf
5envsubst < /studio/app.ini > /custom/conf/app.ini
6chown "$USER_UID:$USER_GID" /custom/conf /custom/conf/app.ini
7chmod 750 /custom/conf
8chmod 600 /custom/conf/app.ini
9# The image's s6 bundle starts an SSH daemon on the host's port 22.
10exec /usr/bin/entrypoint /bin/bash -c 'source /etc/s6/gitea/setup; cd /app/gitea; exec su-exec git /usr/local/bin/gitea web'
service/evil-hedgedoc/icon.svg created+1
......@@ -0,0 +1 @@
1<svg fill="#1e81b0" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>HedgeDoc</title><path d="m12.097.227-1.913 1.341L7.93.914 6.6 2.816l-2.346.142-.586 2.234-2.157.92.23 2.295L.032 9.995l1.015 2.083L0 14.14l1.679 1.616-.267 2.291 2.141.955.549 2.243 2.344.178 1.3 1.925 2.965-.836-6.421-6.298a4.548 4.548 0 0 1-1.491-3.364c0-2.542 2.1-4.601 4.692-4.601 1.406 0 2.668.607 3.527 1.57l.978.959 1.195-1.173a4.725 4.725 0 0 1 3.3-1.332c2.591 0 4.692 2.061 4.692 4.603 0 1.4-.702 2.628-1.644 3.497l-6.291 6.178a1.78 1.78 0 0 0-1.25-.509c-.489 0-.933.195-1.252.51.006.675.563 1.22 1.252 1.22.66 0 1.2-.502 1.248-1.139l2.822.78 1.33-1.901 2.348-.142.585-2.234 2.156-.921-.227-2.297 1.705-1.587-1.015-2.081L24 10.186l-1.68-1.614.266-2.293-2.14-.955-.55-2.243-2.344-.18L16.253.98l-2.265.619ZM9.292 13.58c-.614 0-1.111.489-1.111 1.091a1.1 1.1 0 0 0 1.111 1.09 1.1 1.1 0 0 0 1.112-1.09 1.1 1.1 0 0 0-1.112-1.09zm5.423 0a1.1 1.1 0 0 0-1.11 1.091 1.1 1.1 0 0 0 1.11 1.09c.616 0 1.112-.488 1.112-1.09 0-.602-.496-1.09-1.112-1.09z"/></svg>
service/evil-hedgedoc/service.pkl created+66
......@@ -0,0 +1,66 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4import "../evil-forgejo/service.pkl" as forgejo
5import "../postgres/service.pkl" as postgres
6
7local host = if (site.preview) "\(module.id).evil.\(site.domain)" else if (site.domain.endsWith(".test")) "md.evil.\(site.domain)" else "md.evil.inc"
8
9meta { name = "evil.inc Notes" }
10healthyDeadline = "15m"
11healthRestartGrace = "5m"
12
13requirements {
14 new postgres.Database { name = "evil_hedgedoc" }
15 new forgejo.OAuthClient {
16 name = "HedgeDoc"
17 redirectUris { "https://\(host)/auth/oauth2/callback" }
18 }
19}
20
21secrets { ["session_secret"] {} }
22
23container {
24 image = "quay.io/hedgedoc/hedgedoc@sha256:7b3f79667ad58c6419758547f10940638bcdc85ebee2a4e650318a704095975b"
25 cpu = 200
26 memory = 768
27
28 http {
29 containerPort = 3000
30 hostname = host
31 checkPath = "/_health"
32 metricsPath = "/metrics"
33 }
34
35 volumes { ["/hedgedoc/public/uploads"] {} }
36
37 env {
38 ["CMD_DB_DIALECT"] = "postgres"
39 ["CMD_DB_DATABASE"] = "${secret.database.name}"
40 ["CMD_DB_USERNAME"] = "${secret.database.username}"
41 ["CMD_DB_PASSWORD"] = "${secret.database.password}"
42 ["CMD_DOMAIN"] = host
43 ["CMD_PROTOCOL_USESSL"] = "true"
44 ["CMD_URL_ADDPORT"] = "false"
45 ["CMD_EMAIL"] = "false"
46 ["CMD_ALLOW_ANONYMOUS"] = "false"
47 ["CMD_ALLOW_ANONYMOUS_EDITS"] = "false"
48 ["CMD_SESSION_SECRET"] = "${secret.own.session_secret}"
49 ["CMD_OAUTH2_PROVIDERNAME"] = "git.evil.inc"
50 ["CMD_OAUTH2_CLIENT_ID"] = "${secret.oauth.clientId}"
51 ["CMD_OAUTH2_CLIENT_SECRET"] = "${secret.oauth.clientSecret}"
52 ["CMD_OAUTH2_BASEURL"] = "${secret.oauth.providerUrl}"
53 ["CMD_OAUTH2_AUTHORIZATION_URL"] = "${secret.oauth.providerUrl}/login/oauth/authorize"
54 ["CMD_OAUTH2_USER_PROFILE_ID_ATTR"] = "id"
55 ["CMD_OAUTH2_USER_PROFILE_USERNAME_ATTR"] = "username"
56 ["CMD_OAUTH2_USER_PROFILE_DISPLAY_NAME_ATTR"] = "full_name"
57 ["CMD_OAUTH2_USER_PROFILE_EMAIL_ATTR"] = "email"
58 }
59
60 envTemplate = """
61 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "postgres" }}CMD_DB_HOST={{ .Address }}
62 CMD_DB_PORT={{ .Port }}{{ end }}
63 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "evil-forgejo-forgejo" }}CMD_OAUTH2_TOKEN_URL=http://\(module.nomadHostPort)/login/oauth/access_token
64 CMD_OAUTH2_USER_PROFILE_URL=http://\(module.nomadHostPort)/api/v1/user{{ end }}
65 """
66}
service/flaresolverr/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#ed7d2c"/><g fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 41h34a10 10 0 0 0-2-20 15 15 0 0 0-28 5 8 8 0 0 0-4 15Z"/><path d="m25 33 5 5 11-12"/></g></svg>
service/flaresolverr/service.pkl created+20
......@@ -0,0 +1,20 @@
1amends "../../config/Service.pkl"
2
3meta { name = "FlareSolverr" }
4rollout = "overlapped"
5
6container {
7 image = "ghcr.io/flaresolverr/flaresolverr@sha256:c80ae007ce2ccdcd217a12426e4f039ef763ff90738c808d38810c3e59323767"
8 imageUser = true
9 cpu = 300
10 memory = 1024
11
12 http {
13 containerPort = 8191
14 checkPath = "/health"
15 }
16
17 env {
18 ["TZ"] = "America/Los_Angeles"
19 }
20}
service/forward-auth/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#765dc4"/><g fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="27" width="36" height="27" rx="5"/><path d="M22 27v-8a10 10 0 0 1 20 0v8"/><circle cx="32" cy="40" r="2"/></g></svg>
service/forward-auth/service.pkl created+59
......@@ -0,0 +1,59 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4import "../keycloak/service.pkl" as keycloak
5
6meta { name = "Forward Auth" }
7rollout = "overlapped"
8
9requirements {
10 new keycloak.OpenIDClient {
11 clientId = module.id
12 name = module.meta.name
13 }
14}
15
16secrets {
17 ["cookie"] { bytes = 16 }
18}
19
20container {
21 image = "quay.io/oauth2-proxy/oauth2-proxy@sha256:56e3daedf765c7a1eea6e366fbe684be7d3084830ade14b6174570d3c7960954"
22 cpu = 100
23 memory = 256
24 extraHosts { "\(keycloak.container.http.hostname):host-gateway" }
25
26 http {
27 containerPort = 4180
28 checkPath = "/ping"
29 }
30
31 volumes {
32 ["/etc/ssl/certs/ca-certificates.crt"] {
33 src = "/var/lib/studio/ca-bundle.crt"
34 readOnly = true
35 }
36 }
37
38 env {
39 ["OAUTH2_PROXY_CLIENT_ID"] = "${secret.oidc.clientId}"
40 ["OAUTH2_PROXY_CLIENT_SECRET"] = "${secret.oidc.clientSecret}"
41 ["OAUTH2_PROXY_COOKIE_SECRET"] = "${secret.own.cookie}"
42 ["OAUTH2_PROXY_PROVIDER"] = "keycloak-oidc"
43 ["OAUTH2_PROXY_OIDC_ISSUER_URL"] = "https://\(keycloak.container.http.hostname)/realms/master"
44 // OAuth2 Proxy requires this claim even when Keycloak accounts have no email.
45 ["OAUTH2_PROXY_OIDC_EMAIL_CLAIM"] = "preferred_username"
46 ["OAUTH2_PROXY_CODE_CHALLENGE_METHOD"] = "S256"
47 ["OAUTH2_PROXY_EMAIL_DOMAINS"] = "*"
48 ["OAUTH2_PROXY_HTTP_ADDRESS"] = "0.0.0.0:4180"
49 ["OAUTH2_PROXY_PROXY_PREFIX"] = "/snow.oauth2"
50 ["OAUTH2_PROXY_SKIP_PROVIDER_BUTTON"] = "true"
51 ["OAUTH2_PROXY_REVERSE_PROXY"] = "true"
52 ["OAUTH2_PROXY_WHITELIST_DOMAINS"] = "*.\(site.domain)"
53 ["OAUTH2_PROXY_COOKIE_DOMAINS"] = ".\(site.domain)"
54 ["OAUTH2_PROXY_SET_XAUTHREQUEST"] = "true"
55 ["OAUTH2_PROXY_PASS_USER_HEADERS"] = "true"
56 ["OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL"] = "true"
57 ["OAUTH2_PROXY_UPSTREAMS"] = "static://202"
58 }
59}
service/intake/build/Dockerfile created+5
......@@ -0,0 +1,5 @@
1FROM docker.io/library/python:3.13-slim@sha256:37134a49d21d2120e4c4d73bb76f8a4ab9aef31f096f7ec2ead48c2feead4332
2COPY requirements.txt /app/requirements.txt
3RUN pip install --no-cache-dir -r /app/requirements.txt
4COPY app.py /app/app.py
5CMD ["python3", "-u", "/app/app.py"]
service/intake/build/app.py created+99
......@@ -0,0 +1,99 @@
1#!/usr/bin/env python3
2# intake: append-only json line log. POST any json to /<name> with the shared
3# key and it lands in <name>.jsonl; GET reads it back. no schema, no setup —
4# a new name creates a new file on first write.
5import hmac
6import json
7import os
8import re
9import threading
10import time
11
12from flask import Flask, Response, jsonify, request
13
14os.umask(0o007)
15
16DATA_DIR = os.environ.get("DATA_DIR", "/data")
17KEY = os.environ["INTAKE_KEY"]
18# filenames are user-supplied path segments; anything outside this can escape
19NAME_RE = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}\Z")
20
21app = Flask(__name__)
22app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 * 1024
23write_lock = threading.Lock()
24
25
26def authorized():
27 given = request.headers.get("X-Intake-Key") or ""
28 if not given:
29 auth = request.headers.get("Authorization", "")
30 if auth.startswith("Bearer "):
31 given = auth[7:]
32 return hmac.compare_digest(given, KEY)
33
34
35def path_for(name):
36 if not NAME_RE.match(name):
37 return None
38 return os.path.join(DATA_DIR, name + ".jsonl")
39
40
41@app.before_request
42def check_key():
43 if request.path == "/health":
44 return
45 if not authorized():
46 return jsonify(error="bad or missing key"), 401
47
48
49@app.get("/health")
50def health():
51 return "", 204
52
53
54@app.get("/")
55def index():
56 dbs = []
57 for f in sorted(os.listdir(DATA_DIR)):
58 if f.endswith(".jsonl"):
59 st = os.stat(os.path.join(DATA_DIR, f))
60 dbs.append({"name": f[:-6], "bytes": st.st_size, "modified": int(st.st_mtime)})
61 return jsonify(databases=dbs)
62
63
64@app.post("/<name>")
65def append(name):
66 path = path_for(name)
67 if path is None:
68 return jsonify(error="name must match [a-z0-9][a-z0-9._-]{0,63}"), 400
69 body = request.get_data()
70 try:
71 row = json.loads(body)
72 except ValueError:
73 # shortcuts and curl one-liners often send plain text; keep it rather
74 # than rejecting, so a mis-typed shortcut still logs something usable
75 row = {"text": body.decode("utf-8", "replace")}
76 rows = row if isinstance(row, list) else [row]
77 now = time.time()
78 lines = []
79 for r in rows:
80 if not isinstance(r, dict):
81 r = {"value": r}
82 lines.append(json.dumps({"_at": now, **r}, ensure_ascii=False) + "\n")
83 with write_lock, open(path, "a", encoding="utf-8") as fh:
84 fh.write("".join(lines))
85 return jsonify(ok=True, db=name, appended=len(lines))
86
87
88@app.get("/<name>")
89def read(name):
90 path = path_for(name)
91 if path is None or not os.path.exists(path):
92 return jsonify(error="no such database"), 404
93 with open(path, "rb") as fh:
94 return Response(fh.read(), mimetype="application/x-ndjson")
95
96
97if __name__ == "__main__":
98 os.makedirs(DATA_DIR, exist_ok=True)
99 app.run(host="0.0.0.0", port=8000)
service/intake/build/requirements.txt created+7
......@@ -0,0 +1,7 @@
1blinker==1.9.0
2click==8.5.0
3Flask==3.1.3
4itsdangerous==2.2.0
5Jinja2==3.1.6
6MarkupSafe==3.0.3
7Werkzeug==3.1.8
service/intake/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#4d9d76"/><g fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 39h13l4 7h12l4-7h13l-5 15H14Z"/><path d="M32 8v28m0 0-9-9m9 9 9-9"/></g></svg>
service/intake/service.pkl created+28
......@@ -0,0 +1,28 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5meta { name = "JSON Intake" }
6
7secrets = if (site.domain.endsWith(".test")) new { ["key"] {} } else new {}
8requiredSecrets = if (site.domain.endsWith(".test")) new {} else new { "key" }
9
10container {
11 build = "build"
12 cpu = 100
13 memory = 128
14
15 http {
16 containerPort = 8000
17 subdomain = "intake"
18 checkPath = "/health"
19 }
20
21 volumes {
22 ["/data"] { src = "\(site.cloverRoot)/Documents/Intake"; readOnly = site.cloverReadOnly }
23 }
24
25 env {
26 ["INTAKE_KEY"] = "${secret.own.key}"
27 }
28}
service/jackett/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#4d7393"/><g fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20h28v34H18Z"/><path d="M18 29 8 35l6 11h4m28-17 10 6-6 11h-4"/><path d="M25 20v-8h14v8"/></g></svg>
service/jackett/service.pkl created+30
......@@ -0,0 +1,30 @@
1amends "../../config/Service.pkl"
2
3meta { name = "Jackett" }
4
5container {
6 image = "lscr.io/linuxserver/jackett@sha256:139a0819074ef16556399a66ee3c492f0d202ec20467cf9300d7126b8a31f2b5"
7 cpu = 200
8 memory = 512
9
10 http {
11 containerPort = 9117
12 subdomain = "jkt"
13 authRole = "media-manage"
14 checkPath = "/health"
15 }
16 tcp {
17 name = "internal"
18 containerPort = 9117
19 hostPort = 30017
20 loopback = false
21 }
22
23 volumes {
24 ["/config/Jackett"] {}
25 }
26
27 env {
28 ["TZ"] = "America/Los_Angeles"
29 }
30}
service/jellyfin/branding.xml created+43
......@@ -0,0 +1,43 @@
1<?xml version="1.0" encoding="utf-8"?>
2<BrandingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
3 <LoginDisclaimer>
4&lt;form id=snow-sign-on-form action=&quot;/sso/OID/start/snow&quot; method=GET&gt;
5 &lt;h2&gt;you are not logged in...&lt;/h2&gt;
6 &lt;button type=submit class=&quot;raised block emby-button button-submit snow-sign-on&quot;&gt;
7 snow sign on
8 &lt;/button&gt;
9&lt;/form&gt;
10
11and then we knew, just like paper airplanes: that we could fly.</LoginDisclaimer>
12 <CustomCss>@import url("/web/extra/theme.css");
13#loginPage { display: flex; align-items: center; justify-content: center; }
14#loginPage &gt; .padded-left { width: min(100%, 32rem); margin: auto; padding: 2rem; }
15#loginPage .manualLoginForm, #loginPage .visualLoginForm, #loginPage .btnForgotPassword { display: none; }
16#loginPage .readOnlyContent { display: flex; flex-direction: column; margin: 0 !important; }
17#loginPage .loginDisclaimerContainer { order: 0; display: block; margin: 0; }
18#loginPage .loginDisclaimer { width: 100%; }
19#snow-sign-on-form { text-align: center; }
20#snow-sign-on-form h2 { margin: 0 0 1.5rem; }
21#loginPage .emby-button { background: var(--hover-background); width: 100%; border-radius: 8px; }
22.snow-sign-on { display: flex; align-items: center; justify-content: center; min-height: 3rem; }
23#loginPage .btnQuick { order: 1; margin-top: 1rem; text-transform: lowercase; }
24#loginPage .btnQuick:before { margin-right: .5rem; }
25#loginPage .loginDisclaimer p { margin-top: 2rem; }
26.snow-sign-on:before, .btnQuick:before {
27display: block;
28content: &quot; &quot;;
29width:24px;
30height:24px;
31margin-right: 0.5rem;
32}
33.snow-sign-on:before{
34background:url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9Ii0yIC0yIDI4IDI4IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNMTAgMjBMOC43NSAxNy41TDYgMThNMTAgNEw4Ljc1IDYuNUw2IDZNMTQgMjBMMTUuMjUgMTcuNUwxOCAxOE0xNCA0TDE1LjI1IDYuNUwxOCA2TTE3IDIxTDE0IDE1TTE0IDE1SDEwTTE0IDE1TDE1LjUgMTJNMTAgMTVMNyAyMU0xMCAxNUw4LjUgMTJNMTcgM0wxNCA5TTE0IDlMMTUuNSAxMk0xNCA5SDEwTTE1LjUgMTJIMjJNMiAxMkg4LjVNOC41IDEyTDEwIDlNMTAgOUw3IDNNMjAgMTBMMTguNSAxMkwyMCAxNE00IDEwTDUuNSAxMkw0IDE0IiBzdHJva2U9InVybCgjcGFpbnQwX2xpbmVhcl81NDhfMTkpIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8cGF0aCBkPSJNMTAgMjBMOC43NSAxNy41TDYgMThNMTAgNEw4Ljc1IDYuNUw2IDZNMTQgMjBMMTUuMjUgMTcuNUwxOCAxOE0xNCA0TDE1LjI1IDYuNUwxOCA2TTE3IDIxTDE0IDE1TTE0IDE1SDEwTTE0IDE1TDE1LjUgMTJNMTAgMTVMNyAyMU0xMCAxNUw4LjUgMTJNMTcgM0wxNCA5TTE0IDlMMTUuNSAxMk0xNCA5SDEwTTE1LjUgMTJIMjJNMiAxMkg4LjVNOC41IDEyTDEwIDlNMTAgOUw3IDNNMjAgMTBMMTguNSAxMkwyMCAxNE00IDEwTDUuNSAxMkw0IDE0IiBzdHJva2U9InVybCgjcGFpbnQxX2xpbmVhcl81NDhfMTkpIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzU0OF8xOSIgeDE9IjQiIHkxPSItMiIgeDI9IjE4LjUiIHkyPSIyNSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjMjIzRDk5Ii8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzE1NDM5MiIvPgo8L2xpbmVhckdyYWRpZW50Pgo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MV9saW5lYXJfNTQ4XzE5IiB4MT0iMTAiIHkxPSItMyIgeDI9IjE4IiB5Mj0iMjciIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KPHN0b3Agc3RvcC1jb2xvcj0iI0YyRTNGRiIvPgo8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGMkY4RkYiLz4KPC9saW5lYXJHcmFkaWVudD4KPC9kZWZzPgo8L3N2Zz4=);
35}
36.btnQuick:before{
37mask-image:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNoaWVsZC1lbGxpcHNpcy1pY29uIGx1Y2lkZS1zaGllbGQtZWxsaXBzaXMiPjxwYXRoIGQ9Ik0yMCAxM2MwIDUtMy41IDcuNS03LjY2IDguOTVhMSAxIDAgMCAxLS42Ny0uMDFDNy41IDIwLjUgNCAxOCA0IDEzVjZhMSAxIDAgMCAxIDEtMWMyIDAgNC41LTEuMiA2LjI0LTIuNzJhMS4xNyAxLjE3IDAgMCAxIDEuNTIgMEMxNC41MSAzLjgxIDE3IDUgMTkgNWExIDEgMCAwIDEgMSAxeiIvPjxwYXRoIGQ9Ik04IDEyaC4wMSIvPjxwYXRoIGQ9Ik0xMiAxMmguMDEiLz48cGF0aCBkPSJNMTYgMTJoLjAxIi8+PC9zdmc+);
38mask-size:cover;
39background:var(--main-text);
40}</CustomCss>
41 <SplashscreenEnabled>false</SplashscreenEnabled>
42 <SplashscreenLocation>/config/upload/splashscreen-upload.jpg</SplashscreenLocation>
43</BrandingOptions>
service/jellyfin/configure.py created+73
......@@ -0,0 +1,73 @@
1#!/usr/bin/env python3
2import json
3import ssl
4import sys
5import time
6import urllib.error
7import urllib.parse
8import urllib.request
9from pathlib import Path
10
11
12config = json.load(sys.stdin)
13base = f"https://{config['host']}"
14ca = Path("/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt")
15context = ssl.create_default_context(cafile=str(ca) if ca.exists() else None)
16
17
18def request(path, data=None, token=None):
19 headers = {
20 "Content-Type": "application/json",
21 "Authorization": 'MediaBrowser Client="home server", Device="server", DeviceId="studio", Version="1"',
22 }
23 if token:
24 headers["X-Emby-Token"] = token
25 body = json.dumps(data).encode() if data is not None else None
26 req = urllib.request.Request(base + path, data=body, headers=headers, method="POST" if body is not None else "GET")
27 with urllib.request.urlopen(req, context=context, timeout=30) as response:
28 content = response.read()
29 return json.loads(content) if content else None
30
31
32for _ in range(600):
33 try:
34 info = request("/System/Info/Public")
35 completed = info.get("StartupWizardCompleted", info.get("startupWizardCompleted")) if isinstance(info, dict) else None
36 if completed is not None and (completed or request("/Startup/FirstUser")):
37 break
38 except urllib.error.HTTPError as error:
39 if error.code not in (404, 500, 502, 503):
40 raise
41 except (urllib.error.URLError, TimeoutError):
42 pass
43 time.sleep(2)
44else:
45 raise RuntimeError("Jellyfin did not finish loading")
46
47if completed:
48 sys.exit(0)
49
50request("/Startup/User", {"Name": "snow", "Password": config["admin_password"]})
51request("/Startup/Configuration", {
52 "ServerName": "Jellyfin", "UICulture": "en-US", "MetadataCountryCode": "US", "PreferredMetadataLanguage": "en",
53})
54request("/Startup/Complete", {})
55
56auth = request("/Users/AuthenticateByName", {
57 "Username": "snow", "Pw": config["admin_password"],
58})
59token = auth["AccessToken"]
60libraries = (
61 ("Anime", "tvshows", ("Anime",)),
62 ("Movies", "movies", ("Movies",)),
63 ("paper clover", "musicvideos", ("Paper Clover",)),
64 ("Shows", "tvshows", ("Shows", "Indie Shows")),
65 ("Videos", "homevideos", ("Independent",)),
66)
67for name, kind, folders in libraries:
68 query = urllib.parse.urlencode({
69 "name": name, "collectionType": kind,
70 "paths": ",".join(f"/media/jellyfin/{folder}" for folder in folders),
71 "refreshLibrary": "false",
72 })
73 request(f"/Library/VirtualFolders?{query}", {}, token)
service/jellyfin/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><linearGradient id="a" x1="97.508" x2="522.069" y1="308.135" y2="63.019" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#aa5cc3"/><stop offset="1" style="stop-color:#00a4dc"/></linearGradient><path d="M256 196.2c-22.4 0-94.8 131.3-83.8 153.4s156.8 21.9 167.7 0-61.3-153.4-83.9-153.4" style="fill:url(#a)"/><linearGradient id="b" x1="94.193" x2="518.754" y1="302.394" y2="57.278" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#aa5cc3"/><stop offset="1" style="stop-color:#00a4dc"/></linearGradient><path d="M256 0C188.3 0-29.8 395.4 3.4 462.2s472.3 66 505.2 0S323.8 0 256 0m165.6 404.3c-21.6 43.2-309.3 43.8-331.1 0S211.7 101.4 256 101.4 443.2 361 421.6 404.3" style="fill:url(#b)"/></svg>
\ No newline at end of file
service/jellyfin/prepare.py created+87
......@@ -0,0 +1,87 @@
1#!/usr/bin/env python3
2import json
3import os
4from pathlib import Path
5import subprocess
6import sys
7import xml.etree.ElementTree as ET
8import zipfile
9
10
11data = json.load(sys.stdin)
12root = Path(data["hostRoot"]) / "config"
13uid = data["uid"]
14service = Path(data["hostRoot"]).name
15variable = json.loads(subprocess.check_output(
16 ["nomad", "var", "get", "-out=json", f"nomad/jobs/{service}/inputs/oidc"],
17 text=True, stderr=subprocess.DEVNULL,
18))["Items"]
19
20plugins = root / "plugins"
21plugins.mkdir(parents=True, exist_ok=True)
22os.chown(plugins, uid, uid)
23plugin = plugins / "SSO Authentication_4.0.0.4"
24files = {"meta.json", "Duende.IdentityModel.dll", "SSO-Auth.dll", "Duende.IdentityModel.OidcClient.dll"}
25if not all((plugin / name).is_file() for name in files):
26 with zipfile.ZipFile(Path(__file__).with_name("sso-authentication_4.0.0.4.zip")) as source:
27 if set(source.namelist()) != files:
28 raise ValueError("unexpected SSO plugin archive contents")
29 plugin.mkdir(exist_ok=True)
30 os.chown(plugin, uid, uid)
31 for name in files:
32 path = plugin / name
33 path.write_bytes(source.read(name))
34 os.chown(path, uid, uid)
35
36configs = plugins / "configurations"
37configs.mkdir(parents=True, exist_ok=True)
38os.chown(configs, uid, uid)
39path = configs / "SSO-Auth.xml"
40ET.register_namespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
41tree = ET.parse(path if path.exists() else Path(__file__).with_name("sso.xml"))
42items = tree.getroot().findall("./OidConfigs/item")
43matches = [item for item in items if item.findtext("./key/string") == "snow"]
44if len(matches) != 1:
45 raise ValueError("expected one Jellyfin SSO provider named snow")
46config = matches[0].find("./value/PluginConfiguration")
47for name, value in {
48 "OidEndpoint": variable["issuerUrl"],
49 "OidClientId": variable["clientId"],
50 "OidSecret": variable["clientSecret"],
51 "Enabled": "true",
52 "DefaultUsernameClaim": "preferred_username",
53}.items():
54 element = config.find(name)
55 if element is None:
56 element = ET.SubElement(config, name)
57 element.text = value
58content = ET.tostring(tree.getroot(), encoding="utf-8", xml_declaration=True)
59if not path.exists() or path.read_bytes() != content:
60 pending = path.with_suffix(".xml.pending")
61 pending.write_bytes(content)
62 os.chmod(pending, 0o600)
63 os.chown(pending, uid, uid)
64 os.replace(pending, path)
65
66branding = root / "config"
67branding.mkdir(exist_ok=True)
68os.chown(branding, uid, uid)
69target = branding / "branding.xml"
70if not target.exists():
71 target.touch()
72 os.chown(target, uid, uid)
73
74system = branding / "system.xml"
75if not system.exists() or not system.stat().st_size:
76 system.write_bytes(b"<ServerConfiguration><EnableMetrics>true</EnableMetrics></ServerConfiguration>")
77 os.chown(system, uid, uid)
78settings = ET.parse(system)
79metrics = settings.getroot().find("EnableMetrics")
80if metrics is None:
81 metrics = ET.SubElement(settings.getroot(), "EnableMetrics")
82if metrics.text != "true":
83 metrics.text = "true"
84 pending = system.with_suffix(".xml.pending")
85 settings.write(pending, encoding="utf-8", xml_declaration=True)
86 os.chown(pending, uid, uid)
87 os.replace(pending, system)
service/jellyfin/service.pkl created+55
......@@ -0,0 +1,55 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4import "../keycloak/service.pkl" as keycloak
5
6meta { name = "Jellyfin" }
7// SQLite under /config requires one writer during rollout.
8rollout = "simple"
9healthyDeadline = "20m"
10healthRestartGrace = "20m"
11prepare = "prepare.py"
12setup = "configure.py"
13
14secrets { ["admin_password"] {} }
15
16requirements {
17 new keycloak.OpenIDClient {
18 clientId = module.id
19 name = module.meta.name
20 }
21}
22
23container {
24 image = "docker.io/jellyfin/jellyfin@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db"
25 cpu = 500
26 memory = 3072
27 extraHosts { "\(keycloak.container.http.hostname):host-gateway" }
28
29 http {
30 containerPort = 8096
31 subdomain = "jelly"
32 checkPath = "/health"
33 metricsPath = "/metrics"
34 overrideFiles { ["/web/extra/"] = "theme" }
35 headHtml {
36 ["/web/"] = "<link rel=\"stylesheet\" href=\"/web/extra/theme.css\">"
37 ["/web/index.html"] = "<link rel=\"stylesheet\" href=\"/web/extra/theme.css\">"
38 }
39 }
40
41 volumes {
42 ["/config"] {}
43 ["/cache"] {}
44 ["/media"] { src = site.mediaRoot; readOnly = true }
45 ["/config/config/branding.xml"] { config = "branding.xml" }
46 ["/etc/ssl/certs/ca-certificates.crt"] {
47 src = "/var/lib/studio/ca-bundle.crt"
48 readOnly = true
49 }
50 }
51
52 env {
53 ["JELLYFIN_PublishedServerUrl"] = "https://\(module.container.http.hostname)"
54 }
55}
service/jellyfin/sso-authentication_4.0.0.4.zip created
Binary files /dev/null and b/service/jellyfin/sso-authentication_4.0.0.4.zip differ
service/jellyfin/sso.xml created+41
......@@ -0,0 +1,41 @@
1<?xml version="1.0" encoding="utf-8"?>
2<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
3 <SamlConfigs/>
4 <OidConfigs>
5 <item>
6 <key>
7 <string>snow</string>
8 </key>
9 <value>
10 <PluginConfiguration>
11 <OidEndpoint/>
12 <OidClientId/>
13 <OidSecret/>
14 <Enabled>false</Enabled>
15 <EnableAuthorization>false</EnableAuthorization>
16 <EnableAllFolders>true</EnableAllFolders>
17 <EnabledFolders/>
18 <AdminRoles/>
19 <Roles/>
20 <EnableFolderRoles>false</EnableFolderRoles>
21 <EnableLiveTvRoles>false</EnableLiveTvRoles>
22 <EnableLiveTv>false</EnableLiveTv>
23 <EnableLiveTvManagement>false</EnableLiveTvManagement>
24 <LiveTvRoles/>
25 <LiveTvManagementRoles/>
26 <FolderRoleMappings/>
27 <OidScopes/>
28 <SchemeOverride>https</SchemeOverride>
29 <PortOverride xsi:nil="true"/>
30 <NewPath>true</NewPath>
31 <CanonicalLinks/>
32 <DisableHttps>false</DisableHttps>
33 <DisablePushedAuthorization>false</DisablePushedAuthorization>
34 <DoNotValidateEndpoints>false</DoNotValidateEndpoints>
35 <DoNotValidateIssuerName>false</DoNotValidateIssuerName>
36 <DefaultUsernameClaim>preferred_username</DefaultUsernameClaim>
37 </PluginConfiguration>
38 </value>
39 </item>
40 </OidConfigs>
41</PluginConfiguration>
service/jellyfin/theme/cloverfin-banner-light.png created
Binary files /dev/null and b/service/jellyfin/theme/cloverfin-banner-light.png differ
service/jellyfin/theme/cloverfin-banner.png created
Binary files /dev/null and b/service/jellyfin/theme/cloverfin-banner.png differ
service/jellyfin/theme/cloverfin-icon-light.png created
Binary files /dev/null and b/service/jellyfin/theme/cloverfin-icon-light.png differ
service/jellyfin/theme/cloverfin-icon.png created
Binary files /dev/null and b/service/jellyfin/theme/cloverfin-icon.png differ
service/jellyfin/theme/theme.css created+62
......@@ -0,0 +1,62 @@
1@import url("https://file.paperclover.net/.static/font.css") layer(fnt);
2 @import url("https://jellyfin.catppuccin.com/theme.css") layer(cat);
3 @import url("https://jellyfin.catppuccin.com/catppuccin-macchiato.css");
4 @import url("https://jellyfin.catppuccin.com/catppuccin-latte.css") (prefers-color-scheme: light);
5
6 /* font */
7 body { font-family: "Name Sans", sans-serif }
8 :root {
9 --main-color: var(--sapphire);
10 --jf-font-button: 500 0.875rem / 1.75 "Name Sans", sans-serif;
11 --jf-font-h1: 300 1.8rem / 1.167 "Name Sans", sans-serif;
12 --jf-font-h2: 300 1.5rem / 1.2 "Name Sans", sans-serif;
13 --jf-font-h3: 400 1.17rem / 1.167 "Name Sans", sans-serif;
14 --jf-font-h4: 400 2.125rem / 1.235 "Name Sans", sans-serif;
15 --jf-font-h5: 400 1.5rem / 1.334 "Name Sans", sans-serif;
16 --jf-font-h6: 500 1.25rem / 1.6 "Name Sans", sans-serif;
17 --jf-font-subtitle1: 400 1rem / 1.75 "Name Sans", sans-serif;
18 --jf-font-subtitle2: 500 0.875rem / 1.57 "Name Sans", sans-serif;
19 --jf-font-body1: 400 1rem / 1.5 "Name Sans", sans-serif;
20 --jf-font-body2: 400 0.875rem / 1.43 "Name Sans", sans-serif;
21 --jf-font-caption: 400 0.75rem / 1.66 "Name Sans", sans-serif;
22 --jf-font-overline: 400 0.75rem / 2.66 "Name Sans", sans-serif;
23 --jf-font-inherit: inherit inherit / inherit inherit;
24 }
25
26 /* branding */
27 .preload, .touch-menu-la, .mainDrawer-scrollContainer {
28 background-color: var(--main-background);
29 color: var(--main-text);
30 }
31 .pageTitleWithDefaultLogo { background-image: url(/web/extra/cloverfin-banner.png)!important; }
32 .layout-tv .pageTitleWithDefaultLogo { background-image: url(/web/extra/cloverfin-icon.png)!important; }
33 .splashLogo { background-image: url(/web/extra/cloverfin-icon.png)!important; }
34 @media (min-device-width: 992px) { .splashLogo { background-image: url(/web/extra/cloverfin-banner.png)!important } }
35 @media (prefers-color-scheme: light) {
36 .pageTitleWithDefaultLogo { background-image: url(/web/extra/cloverfin-banner-light.png)!important }
37 .layout-tv .pageTitleWithDefaultLogo { background-image: url(/web/extra/cloverfin-icon-light.png)!important }
38 .splashLogo { background-image: url(/web/extra/cloverfin-icon-light.png)!important }
39 }
40 @media (min-device-width: 992px) and (prefers-color-scheme: light) {
41 .splashLogo { background-image: url(/web/extra/cloverfin-banner-light.png)!important }
42 }
43
44 /* theme fixes */
45 .material-icons.play_arrow { display: flex }
46 .detailPagePrimaryContainer {
47 background: rgb(from var(--main-background) r g b / 75%);
48 }
49 .selectLabel {
50 display: flex;
51 align-items: center;
52 justify-content: flex-end;
53 font-weight: bold;
54 margin: 0;
55 }
56 .selectArrowContainer {
57 position: absolute;
58 display: flex;
59 align-items: center;
60 height: 100%;
61 }
62 .selectArrow { margin: 0; }
service/jellyfin/update-plugin.py created+15
......@@ -0,0 +1,15 @@
1#!/usr/bin/env python3
2import hashlib
3from pathlib import Path
4import urllib.request
5
6
7version = "4.0.0.4"
8digest = "c09f16ba31059a434ddd7f811e4f9608d4b4c4514cc80a5bf1ca33bee61e1107"
9name = f"sso-authentication_{version}.zip"
10url = f"https://github.com/9p4/jellyfin-plugin-sso/releases/download/v{version}/{name}"
11with urllib.request.urlopen(url, timeout=30) as response:
12 archive = response.read()
13if hashlib.sha256(archive).hexdigest() != digest:
14 raise ValueError("SSO plugin archive checksum mismatch")
15Path(__file__).with_name(name).write_bytes(archive)
service/keycloak/api.py created+83
......@@ -0,0 +1,83 @@
1import http.client
2import io
3import json
4import os
5import socket
6import ssl
7import time
8import urllib.error
9import urllib.parse
10
11
12class LoopbackHTTPS(http.client.HTTPSConnection):
13 def connect(self):
14 sock = socket.create_connection(("127.0.0.1", self.port), self.timeout)
15 self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
16
17
18class Keycloak:
19 def configure_profile(self):
20 profile = self.request("/admin/realms/master/users/profile")
21 if not any(attribute["name"] == "picture" for attribute in profile["attributes"]):
22 profile["attributes"].append({"name": "picture", "displayName": "Profile picture",
23 "permissions": {"view": ["admin", "user"], "edit": ["admin", "user"]},
24 "validations": {"length": {"max": 8192}}})
25 self.request("/admin/realms/master/users/profile", "PUT", profile)
26
27 def __init__(self, host, password, attempts=300, cafile=None):
28 self.host = host
29 self.context = ssl.create_default_context(
30 cafile=cafile or ("/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt"
31 if host.endswith(".test") else None)
32 )
33 self.token = None
34 for attempt in range(attempts):
35 try:
36 body = urllib.parse.urlencode({
37 "client_id": "admin-cli",
38 "grant_type": "password",
39 "username": "admin",
40 "password": password,
41 }).encode()
42 self.token = self.request("/realms/master/protocol/openid-connect/token", "POST", body)["access_token"]
43 break
44 except urllib.error.HTTPError as error:
45 if error.code not in (502, 503, 504) or attempt == attempts - 1:
46 raise
47 time.sleep(2)
48 except (ConnectionError, TimeoutError, http.client.HTTPException, ssl.SSLError):
49 if attempt == attempts - 1:
50 raise
51 time.sleep(2)
52
53 def request(self, path, method="GET", body=None, full=False):
54 headers = {}
55 if self.token:
56 headers["Authorization"] = "Bearer " + self.token
57 if isinstance(body, (dict, list)):
58 body = json.dumps(body).encode()
59 headers["Content-Type"] = "application/json"
60 elif body is not None:
61 headers["Content-Type"] = "application/x-www-form-urlencoded"
62 connection = LoopbackHTTPS(
63 self.host,
64 timeout=int(os.getenv("STUDIO_API_TIMEOUT", "10")),
65 context=self.context,
66 )
67 try:
68 connection.request(method, path, body=body, headers=headers)
69 response = connection.getresponse()
70 content = response.read(16 * 1024 * 1024 + 1)
71 if len(content) > 16 * 1024 * 1024:
72 raise ValueError("Keycloak response exceeds 16 MiB")
73 if response.status >= 400:
74 raise urllib.error.HTTPError(
75 "https://" + self.host + path, response.status, response.reason,
76 response.headers, io.BytesIO(content),
77 )
78 value = json.loads(content) if content else None
79 if full:
80 return {"body": value, "id": response.getheader("Location", "").rsplit("/", 1)[-1] or None}
81 return value
82 finally:
83 connection.close()
service/keycloak/configure.py created+67
......@@ -0,0 +1,67 @@
1#!/usr/bin/env python3
2import json
3import sys
4
5from api import Keycloak
6
7
8config = json.load(sys.stdin)
9keycloak = Keycloak(config["host"], config["password"])
10keycloak.configure_profile()
11keycloak.request(
12 "/admin/realms/master",
13 "PUT",
14 {
15 "displayName": "snow sign on",
16 "displayNameHtml": '<div class="kc-logo-text"><span>snow sign on</span></div>',
17 "editUsernameAllowed": True,
18 "resetPasswordAllowed": True,
19 "rememberMe": True,
20 "loginWithEmailAllowed": True,
21 "registrationAllowed": False,
22 "webAuthnPolicyRpEntityName": "snow sign on",
23 "webAuthnPolicyPasswordlessPasskeysEnabled": True,
24 "loginTheme": "snow",
25 },
26)
27roles_path = "/admin/realms/master/roles"
28roles = {role["name"]: role for role in keycloak.request(roles_path)}
29for name in ("admin", "infra-admin", "media", "media-manage"):
30 if name not in roles:
31 keycloak.request(roles_path, "POST", {"name": name})
32 roles[name] = keycloak.request(f"{roles_path}/{name}")
33
34users = keycloak.request("/admin/realms/master/users?username=admin&exact=true")
35if len(users) != 1:
36 raise ValueError("expected one bootstrap admin user")
37if users[0].get("email") or users[0].get("emailVerified"):
38 keycloak.request(
39 f"/admin/realms/master/users/{users[0]['id']}", "PUT",
40 {**users[0], "email": None, "emailVerified": False},
41 )
42mapping_path = f"/admin/realms/master/users/{users[0]['id']}/role-mappings/realm"
43assigned = {role["name"] for role in keycloak.request(mapping_path)}
44missing = [roles[name] for name in ("admin", "media", "media-manage") if name not in assigned]
45if missing:
46 keycloak.request(mapping_path, "POST", missing)
47
48snow = keycloak.request("/admin/realms/master/users?username=snow&exact=true")
49if not snow:
50 keycloak.request("/admin/realms/master/users", "POST", {
51 "username": "snow",
52 "enabled": True,
53 "credentials": [{"type": "password", "value": config["snow_password"], "temporary": False}],
54 })
55 snow = keycloak.request("/admin/realms/master/users?username=snow&exact=true")
56if len(snow) != 1:
57 raise ValueError("expected one snow user")
58if snow[0].get("email") != config["ownerEmail"] or not snow[0].get("emailVerified"):
59 keycloak.request(
60 f"/admin/realms/master/users/{snow[0]['id']}", "PUT",
61 {**snow[0], "email": config["ownerEmail"], "emailVerified": True},
62 )
63mapping_path = f"/admin/realms/master/users/{snow[0]['id']}/role-mappings/realm"
64assigned = {role["name"] for role in keycloak.request(mapping_path)}
65missing = [roles[name] for name in ("infra-admin", "media", "media-manage") if name not in assigned]
66if missing:
67 keycloak.request(mapping_path, "POST", missing)
service/keycloak/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.st9{fill:#d0d0d0}.st11{fill:#d9d9d9}.st13{fill:#d8d8d8}.st14{fill:#e2e2e2}.st16{fill:#dedede}.st21{fill:#00b8e3}.st22{fill:#33c6e9}.st23{fill:#008aaa}</style><g id="g2460" transform="translate(.714 .07)"><path id="path1588" d="M432.9 149.2c-1.4 0-2.7-.7-3.4-2L370.1 44.1c-.7-1.2-2-2-3.5-2H124.2c-1.4 0-2.7.7-3.4 2L58.9 150.9l23.9 34.9c-.7 1.2-6.2 24-5.5 25.2L58.9 360.9l61.9 106.9c.7 1.2 2 2 3.4 2h242.4c1.4 0 2.7-.7 3.5-2l59.4-103.2c.7-1.2 2-2 3.4-2h73.8c2.4 0 4.4-2 4.4-4.4V153.6c0-2.4-2-4.4-4.4-4.4z" style="fill:#4d4d4d"/><path id="path1594" d="M72.7 245.3 6.4 269.4l-6.6-11.3c-.7-1.2-.7-2.7 0-3.9l30-52z" style="fill:#e1e1e1"/><path id="polygon1794" d="M511.3 258.3V309l-43.7-44.5z" style="fill:#c8c8c8"/><path id="path1798" d="m467.5 264.5 43.7 44.5v49.6c0 2.4-2 4.4-4.4 4.4H456z" style="fill:#c2c2c2"/><path id="polygon1802" d="M467.5 264.5 456 362.9h-61.2l-18.5-44.7z" style="fill:#c7c7c7"/><path id="polygon1804" d="M511.3 211.2v47l-43.7 6.2z" style="fill:#cecece"/><path id="path1808" d="M511.3 153.6v57.6l-43.7 53.2-33.1-115.3h72.2c2.4-.1 4.5 1.8 4.6 4.3z" style="fill:#d3d3d3"/><path id="polygon1812" d="M394.8 362.9h-32.3l-8.4-12 22.1-32.7z" style="fill:#c6c6c6"/><path id="polygon1814" d="m467.5 264.5-121.1-51.2 63.7-64.1h24.4z" style="fill:#d5d5d5"/><path id="path1816" d="m346.5 213.3 29.8 105 91.2-53.8z" class="st9"/><path id="polygon1818" d="m353.8 362.9.4-12 8.4 12z" style="fill:#bfbfbf"/><path id="polygon1820" d="m410.1 149.2-63.7 64.1-11.4-57.4 24.6-6.8h50.5z" class="st11"/><path id="path1822" d="m346.5 213.3-147 33.9 154.7 103.7z" style="fill:#d4d4d4"/><path id="path1824" d="m346.5 213.3 7.7 137.6 22.1-32.7z" class="st9"/><path id="path1826" d="m335 155.9-135.5 91.2 147-33.9z" class="st11"/><path id="polygon1828" d="m199.5 247.2-63.7 115.7H99.6L72.7 245.3z" class="st13"/><path id="path1830" d="m134.3 149.2-61.5 96.1L57.3 155l2.2-3.8c.7-1.2 2-1.9 3.4-1.9z" class="st14"/><path id="path1832" d="M99.6 362.9H62.7c-1.4 0-2.8-.8-3.5-2L6.4 269.4l66.4-24.1z" class="st13"/><path id="polygon1834" d="M29.9 202.1 57.1 155l15.7 90.3z" style="fill:#e4e4e4"/><path id="polygon1836" d="m335 155.9-40.8-6.8H159.4l40.1 98z" class="st16"/><path id="polygon1838" d="m199.5 247.2-40.1-98h-25.1l-61.5 96.1z" class="st16"/><path id="polygon1840" d="M324.7 362.9h29.1l.4-12z" style="fill:#c5c5c5"/><path id="polygon1842" d="M266.7 362.9h58l29.5-12-154.7-103.7 27.9 115.7z" class="st9"/><path id="polygon1844" d="m227.4 362.9-27.9-115.7-63.7 115.7z" style="fill:#d1d1d1"/><path id="polygon1856" d="m335.4 149.2-.4 6.8 24.6-6.8z" style="fill:#ddd"/><path id="polygon1858" d="m335 155.9-3.8-6.8h-37z" style="fill:#e3e3e3"/><path id="polygon1860" d="m335 155.9.4-6.8h-4.2z" class="st14"/><path id="path1862" d="m223.9 151-59.7 103.4c-.3.5-.4 1.1-.4 1.7h-41.7l82-142q.75.45 1.2 1.2l18.6 32.3c.5 1.1.5 2.4 0 3.4" class="st21"/><path id="path1864" d="M223.8 364.9 205.3 397q-.45.75-1.2 1.2l-82-142.2h41.7c0 .6.1 1.1.4 1.6l59.6 103.2c.8 1.2.9 2.9 0 4.1" class="st22"/><path id="path1866" d="m204 114.2-82 141.9-20.6 35.6-19.6-34c-.3-.5-.4-1-.4-1.6s.1-1.2.4-1.7l19.9-34.4 60.4-104.5c.6-1.1 1.8-1.8 3-1.8h37.2c.6 0 1.2.2 1.7.5" class="st23"/><path id="path1868" d="M204 398.2c-.5.3-1.1.5-1.8.5h-37.1c-1.3 0-2.4-.7-3-1.8l-55.2-95.6-5.5-9.5 20.6-35.6z" class="st21"/><path id="path1870" d="m368.9 256.1-82 142q-.75-.45-1.2-1.2L267 364.7c-.5-1-.5-2.3 0-3.3L326.7 258c.3-.5.5-1.2.5-1.8z" class="st23"/><path id="path1872" d="M409.4 256.1c0 .6-.2 1.3-.5 1.8l-80.3 139.3c-.6 1-1.8 1.7-3 1.6h-37c-.6 0-1.2-.2-1.8-.5L368.9 256l20.6-35.6 19.5 33.8c.3.7.4 1.3.4 1.9" class="st21"/><path id="path1874" d="M368.9 256.1h-41.7c0-.6-.2-1.2-.5-1.8L267 151.2c-.6-1.1-.6-2.5 0-3.6l18.6-32.2q.45-.75 1.2-1.2z" class="st21"/><path id="path1876" d="m389.4 220.5-20.6 35.6-82-142c.6-.3 1.2-.5 1.8-.5h37.1c1.2 0 2.3.6 3 1.6z" class="st22"/></g></svg>
\ No newline at end of file
service/keycloak/provide.py created+61
......@@ -0,0 +1,61 @@
1#!/usr/bin/env python3
2import json
3import sys
4import urllib.parse
5
6from api import Keycloak
7
8
9data = json.load(sys.stdin)
10request = data["request"]
11if request["kind"] != "client":
12 raise ValueError("unsupported Keycloak input")
13stage_id = data.get("stageId")
14client_id = request["clientId"]
15if stage_id and client_id != stage_id:
16 client_id += "-" + stage_id[-8:]
17if data.get("operation") == "delete" and not stage_id:
18 raise ValueError("refusing to delete a production client")
19existing = data.get("existing") or {}
20if existing.get("clientId") and existing["clientId"] != client_id:
21 raise ValueError("client ID changed; migrate the existing client before deployment")
22keycloak = Keycloak(data["host"], data["providerSecrets"]["password"])
23desired = {
24 "protocol": "openid-connect",
25 "clientId": client_id,
26 "name": request["name"],
27 "publicClient": False,
28 "authorizationServicesEnabled": False,
29 "serviceAccountsEnabled": False,
30 "implicitFlowEnabled": False,
31 "directAccessGrantsEnabled": False,
32 "standardFlowEnabled": True,
33 "frontchannelLogout": True,
34 "redirectUris": request["redirectUris"],
35 "attributes": {"post.logout.redirect.uris": "*"},
36}
37path = "/admin/realms/master/clients"
38query = "?" + urllib.parse.urlencode({"clientId": client_id})
39found = keycloak.request(path + query)
40matches = [client for client in found if client["clientId"] == client_id]
41if len(matches) > 1:
42 raise ValueError(f"duplicate Keycloak client: {client_id}")
43if data.get("operation") == "delete":
44 if matches:
45 keycloak.request(f"{path}/{matches[0]['id']}", "DELETE")
46 sys.exit(0)
47if not matches:
48 keycloak.request(path, "POST", desired)
49 found = keycloak.request(path + query)
50 matches = [client for client in found if client["clientId"] == client_id]
51 if len(matches) != 1:
52 raise ValueError(f"Keycloak client was not created: {client_id}")
53uuid = matches[0]["id"]
54current = keycloak.request(f"{path}/{uuid}")
55attributes = {**(current.get("attributes") or {}), **desired["attributes"]}
56if any(current.get(key) != value for key, value in desired.items() if key != "attributes") or current.get("attributes", {}) != attributes:
57 keycloak.request(f"{path}/{uuid}", "PUT", {**current, **desired, "attributes": attributes})
58secret = keycloak.request(f"{path}/{uuid}/client-secret")["value"]
59if not secret:
60 raise ValueError(f"Keycloak client has no secret: {client_id}")
61print(json.dumps({"clientId": client_id, "clientSecret": secret, "issuerUrl": f"https://{data['host']}/realms/master"}))
service/keycloak/service.pkl created+70
......@@ -0,0 +1,70 @@
1extends "../../config/Service.pkl"
2
3import "../../config/Service.pkl" as service
4import "../postgres/service.pkl" as postgres
5
6class OpenIDClient extends service.Requirement {
7 alias: String = "oidc"
8 fixed provider = "keycloak"
9 fixed kind = "client"
10 clientId: String(isNotEmpty)
11 name: String
12 redirectUris: Listing<String> = new { "*" }
13}
14
15local database = new postgres.Database { name = "keycloak_next" }
16
17meta { name = "Keycloak" }
18traceServiceName = module.id
19dependsOn { "victoria-traces" }
20healthyDeadline = "20m"
21setup = "configure.py"
22provide = "provide.py"
23
24requirements { database }
25
26container {
27 image = "quay.io/keycloak/keycloak@sha256:82a77884f3af238beab1e7afd63b5f530e1b5c0590bd7aa60b40a40463e29b2c"
28 rootGroup = true
29 cpu = 500
30 memory = 1200
31 args { "start" }
32
33 http {
34 containerPort = 8080
35 subdomain = "keycloak"
36 checkPath = "/realms/master"
37 }
38
39 volumes {
40 ["/opt/keycloak/data"] {}
41 ["/opt/keycloak/themes/snow"] { config = "theme" }
42 }
43
44 env {
45 ["JAVA_TOOL_OPTIONS"] = read?("env:STUDIO_JAVA_OPTIONS") ?? ""
46 ["KC_DB"] = "postgres"
47 ["KC_DB_USERNAME"] = "${secret.database.username}"
48 ["KC_DB_PASSWORD"] = "${secret.database.password}"
49 ["KC_BOOTSTRAP_ADMIN_USERNAME"] = "admin"
50 ["KC_BOOTSTRAP_ADMIN_PASSWORD"] = "${secret.own.password}"
51 ["KC_HTTP_ENABLED"] = "true"
52 ["KC_PROXY_HEADERS"] = "xforwarded"
53 ["KC_HOSTNAME"] = "https://\(module.container.http.hostname)"
54 ["KC_TRACING_ENABLED"] = "true"
55 ["KC_TRACING_PROTOCOL"] = "http/protobuf"
56 ["KC_TELEMETRY_SERVICE_NAME"] = module.id
57 ["KC_TRACING_SAMPLER_TYPE"] = "parentbased_traceidratio"
58 ["KC_TRACING_SAMPLER_RATIO"] = "0.25"
59 }
60
61 envTemplate = """
62 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "\(database.provider)" }}KC_DB_URL=jdbc:postgresql://\(module.nomadHostPort)/{{ with nomadVar "nomad/jobs/\(module.id)/inputs/database" }}{{ .name }}{{ end }}{{ end }}
63 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "victoria-traces" }}KC_TRACING_ENDPOINT=http://\(module.nomadHostPort)/insert/opentelemetry{{ end }}
64 """
65}
66
67secrets {
68 ["password"] {}
69 ["snow_password"] {}
70}
service/keycloak/theme/login/resources/css/styles.css created+345
......@@ -0,0 +1,345 @@
1@import "https://file.paperclover.net/.static/font.css";
2
3/* reset */
4html,
5body {
6 height: 100%;
7}
8
9h1,
10h2,
11h3,
12h4,
13h5,
14h6 {
15 font-size: unset;
16 font-weight: unset;
17}
18
19:where(
20 html,
21 body,
22 p,
23 ol,
24 ul,
25 li,
26 dl,
27 dt,
28 dd,
29 blockquote,
30 figure,
31 fieldset,
32 legend,
33 textarea,
34 pre,
35 iframe,
36 hr,
37 h1,
38 h2,
39 h3,
40 h4,
41 h5,
42 h6
43) {
44 margin: 0;
45 padding: 0;
46}
47
48*, :after, :before {
49 box-sizing: border-box;
50 font: unset;
51}
52
53/* root */
54body {
55 color-scheme: light dark;
56 background-color: #f9feff;
57 background-image: url(../img/miku-light.png);
58 background-size: auto 100%;
59 background-position: right top;
60 background-repeat: no-repeat;
61 color: black;
62 --text: black;
63
64 --header: light-dark(#1a46cd, #938cff);
65 --primary: light-dark(#00238f, #938cff);
66}
67@media (prefers-color-scheme: dark) {
68 body {
69 background-image: url(../img/miku-dark.png);
70 background-color: #2f4b67;
71 color: white;
72 --text: white;
73 }
74}
75@media (max-width: 1313px) {
76 body {
77 background-position: right calc(-100px + 50%) top;
78 }
79}
80
81/* sidebar */
82.pf-v5-c-login__main {
83 max-width: 30rem;
84 padding: 2rem;
85 height: 100%;
86 display: flex;
87 flex-direction: column;
88 justify-content: center;
89 --bg: light-dark(rgba(30, 30, 30, 0.1), rgba(0, 0, 0, 0.3));
90 background-color: var(--bg);
91 border-style: solid;
92 border-right-width: 4px;
93 border-color: var(--bg);
94 backdrop-filter: blur(4px);
95 overflow-y: auto;
96}
97.kc-logo-text {
98 font-size: 3rem;
99 text-align: center;
100 color: var(--header);
101}
102#kc-page-title, #kc-info-wrapper {
103 text-align: center;
104 color: rgb(from var(--text) r g b / 0.8);
105}
106#kc-page-title {
107 margin-bottom: 1rem;
108}
109#kc-info-wrapper {
110 margin-top: 1rem;
111}
112#kc-form-options {
113 margin-bottom: 1rem;
114}
115a {
116 color: var(--header);
117 text-decoration: dotted underline;
118}
119a:hover {
120 text-decoration: underline;
121 background-color: rgb(from var(--header) r g b / 0.2);
122}
123
124/* branding */
125.kc-logo-text::before {
126 display: block;
127 content: " ";
128 width: 30%;
129 aspect-ratio: 1;
130 margin: auto;
131 background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9Ii0yIC0yIDI4IDI4IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNMTAgMjBMOC43NSAxNy41TDYgMThNMTAgNEw4Ljc1IDYuNUw2IDZNMTQgMjBMMTUuMjUgMTcuNUwxOCAxOE0xNCA0TDE1LjI1IDYuNUwxOCA2TTE3IDIxTDE0IDE1TTE0IDE1SDEwTTE0IDE1TDE1LjUgMTJNMTAgMTVMNyAyMU0xMCAxNUw4LjUgMTJNMTcgM0wxNCA5TTE0IDlMMTUuNSAxMk0xNCA5SDEwTTE1LjUgMTJIMjJNMiAxMkg4LjVNOC41IDEyTDEwIDlNMTAgOUw3IDNNMjAgMTBMMTguNSAxMkwyMCAxNE00IDEwTDUuNSAxMkw0IDE0IiBzdHJva2U9InVybCgjcGFpbnQwX2xpbmVhcl81NDhfMTkpIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8cGF0aCBkPSJNMTAgMjBMOC43NSAxNy41TDYgMThNMTAgNEw4Ljc1IDYuNUw2IDZNMTQgMjBMMTUuMjUgMTcuNUwxOCAxOE0xNCA0TDE1LjI1IDYuNUwxOCA2TTE3IDIxTDE0IDE1TTE0IDE1SDEwTTE0IDE1TDE1LjUgMTJNMTAgMTVMNyAyMU0xMCAxNUw4LjUgMTJNMTcgM0wxNCA5TTE0IDlMMTUuNSAxMk0xNCA5SDEwTTE1LjUgMTJIMjJNMiAxMkg4LjVNOC41IDEyTDEwIDlNMTAgOUw3IDNNMjAgMTBMMTguNSAxMkwyMCAxNE00IDEwTDUuNSAxMkw0IDE0IiBzdHJva2U9InVybCgjcGFpbnQxX2xpbmVhcl81NDhfMTkpIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzU0OF8xOSIgeDE9IjQiIHkxPSItMiIgeDI9IjE4LjUiIHkyPSIyNSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjMjIzRDk5Ii8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzE1NDM5MiIvPgo8L2xpbmVhckdyYWRpZW50Pgo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MV9saW5lYXJfNTQ4XzE5IiB4MT0iMTAiIHkxPSItMyIgeDI9IjE4IiB5Mj0iMjciIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KPHN0b3Agc3RvcC1jb2xvcj0iI0YyRTNGRiIvPgo8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGMkY4RkYiLz4KPC9saW5lYXJHcmFkaWVudD4KPC9kZWZzPgo8L3N2Zz4K);
132 background-size: cover;
133}
134#kc-page-title,
135#kc-info-wrapper,
136.checkbox,
137a,
138#kc-form-buttons,
139.pf-v5-c-helper-text__item-text,
140.pf-v5-c-alert__title,
141input[type="submit"],
142input[type="button"],
143button {
144 text-transform: lowercase;
145}
146
147/* text input */
148.pf-v5-c-form__group {
149 margin-bottom: 1rem;
150}
151.pf-v5-c-form__group input:not([type="checkbox"]) {
152 width: 100%;
153}
154.pf-v5-c-form__group > div:first-child {
155 display: flex;
156}
157.pf-v5-c-form__label {
158 margin-bottom: 0.25rem;
159 text-transform: lowercase;
160 user-select: none;
161 display: block;
162}
163.pf-v5-c-input-group {
164 display: flex;
165 border-radius: 8px;
166}
167.pf-v5-c-form-control {
168 background-color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15));
169 padding: 2px;
170 appearance: none;
171 border: 2px solid var(--text);
172 font-size: inherit;
173 color: var(--text);
174 text-indent: 8px;
175 height: 38px;
176 border-radius: 8px;
177 flex: 1;
178}
179.pf-v5-c-form-control:has(+ button) {
180 border-top-right-radius: 0;
181 border-bottom-right-radius: 0;
182}
183.pf-v5-c-form-control + button {
184 appearance: none;
185 border-top-right-radius: 8px;
186 border-bottom-right-radius: 8px;
187 width: 38px;
188 border: 2px solid var(--text);
189 border-left: none;
190 background-color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15));
191 transition: background-color 0.1s linear;
192}
193.pf-v5-c-form-control + button:hover {
194 background-color: light-dark(rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2));
195}
196.pf-v5-c-form__group:has(input:focus-visible)
197 > :is(input, .pf-v5-c-input-group) {
198 outline: 2px solid rgb(from var(--primary) r g b / 0.5);
199}
200.pf-v5-c-form__group:has(input:focus-visible) * {
201 border-color: var(--primary);
202 outline: none;
203}
204.pf-v5-c-form__group:has(input:focus-visible) .pf-v5-c-form__label {
205 color: var(--header);
206}
207.pf-v5-c-helper-text__item-text {
208 display: block;
209 margin-top: 0.5rem;
210}
211.pf-m-error {
212 color: light-dark(#c80000, #f56666);
213}
214
215/* checkbox */
216.checkbox input {
217 display: none;
218}
219.checkbox label {
220 display: flex;
221 align-items: center;
222 user-select: none;
223 cursor: pointer;
224}
225.checkbox label:before {
226 content: " ";
227 display: block;
228 width: 24px;
229 height: 24px;
230 margin-right: 0.5rem;
231 margin-left: -2px;
232 background-color: var(--text);
233
234 mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNxdWFyZS1pY29uIGx1Y2lkZS1zcXVhcmUiPjxyZWN0IHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgeD0iMyIgeT0iMyIgcng9IjIiLz48L3N2Zz4=);
235 mask-size: cover;
236}
237.checkbox label:has(:checked):before {
238 background-color: var(--primary);
239 mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNxdWFyZS1jaGVjay1iaWctaWNvbiBsdWNpZGUtc3F1YXJlLWNoZWNrLWJpZyI+PHBhdGggZD0iTTIxIDEwLjY1NlYxOWEyIDIgMCAwIDEtMiAySDVhMiAyIDAgMCAxLTItMlY1YTIgMiAwIDAgMSAyLTJoMTIuMzQ0Ii8+PHBhdGggZD0ibTkgMTEgMyAzTDIyIDQiLz48L3N2Zz4=);
240}
241.checkbox label:has(:checked) {
242 color: var(--primary);
243}
244
245/* buttons */
246.pf-v5-c-button.pf-m-primary {
247 background-color: var(--primary);
248 display: block;
249 border: none;
250 height: 30px;
251 border-radius: 8px;
252 color: white;
253}
254.pf-v5-c-button.pf-m-block {
255 display: block;
256 width: 100%;
257}
258
259/* alert */
260.pf-v5-c-alert {
261 border-radius: 8px;
262 padding: 8px;
263 align-items: center;
264 margin-bottom: 1rem;
265 text-align: center;
266 text-wrap: balance;
267}
268.alert-error {
269 background-color: light-dark(#ff010182, #f56666a1);
270}
271.alert-warning, .alert-info {
272 background-color: rgb(from var(--primary) r g b / 0.5);
273}
274
275/* icons */
276[data-password-toggle] {
277 display: grid;
278 align-items: center;
279 justify-content: center;
280}
281[data-password-toggle] i {
282 display: block;
283 width: 24px;
284 height: 24px;
285 background-color: var(--text);
286 mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWV5ZS1pY29uIGx1Y2lkZS1leWUiPjxwYXRoIGQ9Ik0yLjA2MiAxMi4zNDhhMSAxIDAgMCAxIDAtLjY5NiAxMC43NSAxMC43NSAwIDAgMSAxOS44NzYgMCAxIDEgMCAwIDEgMCAuNjk2IDEwLjc1IDEwLjc1IDAgMCAxLTE5Ljg3NiAwIi8+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIvPjwvc3ZnPg==);
287}
288input[type="text"] + [data-password-toggle] i {
289 mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWV5ZS1vZmYtaWNvbiBsdWNpZGUtZXllLW9mZiI+PHBhdGggZD0iTTEwLjczMyA1LjA3NmExMC43NDQgMTAuNzQ0IDAgMCAxIDExLjIwNSA2LjU3NSAxIDEgMCAwIDEgMCAuNjk2IDEwLjc0NyAxMC43NDcgMCAwIDEtMS40NDQgMi40OSIvPjxwYXRoIGQ9Ik0xNC4wODQgMTQuMTU4YTMgMyAwIDAgMS00LjI0Mi00LjI0MiIvPjxwYXRoIGQ9Ik0xNy40NzkgMTcuNDk5YTEwLjc1IDEwLjc1IDAgMCAxLTE1LjQxNy01LjE1MSAxIDEgMCAwIDEgMC0uNjk2IDEwLjc1IDEwLjc1IDAgMCAxIDQuNDQ2LTUuMTQzIi8+PHBhdGggZD0ibTIgMiAyMCAyMCIvPjwvc3ZnPg==);
290}
291
292@media (max-width: 799px) {
293 body {
294 background-position: left 65% bottom 60%;
295 background-size: auto 150%;
296 display: flex;
297 flex-direction: column;
298 align-items: center;
299 justify-content: flex-end;
300 }
301 body:before {
302 display: block;
303 content: "";
304 flex: 1.5;
305 }
306 .pf-v5-c-login__main {
307 flex: 1 1 40%;
308 max-width: 25rem;
309 min-width: 80%;
310 border-width: 4px;
311 border-bottom-width: 0;
312 border-top-left-radius: 32px;
313 border-top-right-radius: 32px;
314 corner-shape: superellipse(0);
315 justify-content: space-between;
316 --bg: light-dark(rgba(255, 255, 255, 0.7), rgba(0.2, 0, 0.2, 0.6));
317 /* the logo straddles the card's top edge; overflow-y: auto would clip
318 the half that sticks out above (#35) */
319 overflow-y: visible;
320 }
321 .pf-v5-c-login__main:after {
322 display: block;
323 content: "";
324 }
325 .kc-logo-text::before {
326 position: absolute;
327 left: 50%;
328 width: 100px;
329 transform: translate(-50%, calc(-50% - 35px));
330 }
331}
332
333#credit {
334 position: fixed;
335 bottom: 2px;
336 right: 2px;
337 font-size: 14px;
338}
339#credit a:not(:hover) {
340 opacity: 0.5;
341}
342
343.subtitle:has(.subtitle .required) {
344 display: none;
345}
service/keycloak/theme/login/resources/font/.gitignore created
service/keycloak/theme/login/resources/img/license.txt created+2
......@@ -0,0 +1,2 @@
1https://safebooru.org/index.php?page=post&s=view&id=4405346
2dark mode by paper clover
service/keycloak/theme/login/resources/img/miku-dark.png created
Binary files /dev/null and b/service/keycloak/theme/login/resources/img/miku-dark.png differ
service/keycloak/theme/login/resources/img/miku-light.png created
Binary files /dev/null and b/service/keycloak/theme/login/resources/img/miku-light.png differ
service/keycloak/theme/login/resources/js/stars.js created+105
......@@ -0,0 +1,105 @@
1const shader = `
2 // Heavily modified off of https://www.shadertoy.com/view/3sKGWw
3 // big thanks to these resources as well:
4 // https://youtu.be/3CycKKJiwis
5 // https://www.shadertoy.com/view/4djSRW
6 // https://www.shadertoy.com/view/3s3GDn
7
8 precision mediump float;
9 uniform vec2 u_resolution;
10 uniform float u_time;
11 uniform bool u_mask;
12
13 float random(vec2 p){
14 vec3 p3 = fract(vec3(p.xyx) * .1031);
15 p3 += dot(p3, p3.yzx + 33.33);
16 return fract((p3.x + p3.y) * p3.z);
17 }
18
19 vec2 random2(vec2 p){
20 vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973));
21 p3 += dot(p3, p3.yzx+33.33);
22 return fract((p3.xx+p3.yz)*p3.zy);
23 }
24
25 void main() {
26 float glow;
27 vec2 uv;
28 vec2 fl;
29 vec2 local_uv;
30 vec2 seed;
31 vec2 centre;
32 vec2 cell;
33 float t = u_time * 0.00015;
34 float blinkTime = u_time * 0.0068;
35 float rotationAngle = -t * 21.0;
36 mat2 rotation = mat2(cos(rotationAngle), -sin(rotationAngle),
37 sin(rotationAngle), cos(rotationAngle));
38 vec3 col = vec3(0);
39
40 centre = vec2(cos(t), sin(t)) * 5.0 + 0.5;
41
42 // Get uv from the fragment coordinates, rotation and depth
43 uv = (centre - (gl_FragCoord.xy - u_resolution / 2.0) / (1600.0 / 25.0)) * rotation;
44 fl = floor(uv);
45 // The local cell coordinates. uv-fl == frac(uv)
46 local_uv = uv - fl - 0.5;
47
48 // For a 3x3 group of cells around the fragment, find the distance from the points
49 // of each to the current fragment and draw an accumulative glow accordingly
50 // The local cell is (0, 0)
51 for(float j = -1.0; j <= 1.0; j++){
52 for(float k = -1.0; k <= 1.0; k++){
53 cell = vec2(j, k);
54 seed = 128.0 + (fl + cell);
55 glow =
56 ((0.6 + 0.5 * sin((128.0 * random(seed)) * blinkTime))) * 25.0
57 * pow(0.005 / length(local_uv - (cell + 0.9 * (random2(seed) - 0.5))), 2.0);
58 col += vec3(0.2 * glow) + 0.1 * glow;
59 }
60 }
61
62 col = 1.0 - exp(-col);
63 col = pow(col, vec3(0.45));
64
65 if (u_mask) {
66 float mask = smoothstep(100.0, 300.0, length(vec2(gl_FragCoord.x - u_resolution.x/2.0, gl_FragCoord.y - 200.0)));
67 col *= mask;
68 }
69
70 gl_FragColor = vec4(col * (u_mask ? 0.6 : 0.25), 0);
71 }
72`;
73
74function createShader(gl, type, source) {
75 const shader = gl.createShader(type);
76 gl.shaderSource(shader, source);
77 gl.compileShader(shader);
78 const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
79 if (success) {
80 return shader;
81 }
82
83 // eslint-disable-next-line no-console
84 console.error(gl.getShaderInfoLog(shader));
85 gl.deleteShader(shader);
86 throw new Error("Failed to compile shader");
87}
88
89function createProgram(gl, vertexShader, fragmentShader) {
90 const program = gl.createProgram();
91
92 gl.attachShader(program, vertexShader);
93 gl.attachShader(program, fragmentShader);
94 gl.linkProgram(program);
95
96 const success = gl.getProgramParameter(program, gl.LINK_STATUS);
97 if (success) {
98 return program;
99 }
100
101 // eslint-disable-next-line no-console
102 console.error(gl.getProgramInfoLog(program));
103 gl.deleteProgram(program);
104 throw new Error("Failed to link program");
105}
service/keycloak/theme/login/resources/js/webauthnRegister.js created+159
......@@ -0,0 +1,159 @@
1import { base64url } from "rfc4648";
2
3export async function registerByWebAuthn(input) {
4 // Check if WebAuthn is supported by this browser
5 if (!window.PublicKeyCredential) {
6 returnFailure(input.errmsg);
7 return;
8 }
9
10 const publicKey = {
11 challenge: base64url.parse(input.challenge, { loose: true }),
12 rp: { id: input.rpId, name: input.rpEntityName },
13 user: {
14 id: base64url.parse(input.userid, { loose: true }),
15 name: input.username,
16 displayName: input.username,
17 },
18 pubKeyCredParams: getPubKeyCredParams(input.signatureAlgorithms),
19 };
20
21 if (input.attestationConveyancePreference !== "not specified") {
22 publicKey.attestation = input.attestationConveyancePreference;
23 }
24
25 const authenticatorSelection = {};
26 let isAuthenticatorSelectionSpecified = false;
27
28 if (input.authenticatorAttachment !== "not specified") {
29 authenticatorSelection.authenticatorAttachment =
30 input.authenticatorAttachment;
31 isAuthenticatorSelectionSpecified = true;
32 }
33
34 if (input.requireResidentKey !== "not specified") {
35 if (input.requireResidentKey === "Yes") {
36 authenticatorSelection.requireResidentKey = true;
37 } else {
38 authenticatorSelection.requireResidentKey = false;
39 }
40 isAuthenticatorSelectionSpecified = true;
41 }
42
43 if (input.userVerificationRequirement !== "not specified") {
44 authenticatorSelection.userVerification = input.userVerificationRequirement;
45 isAuthenticatorSelectionSpecified = true;
46 }
47
48 if (isAuthenticatorSelectionSpecified) {
49 publicKey.authenticatorSelection = authenticatorSelection;
50 }
51
52 if (input.createTimeout !== 0) {
53 publicKey.timeout = input.createTimeout * 1000;
54 }
55
56 const excludeCredentials = getExcludeCredentials(input.excludeCredentialIds);
57 if (excludeCredentials.length > 0) {
58 publicKey.excludeCredentials = excludeCredentials;
59 }
60
61 try {
62 const result = await doRegister(publicKey);
63 returnSuccess(result, input.initLabel, input.initLabelPrompt);
64 } catch (error) {
65 returnFailure(error);
66 }
67}
68
69function doRegister(publicKey) {
70 return navigator.credentials.create({ publicKey });
71}
72
73function getPubKeyCredParams(signatureAlgorithmsList) {
74 const pubKeyCredParams = [];
75 if (signatureAlgorithmsList.length === 0) {
76 pubKeyCredParams.push({ type: "public-key", alg: -7 });
77 return pubKeyCredParams;
78 }
79
80 for (const entry of signatureAlgorithmsList) {
81 pubKeyCredParams.push({
82 type: "public-key",
83 alg: entry,
84 });
85 }
86
87 return pubKeyCredParams;
88}
89
90function getExcludeCredentials(excludeCredentialIds) {
91 const excludeCredentials = [];
92 if (excludeCredentialIds === "") {
93 return excludeCredentials;
94 }
95
96 for (const entry of excludeCredentialIds.split(",")) {
97 excludeCredentials.push({
98 type: "public-key",
99 id: base64url.parse(entry, { loose: true }),
100 });
101 }
102
103 return excludeCredentials;
104}
105
106function getTransportsAsString(transportsList) {
107 if (!Array.isArray(transportsList)) {
108 return "";
109 }
110
111 return transportsList.join();
112}
113
114function returnSuccess(result, initLabel, initLabelPrompt) {
115 document.getElementById("clientDataJSON").value = base64url.stringify(
116 new Uint8Array(result.response.clientDataJSON),
117 { pad: false },
118 );
119 document.getElementById("attestationObject").value = base64url.stringify(
120 new Uint8Array(result.response.attestationObject),
121 { pad: false },
122 );
123 document.getElementById("publicKeyCredentialId").value = base64url.stringify(
124 new Uint8Array(result.rawId),
125 { pad: false },
126 );
127
128 if (typeof result.response.getTransports === "function") {
129 const transports = result.response.getTransports();
130 if (transports) {
131 document.getElementById("transports").value = getTransportsAsString(
132 transports,
133 );
134 }
135 } else {
136 console.log(
137 "Your browser is not able to recognize supported transport media for the authenticator.",
138 );
139 }
140
141 // let labelResult = window.prompt(initLabelPrompt, initLabel);
142 // if (labelResult === null) {
143 // labelResult = initLabel;
144 // }
145 document.getElementById("authenticatorLabel").value = "Passkey " +
146 new Date().toString();
147
148 document.getElementById("register").requestSubmit();
149}
150
151function returnFailure(err) {
152 if (err.name === "NotAllowedError") {
153 document.getElementById("error").value =
154 "the operation was cancelled / browser does not support passkey";
155 } else {
156 document.getElementById("error").value = err;
157 }
158 document.getElementById("register").requestSubmit();
159}
service/keycloak/theme/login/template.ftl created+209
......@@ -0,0 +1,209 @@
1<#import "footer.ftl" as loginFooter>
2<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false>
3<!DOCTYPE html>
4<html class="${properties.kcHtmlClass!}" lang="${lang}"<#if realm.internationalizationEnabled> dir="${(locale.rtl)?then('rtl','ltr')}"</#if>>
5
6<head>
7 <meta charset="utf-8">
8 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
9 <meta name="viewport" content="width=device-width, initial-scale=1.0">
10
11 <#if properties.meta?has_content>
12 <#list properties.meta?split(' ') as meta>
13 <meta name="${meta?split('==')[0]}" content="${meta?split('==')[1]}"/>
14 </#list>
15 </#if>
16 <title>sso (snow sign on)</title>
17 <link rel="icon" href="${url.resourcesPath}/img/favicon.ico" />
18 <#if properties.stylesCommon?has_content>
19 <#list properties.stylesCommon?split(' ') as style>
20 <link href="${url.resourcesCommonPath}/${style}" rel="stylesheet" />
21 </#list>
22 </#if>
23 <#if properties.styles?has_content>
24 <#list properties.styles?split(' ') as style>
25 <link href="${url.resourcesPath}/${style}" rel="stylesheet" />
26 </#list>
27 </#if>
28 <#if properties.scripts?has_content>
29 <#list properties.scripts?split(' ') as script>
30 <script src="${url.resourcesPath}/${script}" type="text/javascript"></script>
31 </#list>
32 </#if>
33 <script type="importmap">
34 {
35 "imports": {
36 "rfc4648": "${url.resourcesCommonPath}/vendor/rfc4648/rfc4648.js"
37 }
38 }
39 </script>
40 <script src="${url.resourcesPath}/js/menu-button-links.js" type="module"></script>
41 <#if scripts??>
42 <#list scripts as script>
43 <script src="${script}" type="text/javascript"></script>
44 </#list>
45 </#if>
46 <script type="module">
47 import { startSessionPolling } from "${url.resourcesPath}/js/authChecker.js";
48
49 startSessionPolling(
50 "${url.ssoLoginInOtherTabsUrl?no_esc}"
51 );
52 </script>
53 <script type="module">
54 document.addEventListener("click", (event) => {
55 const link = event.target.closest("a[data-once-link]");
56
57 if (!link) {
58 return;
59 }
60
61 if (link.getAttribute("aria-disabled") === "true") {
62 event.preventDefault();
63 return;
64 }
65
66 const { disabledClass } = link.dataset;
67
68 if (disabledClass) {
69 link.classList.add(...disabledClass.trim().split(/\s+/));
70 }
71
72 link.setAttribute("role", "link");
73 link.setAttribute("aria-disabled", "true");
74 });
75 </script>
76 <#if authenticationSession??>
77 <script type="module">
78 import { checkAuthSession } from "${url.resourcesPath}/js/authChecker.js";
79
80 checkAuthSession(
81 "${authenticationSession.authSessionIdHash}"
82 );
83 </script>
84 </#if>
85</head>
86
87<body class="${properties.kcBodyClass!}" data-page-id="login-${pageId}">
88<div class="${properties.kcLoginClass!}">
89 <div id="kc-header" class="${properties.kcHeaderClass!}">
90 <div id="kc-header-wrapper"
91 class="${properties.kcHeaderWrapperClass!}">${kcSanitize(msg("loginTitleHtml",(realm.displayNameHtml!'')))?no_esc}</div>
92 </div>
93 <div class="${properties.kcFormCardClass!}">
94 <header class="${properties.kcFormHeaderClass!}">
95 <#if realm.internationalizationEnabled && locale.supported?size gt 1>
96 <div class="${properties.kcLocaleMainClass!}" id="kc-locale">
97 <div id="kc-locale-wrapper" class="${properties.kcLocaleWrapperClass!}">
98 <div id="kc-locale-dropdown" class="menu-button-links ${properties.kcLocaleDropDownClass!}">
99 <button tabindex="1" id="kc-current-locale-link" aria-label="${msg("languages")}" aria-haspopup="true" aria-expanded="false" aria-controls="language-switch1">${locale.current}</button>
100 <ul role="menu" tabindex="-1" aria-labelledby="kc-current-locale-link" aria-activedescendant="" id="language-switch1" class="${properties.kcLocaleListClass!}">
101 <#assign i = 1>
102 <#list locale.supported as l>
103 <li class="${properties.kcLocaleListItemClass!}" role="none">
104 <a role="menuitem" id="language-${i}" class="${properties.kcLocaleItemClass!}" href="${l.url}">${l.label}</a>
105 </li>
106 <#assign i++>
107 </#list>
108 </ul>
109 </div>
110 </div>
111 </div>
112 </#if>
113 <#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
114 <#if displayRequiredFields>
115 <div class="${properties.kcContentWrapperClass!}">
116 <div class="${properties.kcLabelWrapperClass!} subtitle">
117 <span class="subtitle"><span class="required">*</span> ${msg("requiredFields")}</span>
118 </div>
119 <div class="col-md-10">
120 <h1 id="kc-page-title"><#nested "header"></h1>
121 </div>
122 </div>
123 <#else>
124 <h1 id="kc-page-title"><#nested "header"></h1>
125 </#if>
126 <#else>
127 <#if displayRequiredFields>
128 <div class="${properties.kcContentWrapperClass!}">
129 <div class="${properties.kcLabelWrapperClass!} subtitle">
130 <span class="subtitle"><span class="required">*</span> ${msg("requiredFields")}</span>
131 </div>
132 <div class="col-md-10">
133 <#nested "show-username">
134 <div id="kc-username" class="${properties.kcFormGroupClass!}">
135 <label id="kc-attempted-username">${auth.attemptedUsername}</label>
136 <a id="reset-login" href="${url.loginRestartFlowUrl}" aria-label="${msg("restartLoginTooltip")}">
137 <div class="kc-login-tooltip">
138 <i class="${properties.kcResetFlowIcon!}"></i>
139 <span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
140 </div>
141 </a>
142 </div>
143 </div>
144 </div>
145 <#else>
146 <#nested "show-username">
147 <div id="kc-username" class="${properties.kcFormGroupClass!}">
148 <label id="kc-attempted-username">${auth.attemptedUsername}</label>
149 <a id="reset-login" href="${url.loginRestartFlowUrl}" aria-label="${msg("restartLoginTooltip")}">
150 <div class="kc-login-tooltip">
151 <i class="${properties.kcResetFlowIcon!}"></i>
152 <span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
153 </div>
154 </a>
155 </div>
156 </#if>
157 </#if>
158 </header>
159 <div id="kc-content">
160 <div id="kc-content-wrapper">
161
162 <#-- App-initiated actions should not see warning messages about the need to complete the action -->
163 <#-- during login. -->
164 <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>
165 <div class="alert-${message.type} ${properties.kcAlertClass!} pf-m-<#if message.type = 'error'>danger<#else>${message.type}</#if>">
166 <div class="pf-c-alert__icon">
167 <#if message.type = 'success'><span class="${properties.kcFeedbackSuccessIcon!}"></span></#if>
168 <#if message.type = 'warning'><span class="${properties.kcFeedbackWarningIcon!}"></span></#if>
169 <#if message.type = 'error'><span class="${properties.kcFeedbackErrorIcon!}"></span></#if>
170 <#if message.type = 'info'><span class="${properties.kcFeedbackInfoIcon!}"></span></#if>
171 </div>
172 <span class="${properties.kcAlertTitleClass!}">${kcSanitize(message.summary)?no_esc}</span>
173 </div>
174 </#if>
175
176 <#nested "form">
177
178 <#if auth?has_content && auth.showTryAnotherWayLink()>
179 <form id="kc-select-try-another-way-form" action="${url.loginAction}" method="post">
180 <div class="${properties.kcFormGroupClass!}">
181 <input type="hidden" name="tryAnotherWay" value="on"/>
182 <a href="#" id="try-another-way"
183 onclick="document.forms['kc-select-try-another-way-form'].requestSubmit();return false;">${msg("doTryAnotherWay")}</a>
184 </div>
185 </form>
186 </#if>
187
188 <#nested "socialProviders">
189
190 <#if displayInfo>
191 <div id="kc-info" class="${properties.kcSignUpClass!}">
192 <div id="kc-info-wrapper" class="${properties.kcInfoAreaWrapperClass!}">
193 <#nested "info">
194 </div>
195 </div>
196 </#if>
197 </div>
198 </div>
199
200
201 <@loginFooter.content/>
202 </div>
203 </div>
204 <div id='credit'>
205 <a rel="noopener noreferrer" target="_blank" href="https://www.pixiv.net/en/artworks/109102745">art by 紺屋</a>
206 </div>
207</body>
208</html>
209</#macro>
service/keycloak/theme/login/theme.properties created+128
......@@ -0,0 +1,128 @@
1parent=base
2import=common/keycloak
3
4styles=css/styles.css
5stylesCommon=
6
7darkMode=false
8
9kcFormGroupClass=pf-v5-c-form__group
10kcFormGroupLabelClass=pf-v5-c-form__group-label pf-v5-u-pb-xs
11kcFormLabelClass=pf-v5-c-form__label
12kcFormLabelTextClass=pf-v5-c-form__label-text
13
14kcLabelClass=pf-v5-c-form__label
15kcInputClass=pf-v5-c-form-control
16kcInputGroup=pf-v5-c-input-group
17kcFormHelperTextClass=pf-v5-c-form__helper-text
18kcInputHelperTextClass=pf-v5-c-helper-text pf-v5-u-display-flex pf-v5-u-justify-content-space-between
19kcInputHelperTextItemClass=pf-v5-c-helper-text__item
20kcInputHelperTextItemTextClass=pf-v5-c-helper-text__item-text
21kcInputGroupItemClass=pf-v5-c-input-group__item
22kcFill=pf-m-fill
23kcError=pf-m-error
24kcLoginFooterBand=pf-v5-c-login__main-footer-band
25kcLoginFooterBandItem=pf-v5-c-login__main-footer-band-item
26
27kcCheckboxClass=pf-v5-c-check
28kcCheckboxInputClass=pf-v5-c-check__input
29kcCheckboxLabelClass=pf-v5-c-check__label
30kcCheckboxLabelRequiredClass=pf-v5-c-check__label-required
31
32kcInputRequiredClass=pf-v5-c-form__label-required
33kcInputErrorMessageClass=pf-v5-c-helper-text__item-text pf-m-error kc-feedback-text
34kcFormControlUtilClass=pf-v5-c-form-control__utilities
35kcInputErrorIconStatusClass=pf-v5-c-form-control__icon pf-m-status
36kcInputErrorIconClass=fas fa-exclamation-circle
37kcAlertClass=pf-v5-c-alert pf-m-inline pf-v5-u-mb-md
38kcAlertIconClass=pf-v5-c-alert__icon
39kcAlertTitleClass=pf-v5-c-alert__title
40kcAlertDescriptionClass=pf-v5-c-alert__description
41kcFormPasswordVisibilityButtonClass=pf-v5-c-button pf-m-control
42kcFormControlToggleIcon=pf-v5-c-form-control__toggle-icon
43kcFormActionGroupClass=pf-v5-c-form__actions pf-v5-u-pt-xs
44kcFormReadOnlyClass=pf-m-readonly
45
46kcPanelClass=pf-v5-c-panel pf-m-raised
47kcPanelMainClass=pf-v5-c-panel__main
48kcPanelMainBodyClass=pf-v5-c-panel__main-body
49kcListClass=pf-v5-c-list
50
51kcButtonClass=pf-v5-c-button
52kcButtonPrimaryClass=pf-v5-c-button pf-m-primary
53kcButtonSecondaryClass=pf-v5-c-button pf-m-secondary
54kcButtonBlockClass=pf-m-block
55kcButtonLinkClass=pf-v5-c-button pf-m-link
56kcCommonLogoIdP=pf-v5-c-login__main-footer-links-item
57kcFormSocialAccountListClass=pf-v5-c-login__main-body pf-v5-u-pl-0 pf-v5-u-pr-0
58kcFormSocialAccountListItemClass=pf-v5-u-pb-sm
59kcFormSocialAccountNameClass=pf-v5-u-m-auto
60kcFormSocialAccountListButtonClass=pf-v5-c-button pf-m-secondary pf-m-block pf-v5-u-display-flex pf-v5-u-align-items-center pf-v5-u-justify-content-space-between
61kcFormSocialAccountListButtonDisabledClass=pf-m-aria-disabled
62kcFormSocialAccountListGridClass=pf-v5-l-grid pf-m-gutter pf-m-all-6-col-on-xl pf-m-all-6-col-on-sm
63kcFormSocialAccountGridItem=pf-v5-l-grid__item
64
65kcLoginClass=pf-v5-c-login__main
66kcFormClass=pf-v5-c-form pf-v5-u-w-100
67kcFormCardClass=card-pf
68
69kcResetFlowIcon=pf-icon fas fa-share-square
70
71kcSelectAuthListClass=pf-v5-c-data-list select-auth-container
72kcSelectAuthListItemWrapperClass=pf-v5-c-data-list__item pf-m-clickable
73kcSelectAuthListItemClass=pf-v5-c-data-list__item-row select-auth-box-parent
74kcSelectAuthListItemHeadingClass=pf-v5-u-font-family-heading select-auth-box-headline
75kcSelectAuthListItemBodyClass=pf-v5-c-data-list__cell pf-m-no-fill pf-v5-u-pt-md pf-v5-u-pb-md
76kcSelectAuthListItemIconClass=pf-v5-c-data-list__cell pf-m-icon pf-v5-u-display-flex pf-v5-u-pt-0 pf-v5-u-align-items-center
77kcSelectAuthListItemFillClass=pf-v5-c-data-list__item-action
78kcSelectAuthListItemDescriptionClass=pf-v5-c-data-list__cell pf-m-no-fill select-auth-box-desc
79
80kcRecoveryCodesWarning=pf-v5-c-alert pf-m-warning pf-m-inline pf-v5-u-mb-md kc-recovery-codes-warning
81kcLogin=pf-v5-c-login
82kcLoginContainer=pf-v5-c-login__container
83kcLoginMain=pf-v5-c-login__main
84kcLoginMainHeader=pf-v5-c-login__main-header
85kcLoginMainFooter=pf-v5-c-login__main-footer
86kcLoginMainFooterBand=pf-v5-c-login__main-footer-band
87kcLoginMainFooterBandItem=pf-v5-c-login__main-footer-band-item
88kcLoginMainFooterHelperText=pf-v5-u-font-size-sm pf-v5-u-color-200
89kcLoginMainTitle=pf-v5-c-title pf-m-3xl
90kcLoginMainHeaderUtilities=pf-v5-c-login__main-header-utilities
91kcLoginMainBody=pf-v5-c-login__main-body
92
93kcContentWrapperClass=pf-v5-u-mb-md-on-md
94kcWebAuthnDefaultIcon=pf-v5-c-icon pf-m-lg
95kcMarginTopClass=pf-v5-u-mt-md-on-md
96
97kcLoginOTPListClass=pf-v5-c-tile
98kcLoginOTPListItemHeaderClass=pf-v5-c-tile__header pf-m-stacked
99kcLoginOTPListItemIconBodyClass=pf-v5-c-tile__icon
100kcLoginOTPListItemIconClass=fa fa-mobile
101kcLoginOTPListItemTitleClass=pf-v5-c-tile__title
102kcLoginOTPListSelectedClass=pf-m-selected
103
104kcCopyIconClass=fas fa-copy
105kcCheckIconClass=fas fa-check
106kcAngleRightIconClass=fas fa-angle-right
107kcAngleDownIconClass=fas fa-angle-down
108kcExpandedClass=pf-m-expanded
109
110kcCodeClipboardCopyClass=pf-v5-c-clipboard-copy
111kcCodeClipboardCopyGroupClass=pf-v5-c-clipboard-copy__group
112kcCodeClipboardCopyContentClass=pf-v5-c-clipboard-copy__expandable-content
113
114kcDarkModeClass=pf-v5-theme-dark
115
116kcHtmlClass=login-pf
117kcLogoIdP-facebook=
118kcLogoIdP-google=
119kcLogoIdP-github=
120kcLogoIdP-linkedin=
121kcLogoIdP-instagram=
122kcLogoIdP-microsoft=
123kcLogoIdP-bitbucket=
124kcLogoIdP-gitlab=
125kcLogoIdP-paypal=
126kcLogoIdP-stackoverflow=
127kcLogoIdP-twitter=
128kcLogoIdP-openshift-v4=
service/keycloak/theme/login/webauthn-error.ftl created+36
......@@ -0,0 +1,36 @@
1<#import "template.ftl" as layout>
2<@layout.registrationLayout displayMessage=true; section>
3 <#if section = "header">
4 ${kcSanitize(msg("webauthn-error-title"))?no_esc}
5 <#elseif section = "form">
6
7 <script type="text/javascript">
8 refreshPage = () => {
9 document.getElementById('isSetRetry').value = 'retry';
10 document.getElementById('executionValue').value = '${execution}';
11 document.getElementById('kc-error-credential-form').requestSubmit();
12 }
13 </script>
14
15 <form id="kc-error-credential-form" class="${properties.kcFormClass!}" action="${url.loginAction}"
16 method="post">
17 <input type="hidden" id="executionValue" name="authenticationExecution"/>
18 <input type="hidden" id="isSetRetry" name="isSetRetry"/>
19 </form>
20
21 <input tabindex="4" onclick="refreshPage()" type="button"
22 class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
23 name="try-again" id="kc-try-again" value="${kcSanitize(msg("doTryAgain"))?no_esc}"
24 />
25
26 <#if isAppInitiatedAction??>
27 <form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-webauthn-settings-form" method="post">
28 <button type="submit"
29 class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
30 id="cancelWebAuthnAIA" name="cancel-aia" value="true">${msg("doCancel")}
31 </button>
32 </form>
33 </#if>
34
35 </#if>
36</@layout.registrationLayout>
\ No newline at end of file
service/keycloak/theme/login/webauthn-register.ftl created+72
......@@ -0,0 +1,72 @@
1<#import "template.ftl" as layout>
2<#import "password-commons.ftl" as passwordCommons>
3
4<@layout.registrationLayout; section>
5 <#if section = "title">
6 title
7 <#elseif section = "header">
8 <div class="alert-info pf-v5-c-alert pf-m-inline pf-v5-u-mb-md pf-m-danger">
9 <div class="pf-c-alert__icon">
10 <span class=""></span>
11 </div>
12 <span class="pf-v5-c-alert__title">
13 you will be prompted to create a passkey. please contact clover if
14 this doesn't work in your browser.
15 </span>
16 </div>
17 <#elseif section = "form">
18
19 <form id="register" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
20 <div class="${properties.kcFormGroupClass!}">
21 <input type="hidden" id="clientDataJSON" name="clientDataJSON"/>
22 <input type="hidden" id="attestationObject" name="attestationObject"/>
23 <input type="hidden" id="publicKeyCredentialId" name="publicKeyCredentialId"/>
24 <input type="hidden" id="authenticatorLabel" name="authenticatorLabel"/>
25 <input type="hidden" id="transports" name="transports"/>
26 <input type="hidden" id="error" name="error"/>
27 </div>
28 </form>
29
30 <script type="module">
31 import { registerByWebAuthn } from "${url.resourcesPath}/js/webauthnRegister.js";
32 function init() {
33 const input = {
34 challenge : '${challenge}',
35 userid : '${userid}',
36 username : '${username}',
37 signatureAlgorithms : [<#list signatureAlgorithms as sigAlg>${sigAlg?c},</#list>],
38 rpEntityName : '${rpEntityName}',
39 rpId : '${rpId}',
40 attestationConveyancePreference : '${attestationConveyancePreference}',
41 authenticatorAttachment : '${authenticatorAttachment}',
42 requireResidentKey : '${requireResidentKey}',
43 userVerificationRequirement : '${userVerificationRequirement}',
44 createTimeout : ${createTimeout?c},
45 excludeCredentialIds : '${excludeCredentialIds}',
46 initLabel : "${msg("webauthn-registration-init-label")?no_esc}",
47 initLabelPrompt : "${msg("webauthn-registration-init-label-prompt")?no_esc}",
48 errmsg : "${msg("webauthn-unsupported-browser-text")?no_esc}"
49 };
50 registerByWebAuthn(input);
51 }
52 init();
53 </script>
54
55<!--
56 <input type="submit"
57 class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
58 id="registerWebAuthn" value="${msg("doRegisterSecurityKey")}"/>
59-->
60
61 <#if !isSetRetry?has_content && isAppInitiatedAction?has_content>
62 <form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-webauthn-settings-form"
63 method="post">
64 <button type="submit"
65 class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
66 id="cancelWebAuthnAIA" name="cancel-aia" value="true">${msg("doCancel")}
67 </button>
68 </form>
69 </#if>
70
71 </#if>
72</@layout.registrationLayout>
service/navidrome/icon.svg created+29
......@@ -0,0 +1,29 @@
1<svg xmlns="http://www.w3.org/2000/svg"
2 xml:space="preserve"
3 viewBox="0 0 256 256" width="256" height="256">
4
5 <style>
6 .st1,.st2{fill:#000;stroke:#fff;stroke-width:9.9999;stroke-miterlimit:15.118}
7 .st2{fill:none;stroke-linecap:round}
8 </style>
9
10 <g id="spinGroup">
11
12 <circle cx="128" cy="128" r="122.8"
13 style="fill:#0084ff;stroke:#fff;stroke-width:10.3641;stroke-miterlimit:15.118"/>
14
15 <circle cx="128" cy="128" r="43.7" fill="#fff" class="st1"/>
16
17 <path fill="#fff"
18 d="M201.8 128c0 40.7-33 73.8-73.8 73.8M54.2 128c0-40.7 33-73.8 73.8-73.8"
19 class="st2"/>
20
21 <path fill="#fff"
22 d="M224.9 128c0 1.2 0 2.5-.1 3.7m-2.2 17.6c-9.7 43.3-48.4 75.6-94.6 75.6
23 M31.1 128c0-1.1 0-2.2.1-3.3m1.8-16c9-44.2 48.1-77.6 95-77.6"
24 class="st2"/>
25
26 <circle cx="128" cy="128" r="9" fill="#fff" class="st1"/>
27 </g>
28
29</svg>
\ No newline at end of file
service/navidrome/service.pkl created+33
......@@ -0,0 +1,33 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5meta { name = "Navidrome" }
6
7container {
8 image = "docker.io/deluan/navidrome@sha256:59ce76e6e06193fbfbb1736a93b8468227e1ea663fc7b25b1da13fbb97f5197b"
9 cpu = 200
10 memory = 512
11
12 http {
13 containerPort = 4533
14 subdomain = "music"
15 authRole = "media"
16 userHeader = "Remote-User"
17 checkPath = "/ping"
18 }
19
20 volumes {
21 ["/data"] {}
22 ["/music"] { src = "\(site.mediaRoot)/music"; readOnly = true }
23 }
24
25 env {
26 ["ND_SCANNER_GROUPALBUMRELEASES"] = "1"
27 ["ND_BASEURL"] = "https://\(module.container.http.hostname)/"
28 ["ND_PID_ALBUM"] = "folder"
29 ["ND_EXTAUTH_USERHEADER"] = "Remote-User"
30 ["ND_EXTAUTH_TRUSTEDSOURCES"] = "10.88.0.1/32"
31 ["ND_PORT"] = "4533"
32 }
33}
service/openspeedtest/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#e67e48"/><g fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 47a23 23 0 1 1 40 0"/><path d="m32 39 12-15"/><circle cx="32" cy="39" r="3"/><path d="M17 47h30"/></g></svg>
service/openspeedtest/service.pkl created+18
......@@ -0,0 +1,18 @@
1amends "../../config/Service.pkl"
2
3meta { name = "Open Speed Test" }
4rollout = "overlapped"
5healthRestartGrace = "30s"
6
7container {
8 image = "docker.io/openspeedtest/latest@sha256:1745e913f596fe98882b286a67751efdae74774e9caa742a4934bb056e8748d2"
9 // The image's entrypoint edits nginx configuration before starting it.
10 imageUser = true
11 cpu = 100
12 memory = 256
13
14 http {
15 containerPort = 3000
16 subdomain = "speedtest"
17 }
18}
service/pds/icon.svg created+1
......@@ -0,0 +1 @@
1<svg fill="#1185fe" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bluesky</title><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>
service/pds/service.pkl created+83
......@@ -0,0 +1,83 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5local isolated = site.preview || site.domain.endsWith(".test")
6
7meta { name = "ATProto PDS" }
8traceServiceName = module.id
9metricsPushed = true
10rollout = "simple"
11stageIsolation = "fresh"
12dependsOn { "victoria-metrics"; "victoria-traces" }
13
14secrets = if (isolated) new {
15 ["jwt_secret"] {}
16 ["admin_password"] {}
17 ["plc_rotation_key"] {}
18} else new {}
19
20requiredSecrets = if (isolated) new {} else new {
21 "jwt_secret"
22 "admin_password"
23 "plc_rotation_key"
24 "mailer_address"
25 "mailer_username"
26 "mailer_password"
27}
28
29container {
30 image = "ghcr.io/bluesky-social/pds@sha256:d155af1c906d7848e7dea9d59a8a7def065a04b77aa98ae56ea05a8d4eadb63a"
31 entrypoint = "/bin/sh"
32 args { "/studio/start.sh" }
33 cpu = 250
34 memory = 512
35
36 http {
37 containerPort = 3000
38 subdomain = "at"
39 checkPath = "/xrpc/_health"
40 }
41
42 volumes {
43 ["/pds"] {}
44 ["/studio/start.sh"] { config = "start.sh" }
45 }
46
47 env {
48 ["HOME_DOMAIN"] = site.domain
49 ["PDS_HOSTNAME"] = module.container.http.hostname
50 ["PDS_JWT_SECRET"] = "${secret.own.jwt_secret}"
51 ["PDS_ADMIN_PASSWORD"] = "${secret.own.admin_password}"
52 ["PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX"] = "${secret.own.plc_rotation_key}"
53 ["PDS_DATA_DIRECTORY"] = "/pds"
54 ["PDS_BLOBSTORE_DISK_LOCATION"] = "/pds/blocks"
55 ["PDS_DID_PLC_URL"] = if (isolated) "http://127.0.0.1:1" else "https://plc.directory"
56 ["PDS_BSKY_APP_VIEW_URL"] = "https://api.bsky.app"
57 ["PDS_BSKY_APP_VIEW_DID"] = "did:web:api.bsky.app"
58 ["PDS_REPORT_SERVICE_URL"] = "https://mod.bsky.app"
59 ["PDS_REPORT_SERVICE_DID"] = "did:plc:ar7c4by46qjdydhdevvrndac"
60 ["PDS_CRAWLERS"] = if (isolated) "" else "https://bsky.network"
61 ["PDS_INVITE_REQUIRED"] = if (isolated) "true" else "false"
62 ["PDS_RATE_LIMITS_ENABLED"] = "true"
63 ["PDS_CONTACT_EMAIL_ADDRESS"] = site.ownerEmail
64 ["LOG_ENABLED"] = "true"
65 ["NODE_OPTIONS"] = "--import=@atproto/pds/telemetry"
66 ["OTEL_SERVICE_NAME"] = module.id
67 ["OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"] = "http/protobuf"
68 ["OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"] = "http/protobuf"
69 ["OTEL_METRIC_EXPORT_INTERVAL"] = "15000"
70 ["OTEL_SEMCONV_STABILITY_OPT_IN"] = "http"
71 ["OTEL_TRACES_SAMPLER"] = "parentbased_traceidratio"
72 ["OTEL_TRACES_SAMPLER_ARG"] = "0.25"
73 ["OTEL_NODE_RESOURCE_DETECTORS"] = "env,host,os,process,serviceinstance,container"
74 ["MAILER_ADDRESS"] = if (isolated) "" else "${secret.own.mailer_address}"
75 ["MAILER_USERNAME"] = if (isolated) "" else "${secret.own.mailer_username}"
76 ["MAILER_PASSWORD"] = if (isolated) "" else "${secret.own.mailer_password}"
77 }
78
79 envTemplate = """
80 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "victoria-traces" }}OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://\(module.nomadHostPort)/insert/opentelemetry/v1/traces{{ end }}
81 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "victoria-metrics" }}OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://\(module.nomadHostPort)/opentelemetry/v1/metrics{{ end }}
82 """
83}
service/pds/start.sh created+11
......@@ -0,0 +1,11 @@
1#!/bin/sh
2set -eu
3
4if [ -n "$MAILER_ADDRESS" ]; then
5 mailer_username=$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$MAILER_USERNAME")
6 mailer_password=$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$MAILER_PASSWORD")
7 export PDS_EMAIL_SMTP_URL="smtps://${mailer_username}:${mailer_password}@${MAILER_ADDRESS}:465/"
8 export PDS_EMAIL_FROM_ADDRESS="noreply@${HOME_DOMAIN}"
9fi
10
11exec dumb-init -- node --enable-source-maps index.ts
service/postgres/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="576.095" height="593.844" viewBox="0 0 432.071 445.383"><g style="fill-rule:nonzero;clip-rule:nonzero;fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4"><path d="M323.205 324.227c2.833-23.601 1.984-27.062 19.563-23.239l4.463.392c13.517.615 31.199-2.174 41.587-7 22.362-10.376 35.622-27.7 13.572-23.148-50.297 10.376-53.755-6.655-53.755-6.655 53.111-78.803 75.313-178.836 56.149-203.322-52.27-66.789-142.748-35.206-144.262-34.386l-.482.089c-9.938-2.062-21.06-3.294-33.554-3.496-22.761-.374-40.032 5.967-53.133 15.904 0 0-161.408-66.498-153.899 83.628 1.597 31.936 45.777 241.655 98.47 178.31 19.259-23.163 37.871-42.748 37.871-42.748 9.242 6.14 20.307 9.272 31.912 8.147l.897-.765c-.281 2.876-.157 5.689.359 9.019-13.572 15.167-9.584 17.83-36.723 23.416-27.457 5.659-11.326 15.734-.797 18.367 12.768 3.193 42.305 7.716 62.268-20.224l-.795 3.188c5.325 4.26 4.965 30.619 5.72 49.452.756 18.834 2.017 36.409 5.856 46.771 3.839 10.36 8.369 37.05 44.036 29.406 29.809-6.388 52.6-15.582 54.677-101.107" style="fill:#000;stroke:#000;stroke-width:37.3953;stroke-linecap:butt;stroke-linejoin:miter"/><path stroke="none" d="M402.395 271.23c-50.302 10.376-53.76-6.655-53.76-6.655 53.111-78.808 75.313-178.843 56.153-203.326-52.27-66.785-142.752-35.2-144.262-34.38l-.486.087c-9.938-2.063-21.06-3.292-33.56-3.496-22.761-.373-40.026 5.967-53.127 15.902 0 0-161.411-66.495-153.904 83.63 1.597 31.938 45.776 241.657 98.471 178.312 19.26-23.163 37.869-42.748 37.869-42.748 9.243 6.14 20.308 9.272 31.908 8.147l.901-.765c-.28 2.876-.152 5.689.361 9.019-13.575 15.167-9.586 17.83-36.723 23.416-27.459 5.659-11.328 15.734-.796 18.367 12.768 3.193 42.307 7.716 62.266-20.224l-.796 3.188c5.319 4.26 9.054 27.711 8.428 48.969s-1.044 35.854 3.147 47.254 8.368 37.05 44.042 29.406c29.809-6.388 45.256-22.942 47.405-50.555 1.525-19.631 4.976-16.729 5.194-34.28l2.768-8.309c3.192-26.611.507-35.196 18.872-31.203l4.463.392c13.517.615 31.208-2.174 41.591-7 22.358-10.376 35.618-27.7 13.573-23.148z" style="fill:#336791;stroke:none"/><path d="M215.866 286.484c-1.385 49.516.348 99.377 5.193 111.495 4.848 12.118 15.223 35.688 50.9 28.045 29.806-6.39 40.651-18.756 45.357-46.051 3.466-20.082 10.148-75.854 11.005-87.281M173.104 38.256S11.583-27.76 19.092 122.365c1.597 31.938 45.779 241.664 98.473 178.316 19.256-23.166 36.671-41.335 36.671-41.335M260.349 26.207c-5.591 1.753 89.848-34.889 144.087 34.417 19.159 24.484-3.043 124.519-56.153 203.329"/><path d="M348.282 263.953s3.461 17.036 53.764 6.653c22.04-4.552 8.776 12.774-13.577 23.155-18.345 8.514-59.474 10.696-60.146-1.069-1.729-30.355 21.647-21.133 19.96-28.739-1.525-6.85-11.979-13.573-18.894-30.338-6.037-14.633-82.796-126.849 21.287-110.183 3.813-.789-27.146-99.002-124.553-100.599-97.385-1.597-94.19 119.762-94.19 119.762" style="stroke-linejoin:bevel"/><path d="M188.604 274.334c-13.577 15.166-9.584 17.829-36.723 23.417-27.459 5.66-11.326 15.733-.797 18.365 12.768 3.195 42.307 7.718 62.266-20.229 6.078-8.509-.036-22.086-8.385-25.547-4.034-1.671-9.428-3.765-16.361 3.994"/><path d="M187.715 274.069c-1.368-8.917 2.93-19.528 7.536-31.942 6.922-18.626 22.893-37.255 10.117-96.339-9.523-44.029-73.396-9.163-73.436-3.193-.039 5.968 2.889 30.26-1.067 58.548-5.162 36.913 23.488 68.132 56.479 64.938"/><path d="M172.517 141.7c-.288 2.039 3.733 7.48 8.976 8.207 5.234.73 9.714-3.522 9.998-5.559.284-2.039-3.732-4.285-8.977-5.015-5.237-.731-9.719.333-9.996 2.367z" style="fill:#fff;stroke-width:4.155;stroke-linecap:butt;stroke-linejoin:miter"/><path d="M331.941 137.543c.284 2.039-3.732 7.48-8.976 8.207-5.238.73-9.718-3.522-10.005-5.559-.277-2.039 3.74-4.285 8.979-5.015s9.718.333 10.002 2.368z" style="fill:#fff;stroke-width:2.0775;stroke-linecap:butt;stroke-linejoin:miter"/><path d="M350.676 123.432c.863 15.994-3.445 26.888-3.988 43.914-.804 24.748 11.799 53.074-7.191 81.435"/></g></svg>
\ No newline at end of file
service/postgres/pgadmin.pkl created+48
......@@ -0,0 +1,48 @@
1amends "../../config/Service.pkl"
2
3import "service.pkl" as postgres
4
5meta { name = "pgAdmin" }
6healthyDeadline = "15m"
7requirements { new postgres.AdminConnection {} }
8
9secrets {
10 ["password"] {}
11}
12
13container {
14 image = "docker.io/dpage/pgadmin4@sha256:33eb7d070ee7500d1513453773f61df1256fdea08257d4a3f4aebd8cf1cc87cf"
15 entrypoint = "/bin/sh"
16 args { "/studio/start.sh" }
17 cpu = 150
18 memory = 512
19
20 http {
21 containerPort = 5050
22 subdomain = "pg"
23 authRole = "infra-admin"
24 checkPath = "/misc/ping"
25 }
26
27 volumes {
28 ["/var/lib/pgadmin"] {}
29 ["/studio/start.sh"] { config = "start.sh" }
30 }
31
32 env {
33 ["PGADMIN_DEFAULT_EMAIL"] = "pgadmin4@pgadmin.org"
34 ["PGADMIN_DEFAULT_PASSWORD"] = "${secret.own.password}"
35 ["PGADMIN_CONFIG_SERVER_MODE"] = "False"
36 ["PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED"] = "False"
37 ["PGADMIN_CUSTOM_CONFIG_DISTRO_FILE"] = "/var/lib/pgadmin/config_distro.py"
38 ["PGADMIN_DISABLE_POSTFIX"] = "True"
39 ["PGADMIN_LISTEN_PORT"] = "5050"
40 ["PGADMIN_REPLACE_SERVERS_ON_STARTUP"] = "True"
41 ["POSTGRES_PASSWORD"] = "${secret.admin.password}"
42 }
43
44 envTemplate = """
45 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "postgres" }}POSTGRES_HOST={{ .Address }}
46 POSTGRES_PORT={{ .Port }}{{ end }}
47 """
48}
service/postgres/pgadmin/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#336791"/><g fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><rect x="12" y="13" width="40" height="38" rx="4"/><path d="M12 25h40M12 38h40"/><circle cx="20" cy="19" r="1"/><circle cx="20" cy="32" r="1"/><circle cx="20" cy="45" r="1"/></g></svg>
service/postgres/pgadmin/start.sh created+11
......@@ -0,0 +1,11 @@
1#!/bin/sh
2set -eu
3
4storage=/var/lib/pgadmin/storage/pgadmin4_pgadmin.org
5mkdir -p "$storage"
6printf '%s:%s:*:postgres:%s\n' "$POSTGRES_HOST" "$POSTGRES_PORT" "$POSTGRES_PASSWORD" >"$storage/pgpass"
7chmod 600 "$storage/pgpass"
8printf '{"Servers":{"1":{"Name":"Clover Postgres","Group":"Servers","Host":"%s","Port":%s,"MaintenanceDB":"postgres","Username":"postgres","SSLMode":"prefer","PassFile":"%s"}}}\n' \
9 "$POSTGRES_HOST" "$POSTGRES_PORT" "$storage/pgpass" >/var/lib/pgadmin/servers.json
10export PGADMIN_SERVER_JSON_FILE=/var/lib/pgadmin/servers.json
11exec /entrypoint.sh python3
service/postgres/provide.py created+108
......@@ -0,0 +1,108 @@
1#!/usr/bin/env python3
2import json
3import os
4import re
5import secrets
6import subprocess
7import sys
8import urllib.request
9
10
11data = json.load(sys.stdin)
12request = data["request"]
13if request["kind"] == "admin":
14 if data.get("operation") != "delete":
15 print(json.dumps({"username": "postgres", "password": data["providerSecrets"]["password"]}))
16 sys.exit(0)
17if request["kind"] != "database":
18 raise ValueError("unsupported Postgres input")
19source_database = request["name"]
20if not re.fullmatch(r"[a-z][a-z0-9_]{0,55}", source_database):
21 raise ValueError("invalid database name")
22extensions = request.get("extensions", [])
23if any(not re.fullmatch(r"[a-z][a-z0-9_]*", extension) for extension in extensions):
24 raise ValueError("invalid extension name")
25stage_id = data.get("stageId")
26database = source_database[:42] + "_s_" + stage_id[-8:] if stage_id else source_database
27username = "svc_" + database
28existing = data.get("existing") or {}
29password = existing.get("password") or secrets.token_urlsafe(32)
30if data.get("operation") == "delete" and not stage_id:
31 raise ValueError("refusing to delete a production database")
32if existing.get("name") and existing["name"] != database:
33 raise ValueError("database name changed; migrate the existing database before deployment")
34
35nomad_request = urllib.request.Request(
36 "http://127.0.0.1:4646/v1/job/postgres/allocations",
37 headers={"X-Nomad-Token": os.environ["NOMAD_TOKEN"]},
38)
39with urllib.request.urlopen(nomad_request, timeout=5) as response:
40 allocations = json.load(response)
41running = [item["ID"] for item in allocations if item["ClientStatus"] == "running" and item["DesiredStatus"] == "run"]
42if len(running) != 1:
43 raise ValueError("Postgres needs exactly one running allocation")
44result = subprocess.run(
45 ["podman", "--url", "unix:///run/podman/podman.sock", "ps", "--format", "{{.ID}} {{.Names}}"],
46 text=True, capture_output=True, check=True,
47)
48containers = [parts[0] for line in result.stdout.splitlines() if len(parts := line.split()) == 2 and parts[1].endswith(running[0])]
49if len(containers) != 1:
50 raise ValueError("Postgres allocation container is unavailable")
51container = containers[0]
52podman = ["podman", "--url", "unix:///run/podman/podman.sock", "exec"]
53
54
55def sql(statement, db="postgres"):
56 result = subprocess.run(
57 [*podman, "-i", container,
58 "psql", "-U", "postgres", "-d", db, "-tA", "-v", "ON_ERROR_STOP=1"],
59 input=statement + "\n", text=True, capture_output=True, check=True,
60 )
61 return result.stdout.strip()
62
63
64if data.get("operation") == "delete":
65 sql(f"DROP DATABASE IF EXISTS {database} WITH (FORCE);")
66 sql(f"DROP ROLE IF EXISTS {username};")
67 sys.exit(0)
68
69quoted_password = password.replace("'", "''")
70role = sql(f"SELECT rolname FROM pg_roles WHERE rolname = '{username}';")
71if not role:
72 sql(f"CREATE ROLE {username} LOGIN PASSWORD '{quoted_password}';")
73elif not existing.get("password"):
74 sql(f"ALTER ROLE {username} PASSWORD '{quoted_password}';")
75owner = sql(f"SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname = '{database}';")
76if not owner:
77 sql(f"CREATE DATABASE {database} OWNER {username};")
78 for extension in extensions:
79 sql(f"CREATE EXTENSION IF NOT EXISTS {extension};", database)
80 source_container = data.get("sourceContainer") or container
81 source_exists = stage_id and subprocess.run(
82 [*podman, "-i", source_container, "psql", "-U", "postgres", "-d", "postgres", "-tA", "-v", "ON_ERROR_STOP=1"],
83 input=f"SELECT 1 FROM pg_database WHERE datname = '{source_database}';\n",
84 text=True, capture_output=True, check=True,
85 ).stdout.strip()
86 if source_exists:
87 dump = subprocess.Popen(
88 [*podman, source_container, "pg_dump", "-U", "postgres", "-Fc", "--no-owner", "--no-acl",
89 *(f"--exclude-extension={extension}" for extension in extensions), source_database],
90 stdout=subprocess.PIPE, stderr=subprocess.PIPE,
91 )
92 restore = subprocess.Popen(
93 [*podman, "-i", container, "pg_restore", "-U", "postgres", "--no-owner", "--no-acl",
94 "--role=" + username, "-d", database],
95 stdin=dump.stdout, stderr=subprocess.PIPE,
96 )
97 dump.stdout.close()
98 restore_error = restore.communicate()[1]
99 dump_error = dump.stderr.read()
100 if dump.wait() or restore.returncode:
101 raise RuntimeError((dump_error + restore_error).decode(errors="replace"))
102elif owner != username:
103 raise ValueError(f"database {database} is owned by {owner}")
104
105for extension in extensions:
106 sql(f"CREATE EXTENSION IF NOT EXISTS {extension};", database)
107
108print(json.dumps({"name": database, "username": username, "password": password}))
service/postgres/service.pkl created+46
......@@ -0,0 +1,46 @@
1extends "../../config/Service.pkl"
2
3import "../../config/Service.pkl" as service
4
5class Database extends service.Requirement {
6 alias: String = "database"
7 fixed provider = "postgres"
8 fixed kind = "database"
9 name: String(isNotEmpty)
10 extensions: Listing<String> = new {}
11}
12
13class AdminConnection extends service.Requirement {
14 alias: String = "admin"
15 fixed provider = "postgres"
16 fixed kind = "admin"
17}
18
19meta { name = "Postgres" }
20provide = "provide.py"
21
22container {
23 image = "docker.io/postgis/postgis@sha256:7e00e8c3539fdd43f513b98806c8204714dcd09dea683c259e333d7690317119"
24 cpu = 300
25
26 tcp {
27 name = "db"
28 containerPort = 5432
29 // The podman0 firewall permits this port for Keycloak's connection.
30 hostPort = 15432
31 loopback = false
32 }
33
34 volumes {
35 ["/var/lib/postgresql"] {}
36 }
37
38 env {
39 ["POSTGRES_DB"] = "postgres"
40 ["POSTGRES_PASSWORD"] = "${secret.own.password}"
41 }
42}
43
44secrets {
45 ["password"] {}
46}
service/qbittorrent/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 1024 1024"><linearGradient id="a" x1="-446.298" x2="-445.875" y1="1052.924" y2="1052.156" gradientTransform="matrix(992 0 0 -992 442993 1044565)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#72b4f5"/><stop offset="1" style="stop-color:#356ebf"/></linearGradient><circle cx="512" cy="512" r="496" style="fill-rule:evenodd;clip-rule:evenodd;fill:url(#a);stroke:#daefff;stroke-width:32"/><path d="M712.9 332.4c44.4 0 78.9 15.2 103.4 45.7 24.7 30.2 37 73.1 37 128.7 0 55.5-12.4 98.8-37.3 129.6-24.7 30.7-59 46-103.1 46-22 0-42.2-4-60.5-12-18.1-8.2-33.3-20.8-45.7-37.6H603l-10.8 43.5h-36.7V196h51.2v116.6c0 26.1-.8 49.6-2.5 70.4h2.5c23.9-33.7 59.3-50.6 106.2-50.6m-7.4 42.9c-35 0-60.2 10.1-75.6 30.2-15.4 20-23.1 53.7-23.1 101.2s7.9 81.6 23.8 102.1c15.8 20.4 41.2 30.5 76.2 30.5 31.5 0 54.9-11.4 70.4-34.3 15.4-23 23.1-56.1 23.1-99.1q0-66-23.1-98.4c-15.5-21.4-39.4-32.2-71.7-32.2" style="fill-rule:evenodd;clip-rule:evenodd;fill:#fff"/><path d="M317.3 639.5c34.2 0 59-9.2 74.7-27.5 15.6-18.3 24-49.2 25-92.6V508c0-47.3-8-81.4-24.1-102.1-16-20.8-41.5-31.2-76.2-31.2-30 0-53.1 11.7-69.1 35.2-15.8 23.2-23.8 56.2-23.8 98.8s7.8 75.1 23.5 97.5c15.8 22.1 39.1 33.2 70 33.3m-7.7 42.8c-43.6 0-77.7-15.3-102.1-46-24.5-30.7-36.7-73.4-36.7-128.4 0-55.3 12.3-98.5 37-129.6s59-46.6 103.1-46.6q69.45 0 106.8 52.5h2.8l7.4-46.3h40.4v490h-51.2V683.3c0-20.6 1.1-38.1 3.4-52.5h-4c-23.8 34.4-59.4 51.5-106.9 51.5" style="fill-rule:evenodd;clip-rule:evenodd;fill:#c8e8ff"/></svg>
\ No newline at end of file
service/qbittorrent/openvpn/profile.ovpn created+53
......@@ -0,0 +1,53 @@
1client
2dev tun
3proto udp
4remote us-siliconvalley.privacy.network 1198
5resolv-retry infinite
6nobind
7persist-key
8cipher aes-128-cbc
9auth sha1
10tls-client
11remote-cert-tls server
12
13auth-user-pass credentials.conf
14comp-lzo no
15verb 1
16
17<ca>
18-----BEGIN CERTIFICATE-----
19MIIFqzCCBJOgAwIBAgIJAKZ7D5Yv87qDMA0GCSqGSIb3DQEBDQUAMIHoMQswCQYD
20VQQGEwJVUzELMAkGA1UECBMCQ0ExEzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNV
21BAoTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIElu
22dGVybmV0IEFjY2VzczEgMB4GA1UEAxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3Mx
23IDAeBgNVBCkTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkB
24FiBzZWN1cmVAcHJpdmF0ZWludGVybmV0YWNjZXNzLmNvbTAeFw0xNDA0MTcxNzM1
25MThaFw0zNDA0MTIxNzM1MThaMIHoMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex
26EzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNVBAoTF1ByaXZhdGUgSW50ZXJuZXQg
27QWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UE
28AxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBCkTF1ByaXZhdGUgSW50
29ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkBFiBzZWN1cmVAcHJpdmF0ZWludGVy
30bmV0YWNjZXNzLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPXD
31L1L9tX6DGf36liA7UBTy5I869z0UVo3lImfOs/GSiFKPtInlesP65577nd7UNzzX
32lH/P/CnFPdBWlLp5ze3HRBCc/Avgr5CdMRkEsySL5GHBZsx6w2cayQ2EcRhVTwWp
33cdldeNO+pPr9rIgPrtXqT4SWViTQRBeGM8CDxAyTopTsobjSiYZCF9Ta1gunl0G/
348Vfp+SXfYCC+ZzWvP+L1pFhPRqzQQ8k+wMZIovObK1s+nlwPaLyayzw9a8sUnvWB
35/5rGPdIYnQWPgoNlLN9HpSmsAcw2z8DXI9pIxbr74cb3/HSfuYGOLkRqrOk6h4RC
36OfuWoTrZup1uEOn+fw8CAwEAAaOCAVQwggFQMB0GA1UdDgQWBBQv63nQ/pJAt5tL
37y8VJcbHe22ZOsjCCAR8GA1UdIwSCARYwggESgBQv63nQ/pJAt5tLy8VJcbHe22ZO
38sqGB7qSB6zCB6DELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRMwEQYDVQQHEwpM
39b3NBbmdlbGVzMSAwHgYDVQQKExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4G
40A1UECxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBAMTF1ByaXZhdGUg
41SW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQpExdQcml2YXRlIEludGVybmV0IEFjY2Vz
42czEvMC0GCSqGSIb3DQEJARYgc2VjdXJlQHByaXZhdGVpbnRlcm5ldGFjY2Vzcy5j
43b22CCQCmew+WL/O6gzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBDQUAA4IBAQAn
44a5PgrtxfwTumD4+3/SYvwoD66cB8IcK//h1mCzAduU8KgUXocLx7QgJWo9lnZ8xU
45ryXvWab2usg4fqk7FPi00bED4f4qVQFVfGfPZIH9QQ7/48bPM9RyfzImZWUCenK3
467pdw4Bvgoys2rHLHbGen7f28knT2j/cbMxd78tQc20TIObGjo8+ISTRclSTRBtyC
47GohseKYpTS9himFERpUgNtefvYHbn70mIOzfOJFTVqfrptf9jXa9N8Mpy3ayfodz
481wiqdteqFXkTYoSDctgKMiZ6GdocK9nMroQipIQtpnwd4yBDWIyC6Bvlkrq5TQUt
49YDQ8z9v+DMO6iwyIDRiU
50-----END CERTIFICATE-----
51</ca>
52
53disable-occ
service/qbittorrent/prepare.py created+42
......@@ -0,0 +1,42 @@
1import json
2import os
3from pathlib import Path
4import sys
5import tempfile
6
7
8data = json.load(sys.stdin)
9path = Path(data["hostRoot"]) / "config/qBittorrent/config/qBittorrent.conf"
10for directory in (path.parent.parent, path.parent):
11 directory.mkdir(parents=True, exist_ok=True)
12 os.chown(directory, data["uid"], data["uid"])
13 directory.chmod(0o750)
14lines = path.read_text().splitlines() if path.exists() else []
15
16for section, key, value in (
17 ("BitTorrent", "Session\\DefaultSavePath", "/data/media/seedbox"),
18 ("BitTorrent", "Session\\TempPath", "/data/media/seedbox"),
19 ("Preferences", "WebUI\\AuthSubnetWhitelist", "10.88.0.1/32"),
20 ("Preferences", "WebUI\\AuthSubnetWhitelistEnabled", "true"),
21 ("Preferences", "WebUI\\HostHeaderValidation", "false"),
22):
23 header = f"[{section}]"
24 if header not in lines:
25 lines += [header]
26 start = lines.index(header) + 1
27 end = next((i for i in range(start, len(lines)) if lines[i].startswith("[")), len(lines))
28 current = next((i for i in range(start, end) if lines[i].startswith(key + "=")), None)
29 entry = key + "=" + value
30 if current is None:
31 lines.insert(end, entry)
32 else:
33 lines[current] = entry
34
35content = "\n".join(lines) + "\n"
36if not path.exists() or path.read_text() != content:
37 with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False) as file:
38 pending = Path(file.name)
39 file.write(content)
40 os.chown(pending, data["uid"], data["uid"])
41 pending.chmod(0o600)
42 pending.replace(path)
service/qbittorrent/service.pkl created+60
......@@ -0,0 +1,60 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5meta { name = "qBittorrent" }
6rollout = "simple"
7healthyDeadline = "15m"
8prepare = "prepare.py"
9
10requiredSecrets {
11 "vpn_user"
12 "vpn_pass"
13}
14
15container {
16 image = "docker.io/binhex/arch-qbittorrentvpn@sha256:7320d195ca582b236207b88f483319cd49b08c0665b22b945c3d4cebcb601a17"
17 imageUser = true
18 cpu = 200
19 memory = 1024
20 capAdd { "NET_ADMIN" }
21 devices { "/dev/net/tun" }
22
23 http {
24 containerPort = 23938
25 subdomain = "seedbox"
26 authRole = "media-manage"
27 }
28 tcp {
29 name = "internal"
30 containerPort = 23938
31 hostPort = 30038
32 loopback = false
33 }
34
35 volumes {
36 ["/config"] {}
37 ["/data/tmp"] {}
38 ["/data/media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
39 ["/config/openvpn/profile.ovpn"] { config = "openvpn/profile.ovpn" }
40 }
41 tmpfs { "/config/openvpn:size=1m,mode=0700" }
42
43 env {
44 ["TZ"] = "America/Los_Angeles"
45 ["VPN_ENABLED"] = "yes"
46 ["VPN_PROV"] = "pia"
47 ["VPN_CLIENT"] = "openvpn"
48 ["VPN_USER"] = "${secret.own.vpn_user}"
49 ["VPN_PASS"] = "${secret.own.vpn_pass}"
50 ["LAN_NETWORK"] = "10.88.0.0/16"
51 ["ENABLE_STARTUP_SCRIPTS"] = "no"
52 ["ENABLE_PRIVOXY"] = "no"
53 ["ENABLE_SOCKS"] = "no"
54 ["STRICT_PORT_FORWARD"] = "no"
55 ["WEBUI_PORT"] = "23938"
56 ["PUID"] = "\(module.uid)"
57 ["PGID"] = "\(site.cloverGid)"
58 ["UMASK"] = "002"
59 }
60}
service/radarr/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.st0{fill:#24292e}</style><g id="Group-Copy" transform="translate(70 21)"><path id="Shape" d="m10.3 59.8 3.9 372.4c-31.4 3.9-54.9-11.8-54.9-43.1l-3.9-309.7c0-98 90.2-121.5 145.1-82.3l278.3 160.7c39.2 27.4 47 78.4 27.4 113.7-3.9-27.4-15.7-43.1-39.2-58.8L53.4 36.2C29.9 20.6 10.3 24.5 10.3 59.8" class="st0"/><path id="Shape_00000114049535938561773820000018271523940913105341_" d="M-13.2 451.8c23.5 7.8 47 3.9 66.6-7.8l321.5-188.2c19.6 27.4 15.7 54.9-7.8 70.6L96.5 483.2c-39.2 19.6-90.1 0-109.7-31.4" class="st0"/><path id="Shape_00000165935924413286433040000003668002807793862576_" d="M80.9 342 273 232.3 84.8 126.4z" style="fill:#ffc230"/></g></svg>
\ No newline at end of file
service/radarr/service.pkl created+33
......@@ -0,0 +1,33 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5meta { name = "Radarr" }
6setup = "tools/configure-arr.py"
7dependsOn {
8 "qbittorrent"
9 "jackett"
10}
11
12container {
13 image = "lscr.io/linuxserver/radarr@sha256:adb6c09d6b729ea5e642c99cea35af72702ef476bf4763f153299ac5db9f0b4f"
14 memory = 1024
15
16 http {
17 containerPort = 7878
18 subdomain = "rdr"
19 authRole = "media-manage"
20 checkPath = "/ping"
21 }
22
23 volumes {
24 ["/config"] {}
25 ["/data/media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
26 }
27
28 env {
29 ["TZ"] = "America/Los_Angeles"
30 ["UMASK"] = "002"
31 ["RADARR__AUTH__METHOD"] = "External"
32 }
33}
service/samba/icon.svg created+15
......@@ -0,0 +1,15 @@
1<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" version="1.1" viewBox="0 45.81 1024 932.39">
2 <!-- Generator: Adobe Illustrator 29.2.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 116) -->
3 <defs>
4 <style>
5 .st0 {
6 fill: url(#linear-gradient);
7 }
8 </style>
9 <linearGradient id="linear-gradient" x1="512" y1="903.74" x2="512" y2="123.38" gradientTransform="translate(0 1026) scale(1 -1)" gradientUnits="userSpaceOnUse">
10 <stop offset="0" stop-color="#99a29b"/>
11 <stop offset="1" stop-color="#738777"/>
12 </linearGradient>
13 </defs>
14 <path id="path14" class="st0" d="M910.86,45.81v67.23H204.87c-38.51,0-66.84,9.65-85.08,28.8-18.24,19.15-27.3,48.55-27.3,88.45v326.76c0,12.25,2.75,21.42,8.35,27.46,5.6,5.88,14.32,8.79,26.14,8.79h628.77c18.72,0,31.84,4.22,39.33,12.74,8.11,8.52,12.08,22.97,12.08,43.28v46.36c0,20.25-3.99,34.57-12.08,43.06-7.47,8.49-20.66,12.74-39.33,12.74H113.14v-68.54L0,830.56l113.14,147.64v-68.54h713.79c48.72,0,84.79-12.02,107.87-36.25,23.08-24.23,34.49-61.57,34.49-112.04v-192.23c0-46.46-10.62-80.96-31.86-103.26-21.24-22.92-54.24-34.27-99.08-34.27H306.04c-18.05,0-31.24-4.25-39.33-12.74-8.09-8.49-12.08-22.81-12.08-43.06v-46.14c0-20.25,3.99-34.79,12.08-43.28,8.09-8.49,21.28-12.74,39.33-12.74h604.82v67.23l113.14-147.42-113.14-147.64Z"/>
15</svg>
\ No newline at end of file
service/samba/service.pkl created+44
......@@ -0,0 +1,44 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5local sharePermissions = "create mask = 0664; force create mode = 0660; directory mask = 0775; force directory mode = 02770; vfs objects = catia fruit streams_xattr acl_xattr; acl_xattr:ignore system acls = yes; inherit permissions = yes"
6
7meta { name = "Samba" }
8
9container {
10 image = "ghcr.io/servercontainers/samba@sha256:31b90ea7fe3258d30fccd971c85743b92694605f4b43dc8a4df23a202fced06a"
11 imageUser = true
12 hostNetwork = !site.preview
13 cpu = 100
14 memory = 256
15
16 tcp {
17 name = "smb"
18 containerPort = 445
19 hostPort = 445
20 loopback = false
21 }
22
23 volumes {
24 ["/shares/clover"] { src = site.cloverRoot; readOnly = site.cloverReadOnly }
25 ["/shares/clover/Media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
26 ["/shares/media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
27 }
28
29 env {
30 // Finder writes as Clover's owner; Copyparty writes through the shared group.
31 ["UID_clo"] = site.cloverUid.toString()
32 ["ACCOUNT_clo"] = "${secret.own.password}"
33 ["AVAHI_DISABLE"] = "1"
34 ["WSDD2_DISABLE"] = "1"
35 ["SAMBA_CONF_WORKGROUP"] = "PAPER_CLOVER"
36 ["SAMBA_CONF_SERVER_STRING"] = "paper clover's nas"
37 ["SAMBA_VOLUME_CONFIG_clover"] = "[clover]; path = /shares/clover; valid users = clo; read only = \(if (site.cloverReadOnly) "yes" else "no"); \(sharePermissions)"
38 ["SAMBA_VOLUME_CONFIG_media"] = "[media]; path = /shares/media; valid users = clo; read only = \(if (site.mediaReadOnly) "yes" else "no"); \(sharePermissions)"
39 }
40}
41
42secrets {
43 ["password"] {}
44}
service/shale/icon-dark.svg created+5
......@@ -0,0 +1,5 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff6fa9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2 <path d="M11.264 2.205A4 4 0 0 0 6.42 4.211l-4 8a4 4 0 0 0 1.359 5.117l6 4a4 4 0 0 0 4.438 0l6-4a4 4 0 0 0 1.576-4.592l-2-6a4 4 0 0 0-2.53-2.53z" fill="#ff6fa9" fill-opacity="0.18"/>
3 <path d="M11.99 22 14 12l7.822 3.184"/>
4 <path d="M14 12 8.47 2.302"/>
5</svg>
service/shale/icon-light.svg created+5
......@@ -0,0 +1,5 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#c2185b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2 <path d="M11.264 2.205A4 4 0 0 0 6.42 4.211l-4 8a4 4 0 0 0 1.359 5.117l6 4a4 4 0 0 0 4.438 0l6-4a4 4 0 0 0 1.576-4.592l-2-6a4 4 0 0 0-2.53-2.53z" fill="#c2185b" fill-opacity="0.18"/>
3 <path d="M11.99 22 14 12l7.822 3.184"/>
4 <path d="M14 12 8.47 2.302"/>
5</svg>
service/shale/icons/discord-name-painter.png created
Binary files /dev/null and b/service/shale/icons/discord-name-painter.png differ
service/shale/icons/home-infra.png created
Binary files /dev/null and b/service/shale/icons/home-infra.png differ
service/shale/icons/markodown.png created
Binary files /dev/null and b/service/shale/icons/markodown.png differ
service/shale/icons/react-mutation.png created
Binary files /dev/null and b/service/shale/icons/react-mutation.png differ
service/shale/icons/sitegen.png created
Binary files /dev/null and b/service/shale/icons/sitegen.png differ
service/shale/icons/snowbound.png created
Binary files /dev/null and b/service/shale/icons/snowbound.png differ
service/shale/icons/toolkit.png created
Binary files /dev/null and b/service/shale/icons/toolkit.png differ
service/shale/readme/LICENSE.dompurify created+202
......@@ -0,0 +1,202 @@
1
2 Apache License
3 Version 2.0, January 2004
4 http://www.apache.org/licenses/
5
6 TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
8 1. Definitions.
9
10 "License" shall mean the terms and conditions for use, reproduction,
11 and distribution as defined by Sections 1 through 9 of this document.
12
13 "Licensor" shall mean the copyright owner or entity authorized by
14 the copyright owner that is granting the License.
15
16 "Legal Entity" shall mean the union of the acting entity and all
17 other entities that control, are controlled by, or are under common
18 control with that entity. For the purposes of this definition,
19 "control" means (i) the power, direct or indirect, to cause the
20 direction or management of such entity, whether by contract or
21 otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 outstanding shares, or (iii) beneficial ownership of such entity.
23
24 "You" (or "Your") shall mean an individual or Legal Entity
25 exercising permissions granted by this License.
26
27 "Source" form shall mean the preferred form for making modifications,
28 including but not limited to software source code, documentation
29 source, and configuration files.
30
31 "Object" form shall mean any form resulting from mechanical
32 transformation or translation of a Source form, including but
33 not limited to compiled object code, generated documentation,
34 and conversions to other media types.
35
36 "Work" shall mean the work of authorship, whether in Source or
37 Object form, made available under the License, as indicated by a
38 copyright notice that is included in or attached to the work
39 (an example is provided in the Appendix below).
40
41 "Derivative Works" shall mean any work, whether in Source or Object
42 form, that is based on (or derived from) the Work and for which the
43 editorial revisions, annotations, elaborations, or other modifications
44 represent, as a whole, an original work of authorship. For the purposes
45 of this License, Derivative Works shall not include works that remain
46 separable from, or merely link (or bind by name) to the interfaces of,
47 the Work and Derivative Works thereof.
48
49 "Contribution" shall mean any work of authorship, including
50 the original version of the Work and any modifications or additions
51 to that Work or Derivative Works thereof, that is intentionally
52 submitted to Licensor for inclusion in the Work by the copyright owner
53 or by an individual or Legal Entity authorized to submit on behalf of
54 the copyright owner. For the purposes of this definition, "submitted"
55 means any form of electronic, verbal, or written communication sent
56 to the Licensor or its representatives, including but not limited to
57 communication on electronic mailing lists, source code control systems,
58 and issue tracking systems that are managed by, or on behalf of, the
59 Licensor for the purpose of discussing and improving the Work, but
60 excluding communication that is conspicuously marked or otherwise
61 designated in writing by the copyright owner as "Not a Contribution."
62
63 "Contributor" shall mean Licensor and any individual or Legal Entity
64 on behalf of whom a Contribution has been received by Licensor and
65 subsequently incorporated within the Work.
66
67 2. Grant of Copyright License. Subject to the terms and conditions of
68 this License, each Contributor hereby grants to You a perpetual,
69 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 copyright license to reproduce, prepare Derivative Works of,
71 publicly display, publicly perform, sublicense, and distribute the
72 Work and such Derivative Works in Source or Object form.
73
74 3. Grant of Patent License. Subject to the terms and conditions of
75 this License, each Contributor hereby grants to You a perpetual,
76 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 (except as stated in this section) patent license to make, have made,
78 use, offer to sell, sell, import, and otherwise transfer the Work,
79 where such license applies only to those patent claims licensable
80 by such Contributor that are necessarily infringed by their
81 Contribution(s) alone or by combination of their Contribution(s)
82 with the Work to which such Contribution(s) was submitted. If You
83 institute patent litigation against any entity (including a
84 cross-claim or counterclaim in a lawsuit) alleging that the Work
85 or a Contribution incorporated within the Work constitutes direct
86 or contributory patent infringement, then any patent licenses
87 granted to You under this License for that Work shall terminate
88 as of the date such litigation is filed.
89
90 4. Redistribution. You may reproduce and distribute copies of the
91 Work or Derivative Works thereof in any medium, with or without
92 modifications, and in Source or Object form, provided that You
93 meet the following conditions:
94
95 (a) You must give any other recipients of the Work or
96 Derivative Works a copy of this License; and
97
98 (b) You must cause any modified files to carry prominent notices
99 stating that You changed the files; and
100
101 (c) You must retain, in the Source form of any Derivative Works
102 that You distribute, all copyright, patent, trademark, and
103 attribution notices from the Source form of the Work,
104 excluding those notices that do not pertain to any part of
105 the Derivative Works; and
106
107 (d) If the Work includes a "NOTICE" text file as part of its
108 distribution, then any Derivative Works that You distribute must
109 include a readable copy of the attribution notices contained
110 within such NOTICE file, excluding those notices that do not
111 pertain to any part of the Derivative Works, in at least one
112 of the following places: within a NOTICE text file distributed
113 as part of the Derivative Works; within the Source form or
114 documentation, if provided along with the Derivative Works; or,
115 within a display generated by the Derivative Works, if and
116 wherever such third-party notices normally appear. The contents
117 of the NOTICE file are for informational purposes only and
118 do not modify the License. You may add Your own attribution
119 notices within Derivative Works that You distribute, alongside
120 or as an addendum to the NOTICE text from the Work, provided
121 that such additional attribution notices cannot be construed
122 as modifying the License.
123
124 You may add Your own copyright statement to Your modifications and
125 may provide additional or different license terms and conditions
126 for use, reproduction, or distribution of Your modifications, or
127 for any such Derivative Works as a whole, provided Your use,
128 reproduction, and distribution of the Work otherwise complies with
129 the conditions stated in this License.
130
131 5. Submission of Contributions. Unless You explicitly state otherwise,
132 any Contribution intentionally submitted for inclusion in the Work
133 by You to the Licensor shall be under the terms and conditions of
134 this License, without any additional terms or conditions.
135 Notwithstanding the above, nothing herein shall supersede or modify
136 the terms of any separate license agreement you may have executed
137 with Licensor regarding such Contributions.
138
139 6. Trademarks. This License does not grant permission to use the trade
140 names, trademarks, service marks, or product names of the Licensor,
141 except as required for reasonable and customary use in describing the
142 origin of the Work and reproducing the content of the NOTICE file.
143
144 7. Disclaimer of Warranty. Unless required by applicable law or
145 agreed to in writing, Licensor provides the Work (and each
146 Contributor provides its Contributions) on an "AS IS" BASIS,
147 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 implied, including, without limitation, any warranties or conditions
149 of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 PARTICULAR PURPOSE. You are solely responsible for determining the
151 appropriateness of using or redistributing the Work and assume any
152 risks associated with Your exercise of permissions under this License.
153
154 8. Limitation of Liability. In no event and under no legal theory,
155 whether in tort (including negligence), contract, or otherwise,
156 unless required by applicable law (such as deliberate and grossly
157 negligent acts) or agreed to in writing, shall any Contributor be
158 liable to You for damages, including any direct, indirect, special,
159 incidental, or consequential damages of any character arising as a
160 result of this License or out of the use or inability to use the
161 Work (including but not limited to damages for loss of goodwill,
162 work stoppage, computer failure or malfunction, or any and all
163 other commercial damages or losses), even if such Contributor
164 has been advised of the possibility of such damages.
165
166 9. Accepting Warranty or Additional Liability. While redistributing
167 the Work or Derivative Works thereof, You may choose to offer,
168 and charge a fee for, acceptance of support, warranty, indemnity,
169 or other liability obligations and/or rights consistent with this
170 License. However, in accepting such obligations, You may act only
171 on Your own behalf and on Your sole responsibility, not on behalf
172 of any other Contributor, and only if You agree to indemnify,
173 defend, and hold each Contributor harmless for any liability
174 incurred by, or claims asserted against, such Contributor by reason
175 of your accepting any such warranty or additional liability.
176
177 END OF TERMS AND CONDITIONS
178
179 APPENDIX: How to apply the Apache License to your work.
180
181 To apply the Apache License to your work, attach the following
182 boilerplate notice, with the fields enclosed by brackets "[]"
183 replaced with your own identifying information. (Don't include
184 the brackets!) The text should be enclosed in the appropriate
185 comment syntax for the file format. We also recommend that a
186 file or class name and description of purpose be included on the
187 same "printed page" as the copyright notice for easier
188 identification within third-party archives.
189
190 Copyright [yyyy] [name of copyright owner]
191
192 Licensed under the Apache License, Version 2.0 (the "License");
193 you may not use this file except in compliance with the License.
194 You may obtain a copy of the License at
195
196 http://www.apache.org/licenses/LICENSE-2.0
197
198 Unless required by applicable law or agreed to in writing, software
199 distributed under the License is distributed on an "AS IS" BASIS,
200 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 See the License for the specific language governing permissions and
202 limitations under the License.
service/shale/readme/LICENSE.markdown-it created+22
......@@ -0,0 +1,22 @@
1Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin.
2
3Permission is hereby granted, free of charge, to any person
4obtaining a copy of this software and associated documentation
5files (the "Software"), to deal in the Software without
6restriction, including without limitation the rights to use,
7copy, modify, merge, publish, distribute, sublicense, and/or sell
8copies of the Software, and to permit persons to whom the
9Software is furnished to do so, subject to the following
10conditions:
11
12The above copyright notice and this permission notice shall be
13included in all copies or substantial portions of the Software.
14
15THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22OTHER DEALINGS IN THE SOFTWARE.
service/shale/readme/markdown-it.min.js created+3
......@@ -0,0 +1,3 @@
1/*! markdown-it 14.3.0 https://github.com/markdown-it/markdown-it @license MIT */
2!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):(t="undefined"!=typeof globalThis?globalThis:t||self).markdownit=e()}(this,function(){var t=Object.defineProperty,e=(e,r)=>{let n={};for(var s in e)t(n,s,{get:e[s],enumerable:!0});return r||t(n,Symbol.toStringTag,{value:"Module"}),n},r={};function n(t,e){"string"!=typeof e&&(e=n.defaultChars);const s=function(t){let e=r[t];if(e)return e;e=r[t]=[];for(let r=0;r<128;r++){const t=String.fromCharCode(r);e.push(t)}for(let r=0;r<t.length;r++){const n=t.charCodeAt(r);e[n]="%"+("0"+n.toString(16).toUpperCase()).slice(-2)}return e}(e);return t.replace(/(%[a-f0-9]{2})+/gi,function(t){let e="";for(let r=0,n=t.length;r<n;r+=3){const i=parseInt(t.slice(r+1,r+3),16);if(i<128)e+=s[i];else{if(192==(224&i)&&r+3<n){const n=parseInt(t.slice(r+4,r+6),16);if(128==(192&n)){const t=i<<6&1984|63&n;e+=t<128?"\ufffd\ufffd":String.fromCharCode(t),r+=3;continue}}if(224==(240&i)&&r+6<n){const n=parseInt(t.slice(r+4,r+6),16),s=parseInt(t.slice(r+7,r+9),16);if(128==(192&n)&&128==(192&s)){const t=i<<12&61440|n<<6&4032|63&s;e+=t<2048||t>=55296&&t<=57343?"\ufffd\ufffd\ufffd":String.fromCharCode(t),r+=6;continue}}if(240==(248&i)&&r+9<n){const n=parseInt(t.slice(r+4,r+6),16),s=parseInt(t.slice(r+7,r+9),16),o=parseInt(t.slice(r+10,r+12),16);if(128==(192&n)&&128==(192&s)&&128==(192&o)){let t=i<<18&1835008|n<<12&258048|s<<6&4032|63&o;t<65536||t>1114111?e+="\ufffd\ufffd\ufffd\ufffd":(t-=65536,e+=String.fromCharCode(55296+(t>>10),56320+(1023&t))),r+=9;continue}}e+="\ufffd"}}return e})}n.defaultChars=";/?:@&=+$,#",n.componentChars="";var s={};function i(t,e,r){"string"!=typeof e&&(r=e,e=i.defaultChars),void 0===r&&(r=!0);const n=function(t){let e=s[t];if(e)return e;e=s[t]=[];for(let r=0;r<128;r++){const t=String.fromCharCode(r);/^[0-9a-z]$/i.test(t)?e.push(t):e.push("%"+("0"+r.toString(16).toUpperCase()).slice(-2))}for(let r=0;r<t.length;r++)e[t.charCodeAt(r)]=t[r];return e}(e);let o="";for(let s=0,i=t.length;s<i;s++){const e=t.charCodeAt(s);if(r&&37===e&&s+2<i&&/^[0-9a-f]{2}$/i.test(t.slice(s+1,s+3)))o+=t.slice(s,s+3),s+=2;else if(e<128)o+=n[e];else if(e>=55296&&e<=57343){if(e>=55296&&e<=56319&&s+1<i){const e=t.charCodeAt(s+1);if(e>=56320&&e<=57343){o+=encodeURIComponent(t[s]+t[s+1]),s++;continue}}o+="%EF%BF%BD"}else o+=encodeURIComponent(t[s])}return o}function o(t){let e="";return e+=t.protocol||"",e+=t.slashes?"//":"",e+=t.auth?t.auth+"@":"",t.hostname&&-1!==t.hostname.indexOf(":")?e+="["+t.hostname+"]":e+=t.hostname||"",e+=t.port?":"+t.port:"",e+=t.pathname||"",e+=t.search||"",e+=t.hash||"",e}function u(){this.protocol=null,this.slashes=null,this.auth=null,this.port=null,this.hostname=null,this.hash=null,this.search=null,this.pathname=null}i.defaultChars=";/?:@&=+$,-_.!~*'()#",i.componentChars="-_.!~*'()";var c=/^([a-z0-9.+-]+:)/i,a=/:[0-9]*$/,l=/^(\/\/?(?!\/)[^\?\s]*)(\?[^\s]*)?$/,h=["{","}","|","\\","^","`"].concat(["<",">",'"',"`"," ","\r","\n","\t"]),p=["'"].concat(h),f=["%","/","?",";","#"].concat(p),d=["/","?","#"],m=/^[+a-z0-9A-Z_-]{0,63}$/,_=/^([+a-z0-9A-Z_-]{0,63})(.*)$/,g={javascript:!0,"javascript:":!0},k={http:!0,https:!0,ftp:!0,gopher:!0,file:!0,"http:":!0,"https:":!0,"ftp:":!0,"gopher:":!0,"file:":!0};function D(t,e){if(t&&t instanceof u)return t;const r=new u;return r.parse(t,e),r}u.prototype.parse=function(t,e){let r,n,s,i=t;if(i=i.trim(),!e&&1===t.split("#").length){const t=l.exec(i);if(t)return this.pathname=t[1],t[2]&&(this.search=t[2]),this}let o=c.exec(i);if(o&&(o=o[0],r=o.toLowerCase(),this.protocol=o,i=i.substr(o.length)),(e||o||i.match(/^\/\/[^@\/]+@[^@\/]+/))&&(s="//"===i.substr(0,2),!s||o&&g[o]||(i=i.substr(2),this.slashes=!0)),!g[o]&&(s||o&&!k[o])){let t,e,r=-1;for(let u=0;u<d.length;u++)n=i.indexOf(d[u]),-1!==n&&(-1===r||n<r)&&(r=n);e=-1===r?i.lastIndexOf("@"):i.lastIndexOf("@",r),-1!==e&&(t=i.slice(0,e),i=i.slice(e+1),this.auth=t),r=-1;for(let u=0;u<f.length;u++)n=i.indexOf(f[u]),-1!==n&&(-1===r||n<r)&&(r=n);-1===r&&(r=i.length),":"===i[r-1]&&r--;const s=i.slice(0,r);i=i.slice(r),this.parseHost(s),this.hostname=this.hostname||"";const o="["===this.hostname[0]&&"]"===this.hostname[this.hostname.length-1];if(!o){const t=this.hostname.split(/\./);for(let e=0,r=t.length;e<r;e++){const r=t[e];if(r&&!r.match(m)){let n="";for(let t=0,e=r.length;t<e;t++)r.charCodeAt(t)>127?n+="x":n+=r[t];if(!n.match(m)){const n=t.slice(0,e),s=t.slice(e+1),o=r.match(_);o&&(n.push(o[1]),s.unshift(o[2])),s.length&&(i=s.join(".")+i),this.hostname=n.join(".");break}}}}this.hostname.length>255&&(this.hostname=""),o&&(this.hostname=this.hostname.substr(1,this.hostname.length-2))}const u=i.indexOf("#");-1!==u&&(this.hash=i.substr(u),i=i.slice(0,u));const a=i.indexOf("?");return-1!==a&&(this.search=i.substr(a),i=i.slice(0,a)),i&&(this.pathname=i),k[r]&&this.hostname&&!this.pathname&&(this.pathname=""),this},u.prototype.parseHost=function(t){let e=a.exec(t);e&&(e=e[0],":"!==e&&(this.port=e.substr(1)),t=t.substr(0,t.length-e.length)),t&&(this.hostname=t)};var C,y,E=e({decode:()=>n,encode:()=>i,format:()=>o,parse:()=>D}),A=/[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,b=/[\0-\x1F\x7F-\x9F]/,F=/[\xAD\u0600-\u0605\u061C\u06DD\u070F\u0890\u0891\u08E2\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF\uFFF9-\uFFFB]|\uD804[\uDCBD\uDCCD]|\uD80D[\uDC30-\uDC3F]|\uD82F[\uDCA0-\uDCA3]|\uD834[\uDD73-\uDD7A]|\uDB40[\uDC01\uDC20-\uDC7F]/,x=/[!-#%-\*,-\/:;\?@\[-\]_\{\}\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061D-\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u09FD\u0A76\u0AF0\u0C77\u0C84\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1B7D\u1B7E\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E4F\u2E52-\u2E5D\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD803[\uDEAD\uDF55-\uDF59\uDF86-\uDF89]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC8\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDC4B-\uDC4F\uDC5A\uDC5B\uDC5D\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDE60-\uDE6C\uDEB9\uDF3C-\uDF3E]|\uD806[\uDC3B\uDD44-\uDD46\uDDE2\uDE3F-\uDE46\uDE9A-\uDE9C\uDE9E-\uDEA2\uDF00-\uDF09]|\uD807[\uDC41-\uDC45\uDC70\uDC71\uDEF7\uDEF8\uDF43-\uDF4F\uDFFF]|\uD809[\uDC70-\uDC74]|\uD80B[\uDFF1\uDFF2]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD81B[\uDE97-\uDE9A\uDFE2]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]|\uD83A[\uDD5E\uDD5F]/,v=/[\$\+<->\^`\|~\xA2-\xA6\xA8\xA9\xAC\xAE-\xB1\xB4\xB8\xD7\xF7\u02C2-\u02C5\u02D2-\u02DF\u02E5-\u02EB\u02ED\u02EF-\u02FF\u0375\u0384\u0385\u03F6\u0482\u058D-\u058F\u0606-\u0608\u060B\u060E\u060F\u06DE\u06E9\u06FD\u06FE\u07F6\u07FE\u07FF\u0888\u09F2\u09F3\u09FA\u09FB\u0AF1\u0B70\u0BF3-\u0BFA\u0C7F\u0D4F\u0D79\u0E3F\u0F01-\u0F03\u0F13\u0F15-\u0F17\u0F1A-\u0F1F\u0F34\u0F36\u0F38\u0FBE-\u0FC5\u0FC7-\u0FCC\u0FCE\u0FCF\u0FD5-\u0FD8\u109E\u109F\u1390-\u1399\u166D\u17DB\u1940\u19DE-\u19FF\u1B61-\u1B6A\u1B74-\u1B7C\u1FBD\u1FBF-\u1FC1\u1FCD-\u1FCF\u1FDD-\u1FDF\u1FED-\u1FEF\u1FFD\u1FFE\u2044\u2052\u207A-\u207C\u208A-\u208C\u20A0-\u20C0\u2100\u2101\u2103-\u2106\u2108\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u212E\u213A\u213B\u2140-\u2144\u214A-\u214D\u214F\u218A\u218B\u2190-\u2307\u230C-\u2328\u232B-\u2426\u2440-\u244A\u249C-\u24E9\u2500-\u2767\u2794-\u27C4\u27C7-\u27E5\u27F0-\u2982\u2999-\u29D7\u29DC-\u29FB\u29FE-\u2B73\u2B76-\u2B95\u2B97-\u2BFF\u2CE5-\u2CEA\u2E50\u2E51\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFF\u3004\u3012\u3013\u3020\u3036\u3037\u303E\u303F\u309B\u309C\u3190\u3191\u3196-\u319F\u31C0-\u31E3\u31EF\u3200-\u321E\u322A-\u3247\u3250\u3260-\u327F\u328A-\u32B0\u32C0-\u33FF\u4DC0-\u4DFF\uA490-\uA4C6\uA700-\uA716\uA720\uA721\uA789\uA78A\uA828-\uA82B\uA836-\uA839\uAA77-\uAA79\uAB5B\uAB6A\uAB6B\uFB29\uFBB2-\uFBC2\uFD40-\uFD4F\uFDCF\uFDFC-\uFDFF\uFE62\uFE64-\uFE66\uFE69\uFF04\uFF0B\uFF1C-\uFF1E\uFF3E\uFF40\uFF5C\uFF5E\uFFE0-\uFFE6\uFFE8-\uFFEE\uFFFC\uFFFD]|\uD800[\uDD37-\uDD3F\uDD79-\uDD89\uDD8C-\uDD8E\uDD90-\uDD9C\uDDA0\uDDD0-\uDDFC]|\uD802[\uDC77\uDC78\uDEC8]|\uD805\uDF3F|\uD807[\uDFD5-\uDFF1]|\uD81A[\uDF3C-\uDF3F\uDF45]|\uD82F\uDC9C|\uD833[\uDF50-\uDFC3]|\uD834[\uDC00-\uDCF5\uDD00-\uDD26\uDD29-\uDD64\uDD6A-\uDD6C\uDD83\uDD84\uDD8C-\uDDA9\uDDAE-\uDDEA\uDE00-\uDE41\uDE45\uDF00-\uDF56]|\uD835[\uDEC1\uDEDB\uDEFB\uDF15\uDF35\uDF4F\uDF6F\uDF89\uDFA9\uDFC3]|\uD836[\uDC00-\uDDFF\uDE37-\uDE3A\uDE6D-\uDE74\uDE76-\uDE83\uDE85\uDE86]|\uD838[\uDD4F\uDEFF]|\uD83B[\uDCAC\uDCB0\uDD2E\uDEF0\uDEF1]|\uD83C[\uDC00-\uDC2B\uDC30-\uDC93\uDCA0-\uDCAE\uDCB1-\uDCBF\uDCC1-\uDCCF\uDCD1-\uDCF5\uDD0D-\uDDAD\uDDE6-\uDE02\uDE10-\uDE3B\uDE40-\uDE48\uDE50\uDE51\uDE60-\uDE65\uDF00-\uDFFF]|\uD83D[\uDC00-\uDED7\uDEDC-\uDEEC\uDEF0-\uDEFC\uDF00-\uDF76\uDF7B-\uDFD9\uDFE0-\uDFEB\uDFF0]|\uD83E[\uDC00-\uDC0B\uDC10-\uDC47\uDC50-\uDC59\uDC60-\uDC87\uDC90-\uDCAD\uDCB0\uDCB1\uDD00-\uDE53\uDE60-\uDE6D\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC5\uDECE-\uDEDB\uDEE0-\uDEE8\uDEF0-\uDEF8\uDF00-\uDF92\uDF94-\uDFCA]/,w=/[ \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/,z=e({Any:()=>A,Cc:()=>b,Cf:()=>F,P:()=>x,S:()=>v,Z:()=>w}),S=new Uint16Array('\u1d41<\xd5\u0131\u028a\u049d\u057b\u05d0\u0675\u06de\u07a2\u07d6\u080f\u0a4a\u0a91\u0da1\u0e6d\u0f09\u0f26\u10ca\u1228\u12e1\u1415\u149d\u14c3\u14df\u1525\0\0\0\0\0\0\u156b\u16cd\u198d\u1c12\u1ddd\u1f7e\u2060\u21b0\u228d\u23c0\u23fb\u2442\u2824\u2912\u2d08\u2e48\u2fce\u3016\u32ba\u3639\u37ac\u38fe\u3a28\u3a71\u3ae0\u3b2e\u0800EMabcfglmnoprstu\\bfms\x7f\x84\x8b\x90\x95\x98\xa6\xb3\xb9\xc8\xcflig\u803b\xc6\u40c6P\u803b&\u4026cute\u803b\xc1\u40c1reve;\u4102\u0100iyx}rc\u803b\xc2\u40c2;\u4410r;\uc000\ud835\udd04rave\u803b\xc0\u40c0pha;\u4391acr;\u4100d;\u6a53\u0100gp\x9d\xa1on;\u4104f;\uc000\ud835\udd38plyFunction;\u6061ing\u803b\xc5\u40c5\u0100cs\xbe\xc3r;\uc000\ud835\udc9cign;\u6254ilde\u803b\xc3\u40c3ml\u803b\xc4\u40c4\u0400aceforsu\xe5\xfb\xfe\u0117\u011c\u0122\u0127\u012a\u0100cr\xea\xf2kslash;\u6216\u0176\xf6\xf8;\u6ae7ed;\u6306y;\u4411\u0180crt\u0105\u010b\u0114ause;\u6235noullis;\u612ca;\u4392r;\uc000\ud835\udd05pf;\uc000\ud835\udd39eve;\u42d8c\xf2\u0113mpeq;\u624e\u0700HOacdefhilorsu\u014d\u0151\u0156\u0180\u019e\u01a2\u01b5\u01b7\u01ba\u01dc\u0215\u0273\u0278\u027ecy;\u4427PY\u803b\xa9\u40a9\u0180cpy\u015d\u0162\u017aute;\u4106\u0100;i\u0167\u0168\u62d2talDifferentialD;\u6145leys;\u612d\u0200aeio\u0189\u018e\u0194\u0198ron;\u410cdil\u803b\xc7\u40c7rc;\u4108nint;\u6230ot;\u410a\u0100dn\u01a7\u01adilla;\u40b8terDot;\u40b7\xf2\u017fi;\u43a7rcle\u0200DMPT\u01c7\u01cb\u01d1\u01d6ot;\u6299inus;\u6296lus;\u6295imes;\u6297o\u0100cs\u01e2\u01f8kwiseContourIntegral;\u6232eCurly\u0100DQ\u0203\u020foubleQuote;\u601duote;\u6019\u0200lnpu\u021e\u0228\u0247\u0255on\u0100;e\u0225\u0226\u6237;\u6a74\u0180git\u022f\u0236\u023aruent;\u6261nt;\u622fourIntegral;\u622e\u0100fr\u024c\u024e;\u6102oduct;\u6210nterClockwiseContourIntegral;\u6233oss;\u6a2fcr;\uc000\ud835\udc9ep\u0100;C\u0284\u0285\u62d3ap;\u624d\u0580DJSZacefios\u02a0\u02ac\u02b0\u02b4\u02b8\u02cb\u02d7\u02e1\u02e6\u0333\u048d\u0100;o\u0179\u02a5trahd;\u6911cy;\u4402cy;\u4405cy;\u440f\u0180grs\u02bf\u02c4\u02c7ger;\u6021r;\u61a1hv;\u6ae4\u0100ay\u02d0\u02d5ron;\u410e;\u4414l\u0100;t\u02dd\u02de\u6207a;\u4394r;\uc000\ud835\udd07\u0100af\u02eb\u0327\u0100cm\u02f0\u0322ritical\u0200ADGT\u0300\u0306\u0316\u031ccute;\u40b4o\u0174\u030b\u030d;\u42d9bleAcute;\u42ddrave;\u4060ilde;\u42dcond;\u62c4ferentialD;\u6146\u0470\u033d\0\0\0\u0342\u0354\0\u0405f;\uc000\ud835\udd3b\u0180;DE\u0348\u0349\u034d\u40a8ot;\u60dcqual;\u6250ble\u0300CDLRUV\u0363\u0372\u0382\u03cf\u03e2\u03f8ontourIntegra\xec\u0239o\u0274\u0379\0\0\u037b\xbb\u0349nArrow;\u61d3\u0100eo\u0387\u03a4ft\u0180ART\u0390\u0396\u03a1rrow;\u61d0ightArrow;\u61d4e\xe5\u02cang\u0100LR\u03ab\u03c4eft\u0100AR\u03b3\u03b9rrow;\u67f8ightArrow;\u67faightArrow;\u67f9ight\u0100AT\u03d8\u03derrow;\u61d2ee;\u62a8p\u0241\u03e9\0\0\u03efrrow;\u61d1ownArrow;\u61d5erticalBar;\u6225n\u0300ABLRTa\u0412\u042a\u0430\u045e\u047f\u037crrow\u0180;BU\u041d\u041e\u0422\u6193ar;\u6913pArrow;\u61f5reve;\u4311eft\u02d2\u043a\0\u0446\0\u0450ightVector;\u6950eeVector;\u695eector\u0100;B\u0459\u045a\u61bdar;\u6956ight\u01d4\u0467\0\u0471eeVector;\u695fector\u0100;B\u047a\u047b\u61c1ar;\u6957ee\u0100;A\u0486\u0487\u62a4rrow;\u61a7\u0100ct\u0492\u0497r;\uc000\ud835\udc9frok;\u4110\u0800NTacdfglmopqstux\u04bd\u04c0\u04c4\u04cb\u04de\u04e2\u04e7\u04ee\u04f5\u0521\u052f\u0536\u0552\u055d\u0560\u0565G;\u414aH\u803b\xd0\u40d0cute\u803b\xc9\u40c9\u0180aiy\u04d2\u04d7\u04dcron;\u411arc\u803b\xca\u40ca;\u442dot;\u4116r;\uc000\ud835\udd08rave\u803b\xc8\u40c8ement;\u6208\u0100ap\u04fa\u04fecr;\u4112ty\u0253\u0506\0\0\u0512mallSquare;\u65fberySmallSquare;\u65ab\u0100gp\u0526\u052aon;\u4118f;\uc000\ud835\udd3csilon;\u4395u\u0100ai\u053c\u0549l\u0100;T\u0542\u0543\u6a75ilde;\u6242librium;\u61cc\u0100ci\u0557\u055ar;\u6130m;\u6a73a;\u4397ml\u803b\xcb\u40cb\u0100ip\u056a\u056fsts;\u6203onentialE;\u6147\u0280cfios\u0585\u0588\u058d\u05b2\u05ccy;\u4424r;\uc000\ud835\udd09lled\u0253\u0597\0\0\u05a3mallSquare;\u65fcerySmallSquare;\u65aa\u0370\u05ba\0\u05bf\0\0\u05c4f;\uc000\ud835\udd3dAll;\u6200riertrf;\u6131c\xf2\u05cb\u0600JTabcdfgorst\u05e8\u05ec\u05ef\u05fa\u0600\u0612\u0616\u061b\u061d\u0623\u066c\u0672cy;\u4403\u803b>\u403emma\u0100;d\u05f7\u05f8\u4393;\u43dcreve;\u411e\u0180eiy\u0607\u060c\u0610dil;\u4122rc;\u411c;\u4413ot;\u4120r;\uc000\ud835\udd0a;\u62d9pf;\uc000\ud835\udd3eeater\u0300EFGLST\u0635\u0644\u064e\u0656\u065b\u0666qual\u0100;L\u063e\u063f\u6265ess;\u62dbullEqual;\u6267reater;\u6aa2ess;\u6277lantEqual;\u6a7eilde;\u6273cr;\uc000\ud835\udca2;\u626b\u0400Aacfiosu\u0685\u068b\u0696\u069b\u069e\u06aa\u06be\u06caRDcy;\u442a\u0100ct\u0690\u0694ek;\u42c7;\u405eirc;\u4124r;\u610clbertSpace;\u610b\u01f0\u06af\0\u06b2f;\u610dizontalLine;\u6500\u0100ct\u06c3\u06c5\xf2\u06a9rok;\u4126mp\u0144\u06d0\u06d8ownHum\xf0\u012fqual;\u624f\u0700EJOacdfgmnostu\u06fa\u06fe\u0703\u0707\u070e\u071a\u071e\u0721\u0728\u0744\u0778\u078b\u078f\u0795cy;\u4415lig;\u4132cy;\u4401cute\u803b\xcd\u40cd\u0100iy\u0713\u0718rc\u803b\xce\u40ce;\u4418ot;\u4130r;\u6111rave\u803b\xcc\u40cc\u0180;ap\u0720\u072f\u073f\u0100cg\u0734\u0737r;\u412ainaryI;\u6148lie\xf3\u03dd\u01f4\u0749\0\u0762\u0100;e\u074d\u074e\u622c\u0100gr\u0753\u0758ral;\u622bsection;\u62c2isible\u0100CT\u076c\u0772omma;\u6063imes;\u6062\u0180gpt\u077f\u0783\u0788on;\u412ef;\uc000\ud835\udd40a;\u4399cr;\u6110ilde;\u4128\u01eb\u079a\0\u079ecy;\u4406l\u803b\xcf\u40cf\u0280cfosu\u07ac\u07b7\u07bc\u07c2\u07d0\u0100iy\u07b1\u07b5rc;\u4134;\u4419r;\uc000\ud835\udd0dpf;\uc000\ud835\udd41\u01e3\u07c7\0\u07ccr;\uc000\ud835\udca5rcy;\u4408kcy;\u4404\u0380HJacfos\u07e4\u07e8\u07ec\u07f1\u07fd\u0802\u0808cy;\u4425cy;\u440cppa;\u439a\u0100ey\u07f6\u07fbdil;\u4136;\u441ar;\uc000\ud835\udd0epf;\uc000\ud835\udd42cr;\uc000\ud835\udca6\u0580JTaceflmost\u0825\u0829\u082c\u0850\u0863\u09b3\u09b8\u09c7\u09cd\u0a37\u0a47cy;\u4409\u803b<\u403c\u0280cmnpr\u0837\u083c\u0841\u0844\u084dute;\u4139bda;\u439bg;\u67ealacetrf;\u6112r;\u619e\u0180aey\u0857\u085c\u0861ron;\u413ddil;\u413b;\u441b\u0100fs\u0868\u0970t\u0500ACDFRTUVar\u087e\u08a9\u08b1\u08e0\u08e6\u08fc\u092f\u095b\u0390\u096a\u0100nr\u0883\u088fgleBracket;\u67e8row\u0180;BR\u0899\u089a\u089e\u6190ar;\u61e4ightArrow;\u61c6eiling;\u6308o\u01f5\u08b7\0\u08c3bleBracket;\u67e6n\u01d4\u08c8\0\u08d2eeVector;\u6961ector\u0100;B\u08db\u08dc\u61c3ar;\u6959loor;\u630aight\u0100AV\u08ef\u08f5rrow;\u6194ector;\u694e\u0100er\u0901\u0917e\u0180;AV\u0909\u090a\u0910\u62a3rrow;\u61a4ector;\u695aiangle\u0180;BE\u0924\u0925\u0929\u62b2ar;\u69cfqual;\u62b4p\u0180DTV\u0937\u0942\u094cownVector;\u6951eeVector;\u6960ector\u0100;B\u0956\u0957\u61bfar;\u6958ector\u0100;B\u0965\u0966\u61bcar;\u6952ight\xe1\u039cs\u0300EFGLST\u097e\u098b\u0995\u099d\u09a2\u09adqualGreater;\u62daullEqual;\u6266reater;\u6276ess;\u6aa1lantEqual;\u6a7dilde;\u6272r;\uc000\ud835\udd0f\u0100;e\u09bd\u09be\u62d8ftarrow;\u61daidot;\u413f\u0180npw\u09d4\u0a16\u0a1bg\u0200LRlr\u09de\u09f7\u0a02\u0a10eft\u0100AR\u09e6\u09ecrrow;\u67f5ightArrow;\u67f7ightArrow;\u67f6eft\u0100ar\u03b3\u0a0aight\xe1\u03bfight\xe1\u03caf;\uc000\ud835\udd43er\u0100LR\u0a22\u0a2ceftArrow;\u6199ightArrow;\u6198\u0180cht\u0a3e\u0a40\u0a42\xf2\u084c;\u61b0rok;\u4141;\u626a\u0400acefiosu\u0a5a\u0a5d\u0a60\u0a77\u0a7c\u0a85\u0a8b\u0a8ep;\u6905y;\u441c\u0100dl\u0a65\u0a6fiumSpace;\u605flintrf;\u6133r;\uc000\ud835\udd10nusPlus;\u6213pf;\uc000\ud835\udd44c\xf2\u0a76;\u439c\u0480Jacefostu\u0aa3\u0aa7\u0aad\u0ac0\u0b14\u0b19\u0d91\u0d97\u0d9ecy;\u440acute;\u4143\u0180aey\u0ab4\u0ab9\u0aberon;\u4147dil;\u4145;\u441d\u0180gsw\u0ac7\u0af0\u0b0eative\u0180MTV\u0ad3\u0adf\u0ae8ediumSpace;\u600bhi\u0100cn\u0ae6\u0ad8\xeb\u0ad9eryThi\xee\u0ad9ted\u0100GL\u0af8\u0b06reaterGreate\xf2\u0673essLes\xf3\u0a48Line;\u400ar;\uc000\ud835\udd11\u0200Bnpt\u0b22\u0b28\u0b37\u0b3areak;\u6060BreakingSpace;\u40a0f;\u6115\u0680;CDEGHLNPRSTV\u0b55\u0b56\u0b6a\u0b7c\u0ba1\u0beb\u0c04\u0c5e\u0c84\u0ca6\u0cd8\u0d61\u0d85\u6aec\u0100ou\u0b5b\u0b64ngruent;\u6262pCap;\u626doubleVerticalBar;\u6226\u0180lqx\u0b83\u0b8a\u0b9bement;\u6209ual\u0100;T\u0b92\u0b93\u6260ilde;\uc000\u2242\u0338ists;\u6204reater\u0380;EFGLST\u0bb6\u0bb7\u0bbd\u0bc9\u0bd3\u0bd8\u0be5\u626fqual;\u6271ullEqual;\uc000\u2267\u0338reater;\uc000\u226b\u0338ess;\u6279lantEqual;\uc000\u2a7e\u0338ilde;\u6275ump\u0144\u0bf2\u0bfdownHump;\uc000\u224e\u0338qual;\uc000\u224f\u0338e\u0100fs\u0c0a\u0c27tTriangle\u0180;BE\u0c1a\u0c1b\u0c21\u62eaar;\uc000\u29cf\u0338qual;\u62ecs\u0300;EGLST\u0c35\u0c36\u0c3c\u0c44\u0c4b\u0c58\u626equal;\u6270reater;\u6278ess;\uc000\u226a\u0338lantEqual;\uc000\u2a7d\u0338ilde;\u6274ested\u0100GL\u0c68\u0c79reaterGreater;\uc000\u2aa2\u0338essLess;\uc000\u2aa1\u0338recedes\u0180;ES\u0c92\u0c93\u0c9b\u6280qual;\uc000\u2aaf\u0338lantEqual;\u62e0\u0100ei\u0cab\u0cb9verseElement;\u620cghtTriangle\u0180;BE\u0ccb\u0ccc\u0cd2\u62ebar;\uc000\u29d0\u0338qual;\u62ed\u0100qu\u0cdd\u0d0cuareSu\u0100bp\u0ce8\u0cf9set\u0100;E\u0cf0\u0cf3\uc000\u228f\u0338qual;\u62e2erset\u0100;E\u0d03\u0d06\uc000\u2290\u0338qual;\u62e3\u0180bcp\u0d13\u0d24\u0d4eset\u0100;E\u0d1b\u0d1e\uc000\u2282\u20d2qual;\u6288ceeds\u0200;EST\u0d32\u0d33\u0d3b\u0d46\u6281qual;\uc000\u2ab0\u0338lantEqual;\u62e1ilde;\uc000\u227f\u0338erset\u0100;E\u0d58\u0d5b\uc000\u2283\u20d2qual;\u6289ilde\u0200;EFT\u0d6e\u0d6f\u0d75\u0d7f\u6241qual;\u6244ullEqual;\u6247ilde;\u6249erticalBar;\u6224cr;\uc000\ud835\udca9ilde\u803b\xd1\u40d1;\u439d\u0700Eacdfgmoprstuv\u0dbd\u0dc2\u0dc9\u0dd5\u0ddb\u0de0\u0de7\u0dfc\u0e02\u0e20\u0e22\u0e32\u0e3f\u0e44lig;\u4152cute\u803b\xd3\u40d3\u0100iy\u0dce\u0dd3rc\u803b\xd4\u40d4;\u441eblac;\u4150r;\uc000\ud835\udd12rave\u803b\xd2\u40d2\u0180aei\u0dee\u0df2\u0df6cr;\u414cga;\u43a9cron;\u439fpf;\uc000\ud835\udd46enCurly\u0100DQ\u0e0e\u0e1aoubleQuote;\u601cuote;\u6018;\u6a54\u0100cl\u0e27\u0e2cr;\uc000\ud835\udcaaash\u803b\xd8\u40d8i\u016c\u0e37\u0e3cde\u803b\xd5\u40d5es;\u6a37ml\u803b\xd6\u40d6er\u0100BP\u0e4b\u0e60\u0100ar\u0e50\u0e53r;\u603eac\u0100ek\u0e5a\u0e5c;\u63deet;\u63b4arenthesis;\u63dc\u0480acfhilors\u0e7f\u0e87\u0e8a\u0e8f\u0e92\u0e94\u0e9d\u0eb0\u0efcrtialD;\u6202y;\u441fr;\uc000\ud835\udd13i;\u43a6;\u43a0usMinus;\u40b1\u0100ip\u0ea2\u0eadncareplan\xe5\u069df;\u6119\u0200;eio\u0eb9\u0eba\u0ee0\u0ee4\u6abbcedes\u0200;EST\u0ec8\u0ec9\u0ecf\u0eda\u627aqual;\u6aaflantEqual;\u627cilde;\u627eme;\u6033\u0100dp\u0ee9\u0eeeuct;\u620fortion\u0100;a\u0225\u0ef9l;\u621d\u0100ci\u0f01\u0f06r;\uc000\ud835\udcab;\u43a8\u0200Ufos\u0f11\u0f16\u0f1b\u0f1fOT\u803b"\u4022r;\uc000\ud835\udd14pf;\u611acr;\uc000\ud835\udcac\u0600BEacefhiorsu\u0f3e\u0f43\u0f47\u0f60\u0f73\u0fa7\u0faa\u0fad\u1096\u10a9\u10b4\u10bearr;\u6910G\u803b\xae\u40ae\u0180cnr\u0f4e\u0f53\u0f56ute;\u4154g;\u67ebr\u0100;t\u0f5c\u0f5d\u61a0l;\u6916\u0180aey\u0f67\u0f6c\u0f71ron;\u4158dil;\u4156;\u4420\u0100;v\u0f78\u0f79\u611cerse\u0100EU\u0f82\u0f99\u0100lq\u0f87\u0f8eement;\u620builibrium;\u61cbpEquilibrium;\u696fr\xbb\u0f79o;\u43a1ght\u0400ACDFTUVa\u0fc1\u0feb\u0ff3\u1022\u1028\u105b\u1087\u03d8\u0100nr\u0fc6\u0fd2gleBracket;\u67e9row\u0180;BL\u0fdc\u0fdd\u0fe1\u6192ar;\u61e5eftArrow;\u61c4eiling;\u6309o\u01f5\u0ff9\0\u1005bleBracket;\u67e7n\u01d4\u100a\0\u1014eeVector;\u695dector\u0100;B\u101d\u101e\u61c2ar;\u6955loor;\u630b\u0100er\u102d\u1043e\u0180;AV\u1035\u1036\u103c\u62a2rrow;\u61a6ector;\u695biangle\u0180;BE\u1050\u1051\u1055\u62b3ar;\u69d0qual;\u62b5p\u0180DTV\u1063\u106e\u1078ownVector;\u694feeVector;\u695cector\u0100;B\u1082\u1083\u61bear;\u6954ector\u0100;B\u1091\u1092\u61c0ar;\u6953\u0100pu\u109b\u109ef;\u611dndImplies;\u6970ightarrow;\u61db\u0100ch\u10b9\u10bcr;\u611b;\u61b1leDelayed;\u69f4\u0680HOacfhimoqstu\u10e4\u10f1\u10f7\u10fd\u1119\u111e\u1151\u1156\u1161\u1167\u11b5\u11bb\u11bf\u0100Cc\u10e9\u10eeHcy;\u4429y;\u4428FTcy;\u442ccute;\u415a\u0280;aeiy\u1108\u1109\u110e\u1113\u1117\u6abcron;\u4160dil;\u415erc;\u415c;\u4421r;\uc000\ud835\udd16ort\u0200DLRU\u112a\u1134\u113e\u1149ownArrow\xbb\u041eeftArrow\xbb\u089aightArrow\xbb\u0fddpArrow;\u6191gma;\u43a3allCircle;\u6218pf;\uc000\ud835\udd4a\u0272\u116d\0\0\u1170t;\u621aare\u0200;ISU\u117b\u117c\u1189\u11af\u65a1ntersection;\u6293u\u0100bp\u118f\u119eset\u0100;E\u1197\u1198\u628fqual;\u6291erset\u0100;E\u11a8\u11a9\u6290qual;\u6292nion;\u6294cr;\uc000\ud835\udcaear;\u62c6\u0200bcmp\u11c8\u11db\u1209\u120b\u0100;s\u11cd\u11ce\u62d0et\u0100;E\u11cd\u11d5qual;\u6286\u0100ch\u11e0\u1205eeds\u0200;EST\u11ed\u11ee\u11f4\u11ff\u627bqual;\u6ab0lantEqual;\u627dilde;\u627fTh\xe1\u0f8c;\u6211\u0180;es\u1212\u1213\u1223\u62d1rset\u0100;E\u121c\u121d\u6283qual;\u6287et\xbb\u1213\u0580HRSacfhiors\u123e\u1244\u1249\u1255\u125e\u1271\u1276\u129f\u12c2\u12c8\u12d1ORN\u803b\xde\u40deADE;\u6122\u0100Hc\u124e\u1252cy;\u440by;\u4426\u0100bu\u125a\u125c;\u4009;\u43a4\u0180aey\u1265\u126a\u126fron;\u4164dil;\u4162;\u4422r;\uc000\ud835\udd17\u0100ei\u127b\u1289\u01f2\u1280\0\u1287efore;\u6234a;\u4398\u0100cn\u128e\u1298kSpace;\uc000\u205f\u200aSpace;\u6009lde\u0200;EFT\u12ab\u12ac\u12b2\u12bc\u623cqual;\u6243ullEqual;\u6245ilde;\u6248pf;\uc000\ud835\udd4bipleDot;\u60db\u0100ct\u12d6\u12dbr;\uc000\ud835\udcafrok;\u4166\u0ae1\u12f7\u130e\u131a\u1326\0\u132c\u1331\0\0\0\0\0\u1338\u133d\u1377\u1385\0\u13ff\u1404\u140a\u1410\u0100cr\u12fb\u1301ute\u803b\xda\u40dar\u0100;o\u1307\u1308\u619fcir;\u6949r\u01e3\u1313\0\u1316y;\u440eve;\u416c\u0100iy\u131e\u1323rc\u803b\xdb\u40db;\u4423blac;\u4170r;\uc000\ud835\udd18rave\u803b\xd9\u40d9acr;\u416a\u0100di\u1341\u1369er\u0100BP\u1348\u135d\u0100ar\u134d\u1350r;\u405fac\u0100ek\u1357\u1359;\u63dfet;\u63b5arenthesis;\u63ddon\u0100;P\u1370\u1371\u62c3lus;\u628e\u0100gp\u137b\u137fon;\u4172f;\uc000\ud835\udd4c\u0400ADETadps\u1395\u13ae\u13b8\u13c4\u03e8\u13d2\u13d7\u13f3rrow\u0180;BD\u1150\u13a0\u13a4ar;\u6912ownArrow;\u61c5ownArrow;\u6195quilibrium;\u696eee\u0100;A\u13cb\u13cc\u62a5rrow;\u61a5own\xe1\u03f3er\u0100LR\u13de\u13e8eftArrow;\u6196ightArrow;\u6197i\u0100;l\u13f9\u13fa\u43d2on;\u43a5ing;\u416ecr;\uc000\ud835\udcb0ilde;\u4168ml\u803b\xdc\u40dc\u0480Dbcdefosv\u1427\u142c\u1430\u1433\u143e\u1485\u148a\u1490\u1496ash;\u62abar;\u6aeby;\u4412ash\u0100;l\u143b\u143c\u62a9;\u6ae6\u0100er\u1443\u1445;\u62c1\u0180bty\u144c\u1450\u147aar;\u6016\u0100;i\u144f\u1455cal\u0200BLST\u1461\u1465\u146a\u1474ar;\u6223ine;\u407ceparator;\u6758ilde;\u6240ThinSpace;\u600ar;\uc000\ud835\udd19pf;\uc000\ud835\udd4dcr;\uc000\ud835\udcb1dash;\u62aa\u0280cefos\u14a7\u14ac\u14b1\u14b6\u14bcirc;\u4174dge;\u62c0r;\uc000\ud835\udd1apf;\uc000\ud835\udd4ecr;\uc000\ud835\udcb2\u0200fios\u14cb\u14d0\u14d2\u14d8r;\uc000\ud835\udd1b;\u439epf;\uc000\ud835\udd4fcr;\uc000\ud835\udcb3\u0480AIUacfosu\u14f1\u14f5\u14f9\u14fd\u1504\u150f\u1514\u151a\u1520cy;\u442fcy;\u4407cy;\u442ecute\u803b\xdd\u40dd\u0100iy\u1509\u150drc;\u4176;\u442br;\uc000\ud835\udd1cpf;\uc000\ud835\udd50cr;\uc000\ud835\udcb4ml;\u4178\u0400Hacdefos\u1535\u1539\u153f\u154b\u154f\u155d\u1560\u1564cy;\u4416cute;\u4179\u0100ay\u1544\u1549ron;\u417d;\u4417ot;\u417b\u01f2\u1554\0\u155boWidt\xe8\u0ad9a;\u4396r;\u6128pf;\u6124cr;\uc000\ud835\udcb5\u0be1\u1583\u158a\u1590\0\u15b0\u15b6\u15bf\0\0\0\0\u15c6\u15db\u15eb\u165f\u166d\0\u1695\u169b\u16b2\u16b9\0\u16becute\u803b\xe1\u40e1reve;\u4103\u0300;Ediuy\u159c\u159d\u15a1\u15a3\u15a8\u15ad\u623e;\uc000\u223e\u0333;\u623frc\u803b\xe2\u40e2te\u80bb\xb4\u0306;\u4430lig\u803b\xe6\u40e6\u0100;r\xb2\u15ba;\uc000\ud835\udd1erave\u803b\xe0\u40e0\u0100ep\u15ca\u15d6\u0100fp\u15cf\u15d4sym;\u6135\xe8\u15d3ha;\u43b1\u0100ap\u15dfc\u0100cl\u15e4\u15e7r;\u4101g;\u6a3f\u0264\u15f0\0\0\u160a\u0280;adsv\u15fa\u15fb\u15ff\u1601\u1607\u6227nd;\u6a55;\u6a5clope;\u6a58;\u6a5a\u0380;elmrsz\u1618\u1619\u161b\u161e\u163f\u164f\u1659\u6220;\u69a4e\xbb\u1619sd\u0100;a\u1625\u1626\u6221\u0461\u1630\u1632\u1634\u1636\u1638\u163a\u163c\u163e;\u69a8;\u69a9;\u69aa;\u69ab;\u69ac;\u69ad;\u69ae;\u69aft\u0100;v\u1645\u1646\u621fb\u0100;d\u164c\u164d\u62be;\u699d\u0100pt\u1654\u1657h;\u6222\xbb\xb9arr;\u637c\u0100gp\u1663\u1667on;\u4105f;\uc000\ud835\udd52\u0380;Eaeiop\u12c1\u167b\u167d\u1682\u1684\u1687\u168a;\u6a70cir;\u6a6f;\u624ad;\u624bs;\u4027rox\u0100;e\u12c1\u1692\xf1\u1683ing\u803b\xe5\u40e5\u0180cty\u16a1\u16a6\u16a8r;\uc000\ud835\udcb6;\u402amp\u0100;e\u12c1\u16af\xf1\u0288ilde\u803b\xe3\u40e3ml\u803b\xe4\u40e4\u0100ci\u16c2\u16c8onin\xf4\u0272nt;\u6a11\u0800Nabcdefiklnoprsu\u16ed\u16f1\u1730\u173c\u1743\u1748\u1778\u177d\u17e0\u17e6\u1839\u1850\u170d\u193d\u1948\u1970ot;\u6aed\u0100cr\u16f6\u171ek\u0200ceps\u1700\u1705\u170d\u1713ong;\u624cpsilon;\u43f6rime;\u6035im\u0100;e\u171a\u171b\u623dq;\u62cd\u0176\u1722\u1726ee;\u62bded\u0100;g\u172c\u172d\u6305e\xbb\u172drk\u0100;t\u135c\u1737brk;\u63b6\u0100oy\u1701\u1741;\u4431quo;\u601e\u0280cmprt\u1753\u175b\u1761\u1764\u1768aus\u0100;e\u010a\u0109ptyv;\u69b0s\xe9\u170cno\xf5\u0113\u0180ahw\u176f\u1771\u1773;\u43b2;\u6136een;\u626cr;\uc000\ud835\udd1fg\u0380costuvw\u178d\u179d\u17b3\u17c1\u17d5\u17db\u17de\u0180aiu\u1794\u1796\u179a\xf0\u0760rc;\u65efp\xbb\u1371\u0180dpt\u17a4\u17a8\u17adot;\u6a00lus;\u6a01imes;\u6a02\u0271\u17b9\0\0\u17becup;\u6a06ar;\u6605riangle\u0100du\u17cd\u17d2own;\u65bdp;\u65b3plus;\u6a04e\xe5\u1444\xe5\u14adarow;\u690d\u0180ako\u17ed\u1826\u1835\u0100cn\u17f2\u1823k\u0180lst\u17fa\u05ab\u1802ozenge;\u69ebriangle\u0200;dlr\u1812\u1813\u1818\u181d\u65b4own;\u65beeft;\u65c2ight;\u65b8k;\u6423\u01b1\u182b\0\u1833\u01b2\u182f\0\u1831;\u6592;\u65914;\u6593ck;\u6588\u0100eo\u183e\u184d\u0100;q\u1843\u1846\uc000=\u20e5uiv;\uc000\u2261\u20e5t;\u6310\u0200ptwx\u1859\u185e\u1867\u186cf;\uc000\ud835\udd53\u0100;t\u13cb\u1863om\xbb\u13cctie;\u62c8\u0600DHUVbdhmptuv\u1885\u1896\u18aa\u18bb\u18d7\u18db\u18ec\u18ff\u1905\u190a\u1910\u1921\u0200LRlr\u188e\u1890\u1892\u1894;\u6557;\u6554;\u6556;\u6553\u0280;DUdu\u18a1\u18a2\u18a4\u18a6\u18a8\u6550;\u6566;\u6569;\u6564;\u6567\u0200LRlr\u18b3\u18b5\u18b7\u18b9;\u655d;\u655a;\u655c;\u6559\u0380;HLRhlr\u18ca\u18cb\u18cd\u18cf\u18d1\u18d3\u18d5\u6551;\u656c;\u6563;\u6560;\u656b;\u6562;\u655fox;\u69c9\u0200LRlr\u18e4\u18e6\u18e8\u18ea;\u6555;\u6552;\u6510;\u650c\u0280;DUdu\u06bd\u18f7\u18f9\u18fb\u18fd;\u6565;\u6568;\u652c;\u6534inus;\u629flus;\u629eimes;\u62a0\u0200LRlr\u1919\u191b\u191d\u191f;\u655b;\u6558;\u6518;\u6514\u0380;HLRhlr\u1930\u1931\u1933\u1935\u1937\u1939\u193b\u6502;\u656a;\u6561;\u655e;\u653c;\u6524;\u651c\u0100ev\u0123\u1942bar\u803b\xa6\u40a6\u0200ceio\u1951\u1956\u195a\u1960r;\uc000\ud835\udcb7mi;\u604fm\u0100;e\u171a\u171cl\u0180;bh\u1968\u1969\u196b\u405c;\u69c5sub;\u67c8\u016c\u1974\u197el\u0100;e\u1979\u197a\u6022t\xbb\u197ap\u0180;Ee\u012f\u1985\u1987;\u6aae\u0100;q\u06dc\u06db\u0ce1\u19a7\0\u19e8\u1a11\u1a15\u1a32\0\u1a37\u1a50\0\0\u1ab4\0\0\u1ac1\0\0\u1b21\u1b2e\u1b4d\u1b52\0\u1bfd\0\u1c0c\u0180cpr\u19ad\u19b2\u19ddute;\u4107\u0300;abcds\u19bf\u19c0\u19c4\u19ca\u19d5\u19d9\u6229nd;\u6a44rcup;\u6a49\u0100au\u19cf\u19d2p;\u6a4bp;\u6a47ot;\u6a40;\uc000\u2229\ufe00\u0100eo\u19e2\u19e5t;\u6041\xee\u0693\u0200aeiu\u19f0\u19fb\u1a01\u1a05\u01f0\u19f5\0\u19f8s;\u6a4don;\u410ddil\u803b\xe7\u40e7rc;\u4109ps\u0100;s\u1a0c\u1a0d\u6a4cm;\u6a50ot;\u410b\u0180dmn\u1a1b\u1a20\u1a26il\u80bb\xb8\u01adptyv;\u69b2t\u8100\xa2;e\u1a2d\u1a2e\u40a2r\xe4\u01b2r;\uc000\ud835\udd20\u0180cei\u1a3d\u1a40\u1a4dy;\u4447ck\u0100;m\u1a47\u1a48\u6713ark\xbb\u1a48;\u43c7r\u0380;Ecefms\u1a5f\u1a60\u1a62\u1a6b\u1aa4\u1aaa\u1aae\u65cb;\u69c3\u0180;el\u1a69\u1a6a\u1a6d\u42c6q;\u6257e\u0261\u1a74\0\0\u1a88rrow\u0100lr\u1a7c\u1a81eft;\u61baight;\u61bb\u0280RSacd\u1a92\u1a94\u1a96\u1a9a\u1a9f\xbb\u0f47;\u64c8st;\u629birc;\u629aash;\u629dnint;\u6a10id;\u6aefcir;\u69c2ubs\u0100;u\u1abb\u1abc\u6663it\xbb\u1abc\u02ec\u1ac7\u1ad4\u1afa\0\u1b0aon\u0100;e\u1acd\u1ace\u403a\u0100;q\xc7\xc6\u026d\u1ad9\0\0\u1ae2a\u0100;t\u1ade\u1adf\u402c;\u4040\u0180;fl\u1ae8\u1ae9\u1aeb\u6201\xee\u1160e\u0100mx\u1af1\u1af6ent\xbb\u1ae9e\xf3\u024d\u01e7\u1afe\0\u1b07\u0100;d\u12bb\u1b02ot;\u6a6dn\xf4\u0246\u0180fry\u1b10\u1b14\u1b17;\uc000\ud835\udd54o\xe4\u0254\u8100\xa9;s\u0155\u1b1dr;\u6117\u0100ao\u1b25\u1b29rr;\u61b5ss;\u6717\u0100cu\u1b32\u1b37r;\uc000\ud835\udcb8\u0100bp\u1b3c\u1b44\u0100;e\u1b41\u1b42\u6acf;\u6ad1\u0100;e\u1b49\u1b4a\u6ad0;\u6ad2dot;\u62ef\u0380delprvw\u1b60\u1b6c\u1b77\u1b82\u1bac\u1bd4\u1bf9arr\u0100lr\u1b68\u1b6a;\u6938;\u6935\u0270\u1b72\0\0\u1b75r;\u62dec;\u62dfarr\u0100;p\u1b7f\u1b80\u61b6;\u693d\u0300;bcdos\u1b8f\u1b90\u1b96\u1ba1\u1ba5\u1ba8\u622arcap;\u6a48\u0100au\u1b9b\u1b9ep;\u6a46p;\u6a4aot;\u628dr;\u6a45;\uc000\u222a\ufe00\u0200alrv\u1bb5\u1bbf\u1bde\u1be3rr\u0100;m\u1bbc\u1bbd\u61b7;\u693cy\u0180evw\u1bc7\u1bd4\u1bd8q\u0270\u1bce\0\0\u1bd2re\xe3\u1b73u\xe3\u1b75ee;\u62ceedge;\u62cfen\u803b\xa4\u40a4earrow\u0100lr\u1bee\u1bf3eft\xbb\u1b80ight\xbb\u1bbde\xe4\u1bdd\u0100ci\u1c01\u1c07onin\xf4\u01f7nt;\u6231lcty;\u632d\u0980AHabcdefhijlorstuwz\u1c38\u1c3b\u1c3f\u1c5d\u1c69\u1c75\u1c8a\u1c9e\u1cac\u1cb7\u1cfb\u1cff\u1d0d\u1d7b\u1d91\u1dab\u1dbb\u1dc6\u1dcdr\xf2\u0381ar;\u6965\u0200glrs\u1c48\u1c4d\u1c52\u1c54ger;\u6020eth;\u6138\xf2\u1133h\u0100;v\u1c5a\u1c5b\u6010\xbb\u090a\u016b\u1c61\u1c67arow;\u690fa\xe3\u0315\u0100ay\u1c6e\u1c73ron;\u410f;\u4434\u0180;ao\u0332\u1c7c\u1c84\u0100gr\u02bf\u1c81r;\u61catseq;\u6a77\u0180glm\u1c91\u1c94\u1c98\u803b\xb0\u40b0ta;\u43b4ptyv;\u69b1\u0100ir\u1ca3\u1ca8sht;\u697f;\uc000\ud835\udd21ar\u0100lr\u1cb3\u1cb5\xbb\u08dc\xbb\u101e\u0280aegsv\u1cc2\u0378\u1cd6\u1cdc\u1ce0m\u0180;os\u0326\u1cca\u1cd4nd\u0100;s\u0326\u1cd1uit;\u6666amma;\u43ddin;\u62f2\u0180;io\u1ce7\u1ce8\u1cf8\u40f7de\u8100\xf7;o\u1ce7\u1cf0ntimes;\u62c7n\xf8\u1cf7cy;\u4452c\u026f\u1d06\0\0\u1d0arn;\u631eop;\u630d\u0280lptuw\u1d18\u1d1d\u1d22\u1d49\u1d55lar;\u4024f;\uc000\ud835\udd55\u0280;emps\u030b\u1d2d\u1d37\u1d3d\u1d42q\u0100;d\u0352\u1d33ot;\u6251inus;\u6238lus;\u6214quare;\u62a1blebarwedg\xe5\xfan\u0180adh\u112e\u1d5d\u1d67ownarrow\xf3\u1c83arpoon\u0100lr\u1d72\u1d76ef\xf4\u1cb4igh\xf4\u1cb6\u0162\u1d7f\u1d85karo\xf7\u0f42\u026f\u1d8a\0\0\u1d8ern;\u631fop;\u630c\u0180cot\u1d98\u1da3\u1da6\u0100ry\u1d9d\u1da1;\uc000\ud835\udcb9;\u4455l;\u69f6rok;\u4111\u0100dr\u1db0\u1db4ot;\u62f1i\u0100;f\u1dba\u1816\u65bf\u0100ah\u1dc0\u1dc3r\xf2\u0429a\xf2\u0fa6angle;\u69a6\u0100ci\u1dd2\u1dd5y;\u445fgrarr;\u67ff\u0900Dacdefglmnopqrstux\u1e01\u1e09\u1e19\u1e38\u0578\u1e3c\u1e49\u1e61\u1e7e\u1ea5\u1eaf\u1ebd\u1ee1\u1f2a\u1f37\u1f44\u1f4e\u1f5a\u0100Do\u1e06\u1d34o\xf4\u1c89\u0100cs\u1e0e\u1e14ute\u803b\xe9\u40e9ter;\u6a6e\u0200aioy\u1e22\u1e27\u1e31\u1e36ron;\u411br\u0100;c\u1e2d\u1e2e\u6256\u803b\xea\u40ealon;\u6255;\u444dot;\u4117\u0100Dr\u1e41\u1e45ot;\u6252;\uc000\ud835\udd22\u0180;rs\u1e50\u1e51\u1e57\u6a9aave\u803b\xe8\u40e8\u0100;d\u1e5c\u1e5d\u6a96ot;\u6a98\u0200;ils\u1e6a\u1e6b\u1e72\u1e74\u6a99nters;\u63e7;\u6113\u0100;d\u1e79\u1e7a\u6a95ot;\u6a97\u0180aps\u1e85\u1e89\u1e97cr;\u4113ty\u0180;sv\u1e92\u1e93\u1e95\u6205et\xbb\u1e93p\u01001;\u1e9d\u1ea4\u0133\u1ea1\u1ea3;\u6004;\u6005\u6003\u0100gs\u1eaa\u1eac;\u414bp;\u6002\u0100gp\u1eb4\u1eb8on;\u4119f;\uc000\ud835\udd56\u0180als\u1ec4\u1ece\u1ed2r\u0100;s\u1eca\u1ecb\u62d5l;\u69e3us;\u6a71i\u0180;lv\u1eda\u1edb\u1edf\u43b5on\xbb\u1edb;\u43f5\u0200csuv\u1eea\u1ef3\u1f0b\u1f23\u0100io\u1eef\u1e31rc\xbb\u1e2e\u0269\u1ef9\0\0\u1efb\xed\u0548ant\u0100gl\u1f02\u1f06tr\xbb\u1e5dess\xbb\u1e7a\u0180aei\u1f12\u1f16\u1f1als;\u403dst;\u625fv\u0100;D\u0235\u1f20D;\u6a78parsl;\u69e5\u0100Da\u1f2f\u1f33ot;\u6253rr;\u6971\u0180cdi\u1f3e\u1f41\u1ef8r;\u612fo\xf4\u0352\u0100ah\u1f49\u1f4b;\u43b7\u803b\xf0\u40f0\u0100mr\u1f53\u1f57l\u803b\xeb\u40ebo;\u60ac\u0180cip\u1f61\u1f64\u1f67l;\u4021s\xf4\u056e\u0100eo\u1f6c\u1f74ctatio\xee\u0559nential\xe5\u0579\u09e1\u1f92\0\u1f9e\0\u1fa1\u1fa7\0\0\u1fc6\u1fcc\0\u1fd3\0\u1fe6\u1fea\u2000\0\u2008\u205allingdotse\xf1\u1e44y;\u4444male;\u6640\u0180ilr\u1fad\u1fb3\u1fc1lig;\u8000\ufb03\u0269\u1fb9\0\0\u1fbdg;\u8000\ufb00ig;\u8000\ufb04;\uc000\ud835\udd23lig;\u8000\ufb01lig;\uc000fj\u0180alt\u1fd9\u1fdc\u1fe1t;\u666dig;\u8000\ufb02ns;\u65b1of;\u4192\u01f0\u1fee\0\u1ff3f;\uc000\ud835\udd57\u0100ak\u05bf\u1ff7\u0100;v\u1ffc\u1ffd\u62d4;\u6ad9artint;\u6a0d\u0100ao\u200c\u2055\u0100cs\u2011\u2052\u03b1\u201a\u2030\u2038\u2045\u2048\0\u2050\u03b2\u2022\u2025\u2027\u202a\u202c\0\u202e\u803b\xbd\u40bd;\u6153\u803b\xbc\u40bc;\u6155;\u6159;\u615b\u01b3\u2034\0\u2036;\u6154;\u6156\u02b4\u203e\u2041\0\0\u2043\u803b\xbe\u40be;\u6157;\u615c5;\u6158\u01b6\u204c\0\u204e;\u615a;\u615d8;\u615el;\u6044wn;\u6322cr;\uc000\ud835\udcbb\u0880Eabcdefgijlnorstv\u2082\u2089\u209f\u20a5\u20b0\u20b4\u20f0\u20f5\u20fa\u20ff\u2103\u2112\u2138\u0317\u213e\u2152\u219e\u0100;l\u064d\u2087;\u6a8c\u0180cmp\u2090\u2095\u209dute;\u41f5ma\u0100;d\u209c\u1cda\u43b3;\u6a86reve;\u411f\u0100iy\u20aa\u20aerc;\u411d;\u4433ot;\u4121\u0200;lqs\u063e\u0642\u20bd\u20c9\u0180;qs\u063e\u064c\u20c4lan\xf4\u0665\u0200;cdl\u0665\u20d2\u20d5\u20e5c;\u6aa9ot\u0100;o\u20dc\u20dd\u6a80\u0100;l\u20e2\u20e3\u6a82;\u6a84\u0100;e\u20ea\u20ed\uc000\u22db\ufe00s;\u6a94r;\uc000\ud835\udd24\u0100;g\u0673\u061bmel;\u6137cy;\u4453\u0200;Eaj\u065a\u210c\u210e\u2110;\u6a92;\u6aa5;\u6aa4\u0200Eaes\u211b\u211d\u2129\u2134;\u6269p\u0100;p\u2123\u2124\u6a8arox\xbb\u2124\u0100;q\u212e\u212f\u6a88\u0100;q\u212e\u211bim;\u62e7pf;\uc000\ud835\udd58\u0100ci\u2143\u2146r;\u610am\u0180;el\u066b\u214e\u2150;\u6a8e;\u6a90\u8300>;cdlqr\u05ee\u2160\u216a\u216e\u2173\u2179\u0100ci\u2165\u2167;\u6aa7r;\u6a7aot;\u62d7Par;\u6995uest;\u6a7c\u0280adels\u2184\u216a\u2190\u0656\u219b\u01f0\u2189\0\u218epro\xf8\u209er;\u6978q\u0100lq\u063f\u2196les\xf3\u2088i\xed\u066b\u0100en\u21a3\u21adrtneqq;\uc000\u2269\ufe00\xc5\u21aa\u0500Aabcefkosy\u21c4\u21c7\u21f1\u21f5\u21fa\u2218\u221d\u222f\u2268\u227dr\xf2\u03a0\u0200ilmr\u21d0\u21d4\u21d7\u21dbrs\xf0\u1484f\xbb\u2024il\xf4\u06a9\u0100dr\u21e0\u21e4cy;\u444a\u0180;cw\u08f4\u21eb\u21efir;\u6948;\u61adar;\u610firc;\u4125\u0180alr\u2201\u220e\u2213rts\u0100;u\u2209\u220a\u6665it\xbb\u220alip;\u6026con;\u62b9r;\uc000\ud835\udd25s\u0100ew\u2223\u2229arow;\u6925arow;\u6926\u0280amopr\u223a\u223e\u2243\u225e\u2263rr;\u61fftht;\u623bk\u0100lr\u2249\u2253eftarrow;\u61a9ightarrow;\u61aaf;\uc000\ud835\udd59bar;\u6015\u0180clt\u226f\u2274\u2278r;\uc000\ud835\udcbdas\xe8\u21f4rok;\u4127\u0100bp\u2282\u2287ull;\u6043hen\xbb\u1c5b\u0ae1\u22a3\0\u22aa\0\u22b8\u22c5\u22ce\0\u22d5\u22f3\0\0\u22f8\u2322\u2367\u2362\u237f\0\u2386\u23aa\u23b4cute\u803b\xed\u40ed\u0180;iy\u0771\u22b0\u22b5rc\u803b\xee\u40ee;\u4438\u0100cx\u22bc\u22bfy;\u4435cl\u803b\xa1\u40a1\u0100fr\u039f\u22c9;\uc000\ud835\udd26rave\u803b\xec\u40ec\u0200;ino\u073e\u22dd\u22e9\u22ee\u0100in\u22e2\u22e6nt;\u6a0ct;\u622dfin;\u69dcta;\u6129lig;\u4133\u0180aop\u22fe\u231a\u231d\u0180cgt\u2305\u2308\u2317r;\u412b\u0180elp\u071f\u230f\u2313in\xe5\u078ear\xf4\u0720h;\u4131f;\u62b7ed;\u41b5\u0280;cfot\u04f4\u232c\u2331\u233d\u2341are;\u6105in\u0100;t\u2338\u2339\u621eie;\u69dddo\xf4\u2319\u0280;celp\u0757\u234c\u2350\u235b\u2361al;\u62ba\u0100gr\u2355\u2359er\xf3\u1563\xe3\u234darhk;\u6a17rod;\u6a3c\u0200cgpt\u236f\u2372\u2376\u237by;\u4451on;\u412ff;\uc000\ud835\udd5aa;\u43b9uest\u803b\xbf\u40bf\u0100ci\u238a\u238fr;\uc000\ud835\udcben\u0280;Edsv\u04f4\u239b\u239d\u23a1\u04f3;\u62f9ot;\u62f5\u0100;v\u23a6\u23a7\u62f4;\u62f3\u0100;i\u0777\u23aelde;\u4129\u01eb\u23b8\0\u23bccy;\u4456l\u803b\xef\u40ef\u0300cfmosu\u23cc\u23d7\u23dc\u23e1\u23e7\u23f5\u0100iy\u23d1\u23d5rc;\u4135;\u4439r;\uc000\ud835\udd27ath;\u4237pf;\uc000\ud835\udd5b\u01e3\u23ec\0\u23f1r;\uc000\ud835\udcbfrcy;\u4458kcy;\u4454\u0400acfghjos\u240b\u2416\u2422\u2427\u242d\u2431\u2435\u243bppa\u0100;v\u2413\u2414\u43ba;\u43f0\u0100ey\u241b\u2420dil;\u4137;\u443ar;\uc000\ud835\udd28reen;\u4138cy;\u4445cy;\u445cpf;\uc000\ud835\udd5ccr;\uc000\ud835\udcc0\u0b80ABEHabcdefghjlmnoprstuv\u2470\u2481\u2486\u248d\u2491\u250e\u253d\u255a\u2580\u264e\u265e\u2665\u2679\u267d\u269a\u26b2\u26d8\u275d\u2768\u278b\u27c0\u2801\u2812\u0180art\u2477\u247a\u247cr\xf2\u09c6\xf2\u0395ail;\u691barr;\u690e\u0100;g\u0994\u248b;\u6a8bar;\u6962\u0963\u24a5\0\u24aa\0\u24b1\0\0\0\0\0\u24b5\u24ba\0\u24c6\u24c8\u24cd\0\u24f9ute;\u413amptyv;\u69b4ra\xee\u084cbda;\u43bbg\u0180;dl\u088e\u24c1\u24c3;\u6991\xe5\u088e;\u6a85uo\u803b\xab\u40abr\u0400;bfhlpst\u0899\u24de\u24e6\u24e9\u24eb\u24ee\u24f1\u24f5\u0100;f\u089d\u24e3s;\u691fs;\u691d\xeb\u2252p;\u61abl;\u6939im;\u6973l;\u61a2\u0180;ae\u24ff\u2500\u2504\u6aabil;\u6919\u0100;s\u2509\u250a\u6aad;\uc000\u2aad\ufe00\u0180abr\u2515\u2519\u251drr;\u690crk;\u6772\u0100ak\u2522\u252cc\u0100ek\u2528\u252a;\u407b;\u405b\u0100es\u2531\u2533;\u698bl\u0100du\u2539\u253b;\u698f;\u698d\u0200aeuy\u2546\u254b\u2556\u2558ron;\u413e\u0100di\u2550\u2554il;\u413c\xec\u08b0\xe2\u2529;\u443b\u0200cqrs\u2563\u2566\u256d\u257da;\u6936uo\u0100;r\u0e19\u1746\u0100du\u2572\u2577har;\u6967shar;\u694bh;\u61b2\u0280;fgqs\u258b\u258c\u0989\u25f3\u25ff\u6264t\u0280ahlrt\u2598\u25a4\u25b7\u25c2\u25e8rrow\u0100;t\u0899\u25a1a\xe9\u24f6arpoon\u0100du\u25af\u25b4own\xbb\u045ap\xbb\u0966eftarrows;\u61c7ight\u0180ahs\u25cd\u25d6\u25derrow\u0100;s\u08f4\u08a7arpoon\xf3\u0f98quigarro\xf7\u21f0hreetimes;\u62cb\u0180;qs\u258b\u0993\u25falan\xf4\u09ac\u0280;cdgs\u09ac\u260a\u260d\u261d\u2628c;\u6aa8ot\u0100;o\u2614\u2615\u6a7f\u0100;r\u261a\u261b\u6a81;\u6a83\u0100;e\u2622\u2625\uc000\u22da\ufe00s;\u6a93\u0280adegs\u2633\u2639\u263d\u2649\u264bppro\xf8\u24c6ot;\u62d6q\u0100gq\u2643\u2645\xf4\u0989gt\xf2\u248c\xf4\u099bi\xed\u09b2\u0180ilr\u2655\u08e1\u265asht;\u697c;\uc000\ud835\udd29\u0100;E\u099c\u2663;\u6a91\u0161\u2669\u2676r\u0100du\u25b2\u266e\u0100;l\u0965\u2673;\u696alk;\u6584cy;\u4459\u0280;acht\u0a48\u2688\u268b\u2691\u2696r\xf2\u25c1orne\xf2\u1d08ard;\u696bri;\u65fa\u0100io\u269f\u26a4dot;\u4140ust\u0100;a\u26ac\u26ad\u63b0che\xbb\u26ad\u0200Eaes\u26bb\u26bd\u26c9\u26d4;\u6268p\u0100;p\u26c3\u26c4\u6a89rox\xbb\u26c4\u0100;q\u26ce\u26cf\u6a87\u0100;q\u26ce\u26bbim;\u62e6\u0400abnoptwz\u26e9\u26f4\u26f7\u271a\u272f\u2741\u2747\u2750\u0100nr\u26ee\u26f1g;\u67ecr;\u61fdr\xeb\u08c1g\u0180lmr\u26ff\u270d\u2714eft\u0100ar\u09e6\u2707ight\xe1\u09f2apsto;\u67fcight\xe1\u09fdparrow\u0100lr\u2725\u2729ef\xf4\u24edight;\u61ac\u0180afl\u2736\u2739\u273dr;\u6985;\uc000\ud835\udd5dus;\u6a2dimes;\u6a34\u0161\u274b\u274fst;\u6217\xe1\u134e\u0180;ef\u2757\u2758\u1800\u65cange\xbb\u2758ar\u0100;l\u2764\u2765\u4028t;\u6993\u0280achmt\u2773\u2776\u277c\u2785\u2787r\xf2\u08a8orne\xf2\u1d8car\u0100;d\u0f98\u2783;\u696d;\u600eri;\u62bf\u0300achiqt\u2798\u279d\u0a40\u27a2\u27ae\u27bbquo;\u6039r;\uc000\ud835\udcc1m\u0180;eg\u09b2\u27aa\u27ac;\u6a8d;\u6a8f\u0100bu\u252a\u27b3o\u0100;r\u0e1f\u27b9;\u601arok;\u4142\u8400<;cdhilqr\u082b\u27d2\u2639\u27dc\u27e0\u27e5\u27ea\u27f0\u0100ci\u27d7\u27d9;\u6aa6r;\u6a79re\xe5\u25f2mes;\u62c9arr;\u6976uest;\u6a7b\u0100Pi\u27f5\u27f9ar;\u6996\u0180;ef\u2800\u092d\u181b\u65c3r\u0100du\u2807\u280dshar;\u694ahar;\u6966\u0100en\u2817\u2821rtneqq;\uc000\u2268\ufe00\xc5\u281e\u0700Dacdefhilnopsu\u2840\u2845\u2882\u288e\u2893\u28a0\u28a5\u28a8\u28da\u28e2\u28e4\u0a83\u28f3\u2902Dot;\u623a\u0200clpr\u284e\u2852\u2863\u287dr\u803b\xaf\u40af\u0100et\u2857\u2859;\u6642\u0100;e\u285e\u285f\u6720se\xbb\u285f\u0100;s\u103b\u2868to\u0200;dlu\u103b\u2873\u2877\u287bow\xee\u048cef\xf4\u090f\xf0\u13d1ker;\u65ae\u0100oy\u2887\u288cmma;\u6a29;\u443cash;\u6014asuredangle\xbb\u1626r;\uc000\ud835\udd2ao;\u6127\u0180cdn\u28af\u28b4\u28c9ro\u803b\xb5\u40b5\u0200;acd\u1464\u28bd\u28c0\u28c4s\xf4\u16a7ir;\u6af0ot\u80bb\xb7\u01b5us\u0180;bd\u28d2\u1903\u28d3\u6212\u0100;u\u1d3c\u28d8;\u6a2a\u0163\u28de\u28e1p;\u6adb\xf2\u2212\xf0\u0a81\u0100dp\u28e9\u28eeels;\u62a7f;\uc000\ud835\udd5e\u0100ct\u28f8\u28fdr;\uc000\ud835\udcc2pos\xbb\u159d\u0180;lm\u2909\u290a\u290d\u43bctimap;\u62b8\u0c00GLRVabcdefghijlmoprstuvw\u2942\u2953\u297e\u2989\u2998\u29da\u29e9\u2a15\u2a1a\u2a58\u2a5d\u2a83\u2a95\u2aa4\u2aa8\u2b04\u2b07\u2b44\u2b7f\u2bae\u2c34\u2c67\u2c7c\u2ce9\u0100gt\u2947\u294b;\uc000\u22d9\u0338\u0100;v\u2950\u0bcf\uc000\u226b\u20d2\u0180elt\u295a\u2972\u2976ft\u0100ar\u2961\u2967rrow;\u61cdightarrow;\u61ce;\uc000\u22d8\u0338\u0100;v\u297b\u0c47\uc000\u226a\u20d2ightarrow;\u61cf\u0100Dd\u298e\u2993ash;\u62afash;\u62ae\u0280bcnpt\u29a3\u29a7\u29ac\u29b1\u29ccla\xbb\u02deute;\u4144g;\uc000\u2220\u20d2\u0280;Eiop\u0d84\u29bc\u29c0\u29c5\u29c8;\uc000\u2a70\u0338d;\uc000\u224b\u0338s;\u4149ro\xf8\u0d84ur\u0100;a\u29d3\u29d4\u666el\u0100;s\u29d3\u0b38\u01f3\u29df\0\u29e3p\u80bb\xa0\u0b37mp\u0100;e\u0bf9\u0c00\u0280aeouy\u29f4\u29fe\u2a03\u2a10\u2a13\u01f0\u29f9\0\u29fb;\u6a43on;\u4148dil;\u4146ng\u0100;d\u0d7e\u2a0aot;\uc000\u2a6d\u0338p;\u6a42;\u443dash;\u6013\u0380;Aadqsx\u0b92\u2a29\u2a2d\u2a3b\u2a41\u2a45\u2a50rr;\u61d7r\u0100hr\u2a33\u2a36k;\u6924\u0100;o\u13f2\u13f0ot;\uc000\u2250\u0338ui\xf6\u0b63\u0100ei\u2a4a\u2a4ear;\u6928\xed\u0b98ist\u0100;s\u0ba0\u0b9fr;\uc000\ud835\udd2b\u0200Eest\u0bc5\u2a66\u2a79\u2a7c\u0180;qs\u0bbc\u2a6d\u0be1\u0180;qs\u0bbc\u0bc5\u2a74lan\xf4\u0be2i\xed\u0bea\u0100;r\u0bb6\u2a81\xbb\u0bb7\u0180Aap\u2a8a\u2a8d\u2a91r\xf2\u2971rr;\u61aear;\u6af2\u0180;sv\u0f8d\u2a9c\u0f8c\u0100;d\u2aa1\u2aa2\u62fc;\u62facy;\u445a\u0380AEadest\u2ab7\u2aba\u2abe\u2ac2\u2ac5\u2af6\u2af9r\xf2\u2966;\uc000\u2266\u0338rr;\u619ar;\u6025\u0200;fqs\u0c3b\u2ace\u2ae3\u2aeft\u0100ar\u2ad4\u2ad9rro\xf7\u2ac1ightarro\xf7\u2a90\u0180;qs\u0c3b\u2aba\u2aealan\xf4\u0c55\u0100;s\u0c55\u2af4\xbb\u0c36i\xed\u0c5d\u0100;r\u0c35\u2afei\u0100;e\u0c1a\u0c25i\xe4\u0d90\u0100pt\u2b0c\u2b11f;\uc000\ud835\udd5f\u8180\xac;in\u2b19\u2b1a\u2b36\u40acn\u0200;Edv\u0b89\u2b24\u2b28\u2b2e;\uc000\u22f9\u0338ot;\uc000\u22f5\u0338\u01e1\u0b89\u2b33\u2b35;\u62f7;\u62f6i\u0100;v\u0cb8\u2b3c\u01e1\u0cb8\u2b41\u2b43;\u62fe;\u62fd\u0180aor\u2b4b\u2b63\u2b69r\u0200;ast\u0b7b\u2b55\u2b5a\u2b5flle\xec\u0b7bl;\uc000\u2afd\u20e5;\uc000\u2202\u0338lint;\u6a14\u0180;ce\u0c92\u2b70\u2b73u\xe5\u0ca5\u0100;c\u0c98\u2b78\u0100;e\u0c92\u2b7d\xf1\u0c98\u0200Aait\u2b88\u2b8b\u2b9d\u2ba7r\xf2\u2988rr\u0180;cw\u2b94\u2b95\u2b99\u619b;\uc000\u2933\u0338;\uc000\u219d\u0338ghtarrow\xbb\u2b95ri\u0100;e\u0ccb\u0cd6\u0380chimpqu\u2bbd\u2bcd\u2bd9\u2b04\u0b78\u2be4\u2bef\u0200;cer\u0d32\u2bc6\u0d37\u2bc9u\xe5\u0d45;\uc000\ud835\udcc3ort\u026d\u2b05\0\0\u2bd6ar\xe1\u2b56m\u0100;e\u0d6e\u2bdf\u0100;q\u0d74\u0d73su\u0100bp\u2beb\u2bed\xe5\u0cf8\xe5\u0d0b\u0180bcp\u2bf6\u2c11\u2c19\u0200;Ees\u2bff\u2c00\u0d22\u2c04\u6284;\uc000\u2ac5\u0338et\u0100;e\u0d1b\u2c0bq\u0100;q\u0d23\u2c00c\u0100;e\u0d32\u2c17\xf1\u0d38\u0200;Ees\u2c22\u2c23\u0d5f\u2c27\u6285;\uc000\u2ac6\u0338et\u0100;e\u0d58\u2c2eq\u0100;q\u0d60\u2c23\u0200gilr\u2c3d\u2c3f\u2c45\u2c47\xec\u0bd7lde\u803b\xf1\u40f1\xe7\u0c43iangle\u0100lr\u2c52\u2c5ceft\u0100;e\u0c1a\u2c5a\xf1\u0c26ight\u0100;e\u0ccb\u2c65\xf1\u0cd7\u0100;m\u2c6c\u2c6d\u43bd\u0180;es\u2c74\u2c75\u2c79\u4023ro;\u6116p;\u6007\u0480DHadgilrs\u2c8f\u2c94\u2c99\u2c9e\u2ca3\u2cb0\u2cb6\u2cd3\u2ce3ash;\u62adarr;\u6904p;\uc000\u224d\u20d2ash;\u62ac\u0100et\u2ca8\u2cac;\uc000\u2265\u20d2;\uc000>\u20d2nfin;\u69de\u0180Aet\u2cbd\u2cc1\u2cc5rr;\u6902;\uc000\u2264\u20d2\u0100;r\u2cca\u2ccd\uc000<\u20d2ie;\uc000\u22b4\u20d2\u0100At\u2cd8\u2cdcrr;\u6903rie;\uc000\u22b5\u20d2im;\uc000\u223c\u20d2\u0180Aan\u2cf0\u2cf4\u2d02rr;\u61d6r\u0100hr\u2cfa\u2cfdk;\u6923\u0100;o\u13e7\u13e5ear;\u6927\u1253\u1a95\0\0\0\0\0\0\0\0\0\0\0\0\0\u2d2d\0\u2d38\u2d48\u2d60\u2d65\u2d72\u2d84\u1b07\0\0\u2d8d\u2dab\0\u2dc8\u2dce\0\u2ddc\u2e19\u2e2b\u2e3e\u2e43\u0100cs\u2d31\u1a97ute\u803b\xf3\u40f3\u0100iy\u2d3c\u2d45r\u0100;c\u1a9e\u2d42\u803b\xf4\u40f4;\u443e\u0280abios\u1aa0\u2d52\u2d57\u01c8\u2d5alac;\u4151v;\u6a38old;\u69bclig;\u4153\u0100cr\u2d69\u2d6dir;\u69bf;\uc000\ud835\udd2c\u036f\u2d79\0\0\u2d7c\0\u2d82n;\u42dbave\u803b\xf2\u40f2;\u69c1\u0100bm\u2d88\u0df4ar;\u69b5\u0200acit\u2d95\u2d98\u2da5\u2da8r\xf2\u1a80\u0100ir\u2d9d\u2da0r;\u69beoss;\u69bbn\xe5\u0e52;\u69c0\u0180aei\u2db1\u2db5\u2db9cr;\u414dga;\u43c9\u0180cdn\u2dc0\u2dc5\u01cdron;\u43bf;\u69b6pf;\uc000\ud835\udd60\u0180ael\u2dd4\u2dd7\u01d2r;\u69b7rp;\u69b9\u0380;adiosv\u2dea\u2deb\u2dee\u2e08\u2e0d\u2e10\u2e16\u6228r\xf2\u1a86\u0200;efm\u2df7\u2df8\u2e02\u2e05\u6a5dr\u0100;o\u2dfe\u2dff\u6134f\xbb\u2dff\u803b\xaa\u40aa\u803b\xba\u40bagof;\u62b6r;\u6a56lope;\u6a57;\u6a5b\u0180clo\u2e1f\u2e21\u2e27\xf2\u2e01ash\u803b\xf8\u40f8l;\u6298i\u016c\u2e2f\u2e34de\u803b\xf5\u40f5es\u0100;a\u01db\u2e3as;\u6a36ml\u803b\xf6\u40f6bar;\u633d\u0ae1\u2e5e\0\u2e7d\0\u2e80\u2e9d\0\u2ea2\u2eb9\0\0\u2ecb\u0e9c\0\u2f13\0\0\u2f2b\u2fbc\0\u2fc8r\u0200;ast\u0403\u2e67\u2e72\u0e85\u8100\xb6;l\u2e6d\u2e6e\u40b6le\xec\u0403\u0269\u2e78\0\0\u2e7bm;\u6af3;\u6afdy;\u443fr\u0280cimpt\u2e8b\u2e8f\u2e93\u1865\u2e97nt;\u4025od;\u402eil;\u6030enk;\u6031r;\uc000\ud835\udd2d\u0180imo\u2ea8\u2eb0\u2eb4\u0100;v\u2ead\u2eae\u43c6;\u43d5ma\xf4\u0a76ne;\u660e\u0180;tv\u2ebf\u2ec0\u2ec8\u43c0chfork\xbb\u1ffd;\u43d6\u0100au\u2ecf\u2edfn\u0100ck\u2ed5\u2eddk\u0100;h\u21f4\u2edb;\u610e\xf6\u21f4s\u0480;abcdemst\u2ef3\u2ef4\u1908\u2ef9\u2efd\u2f04\u2f06\u2f0a\u2f0e\u402bcir;\u6a23ir;\u6a22\u0100ou\u1d40\u2f02;\u6a25;\u6a72n\u80bb\xb1\u0e9dim;\u6a26wo;\u6a27\u0180ipu\u2f19\u2f20\u2f25ntint;\u6a15f;\uc000\ud835\udd61nd\u803b\xa3\u40a3\u0500;Eaceinosu\u0ec8\u2f3f\u2f41\u2f44\u2f47\u2f81\u2f89\u2f92\u2f7e\u2fb6;\u6ab3p;\u6ab7u\xe5\u0ed9\u0100;c\u0ece\u2f4c\u0300;acens\u0ec8\u2f59\u2f5f\u2f66\u2f68\u2f7eppro\xf8\u2f43urlye\xf1\u0ed9\xf1\u0ece\u0180aes\u2f6f\u2f76\u2f7approx;\u6ab9qq;\u6ab5im;\u62e8i\xed\u0edfme\u0100;s\u2f88\u0eae\u6032\u0180Eas\u2f78\u2f90\u2f7a\xf0\u2f75\u0180dfp\u0eec\u2f99\u2faf\u0180als\u2fa0\u2fa5\u2faalar;\u632eine;\u6312urf;\u6313\u0100;t\u0efb\u2fb4\xef\u0efbrel;\u62b0\u0100ci\u2fc0\u2fc5r;\uc000\ud835\udcc5;\u43c8ncsp;\u6008\u0300fiopsu\u2fda\u22e2\u2fdf\u2fe5\u2feb\u2ff1r;\uc000\ud835\udd2epf;\uc000\ud835\udd62rime;\u6057cr;\uc000\ud835\udcc6\u0180aeo\u2ff8\u3009\u3013t\u0100ei\u2ffe\u3005rnion\xf3\u06b0nt;\u6a16st\u0100;e\u3010\u3011\u403f\xf1\u1f19\xf4\u0f14\u0a80ABHabcdefhilmnoprstux\u3040\u3051\u3055\u3059\u30e0\u310e\u312b\u3147\u3162\u3172\u318e\u3206\u3215\u3224\u3229\u3258\u326e\u3272\u3290\u32b0\u32b7\u0180art\u3047\u304a\u304cr\xf2\u10b3\xf2\u03ddail;\u691car\xf2\u1c65ar;\u6964\u0380cdenqrt\u3068\u3075\u3078\u307f\u308f\u3094\u30cc\u0100eu\u306d\u3071;\uc000\u223d\u0331te;\u4155i\xe3\u116emptyv;\u69b3g\u0200;del\u0fd1\u3089\u308b\u308d;\u6992;\u69a5\xe5\u0fd1uo\u803b\xbb\u40bbr\u0580;abcfhlpstw\u0fdc\u30ac\u30af\u30b7\u30b9\u30bc\u30be\u30c0\u30c3\u30c7\u30cap;\u6975\u0100;f\u0fe0\u30b4s;\u6920;\u6933s;\u691e\xeb\u225d\xf0\u272el;\u6945im;\u6974l;\u61a3;\u619d\u0100ai\u30d1\u30d5il;\u691ao\u0100;n\u30db\u30dc\u6236al\xf3\u0f1e\u0180abr\u30e7\u30ea\u30eer\xf2\u17e5rk;\u6773\u0100ak\u30f3\u30fdc\u0100ek\u30f9\u30fb;\u407d;\u405d\u0100es\u3102\u3104;\u698cl\u0100du\u310a\u310c;\u698e;\u6990\u0200aeuy\u3117\u311c\u3127\u3129ron;\u4159\u0100di\u3121\u3125il;\u4157\xec\u0ff2\xe2\u30fa;\u4440\u0200clqs\u3134\u3137\u313d\u3144a;\u6937dhar;\u6969uo\u0100;r\u020e\u020dh;\u61b3\u0180acg\u314e\u315f\u0f44l\u0200;ips\u0f78\u3158\u315b\u109cn\xe5\u10bbar\xf4\u0fa9t;\u65ad\u0180ilr\u3169\u1023\u316esht;\u697d;\uc000\ud835\udd2f\u0100ao\u3177\u3186r\u0100du\u317d\u317f\xbb\u047b\u0100;l\u1091\u3184;\u696c\u0100;v\u318b\u318c\u43c1;\u43f1\u0180gns\u3195\u31f9\u31fcht\u0300ahlrst\u31a4\u31b0\u31c2\u31d8\u31e4\u31eerrow\u0100;t\u0fdc\u31ada\xe9\u30c8arpoon\u0100du\u31bb\u31bfow\xee\u317ep\xbb\u1092eft\u0100ah\u31ca\u31d0rrow\xf3\u0feaarpoon\xf3\u0551ightarrows;\u61c9quigarro\xf7\u30cbhreetimes;\u62ccg;\u42daingdotse\xf1\u1f32\u0180ahm\u320d\u3210\u3213r\xf2\u0feaa\xf2\u0551;\u600foust\u0100;a\u321e\u321f\u63b1che\xbb\u321fmid;\u6aee\u0200abpt\u3232\u323d\u3240\u3252\u0100nr\u3237\u323ag;\u67edr;\u61fer\xeb\u1003\u0180afl\u3247\u324a\u324er;\u6986;\uc000\ud835\udd63us;\u6a2eimes;\u6a35\u0100ap\u325d\u3267r\u0100;g\u3263\u3264\u4029t;\u6994olint;\u6a12ar\xf2\u31e3\u0200achq\u327b\u3280\u10bc\u3285quo;\u603ar;\uc000\ud835\udcc7\u0100bu\u30fb\u328ao\u0100;r\u0214\u0213\u0180hir\u3297\u329b\u32a0re\xe5\u31f8mes;\u62cai\u0200;efl\u32aa\u1059\u1821\u32ab\u65b9tri;\u69celuhar;\u6968;\u611e\u0d61\u32d5\u32db\u32df\u332c\u3338\u3371\0\u337a\u33a4\0\0\u33ec\u33f0\0\u3428\u3448\u345a\u34ad\u34b1\u34ca\u34f1\0\u3616\0\0\u3633cute;\u415bqu\xef\u27ba\u0500;Eaceinpsy\u11ed\u32f3\u32f5\u32ff\u3302\u330b\u330f\u331f\u3326\u3329;\u6ab4\u01f0\u32fa\0\u32fc;\u6ab8on;\u4161u\xe5\u11fe\u0100;d\u11f3\u3307il;\u415frc;\u415d\u0180Eas\u3316\u3318\u331b;\u6ab6p;\u6abaim;\u62e9olint;\u6a13i\xed\u1204;\u4441ot\u0180;be\u3334\u1d47\u3335\u62c5;\u6a66\u0380Aacmstx\u3346\u334a\u3357\u335b\u335e\u3363\u336drr;\u61d8r\u0100hr\u3350\u3352\xeb\u2228\u0100;o\u0a36\u0a34t\u803b\xa7\u40a7i;\u403bwar;\u6929m\u0100in\u3369\xf0nu\xf3\xf1t;\u6736r\u0100;o\u3376\u2055\uc000\ud835\udd30\u0200acoy\u3382\u3386\u3391\u33a0rp;\u666f\u0100hy\u338b\u338fcy;\u4449;\u4448rt\u026d\u3399\0\0\u339ci\xe4\u1464ara\xec\u2e6f\u803b\xad\u40ad\u0100gm\u33a8\u33b4ma\u0180;fv\u33b1\u33b2\u33b2\u43c3;\u43c2\u0400;deglnpr\u12ab\u33c5\u33c9\u33ce\u33d6\u33de\u33e1\u33e6ot;\u6a6a\u0100;q\u12b1\u12b0\u0100;E\u33d3\u33d4\u6a9e;\u6aa0\u0100;E\u33db\u33dc\u6a9d;\u6a9fe;\u6246lus;\u6a24arr;\u6972ar\xf2\u113d\u0200aeit\u33f8\u3408\u340f\u3417\u0100ls\u33fd\u3404lsetm\xe9\u336ahp;\u6a33parsl;\u69e4\u0100dl\u1463\u3414e;\u6323\u0100;e\u341c\u341d\u6aaa\u0100;s\u3422\u3423\u6aac;\uc000\u2aac\ufe00\u0180flp\u342e\u3433\u3442tcy;\u444c\u0100;b\u3438\u3439\u402f\u0100;a\u343e\u343f\u69c4r;\u633ff;\uc000\ud835\udd64a\u0100dr\u344d\u0402es\u0100;u\u3454\u3455\u6660it\xbb\u3455\u0180csu\u3460\u3479\u349f\u0100au\u3465\u346fp\u0100;s\u1188\u346b;\uc000\u2293\ufe00p\u0100;s\u11b4\u3475;\uc000\u2294\ufe00u\u0100bp\u347f\u348f\u0180;es\u1197\u119c\u3486et\u0100;e\u1197\u348d\xf1\u119d\u0180;es\u11a8\u11ad\u3496et\u0100;e\u11a8\u349d\xf1\u11ae\u0180;af\u117b\u34a6\u05b0r\u0165\u34ab\u05b1\xbb\u117car\xf2\u1148\u0200cemt\u34b9\u34be\u34c2\u34c5r;\uc000\ud835\udcc8tm\xee\xf1i\xec\u3415ar\xe6\u11be\u0100ar\u34ce\u34d5r\u0100;f\u34d4\u17bf\u6606\u0100an\u34da\u34edight\u0100ep\u34e3\u34eapsilo\xee\u1ee0h\xe9\u2eafs\xbb\u2852\u0280bcmnp\u34fb\u355e\u1209\u358b\u358e\u0480;Edemnprs\u350e\u350f\u3511\u3515\u351e\u3523\u352c\u3531\u3536\u6282;\u6ac5ot;\u6abd\u0100;d\u11da\u351aot;\u6ac3ult;\u6ac1\u0100Ee\u3528\u352a;\u6acb;\u628alus;\u6abfarr;\u6979\u0180eiu\u353d\u3552\u3555t\u0180;en\u350e\u3545\u354bq\u0100;q\u11da\u350feq\u0100;q\u352b\u3528m;\u6ac7\u0100bp\u355a\u355c;\u6ad5;\u6ad3c\u0300;acens\u11ed\u356c\u3572\u3579\u357b\u3326ppro\xf8\u32faurlye\xf1\u11fe\xf1\u11f3\u0180aes\u3582\u3588\u331bppro\xf8\u331aq\xf1\u3317g;\u666a\u0680123;Edehlmnps\u35a9\u35ac\u35af\u121c\u35b2\u35b4\u35c0\u35c9\u35d5\u35da\u35df\u35e8\u35ed\u803b\xb9\u40b9\u803b\xb2\u40b2\u803b\xb3\u40b3;\u6ac6\u0100os\u35b9\u35bct;\u6abeub;\u6ad8\u0100;d\u1222\u35c5ot;\u6ac4s\u0100ou\u35cf\u35d2l;\u67c9b;\u6ad7arr;\u697bult;\u6ac2\u0100Ee\u35e4\u35e6;\u6acc;\u628blus;\u6ac0\u0180eiu\u35f4\u3609\u360ct\u0180;en\u121c\u35fc\u3602q\u0100;q\u1222\u35b2eq\u0100;q\u35e7\u35e4m;\u6ac8\u0100bp\u3611\u3613;\u6ad4;\u6ad6\u0180Aan\u361c\u3620\u362drr;\u61d9r\u0100hr\u3626\u3628\xeb\u222e\u0100;o\u0a2b\u0a29war;\u692alig\u803b\xdf\u40df\u0be1\u3651\u365d\u3660\u12ce\u3673\u3679\0\u367e\u36c2\0\0\0\0\0\u36db\u3703\0\u3709\u376c\0\0\0\u3787\u0272\u3656\0\0\u365bget;\u6316;\u43c4r\xeb\u0e5f\u0180aey\u3666\u366b\u3670ron;\u4165dil;\u4163;\u4442lrec;\u6315r;\uc000\ud835\udd31\u0200eiko\u3686\u369d\u36b5\u36bc\u01f2\u368b\0\u3691e\u01004f\u1284\u1281a\u0180;sv\u3698\u3699\u369b\u43b8ym;\u43d1\u0100cn\u36a2\u36b2k\u0100as\u36a8\u36aeppro\xf8\u12c1im\xbb\u12acs\xf0\u129e\u0100as\u36ba\u36ae\xf0\u12c1rn\u803b\xfe\u40fe\u01ec\u031f\u36c6\u22e7es\u8180\xd7;bd\u36cf\u36d0\u36d8\u40d7\u0100;a\u190f\u36d5r;\u6a31;\u6a30\u0180eps\u36e1\u36e3\u3700\xe1\u2a4d\u0200;bcf\u0486\u36ec\u36f0\u36f4ot;\u6336ir;\u6af1\u0100;o\u36f9\u36fc\uc000\ud835\udd65rk;\u6ada\xe1\u3362rime;\u6034\u0180aip\u370f\u3712\u3764d\xe5\u1248\u0380adempst\u3721\u374d\u3740\u3751\u3757\u375c\u375fngle\u0280;dlqr\u3730\u3731\u3736\u3740\u3742\u65b5own\xbb\u1dbbeft\u0100;e\u2800\u373e\xf1\u092e;\u625cight\u0100;e\u32aa\u374b\xf1\u105aot;\u65ecinus;\u6a3alus;\u6a39b;\u69cdime;\u6a3bezium;\u63e2\u0180cht\u3772\u377d\u3781\u0100ry\u3777\u377b;\uc000\ud835\udcc9;\u4446cy;\u445brok;\u4167\u0100io\u378b\u378ex\xf4\u1777head\u0100lr\u3797\u37a0eftarro\xf7\u084fightarrow\xbb\u0f5d\u0900AHabcdfghlmoprstuw\u37d0\u37d3\u37d7\u37e4\u37f0\u37fc\u380e\u381c\u3823\u3834\u3851\u385d\u386b\u38a9\u38cc\u38d2\u38ea\u38f6r\xf2\u03edar;\u6963\u0100cr\u37dc\u37e2ute\u803b\xfa\u40fa\xf2\u1150r\u01e3\u37ea\0\u37edy;\u445eve;\u416d\u0100iy\u37f5\u37farc\u803b\xfb\u40fb;\u4443\u0180abh\u3803\u3806\u380br\xf2\u13adlac;\u4171a\xf2\u13c3\u0100ir\u3813\u3818sht;\u697e;\uc000\ud835\udd32rave\u803b\xf9\u40f9\u0161\u3827\u3831r\u0100lr\u382c\u382e\xbb\u0957\xbb\u1083lk;\u6580\u0100ct\u3839\u384d\u026f\u383f\0\0\u384arn\u0100;e\u3845\u3846\u631cr\xbb\u3846op;\u630fri;\u65f8\u0100al\u3856\u385acr;\u416b\u80bb\xa8\u0349\u0100gp\u3862\u3866on;\u4173f;\uc000\ud835\udd66\u0300adhlsu\u114b\u3878\u387d\u1372\u3891\u38a0own\xe1\u13b3arpoon\u0100lr\u3888\u388cef\xf4\u382digh\xf4\u382fi\u0180;hl\u3899\u389a\u389c\u43c5\xbb\u13faon\xbb\u389aparrows;\u61c8\u0180cit\u38b0\u38c4\u38c8\u026f\u38b6\0\0\u38c1rn\u0100;e\u38bc\u38bd\u631dr\xbb\u38bdop;\u630eng;\u416fri;\u65f9cr;\uc000\ud835\udcca\u0180dir\u38d9\u38dd\u38e2ot;\u62f0lde;\u4169i\u0100;f\u3730\u38e8\xbb\u1813\u0100am\u38ef\u38f2r\xf2\u38a8l\u803b\xfc\u40fcangle;\u69a7\u0780ABDacdeflnoprsz\u391c\u391f\u3929\u392d\u39b5\u39b8\u39bd\u39df\u39e4\u39e8\u39f3\u39f9\u39fd\u3a01\u3a20r\xf2\u03f7ar\u0100;v\u3926\u3927\u6ae8;\u6ae9as\xe8\u03e1\u0100nr\u3932\u3937grt;\u699c\u0380eknprst\u34e3\u3946\u394b\u3952\u395d\u3964\u3996app\xe1\u2415othin\xe7\u1e96\u0180hir\u34eb\u2ec8\u3959op\xf4\u2fb5\u0100;h\u13b7\u3962\xef\u318d\u0100iu\u3969\u396dgm\xe1\u33b3\u0100bp\u3972\u3984setneq\u0100;q\u397d\u3980\uc000\u228a\ufe00;\uc000\u2acb\ufe00setneq\u0100;q\u398f\u3992\uc000\u228b\ufe00;\uc000\u2acc\ufe00\u0100hr\u399b\u399fet\xe1\u369ciangle\u0100lr\u39aa\u39afeft\xbb\u0925ight\xbb\u1051y;\u4432ash\xbb\u1036\u0180elr\u39c4\u39d2\u39d7\u0180;be\u2dea\u39cb\u39cfar;\u62bbq;\u625alip;\u62ee\u0100bt\u39dc\u1468a\xf2\u1469r;\uc000\ud835\udd33tr\xe9\u39aesu\u0100bp\u39ef\u39f1\xbb\u0d1c\xbb\u0d59pf;\uc000\ud835\udd67ro\xf0\u0efbtr\xe9\u39b4\u0100cu\u3a06\u3a0br;\uc000\ud835\udccb\u0100bp\u3a10\u3a18n\u0100Ee\u3980\u3a16\xbb\u397en\u0100Ee\u3992\u3a1e\xbb\u3990igzag;\u699a\u0380cefoprs\u3a36\u3a3b\u3a56\u3a5b\u3a54\u3a61\u3a6airc;\u4175\u0100di\u3a40\u3a51\u0100bg\u3a45\u3a49ar;\u6a5fe\u0100;q\u15fa\u3a4f;\u6259erp;\u6118r;\uc000\ud835\udd34pf;\uc000\ud835\udd68\u0100;e\u1479\u3a66at\xe8\u1479cr;\uc000\ud835\udccc\u0ae3\u178e\u3a87\0\u3a8b\0\u3a90\u3a9b\0\0\u3a9d\u3aa8\u3aab\u3aaf\0\0\u3ac3\u3ace\0\u3ad8\u17dc\u17dftr\xe9\u17d1r;\uc000\ud835\udd35\u0100Aa\u3a94\u3a97r\xf2\u03c3r\xf2\u09f6;\u43be\u0100Aa\u3aa1\u3aa4r\xf2\u03b8r\xf2\u09eba\xf0\u2713is;\u62fb\u0180dpt\u17a4\u3ab5\u3abe\u0100fl\u3aba\u17a9;\uc000\ud835\udd69im\xe5\u17b2\u0100Aa\u3ac7\u3acar\xf2\u03cer\xf2\u0a01\u0100cq\u3ad2\u17b8r;\uc000\ud835\udccd\u0100pt\u17d6\u3adcr\xe9\u17d4\u0400acefiosu\u3af0\u3afd\u3b08\u3b0c\u3b11\u3b15\u3b1b\u3b21c\u0100uy\u3af6\u3afbte\u803b\xfd\u40fd;\u444f\u0100iy\u3b02\u3b06rc;\u4177;\u444bn\u803b\xa5\u40a5r;\uc000\ud835\udd36cy;\u4457pf;\uc000\ud835\udd6acr;\uc000\ud835\udcce\u0100cm\u3b26\u3b29y;\u444el\u803b\xff\u40ff\u0500acdefhiosw\u3b42\u3b48\u3b54\u3b58\u3b64\u3b69\u3b6d\u3b74\u3b7a\u3b80cute;\u417a\u0100ay\u3b4d\u3b52ron;\u417e;\u4437ot;\u417c\u0100et\u3b5d\u3b61tr\xe6\u155fa;\u43b6r;\uc000\ud835\udd37cy;\u4436grarr;\u61ddpf;\uc000\ud835\udd6bcr;\uc000\ud835\udccf\u0100jn\u3b85\u3b87;\u600dj;\u600c'.split("").map(t=>t.charCodeAt(0))),q=new Uint16Array("\u0200aglq\t\x15\x18\x1b\u026d\x0f\0\0\x12p;\u4026os;\u4027t;\u403et;\u403cuot;\u4022".split("").map(t=>t.charCodeAt(0))),B=new Map([[0,65533],[128,8364],[130,8218],[131,402],[132,8222],[133,8230],[134,8224],[135,8225],[136,710],[137,8240],[138,352],[139,8249],[140,338],[142,381],[145,8216],[146,8217],[147,8220],[148,8221],[149,8226],[150,8211],[151,8212],[152,732],[153,8482],[154,353],[155,8250],[156,339],[158,382],[159,376]]),L=null!==(C=String.fromCodePoint)&&void 0!==C?C:function(t){let e="";return t>65535&&(t-=65536,e+=String.fromCharCode(t>>>10&1023|55296),t=56320|1023&t),e+=String.fromCharCode(t),e};!function(t){t[t.NUM=35]="NUM",t[t.SEMI=59]="SEMI",t[t.EQUALS=61]="EQUALS",t[t.ZERO=48]="ZERO",t[t.NINE=57]="NINE",t[t.LOWER_A=97]="LOWER_A",t[t.LOWER_F=102]="LOWER_F",t[t.LOWER_X=120]="LOWER_X",t[t.LOWER_Z=122]="LOWER_Z",t[t.UPPER_A=65]="UPPER_A",t[t.UPPER_F=70]="UPPER_F",t[t.UPPER_Z=90]="UPPER_Z"}(y||(y={}));var I,M,T;function $(t){return t>=y.ZERO&&t<=y.NINE}function R(t){return t>=y.UPPER_A&&t<=y.UPPER_F||t>=y.LOWER_A&&t<=y.LOWER_F}function N(t){return t===y.EQUALS||function(t){return t>=y.UPPER_A&&t<=y.UPPER_Z||t>=y.LOWER_A&&t<=y.LOWER_Z||$(t)}(t)}!function(t){t[t.VALUE_LENGTH=49152]="VALUE_LENGTH",t[t.BRANCH_LENGTH=16256]="BRANCH_LENGTH",t[t.JUMP_TABLE=127]="JUMP_TABLE"}(I||(I={})),function(t){t[t.EntityStart=0]="EntityStart",t[t.NumericStart=1]="NumericStart",t[t.NumericDecimal=2]="NumericDecimal",t[t.NumericHex=3]="NumericHex",t[t.NamedEntity=4]="NamedEntity"}(M||(M={})),function(t){t[t.Legacy=0]="Legacy",t[t.Strict=1]="Strict",t[t.Attribute=2]="Attribute"}(T||(T={}));var P=class{constructor(t,e,r){this.decodeTree=t,this.emitCodePoint=e,this.errors=r,this.state=M.EntityStart,this.consumed=1,this.result=0,this.treeIndex=0,this.excess=1,this.decodeMode=T.Strict}startEntity(t){this.decodeMode=t,this.state=M.EntityStart,this.result=0,this.treeIndex=0,this.excess=1,this.consumed=1}write(t,e){switch(this.state){case M.EntityStart:return t.charCodeAt(e)===y.NUM?(this.state=M.NumericStart,this.consumed+=1,this.stateNumericStart(t,e+1)):(this.state=M.NamedEntity,this.stateNamedEntity(t,e));case M.NumericStart:return this.stateNumericStart(t,e);case M.NumericDecimal:return this.stateNumericDecimal(t,e);case M.NumericHex:return this.stateNumericHex(t,e);case M.NamedEntity:return this.stateNamedEntity(t,e)}}stateNumericStart(t,e){return e>=t.length?-1:(32|t.charCodeAt(e))===y.LOWER_X?(this.state=M.NumericHex,this.consumed+=1,this.stateNumericHex(t,e+1)):(this.state=M.NumericDecimal,this.stateNumericDecimal(t,e))}addToNumericResult(t,e,r,n){if(e!==r){const s=r-e;this.result=this.result*Math.pow(n,s)+parseInt(t.substr(e,s),n),this.consumed+=s}}stateNumericHex(t,e){const r=e;for(;e<t.length;){const n=t.charCodeAt(e);if(!$(n)&&!R(n))return this.addToNumericResult(t,r,e,16),this.emitNumericEntity(n,3);e+=1}return this.addToNumericResult(t,r,e,16),-1}stateNumericDecimal(t,e){const r=e;for(;e<t.length;){const n=t.charCodeAt(e);if(!$(n))return this.addToNumericResult(t,r,e,10),this.emitNumericEntity(n,2);e+=1}return this.addToNumericResult(t,r,e,10),-1}emitNumericEntity(t,e){var r;if(this.consumed<=e)return null===(r=this.errors)||void 0===r||r.absenceOfDigitsInNumericCharacterReference(this.consumed),0;if(t===y.SEMI)this.consumed+=1;else if(this.decodeMode===T.Strict)return 0;return this.emitCodePoint(function(t){var e;return t>=55296&&t<=57343||t>1114111?65533:null!==(e=B.get(t))&&void 0!==e?e:t}(this.result),this.consumed),this.errors&&(t!==y.SEMI&&this.errors.missingSemicolonAfterCharacterReference(),this.errors.validateNumericCharacterReference(this.result)),this.consumed}stateNamedEntity(t,e){const{decodeTree:r}=this;let n=r[this.treeIndex],s=(n&I.VALUE_LENGTH)>>14;for(;e<t.length;e++,this.excess++){const i=t.charCodeAt(e);if(this.treeIndex=Z(r,n,this.treeIndex+Math.max(1,s),i),this.treeIndex<0)return 0===this.result||this.decodeMode===T.Attribute&&(0===s||N(i))?0:this.emitNotTerminatedNamedEntity();if(n=r[this.treeIndex],s=(n&I.VALUE_LENGTH)>>14,0!==s){if(i===y.SEMI)return this.emitNamedEntityData(this.treeIndex,s,this.consumed+this.excess);this.decodeMode!==T.Strict&&(this.result=this.treeIndex,this.consumed+=this.excess,this.excess=0)}}return-1}emitNotTerminatedNamedEntity(){var t;const{result:e,decodeTree:r}=this,n=(r[e]&I.VALUE_LENGTH)>>14;return this.emitNamedEntityData(e,n,this.consumed),null===(t=this.errors)||void 0===t||t.missingSemicolonAfterCharacterReference(),this.consumed}emitNamedEntityData(t,e,r){const{decodeTree:n}=this;return this.emitCodePoint(1===e?n[t]&~I.VALUE_LENGTH:n[t+1],r),3===e&&this.emitCodePoint(n[t+2],r),r}end(){var t;switch(this.state){case M.NamedEntity:return 0===this.result||this.decodeMode===T.Attribute&&this.result!==this.treeIndex?0:this.emitNotTerminatedNamedEntity();case M.NumericDecimal:return this.emitNumericEntity(0,2);case M.NumericHex:return this.emitNumericEntity(0,3);case M.NumericStart:return null===(t=this.errors)||void 0===t||t.absenceOfDigitsInNumericCharacterReference(this.consumed),0;case M.EntityStart:return 0}}};function O(t){let e="";const r=new P(t,t=>e+=L(t));return function(t,n){let s=0,i=0;for(;(i=t.indexOf("&",i))>=0;){e+=t.slice(s,i),r.startEntity(n);const o=r.write(t,i+1);if(o<0){s=i+r.end();break}s=i+o,i=0===o?s+1:s}const o=e+t.slice(s);return e="",o}}function Z(t,e,r,n){const s=(e&I.BRANCH_LENGTH)>>7,i=e&I.JUMP_TABLE;if(0===s)return 0!==i&&n===i?r:-1;if(i){const e=n-i;return e<0||e>=s?-1:t[r+e]-1}let o=r,u=o+s-1;for(;o<=u;){const e=o+u>>>1,r=t[e];if(r<n)o=e+1;else{if(!(r>n))return t[e+s];u=e-1}}return-1}var j=O(S);O(q);var U=e({arrayReplaceAt:()=>J,asciiTrim:()=>kt,assign:()=>W,escapeHtml:()=>ct,escapeRE:()=>lt,fromCodePoint:()=>X,has:()=>G,isMdAsciiPunct:()=>mt,isPunctChar:()=>ft,isPunctCharCode:()=>dt,isSpace:()=>ht,isString:()=>H,isValidEntityCode:()=>Q,isWhiteSpace:()=>pt,lib:()=>Dt,normalizeReference:()=>_t,unescapeAll:()=>nt,unescapeMd:()=>rt});function H(t){return"[object String]"===function(t){return Object.prototype.toString.call(t)}(t)}var V=Object.prototype.hasOwnProperty;function G(t,e){return V.call(t,e)}function W(t){return Array.prototype.slice.call(arguments,1).forEach(function(e){if(e){if("object"!=typeof e)throw new TypeError(e+"must be object");Object.keys(e).forEach(function(r){t[r]=e[r]})}}),t}function J(t,e,r){return[].concat(t.slice(0,e),r,t.slice(e+1))}function Q(t){return!(t>=55296&&t<=57343)&&(!(t>=64976&&t<=65007)&&(!!(65535&~t&&65534!=(65535&t))&&(!(t>=0&&t<=8)&&(11!==t&&(!(t>=14&&t<=31)&&(!(t>=127&&t<=159)&&!(t>1114111)))))))}function X(t){if(t>65535){const e=55296+((t-=65536)>>10),r=56320+(1023&t);return String.fromCharCode(e,r)}return String.fromCharCode(t)}var Y=/\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g,K=new RegExp(Y.source+"|"+/&([a-z#][a-z0-9]{1,31});/gi.source,"gi"),tt=/^#((?:x[a-f0-9]{1,8}|[0-9]{1,8}))$/i;function et(t,e){if(35===e.charCodeAt(0)&&tt.test(e)){const r="x"===e[1].toLowerCase()?parseInt(e.slice(2),16):parseInt(e.slice(1),10);return Q(r)?X(r):t}const r=function(t,e=T.Legacy){return j(t,e)}(t);return r!==t?r:t}function rt(t){return t.indexOf("\\")<0?t:t.replace(Y,"$1")}function nt(t){return t.indexOf("\\")<0&&t.indexOf("&")<0?t:t.replace(K,function(t,e,r){return e||et(t,r)})}var st=/[&<>"]/,it=/[&<>"]/g,ot={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"};function ut(t){return ot[t]}function ct(t){return st.test(t)?t.replace(it,ut):t}var at=/[.?*+^$[\]\\(){}|-]/g;function lt(t){return t.replace(at,"\\$&")}function ht(t){switch(t){case 9:case 32:return!0}return!1}function pt(t){if(t>=8192&&t<=8202)return!0;switch(t){case 9:case 10:case 11:case 12:case 13:case 32:case 160:case 5760:case 8239:case 8287:case 12288:return!0}return!1}function ft(t){return x.test(t)||v.test(t)}function dt(t){return ft(X(t))}function mt(t){switch(t){case 33:case 34:case 35:case 36:case 37:case 38:case 39:case 40:case 41:case 42:case 43:case 44:case 45:case 46:case 47:case 58:case 59:case 60:case 61:case 62:case 63:case 64:case 91:case 92:case 93:case 94:case 95:case 96:case 123:case 124:case 125:case 126:return!0;default:return!1}}function _t(t){return t=t.trim().replace(/\s+/g," "),"\u1e7e"==="\u1e9e".toLowerCase()&&(t=t.replace(/\u1e9e/g,"\xdf")),t.toLowerCase().toUpperCase()}function gt(t){return 32===t||9===t||10===t||13===t}function kt(t){let e=0;for(;e<t.length&&gt(t.charCodeAt(e));e++);let r=t.length-1;for(;r>=e&&gt(t.charCodeAt(r));r--);return t.slice(e,r+1)}var Dt={mdurl:E,ucmicro:z};function Ct(t,e,r){let n,s,i,o;const u=t.posMax,c=t.pos;for(t.pos=e+1,n=1;t.pos<u;){if(i=t.src.charCodeAt(t.pos),93===i&&(n--,0===n)){s=!0;break}if(o=t.pos,t.md.inline.skipToken(t),91===i)if(o===t.pos-1)n++;else if(r)return t.pos=c,-1}let a=-1;return s&&(a=t.pos),t.pos=c,a}function yt(t,e,r){let n,s=e;const i={ok:!1,pos:0,str:""};if(60===t.charCodeAt(s)){for(s++;s<r;){if(n=t.charCodeAt(s),10===n)return i;if(60===n)return i;if(62===n)return i.pos=s+1,i.str=nt(t.slice(e+1,s)),i.ok=!0,i;92===n&&s+1<r?s+=2:s++}return i}let o=0;for(;s<r&&(n=t.charCodeAt(s),32!==n)&&!(n<32||127===n);)if(92===n&&s+1<r){if(32===t.charCodeAt(s+1))break;s+=2}else{if(40===n&&(o++,o>32))return i;if(41===n){if(0===o)break;o--}s++}return e===s||0!==o||(i.str=nt(t.slice(e,s)),i.pos=s,i.ok=!0),i}function Et(t,e,r,n){let s,i=e;const o={ok:!1,can_continue:!1,pos:0,str:"",marker:0};if(n)o.str=n.str,o.marker=n.marker;else{if(i>=r)return o;let n=t.charCodeAt(i);if(34!==n&&39!==n&&40!==n)return o;e++,i++,40===n&&(n=41),o.marker=n}for(;i<r;){if(s=t.charCodeAt(i),s===o.marker)return o.pos=i+1,o.str+=nt(t.slice(e,i)),o.ok=!0,o;if(40===s&&41===o.marker)return o;92===s&&i+1<r&&i++,i++}return o.can_continue=!0,o.str+=nt(t.slice(e,i)),o}var At=e({parseLinkDestination:()=>yt,parseLinkLabel:()=>Ct,parseLinkTitle:()=>Et}),bt={};function Ft(){this.rules=W({},bt)}function xt(){this.__rules__=[],this.__cache__=null}function vt(t,e,r){this.type=t,this.tag=e,this.attrs=null,this.map=null,this.nesting=r,this.level=0,this.children=null,this.content="",this.markup="",this.info="",this.meta=null,this.block=!1,this.hidden=!1}function wt(t,e,r){this.src=t,this.env=r,this.tokens=[],this.inlineMode=!1,this.md=e}bt.code_inline=function(t,e,r,n,s){const i=t[e];return"<code"+s.renderAttrs(i)+">"+ct(i.content)+"</code>"},bt.code_block=function(t,e,r,n,s){const i=t[e];return"<pre"+s.renderAttrs(i)+"><code>"+ct(t[e].content)+"</code></pre>\n"},bt.fence=function(t,e,r,n,s){const i=t[e],o=i.info?nt(i.info).trim():"";let u,c="",a="";if(o){const t=o.split(/(\s+)/g);c=t[0],a=t.slice(2).join("")}if(u=r.highlight&&r.highlight(i.content,c,a)||ct(i.content),0===u.indexOf("<pre"))return u+"\n";if(o){const t=i.attrIndex("class"),e=i.attrs?i.attrs.slice():[];t<0?e.push(["class",r.langPrefix+c]):(e[t]=e[t].slice(),e[t][1]+=" "+r.langPrefix+c);const n={attrs:e};return`<pre><code${s.renderAttrs(n)}>${u}</code></pre>\n`}return`<pre><code${s.renderAttrs(i)}>${u}</code></pre>\n`},bt.image=function(t,e,r,n,s){const i=t[e];return i.attrs[i.attrIndex("alt")][1]=s.renderInlineAsText(i.children,r,n),s.renderToken(t,e,r)},bt.hardbreak=function(t,e,r){return r.xhtmlOut?"<br />\n":"<br>\n"},bt.softbreak=function(t,e,r){return r.breaks?r.xhtmlOut?"<br />\n":"<br>\n":"\n"},bt.text=function(t,e){return ct(t[e].content)},bt.html_block=function(t,e){return t[e].content},bt.html_inline=function(t,e){return t[e].content},Ft.prototype.renderAttrs=function(t){let e,r,n;if(!t.attrs)return"";for(n="",e=0,r=t.attrs.length;e<r;e++)n+=" "+ct(t.attrs[e][0])+'="'+ct(t.attrs[e][1])+'"';return n},Ft.prototype.renderToken=function(t,e,r){const n=t[e];let s="";if(n.hidden)return"";n.block&&-1!==n.nesting&&e&&t[e-1].hidden&&(s+="\n"),s+=(-1===n.nesting?"</":"<")+n.tag,s+=this.renderAttrs(n),0===n.nesting&&r.xhtmlOut&&(s+=" /");let i=!1;if(n.block&&(i=!0,1===n.nesting&&e+1<t.length)){const r=t[e+1];("inline"===r.type||r.hidden||-1===r.nesting&&r.tag===n.tag)&&(i=!1)}return s+=i?">\n":">",s},Ft.prototype.renderInline=function(t,e,r){let n="";const s=this.rules;for(let i=0,o=t.length;i<o;i++){const o=t[i].type;void 0!==s[o]?n+=s[o](t,i,e,r,this):n+=this.renderToken(t,i,e)}return n},Ft.prototype.renderInlineAsText=function(t,e,r){let n="";for(let s=0,i=t.length;s<i;s++)switch(t[s].type){case"text":case"html_inline":case"html_block":n+=t[s].content;break;case"image":n+=this.renderInlineAsText(t[s].children,e,r);break;case"softbreak":case"hardbreak":n+="\n"}return n},Ft.prototype.render=function(t,e,r){let n="";const s=this.rules;for(let i=0,o=t.length;i<o;i++){const o=t[i].type;"inline"===o?n+=this.renderInline(t[i].children,e,r):void 0!==s[o]?n+=s[o](t,i,e,r,this):n+=this.renderToken(t,i,e,r)}return n},xt.prototype.__find__=function(t){for(let e=0;e<this.__rules__.length;e++)if(this.__rules__[e].name===t)return e;return-1},xt.prototype.__compile__=function(){const t=this,e=[""];t.__rules__.forEach(function(t){t.enabled&&t.alt.forEach(function(t){e.indexOf(t)<0&&e.push(t)})}),t.__cache__={},e.forEach(function(e){t.__cache__[e]=[],t.__rules__.forEach(function(r){r.enabled&&(e&&r.alt.indexOf(e)<0||t.__cache__[e].push(r.fn))})})},xt.prototype.at=function(t,e,r){const n=this.__find__(t),s=r||{};if(-1===n)throw new Error("Parser rule not found: "+t);this.__rules__[n].fn=e,this.__rules__[n].alt=s.alt||[],this.__cache__=null},xt.prototype.before=function(t,e,r,n){const s=this.__find__(t),i=n||{};if(-1===s)throw new Error("Parser rule not found: "+t);this.__rules__.splice(s,0,{name:e,enabled:!0,fn:r,alt:i.alt||[]}),this.__cache__=null},xt.prototype.after=function(t,e,r,n){const s=this.__find__(t),i=n||{};if(-1===s)throw new Error("Parser rule not found: "+t);this.__rules__.splice(s+1,0,{name:e,enabled:!0,fn:r,alt:i.alt||[]}),this.__cache__=null},xt.prototype.push=function(t,e,r){const n=r||{};this.__rules__.push({name:t,enabled:!0,fn:e,alt:n.alt||[]}),this.__cache__=null},xt.prototype.enable=function(t,e){Array.isArray(t)||(t=[t]);const r=[];return t.forEach(function(t){const n=this.__find__(t);if(n<0){if(e)return;throw new Error("Rules manager: invalid rule name "+t)}this.__rules__[n].enabled=!0,r.push(t)},this),this.__cache__=null,r},xt.prototype.enableOnly=function(t,e){Array.isArray(t)||(t=[t]),this.__rules__.forEach(function(t){t.enabled=!1}),this.enable(t,e)},xt.prototype.disable=function(t,e){Array.isArray(t)||(t=[t]);const r=[];return t.forEach(function(t){const n=this.__find__(t);if(n<0){if(e)return;throw new Error("Rules manager: invalid rule name "+t)}this.__rules__[n].enabled=!1,r.push(t)},this),this.__cache__=null,r},xt.prototype.getRules=function(t){return null===this.__cache__&&this.__compile__(),this.__cache__[t]||[]},vt.prototype.attrIndex=function(t){if(!this.attrs)return-1;const e=this.attrs;for(let r=0,n=e.length;r<n;r++)if(e[r][0]===t)return r;return-1},vt.prototype.attrPush=function(t){this.attrs?this.attrs.push(t):this.attrs=[t]},vt.prototype.attrSet=function(t,e){const r=this.attrIndex(t),n=[t,e];r<0?this.attrPush(n):this.attrs[r]=n},vt.prototype.attrGet=function(t){const e=this.attrIndex(t);let r=null;return e>=0&&(r=this.attrs[e][1]),r},vt.prototype.attrJoin=function(t,e){const r=this.attrIndex(t);r<0?this.attrPush([t,e]):this.attrs[r][1]=this.attrs[r][1]+" "+e},wt.prototype.Token=vt;var zt=/\r\n?|\n/g,St=/\0/g;function qt(t){return/^<a[>\s]/i.test(t)}function Bt(t){return/^<\/a\s*>/i.test(t)}var Lt=/\+-|\.\.|\?\?\?\?|!!!!|,,|--/,It=/\((c|tm|r)\)/i,Mt=/\((c|tm|r)\)/gi,Tt={c:"\xa9",r:"\xae",tm:"\u2122"};function $t(t,e){return Tt[e.toLowerCase()]}function Rt(t){let e=0;for(let r=t.length-1;r>=0;r--){const n=t[r];"text"!==n.type||e||(n.content=n.content.replace(Mt,$t)),"link_open"===n.type&&"auto"===n.info&&e--,"link_close"===n.type&&"auto"===n.info&&e++}}function Nt(t){let e=0;for(let r=t.length-1;r>=0;r--){const n=t[r];"text"!==n.type||e||Lt.test(n.content)&&(n.content=n.content.replace(/\+-/g,"\xb1").replace(/\.{2,}/g,"\u2026").replace(/([?!])\u2026/g,"$1..").replace(/([?!]){4,}/g,"$1$1$1").replace(/,{2,}/g,",").replace(/(^|[^-])---(?=[^-]|$)/gm,"$1\u2014").replace(/(^|\s)--(?=\s|$)/gm,"$1\u2013").replace(/(^|[^-\s])--(?=[^-\s]|$)/gm,"$1\u2013")),"link_open"===n.type&&"auto"===n.info&&e--,"link_close"===n.type&&"auto"===n.info&&e++}}var Pt=/['"]/,Ot=/['"]/g;function Zt(t,e,r,n){t[e]||(t[e]=[]),t[e].push({pos:r,ch:n})}function jt(t,e){let r;const n=[],s={};for(let i=0;i<t.length;i++){const o=t[i],u=t[i].level;for(r=n.length-1;r>=0&&!(n[r].level<=u);r--);if(n.length=r+1,"text"!==o.type)continue;const c=o.content;let a=0;const l=c.length;t:for(;a<l;){Ot.lastIndex=a;const o=Ot.exec(c);if(!o)break;let h=!0,p=!0;a=o.index+1;const f="'"===o[0];let d=32;if(o.index-1>=0)d=c.charCodeAt(o.index-1);else for(r=i-1;r>=0&&("softbreak"!==t[r].type&&"hardbreak"!==t[r].type);r--)if(t[r].content){d=t[r].content.charCodeAt(t[r].content.length-1);break}let m=32;if(a<l)m=c.charCodeAt(a);else for(r=i+1;r<t.length&&("softbreak"!==t[r].type&&"hardbreak"!==t[r].type);r++)if(t[r].content){m=t[r].content.charCodeAt(0);break}const _=mt(d)||dt(d),g=mt(m)||dt(m),k=pt(d),D=pt(m);if(D?h=!1:g&&(k||_||(h=!1)),k?p=!1:_&&(D||g||(p=!1)),34===m&&'"'===o[0]&&d>=48&&d<=57&&(p=h=!1),h&&p&&(h=_,p=g),h||p){if(p)for(r=n.length-1;r>=0;r--){let t=n[r];if(n[r].level<u)break;if(t.single===f&&n[r].level===u){let u,c;t=n[r],f?(u=e.md.options.quotes[2],c=e.md.options.quotes[3]):(u=e.md.options.quotes[0],c=e.md.options.quotes[1]),Zt(s,i,o.index,c),Zt(s,t.token,t.pos,u),n.length=r;continue t}}h?n.push({token:i,pos:o.index,single:f,level:u}):p&&f&&Zt(s,i,o.index,"\u2019")}else f&&Zt(s,i,o.index,"\u2019")}}Object.keys(s).forEach(function(e){t[e].content=function(t,e){let r="",n=0;e.sort((t,e)=>t.pos-e.pos);for(let s=0;s<e.length;s++){const i=e[s];r+=t.slice(n,i.pos)+i.ch,n=i.pos+1}return r+t.slice(n)}(t[e].content,s[e])})}var Ut=[["normalize",function(t){let e;e=t.src.replace(zt,"\n"),e=e.replace(St,"\ufffd"),t.src=e}],["block",function(t){let e;t.inlineMode?(e=new t.Token("inline","",0),e.content=t.src,e.map=[0,1],e.children=[],t.tokens.push(e)):t.md.block.parse(t.src,t.md,t.env,t.tokens)}],["inline",function(t){const e=t.tokens;for(let r=0,n=e.length;r<n;r++){const n=e[r];"inline"===n.type&&t.md.inline.parse(n.content,t.md,t.env,n.children)}}],["linkify",function(t){const e=t.tokens;if(t.md.options.linkify)for(let r=0,n=e.length;r<n;r++){if("inline"!==e[r].type||!t.md.linkify.pretest(e[r].content))continue;let n=e[r].children,s=0;for(let i=n.length-1;i>=0;i--){const o=n[i];if("link_close"!==o.type){if("html_inline"===o.type&&(qt(o.content)&&s>0&&s--,Bt(o.content)&&s++),!(s>0)&&"text"===o.type&&t.md.linkify.test(o.content)){const s=o.content;let u=t.md.linkify.match(s);const c=[];let a=o.level,l=0;u.length>0&&0===u[0].index&&i>0&&"text_special"===n[i-1].type&&(u=u.slice(1));for(let e=0;e<u.length;e++){const r=u[e].url,n=t.md.normalizeLink(r);if(!t.md.validateLink(n))continue;let i=u[e].text;i=u[e].schema?"mailto:"!==u[e].schema||/^mailto:/i.test(i)?t.md.normalizeLinkText(i):t.md.normalizeLinkText("mailto:"+i).replace(/^mailto:/,""):t.md.normalizeLinkText("http://"+i).replace(/^http:\/\//,"");const o=u[e].index;if(o>l){const e=new t.Token("text","",0);e.content=s.slice(l,o),e.level=a,c.push(e)}const h=new t.Token("link_open","a",1);h.attrs=[["href",n]],h.level=a++,h.markup="linkify",h.info="auto",c.push(h);const p=new t.Token("text","",0);p.content=i,p.level=a,c.push(p);const f=new t.Token("link_close","a",-1);f.level=--a,f.markup="linkify",f.info="auto",c.push(f),l=u[e].lastIndex}if(l<s.length){const e=new t.Token("text","",0);e.content=s.slice(l),e.level=a,c.push(e)}e[r].children=n=J(n,i,c)}}else for(i--;n[i].level!==o.level&&"link_open"!==n[i].type;)i--}}}],["replacements",function(t){let e;if(t.md.options.typographer)for(e=t.tokens.length-1;e>=0;e--)"inline"===t.tokens[e].type&&(It.test(t.tokens[e].content)&&Rt(t.tokens[e].children),Lt.test(t.tokens[e].content)&&Nt(t.tokens[e].children))}],["smartquotes",function(t){if(t.md.options.typographer)for(let e=t.tokens.length-1;e>=0;e--)"inline"===t.tokens[e].type&&Pt.test(t.tokens[e].content)&&jt(t.tokens[e].children,t)}],["text_join",function(t){let e,r;const n=t.tokens,s=n.length;for(let i=0;i<s;i++){if("inline"!==n[i].type)continue;const t=n[i].children,s=t.length;for(e=0;e<s;e++)"text_special"===t[e].type&&(t[e].type="text");for(e=r=0;e<s;e++)"text"===t[e].type&&e+1<s&&"text"===t[e+1].type?t[e+1].content=t[e].content+t[e+1].content:(e!==r&&(t[r]=t[e]),r++);e!==r&&(t.length=r)}}]];function Ht(){this.ruler=new xt;for(let t=0;t<Ut.length;t++)this.ruler.push(Ut[t][0],Ut[t][1])}function Vt(t,e,r,n){this.src=t,this.md=e,this.env=r,this.tokens=n,this.bMarks=[],this.eMarks=[],this.tShift=[],this.sCount=[],this.bsCount=[],this.blkIndent=0,this.line=0,this.lineMax=0,this.tight=!1,this.ddIndent=-1,this.listIndent=-1,this.parentType="root",this.level=0;const s=this.src;for(let i=0,o=0,u=0,c=0,a=s.length,l=!1;o<a;o++){const t=s.charCodeAt(o);if(!l){if(ht(t)){u++,9===t?c+=4-c%4:c++;continue}l=!0}10!==t&&o!==a-1||(10!==t&&o++,this.bMarks.push(i),this.eMarks.push(o),this.tShift.push(u),this.sCount.push(c),this.bsCount.push(0),l=!1,u=0,c=0,i=o+1)}this.bMarks.push(s.length),this.eMarks.push(s.length),this.tShift.push(0),this.sCount.push(0),this.bsCount.push(0),this.lineMax=this.bMarks.length-1}Ht.prototype.process=function(t){const e=this.ruler.getRules("");for(let r=0,n=e.length;r<n;r++)e[r](t)},Ht.prototype.State=wt,Vt.prototype.push=function(t,e,r){const n=new vt(t,e,r);return n.block=!0,r<0&&this.level--,n.level=this.level,r>0&&this.level++,this.tokens.push(n),n},Vt.prototype.isEmpty=function(t){return this.bMarks[t]+this.tShift[t]>=this.eMarks[t]},Vt.prototype.skipEmptyLines=function(t){for(let e=this.lineMax;t<e&&!(this.bMarks[t]+this.tShift[t]<this.eMarks[t]);t++);return t},Vt.prototype.skipSpaces=function(t){for(let e=this.src.length;t<e&&ht(this.src.charCodeAt(t));t++);return t},Vt.prototype.skipSpacesBack=function(t,e){if(t<=e)return t;for(;t>e;)if(!ht(this.src.charCodeAt(--t)))return t+1;return t},Vt.prototype.skipChars=function(t,e){for(let r=this.src.length;t<r&&this.src.charCodeAt(t)===e;t++);return t},Vt.prototype.skipCharsBack=function(t,e,r){if(t<=r)return t;for(;t>r;)if(e!==this.src.charCodeAt(--t))return t+1;return t},Vt.prototype.getLines=function(t,e,r,n){if(t>=e)return"";const s=new Array(e-t);for(let i=0,o=t;o<e;o++,i++){let t=0;const u=this.bMarks[o];let c,a=u;for(c=o+1<e||n?this.eMarks[o]+1:this.eMarks[o];a<c&&t<r;){const e=this.src.charCodeAt(a);if(ht(e))9===e?t+=4-(t+this.bsCount[o])%4:t++;else{if(!(a-u<this.tShift[o]))break;t++}a++}s[i]=t>r?new Array(t-r+1).join(" ")+this.src.slice(a,c):this.src.slice(a,c)}return s.join("")},Vt.prototype.Token=vt;function Gt(t,e){const r=t.bMarks[e]+t.tShift[e],n=t.eMarks[e];return t.src.slice(r,n)}function Wt(t){const e=[],r=t.length;let n=0,s=t.charCodeAt(n),i=!1,o=0,u="";for(;n<r;)124===s&&(i?(u+=t.substring(o,n-1),o=n):(e.push(u+t.substring(o,n)),u="",o=n+1)),i=92===s,n++,s=t.charCodeAt(n);return e.push(u+t.substring(o)),e}function Jt(t,e){const r=t.eMarks[e];let n=t.bMarks[e]+t.tShift[e];const s=t.src.charCodeAt(n++);return 42!==s&&45!==s&&43!==s||n<r&&!ht(t.src.charCodeAt(n))?-1:n}function Qt(t,e){const r=t.bMarks[e]+t.tShift[e],n=t.eMarks[e];let s=r;if(s+1>=n)return-1;let i=t.src.charCodeAt(s++);if(i<48||i>57)return-1;for(;;){if(s>=n)return-1;if(i=t.src.charCodeAt(s++),!(i>=48&&i<=57)){if(41===i||46===i)break;return-1}if(s-r>=10)return-1}return s<n&&(i=t.src.charCodeAt(s),!ht(i))?-1:s}var Xt=new RegExp("^(?:<[A-Za-z][A-Za-z0-9\\-]*(?:\\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\\s*=\\s*(?:[^\"'=<>`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*\\/?>|<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>|\x3c!---?>|\x3c!--(?:[^-]|-[^-]|--[^>])*--\x3e|<[?][\\s\\S]*?[?]>|<![A-Za-z][^>]*>|<!\\[CDATA\\[[\\s\\S]*?\\]\\]>)"),Yt=new RegExp("^(?:<[A-Za-z][A-Za-z0-9\\-]*(?:\\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\\s*=\\s*(?:[^\"'=<>`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*\\/?>|<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>)"),Kt=[[/^<(script|pre|style|textarea)(?=(\s|>|$))/i,/<\/(script|pre|style|textarea)>/i,!0],[/^<!--/,/-->/,!0],[/^<\?/,/\?>/,!0],[/^<![A-Z]/,/>/,!0],[/^<!\[CDATA\[/,/\]\]>/,!0],[new RegExp("^</?("+["address","article","aside","base","basefont","blockquote","body","caption","center","col","colgroup","dd","details","dialog","dir","div","dl","dt","fieldset","figcaption","figure","footer","form","frame","frameset","h1","h2","h3","h4","h5","h6","head","header","hr","html","iframe","legend","li","link","main","menu","menuitem","nav","noframes","ol","optgroup","option","p","param","search","section","summary","table","tbody","td","tfoot","th","thead","title","tr","track","ul"].join("|")+")(?=(\\s|/?>|$))","i"),/^$/,!0],[new RegExp(Yt.source+"\\s*$"),/^$/,!1]];var te=[["table",function(t,e,r,n){if(e+2>r)return!1;let s=e+1;if(t.sCount[s]<t.blkIndent)return!1;if(t.sCount[s]-t.blkIndent>=4)return!1;let i=t.bMarks[s]+t.tShift[s];if(i>=t.eMarks[s])return!1;const o=t.src.charCodeAt(i++);if(124!==o&&45!==o&&58!==o)return!1;if(i>=t.eMarks[s])return!1;const u=t.src.charCodeAt(i++);if(124!==u&&45!==u&&58!==u&&!ht(u))return!1;if(45===o&&ht(u))return!1;for(;i<t.eMarks[s];){const e=t.src.charCodeAt(i);if(124!==e&&45!==e&&58!==e&&!ht(e))return!1;i++}let c=Gt(t,e+1),a=c.split("|");const l=[];for(let g=0;g<a.length;g++){const t=a[g].trim();if(!t){if(0===g||g===a.length-1)continue;return!1}if(!/^:?-+:?$/.test(t))return!1;58===t.charCodeAt(t.length-1)?l.push(58===t.charCodeAt(0)?"center":"right"):58===t.charCodeAt(0)?l.push("left"):l.push("")}if(c=Gt(t,e).trim(),-1===c.indexOf("|"))return!1;if(t.sCount[e]-t.blkIndent>=4)return!1;a=Wt(c),a.length&&""===a[0]&&a.shift(),a.length&&""===a[a.length-1]&&a.pop();const h=a.length;if(0===h||h!==l.length)return!1;if(n)return!0;const p=t.parentType;t.parentType="table";const f=t.md.block.ruler.getRules("blockquote"),d=[e,0];t.push("table_open","table",1).map=d,t.push("thead_open","thead",1).map=[e,e+1],t.push("tr_open","tr",1).map=[e,e+1];for(let g=0;g<a.length;g++){const e=t.push("th_open","th",1);l[g]&&(e.attrs=[["style","text-align:"+l[g]]]);const r=t.push("inline","",0);r.content=a[g].trim(),r.children=[],t.push("th_close","th",-1)}let m;t.push("tr_close","tr",-1),t.push("thead_close","thead",-1);let _=0;for(s=e+2;s<r&&!(t.sCount[s]<t.blkIndent);s++){let n=!1;for(let e=0,i=f.length;e<i;e++)if(f[e](t,s,r,!0)){n=!0;break}if(n)break;if(c=Gt(t,s).trim(),!c)break;if(t.sCount[s]-t.blkIndent>=4)break;if(a=Wt(c),a.length&&""===a[0]&&a.shift(),a.length&&""===a[a.length-1]&&a.pop(),_+=h-a.length,_>65536)break;if(s===e+2){t.push("tbody_open","tbody",1).map=m=[e+2,0]}t.push("tr_open","tr",1).map=[s,s+1];for(let e=0;e<h;e++){const r=t.push("td_open","td",1);l[e]&&(r.attrs=[["style","text-align:"+l[e]]]);const n=t.push("inline","",0);n.content=a[e]?a[e].trim():"",n.children=[],t.push("td_close","td",-1)}t.push("tr_close","tr",-1)}return m&&(t.push("tbody_close","tbody",-1),m[1]=s),t.push("table_close","table",-1),d[1]=s,t.parentType=p,t.line=s,!0},["paragraph","reference"]],["code",function(t,e,r){if(t.sCount[e]-t.blkIndent<4)return!1;let n=e+1,s=n;for(;n<r;)if(t.isEmpty(n))n++;else{if(!(t.sCount[n]-t.blkIndent>=4))break;n++,s=n}t.line=s;const i=t.push("code_block","code",0);return i.content=t.getLines(e,s,4+t.blkIndent,!1)+"\n",i.map=[e,t.line],!0}],["fence",function(t,e,r,n){let s=t.bMarks[e]+t.tShift[e],i=t.eMarks[e];if(t.sCount[e]-t.blkIndent>=4)return!1;if(s+3>i)return!1;const o=t.src.charCodeAt(s);if(126!==o&&96!==o)return!1;let u=s;s=t.skipChars(s,o);let c=s-u;if(c<3)return!1;const a=t.src.slice(u,s),l=t.src.slice(s,i);if(96===o&&l.indexOf(String.fromCharCode(o))>=0)return!1;if(n)return!0;let h=e,p=!1;for(;(h++,!(h>=r))&&(s=u=t.bMarks[h]+t.tShift[h],i=t.eMarks[h],!(s<i&&t.sCount[h]<t.blkIndent));)if(t.src.charCodeAt(s)===o&&!(t.sCount[h]-t.blkIndent>=4||(s=t.skipChars(s,o),s-u<c||(s=t.skipSpaces(s),s<i)))){p=!0;break}c=t.sCount[e],t.line=h+(p?1:0);const f=t.push("fence","code",0);return f.info=l,f.content=t.getLines(e+1,h,c,!0),f.markup=a,f.map=[e,t.line],!0},["paragraph","reference","blockquote","list"]],["blockquote",function(t,e,r,n){let s=t.bMarks[e]+t.tShift[e],i=t.eMarks[e];const o=t.lineMax;if(t.sCount[e]-t.blkIndent>=4)return!1;if(62!==t.src.charCodeAt(s))return!1;if(n)return!0;const u=[],c=[],a=[],l=[],h=t.md.block.ruler.getRules("blockquote"),p=t.parentType;t.parentType="blockquote";let f,d=!1;for(f=e;f<r;f++){const e=t.sCount[f]<t.blkIndent;if(s=t.bMarks[f]+t.tShift[f],i=t.eMarks[f],s>=i)break;if(62===t.src.charCodeAt(s++)&&!e){let e,r,n=t.sCount[f]+1;32===t.src.charCodeAt(s)?(s++,n++,r=!1,e=!0):9===t.src.charCodeAt(s)?(e=!0,(t.bsCount[f]+n)%4==3?(s++,n++,r=!1):r=!0):e=!1;let o=n;for(u.push(t.bMarks[f]),t.bMarks[f]=s;s<i;){const e=t.src.charCodeAt(s);if(!ht(e))break;9===e?o+=4-(o+t.bsCount[f]+(r?1:0))%4:o++,s++}d=s>=i,c.push(t.bsCount[f]),t.bsCount[f]=t.sCount[f]+1+(e?1:0),a.push(t.sCount[f]),t.sCount[f]=o-n,l.push(t.tShift[f]),t.tShift[f]=s-t.bMarks[f];continue}if(d)break;let n=!1;for(let s=0,i=h.length;s<i;s++)if(h[s](t,f,r,!0)){n=!0;break}if(n){t.lineMax=f,0!==t.blkIndent&&(u.push(t.bMarks[f]),c.push(t.bsCount[f]),l.push(t.tShift[f]),a.push(t.sCount[f]),t.sCount[f]-=t.blkIndent);break}u.push(t.bMarks[f]),c.push(t.bsCount[f]),l.push(t.tShift[f]),a.push(t.sCount[f]),t.sCount[f]=-1}const m=t.blkIndent;t.blkIndent=0;const _=t.push("blockquote_open","blockquote",1);_.markup=">";const g=[e,0];_.map=g,t.md.block.tokenize(t,e,f),t.push("blockquote_close","blockquote",-1).markup=">",t.lineMax=o,t.parentType=p,g[1]=t.line;for(let k=0;k<l.length;k++)t.bMarks[k+e]=u[k],t.tShift[k+e]=l[k],t.sCount[k+e]=a[k],t.bsCount[k+e]=c[k];return t.blkIndent=m,!0},["paragraph","reference","blockquote","list"]],["hr",function(t,e,r,n){const s=t.eMarks[e];if(t.sCount[e]-t.blkIndent>=4)return!1;let i=t.bMarks[e]+t.tShift[e];const o=t.src.charCodeAt(i++);if(42!==o&&45!==o&&95!==o)return!1;let u=1;for(;i<s;){const e=t.src.charCodeAt(i++);if(e!==o&&!ht(e))return!1;e===o&&u++}if(u<3)return!1;if(n)return!0;t.line=e+1;const c=t.push("hr","hr",0);return c.map=[e,t.line],c.markup=Array(u+1).join(String.fromCharCode(o)),!0},["paragraph","reference","blockquote","list"]],["list",function(t,e,r,n){let s,i,o,u,c=e,a=!0;if(t.sCount[c]-t.blkIndent>=4)return!1;if(t.listIndent>=0&&t.sCount[c]-t.listIndent>=4&&t.sCount[c]<t.blkIndent)return!1;let l,h,p,f=!1;if(n&&"paragraph"===t.parentType&&t.sCount[c]>=t.blkIndent&&(f=!0),(p=Qt(t,c))>=0){if(l=!0,o=t.bMarks[c]+t.tShift[c],h=Number(t.src.slice(o,p-1)),f&&1!==h)return!1}else{if(!((p=Jt(t,c))>=0))return!1;l=!1}if(f&&t.skipSpaces(p)>=t.eMarks[c])return!1;if(n)return!0;const d=t.src.charCodeAt(p-1),m=t.tokens.length;l?(u=t.push("ordered_list_open","ol",1),1!==h&&(u.attrs=[["start",h]])):u=t.push("bullet_list_open","ul",1);const _=[c,0];u.map=_,u.markup=String.fromCharCode(d);let g=!1;const k=t.md.block.ruler.getRules("list"),D=t.parentType;for(t.parentType="list";c<r;){i=p,s=t.eMarks[c];const e=t.sCount[c]+p-(t.bMarks[c]+t.tShift[c]);let n=e;for(;i<s;){const e=t.src.charCodeAt(i);if(9===e)n+=4-(n+t.bsCount[c])%4;else{if(32!==e)break;n++}i++}const h=i;let f;f=h>=s?1:n-e,f>4&&(f=1);const m=e+f;u=t.push("list_item_open","li",1),u.markup=String.fromCharCode(d);const _=[c,0];u.map=_,l&&(u.info=t.src.slice(o,p-1));const D=t.tight,C=t.tShift[c],y=t.sCount[c],E=t.listIndent;if(t.listIndent=t.blkIndent,t.blkIndent=m,t.tight=!0,t.tShift[c]=h-t.bMarks[c],t.sCount[c]=n,h>=s&&t.isEmpty(c+1)?t.line=Math.min(t.line+2,r):t.md.block.tokenize(t,c,r,!0),t.tight&&!g||(a=!1),g=t.line-c>1&&t.isEmpty(t.line-1),t.blkIndent=t.listIndent,t.listIndent=E,t.tShift[c]=C,t.sCount[c]=y,t.tight=D,u=t.push("list_item_close","li",-1),u.markup=String.fromCharCode(d),c=t.line,_[1]=c,c>=r)break;if(t.sCount[c]<t.blkIndent)break;if(t.sCount[c]-t.blkIndent>=4)break;let A=!1;for(let s=0,i=k.length;s<i;s++)if(k[s](t,c,r,!0)){A=!0;break}if(A)break;if(l){if(p=Qt(t,c),p<0)break;o=t.bMarks[c]+t.tShift[c]}else if(p=Jt(t,c),p<0)break;if(d!==t.src.charCodeAt(p-1))break}return u=l?t.push("ordered_list_close","ol",-1):t.push("bullet_list_close","ul",-1),u.markup=String.fromCharCode(d),_[1]=c,t.line=c,t.parentType=D,a&&function(t,e){const r=t.level+2;for(let n=e+2,s=t.tokens.length-2;n<s;n++)t.tokens[n].level===r&&"paragraph_open"===t.tokens[n].type&&(t.tokens[n+2].hidden=!0,t.tokens[n].hidden=!0,n+=2)}(t,m),!0},["paragraph","reference","blockquote"]],["reference",function(t,e,r,n){let s=t.bMarks[e]+t.tShift[e],i=t.eMarks[e],o=e+1;if(t.sCount[e]-t.blkIndent>=4)return!1;if(91!==t.src.charCodeAt(s))return!1;function u(e){const r=t.lineMax;if(e>=r||t.isEmpty(e))return null;let n=!1;if(t.sCount[e]-t.blkIndent>3&&(n=!0),t.sCount[e]<0&&(n=!0),!n){const n=t.md.block.ruler.getRules("reference"),s=t.parentType;t.parentType="reference";let i=!1;for(let o=0,u=n.length;o<u;o++)if(n[o](t,e,r,!0)){i=!0;break}if(t.parentType=s,i)return null}const s=t.bMarks[e]+t.tShift[e],i=t.eMarks[e];return t.src.slice(s,i+1)}let c=t.src.slice(s,i+1);i=c.length;let a=-1;for(s=1;s<i;s++){const t=c.charCodeAt(s);if(91===t)return!1;if(93===t){a=s;break}if(10===t){const t=u(o);null!==t&&(c+=t,i=c.length,o++)}else if(92===t&&(s++,s<i&&10===c.charCodeAt(s))){const t=u(o);null!==t&&(c+=t,i=c.length,o++)}}if(a<0||58!==c.charCodeAt(a+1))return!1;for(s=a+2;s<i;s++){const t=c.charCodeAt(s);if(10===t){const t=u(o);null!==t&&(c+=t,i=c.length,o++)}else if(!ht(t))break}const l=t.md.helpers.parseLinkDestination(c,s,i);if(!l.ok)return!1;const h=t.md.normalizeLink(l.str);if(!t.md.validateLink(h))return!1;s=l.pos;const p=s,f=o,d=s;for(;s<i;s++){const t=c.charCodeAt(s);if(10===t){const t=u(o);null!==t&&(c+=t,i=c.length,o++)}else if(!ht(t))break}let m,_=t.md.helpers.parseLinkTitle(c,s,i);for(;_.can_continue;){const e=u(o);if(null===e)break;c+=e,s=i,i=c.length,o++,_=t.md.helpers.parseLinkTitle(c,s,i,_)}for(s<i&&d!==s&&_.ok?(m=_.str,s=_.pos):(m="",s=p,o=f);s<i&&ht(c.charCodeAt(s));)s++;if(s<i&&10!==c.charCodeAt(s)&&m)for(m="",s=p,o=f;s<i&&ht(c.charCodeAt(s));)s++;if(s<i&&10!==c.charCodeAt(s))return!1;const g=_t(c.slice(1,a));return!!g&&(n||(void 0===t.env.references&&(t.env.references={}),void 0===t.env.references[g]&&(t.env.references[g]={title:m,href:h}),t.line=o),!0)}],["html_block",function(t,e,r,n){let s=t.bMarks[e]+t.tShift[e],i=t.eMarks[e];if(t.sCount[e]-t.blkIndent>=4)return!1;if(!t.md.options.html)return!1;if(60!==t.src.charCodeAt(s))return!1;let o=t.src.slice(s,i),u=0;for(;u<Kt.length&&!Kt[u][0].test(o);u++);if(u===Kt.length)return!1;if(n)return Kt[u][2];let c=e+1;const a=Kt[u][1].test("");if(!Kt[u][1].test(o))for(;c<r&&(!(t.sCount[c]<t.blkIndent)||!a&&t.isEmpty(c));c++)if(s=t.bMarks[c]+t.tShift[c],i=t.eMarks[c],o=t.src.slice(s,i),Kt[u][1].test(o)){0!==o.length&&c++;break}t.line=c;const l=t.push("html_block","",0);return l.map=[e,c],l.content=t.getLines(e,c,t.blkIndent,!0),!0},["paragraph","reference","blockquote"]],["heading",function(t,e,r,n){let s=t.bMarks[e]+t.tShift[e],i=t.eMarks[e];if(t.sCount[e]-t.blkIndent>=4)return!1;let o=t.src.charCodeAt(s);if(35!==o||s>=i)return!1;let u=1;for(o=t.src.charCodeAt(++s);35===o&&s<i&&u<=6;)u++,o=t.src.charCodeAt(++s);if(u>6||s<i&&!ht(o))return!1;if(n)return!0;i=t.skipSpacesBack(i,s);const c=t.skipCharsBack(i,35,s);c>s&&ht(t.src.charCodeAt(c-1))&&(i=c),t.line=e+1;const a=t.push("heading_open","h"+String(u),1);a.markup="########".slice(0,u),a.map=[e,t.line];const l=t.push("inline","",0);return l.content=kt(t.src.slice(s,i)),l.map=[e,t.line],l.children=[],t.push("heading_close","h"+String(u),-1).markup="########".slice(0,u),!0},["paragraph","reference","blockquote"]],["lheading",function(t,e,r){const n=t.md.block.ruler.getRules("paragraph");if(t.sCount[e]-t.blkIndent>=4)return!1;const s=t.parentType;t.parentType="paragraph";let i,o=0,u=e+1;for(;u<r&&!t.isEmpty(u);u++){if(t.sCount[u]-t.blkIndent>3)continue;if(t.sCount[u]>=t.blkIndent){let e=t.bMarks[u]+t.tShift[u];const r=t.eMarks[u];if(e<r&&(i=t.src.charCodeAt(e),(45===i||61===i)&&(e=t.skipChars(e,i),e=t.skipSpaces(e),e>=r))){o=61===i?1:2;break}}if(t.sCount[u]<0)continue;let e=!1;for(let s=0,i=n.length;s<i;s++)if(n[s](t,u,r,!0)){e=!0;break}if(e)break}if(!o)return t.parentType=s,!1;const c=kt(t.getLines(e,u,t.blkIndent,!1));t.line=u+1;const a=t.push("heading_open","h"+String(o),1);a.markup=String.fromCharCode(i),a.map=[e,t.line];const l=t.push("inline","",0);return l.content=c,l.map=[e,t.line-1],l.children=[],t.push("heading_close","h"+String(o),-1).markup=String.fromCharCode(i),t.parentType=s,!0}],["paragraph",function(t,e,r){const n=t.md.block.ruler.getRules("paragraph"),s=t.parentType;let i=e+1;for(t.parentType="paragraph";i<r&&!t.isEmpty(i);i++){if(t.sCount[i]-t.blkIndent>3)continue;if(t.sCount[i]<0)continue;let e=!1;for(let s=0,o=n.length;s<o;s++)if(n[s](t,i,r,!0)){e=!0;break}if(e)break}const o=kt(t.getLines(e,i,t.blkIndent,!1));t.line=i,t.push("paragraph_open","p",1).map=[e,t.line];const u=t.push("inline","",0);return u.content=o,u.map=[e,t.line],u.children=[],t.push("paragraph_close","p",-1),t.parentType=s,!0}]];function ee(){this.ruler=new xt;for(let t=0;t<te.length;t++)this.ruler.push(te[t][0],te[t][1],{alt:(te[t][2]||[]).slice()})}function re(t,e,r,n){this.src=t,this.env=r,this.md=e,this.tokens=n,this.tokens_meta=Array(n.length),this.pos=0,this.posMax=this.src.length,this.level=0,this.pending="",this.pendingLevel=0,this.cache={},this.delimiters=[],this._prev_delimiters=[],this.backticks={},this.backticksScanned=!1,this.linkLevel=0}function ne(t){switch(t){case 10:case 33:case 35:case 36:case 37:case 38:case 42:case 43:case 45:case 58:case 60:case 61:case 62:case 64:case 91:case 92:case 93:case 94:case 95:case 96:case 123:case 125:case 126:return!0;default:return!1}}ee.prototype.tokenize=function(t,e,r){const n=this.ruler.getRules(""),s=n.length,i=t.md.options.maxNesting;let o=e,u=!1;for(;o<r&&(t.line=o=t.skipEmptyLines(o),!(o>=r))&&!(t.sCount[o]<t.blkIndent);){if(t.level>=i){t.line=r;break}const e=t.line;let c=!1;for(let i=0;i<s;i++)if(c=n[i](t,o,r,!1),c){if(e>=t.line)throw new Error("block rule didn't increment state.line");break}if(!c)throw new Error("none of the block rules matched");t.tight=!u,t.isEmpty(t.line-1)&&(u=!0),o=t.line,o<r&&t.isEmpty(o)&&(u=!0,o++,t.line=o)}},ee.prototype.parse=function(t,e,r,n){if(!t)return;const s=new this.State(t,e,r,n);this.tokenize(s,s.line,s.lineMax)},ee.prototype.State=Vt,re.prototype.pushPending=function(){const t=new vt("text","",0);return t.content=this.pending,t.level=this.pendingLevel,this.tokens.push(t),this.pending="",t},re.prototype.push=function(t,e,r){this.pending&&this.pushPending();const n=new vt(t,e,r);let s=null;return r<0&&(this.level--,this.delimiters=this._prev_delimiters.pop()),n.level=this.level,r>0&&(this.level++,this._prev_delimiters.push(this.delimiters),this.delimiters=[],s={delimiters:this.delimiters}),this.pendingLevel=this.level,this.tokens.push(n),this.tokens_meta.push(s),n},re.prototype.scanDelims=function(t,e){const r=this.posMax,n=this.src.charCodeAt(t);let s;if(0===t)s=32;else if(1===t)s=this.src.charCodeAt(0),55296==(63488&s)&&(s=65533);else if(s=this.src.charCodeAt(t-1),56320==(64512&s)){const e=this.src.charCodeAt(t-2);s=55296==(64512&e)?65536+(e-55296<<10)+(s-56320):65533}else 55296==(64512&s)&&(s=65533);let i=t;for(;i<r&&this.src.charCodeAt(i)===n;)i++;const o=i-t;let u=i<r?this.src.charCodeAt(i):32;if(55296==(64512&u)){const t=this.src.charCodeAt(i+1);u=56320==(64512&t)?65536+(u-55296<<10)+(t-56320):65533}else 56320==(64512&u)&&(u=65533);const c=mt(s)||dt(s),a=mt(u)||dt(u),l=pt(s),h=pt(u),p=!h&&(!a||l||c),f=!l&&(!c||h||a);return{can_open:p&&(e||!f||c),can_close:f&&(e||!p||a),length:o}},re.prototype.Token=vt;var se=/(?:^|[^a-z0-9.+-])([a-z][a-z0-9.+-]*)$/i;var ie=[];for(let tr=0;tr<256;tr++)ie.push(0);function oe(t,e){let r;const n=[],s=e.length;for(let i=0;i<s;i++){const s=e[i];if(126!==s.marker)continue;if(-1===s.end)continue;const o=e[s.end];r=t.tokens[s.token],r.type="s_open",r.tag="s",r.nesting=1,r.markup="~~",r.content="",r=t.tokens[o.token],r.type="s_close",r.tag="s",r.nesting=-1,r.markup="~~",r.content="","text"===t.tokens[o.token-1].type&&"~"===t.tokens[o.token-1].content&&n.push(o.token-1)}for(;n.length;){const e=n.pop();let s=e+1;for(;s<t.tokens.length&&"s_close"===t.tokens[s].type;)s++;s--,e!==s&&(r=t.tokens[s],t.tokens[s]=t.tokens[e],t.tokens[e]=r)}}"\\!\"#$%&'()*+,./:;<=>?@[]^_`{|}~-".split("").forEach(function(t){ie[t.charCodeAt(0)]=1});var ue={tokenize:function(t,e){const r=t.pos,n=t.src.charCodeAt(r);if(e)return!1;if(126!==n)return!1;const s=t.scanDelims(t.pos,!0);let i=s.length;const o=String.fromCharCode(n);if(i<2)return!1;let u;i%2&&(u=t.push("text","",0),u.content=o,i--);for(let c=0;c<i;c+=2)u=t.push("text","",0),u.content=o+o,t.delimiters.push({marker:n,length:0,token:t.tokens.length-1,end:-1,open:s.can_open,close:s.can_close});return t.pos+=s.length,!0},postProcess:function(t){const e=t.tokens_meta,r=t.tokens_meta.length;oe(t,t.delimiters);for(let n=0;n<r;n++)e[n]&&e[n].delimiters&&oe(t,e[n].delimiters)}};function ce(t,e){for(let r=e.length-1;r>=0;r--){const n=e[r];if(95!==n.marker&&42!==n.marker)continue;if(-1===n.end)continue;const s=e[n.end],i=r>0&&e[r-1].end===n.end+1&&e[r-1].marker===n.marker&&e[r-1].token===n.token-1&&e[n.end+1].token===s.token+1,o=String.fromCharCode(n.marker),u=t.tokens[n.token];u.type=i?"strong_open":"em_open",u.tag=i?"strong":"em",u.nesting=1,u.markup=i?o+o:o,u.content="";const c=t.tokens[s.token];c.type=i?"strong_close":"em_close",c.tag=i?"strong":"em",c.nesting=-1,c.markup=i?o+o:o,c.content="",i&&(t.tokens[e[r-1].token].content="",t.tokens[e[n.end+1].token].content="",r--)}}var ae={tokenize:function(t,e){const r=t.pos,n=t.src.charCodeAt(r);if(e)return!1;if(95!==n&&42!==n)return!1;const s=t.scanDelims(t.pos,42===n);for(let i=0;i<s.length;i++){t.push("text","",0).content=String.fromCharCode(n),t.delimiters.push({marker:n,length:s.length,token:t.tokens.length-1,end:-1,open:s.can_open,close:s.can_close})}return t.pos+=s.length,!0},postProcess:function(t){const e=t.tokens_meta,r=t.tokens_meta.length;ce(t,t.delimiters);for(let n=0;n<r;n++)e[n]&&e[n].delimiters&&ce(t,e[n].delimiters)}};var le=/^([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)$/,he=/^([a-zA-Z][a-zA-Z0-9+.-]{1,31}):([^<>\x00-\x20]*)$/;var pe=/^&#((?:x[a-f0-9]{1,6}|[0-9]{1,7}));/i,fe=/^&([a-z][a-z0-9]{1,31});/i;function de(t){const e={},r=t.length;if(!r)return;let n=0,s=-2;const i=[];for(let o=0;o<r;o++){const r=t[o];if(i.push(0),t[n].marker===r.marker&&s===r.token-1||(n=o),s=r.token,r.length=r.length||0,!r.close)continue;e.hasOwnProperty(r.marker)||(e[r.marker]=[-1,-1,-1,-1,-1,-1]);const u=e[r.marker][(r.open?3:0)+r.length%3];let c=n-i[n]-1,a=c;for(;c>u;c-=i[c]+1){const e=t[c];if(e.marker===r.marker&&(e.open&&e.end<0)){let n=!1;if((e.close||r.open)&&(e.length+r.length)%3==0&&(e.length%3==0&&r.length%3==0||(n=!0)),!n){const n=c>0&&!t[c-1].open?i[c-1]+1:0;i[o]=o-c+n,i[c]=n,r.open=!1,e.end=o,e.close=!1,a=-1,s=-2;break}}}-1!==a&&(e[r.marker][(r.open?3:0)+(r.length||0)%3]=a)}}var me=[["text",function(t,e){let r=t.pos;for(;r<t.posMax&&!ne(t.src.charCodeAt(r));)r++;return r!==t.pos&&(e||(t.pending+=t.src.slice(t.pos,r)),t.pos=r,!0)}],["linkify",function(t,e){if(!t.md.options.linkify)return!1;if(t.linkLevel>0)return!1;const r=t.pos;if(r+3>t.posMax)return!1;if(58!==t.src.charCodeAt(r))return!1;if(47!==t.src.charCodeAt(r+1))return!1;if(47!==t.src.charCodeAt(r+2))return!1;const n=t.pending.match(se);if(!n)return!1;const s=n[1],i=t.md.linkify.matchAtStart(t.src.slice(r-s.length));if(!i)return!1;let o=i.url;if(o.length<=s.length)return!1;let u=o.length;for(;u>0&&42===o.charCodeAt(u-1);)u--;u!==o.length&&(o=o.slice(0,u));const c=t.md.normalizeLink(o);if(!t.md.validateLink(c))return!1;if(!e){t.pending=t.pending.slice(0,-s.length);const e=t.push("link_open","a",1);e.attrs=[["href",c]],e.markup="linkify",e.info="auto";t.push("text","",0).content=t.md.normalizeLinkText(o);const r=t.push("link_close","a",-1);r.markup="linkify",r.info="auto"}return t.pos+=o.length-s.length,!0}],["newline",function(t,e){let r=t.pos;if(10!==t.src.charCodeAt(r))return!1;const n=t.pending.length-1,s=t.posMax;if(!e)if(n>=0&&32===t.pending.charCodeAt(n))if(n>=1&&32===t.pending.charCodeAt(n-1)){let e=n-1;for(;e>=1&&32===t.pending.charCodeAt(e-1);)e--;t.pending=t.pending.slice(0,e),t.push("hardbreak","br",0)}else t.pending=t.pending.slice(0,-1),t.push("softbreak","br",0);else t.push("softbreak","br",0);for(r++;r<s&&ht(t.src.charCodeAt(r));)r++;return t.pos=r,!0}],["escape",function(t,e){let r=t.pos;const n=t.posMax;if(92!==t.src.charCodeAt(r))return!1;if(r++,r>=n)return!1;let s=t.src.charCodeAt(r);if(10===s){for(e||t.push("hardbreak","br",0),r++;r<n&&(s=t.src.charCodeAt(r),ht(s));)r++;return t.pos=r,!0}if(32===s){if(!e){const e=t.push("text_special","",0);e.content="\\",e.markup="\\",e.info="escape"}return t.pos=r,!0}let i=t.src[r];if(s>=55296&&s<=56319&&r+1<n){const e=t.src.charCodeAt(r+1);e>=56320&&e<=57343&&(i+=t.src[r+1],r++)}const o="\\"+i;if(!e){const e=t.push("text_special","",0);s<256&&0!==ie[s]?e.content=i:e.content=o,e.markup=o,e.info="escape"}return t.pos=r+1,!0}],["backticks",function(t,e){let r=t.pos;if(96!==t.src.charCodeAt(r))return!1;const n=r;r++;const s=t.posMax;for(;r<s&&96===t.src.charCodeAt(r);)r++;const i=t.src.slice(n,r),o=i.length;if(t.backticksScanned&&(t.backticks[o]||0)<=n)return e||(t.pending+=i),t.pos+=o,!0;let u,c=r;for(;-1!==(u=t.src.indexOf("`",c));){for(c=u+1;c<s&&96===t.src.charCodeAt(c);)c++;const n=c-u;if(n===o){if(!e){const e=t.push("code_inline","code",0);e.markup=i,e.content=t.src.slice(r,u).replace(/\n/g," ").replace(/^ (.+) $/,"$1")}return t.pos=c,!0}t.backticks[n]=u}return t.backticksScanned=!0,e||(t.pending+=i),t.pos+=o,!0}],["strikethrough",ue.tokenize],["emphasis",ae.tokenize],["link",function(t,e){let r,n,s,i,o="",u="",c=t.pos,a=!0;if(91!==t.src.charCodeAt(t.pos))return!1;const l=t.pos,h=t.posMax,p=t.pos+1,f=t.md.helpers.parseLinkLabel(t,t.pos,!0);if(f<0)return!1;let d=f+1;if(d<h&&40===t.src.charCodeAt(d)){for(a=!1,d++;d<h&&(r=t.src.charCodeAt(d),ht(r)||10===r);d++);if(d>=h)return!1;if(c=d,s=t.md.helpers.parseLinkDestination(t.src,d,t.posMax),s.ok){for(o=t.md.normalizeLink(s.str),t.md.validateLink(o)?d=s.pos:o="",c=d;d<h&&(r=t.src.charCodeAt(d),ht(r)||10===r);d++);if(s=t.md.helpers.parseLinkTitle(t.src,d,t.posMax),d<h&&c!==d&&s.ok)for(u=s.str,d=s.pos;d<h&&(r=t.src.charCodeAt(d),ht(r)||10===r);d++);}(d>=h||41!==t.src.charCodeAt(d))&&(a=!0),d++}if(a){if(void 0===t.env.references)return!1;if(d<h&&91===t.src.charCodeAt(d)?(c=d+1,d=t.md.helpers.parseLinkLabel(t,d),d>=0?n=t.src.slice(c,d++):d=f+1):d=f+1,n||(n=t.src.slice(p,f)),i=t.env.references[_t(n)],!i)return t.pos=l,!1;o=i.href,u=i.title}if(!e){t.pos=p,t.posMax=f;const e=[["href",o]];t.push("link_open","a",1).attrs=e,u&&e.push(["title",u]),t.linkLevel++,t.md.inline.tokenize(t),t.linkLevel--,t.push("link_close","a",-1)}return t.pos=d,t.posMax=h,!0}],["image",function(t,e){let r,n,s,i,o,u,c,a,l="";const h=t.pos,p=t.posMax;if(33!==t.src.charCodeAt(t.pos))return!1;if(91!==t.src.charCodeAt(t.pos+1))return!1;const f=t.pos+2,d=t.md.helpers.parseLinkLabel(t,t.pos+1,!1);if(d<0)return!1;if(i=d+1,i<p&&40===t.src.charCodeAt(i)){for(i++;i<p&&(r=t.src.charCodeAt(i),ht(r)||10===r);i++);if(i>=p)return!1;for(a=i,u=t.md.helpers.parseLinkDestination(t.src,i,t.posMax),u.ok&&(l=t.md.normalizeLink(u.str),t.md.validateLink(l)?i=u.pos:l=""),a=i;i<p&&(r=t.src.charCodeAt(i),ht(r)||10===r);i++);if(u=t.md.helpers.parseLinkTitle(t.src,i,t.posMax),i<p&&a!==i&&u.ok)for(c=u.str,i=u.pos;i<p&&(r=t.src.charCodeAt(i),ht(r)||10===r);i++);else c="";if(i>=p||41!==t.src.charCodeAt(i))return t.pos=h,!1;i++}else{if(void 0===t.env.references)return!1;if(i<p&&91===t.src.charCodeAt(i)?(a=i+1,i=t.md.helpers.parseLinkLabel(t,i),i>=0?s=t.src.slice(a,i++):i=d+1):i=d+1,s||(s=t.src.slice(f,d)),o=t.env.references[_t(s)],!o)return t.pos=h,!1;l=o.href,c=o.title}if(!e){n=t.src.slice(f,d);const e=[];t.md.inline.parse(n,t.md,t.env,e);const r=t.push("image","img",0),s=[["src",l],["alt",""]];r.attrs=s,r.children=e,r.content=n,c&&s.push(["title",c])}return t.pos=i,t.posMax=p,!0}],["autolink",function(t,e){let r=t.pos;if(60!==t.src.charCodeAt(r))return!1;const n=t.pos,s=t.posMax;for(;;){if(++r>=s)return!1;const e=t.src.charCodeAt(r);if(60===e)return!1;if(62===e)break}const i=t.src.slice(n+1,r);if(he.test(i)){const r=t.md.normalizeLink(i);if(!t.md.validateLink(r))return!1;if(!e){const e=t.push("link_open","a",1);e.attrs=[["href",r]],e.markup="autolink",e.info="auto";t.push("text","",0).content=t.md.normalizeLinkText(i);const n=t.push("link_close","a",-1);n.markup="autolink",n.info="auto"}return t.pos+=i.length+2,!0}if(le.test(i)){const r=t.md.normalizeLink("mailto:"+i);if(!t.md.validateLink(r))return!1;if(!e){const e=t.push("link_open","a",1);e.attrs=[["href",r]],e.markup="autolink",e.info="auto";t.push("text","",0).content=t.md.normalizeLinkText(i);const n=t.push("link_close","a",-1);n.markup="autolink",n.info="auto"}return t.pos+=i.length+2,!0}return!1}],["html_inline",function(t,e){if(!t.md.options.html)return!1;const r=t.posMax,n=t.pos;if(60!==t.src.charCodeAt(n)||n+2>=r)return!1;const s=t.src.charCodeAt(n+1);if(33!==s&&63!==s&&47!==s&&!function(t){const e=32|t;return e>=97&&e<=122}(s))return!1;const i=t.src.slice(n).match(Xt);if(!i)return!1;if(!e){const e=t.push("html_inline","",0);e.content=i[0],o=e.content,/^<a[>\s]/i.test(o)&&t.linkLevel++,function(t){return/^<\/a\s*>/i.test(t)}(e.content)&&t.linkLevel--}var o;return t.pos+=i[0].length,!0}],["entity",function(t,e){const r=t.pos,n=t.posMax;if(38!==t.src.charCodeAt(r))return!1;if(r+1>=n)return!1;if(35===t.src.charCodeAt(r+1)){const n=t.src.slice(r).match(pe);if(n){if(!e){const e="x"===n[1][0].toLowerCase()?parseInt(n[1].slice(1),16):parseInt(n[1],10),r=t.push("text_special","",0);r.content=Q(e)?X(e):X(65533),r.markup=n[0],r.info="entity"}return t.pos+=n[0].length,!0}}else{const n=t.src.slice(r).match(fe);if(n){const r=(s=n[0],j(s,T.Strict));if(r!==n[0]){if(!e){const e=t.push("text_special","",0);e.content=r,e.markup=n[0],e.info="entity"}return t.pos+=n[0].length,!0}}}var s;return!1}]],_e=[["balance_pairs",function(t){const e=t.tokens_meta,r=t.tokens_meta.length;de(t.delimiters);for(let n=0;n<r;n++)e[n]&&e[n].delimiters&&de(e[n].delimiters)}],["strikethrough",ue.postProcess],["emphasis",ae.postProcess],["fragments_join",function(t){let e,r,n=0;const s=t.tokens,i=t.tokens.length;for(e=r=0;e<i;e++)s[e].nesting<0&&n--,s[e].level=n,s[e].nesting>0&&n++,"text"===s[e].type&&e+1<i&&"text"===s[e+1].type?s[e+1].content=s[e].content+s[e+1].content:(e!==r&&(s[r]=s[e]),r++);e!==r&&(s.length=r)}]];function ge(){this.ruler=new xt;for(let t=0;t<me.length;t++)this.ruler.push(me[t][0],me[t][1]);this.ruler2=new xt;for(let t=0;t<_e.length;t++)this.ruler2.push(_e[t][0],_e[t][1])}function ke(t){return Array.prototype.slice.call(arguments,1).forEach(function(e){e&&Object.keys(e).forEach(function(r){t[r]=e[r]})}),t}function De(t){return Object.prototype.toString.call(t)}function Ce(t){return"[object Function]"===De(t)}function ye(t){return t.replace(/[.?*+^$[\]\\(){}|-]/g,"\\$&")}ge.prototype.skipToken=function(t){const e=t.pos,r=this.ruler.getRules(""),n=r.length,s=t.md.options.maxNesting,i=t.cache;if(void 0!==i[e])return void(t.pos=i[e]);let o=!1;if(t.level<s){for(let u=0;u<n;u++)if(t.level++,o=r[u](t,!0),t.level--,o){if(e>=t.pos)throw new Error("inline rule didn't increment state.pos");break}}else t.pos=t.posMax;o||t.pos++,i[e]=t.pos},ge.prototype.tokenize=function(t){const e=this.ruler.getRules(""),r=e.length,n=t.posMax,s=t.md.options.maxNesting;for(;t.pos<n;){const i=t.pos;let o=!1;if(t.level<s)for(let n=0;n<r;n++)if(o=e[n](t,!1),o){if(i>=t.pos)throw new Error("inline rule didn't increment state.pos");break}if(o){if(t.pos>=n)break}else t.pending+=t.src[t.pos++]}t.pending&&t.pushPending()},ge.prototype.parse=function(t,e,r,n){const s=new this.State(t,e,r,n);this.tokenize(s);const i=this.ruler2.getRules(""),o=i.length;for(let u=0;u<o;u++)i[u](s)},ge.prototype.State=re;var Ee={fuzzyLink:!0,fuzzyEmail:!0,fuzzyIP:!1};var Ae={"http:":{validate:function(t,e,r){const n=t.slice(e);return r.re.http||(r.re.http=new RegExp(`^\\/\\/${r.re.src_auth}${r.re.src_host_port_strict}${r.re.src_path}`,"i")),r.re.http.test(n)?n.match(r.re.http)[0].length:0}},"https:":"http:","ftp:":"http:","//":{validate:function(t,e,r){const n=t.slice(e);return r.re.no_http||(r.re.no_http=new RegExp("^"+r.re.src_auth+`(?:localhost|(?:(?:${r.re.src_domain})\\.)+${r.re.src_domain_root})`+r.re.src_port+r.re.src_host_terminator+r.re.src_path,"i")),r.re.no_http.test(n)?e>=3&&":"===t[e-3]||e>=3&&"/"===t[e-3]?0:n.match(r.re.no_http)[0].length:0}},"mailto:":{validate:function(t,e,r){const n=t.slice(e);return r.re.mailto||(r.re.mailto=new RegExp(`^${r.re.src_email_name}@${r.re.src_host_strict}`,"i")),r.re.mailto.test(n)?n.match(r.re.mailto)[0].length:0}}},be="biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|\u0440\u0444".split("|");function Fe(t){const e=t.re=function(t){const e={};t=t||{},e.src_Any=A.source,e.src_Cc=b.source,e.src_Z=w.source,e.src_P=x.source,e.src_ZPCc=[e.src_Z,e.src_P,e.src_Cc].join("|"),e.src_ZCc=[e.src_Z,e.src_Cc].join("|");const r="[><\uff5c]";return e.src_pseudo_letter=`(?:(?!${r}|${e.src_ZPCc})${e.src_Any})`,e.src_ip4="(?:(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)",e.src_auth=`(?:(?:(?!${e.src_ZCc}|[@/\\[\\]()]).){1,50}@)?`,e.src_port="(?::(?:6(?:[0-4]\\d{3}|5(?:[0-4]\\d{2}|5(?:[0-2]\\d|3[0-5])))|[1-5]?\\d{1,4}))?",e.src_host_terminator=`(?=$|${r}|${e.src_ZPCc})(?!${t["---"]?"-(?!--)|":"-|"}_|:\\d|\\.-|\\.(?!$|${e.src_ZPCc}))`,e.src_path=`(?:[/?#](?:(?!${e.src_ZCc}|${r}|[()[\\]{}.,"'?!\\-;]).|\\[(?:(?!${e.src_ZCc}|\\]).)*\\]|\\((?:(?!${e.src_ZCc}|[)]).)*\\)|\\{(?:(?!${e.src_ZCc}|[}]).)*\\}|\\"(?:(?!${e.src_ZCc}|["]).)+\\"|\\'(?:(?!${e.src_ZCc}|[']).)+\\'|\\'(?=${e.src_pseudo_letter}|[-])|\\.{2,}[a-zA-Z0-9%/&]|\\.(?!${e.src_ZCc}|[.]|$)|`+(t["---"]?"\\-(?!--(?:[^-]|$))(?:-*)|":"\\-+|")+`,(?!${e.src_ZCc}|$)|;(?!${e.src_ZCc}|$)|\\!+(?!${e.src_ZCc}|[!]|$)|\\?(?!${e.src_ZCc}|[?]|$))+|\\/)?`,e.src_email_name='[\\-;:&=\\+\\$,\\.a-zA-Z0-9_][\\-;:&=\\+\\$,\\"\\.a-zA-Z0-9_]{0,63}',e.src_xn="xn--[a-z0-9\\-]{1,59}",e.src_domain_root="(?:"+e.src_xn+`|${e.src_pseudo_letter}{1,63})`,e.src_domain="(?:"+e.src_xn+`|(?:${e.src_pseudo_letter})|(?:${e.src_pseudo_letter}(?:-|${e.src_pseudo_letter}){0,61}${e.src_pseudo_letter}))`,e.src_host=`(?:(?:(?:(?:${e.src_domain})\\.)*${e.src_domain}))`,e.tpl_host_fuzzy="(?:"+e.src_ip4+`|(?:(?:(?:${e.src_domain})\\.)+(?:%TLDS%)))`,e.tpl_host_no_ip_fuzzy=`(?:(?:(?:${e.src_domain})\\.)+(?:%TLDS%))`,e.src_host_strict=e.src_host+e.src_host_terminator,e.tpl_host_fuzzy_strict=e.tpl_host_fuzzy+e.src_host_terminator,e.src_host_port_strict=e.src_host+e.src_port+e.src_host_terminator,e.tpl_host_port_fuzzy_strict=e.tpl_host_fuzzy+e.src_port+e.src_host_terminator,e.tpl_host_port_no_ip_fuzzy_strict=e.tpl_host_no_ip_fuzzy+e.src_port+e.src_host_terminator,e.tpl_host_fuzzy_test=`localhost|www\\.|\\.\\d{1,3}\\.|(?:\\.(?:%TLDS%)(?:${e.src_ZPCc}|>|$))`,e.tpl_email_fuzzy=`(^|${r}|"|\\(|${e.src_ZCc})(${e.src_email_name}@${e.tpl_host_fuzzy_strict})`,e.tpl_link_fuzzy=`(^|(?![.:/\\-_@])(?:[$+<=>^\`|\uff5c]|${e.src_ZPCc}))((?![$+<=>^\`|\uff5c])${e.tpl_host_port_fuzzy_strict}${e.src_path})`,e.tpl_link_no_ip_fuzzy=`(^|(?![.:/\\-_@])(?:[$+<=>^\`|\uff5c]|${e.src_ZPCc}))((?![$+<=>^\`|\uff5c])${e.tpl_host_port_no_ip_fuzzy_strict}${e.src_path})`,e}(t.__opts__),r=t.__tlds__.slice();function n(t){return t.replace("%TLDS%",e.src_tlds)}t.onCompile(),t.__tlds_replaced__||r.push("a[cdefgilmnoqrstuwxz]|b[abdefghijmnorstvwyz]|c[acdfghiklmnoruvwxyz]|d[ejkmoz]|e[cegrstu]|f[ijkmor]|g[abdefghilmnpqrstuwy]|h[kmnrtu]|i[delmnoqrst]|j[emop]|k[eghimnprwyz]|l[abcikrstuvy]|m[acdeghklmnopqrstuvwxyz]|n[acefgilopruz]|om|p[aefghklmnrstwy]|qa|r[eosuw]|s[abcdeghijklmnortuvxyz]|t[cdfghjklmnortvwz]|u[agksyz]|v[aceginu]|w[fs]|y[et]|z[amw]"),r.push(e.src_xn),e.src_tlds=r.join("|"),e.email_fuzzy=RegExp(n(e.tpl_email_fuzzy),"i"),e.email_fuzzy_global=RegExp(n(e.tpl_email_fuzzy),"ig"),e.link_fuzzy=RegExp(n(e.tpl_link_fuzzy),"i"),e.link_fuzzy_global=RegExp(n(e.tpl_link_fuzzy),"ig"),e.link_no_ip_fuzzy=RegExp(n(e.tpl_link_no_ip_fuzzy),"i"),e.link_no_ip_fuzzy_global=RegExp(n(e.tpl_link_no_ip_fuzzy),"ig"),e.host_fuzzy_test=RegExp(n(e.tpl_host_fuzzy_test),"i");const s=[];function i(t,e){throw new Error(`(LinkifyIt) Invalid schema "${t}": ${e}`)}t.__compiled__={},Object.keys(t.__schemas__).forEach(function(e){const r=t.__schemas__[e];if(null===r)return;const n={validate:null,link:null};if(t.__compiled__[e]=n,"[object Object]"===De(r))return!function(t){return"[object RegExp]"===De(t)}(r.validate)?Ce(r.validate)?n.validate=r.validate:i(e,r):n.validate=function(t){return function(e,r){const n=e.slice(r);return t.test(n)?n.match(t)[0].length:0}}(r.validate),void(Ce(r.normalize)?n.normalize=r.normalize:r.normalize?i(e,r):n.normalize=function(t,e){e.normalize(t)});!function(t){return"[object String]"===De(t)}(r)?i(e,r):s.push(e)}),s.forEach(function(e){t.__compiled__[t.__schemas__[e]]&&(t.__compiled__[e].validate=t.__compiled__[t.__schemas__[e]].validate,t.__compiled__[e].normalize=t.__compiled__[t.__schemas__[e]].normalize)}),t.__compiled__[""]={validate:null,normalize:function(t,e){e.normalize(t)}};const o=Object.keys(t.__compiled__).filter(function(e){return e.length>0&&t.__compiled__[e]}).map(ye).join("|");t.re.schema_test=RegExp(`(^|(?!_)(?:[><\uff5c]|${e.src_ZPCc}))(${o})`,"i"),t.re.schema_search=RegExp(`(^|(?!_)(?:[><\uff5c]|${e.src_ZPCc}))(${o})`,"ig"),t.re.schema_at_start=RegExp(`^${t.re.schema_search.source}`,"i"),t.re.pretest=RegExp(`(${t.re.schema_test.source})|(${t.re.host_fuzzy_test.source})|@`,"i")}function xe(t,e,r,n){const s=t.slice(r,n);this.schema=e.toLowerCase(),this.index=r,this.lastIndex=n,this.raw=s,this.text=s,this.url=s}function ve(t,e){if(!(this instanceof ve))return new ve(t,e);var r;e||(r=t,Object.keys(r||{}).reduce(function(t,e){return t||Ee.hasOwnProperty(e)},!1)&&(e=t,t={})),this.__opts__=ke({},Ee,e),this.__schemas__=ke({},Ae,t),this.__compiled__={},this.__tlds__=be,this.__tlds_replaced__=!1,this.re={},Fe(this)}ve.prototype.add=function(t,e){return this.__schemas__[t]=e,Fe(this),this},ve.prototype.set=function(t){return this.__opts__=ke(this.__opts__,t),this},ve.prototype.test=function(t){if(!t.length)return!1;let e,r;if(this.re.schema_test.test(t))for(r=this.re.schema_search,r.lastIndex=0;null!==(e=r.exec(t));)if(this.testSchemaAt(t,e[2],r.lastIndex))return!0;return!!(this.__opts__.fuzzyLink&&this.__compiled__["http:"]&&t.search(this.re.host_fuzzy_test)>=0&&null!==t.match(this.__opts__.fuzzyIP?this.re.link_fuzzy:this.re.link_no_ip_fuzzy))||!!(this.__opts__.fuzzyEmail&&this.__compiled__["mailto:"]&&t.indexOf("@")>=0&&null!==t.match(this.re.email_fuzzy))},ve.prototype.pretest=function(t){return this.re.pretest.test(t)},ve.prototype.testSchemaAt=function(t,e,r){return this.__compiled__[e.toLowerCase()]?this.__compiled__[e.toLowerCase()].validate(t,r,this):0},ve.prototype.match=function(t){const e=[],r=[],n=[],s=[];let i,o,u;function c(t,e){return t?e?t.index!==e.index?t.index<e.index?t:e:t.lastIndex>=e.lastIndex?t:e:t:e}if(!t.length)return null;if(this.re.schema_test.test(t))for(u=this.re.schema_search,u.lastIndex=0;null!==(i=u.exec(t));)o=this.testSchemaAt(t,i[2],u.lastIndex),o&&r.push({schema:i[2],index:i.index+i[1].length,lastIndex:i.index+i[0].length+o});if(this.__opts__.fuzzyLink&&this.__compiled__["http:"])for(u=this.__opts__.fuzzyIP?this.re.link_fuzzy_global:this.re.link_no_ip_fuzzy_global,u.lastIndex=0;null!==(i=u.exec(t));)n.push({schema:"",index:i.index+i[1].length,lastIndex:i.index+i[0].length});if(this.__opts__.fuzzyEmail&&this.__compiled__["mailto:"])for(u=this.re.email_fuzzy_global,u.lastIndex=0;null!==(i=u.exec(t));)s.push({schema:"mailto:",index:i.index+i[1].length,lastIndex:i.index+i[0].length});const a=[0,0,0];let l=0;for(;;){const i=[r[a[0]],s[a[1]],n[a[2]]],o=c(c(i[0],i[1]),i[2]);if(!o)break;if(o===i[0]?a[0]++:o===i[1]?a[1]++:a[2]++,o.index<l)continue;const u=new xe(t,o.schema,o.index,o.lastIndex);this.__compiled__[u.schema].normalize(u,this),e.push(u),l=o.lastIndex}return e.length?e:null},ve.prototype.matchAtStart=function(t){if(!t.length)return null;const e=this.re.schema_at_start.exec(t);if(!e)return null;const r=this.testSchemaAt(t,e[2],e[0].length);if(!r)return null;const n=new xe(t,e[2],e.index+e[1].length,e.index+e[0].length+r);return this.__compiled__[n.schema].normalize(n,this),n},ve.prototype.tlds=function(t,e){return t=Array.isArray(t)?t:[t],e?(this.__tlds__=this.__tlds__.concat(t).sort().filter(function(t,e,r){return t!==r[e-1]}).reverse(),Fe(this),this):(this.__tlds__=t.slice(),this.__tlds_replaced__=!0,Fe(this),this)},ve.prototype.normalize=function(t){t.schema||(t.url=`http://${t.url}`),"mailto:"!==t.schema||/^mailto:/i.test(t.url)||(t.url=`mailto:${t.url}`)},ve.prototype.onCompile=function(){};var we=2147483647,ze=36,Se=/^xn--/,qe=/[^\0-\x7F]/,Be=/[\x2E\u3002\uFF0E\uFF61]/g,Le={overflow:"Overflow: input needs wider integers to process","not-basic":"Illegal input >= 0x80 (not a basic code point)","invalid-input":"Invalid input"},Ie=Math.floor,Me=String.fromCharCode;function Te(t){throw new RangeError(Le[t])}function $e(t,e){const r=t.split("@");let n="";r.length>1&&(n=r[0]+"@",t=r[1]);const s=function(t,e){const r=[];let n=t.length;for(;n--;)r[n]=e(t[n]);return r}((t=t.replace(Be,".")).split("."),e).join(".");return n+s}function Re(t){const e=[];let r=0;const n=t.length;for(;r<n;){const s=t.charCodeAt(r++);if(s>=55296&&s<=56319&&r<n){const n=t.charCodeAt(r++);56320==(64512&n)?e.push(((1023&s)<<10)+(1023&n)+65536):(e.push(s),r--)}else e.push(s)}return e}var Ne=function(t){return t>=48&&t<58?t-48+26:t>=65&&t<91?t-65:t>=97&&t<123?t-97:ze},Pe=function(t,e){return t+22+75*(t<26)-((0!=e)<<5)},Oe=function(t,e,r){let n=0;for(t=r?Ie(t/700):t>>1,t+=Ie(t/e);t>455;n+=ze)t=Ie(t/35);return Ie(n+36*t/(t+38))},Ze=function(t){const e=[],r=t.length;let n=0,s=128,i=72,o=t.lastIndexOf("-");o<0&&(o=0);for(let u=0;u<o;++u)t.charCodeAt(u)>=128&&Te("not-basic"),e.push(t.charCodeAt(u));for(let u=o>0?o+1:0;u<r;){const o=n;for(let e=1,s=ze;;s+=ze){u>=r&&Te("invalid-input");const o=Ne(t.charCodeAt(u++));o>=ze&&Te("invalid-input"),o>Ie((we-n)/e)&&Te("overflow"),n+=o*e;const c=s<=i?1:s>=i+26?26:s-i;if(o<c)break;const a=ze-c;e>Ie(we/a)&&Te("overflow"),e*=a}const c=e.length+1;i=Oe(n-o,c,0==o),Ie(n/c)>we-s&&Te("overflow"),s+=Ie(n/c),n%=c,e.splice(n++,0,s)}return String.fromCodePoint(...e)},je=function(t){const e=[],r=(t=Re(t)).length;let n=128,s=0,i=72;for(const c of t)c<128&&e.push(Me(c));const o=e.length;let u=o;for(o&&e.push("-");u<r;){let r=we;for(const e of t)e>=n&&e<r&&(r=e);const c=u+1;r-n>Ie((we-s)/c)&&Te("overflow"),s+=(r-n)*c,n=r;for(const a of t)if(a<n&&++s>we&&Te("overflow"),a===n){let t=s;for(let r=ze;;r+=ze){const n=r<=i?1:r>=i+26?26:r-i;if(t<n)break;const s=t-n,o=ze-n;e.push(Me(Pe(n+s%o,0))),t=Ie(s/o)}e.push(Me(Pe(t,0))),i=Oe(s,c,u===o),s=0,++u}++s,++n}return e.join("")},Ue=function(t){return $e(t,function(t){return qe.test(t)?"xn--"+je(t):t})},He=function(t){return $e(t,function(t){return Se.test(t)?Ze(t.slice(4).toLowerCase()):t})},Ve={default:{options:{html:!1,xhtmlOut:!1,breaks:!1,langPrefix:"language-",linkify:!1,typographer:!1,quotes:"\u201c\u201d\u2018\u2019",highlight:null,maxNesting:100},components:{core:{},block:{},inline:{}}},zero:{options:{html:!1,xhtmlOut:!1,breaks:!1,langPrefix:"language-",linkify:!1,typographer:!1,quotes:"\u201c\u201d\u2018\u2019",highlight:null,maxNesting:20},components:{core:{rules:["normalize","block","inline","text_join"]},block:{rules:["paragraph"]},inline:{rules:["text"],rules2:["balance_pairs","fragments_join"]}}},commonmark:{options:{html:!0,xhtmlOut:!0,breaks:!1,langPrefix:"language-",linkify:!1,typographer:!1,quotes:"\u201c\u201d\u2018\u2019",highlight:null,maxNesting:20},components:{core:{rules:["normalize","block","inline","text_join"]},block:{rules:["blockquote","code","fence","heading","hr","html_block","lheading","list","reference","paragraph"]},inline:{rules:["autolink","backticks","emphasis","entity","escape","html_inline","image","link","newline","text"],rules2:["balance_pairs","emphasis","fragments_join"]}}}},Ge=/^(vbscript|javascript|file|data):/,We=/^data:image\/(gif|png|jpeg|webp);/;function Je(t){const e=t.trim().toLowerCase();return!Ge.test(e)||We.test(e)}var Qe=["http:","https:","mailto:"];function Xe(t){const e=D(t,!0);if(e.hostname&&(!e.protocol||Qe.indexOf(e.protocol)>=0))try{e.hostname=Ue(e.hostname)}catch(r){}return i(o(e))}function Ye(t){const e=D(t,!0);if(e.hostname&&(!e.protocol||Qe.indexOf(e.protocol)>=0))try{e.hostname=He(e.hostname)}catch(r){}return n(o(e),n.defaultChars+"%")}function Ke(t,e){if(!(this instanceof Ke))return new Ke(t,e);e||H(t)||(e=t||{},t="default"),this.inline=new ge,this.block=new ee,this.core=new Ht,this.renderer=new Ft,this.linkify=new ve,this.validateLink=Je,this.normalizeLink=Xe,this.normalizeLinkText=Ye,this.utils=U,this.helpers=W({},At),this.options={},this.configure(t),e&&this.set(e)}return Ke.prototype.set=function(t){return W(this.options,t),this},Ke.prototype.configure=function(t){const e=this;if(H(t)){const e=t;if(!(t=Ve[e]))throw new Error('Wrong `markdown-it` preset "'+e+'", check name')}if(!t)throw new Error("Wrong `markdown-it` preset, can't be empty");return t.options&&e.set(t.options),t.components&&Object.keys(t.components).forEach(function(r){t.components[r].rules&&e[r].ruler.enableOnly(t.components[r].rules),t.components[r].rules2&&e[r].ruler2.enableOnly(t.components[r].rules2)}),this},Ke.prototype.enable=function(t,e){let r=[];Array.isArray(t)||(t=[t]),["core","block","inline"].forEach(function(e){r=r.concat(this[e].ruler.enable(t,!0))},this),r=r.concat(this.inline.ruler2.enable(t,!0));const n=t.filter(function(t){return r.indexOf(t)<0});if(n.length&&!e)throw new Error("MarkdownIt. Failed to enable unknown rule(s): "+n);return this},Ke.prototype.disable=function(t,e){let r=[];Array.isArray(t)||(t=[t]),["core","block","inline"].forEach(function(e){r=r.concat(this[e].ruler.disable(t,!0))},this),r=r.concat(this.inline.ruler2.disable(t,!0));const n=t.filter(function(t){return r.indexOf(t)<0});if(n.length&&!e)throw new Error("MarkdownIt. Failed to disable unknown rule(s): "+n);return this},Ke.prototype.use=function(t){const e=[this].concat(Array.prototype.slice.call(arguments,1));return t.apply(t,e),this},Ke.prototype.parse=function(t,e){if("string"!=typeof t)throw new Error("Input data should be a String");const r=new this.core.State(t,this,e);return this.core.process(r),r.tokens},Ke.prototype.render=function(t,e){return e=e||{},this.renderer.render(this.parse(t,e),this.options,e)},Ke.prototype.parseInline=function(t,e){const r=new this.core.State(t,this,e);return r.inlineMode=!0,this.core.process(r),r.tokens},Ke.prototype.renderInline=function(t,e){return e=e||{},this.renderer.render(this.parseInline(t,e),this.options,e)},Ke});
3//# sourceMappingURL=markdown-it.min.js.map
\ No newline at end of file
service/shale/readme/purify.min.js created+3
......@@ -0,0 +1,3 @@
1/*! @license DOMPurify 3.4.15 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.15/LICENSE */
2!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).DOMPurify=e()}(this,function(){"use strict";function t(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=Array(e);n<e;n++)o[n]=t[n];return o}function e(e,n){return function(t){if(Array.isArray(t))return t}(e)||function(t,e){var n=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=n){var o,r,i,a,l=[],c=!0,s=!1;try{if(i=(n=n.call(t)).next,0===e);else for(;!(c=(o=i.call(n)).done)&&(l.push(o.value),l.length!==e);c=!0);}catch(t){s=!0,r=t}finally{try{if(!c&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(s)throw r}}return l}}(e,n)||function(e,n){if(e){if("string"==typeof e)return t(e,n);var o={}.toString.call(e).slice(8,-1);return"Object"===o&&e.constructor&&(o=e.constructor.name),"Map"===o||"Set"===o?Array.from(e):"Arguments"===o||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(o)?t(e,n):void 0}}(e,n)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}const n=Object.entries,o=Object.setPrototypeOf,r=Object.isFrozen,i=Object.getPrototypeOf,a=Object.getOwnPropertyDescriptor;let l=Object.freeze,c=Object.seal,s=Object.create,u="undefined"!=typeof Reflect&&Reflect,f=u.apply,p=u.construct;l||(l=function(t){return t}),c||(c=function(t){return t}),f||(f=function(t,e){for(var n=arguments.length,o=new Array(n>2?n-2:0),r=2;r<n;r++)o[r-2]=arguments[r];return t.apply(e,o)}),p||(p=function(t){for(var e=arguments.length,n=new Array(e>1?e-1:0),o=1;o<e;o++)n[o-1]=arguments[o];return new t(...n)});const m=L(Array.prototype.forEach),d=L(Array.prototype.lastIndexOf),h=L(Array.prototype.pop),y=L(Array.prototype.push),g=L(Array.prototype.splice),b=Array.isArray,S=L(String.prototype.toLowerCase),T=L(String.prototype.toString),A=L(String.prototype.match),E=L(String.prototype.replace),w=L(String.prototype.indexOf),v=L(String.prototype.trim),O=L(Number.prototype.toString),N=L(Boolean.prototype.toString),x="undefined"==typeof BigInt?null:L(BigInt.prototype.toString),_="undefined"==typeof Symbol?null:L(Symbol.prototype.toString),D=L(Object.prototype.hasOwnProperty),R=L(Object.prototype.toString),k=L(RegExp.prototype.test),C=(I=TypeError,function(){for(var t=arguments.length,e=new Array(t),n=0;n<t;n++)e[n]=arguments[n];return p(I,e)});var I;function L(t){return function(e){e instanceof RegExp&&(e.lastIndex=0);for(var n=arguments.length,o=new Array(n>1?n-1:0),r=1;r<n;r++)o[r-1]=arguments[r];return f(t,e,o)}}function z(t,e){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:S;if(o&&o(t,null),!b(e))return t;let i=e.length;for(;i--;){let o=e[i];if("string"==typeof o){const t=n(o);t!==o&&(r(e)||(e[i]=t),o=t)}t[o]=!0}return t}function M(t){for(let e=0;e<t.length;e++){D(t,e)||(t[e]=null)}return t}function P(t){const o=s(null);for(const i of n(t)){var r=e(i,2);const n=r[0],a=r[1];D(t,n)&&(b(a)?o[n]=M(a):a&&"object"==typeof a&&a.constructor===Object?o[n]=P(a):o[n]=a)}return o}function U(t,e){for(;null!==t;){const n=a(t,e);if(n){if(n.get)return L(n.get);if("function"==typeof n.value)return L(n.value)}t=i(t)}return function(){return null}}const F=l(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","search","section","select","shadow","slot","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),H=l(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","enterkeyhint","exportparts","filter","font","g","glyph","glyphref","hkern","image","inputmode","line","lineargradient","marker","mask","metadata","mpath","part","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),j=l(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),B=l(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),W=l(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),Y=l(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),G=l(["#text"]),q=l(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","command","commandfor","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","exportparts","face","for","headers","height","hidden","high","href","hreflang","id","inert","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","part","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","slot","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","wrap","xmlns"]),$=l(["accent-height","accumulate","additive","alignment-baseline","amplitude","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dominant-baseline","dur","edgemode","elevation","end","exponent","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","intercept","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","mask-type","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","pointer-events","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","slope","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","tablevalues","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-orientation","text-rendering","textlength","type","u1","u2","unicode","values","vector-effect","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),X=l(["accent","accentunder","align","bevelled","close","columnalign","columnlines","columnspacing","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lquote","lspace","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),K=l(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),V=c(/{{[\w\W]*|^[\w\W]*}}/g),Z=c(/<%[\w\W]*|^[\w\W]*%>/g),J=c(/\${[\w\W]*/g),Q=c(/^data-[\-\w.\u00B7-\uFFFF]+$/),tt=c(/^aria-[\-\w]+$/),et=c(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),nt=c(/^(?:\w+script|data):/i),ot=c(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),rt=c(/^html$/i),it=c(/^[a-z][.\w]*(-[.\w]+)+$/i),at=c(/<[/\w!]/g),lt=c(/<[/\w]/g),ct=c(/<\/no(script|embed|frames)/i),st=c(/\/>/i),ut=1,ft=3,pt=7,mt=8,dt=9,ht=11,yt=["style","script","xmp","iframe","noembed","noframes","plaintext","noscript"],gt=l(z({},yt)),bt=function(){const t={};return m(yt,e=>{t[e]=c(new RegExp("</"+e+"(?=[\\t\\n\\f\\r />])","i"))}),l(t)}(),St=function(){return"undefined"==typeof window?null:window},Tt=function(t,e,n,o){return D(t,e)&&b(t[e])?z(o.base?P(o.base):{},t[e],o.transform):n},At=function(t,e,n){const o=D(t,e)?t[e]:void 0;return o&&"object"==typeof o?P(o):n()};var Et=function t(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:St();const o=e=>t(e);if(o.version="3.4.15",o.removed=[],!e||!e.document||e.document.nodeType!==dt||!e.Element)return o.isSupported=!1,o;let r=e.document;const i=r,a=i.currentScript;e.DocumentFragment;const u=e.HTMLTemplateElement,f=e.Node,p=e.Element,I=e.NodeFilter,L=e.NamedNodeMap;void 0===L&&(e.NamedNodeMap||e.MozNamedAttrMap),e.HTMLFormElement;const M=e.DOMParser,yt=e.trustedTypes,Et=p.prototype,wt=U(Et,"cloneNode"),vt=U(Et,"remove"),Ot=U(Et,"removeAttributeNode"),Nt=U(Et,"nextSibling"),xt=U(Et,"childNodes"),_t=U(Et,"parentNode"),Dt=U(Et,"shadowRoot"),Rt=U(Et,"attributes"),kt=f&&f.prototype?U(f.prototype,"nodeType"):null,Ct=f&&f.prototype?U(f.prototype,"nodeName"):null,It=f&&f.prototype?U(f.prototype,"ownerDocument"):null,Lt=function(t){return kt?kt(t):t.nodeType},zt=function(t){return Ct?Ct(t):t.nodeName};if("function"==typeof u){const t=r.createElement("template");t.content&&t.content.ownerDocument&&(r=t.content.ownerDocument)}let Mt,Pt,Ut="",Ft=!1,Ht=0;const jt=function(){if(Ht>0)throw C('A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.')},Bt=function(t){jt(),Ht++;try{return Mt.createHTML(t)}finally{Ht--}},Wt=function(){return Ft||(Pt=function(t,e){if("object"!=typeof t||"function"!=typeof t.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";e&&e.hasAttribute(o)&&(n=e.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return t.createPolicy(r,{createHTML:t=>t,createScriptURL:t=>t})}catch(t){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(yt,a),Ft=!0),Pt},Yt=r,Gt=Yt.implementation,qt=Yt.createNodeIterator,$t=Yt.createDocumentFragment,Xt=Yt.getElementsByTagName,Kt=i.importNode;let Vt={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof n&&"function"==typeof _t&&Gt&&void 0!==Gt.createHTMLDocument;const Zt=V,Jt=Z,Qt=J,te=Q,ee=tt,ne=nt,oe=ot,re=it;let ie=et,ae=null;const le=z({},[...F,...H,...j,...W,...G]);let ce=null;const se=z({},[...q,...$,...X,...K]);let ue=Object.seal(s(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),fe=null,pe=null;const me=Object.seal(s(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let de=!0,he=!0,ye=!1,ge=!0,be=!1,Se=!0,Te=!1,Ae=!1,Ee=null,we=null,ve=!1,Oe=!1,Ne=!1,xe=!1,_e=!0,De=!1;const Re="user-content-";let ke=!0,Ce=!1,Ie={},Le=null;const ze=z({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","selectedcontent","style","svg","template","thead","title","video","xmp"]);let Me=null;const Pe=z({},["audio","video","img","source","image","track"]);let Ue=null;const Fe=z({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),He="http://www.w3.org/1998/Math/MathML",je="http://www.w3.org/2000/svg",Be="http://www.w3.org/1999/xhtml";let We=Be,Ye=!1,Ge=null;const qe=z({},[He,je,Be],T),$e=l(["mi","mo","mn","ms","mtext"]);let Xe=z({},$e);const Ke=l(["annotation-xml"]);let Ve=z({},Ke);const Ze=z({},["title","style","font","a","script"]);let Je=null;const Qe=["application/xhtml+xml","text/html"];let tn=null,en=null;const nn=r.createElement("form"),on=function(t){return t instanceof RegExp||t instanceof Function},rn=function(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(en&&en===t)return;t&&"object"==typeof t||(t={}),t=P(t),Je=-1===Qe.indexOf(t.PARSER_MEDIA_TYPE)?"text/html":t.PARSER_MEDIA_TYPE,tn="application/xhtml+xml"===Je?T:S,ae=Tt(t,"ALLOWED_TAGS",le,{transform:tn}),ce=Tt(t,"ALLOWED_ATTR",se,{transform:tn}),Ge=Tt(t,"ALLOWED_NAMESPACES",qe,{transform:T}),Ue=Tt(t,"ADD_URI_SAFE_ATTR",Fe,{transform:tn,base:Fe}),Me=Tt(t,"ADD_DATA_URI_TAGS",Pe,{transform:tn,base:Pe}),Le=Tt(t,"FORBID_CONTENTS",ze,{transform:tn}),fe=Tt(t,"FORBID_TAGS",P({}),{transform:tn}),pe=Tt(t,"FORBID_ATTR",P({}),{transform:tn}),Ie=!!D(t,"USE_PROFILES")&&(t.USE_PROFILES&&"object"==typeof t.USE_PROFILES?P(t.USE_PROFILES):t.USE_PROFILES),de=!1!==t.ALLOW_ARIA_ATTR,he=!1!==t.ALLOW_DATA_ATTR,ye=t.ALLOW_UNKNOWN_PROTOCOLS||!1,ge=!1!==t.ALLOW_SELF_CLOSE_IN_ATTR,be=t.SAFE_FOR_TEMPLATES||!1,Se=!1!==t.SAFE_FOR_XML,Te=t.WHOLE_DOCUMENT||!1,Oe=t.RETURN_DOM||!1,Ne=t.RETURN_DOM_FRAGMENT||!1,xe=t.RETURN_TRUSTED_TYPE||!1,ve=t.FORCE_BODY||!1,_e=!1!==t.SANITIZE_DOM,De=t.SANITIZE_NAMED_PROPS||!1,ke=!1!==t.KEEP_CONTENT,Ce=t.IN_PLACE||!1,ie=function(t){try{return k(t,""),!0}catch(t){return!1}}(t.ALLOWED_URI_REGEXP)?t.ALLOWED_URI_REGEXP:et,We="string"==typeof t.NAMESPACE?t.NAMESPACE:Be,Xe=At(t,"MATHML_TEXT_INTEGRATION_POINTS",()=>z({},$e)),Ve=At(t,"HTML_INTEGRATION_POINTS",()=>z({},Ke));const e=At(t,"CUSTOM_ELEMENT_HANDLING",()=>s(null));if(ue=s(null),D(e,"tagNameCheck")&&on(e.tagNameCheck)&&(ue.tagNameCheck=e.tagNameCheck),D(e,"attributeNameCheck")&&on(e.attributeNameCheck)&&(ue.attributeNameCheck=e.attributeNameCheck),D(e,"allowCustomizedBuiltInElements")&&"boolean"==typeof e.allowCustomizedBuiltInElements&&(ue.allowCustomizedBuiltInElements=e.allowCustomizedBuiltInElements),c(ue),be&&(he=!1),Ne&&(Oe=!0),Ie&&(ae=z({},G),ce=s(null),!0===Ie.html&&(z(ae,F),z(ce,q)),!0===Ie.svg&&(z(ae,H),z(ce,$),z(ce,K)),!0===Ie.svgFilters&&(z(ae,j),z(ce,$),z(ce,K)),!0===Ie.mathMl&&(z(ae,W),z(ce,X),z(ce,K))),me.tagCheck=null,me.attributeCheck=null,D(t,"ADD_TAGS")&&("function"==typeof t.ADD_TAGS?me.tagCheck=t.ADD_TAGS:b(t.ADD_TAGS)&&(ae===le&&(ae=P(ae)),z(ae,t.ADD_TAGS,tn))),D(t,"ADD_ATTR")&&("function"==typeof t.ADD_ATTR?me.attributeCheck=t.ADD_ATTR:b(t.ADD_ATTR)&&(ce===se&&(ce=P(ce)),z(ce,t.ADD_ATTR,tn))),D(t,"ADD_FORBID_CONTENTS")&&b(t.ADD_FORBID_CONTENTS)&&(Le===ze&&(Le=P(Le)),z(Le,t.ADD_FORBID_CONTENTS,tn)),ke&&(ae["#text"]=!0),Te&&z(ae,["html","head","body"]),ae.table&&(z(ae,["tbody"]),delete fe.tbody),t.TRUSTED_TYPES_POLICY){if("function"!=typeof t.TRUSTED_TYPES_POLICY.createHTML)throw C('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof t.TRUSTED_TYPES_POLICY.createScriptURL)throw C('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');const e=Mt;Mt=t.TRUSTED_TYPES_POLICY;try{Ut=Bt("")}catch(t){throw Mt=e,t}}else null===t.TRUSTED_TYPES_POLICY?(Mt=void 0,Ut=""):(void 0===Mt&&(Mt=Wt()),Mt&&"string"==typeof Ut&&(Ut=Bt("")));l&&l(t),en=t},an=z({},[...H,...j,...B]),ln=z({},[...W,...Y]),cn=function(t){let e=_t(t);e&&e.tagName||(e={namespaceURI:We,tagName:"template"});const n=S(t.tagName),o=S(e.tagName);return!!Ge[t.namespaceURI]&&(t.namespaceURI===je?function(t,e,n){return e.namespaceURI===Be?"svg"===t:e.namespaceURI===He?"svg"===t&&("annotation-xml"===n||Xe[n]):Boolean(an[t])}(n,e,o):t.namespaceURI===He?function(t,e,n){return e.namespaceURI===Be?"math"===t:e.namespaceURI===je?"math"===t&&Ve[n]:Boolean(ln[t])}(n,e,o):t.namespaceURI===Be?function(t,e,n){return!(e.namespaceURI===je&&!Ve[n])&&!(e.namespaceURI===He&&!Xe[n])&&!ln[t]&&(Ze[t]||!an[t])}(n,e,o):!("application/xhtml+xml"!==Je||!Ge[t.namespaceURI]))},sn=function(t){y(o.removed,{element:t});try{_t(t).removeChild(t)}catch(e){if(vt(t),!_t(t))throw C("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place")}},un=function(t,e,n){try{Ot(t,e)}catch(e){try{t.removeAttribute(n)}catch(t){}}},fn=function(t){dn(t);const e=xt(t);if(e){const t=[];m(e,e=>{y(t,e)}),m(t,t=>{try{vt(t)}catch(t){}})}const n=Rt(t);if(n)for(let e=n.length-1;e>=0;--e){const o=n[e],r=o&&o.name;"string"==typeof r&&un(t,o,r)}},pn=function(t,e,n){if(!n)try{n=e.getAttributeNode(t)}catch(t){n=null}y(o.removed,{attribute:n||null,from:e});try{n?Ot(e,n):e.removeAttribute(t)}catch(n){try{e.removeAttribute(t)}catch(t){}}if("is"===t)if(Oe||Ne)try{sn(e)}catch(t){}else try{e.setAttribute(t,"")}catch(t){}},mn=function(t){const e=Rt(t);if(e)for(let n=e.length-1;n>=0;--n){const o=e[n],r=o&&o.name;"string"!=typeof r||ce[tn(r)]||un(t,o,r)}},dn=function(t){const e=[t];for(;e.length>0;){const t=e.pop();Lt(t)===ut&&mn(t);const n=xt(t);if(n)for(let t=n.length-1;t>=0;--t)e.push(n[t])}},hn=function(t,e){return!!Se&&("patchsrc"===t||"for"===t&&"label"!==e&&"output"!==e)},yn=function(t){let e=null,n=null;if(ve)t="<remove></remove>"+t;else{const e=A(t,/^[\r\n\t ]+/);n=e&&e[0]}"application/xhtml+xml"===Je&&We===Be&&(t='<html xmlns="http://www.w3.org/1999/xhtml"><head></head><body>'+t+"</body></html>");const o=Mt?Bt(t):t;if(We===Be)try{e=(new M).parseFromString(o,Je)}catch(t){}if(!e||!e.documentElement){e=Gt.createDocument(We,"template",null);try{e.documentElement.innerHTML=Ye?Ut:o}catch(t){}}const i=e.body||e.documentElement;return t&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),We===Be?Xt.call(e,Te?"html":"body")[0]:Te?e.documentElement:i},gn=function(t){const e=It?It(t):t.ownerDocument;return qt.call(e||t,t,I.SHOW_ELEMENT|I.SHOW_COMMENT|I.SHOW_TEXT|I.SHOW_PROCESSING_INSTRUCTION|I.SHOW_CDATA_SECTION,null)},bn=function(t){return t=E(t,Zt," "),t=E(t,Jt," "),t=E(t,Qt," ")},Sn=function(t){var e;t.normalize();const n=It?It(t):t.ownerDocument,o=qt.call(n||t,t,I.SHOW_TEXT|I.SHOW_COMMENT|I.SHOW_CDATA_SECTION|I.SHOW_PROCESSING_INSTRUCTION,null);let r=o.nextNode();for(;r;)r.data=bn(r.data),r=o.nextNode();const i=null===(e=t.querySelectorAll)||void 0===e?void 0:e.call(t,"template");i&&m(i,t=>{An(t.content)&&Sn(t.content)})},Tn=function(t){const e=Ct?Ct(t):null;return"string"==typeof e&&("form"===tn(e)&&("string"!=typeof t.nodeName||"string"!=typeof t.textContent||"function"!=typeof t.removeChild||t.attributes!==Rt(t)||"function"!=typeof t.removeAttribute||"function"!=typeof t.removeAttributeNode||"function"!=typeof t.getAttributeNode||"function"!=typeof t.setAttribute||"string"!=typeof t.namespaceURI||"function"!=typeof t.insertBefore||"function"!=typeof t.hasChildNodes||t.nodeType!==kt(t)||t.childNodes!==xt(t)))},An=function(t){if(!kt||"object"!=typeof t||null===t)return!1;try{return kt(t)===ht}catch(t){return!1}},En=function(t){if(!kt||"object"!=typeof t||null===t)return!1;try{return"number"==typeof kt(t)}catch(t){return!1}};function wn(t,e,n){0!==t.length&&m(t,t=>{t.call(o,e,n,en)})}const vn=function(t,e){if(t instanceof RegExp)return k(t,e);if(t instanceof Function){for(var n=arguments.length,o=new Array(n>2?n-2:0),r=2;r<n;r++)o[r-2]=arguments[r];return Boolean(t(e,...o))}return!1},On=function(t,e,n,o){return 0===t.length?e:e===n||e===o?P(e):e},Nn=function(t,e){return t!==e&&null===_t(t)&&(Ce&&dn(t),!0)},xn=function(t,e){if(wn(Vt.beforeSanitizeElements,t,null),Nn(t,e))return!0;if(Tn(t))return sn(t),!0;const n=tn(zt(t));if(ae=On(Vt.uponSanitizeElement,ae,le,Ee),wn(Vt.uponSanitizeElement,t,{tagName:n,allowedTags:ae}),Nn(t,e))return!0;if(function(t,e){return!!(Se&&t.hasChildNodes()&&!En(t.firstElementChild)&&k(at,t.textContent)&&k(at,t.innerHTML))||!!(Se&&t.namespaceURI===Be&&gt[e]&&(En(t.firstElementChild)||"string"==typeof t.textContent&&k(bt[e],t.textContent)))||t.nodeType===pt||!(!Se||t.nodeType!==mt||!k(lt,t.data))}(t,n))return sn(t),!0;if(fe[n]||!(me.tagCheck instanceof Function&&me.tagCheck(n))&&!ae[n]){const o=function(t,e,n){if(!fe[e]&&Rn(e)&&vn(ue.tagNameCheck,e))return!1;if(ke&&!Le[e]){const e=_t(t),o=xt(t);if(o&&e)for(let r=o.length-1;r>=0;--r){const i=t===n?wt(o[r],!0):o[r];e.insertBefore(i,Nt(t))}}return sn(t),!0}(t,n,e);return!1===o&&wn(Vt.afterSanitizeElements,t,null),o}if(Lt(t)===ut&&!cn(t))return sn(t),!0;if(("noscript"===n||"noembed"===n||"noframes"===n)&&k(ct,t.innerHTML))return sn(t),!0;if(be&&t.nodeType===ft){const e=bn(t.textContent);t.textContent!==e&&(y(o.removed,{element:t.cloneNode()}),t.textContent=e)}return wn(Vt.afterSanitizeElements,t,null),!1},_n=function(t,e,n){if(pe[e])return!1;if(hn(e,t))return!1;if(_e&&("id"===e||"name"===e)&&(n in r||n in nn))return!1;const o=ce[e]||me.attributeCheck instanceof Function&&me.attributeCheck(e,t);return!(!he||!k(te,e))||(!(!de||!k(ee,e))||(o?!!Ue[e]||(!!k(ie,E(n,oe,""))||(!("src"!==e&&"xlink:href"!==e&&"href"!==e||"script"===t||0!==w(n,"data:")||!Me[t])||(!(!ye||k(ne,E(n,oe,"")))||!n))):Rn(t)&&vn(ue.tagNameCheck,t)&&vn(ue.attributeNameCheck,e,t)||"is"===e&&ue.allowCustomizedBuiltInElements&&vn(ue.tagNameCheck,n)))},Dn=z({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),Rn=function(t){return!Dn[S(t)]&&k(re,t)},kn=function(t,e,n,o){if(Mt&&"object"==typeof yt&&"function"==typeof yt.getAttributeType&&!n)switch(yt.getAttributeType(t,e)){case"TrustedHTML":return Bt(o);case"TrustedScriptURL":return function(t){jt(),Ht++;try{return Mt.createScriptURL(t)}finally{Ht--}}(o)}return o},Cn=function(t,e,n,o){try{return n?t.setAttributeNS(n,e,o):t.setAttribute(e,o),!Tn(t)||(sn(t),!1)}catch(n){return pn(e,t),!1}},In=function(t){wn(Vt.beforeSanitizeAttributes,t,null);const e=t.attributes;if(!e||Tn(t))return;ce=On(Vt.uponSanitizeAttribute,ce,se,we);const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:ce,forceKeepAttr:void 0};let r=e.length;const i=tn(t.nodeName);for(;r--;){const a=e[r],l=a.name,c=a.namespaceURI,s=a.value,u=tn(l),f=s;let p="value"===l?f:v(f),m=!1;if(n.attrName=u,n.attrValue=p,n.keepAttr=!0,n.forceKeepAttr=void 0,wn(Vt.uponSanitizeAttribute,t,n),p=n.attrValue,!De||"id"!==u&&"name"!==u||0===w(p,Re)||(pn(l,t,a),p=Re+p,m=!0),Se&&k(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,p))pn(l,t,a);else if("attributename"===u&&A(p,"href"))pn(l,t,a);else if(!n.forceKeepAttr)if(n.keepAttr)if(ge||!k(st,p))if(be&&(p=bn(p)),_n(i,u,p)){if(p=kn(i,u,c,p),p!==f){Cn(t,l,c,p)&&m&&h(o.removed)}}else pn(l,t,a);else pn(l,t,a);else pn(l,t,a)}wn(Vt.afterSanitizeAttributes,t,null)},Ln=function(t){let e=null;const n=gn(t);for(wn(Vt.beforeSanitizeShadowDOM,t,null);e=n.nextNode();)if(wn(Vt.uponSanitizeShadowNode,e,null),xn(e,t),In(e),An(e.content)&&Ln(e.content),Lt(e)===ut){const t=Dt(e);An(t)&&(zn(t),Ln(t))}wn(Vt.afterSanitizeShadowDOM,t,null)},zn=function(t){const e=[{node:t,shadow:null}];for(;e.length>0;){const t=e.pop();if(t.shadow){Ln(t.shadow);continue}const n=t.node,o=Lt(n)===ut,r=xt(n);if(r)for(let t=r.length-1;t>=0;--t)e.push({node:r[t],shadow:null});if(o){const t=Ct?Ct(n):null;if("string"==typeof t&&"template"===tn(t)){const t=n.content;An(t)&&e.push({node:t,shadow:null})}}if(o){const t=Dt(n);An(t)&&e.push({node:null,shadow:t},{node:t,shadow:null})}}};return o.sanitize=function(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,a=null,l=null;if(Ye=!t,Ye&&(t="\x3c!--\x3e"),"string"!=typeof t&&!En(t)&&"string"!=typeof(t=function(t){switch(typeof t){case"string":return t;case"number":return O(t);case"boolean":return N(t);case"bigint":return x?x(t):"0";case"symbol":return _?_(t):"Symbol()";case"undefined":default:return R(t);case"function":case"object":{if(null===t)return R(t);const e=t,n=U(e,"toString");if("function"==typeof n){const t=n(e);return"string"==typeof t?t:R(t)}return R(t)}}}(t)))throw C("dirty is not a string, aborting");if(!o.isSupported)return t;Ae?(ae=Ee,ce=we):rn(e),(Vt.uponSanitizeElement.length>0||Vt.uponSanitizeAttribute.length>0)&&(ae=P(ae)),Vt.uponSanitizeAttribute.length>0&&(ce=P(ce)),o.removed=[];const c=Ce&&"string"!=typeof t&&En(t);if(c){!function(t){if(!Se)return;const e=[t];for(;e.length>0;){const t=e.pop(),n=Lt(t);if(n===pt||n===mt&&k(lt,t.data)){try{vt(t)}catch(t){}continue}if(n===ut){const e=t,n=tn(zt(t));try{e.hasAttribute&&e.hasAttribute("patchsrc")&&e.removeAttribute("patchsrc"),e.hasAttribute&&e.hasAttribute("for")&&hn("for",n)&&e.removeAttribute("for")}catch(t){}}const o=xt(t);if(o)for(let t=o.length-1;t>=0;--t)e.push(o[t])}}(t);const e=zt(t);if("string"==typeof e){const n=tn(e);if(!ae[n]||fe[n])throw fn(t),C("root node is forbidden and cannot be sanitized in-place")}if(Tn(t))throw fn(t),C("root node is clobbered and cannot be sanitized in-place");try{zn(t)}catch(e){throw fn(t),e}}else if(En(t))n=yn("\x3c!----\x3e"),r=n.ownerDocument.importNode(t,!0),r.nodeType===ut&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r),zn(n);else{if(!Oe&&!be&&!Te&&-1===t.indexOf("<"))return Mt&&xe?Bt(t):t;if(n=yn(t),!n)return Oe?null:xe?Ut:""}n&&ve&&sn(n.firstChild);const s=c?t:n;try{const t=gn(s);for(;a=t.nextNode();)xn(a,s),In(a),An(a.content)&&Ln(a.content)}catch(e){throw c&&(fn(t),m(o.removed,t=>{t.element&&dn(t.element)})),e}if(c)return m(o.removed,t=>{t.element&&dn(t.element)}),be&&Sn(t),t;if(Oe){if(be&&Sn(n),Ne)for(l=$t.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(ce.shadowroot||ce.shadowrootmode)&&(l=Kt.call(i,l,!0)),l}let u=Te?n.outerHTML:n.innerHTML;return Te&&ae["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&k(rt,n.ownerDocument.doctype.name)&&(u="<!DOCTYPE "+n.ownerDocument.doctype.name+">\n"+u),be&&(u=bn(u)),Mt&&xe?Bt(u):u},o.setConfig=function(){rn(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Ae=!0,Ee=ae,we=ce},o.clearConfig=function(){en=null,Ae=!1,Ee=null,we=null,Mt=Pt,Ut=""},o.isValidAttribute=function(t,e,n){en||rn({});const o=tn(t),r=tn(e);return _n(o,r,n)},o.addHook=function(t,e){"function"==typeof e&&D(Vt,t)&&y(Vt[t],e)},o.removeHook=function(t,e){if(D(Vt,t)){if(void 0!==e){const n=d(Vt[t],e);return-1===n?void 0:g(Vt[t],n,1)[0]}return h(Vt[t])}},o.removeHooks=function(t){D(Vt,t)&&(Vt[t]=[])},o.removeAllHooks=function(){Vt={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return Et});
3//# sourceMappingURL=purify.min.js.map
service/shale/readme/readme.js created+39
......@@ -0,0 +1,39 @@
1(async () => {
2 const readme = document.querySelector("#readme .markdown");
3 if (!readme || !window.markdownit || !window.DOMPurify) return;
4
5 const response = await fetch("/snowbound/plain/main/readme.md");
6 if (!response.ok) return;
7
8 const markdown = await response.text();
9 const rendered = window.markdownit({ html: true }).render(markdown);
10 const fragment = window.DOMPurify.sanitize(rendered, {
11 USE_PROFILES: { html: true },
12 RETURN_DOM_FRAGMENT: true,
13 });
14
15 const mediaBase = new URL("/snowbound/plain/main/", location.origin);
16 const linkBase = new URL("/snowbound/tree/main/", location.origin);
17 const rebase = (value, base) =>
18 value && !value.startsWith("/") && !value.startsWith("#") && !/^[a-z][a-z\d+.-]*:/i.test(value)
19 ? new URL(value, base).href
20 : value;
21
22 for (const image of fragment.querySelectorAll("img[src]")) {
23 image.src = rebase(image.getAttribute("src"), mediaBase);
24 for (const dimension of ["width", "height"]) {
25 const value = image.getAttribute(dimension);
26 if (value && /^\d+$/.test(value)) image.style[dimension] = `${value}px`;
27 }
28 }
29 for (const pictureSource of fragment.querySelectorAll("source[srcset]")) {
30 pictureSource.srcset = pictureSource.srcset.split(",").map(candidate => {
31 const [, path, descriptor] = candidate.trim().match(/^(\S+)(.*)$/) || [];
32 return path ? rebase(path, mediaBase) + descriptor : candidate;
33 }).join(", ");
34 }
35 for (const link of fragment.querySelectorAll("a[href]")) {
36 link.href = rebase(link.getAttribute("href"), linkBase);
37 }
38 readme.replaceChildren(fragment);
39})().catch(console.error);
service/shale/service.pkl created+65
......@@ -0,0 +1,65 @@
1amends "../../config/Service.pkl"
2
3import "../keycloak/service.pkl" as keycloak
4
5meta {
6 name = "Shale"
7}
8traceServiceName = "astheno.shale"
9dependsOn { "victoria-traces" }
10
11requirements {
12 new keycloak.OpenIDClient {
13 clientId = module.id
14 name = module.meta.name
15 }
16}
17
18container {
19 image = "docker.io/astheno/shale@sha256:ece987e25ebe67275fbe105b1b03e650a9b3adfe716928beb717cde1d32c9652"
20 entrypoint = "/bin/sh"
21 args { "-c"; "exec /app/astheno-shale --port 8000 --datadir /data --otel-exporter-otlp-traces-endpoint \"$STUDIO_TRACES_ENDPOINT\"" }
22 extraHosts { "\(keycloak.container.http.hostname):host-gateway" }
23
24 http {
25 containerPort = 8000
26 subdomain = "shale"
27 overrideFiles {
28 ["/-/theme.css"] = "theme.css"
29 ["/-/repo-icons/"] = "icons"
30 ["/-/studio-readme/"] = "readme"
31 }
32 headHtml {
33 ["/snowbound/"] = """
34 <script defer src="/-/studio-readme/markdown-it.min.js"></script><script defer src="/-/studio-readme/purify.min.js"></script><script defer src="/-/studio-readme/readme.js"></script>
35 """
36 }
37 }
38
39 volumes {
40 ["/data"] {}
41 ["/repositories_owned"] {}
42 ["/repositories_mirrors"] {}
43 ["/etc/crontabs"] {}
44 ["/etc/shale/theme.css"] { config = "theme.css" }
45 ["/etc/ssl/certs/ca-certificates.crt"] {
46 src = "/var/lib/studio/ca-bundle.crt"
47 readOnly = true
48 }
49 }
50
51 env {
52 ["DOMAIN"] = module.container.http.hostname
53 ["HOME"] = "/data"
54 ["SERVER_TITLE"] = "clover's git"
55 ["SESSION_SECRET"] = "${secret.own.session_secret}"
56 ["OAUTH2_CLIENT"] = "oidc,\(keycloak.container.http.hostname)/realms/master|${secret.oidc.clientId}|${secret.oidc.clientSecret}"
57 }
58 envTemplate = """
59 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "victoria-traces" }}STUDIO_TRACES_ENDPOINT=http://\(module.nomadHostPort)/insert/opentelemetry/v1/traces{{ end }}
60 """
61}
62
63secrets {
64 ["session_secret"] {}
65}
service/shale/theme.css created+418
......@@ -0,0 +1,418 @@
1@media (prefers-color-scheme: light) {
2 :root {
3 --theme-color-base-lightest: #fff8fb;
4 --theme-color-base-lighter: #f7e7ee;
5 --theme-color-base-light: #d9bdca;
6 --theme-color-base: #8b6879;
7 --theme-color-base-dark: #614957;
8 --theme-color-base-darker: #47343e;
9 --theme-color-base-darkest: #241a1f;
10 --theme-color-base-ink: #241a1f;
11 --theme-color-primary-lighter: #fde7f1;
12 --theme-color-primary-light: #f38ab7;
13 --theme-color-primary: #c2185b;
14 --theme-color-primary-vivid: #e91e63;
15 --theme-color-primary-dark: #9d174d;
16 --theme-color-primary-darker: #74113a;
17 --theme-body-background-color: #fffafd;
18 --theme-link-color: var(--theme-color-primary);
19 --theme-link-visited-color: #88436c;
20 --theme-link-hover-color: var(--theme-color-primary-dark);
21 --theme-link-active-color: var(--theme-color-primary-darker);
22 --theme-focus-color: var(--theme-color-primary-vivid);
23 --shale-color-cool-lightest: #f0effc;
24 --shale-color-cool-light: #d9d6f5;
25 --shale-color-cool: #5d55b3;
26 --shale-color-cool-dark: #47408f;
27 }
28
29 body {
30 background-color: #fffafd;
31 }
32
33 header {
34 background-color: var(--theme-color-primary);
35 }
36
37 .usa-header--extended .usa-nav {
38 background-color: #fff6fa;
39 border-bottom-color: #f4dce7;
40 }
41
42 .usa-nav__primary > .usa-nav__primary-item > a {
43 color: #543745;
44 }
45
46 .usa-nav__primary > .usa-nav__primary-item > a.usa-current {
47 color: var(--theme-color-primary-dark);
48 }
49
50 .usa-nav__primary > .usa-nav__primary-item > a:hover {
51 color: var(--theme-color-primary-dark);
52 background-color: #f9e7ef;
53 }
54
55 .usa-nav__primary > .usa-nav__primary-item > a.usa-current::after {
56 background-color: var(--theme-color-primary);
57 }
58
59 .usa-link,
60 .markdown a {
61 text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
62 }
63
64 .markdown a {
65 color: var(--theme-link-color);
66 }
67
68 .usa-table th,
69 .usa-table td,
70 .markdown th,
71 .markdown td {
72 border-color: #ead8e0;
73 border-width: 1px;
74 }
75
76 .usa-table thead th,
77 .usa-table thead td,
78 .usa-table tfoot th,
79 .usa-table tfoot td {
80 background-color: #f5e3eb;
81 }
82
83 details.m-usa-accordion > summary.m-usa-accordion__heading {
84 background-color: #f5e8ee;
85 }
86
87 details.m-usa-accordion > summary.m-usa-accordion__heading:hover {
88 background-color: #efd9e3;
89 }
90
91 details.m-usa-accordion > summary.m-usa-accordion__heading:focus {
92 outline-color: var(--theme-focus-color);
93 }
94
95 details.m-usa-accordion > .m-usa-accordion__content {
96 background-color: #fffdfe;
97 }
98
99 :is(.usa-input, .usa-textarea, .usa-select) {
100 border-color: #ad8a9a;
101 }
102
103 input:is([type="radio"], [type="checkbox"]) {
104 accent-color: var(--theme-color-primary);
105 }
106
107 :is(.usa-radio__input, .usa-checkbox__input):focus
108 + [class*="__label"]::before {
109 outline-color: var(--theme-focus-color);
110 }
111
112 :is(.usa-radio__input--tile, .usa-checkbox__input--tile):checked
113 + [class*="__label"] {
114 background-color: var(--theme-color-primary-lighter);
115 }
116
117 hr {
118 border-color: #c9aeba;
119 }
120
121 .markdown p > code,
122 kbd {
123 color: #543745;
124 background-color: #f3e4eb;
125 border-color: #d9bdca;
126 }
127
128 :is(td, span).idleage.weeks,
129 :is(td, span).idleage.months,
130 :is(td, span).idleage.years {
131 color: #8b6879;
132 }
133
134 :is(#page-log, #page-history) .usa-tag.tag {
135 color: white;
136 background-color: var(--shale-color-cool);
137 }
138
139 #page-commit details .adds,
140 #page-commit table.unified-diffs tr.added td:is(:nth-child(1), :nth-child(2)),
141 #page-commit table.unified-diffs tr.added td:nth-child(3)::before,
142 #page-commit table.split-diffs tr td.added:nth-child(odd),
143 #page-commit table.split-diffs tr td.added::before {
144 color: var(--shale-color-cool-dark);
145 }
146
147 #page-commit table.unified-diffs tr.added td,
148 #page-commit table.split-diffs tr td.added,
149 #page-commit :is(table.unified-diffs, table.split-diffs) tr.status td {
150 background-color: var(--shale-color-cool-lightest);
151 }
152
153 #page-commit table.unified-diffs tr.added
154 td:is(:nth-child(1), :nth-child(2)),
155 #page-commit table.split-diffs tr td.added:nth-child(odd) {
156 background-color: var(--shale-color-cool-light);
157 }
158
159 #page-commit #m-changedfiles a[data-icon="file-plus"]::before {
160 background-color: var(--shale-color-cool);
161 }
162
163 .issuestatus-todo use {
164 --stroke: var(--shale-color-cool);
165 }
166
167 ::highlight(co),
168 ::highlight(n),
169 ::highlight(at),
170 ::highlight(pr),
171 ::highlight(tu) {
172 color: var(--shale-color-cool-dark);
173 }
174
175 .usa-footer__secondary-section {
176 background-color: #f6e8ee;
177 }
178}
179
180@media (prefers-color-scheme: dark) {
181 :root {
182 --theme-color-base-lightest: #f9eef3;
183 --theme-color-base-lighter: #e8d4dd;
184 --theme-color-base-light: #b994a5;
185 --theme-color-base: #8d697a;
186 --theme-color-base-dark: #5d414e;
187 --theme-color-base-darker: #3f2b34;
188 --theme-color-base-darkest: #241820;
189 --theme-color-base-ink: #171015;
190 --theme-color-primary-lighter: #ffe0ec;
191 --theme-color-primary-light: #ff9fc3;
192 --theme-color-primary: #ff6fa9;
193 --theme-color-primary-vivid: #ff4f98;
194 --theme-color-primary-dark: #f24b8b;
195 --theme-color-primary-darker: #da3476;
196 --theme-body-background-color: #1d151a;
197 --theme-text-color: #f7edf2;
198 --theme-text-reverse-color: #21161c;
199 --theme-link-color: #ff8fba;
200 --theme-link-visited-color: #d9a3c1;
201 --theme-link-hover-color: #ffc0d8;
202 --theme-link-active-color: #ffd7e6;
203 --theme-focus-color: var(--theme-color-primary-vivid);
204 --shale-color-cool-lightest: #292641;
205 --shale-color-cool-light: #38345f;
206 --shale-color-cool: #a59cff;
207 --shale-color-cool-dark: #cbc7ff;
208 }
209
210 body {
211 background-color: var(--theme-body-background-color);
212 color: var(--theme-text-color);
213 }
214
215 header {
216 background-color: #861947;
217 }
218
219 .usa-header--extended .usa-nav {
220 background-color: #281c22;
221 border-bottom-color: #49313d;
222 }
223
224 .usa-nav__primary > .usa-nav__primary-item > a {
225 color: #ead7e0;
226 }
227
228 .usa-nav__primary > .usa-nav__primary-item > a.usa-current {
229 color: var(--theme-color-primary-light);
230 }
231
232 .usa-nav__primary > .usa-nav__primary-item > a:hover {
233 color: var(--theme-link-hover-color);
234 background-color: #3a2730;
235 }
236
237 .usa-nav__primary > .usa-nav__primary-item > a.usa-current::after {
238 background-color: var(--theme-color-primary);
239 }
240
241 .usa-link,
242 .markdown a {
243 text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
244 }
245
246 .markdown a {
247 color: var(--theme-link-color);
248 }
249
250 .usa-table th,
251 .usa-table td,
252 .markdown th,
253 .markdown td {
254 border-color: #503844;
255 border-width: 1px;
256 }
257
258 .usa-table thead th,
259 .usa-table thead td,
260 .usa-table tfoot th,
261 .usa-table tfoot td {
262 background-color: #34242c;
263 }
264
265 details.m-usa-accordion > summary.m-usa-accordion__heading {
266 background-color: #302129;
267 }
268
269 details.m-usa-accordion > summary.m-usa-accordion__heading:hover {
270 background-color: #422b36;
271 }
272
273 details.m-usa-accordion > summary.m-usa-accordion__heading:focus {
274 outline-color: var(--theme-focus-color);
275 }
276
277 details.m-usa-accordion > .m-usa-accordion__content {
278 color: var(--theme-text-color);
279 background-color: #23181e;
280 }
281
282 :is(.usa-input, .usa-textarea, .usa-select) {
283 color: var(--theme-text-color);
284 background-color: #24191f;
285 border-color: #765361;
286 }
287
288 input:is([type="radio"], [type="checkbox"]) {
289 accent-color: var(--theme-color-primary);
290 }
291
292 :is(.usa-radio__input, .usa-checkbox__input):focus
293 + [class*="__label"]::before {
294 outline-color: var(--theme-focus-color);
295 }
296
297 :is(.usa-radio__input--tile, .usa-checkbox__input--tile):checked
298 + [class*="__label"] {
299 background-color: #3b2330;
300 }
301
302 hr {
303 border-color: #654653;
304 }
305
306 .markdown p > code,
307 kbd {
308 color: #f1dce6;
309 background-color: #38262f;
310 border-color: #624451;
311 }
312
313 :is(td, span).idleage.weeks,
314 :is(td, span).idleage.months,
315 :is(td, span).idleage.years {
316 color: #bd9eac;
317 }
318
319 :is(#page-log, #page-history) .usa-tag.tag {
320 color: #17132b;
321 background-color: var(--shale-color-cool);
322 }
323
324 #page-commit details .adds,
325 #page-commit table.unified-diffs tr.added td:is(:nth-child(1), :nth-child(2)),
326 #page-commit table.unified-diffs tr.added td:nth-child(3)::before,
327 #page-commit table.split-diffs tr td.added:nth-child(odd),
328 #page-commit table.split-diffs tr td.added::before {
329 color: var(--shale-color-cool-dark);
330 }
331
332 #page-commit table.unified-diffs tr.added td,
333 #page-commit table.split-diffs tr td.added,
334 #page-commit :is(table.unified-diffs, table.split-diffs) tr.status td {
335 background-color: var(--shale-color-cool-lightest);
336 }
337
338 #page-commit table.unified-diffs tr.added
339 td:is(:nth-child(1), :nth-child(2)),
340 #page-commit table.split-diffs tr td.added:nth-child(odd) {
341 background-color: var(--shale-color-cool-light);
342 }
343
344 #page-commit #m-changedfiles a[data-icon="file-plus"]::before {
345 background-color: var(--shale-color-cool);
346 }
347
348 .issuestatus-todo use {
349 --stroke: var(--shale-color-cool);
350 }
351
352 ::highlight(co),
353 ::highlight(n),
354 ::highlight(at),
355 ::highlight(pr),
356 ::highlight(tu) {
357 color: var(--shale-color-cool-dark);
358 }
359
360 svg {
361 color: #f1dfe7;
362 }
363
364 use {
365 stroke: currentColor;
366 --stroke: currentColor;
367 }
368
369 .usa-card__container {
370 border-color: #503844;
371 }
372
373 .usa-footer__secondary-section {
374 background-color: #2b1e25;
375 }
376}
377
378.va-middle-childs > svg,
379.usa-header--basic .usa-logo a > svg {
380 margin-right: 4px;
381}
382
383/* Repo icons: forgejo avatars painted over the lucide glyph on the index and repo header. */
384.va-middle-childs > svg:has(+ a[href="./discord-name-painter/"]),
385.usa-header--basic a[href="/discord-name-painter/"] > svg {
386 background: url(/-/repo-icons/discord-name-painter.png) center / contain no-repeat;
387 & > use { display: none; }
388}
389.va-middle-childs > svg:has(+ a[href="./home-infra/"]),
390.usa-header--basic a[href="/home-infra/"] > svg {
391 background: url(/-/repo-icons/home-infra.png) center / contain no-repeat;
392 & > use { display: none; }
393}
394.va-middle-childs > svg:has(+ a[href="./markodown/"]),
395.usa-header--basic a[href="/markodown/"] > svg {
396 background: url(/-/repo-icons/markodown.png) center / contain no-repeat;
397 & > use { display: none; }
398}
399.va-middle-childs > svg:has(+ a[href="./react-mutation/"]),
400.usa-header--basic a[href="/react-mutation/"] > svg {
401 background: url(/-/repo-icons/react-mutation.png) center / contain no-repeat;
402 & > use { display: none; }
403}
404.va-middle-childs > svg:has(+ a[href="./sitegen/"]),
405.usa-header--basic a[href="/sitegen/"] > svg {
406 background: url(/-/repo-icons/sitegen.png) center / contain no-repeat;
407 & > use { display: none; }
408}
409.va-middle-childs > svg:has(+ a[href="./toolkit/"]),
410.usa-header--basic a[href="/toolkit/"] > svg {
411 background: url(/-/repo-icons/toolkit.png) center / contain no-repeat;
412 & > use { display: none; }
413}
414.va-middle-childs > svg:has(+ a[href="./snowbound/"]),
415.usa-header--basic a[href="/snowbound/"] > svg {
416 background: url(/-/repo-icons/snowbound.png) center / contain no-repeat;
417 & > use { display: none; }
418}
service/sonarr/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M511.8 256c0 70.4-24.9 130.8-74.6 181.1-1.7 2-3.5 3.8-5.5 5.4-8.2 8-16.8 15.3-26 21.8Q341.05 512 256.3 512c-56.6 0-106.3-15.9-149.2-47.7-11.3-8-22-17.1-31.9-27.3C36.5 398.7 12.8 354 4 303.2c-1.7-9.9-2.9-20-3.4-30.2-.2-5.7-.4-11.3-.4-17 0-6 .1-11.7.4-17.1 0-.6.2-1.1.5-1.7 3.7-62.8 28.4-117 74.1-162.8C125.5 24.8 185.8 0 256.2 0c70.7 0 131 24.8 180.9 74.5q74.7 75.9 74.7 181.5" style="fill-rule:evenodd;clip-rule:evenodd;fill:#eee"/><path d="m459.7 100.3-52.9 52.9c-30.9 30.9-33.6 57.8-33.6 105.3 0 42.3 6.7 81.1 38.2 112.6 23 23 44.9 44.7 44.9 44.7-5.9 7.2-12.3 14.3-19.1 21.2-1.7 2-3.5 3.8-5.5 5.4-6 5.9-12.2 11.4-18.6 16.4l-41.4-41.4C334.9 380.6 305.6 377 257 377c-46.7 0-78.4 4.3-112.6 38.5-20.4 20.4-43.8 43.9-43.8 43.9-8.9-6.8-17.3-14.2-25.3-22.4-6.6-6.6-12.8-13.4-18.5-20.3 0 0 23.1-23.2 45.2-45.3 32.7-32.7 38-70.6 38-113 0-41.3-6.8-79.8-36.8-109.9C82.2 127.7 53.3 99 53.3 99c6.7-8.5 14-16.7 21.8-24.5 6.9-6.8 14-13.1 21.2-19l48 48c30.7 30.7 70 38.6 112.4 38.6 43.6 0 82.8-8.4 114.7-40.4C391 82.1 417 56.3 417 56.3c6.8 5.6 13.5 11.6 20.1 18.2 8.3 8.3 15.8 16.9 22.6 25.8" style="fill-rule:evenodd;clip-rule:evenodd;fill:#3a3f51"/><path d="M186 269.1c-.5-2.8-.8-5.5-.9-8.4-.1-1.6-.1-3.1-.1-4.7 0-1.7 0-3.2.1-4.7 0-.2 0-.3.1-.5 1-17.4 7.9-32.4 20.5-45.1 13.9-13.8 30.6-20.7 50.2-20.7s36.3 6.9 50.2 20.7c13.8 14 20.7 30.8 20.7 50.3s-6.9 36.2-20.7 50.2c-.5.5-1 1.1-1.5 1.5q-3.45 3.3-7.2 6-18 13.2-41.4 13.2c-23.4 0-29.4-4.4-41.3-13.2-3.1-2.2-6.1-4.7-8.9-7.6-10.8-10.6-17.3-22.9-19.8-37" style="fill-rule:evenodd;clip-rule:evenodd;fill:#0cf"/><path d="m372.7 141-35.4 34.6M72.9 76.8l96.5 96.1m199.7 198.9 65.6 67.9m4.4-363.3L372.7 141M76.6 438.5l64.6-64.7" style="fill:none;stroke:#0cf;stroke-width:2;stroke-miterlimit:1"/><path d="m372.7 141-40 40.6m-193.3-38.5 40.6 40.5M141 374l39.5-41.1m146.2-3.3 42.6 42.4" style="fill:none;stroke:#0cf;stroke-width:7;stroke-miterlimit:1"/></svg>
\ No newline at end of file
service/sonarr/service.pkl created+33
......@@ -0,0 +1,33 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5meta { name = "Sonarr" }
6setup = "tools/configure-arr.py"
7dependsOn {
8 "qbittorrent"
9 "jackett"
10}
11
12container {
13 image = "lscr.io/linuxserver/sonarr@sha256:a5c1a5fecbef946927ab90ad68df319ac5fe644057e5fc18cd993f01ac07b2b2"
14 memory = 1024
15
16 http {
17 containerPort = 8989
18 subdomain = "snr"
19 authRole = "media-manage"
20 checkPath = "/ping"
21 }
22
23 volumes {
24 ["/config"] {}
25 ["/data/media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
26 }
27
28 env {
29 ["TZ"] = "America/Los_Angeles"
30 ["UMASK"] = "002"
31 ["SONARR__AUTH__METHOD"] = "External"
32 }
33}
service/tailscale/icon-dark.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.st0{opacity:.2;enable-background:new}</style><path fill="#f0f0f0" d="M65.6 127.7c35.3 0 63.9-28.6 63.9-63.9S100.9 0 65.6 0 1.8 28.6 1.8 63.9s28.6 63.8 63.8 63.8" class="st0"/><path fill="#f0f0f0" d="M65.6 318.1c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9S1.8 219 1.8 254.2s28.6 63.9 63.8 63.9"/><path fill="#f0f0f0" d="M65.6 512c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.8 28.7-63.8 63.9S30.4 512 65.6 512" class="st0"/><path fill="#f0f0f0" d="M257.2 318.1c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9m0 193.9c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9"/><path fill="#f0f0f0" d="M257.2 127.7c35.3 0 63.9-28.6 63.9-63.9S292.5 0 257.2 0s-63.9 28.6-63.9 63.9 28.6 63.8 63.9 63.8m189.2 0c35.3 0 63.9-28.6 63.9-63.9S481.6 0 446.4 0c-35.3 0-63.9 28.6-63.9 63.9s28.6 63.8 63.9 63.8" class="st0"/><path fill="#f0f0f0" d="M446.4 318.1c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9"/><path fill="#f0f0f0" d="M446.4 512c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9" class="st0"/></svg>
\ No newline at end of file
service/tailscale/icon-light.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.st0{opacity:.2;enable-background:new}</style><path d="M65.6 127.7c35.3 0 63.9-28.6 63.9-63.9S100.9 0 65.6 0 1.8 28.6 1.8 63.9s28.6 63.8 63.8 63.8" class="st0"/><path d="M65.6 318.1c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9S1.8 219 1.8 254.2s28.6 63.9 63.8 63.9"/><path d="M65.6 512c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.8 28.7-63.8 63.9S30.4 512 65.6 512" class="st0"/><path d="M257.2 318.1c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9m0 193.9c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9"/><path d="M257.2 127.7c35.3 0 63.9-28.6 63.9-63.9S292.5 0 257.2 0s-63.9 28.6-63.9 63.9 28.6 63.8 63.9 63.8m189.2 0c35.3 0 63.9-28.6 63.9-63.9S481.6 0 446.4 0c-35.3 0-63.9 28.6-63.9 63.9s28.6 63.8 63.9 63.8" class="st0"/><path d="M446.4 318.1c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9"/><path d="M446.4 512c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9" class="st0"/></svg>
\ No newline at end of file
service/tailscale/service.pkl created+29
......@@ -0,0 +1,29 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5meta { name = "Tailscale" }
6
7container {
8 image = "docker.io/tailscale/tailscale@sha256:2667499ed87ae29218f292556ba062918402dd5e92e93637af14867e4df12dd3"
9 // The node joins the tailnet through its own network stack and TUN device.
10 hostNetwork = true
11 imageUser = true
12 cpu = 100
13 memory = 256
14 capAdd { "NET_ADMIN"; "NET_RAW" }
15 devices { "/dev/net/tun" }
16
17 volumes {
18 ["/var/lib/tailscale"] {}
19 }
20
21 env {
22 ["TS_HOSTNAME"] = site.nodeName
23 ["TS_STATE_DIR"] = "/var/lib/tailscale"
24 ["TS_USERSPACE"] = "false"
25 ["TS_AUTH_ONCE"] = "true"
26 ["TS_ACCEPT_DNS"] = "false"
27 ["TS_BOOT_TIMEOUT"] = "1h"
28 }
29}
service/victoria/victoria-logs.pkl created+21
......@@ -0,0 +1,21 @@
1amends "../../config/Service.pkl"
2
3meta { name = "VictoriaLogs"; tagline = "Searchable service and system logs" }
4rollout = "simple"
5
6container {
7 image = "docker.io/victoriametrics/victoria-logs:v1.52.0"
8 cpu = 300
9 memory = 512
10 args { "-storageDataPath=/data"; "-retentionPeriod=30d" }
11
12 http {
13 containerPort = 9428
14 subdomain = "logs"
15 authRole = "infra-admin"
16 checkPath = "/health"
17 metricsPath = "/metrics"
18 }
19
20 volumes { ["/data"] {} }
21}
service/victoria/victoria-metrics.pkl created+22
......@@ -0,0 +1,22 @@
1amends "../../config/Service.pkl"
2
3meta { name = "VictoriaMetrics"; tagline = "Metrics history" }
4rollout = "simple"
5
6container {
7 image = "docker.io/victoriametrics/victoria-metrics:v1.152.0"
8 cpu = 300
9 memory = 512
10 args { "-storageDataPath=/data"; "-retentionPeriod=30d"; "-usePromCompatibleNaming" }
11
12 http {
13 containerPort = 8428
14 hostPort = 18428
15 subdomain = "metrics"
16 authRole = "infra-admin"
17 checkPath = "/health"
18 metricsPath = "/metrics"
19 }
20
21 volumes { ["/data"] {} }
22}
service/victoria/victoria-metrics/icon-dark.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path fill="#fafafa" d="m40.6 65.6 174.1 156.7c23.9 21.5 60.1 21.4 83.9-.1L471.4 65.6C506.7 32.8 408.6.2 256.8 0h-1.6C103.5.2 5.3 32.8 40.6 65.6m-7.2 130 181.3 163.2c23.9 21.5 60.1 21.4 83.9-.1l180.1-163.1v-71.2L289.4 295.8c-18.6 16.9-46.9 16.9-65.5.1L33.4 124.4zm181.3 300.3L43.8 342c-6.7-5.9-10.4-14.5-10.4-23.4v-57.1L224 433.1c18.6 16.8 46.9 16.8 65.5-.1l189.2-171.4v57.2c0 8.8-3.7 17.3-10.3 23.3L298.5 495.9c-23.7 21.4-59.9 21.5-83.8 0"/></svg>
\ No newline at end of file
service/victoria/victoria-metrics/icon-light.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m40.6 65.6 174.1 156.7c23.9 21.5 60.1 21.4 83.9-.1L471.4 65.6C506.7 32.8 408.6.2 256.8 0h-1.6C103.5.2 5.3 32.8 40.6 65.6m-7.2 130 181.3 163.2c23.9 21.5 60.1 21.4 83.9-.1l180.1-163.1v-71.2L289.4 295.8c-18.6 16.9-46.9 16.9-65.5.1L33.4 124.4zm181.3 300.3L43.8 342c-6.7-5.9-10.4-14.5-10.4-23.4v-57.1L224 433.1c18.6 16.8 46.9 16.8 65.5-.1l189.2-171.4v57.2c0 8.8-3.7 17.3-10.3 23.3L298.5 495.9c-23.7 21.4-59.9 21.5-83.8 0"/></svg>
\ No newline at end of file
service/victoria/victoria-traces.pkl created+22
......@@ -0,0 +1,22 @@
1amends "../../config/Service.pkl"
2
3meta { name = "VictoriaTraces"; tagline = "Request traces" }
4rollout = "simple"
5
6container {
7 image = "docker.io/victoriametrics/victoria-traces:v0.11.1"
8 cpu = 300
9 memory = 512
10 args { "-storageDataPath=/data"; "-retentionPeriod=30d" }
11
12 http {
13 containerPort = 10428
14 hostPort = 10428
15 subdomain = "traces"
16 authRole = "infra-admin"
17 checkPath = "/health"
18 metricsPath = "/metrics"
19 }
20
21 volumes { ["/data"] {} }
22}
service/victoria/victoria-traces/icon.svg created+1
......@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#191b2b"/><g fill="none" stroke="#9d8cff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"><path d="M10 20h15l9 11h20M10 44h15l9-13"/><circle cx="10" cy="20" r="4" fill="#9d8cff"/><circle cx="10" cy="44" r="4" fill="#9d8cff"/><circle cx="54" cy="31" r="4" fill="#9d8cff"/></g></svg>
service/youtube/yt-feed.pkl created+33
......@@ -0,0 +1,33 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5local passive = site.preview || site.mediaReadOnly
6
7meta { name = "YouTube Thumbnails" }
8
9requiredSecrets {
10 "smtp_host"
11 "smtp_user"
12 "smtp_pass"
13}
14
15container {
16 build = "upscaler"
17 cpu = if (passive) 200 else 1000
18 memory = if (passive) 512 else 2048
19 entrypoint = if (passive) "/bin/sh" else null
20 args = if (passive) new {
21 "-c"
22 "python3 /app/upscale.py --check && exec python3 -m http.server 8899 --bind 0.0.0.0 --directory /tmp"
23 } else new {}
24 http = if (passive) new { containerPort = 8899 } else null
25 volumes {
26 ["/data"] {}
27 ["/media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
28 }
29 env {
30 ["STATE_DIR"] = "/data"
31 ["SR_THREADS"] = "4"
32 }
33}
service/youtube/yt-feed/icon.svg created+1
......@@ -0,0 +1 @@
1<svg fill="#ff0033" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>YouTube</title><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
service/youtube/yt-feed/upscaler/Dockerfile created+5
......@@ -0,0 +1,5 @@
1FROM docker.io/library/python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b
2RUN pip install --no-cache-dir onnxruntime==1.30.0 numpy==2.5.3 Pillow==12.3.0
3RUN mkdir /app && python3 -c 'import hashlib,urllib.request; u="https://huggingface.co/notaneimu/onnx-image-models/resolve/main/realesr-general-x4v3.onnx"; d=urllib.request.urlopen(u).read(); assert hashlib.sha256(d).hexdigest()=="e8db65652ed421c2f8c92645d8f6fc6b07fd2868a916fdaa1a99c8d28091f097"; open("/app/realesr-general-x4v3.onnx","wb").write(d)'
4COPY upscale.py /app/upscale.py
5CMD ["python3", "-u", "/app/upscale.py"]
service/youtube/yt-feed/upscaler/upscale.py created+216
......@@ -0,0 +1,216 @@
1#!/usr/bin/env python3
2# yt-upscaler: keeps the Independent library's thumbnails sharp on a TV.
3# youtube only stores ~720p thumbnails, which jellyfin then stretches across
4# the whole screen as the item backdrop (soft + blocky). this re-fetches the
5# highest-res thumbnail from the image CDN (not bot-walled) and runs it through
6# Real-ESRGAN general-x4v3 (exported to ONNX, so no pickle is ever loaded) at
7# 4x on CPU, writing a crisp <video>.webp that jellyfin uses for card + backdrop.
8import io
9import json
10import os
11import re
12import sys
13import time
14import urllib.request
15
16import numpy as np
17import onnxruntime as ort
18from PIL import Image
19
20INDEP_DIR = os.environ.get("INDEP_DIR", "/media/jellyfin/Independent")
21STATE_DIR = os.environ.get("STATE_DIR", "/state")
22MODEL = os.environ.get("SR_MODEL", "/app/realesr-general-x4v3.onnx")
23MIN_H = int(os.environ.get("SR_MIN_H", "1400")) # >= this tall ⇒ already done
24CAP_H = int(os.environ.get("SR_CAP_H", "2160")) # cap output height (4k-ish)
25TILE = int(os.environ.get("SR_TILE", "256")) # tile size to bound memory
26THREADS = int(os.environ.get("SR_THREADS", "4")) # leave cpu for other services
27SCAN_INTERVAL = int(os.environ.get("SR_SCAN_INTERVAL", "1800"))
28VIDEO_EXTS = (".webm", ".mp4", ".mkv")
29UA = {"User-Agent": "Mozilla/5.0 (yt-upscaler; +https://paperclover.net)"}
30
31status = {"enabled": True, "running": False, "done": 0, "total": 0,
32 "current": "", "errors": 0}
33_sess = None
34
35
36def log(m):
37 print(m, flush=True)
38
39
40def write_status():
41 try:
42 tmp = os.path.join(STATE_DIR, "upscale-status.json.tmp")
43 with open(tmp, "w") as f:
44 json.dump(status, f)
45 os.replace(tmp, os.path.join(STATE_DIR, "upscale-status.json"))
46 except OSError:
47 pass
48
49
50def enabled():
51 try:
52 with open(os.path.join(STATE_DIR, "upscale-enabled.json")) as f:
53 return bool(json.load(f)["enabled"])
54 except FileNotFoundError:
55 return True
56
57
58def session():
59 global _sess
60 if _sess is None:
61 opts = ort.SessionOptions()
62 opts.intra_op_num_threads = THREADS
63 opts.inter_op_num_threads = 1
64 _sess = ort.InferenceSession(MODEL, sess_options=opts,
65 providers=["CPUExecutionProvider"])
66 return _sess
67
68
69def upscale(img, scale=4, pad=8):
70 # tiled 4x SR; overlap each tile by `pad` px and crop it back to hide seams
71 arr = np.asarray(img, dtype=np.float32) / 255.0
72 h, w, _ = arr.shape
73 out = np.zeros((h * scale, w * scale, 3), dtype=np.float32)
74 sess = session()
75 for y in range(0, h, TILE):
76 for x in range(0, w, TILE):
77 y0, x0 = max(0, y - pad), max(0, x - pad)
78 y1, x1 = min(h, y + TILE + pad), min(w, x + TILE + pad)
79 patch = arr[y0:y1, x0:x1].transpose(2, 0, 1)[None]
80 res = sess.run(None, {sess.get_inputs()[0].name: patch})[0][0].transpose(1, 2, 0)
81 th, tw = min(TILE, h - y) * scale, min(TILE, w - x) * scale
82 ty, tx = (y - y0) * scale, (x - x0) * scale
83 out[y * scale:y * scale + th, x * scale:x * scale + tw] = \
84 res[ty:ty + th, tx:tx + tw]
85 return Image.fromarray((out.clip(0, 1) * 255).round().astype("uint8"))
86
87
88def yt_id(base):
89 ij = base + ".info.json"
90 if os.path.exists(ij):
91 try:
92 with open(ij) as f:
93 vid = json.load(f).get("id")
94 if vid:
95 return vid
96 except (OSError, json.JSONDecodeError):
97 pass
98 try:
99 with open(base + ".nfo") as f:
100 m = re.search(r"<plot>(.*?)</plot>", f.read(), re.S)
101 if m:
102 u = re.search(r"(?:v=|youtu\.be/)([A-Za-z0-9_-]{11})", m.group(1))
103 return u.group(1) if u else None
104 except OSError:
105 pass
106 return None
107
108
109def fetch_cdn_webp(vid):
110 for q in ("maxresdefault", "sddefault", "hqdefault"):
111 try:
112 req = urllib.request.Request(
113 f"https://i.ytimg.com/vi_webp/{vid}/{q}.webp", headers=UA)
114 with urllib.request.urlopen(req, timeout=30) as r:
115 data = r.read()
116 if len(data) > 1000:
117 return Image.open(io.BytesIO(data)).convert("RGB")
118 except Exception:
119 continue
120 return None
121
122
123def img_height(path):
124 try:
125 with Image.open(path) as im:
126 return im.height
127 except Exception:
128 return 0
129
130
131def enhance(video_path):
132 base = os.path.splitext(video_path)[0]
133 webp, jpg = base + ".webp", base + ".jpg"
134 if os.path.exists(webp) and img_height(webp) >= MIN_H:
135 return "skip"
136 src = None
137 vid = yt_id(base)
138 if vid:
139 src = fetch_cdn_webp(vid)
140 if src is None:
141 for p in (webp, jpg):
142 if os.path.exists(p):
143 src = Image.open(p).convert("RGB")
144 break
145 if src is None:
146 return "no-source"
147 up = upscale(src)
148 if up.height > CAP_H:
149 up = up.resize((round(up.width * CAP_H / up.height), CAP_H), Image.LANCZOS)
150 tmp = webp + ".tmp"
151 up.save(tmp, "WEBP", quality=92, method=6)
152 os.replace(tmp, webp)
153 if os.path.exists(jpg):
154 os.remove(jpg)
155 return "done"
156
157
158def independent_videos():
159 out = []
160 for ch in sorted(os.listdir(INDEP_DIR)) if os.path.isdir(INDEP_DIR) else []:
161 cdir = os.path.join(INDEP_DIR, ch)
162 if not os.path.isdir(cdir):
163 continue
164 for f in sorted(os.listdir(cdir)):
165 if f.lower().endswith(VIDEO_EXTS):
166 out.append(os.path.join(cdir, f))
167 return out
168
169
170def main():
171 log(f"yt-upscaler started (model={MODEL}, threads={THREADS})")
172 while True:
173 status["enabled"] = enabled()
174 if not status["enabled"]:
175 status.update(running=False, current="")
176 write_status()
177 time.sleep(5)
178 continue
179 vids = independent_videos()
180 pending = [v for v in vids
181 if img_height(os.path.splitext(v)[0] + ".webp") < MIN_H]
182 status.update(total=len(vids), done=len(vids) - len(pending),
183 running=bool(pending), current="")
184 write_status()
185 if pending:
186 log(f"upscaling {len(pending)} thumbnail(s)…")
187 for v in pending:
188 if not enabled():
189 break
190 status["current"] = os.path.basename(v)
191 write_status()
192 t = time.time()
193 try:
194 r = enhance(v)
195 if r == "done":
196 log(f"upscaled ({time.time()-t:.0f}s): {os.path.basename(v)}")
197 elif r != "skip":
198 log(f"{r}: {os.path.basename(v)}")
199 except Exception as e:
200 status["errors"] += 1
201 log(f"failed: {os.path.basename(v)}: {e}")
202 status["done"] += 1
203 write_status()
204 time.sleep(1) # be polite to the rest of the box
205 status.update(running=False, current="")
206 write_status()
207 if not enabled():
208 continue
209 time.sleep(SCAN_INTERVAL)
210
211
212if __name__ == "__main__":
213 if sys.argv[1:] == ["--check"]:
214 assert upscale(Image.new("RGB", (8, 8), (30, 60, 90))).size == (32, 32)
215 else:
216 main()
service/youtube/ytdl-sub.pkl created+42
......@@ -0,0 +1,42 @@
1amends "../../config/Service.pkl"
2
3import "../../config/site.pkl" as site
4
5local passive = site.preview || site.mediaReadOnly
6
7meta { name = "YouTube Archiver" }
8
9container {
10 image = "ghcr.io/jmbannon/ytdl-sub@sha256:29f27bc2b405b303d9da07f11f53ba39f7575efb94f98fe4c2c65d40a3e83bdd"
11 entrypoint = "/bin/sh"
12 args {
13 "-c"
14 if (passive)
15 "ytdl-sub --config /config-yt/config.yaml inspect --level 1 /yt-config/subscriptions.yaml >/dev/null && exec python3 -m http.server 8899 --bind 0.0.0.0 --directory /tmp"
16 else
17 "sh /config-yt/archive-loop.sh"
18 }
19 cpu = 200
20 memory = if (passive) 256 else 512
21
22 http = if (passive) new {
23 containerPort = 8899
24 } else null
25
26 volumes {
27 ["/config"] {}
28 ["/config-yt"] { config = "config" }
29 ["/yt-config"] {
30 src = "\(site.cloverRoot)/Documents/Config/Youtube Downloader"
31 readOnly = true
32 }
33 ["/media"] {
34 src = if (passive) null else site.mediaRoot
35 readOnly = false
36 }
37 }
38
39 env {
40 ["HOME"] = "/config"
41 }
42}
service/youtube/ytdl-sub/config/archive-loop.sh created+19
......@@ -0,0 +1,19 @@
1#!/bin/sh
2# subscription pass on a 6h cycle — but if youtube has bot-walled the ip
3# ("sign in to confirm you're not a bot"), back off for a whole day instead
4# of hammering it every cycle, which prolongs the wall.
5while true; do
6 started=$(date +%s)
7 ytdl-sub --config /config-yt/config.yaml sub /yt-config/subscriptions.yaml 2>&1 | tee /tmp/last-pass.log
8 finished=$(date +%s)
9 if grep -q "confirm you.re not a bot" /tmp/last-pass.log; then
10 echo "[archive-loop] bot wall detected; sleeping 24h"
11 printf '{"started":%s,"finished":%s,"walled":true,"next":%s}\n' "$started" "$finished" "$((finished + 86400))" > /config/archive-status.json.tmp
12 mv /config/archive-status.json.tmp /config/archive-status.json
13 sleep 86400
14 else
15 printf '{"started":%s,"finished":%s,"walled":false,"next":%s}\n' "$started" "$finished" "$((finished + 21600))" > /config/archive-status.json.tmp
16 mv /config/archive-status.json.tmp /config/archive-status.json
17 sleep 21600
18 fi
19done
service/youtube/ytdl-sub/config/config.yaml created+42
......@@ -0,0 +1,42 @@
1# ytdl-sub tool configuration — the channel list lives in subscriptions.yaml
2configuration:
3 working_directory: "/config/work"
4
5presets:
6 # per-channel backlog control: only download uploads on/after download_after
7 # (yyyymmdd). subscriptions.yaml sets the default and per-channel values.
8 only-after:
9 date_range:
10 after: "{download_after}"
11 overrides:
12 download_after: "19700101"
13
14 # output for the Independent library, which is a jellyfin "home videos"
15 # library: flat files (no Season <year> folder), movie-style nfo (that
16 # library type parses nfo with the movie parser, not episodedetails),
17 # title without the date prefix, and the thumbnail named exactly like the
18 # video so jellyfin uses it as the card image.
19 flat-videos:
20 nfo_tags:
21 nfo_root: "movie"
22 tags:
23 title: "{title}"
24 premiered: "{episode_date_standardized}"
25 overrides:
26 episode_file_path: "{episode_file_name_sanitized}"
27 episode_file_name: "{upload_date_standardized} - {file_title}"
28 thumbnail_file_name: "{episode_file_path}.jpg"
29 # sponsorblock: cut paid sponsor reads, self-promo (merch/patreon), and
30 # like/subscribe reminders out of newly downloaded videos. intros, outros,
31 # and all real content are kept. applies to new downloads only (the archive
32 # remembers what's already fetched). segment data is crowd-sourced from the
33 # sponsorblock api, which isn't affected by youtube bot walls.
34 chapters:
35 sponsorblock_categories:
36 - sponsor
37 - selfpromo
38 - interaction
39 remove_sponsorblock_categories:
40 - sponsor
41 - selfpromo
42 - interaction
service/youtube/ytdl-sub/icon.svg created+1
......@@ -0,0 +1 @@
1<svg fill="#ff0033" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>YouTube</title><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
tools/check-legacy-handoff.sh created+14
......@@ -0,0 +1,14 @@
1#!/bin/sh
2set -eu
3
4handoff=${1:?Expected a legacy handoff directory}
5target=${2:?Expected a production target}
6port=${3:?Expected a target SSH port}
7printf '%s\n' "$handoff" | grep -Eq '^/mnt/storage1/apps/studio-handoff/[0-9]{8}T[0-9]{6}Z-[0-9a-f]{6}$' || {
8 echo 'Invalid legacy handoff directory' >&2
9 exit 1
10}
11ssh -p "$port" "$target" "set -eu; test -f '$handoff/manifest.json'; test \"\$(findmnt -n -o SOURCE --mountpoint /mnt/storage1/apps)\" = storage1/apps; test \"\$(findmnt -n -o FSTYPE --mountpoint /mnt/storage1/apps)\" = zfs; test \"\$(zfs get -H -o value encryption storage1/apps)\" = aes-256-gcm" || {
12 echo 'Legacy handoff or mounted storage1/apps dataset is unavailable' >&2
13 exit 1
14}
tools/configure-arr.py created+113
......@@ -0,0 +1,113 @@
1import json
2import os
3from pathlib import Path
4import sys
5from urllib.parse import urlsplit, urlunsplit
6import urllib.request
7import xml.etree.ElementTree as ET
8
9
10data = json.load(sys.stdin)
11service = data["serviceId"]
12api_key = ET.parse(Path(data["hostRoot"]) / "config/config.xml").findtext("ApiKey")
13if not api_key:
14 raise ValueError(f"{service} has no API key in config.xml")
15
16nomad_headers = {"X-Nomad-Token": os.environ["NOMAD_TOKEN"]}
17
18
19def internal_endpoint(name):
20 jobs = json.load(urllib.request.urlopen(urllib.request.Request(
21 f"http://127.0.0.1:4646/v1/job/{name}/allocations", headers=nomad_headers,
22 ), timeout=10))
23 running = [job for job in jobs if job["ClientStatus"] == "running" and job["DesiredStatus"] == "run"]
24 if len(running) != 1:
25 raise ValueError(f"{name} needs one running allocation before configuring {service}")
26 allocation = json.load(urllib.request.urlopen(urllib.request.Request(
27 f"http://127.0.0.1:4646/v1/allocation/{running[0]['ID']}", headers=nomad_headers,
28 ), timeout=10))
29 ports = [port for port in allocation["AllocatedResources"]["Shared"]["Ports"]
30 if port["Label"] == "internal"]
31 if len(ports) != 1:
32 raise ValueError(f"{name} needs one reserved internal port before configuring {service}")
33 return ports[0]["HostIP"], ports[0]["Value"]
34
35
36nomad = urllib.request.Request(
37 f"http://127.0.0.1:4646/v1/service/{service}",
38 headers=nomad_headers,
39)
40allocations = json.load(urllib.request.urlopen(nomad, timeout=10))
41if len(allocations) != 1:
42 raise ValueError(f"{service} needs one running allocation before configuring download clients")
43allocation = allocations[0]
44base = f"http://{allocation['Address']}:{allocation['Port']}/api/v3/downloadclient"
45headers = {"X-Api-Key": api_key}
46clients = json.load(urllib.request.urlopen(urllib.request.Request(base, headers=headers), timeout=10))
47qbittorrent_host, qbittorrent_port = internal_endpoint("qbittorrent")
48if not any(client["implementation"] == "QBittorrent" for client in clients):
49 schemas = json.load(urllib.request.urlopen(urllib.request.Request(base + "/schema", headers=headers), timeout=10))
50 client = next(schema for schema in schemas if schema["implementation"] == "QBittorrent")
51 client["name"] = "qBittorrent"
52 client["enable"] = not data["preview"]
53 fields = {field["name"]: field for field in client["fields"]}
54 for name, value in {"host": qbittorrent_host, "port": qbittorrent_port, "useSsl": False}.items():
55 fields[name]["value"] = value
56 request = urllib.request.Request(
57 base,
58 data=json.dumps(client).encode(),
59 headers={**headers, "Content-Type": "application/json"},
60 method="POST",
61 )
62 urllib.request.urlopen(request, timeout=15).close()
63 clients = [client]
64
65for client in clients:
66 if client["implementation"] != "QBittorrent":
67 continue
68 fields = {field["name"]: field for field in client["fields"]}
69 if "host" not in fields or "port" not in fields or "useSsl" not in fields:
70 raise ValueError(f"{service} has an incomplete qBittorrent client")
71 expected = {"host": qbittorrent_host, "port": qbittorrent_port, "useSsl": False}
72 changed = any(fields[name]["value"] != value for name, value in expected.items())
73 for name, value in expected.items():
74 fields[name]["value"] = value
75 if data["preview"] and client["enable"]:
76 client["enable"] = False
77 changed = True
78 if not changed:
79 continue
80 request = urllib.request.Request(
81 f"{base}/{client['id']}",
82 data=json.dumps(client).encode(),
83 headers={**headers, "Content-Type": "application/json"},
84 method="PUT",
85 )
86 urllib.request.urlopen(request, timeout=15).close()
87
88jackett_host, jackett_port = internal_endpoint("jackett")
89indexer_base = base.replace("/downloadclient", "/indexer")
90indexers = json.load(urllib.request.urlopen(urllib.request.Request(indexer_base, headers=headers), timeout=10))
91for indexer in indexers:
92 changed = False
93 if data["preview"]:
94 for setting in ("enableRss", "enableAutomaticSearch"):
95 if indexer[setting]:
96 indexer[setting] = False
97 changed = True
98 fields = {field["name"]: field for field in indexer["fields"]}
99 if "baseUrl" in fields and isinstance(fields["baseUrl"].get("value"), str):
100 current = urlsplit(fields["baseUrl"]["value"])
101 if current.path.startswith("/api/v2.0/indexers/"):
102 url = urlunsplit(("http", f"{jackett_host}:{jackett_port}", current.path, current.query, current.fragment))
103 changed |= fields["baseUrl"]["value"] != url
104 fields["baseUrl"]["value"] = url
105 if not changed:
106 continue
107 request = urllib.request.Request(
108 f"{indexer_base}/{indexer['id']}",
109 data=json.dumps(indexer).encode(),
110 headers={**headers, "Content-Type": "application/json"},
111 method="PUT",
112 )
113 urllib.request.urlopen(request, timeout=15).close()
tools/dashboard-bench.py created+67
......@@ -0,0 +1,67 @@
1#!/usr/bin/env python3
2"""Run on the VM to measure the dashboard through its trusted loopback listener."""
3import argparse
4import concurrent.futures
5import json
6from pathlib import Path
7import time
8import urllib.request
9
10
11parser = argparse.ArgumentParser(description=__doc__)
12parser.add_argument("--clients", type=int, default=24)
13parser.add_argument("--rounds", type=int, default=20)
14parser.add_argument("--port", type=int, default=7072)
15parser.add_argument("--unit", default="studio-dashboard.service")
16parser.add_argument("--include-files", action="store_true")
17args = parser.parse_args()
18if args.clients < 1 or args.rounds < 1:
19 parser.error("clients and rounds must be positive")
20
21paths = ["/", "/api/me", "/api/host", "/api/live", "/api/services", "/api/launcher", "/api/status", "/api/storage"]
22paths += [f"/api/metrics/{metric}?range=3600" for metric in ["host.cpu", "host.memory", "service.cpu", "service.memory"]]
23if args.include_files:
24 paths.append("/api/media/list")
25
26
27def request(path):
28 started = time.monotonic()
29 try:
30 request = urllib.request.Request(f"http://127.0.0.1:{args.port}" + path,
31 headers={"User-Name": "benchmark", "User-Groups": "infra-admin"})
32 with urllib.request.urlopen(request, timeout=15) as response:
33 if path == "/api/live":
34 while response.readline().strip():
35 pass
36 else:
37 response.read()
38 error = None
39 except Exception as failure:
40 error = str(failure)
41 return path, time.monotonic() - started, error
42
43
44def cpu():
45 values = dict(line.split() for line in Path(f"/sys/fs/cgroup/system.slice/{args.unit}/cpu.stat").read_text().splitlines())
46 return int(values["usage_usec"]) / 1_000_000
47
48
49for path in paths:
50 request(path)
51started = time.monotonic()
52before = cpu()
53with concurrent.futures.ThreadPoolExecutor(max_workers=args.clients) as pool:
54 results = list(pool.map(request, paths * args.rounds))
55elapsed = time.monotonic() - started
56report = {}
57for path in paths:
58 samples = sorted(duration for route, duration, _ in results if route == path)
59 errors = [error for route, _, error in results if route == path and error]
60 report[path] = {
61 "requests": len(samples),
62 "p50_ms": round(samples[len(samples) // 2] * 1000, 1),
63 "p95_ms": round(samples[min(len(samples) - 1, int(len(samples) * .95))] * 1000, 1),
64 "errors": errors,
65 }
66print(json.dumps({"clients": args.clients, "requests": len(results), "seconds": round(elapsed, 2),
67 "dashboard_cpu_cores": round((cpu() - before) / elapsed, 3), "routes": report}, indent=2))
tools/dashboard-browser-test.py created+71
......@@ -0,0 +1,71 @@
1#!/usr/bin/env python3
2import argparse
3import hashlib
4import json
5import os
6from pathlib import Path
7import subprocess
8import time
9
10import router
11
12
13def main():
14 parser = argparse.ArgumentParser()
15 parser.add_argument("--ready-file", type=Path, required=True)
16 parser.add_argument("--timeout", type=int, default=180)
17 args = parser.parse_args()
18 assert 1 <= args.timeout <= 600
19 args.ready_file.unlink(missing_ok=True)
20 stop = args.ready_file.with_suffix(".stop")
21 stop.unlink(missing_ok=True)
22 routes = Path(router.ROUTES)
23 original = routes.read_bytes()
24 metadata = routes.stat()
25 candidate = router.render(Path(router.TOKEN).read_text().strip())
26 start = "globe." + os.environ["STUDIO_DOMAIN"] + " {"
27 begin = original.index(start.encode())
28 depth = 0
29 end = None
30 for position in range(begin + len(start) - 1, len(original)):
31 depth += (original[position] == ord("{")) - (original[position] == ord("}"))
32 if depth == 0:
33 end = position + 1
34 break
35 assert end is not None
36 replacement = candidate[candidate.index(start):candidate.index("dashboard.internal." + os.environ["STUDIO_DOMAIN"] + ":")].strip().encode()
37 content = original[:begin] + replacement + original[end:]
38 active = subprocess.run(["systemctl", "is-active", "studio-router"], capture_output=True).returncode == 0
39 pending = routes.with_name("routes.browser-test.pending")
40
41 def install(value):
42 pending.write_bytes(value)
43 os.chown(pending, metadata.st_uid, metadata.st_gid)
44 pending.chmod(metadata.st_mode & 0o777)
45 pending.replace(routes)
46 subprocess.run(["systemctl", "reload", "caddy"], check=True, capture_output=True, timeout=30)
47
48 try:
49 if active:
50 subprocess.run(["systemctl", "stop", "studio-router"], check=True, capture_output=True, timeout=30)
51 install(content)
52 args.ready_file.write_text(json.dumps({"origin": "https://globe." + os.environ["STUDIO_DOMAIN"], "ready": True}) + "\n")
53 args.ready_file.chmod(0o600)
54 deadline = time.monotonic() + args.timeout
55 while not stop.exists() and time.monotonic() < deadline:
56 time.sleep(.2)
57 finally:
58 try:
59 install(original)
60 assert hashlib.sha256(routes.read_bytes()).digest() == hashlib.sha256(original).digest()
61 finally:
62 if active:
63 subprocess.run(["systemctl", "start", "studio-router"], check=True, capture_output=True, timeout=30)
64 pending.unlink(missing_ok=True)
65 args.ready_file.unlink(missing_ok=True)
66 stop.unlink(missing_ok=True)
67 print(json.dumps({"original_public_routes_restored": True, "router_state_restored": True}))
68
69
70if __name__ == "__main__":
71 main()
tools/dashboard-container-test.py created+94
......@@ -0,0 +1,94 @@
1#!/usr/bin/env python3
2import argparse
3import concurrent.futures
4import json
5from pathlib import Path
6import subprocess
7import time
8import urllib.request
9
10
11def main():
12 parser = argparse.ArgumentParser()
13 parser.add_argument("container")
14 parser.add_argument("--url", default="http://127.0.0.1:7074")
15 parser.add_argument("--clients", type=int, default=40)
16 parser.add_argument("--requests", type=int, default=1000)
17 parser.add_argument("--output", type=Path)
18 parser.add_argument("--proxy-token-file", type=Path, required=True)
19 parser.add_argument("--stop", action="store_true")
20 args = parser.parse_args()
21 info = json.loads(subprocess.check_output(["podman", "inspect", args.container]))[0]
22 host = info["HostConfig"]
23 assert info["Config"]["User"].split(":")[0] not in {"", "0", "root"}
24 assert host["ReadonlyRootfs"] is True
25 assert "no-new-privileges" in host["SecurityOpt"]
26 assert not host["Privileged"] and not host["Devices"]
27 assert host["NetworkMode"] != "host"
28 assert all(m["Destination"] in ["/run/studio-host", "/run/secrets/dashboard-proxy.token", "/data"] for m in info["Mounts"]), info["Mounts"]
29 assert next(m for m in info["Mounts"] if m["Destination"] == "/run/studio-host")["RW"] is False
30 status = subprocess.check_output(["podman", "exec", args.container, "/bin/cat", "/proc/1/status"], text=True)
31 uid = next(line.split()[1:] for line in status.splitlines() if line.startswith("Uid:"))
32 assert all(int(value) != 0 for value in uid), uid
33 for field in ["CapEff", "CapBnd", "CapPrm", "CapAmb"]:
34 assert field + ":\t0000000000000000" in status, status
35 assert "NoNewPrivs:\t1" in status, status
36 mounts = subprocess.check_output(["podman", "exec", args.container, "/bin/cat", "/proc/1/mountinfo"], text=True)
37 root = next(line.split() for line in mounts.splitlines() if line.split()[4] == "/")
38 assert "ro" in root[5].split(","), root
39 assert subprocess.run(["podman", "exec", args.container, "/bin/touch", "/escaped"], capture_output=True).returncode != 0
40 for denied in ["/var/lib/studio/nomad.token", "/run/podman/podman.sock", "/run/libvirt/libvirt-sock", "/dev/zfs", "/opt/studio/current"]:
41 assert subprocess.run(["podman", "exec", args.container, "/bin/test", "-e", denied], capture_output=True).returncode != 0, denied
42 pid = info["State"]["Pid"]
43 for namespace in ["net", "pid", "mnt"]:
44 assert Path(f"/proc/{pid}/ns/{namespace}").stat().st_ino != Path(f"/proc/self/ns/{namespace}").stat().st_ino
45
46 headers = {"User-Name": "fixture", "User-Groups": "infra-admin", "Studio-Proxy-Token": args.proxy_token_file.read_text().strip()}
47 with urllib.request.urlopen(urllib.request.Request(args.url + "/api/host", headers=headers), timeout=10) as response:
48 machine = json.load(response)
49 expected_memory = next(int(line.split()[1]) * 1024 for line in Path("/proc/meminfo").read_text().splitlines() if line.startswith("MemTotal:"))
50 expected_cores = sum(line.startswith("cpu") and line[3:4].isdigit() for line in Path("/proc/stat").read_text().splitlines())
51 assert machine["memory"] == expected_memory and machine["cores"] == expected_cores, machine
52 with urllib.request.urlopen(urllib.request.Request(args.url + "/api/live", headers=headers), timeout=10) as response:
53 for _ in range(20):
54 line = response.readline()
55 if line.startswith(b"data:"):
56 live = json.loads(line[5:])
57 break
58 else:
59 raise AssertionError("host sample did not arrive")
60 assert 0 <= live["host"]["cpu"] <= 100 and live["host"]["memory"] > 0, live
61 assert live["host"]["arc"] is not None, live
62
63 def request(_):
64 start = time.monotonic()
65 req = urllib.request.Request(args.url + "/api/storage", headers=headers)
66 with urllib.request.urlopen(req, timeout=10) as response:
67 assert json.load(response)["pool"]["state"] == "ONLINE"
68 return (time.monotonic() - start) * 1000
69
70 with concurrent.futures.ThreadPoolExecutor(max_workers=args.clients) as clients:
71 latency = sorted(clients.map(request, range(args.requests)))
72 result = {"nonroot_container": "passed", "readonly_rootfs": "passed", "zero_capabilities": "passed",
73 "no_new_privileges": "passed", "private_namespaces": "passed", "control_sockets_and_management_token_absent": "passed",
74 "host_identity_outside_container_quota": "passed", "host_live_sample": "passed",
75 "storage_requests": len(latency), "storage_clients": args.clients,
76 "storage_p95_ms": round(latency[int(len(latency) * .95)], 2), "storage_max_ms": round(latency[-1], 2)}
77 if args.stop:
78 req = urllib.request.Request(args.url + "/api/live", headers=headers)
79 with urllib.request.urlopen(req, timeout=10):
80 started = time.monotonic()
81 stopped = subprocess.run(["podman", "stop", "--time=8", args.container], capture_output=True, text=True, check=True)
82 elapsed = time.monotonic() - started
83 ended = json.loads(subprocess.check_output(["podman", "inspect", args.container]))[0]["State"]
84 assert ended["ExitCode"] == 0 and not ended["OOMKilled"], (ended, stopped.stderr)
85 assert elapsed < 8, elapsed
86 result["stop_with_active_stream"] = "passed"
87 result["stop_seconds"] = round(elapsed, 2)
88 if args.output:
89 args.output.write_text(json.dumps(result, indent=2) + "\n")
90 print(json.dumps(result))
91
92
93if __name__ == "__main__":
94 main()
tools/dashboard-deploy-test.py created+300
......@@ -0,0 +1,300 @@
1#!/usr/bin/env python3
2import importlib
3import json
4import os
5from pathlib import Path
6import subprocess
7import tempfile
8import unittest
9import uuid
10from unittest.mock import patch
11
12import release
13
14runs = importlib.import_module("dashboard-run")
15host = importlib.import_module("dashboard-host")
16
17
18class DeploymentBoundary(unittest.TestCase):
19 def setUp(self):
20 self.temporary = tempfile.TemporaryDirectory(prefix="studio-deploy-boundary-")
21 self.addCleanup(self.temporary.cleanup)
22 self.root = Path(self.temporary.name).resolve()
23 self.state = self.root / "state"
24 self.state.mkdir()
25 self.releases = self.root / "releases"
26 self.releases.mkdir()
27 self.history = self.state / "deployments.json"
28 self.history.write_text("[]")
29 for name, value in [("ROOT", self.root), ("STATE", self.state), ("RELEASES", self.releases), ("HISTORY", self.history)]:
30 context = patch.object(release, name, value)
31 context.start()
32 self.addCleanup(context.stop)
33 self.host = host.Host("studio-demo")
34 state_context = patch.object(runs, "HOST_STATE", self.state / "host")
35 state_context.start()
36 self.addCleanup(state_context.stop)
37 self.version = "a" * 16
38 self.snapshot = self.releases / self.version
39 self.snapshot.mkdir()
40 for name in release.SOURCES:
41 path = self.snapshot / name
42 if "." in name:
43 path.write_text("fixture")
44 else:
45 path.mkdir()
46 (self.snapshot / "tools/studio.py").write_text("print('fixture')")
47 (self.snapshot / ".studio-release.json").write_text(json.dumps({"id": self.version, "digest": release.tree_digest(self.snapshot), "version": 2, "main": {"commit": "b" * 40, "description": "Fixture main"}}))
48 (self.state / "stages").mkdir()
49 self.stage = "fixture-preview"
50 self.stage_file = self.state / "stages" / (self.stage + ".json")
51 self.stage_file.write_text(json.dumps({"sourceId": "fixture", "release": self.version, "ready": True,
52 "clone": None, "mount": "/srv/staging/fixture", "overrides": []}))
53 self.history.write_text(json.dumps([{"release": self.version, "source": "fixture", "time": 123, "legacy": False}]))
54 (self.state / "managed-jobs.json").write_text('["fixture"]')
55 (self.root / "current").symlink_to(self.snapshot)
56 (self.root / "main").symlink_to(self.snapshot)
57
58 def call(self, operation, **kwargs):
59 return self.host.handle({"operation": operation, **kwargs})
60
61 def test_iam_requests_are_bounded_before_any_worker_starts(self):
62 user = "/users/" + str(uuid.uuid4())
63 bad = [
64 ("/clients", "GET", None), ("/roles", "POST", {}),
65 ("/users?username=fixture&exact=true&first=0", "GET", None),
66 ("/users?username=fixture&exact=true&exact=true", "GET", None),
67 (user + "/../../clients", "GET", None), (user + "/users", "POST", {}),
68 (user, "PUT", {"realmRoles": ["admin"]}),
69 (user, "PUT", {"attributes": {"admin": ["true"]}}),
70 (user + "/role-mappings/realm", "POST", [{"id": "fixture", "name": "admin"}]),
71 (user + "/role-mappings/realm", "POST", [{"id": "fixture", "name": []}]),
72 (user + "/reset-password", "PUT", {"type": "password", "value": "password", "temporary": 1}),
73 (user + "/execute-actions-email", "PUT", ["arbitrary"]),
74 (user + "/logout", "POST", {"redirect": "https://elsewhere.invalid"}),
75 ]
76 with patch.object(runs.subprocess, "run") as process:
77 for path, method, body in bad:
78 with self.subTest(path=path), self.assertRaises(runs.Error):
79 self.call("iam.request", path=path, method=method, body=body)
80 for operation in ["get", "set", "rotate"]:
81 fields = {"service": "keycloak", "key": "password"}
82 if operation == "set":
83 fields["value"] = "known-password"
84 with self.assertRaises(runs.Error) as denied:
85 self.call("deploy.secret." + operation, **fields)
86 self.assertEqual(denied.exception.status, 403)
87 process.assert_not_called()
88
89 def test_requests_cannot_select_commands_paths_or_environment(self):
90 requests = [{"operation": "deploy.start", "action": "destroy", "target": self.stage, "argv": ["sh"]},
91 {"operation": "deploy.start", "action": "deploy", "target": self.version, "env": {"PATH": "/tmp"}},
92 {"operation": "deploy.release", "release": "../private"},
93 {"operation": "deploy.run", "id": "../../nomad.token"},
94 {"operation": "deploy.history", "path": "/etc/shadow"}]
95 for action, target in [("sh", self.stage), ([], self.stage), ("destroy", "../escape"),
96 ("destroy", "--root"), ("rollback", self.version), ("rollback", "0"), ("rollback", [])]:
97 requests.append({"operation": "deploy.start", "action": action, "target": target})
98 with patch.object(runs.subprocess, "run") as executed:
99 for request in requests:
100 with self.subTest(request=request), self.assertRaises((host.Rejected, runs.Error)):
101 self.host.handle(request)
102 executed.assert_not_called()
103
104 def test_commands_come_from_verified_releases_and_history(self):
105 self.assertEqual(runs.command("destroy", self.stage)[1:], [str(self.snapshot / "tools/studio.py"), "destroy", self.stage])
106 self.assertEqual(runs.command("deploy", self.version)[1:], [str(Path(runs.__file__).with_name("release.py")), "deploy", self.version])
107 self.assertEqual(runs.command("rollback", "1")[1:], [str(Path(runs.__file__).with_name("release.py")), "rollback", self.version])
108 self.assertEqual(runs.command("start", "fixture")[1:], [str(self.snapshot / "tools/studio.py"), "deploy", "fixture"])
109 self.assertEqual(runs.command("stop", "fixture"), ["nomad", "job", "stop", "-yes", "fixture"])
110 self.assertEqual(runs.command("restart", "fixture"), ["nomad", "job", "restart", "-yes", "fixture"])
111 (self.snapshot / "tools/studio.py").write_text("changed")
112 for action, target in [("destroy", self.stage), ("deploy", self.version), ("rollback", "1"), ("start", "fixture")]:
113 with self.subTest(action=action), self.assertRaisesRegex(ValueError, "contents changed"):
114 runs.command(action, target)
115
116 def test_service_control_is_limited_to_managed_names(self):
117 self.assertEqual(self.call("deploy.managed"), ["fixture"])
118 for action in ["start", "stop", "restart"]:
119 for target, status in [("unmanaged", 404), ("../escape", 400), ("--purge", 400), ([], 400)]:
120 with self.subTest(action=action, target=target), self.assertRaises(runs.Error) as error:
121 runs.command(action, target)
122 self.assertEqual(error.exception.status, status)
123 (self.state / "managed-jobs.json").write_text('["--purge"]')
124 with self.assertRaisesRegex(ValueError, "invalid managed"):
125 self.call("deploy.managed")
126 for data in [b'{"argv":["sh"]}', b'[1]', b'x' * (runs.MAX_METADATA + 1)]:
127 (self.state / "managed-jobs.json").write_bytes(data)
128 with self.subTest(data=data[:30]), self.assertRaises(ValueError):
129 self.call("deploy.managed")
130 (self.state / "managed-jobs.json").unlink()
131 self.assertEqual(self.call("deploy.managed"), [])
132
133 def test_main_deployment_ignores_stage_state_and_rejects_stale_candidates(self):
134 self.stage_file.write_text(json.dumps({"sourceId": "fixture", "ready": False, "overrides": ["PASSWORD=private"]}))
135 self.assertEqual(runs.command("deploy", self.version)[-2:], ["deploy", self.version])
136 for target in [self.stage, "c" * 16, "../escape", []]:
137 with self.subTest(target=target), self.assertRaises(runs.Error) as error:
138 runs.command("deploy", target)
139 self.assertEqual(error.exception.status, 409)
140 with self.assertRaises(runs.Error):
141 runs.command("promote", self.stage)
142 (self.root / "main").unlink()
143 self.assertIsNone(self.call("deploy.main"))
144 with self.assertRaises(runs.Error):
145 runs.command("deploy", self.version)
146
147 def test_main_metadata_and_deployment_are_checked_again_under_lock(self):
148 import sys
149 with patch.object(sys, "argv", ["release.py", "deploy", "c" * 16]), patch.object(release, "activate") as activate:
150 with self.assertRaisesRegex(ValueError, "main changed"):
151 release.main()
152 activate.assert_not_called()
153 manifest = self.snapshot / ".studio-release.json"
154 original = json.loads(manifest.read_text())
155 for revision in [None, {"commit": "b" * 40, "description": " "}, {"commit": "escape", "description": "main"}]:
156 manifest.write_text(json.dumps({**original, "main": revision}))
157 with self.subTest(revision=revision), self.assertRaises(ValueError):
158 self.call("deploy.main")
159
160 def test_metadata_and_legacy_logs_are_read_without_running_commands(self):
161 directory = self.snapshot / "service/fixture"
162 directory.mkdir()
163 (directory / "service.pkl").write_text("fixture definition")
164 identity = str(uuid.uuid4())
165 directory = self.state / "runs"
166 directory.mkdir()
167 (directory / (identity + ".log")).write_bytes(b"legacy\n\xff\n")
168 (directory / (identity + ".exit")).write_text("0")
169 with patch.object(runs.subprocess, "run") as executed:
170 self.assertEqual(self.call("deploy.current"), self.version)
171 self.assertEqual(len(self.call("deploy.history")), 1)
172 self.assertEqual(self.call("deploy.release", release=self.version), {"fixture": "fixture definition"})
173 self.assertEqual(self.call("deploy.run", id=identity), {"lines": ["legacy", "\ufffd"], "code": 0})
174 executed.assert_not_called()
175
176 def test_state_reads_reject_symlinks_and_large_files(self):
177 source = self.root / "fixture"
178 source.write_bytes(b"x" * 200)
179 link = self.root / "link"
180 link.symlink_to(source)
181 with self.assertRaises(OSError):
182 runs.read(link)
183 with self.assertRaisesRegex(ValueError, "too large"):
184 runs.read(source, 100)
185
186 def test_output_selection_ignores_unrelated_and_old_large_logs(self):
187 directory = self.state / "runs"
188 directory.mkdir()
189 (directory / "unrelated.log").write_bytes(b"x" * (runs.MAX_LOG + 1))
190 old = directory / (str(uuid.uuid4()) + ".log")
191 old.write_bytes(b"x" * (runs.MAX_LOG + 1))
192 selected = directory / "new-prod-fixture.log"
193 selected.write_text("approved deployment\n")
194 os.utime(selected, (123, 123))
195 self.assertEqual(self.call("deploy.output", kind="history", target="1"), ["approved deployment"])
196 self.assertIsNone(self.call("deploy.output", kind="stages", target=self.stage))
197
198 def test_run_state_survives_restart_and_never_forwards_request_environment(self):
199 with patch.object(runs.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout="", stderr="")) as executed:
200 started = self.call("deploy.start", action="destroy", target=self.stage)
201 argv = executed.call_args.args[0]
202 self.assertIn("--property=RuntimeMaxSec=3600", argv)
203 self.assertEqual(argv[-3:], [started["id"], "destroy", self.stage])
204 self.assertNotIn("--setenv=NOMAD_TOKEN", " ".join(argv))
205 root = runs.HOST_STATE
206 (root / "runs").mkdir(exist_ok=True)
207 (root / "runs" / (started["id"] + ".log")).write_text("fixture\n")
208 restored = host.Host("studio-demo")
209 active = subprocess.CompletedProcess([], 0, stdout="ActiveState=active\nExecMainStatus=0\nLoadState=loaded\n")
210 with patch.object(runs.subprocess, "run", return_value=active):
211 self.assertEqual(restored.handle({"operation": "deploy.last"}), {**started, "code": None})
212 with self.assertRaises(runs.Error) as error:
213 restored.handle({"operation": "deploy.start", "action": "deploy", "target": self.version})
214 self.assertEqual(error.exception.status, 409)
215 (root / "runs" / (started["id"] + ".exit")).write_text("0")
216 self.assertEqual(restored.handle({"operation": "deploy.last"}), {**started, "code": 0})
217 (root / "runs" / (started["id"] + ".exit")).unlink()
218 missing = subprocess.CompletedProcess([], 1, stdout="LoadState=not-found\nActiveState=inactive\nExecMainStatus=0\n")
219 with patch.object(runs.subprocess, "run", return_value=missing):
220 self.assertEqual(restored.handle({"operation": "deploy.run", "id": started["id"]})["code"], 1)
221
222 def test_worker_output_and_child_cleanup_are_bounded(self):
223 identity = str(uuid.uuid4())
224 root = runs.HOST_STATE
225 root.mkdir()
226 (root / "last-run.json").write_text(json.dumps({"id": identity, "action": "destroy", "target": self.stage}))
227 import sys
228 argv = [sys.executable, "-c", "import os,time; os.write(1,b'x'*200000); time.sleep(10)"]
229 with patch.object(runs, "command", return_value=argv), patch.object(runs, "MAX_LOG", 4096), patch.object(runs.signal, "signal"):
230 self.assertEqual(runs.worker(identity, "destroy", self.stage), 1)
231 self.assertEqual((root / "runs" / (identity + ".exit")).read_text(), "1")
232 log = (root / "runs" / (identity + ".log")).read_text()
233 self.assertLess(len(log), 8192)
234 self.assertIn("output exceeded its size limit", log)
235
236 def test_management_token_is_loaded_only_in_the_host_worker(self):
237 identity = str(uuid.uuid4())
238 root = runs.HOST_STATE
239 root.mkdir()
240 (root / "last-run.json").write_text(json.dumps({"id": identity, "action": "stop", "target": "fixture"}))
241 (self.state / "nomad.token").write_text("private-fixture-token\n")
242 import sys
243 argv = [sys.executable, "-c", "import os; assert os.environ['NOMAD_TOKEN']==open(" + repr(str(self.state / "nomad.token")) + ").read().strip(); print('stopped')"]
244 with patch.object(runs, "command", return_value=argv), patch.object(runs.signal, "signal"):
245 self.assertEqual(runs.worker(identity, "stop", "fixture"), 0)
246 log = (root / "runs" / (identity + ".log")).read_text()
247 self.assertIn("stopped", log)
248 self.assertNotIn("private-fixture-token", log)
249
250 def test_secret_scope_and_compare_and_set_preserve_siblings(self):
251 (self.state / "nomad.token").write_text("private-fixture-token")
252 job = {"Meta": {"studio_secrets": json.dumps([{"name": "token", "generated": True, "bytes": 16}])}}
253 existing = {"ModifyIndex": 7, "Items": {"token": "old", "sibling": "keep", "undeclared": "private"}}
254 with patch.object(runs, "nomad", side_effect=[job, existing]) as requested:
255 self.assertEqual(runs.secret("get", "fixture", "token"), "old")
256 with patch.object(runs, "nomad", return_value=job) as requested, self.assertRaises(runs.Error):
257 runs.secret("get", "fixture", "undeclared")
258 requested.assert_called_once_with("job/fixture")
259 with patch.object(runs, "nomad", side_effect=[job, existing, {}]) as requested, patch.object(runs.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)):
260 runs.secret("set", "fixture", "token", "new-secret")
261 self.assertEqual(requested.call_args.args, ("var/nomad/jobs/fixture?cas=7", "PUT", {"Namespace": "default", "Path": "nomad/jobs/fixture", "Items": {**existing["Items"], "token": "new-secret"}}))
262 with patch.object(runs, "nomad", side_effect=[job, existing, runs.Error(409, "conflict")]), patch.object(runs.subprocess, "run") as restarted, self.assertRaises(runs.Error):
263 runs.secret("set", "fixture", "token", "new-secret")
264 restarted.assert_not_called()
265
266 def test_rotations_use_the_recorded_size_and_refuse_external_secrets(self):
267 (self.state / "nomad.token").write_text("private-fixture-token")
268 for generated, count in [(False, 16), (True, True), (True, 4097), (True, 0)]:
269 job = {"Meta": {"studio_secrets": json.dumps([{"name": "token", "generated": generated, "bytes": count}])}}
270 with patch.object(runs, "nomad", side_effect=[job, None]) as requested, self.assertRaises(runs.Error):
271 runs.secret("rotate", "fixture", "token")
272 self.assertEqual(requested.call_count, 2)
273 job = {"Meta": {"studio_secrets": '[{"name":"token","generated":true,"bytes":16}]'}}
274 with patch.object(runs, "nomad", side_effect=[job, None, {}]) as requested, patch.object(runs.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)):
275 runs.secret("rotate", "fixture", "token")
276 value = requested.call_args.args[2]["Items"]["token"]
277 self.assertEqual(len(value), 32)
278 self.assertRegex(value, "^[a-f0-9]+$")
279
280 def test_secret_input_is_private_never_forwarded_and_removed_after_consumption(self):
281 import stat
282 import sys
283 with patch.object(runs.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as dispatched:
284 saved = self.call("deploy.secret.set", service="fixture", key="token", value="private-fixture-value")
285 self.assertNotIn("private-fixture-value", " ".join(dispatched.call_args.args[0]))
286 self.assertNotIn("value", saved)
287 source = runs.HOST_STATE / "runs" / (saved["id"] + ".input")
288 self.assertEqual(stat.S_IMODE(source.stat().st_mode), 0o600)
289 argv = [sys.executable, "-c", "import sys; assert len(sys.stdin.read())==21; print('consumed')"]
290 with patch.object(runs, "command", return_value=argv), patch.object(runs.signal, "signal"):
291 self.assertEqual(runs.worker(saved["id"], "secret-set", "fixture", "token"), 0)
292 self.assertFalse(source.exists())
293 self.assertNotIn("private-fixture-value", (source.with_suffix(".log")).read_text())
294 with patch.object(runs.subprocess, "run", side_effect=OSError("fixture dispatch failed")), self.assertRaises(OSError):
295 self.call("deploy.secret.set", service="fixture", key="token", value="private-fixture-value")
296 self.assertFalse(list(source.parent.glob("*.input")))
297
298
299if __name__ == "__main__":
300 unittest.main()
tools/dashboard-deploy-vm-test.py created+303
......@@ -0,0 +1,303 @@
1#!/usr/bin/env python3
2import argparse
3from concurrent.futures import ThreadPoolExecutor
4import json
5import os
6from pathlib import Path
7import shutil
8import subprocess
9import sys
10import tempfile
11import time
12import urllib.error
13import urllib.request
14import uuid
15
16import release
17
18
19def main():
20 parser = argparse.ArgumentParser()
21 parser.add_argument("socket", type=Path)
22 parser.add_argument("--image", required=True)
23 parser.add_argument("--output", type=Path)
24 args = parser.parse_args()
25 suffix = uuid.uuid4().hex[:12]
26 proof = uuid.uuid4().hex + uuid.uuid4().hex
27 stage_id = "boundary-preview-" + suffix
28 stage_file = release.STATE / "stages" / (stage_id + ".json")
29 service_id = "boundary-service-" + suffix
30 managed_file = release.STATE / "managed-jobs.json"
31 managed_bytes = managed_file.read_bytes()
32 container = "studio-dashboard-deploy-test-" + suffix
33 current = release.current_release()
34 history_bytes = release.HISTORY.read_bytes()
35 stages_before = {path.name for path in (release.STATE / "stages").glob("*.json")}
36 client = Path(__file__).with_name("dashboard-host-vm-test.py")
37
38 def shell(*argv):
39 process = subprocess.run(argv, capture_output=True, text=True)
40 if process.returncode:
41 raise RuntimeError(process.stderr)
42 return process.stdout
43
44 def call(operation, status=None, **fields):
45 payload = json.dumps({"operation": operation, **fields}).encode() + b"\n"
46 output = subprocess.run([sys.executable, str(client), str(args.socket), "--client"], input=payload,
47 capture_output=True, check=True)
48 value = json.loads(output.stdout)
49 if status:
50 assert value["status"] == status, value
51 return value
52 assert "value" in value, value
53 return value["value"]
54
55 def http(path, body=None, status=200, section="deploys", groups="infra-admin", method=None):
56 request = urllib.request.Request("http://127.0.0.1:7076/api/" + section + path,
57 data=json.dumps(body).encode() if body is not None else None, method=method,
58 headers={"User-Name": "fixture", "User-Groups": groups, "Studio-Proxy-Token": proof, "Content-Type": "application/json"})
59 try:
60 response = urllib.request.urlopen(request, timeout=40)
61 except urllib.error.HTTPError as error:
62 response = error
63 with response:
64 payload = response.read()
65 assert response.status == status, (path, response.status, payload[:300])
66 return json.loads(payload) if response.status == 200 and not path.startswith("/runs/") else payload
67
68 def nomad(path, body=None, method=None):
69 request = urllib.request.Request("http://127.0.0.1:4646/v1/" + path,
70 data=json.dumps(body).encode() if body is not None else None, method=method,
71 headers={"X-Nomad-Token": (release.STATE / "nomad.token").read_text().strip(), "Content-Type": "application/json"})
72 try:
73 with urllib.request.urlopen(request, timeout=10) as response:
74 return None if method == "DELETE" else json.load(response)
75 except urllib.error.HTTPError as error:
76 if method == "DELETE" and error.code == 404:
77 return None
78 raise
79
80 def done(identity):
81 deadline = time.monotonic() + 30
82 while time.monotonic() < deadline:
83 result = call("deploy.run", id=identity)
84 if result["code"] is not None:
85 return result
86 time.sleep(.1)
87 raise AssertionError("deployment worker did not finish")
88
89 def ready():
90 deadline = time.monotonic() + 40
91 while time.monotonic() < deadline:
92 try:
93 return http("")
94 except OSError:
95 time.sleep(.1)
96 raise AssertionError("container deployment view did not start")
97
98 with tempfile.TemporaryDirectory(prefix="studio-deploy-boundary-", dir="/run") as temporary:
99 data = Path(temporary) / "data"
100 data.mkdir()
101 os.chown(data, 65534, 65534)
102 proxy_token = Path(temporary) / "proxy.token"
103 proxy_token.write_text(proof)
104 os.chown(proxy_token, 0, 65534)
105 proxy_token.chmod(0o440)
106 fixture_release = None
107 owned_runs = []
108 service_created = False
109 mount = Path("/srv/staging") / stage_id
110 try:
111 assert call("deploy.current") == current
112 assert call("deploy.history") == json.loads(history_bytes)
113 assert {stage["id"] + ".json" for stage in call("deploy.stages")} == stages_before
114 assert call("deploy.release", release=current)
115 call("deploy.start", action="sh", target=stage_id, status=400)
116 call("deploy.start", action="destroy", target="../escape", status=400)
117 call("deploy.start", action="rollback", target=current, status=400)
118 call("deploy.start", action="destroy", target=stage_id, env={"PATH": "/tmp"}, status=400)
119 call("deploy.run", id="../../nomad.token", status=400)
120 stage_file.write_text(json.dumps({"sourceId": "navidrome", "release": current, "ready": True,
121 "overrides": [], "clone": None, "mount": str(mount), "inputs": {}, "tasks": []}))
122 candidate = call("deploy.main")
123 call("deploy.start", action="promote", target=stage_id, status=400)
124 call("deploy.start", action="deploy", target=stage_id, status=409)
125 if candidate and candidate["release"] == current:
126 deployed = call("deploy.start", action="deploy", target=current)
127 owned_runs.append(deployed["id"])
128 result = done(deployed["id"])
129 assert result["code"] == 0 and "already running " + current in result["lines"], result
130 rolled = call("deploy.start", action="rollback", target=str(len(json.loads(history_bytes))))
131 owned_runs.append(rolled["id"])
132 assert done(rolled["id"])["code"] == 0
133
134 fixture_release = release.RELEASES / ("boundary-" + suffix)
135 fixture_release.mkdir()
136 for name in release.SOURCES:
137 path = fixture_release / name
138 if "." in name:
139 path.write_text("fixture")
140 else:
141 path.mkdir()
142 script = fixture_release / "tools/studio.py"
143 script.write_text("import time\ntime.sleep(3)\nprint('approved fixture worker')\n")
144 digest = release.tree_digest(fixture_release)
145 destination = release.RELEASES / digest[:16]
146 assert not destination.exists()
147 fixture_release.rename(destination)
148 fixture_release = destination
149 (fixture_release / ".studio-release.json").write_text(json.dumps({"id": digest[:16], "digest": digest, "version": 2}))
150 value = json.loads(stage_file.read_text())
151 stage_file.write_text(json.dumps({**value, "release": digest[:16]}))
152 active = call("deploy.start", action="destroy", target=stage_id)
153 owned_runs.append(active["id"])
154 call("deploy.start", action="deploy", target=current, status=409)
155 shell("systemctl", "restart", "studio-host-boundary-test.service")
156 assert call("deploy.last")["id"] == active["id"]
157 result = done(active["id"])
158 assert result["code"] == 0 and "approved fixture worker" in result["lines"], result
159 (fixture_release / "tools/studio.py").write_text("print('unapproved changed worker')")
160 call("deploy.start", action="destroy", target=stage_id, status=502)
161 assert call("deploy.last")["id"] == active["id"]
162 stage_file.write_text(json.dumps(value))
163 mount.mkdir()
164 (mount / "fixture").write_text("disposable")
165
166 shell("podman", "run", "-d", "--name=" + container, "--pull=never", "--user=65534:65534",
167 "--read-only", "--cap-drop=all", "--security-opt=no-new-privileges", "--pids-limit=128",
168 "--memory=512m", "--cpus=2", "--tmpfs=/tmp:rw,noexec,nosuid,nodev,size=64m",
169 "--publish=127.0.0.1:7076:7072", "--volume=" + str(args.socket.parent) + ":/run/studio-host:ro",
170 "--volume=" + str(proxy_token) + ":/run/secrets/dashboard-proxy.token:ro",
171 "--volume=" + str(data) + ":/data:rw,nosuid,nodev", "--env=STUDIO_DATA_DIR=/data", args.image)
172 overview = ready()
173 assert overview["current"] == current and overview["recorded"]
174 assert len(overview["history"]) == len(json.loads(history_bytes))
175 assert any(stage["id"] == stage_id for stage in overview["stages"])
176 assert overview["run"]["id"] == active["id"] and overview["run"]["code"] == 0
177 with ThreadPoolExecutor(max_workers=40) as workers:
178 latencies = []
179 def overview_check(_):
180 start = time.monotonic()
181 assert http("")["current"] == current
182 return (time.monotonic() - start) * 1000
183 latencies = list(workers.map(overview_check, range(1000)))
184 latencies.sort()
185 http("/" + service_id + "/promote", {}, status=400, section="services")
186 http("/" + service_id + "/stop", {}, status=404, section="services")
187 managed_file.write_text(json.dumps([*json.loads(managed_bytes), service_id]))
188 service_created = True
189 nomad("jobs", {"Job": {"ID": service_id, "Name": service_id, "Namespace": "default", "Datacenters": ["*"], "Type": "service",
190 "Meta": {"studio_secrets": json.dumps([{"name": "fixture", "generated": True, "bytes": 16}, {"name": "outside", "generated": False}])},
191 "TaskGroups": [{"Name": "fixture", "Count": 1, "Tasks": [{"Name": "idle", "Driver": "podman",
192 "Config": {"image": "docker.io/library/alpine:3.22", "command": "/bin/sleep", "args": ["600"]},
193 "Resources": {"CPU": 25, "MemoryMB": 32}}]}]}})
194 deadline = time.monotonic() + 40
195 while time.monotonic() < deadline:
196 allocations = nomad("job/" + service_id + "/allocations")
197 deployments = nomad("job/" + service_id + "/deployments")
198 if (any(allocation["ClientStatus"] == "running" for allocation in allocations)
199 and any(deployment["Status"] == "successful" for deployment in deployments)):
200 break
201 time.sleep(.1)
202 else:
203 raise AssertionError("service fixture did not start")
204 http("/" + service_id + "/stop", {}, status=403, section="services", groups="")
205 nomad("var/nomad/jobs/" + service_id, {"Namespace": "default", "Path": "nomad/jobs/" + service_id,
206 "Items": {"fixture": "old", "outside": "keep", "undeclared": "private"}}, "PUT")
207 prefix = "/" + service_id + "/secrets/"
208 assert http(prefix + "fixture", section="services")["value"] == "old"
209 http(prefix + "fixture", status=403, section="services", groups="")
210 with ThreadPoolExecutor(max_workers=20) as workers:
211 assert all(workers.map(lambda _: http(prefix + "fixture", section="services")["value"] == "old", range(100)))
212 http(prefix + "undeclared", status=404, section="services")
213 http(prefix + "fixture", {"value": "line\nbreak"}, method="PUT", status=400, section="services")
214 secret_value = "disposable-" + suffix + '-"$();☃'
215 http(prefix + "fixture", {"value": secret_value}, method="PUT", status=204, section="services")
216 saved = call("deploy.last")
217 owned_runs.append(saved["id"])
218 assert saved["action"] == "secret-set" and saved["key"] == "fixture" and saved["code"] == 0
219 assert secret_value not in "\n".join(call("deploy.run", id=saved["id"])["lines"])
220 assert http(prefix + "fixture", section="services")["value"] == secret_value
221 assert nomad("var/nomad/jobs/" + service_id)["Items"]["outside"] == "keep"
222 assert not list(Path("/var/lib/studio/host-boundary-test/runs").glob("*.input"))
223 http(prefix + "fixture/rotate", {}, status=204, section="services")
224 saved = call("deploy.last")
225 owned_runs.append(saved["id"])
226 rotated = http(prefix + "fixture", section="services")["value"]
227 assert len(rotated) == 32 and all(c in "0123456789abcdef" for c in rotated)
228 http(prefix + "outside/rotate", {}, status=502, section="services")
229 owned_runs.append(call("deploy.last")["id"])
230 assert nomad("var/nomad/jobs/" + service_id)["Items"]["outside"] == "keep"
231 for action, status in [("restart", 204), ("stop", 204), ("start", 502)]:
232 http("/" + service_id + "/" + action, {}, status=status, section="services")
233 saved = call("deploy.last")
234 owned_runs.append(saved["id"])
235 assert saved["action"] == action and saved["target"] == service_id
236 assert http("")["run"]["id"] == saved["id"]
237 if action == "restart":
238 allocations = nomad("job/" + service_id + "/allocations")
239 assert any(allocation["TaskStates"]["idle"]["Restarts"] > 0 for allocation in allocations), allocations
240 elif action == "stop":
241 assert nomad("job/" + service_id)["Stop"] is True
242 else:
243 assert saved["code"] != 0
244 nomad("job/" + service_id + "?purge=true", method="DELETE")
245 nomad("var/nomad/jobs/" + service_id, method="DELETE")
246 managed_file.write_bytes(managed_bytes)
247 service_created = False
248 http("/openspeedtest/start", {}, status=204, section="services")
249 saved = call("deploy.last")
250 owned_runs.append(saved["id"])
251 assert saved["action"] == "start" and saved["target"] == "openspeedtest" and saved["code"] == 0
252 assert nomad("job/openspeedtest")["Stop"] is False
253 launched = http("/stages/" + stage_id + "/destroy", {})
254 owned_runs.append(launched["id"])
255 result = done(launched["id"])
256 assert result["code"] == 0, result
257 assert not stage_file.exists() and not mount.exists()
258 events = http("/runs/" + launched["id"]).decode()
259 assert "event: exit\ndata: 0" in events, events
260 shell("podman", "stop", "--time=8", container)
261 shell("podman", "start", container)
262 assert ready()["run"]["id"] == launched["id"]
263 assert release.current_release() == current and release.HISTORY.read_bytes() == history_bytes
264 assert {path.name for path in (release.STATE / "stages").glob("*.json")} == stages_before
265 result = {"fixed_deployment_request_boundary": "passed", "manifest_tampering_refused": "passed",
266 "main_guards_and_noop_rollback": "passed", "host_restart_preserves_worker": "passed",
267 "concurrent_deployment_refused": "passed", "container_deployment_metadata": "passed",
268 "container_stage_destroy": "passed", "container_run_stream_and_restart": "passed",
269 "container_managed_service_restart_and_stop": "passed", "service_start_failure_preserves_host_run": "passed",
270 "container_start_approved_existing_stateless_service": "passed",
271 "container_declared_secret_reads_updates_and_rotation": "passed", "secret_siblings_and_private_input_cleanup": "passed",
272 "concurrent_named_secret_reads": 100, "secret_read_clients": 20,
273 "undeclared_secret_read_and_external_rotation_refused": "passed",
274 "unmanaged_service_and_non_admin_control_refused": "passed", "managed_service_registry_preserved": "passed",
275 "current_release_history_and_existing_stages_preserved": "passed",
276 "concurrent_overview_requests": 1000, "clients": 40,
277 "overview_p95_ms": round(latencies[949], 2), "overview_max_ms": round(latencies[-1], 2)}
278 if args.output:
279 args.output.write_text(json.dumps(result, indent=2) + "\n")
280 print(json.dumps(result))
281 except BaseException:
282 print(subprocess.run(["podman", "logs", "--tail=10", container], capture_output=True, text=True).stderr)
283 raise
284 finally:
285 subprocess.run(["podman", "rm", "--force", container], capture_output=True)
286 for identity in owned_runs:
287 subprocess.run(["systemctl", "stop", "studio-run-" + identity], capture_output=True, timeout=15)
288 if service_created:
289 nomad("job/" + service_id + "?purge=true", method="DELETE")
290 nomad("var/nomad/jobs/" + service_id, method="DELETE")
291 jobs = json.loads(managed_file.read_bytes())
292 if jobs == [*json.loads(managed_bytes), service_id]:
293 managed_file.write_bytes(managed_bytes)
294 else:
295 managed_file.write_text(json.dumps([job for job in jobs if job != service_id]))
296 stage_file.unlink(missing_ok=True)
297 shutil.rmtree(mount, ignore_errors=True)
298 if fixture_release:
299 shutil.rmtree(fixture_release)
300
301
302if __name__ == "__main__":
303 main()
tools/dashboard-file-boundary-test.py created+280
......@@ -0,0 +1,280 @@
1#!/usr/bin/env python3
2import argparse
3import json
4import os
5from pathlib import Path
6import shutil
7import sqlite3
8import subprocess
9import sys
10import tempfile
11import time
12import urllib.error
13import urllib.parse
14import urllib.request
15import uuid
16
17
18def main():
19 parser = argparse.ArgumentParser()
20 parser.add_argument("socket", type=Path)
21 parser.add_argument("--pool", default="studio-demo")
22 parser.add_argument("--image", required=True)
23 parser.add_argument("--output", type=Path)
24 args = parser.parse_args()
25 suffix = uuid.uuid4().hex
26 proof = uuid.uuid4().hex + uuid.uuid4().hex
27 dataset = args.pool + "/files-boundary-test-" + suffix
28 mount = Path("/srv/.files-boundary-test-" + suffix)
29 private = Path("/srv/.files-private-test-" + suffix)
30 container = "studio-dashboard-files-test-" + suffix
31
32 def run(*argv):
33 return subprocess.run(argv, check=True, capture_output=True, text=True).stdout
34
35 def call(path, body=None, status=200):
36 req = urllib.request.Request("http://127.0.0.1:7075" + path,
37 data=json.dumps(body).encode() if body is not None else None,
38 headers={"User-Name": "fixture", "User-Groups": "infra-admin", "Studio-Proxy-Token": proof, "Content-Type": "application/json"})
39 try:
40 response = urllib.request.urlopen(req, timeout=30)
41 except urllib.error.HTTPError as error:
42 response = error
43 with response:
44 payload = response.read()
45 assert response.status == status, (path, response.status, payload[:500])
46 return json.loads(payload) if payload and response.headers.get("content-type", "").startswith("application/json") else payload
47
48 def ready():
49 deadline = time.monotonic() + 30
50 while time.monotonic() < deadline:
51 try:
52 call("/api/me")
53 return
54 except OSError:
55 time.sleep(.1)
56 raise RuntimeError("file fixture did not start")
57
58 def metadata(name):
59 file = data / "index" / (urllib.parse.quote_plus(name, safe="") + ".db")
60 with sqlite3.connect(file.as_uri() + "?mode=ro", uri=True) as db:
61 return db.execute("SELECT snapshot,scanned,updated FROM meta").fetchone()
62
63 def indexed(size, files, previous=None):
64 deadline = time.monotonic() + 60
65 while time.monotonic() < deadline:
66 tree = call("/api/storage/files/map?width=1000&height=1000")
67 try:
68 meta = {name: metadata(name) for name in [dataset, dataset + "/media", dataset + "/docs"]}
69 except sqlite3.OperationalError:
70 meta = {}
71 if (not tree["scanning"] and tree["tree"] and tree["tree"][1:3] == [size, files]
72 and len(meta) == 3 and all(meta.values())
73 and (previous is None or all(meta[name][0] != previous[name][0] for name in meta))):
74 return meta
75 time.sleep(.1)
76 raise AssertionError((tree, meta))
77
78 with tempfile.TemporaryDirectory(prefix="studio-file-boundary-", dir="/run") as temporary:
79 os.chown(temporary, 65534, 65534)
80 data = Path(temporary) / "data"
81 data.mkdir()
82 os.chown(data, 65534, 65534)
83 proxy_token = Path(temporary) / "proxy.token"
84 proxy_token.write_text(proof)
85 os.chown(proxy_token, 0, 65534)
86 proxy_token.chmod(0o440)
87 try:
88 for name, path in [(dataset, mount), (dataset + "/media", mount / "media"), (dataset + "/docs", mount / "docs"), (dataset + "/private", private)]:
89 run("zfs", "create", "-o", "mountpoint=" + str(path), name)
90 os.chown(path, 65534, 65534)
91 for relative, content in [("media/a.txt", "media fixture"), ("docs/notes", "document fixture")]:
92 target = mount / relative
93 target.write_text(content)
94 os.chown(target, 65534, 65534)
95 (private / "secret").write_text("unmounted fixture")
96 run("zfs", "snapshot", dataset + "@manual")
97 run("zfs", "snapshot", dataset + "@index-fixture")
98 launch = ["podman", "run", "-d", "--name=" + container, "--pull=never", "--user=65534:65534",
99 "--read-only", "--cap-drop=all", "--security-opt=no-new-privileges", "--pids-limit=128",
100 "--memory=512m", "--cpus=2", "--tmpfs=/tmp:rw,noexec,nosuid,nodev,size=64m",
101 "--publish=127.0.0.1:7075:7072", "--volume=" + str(args.socket.parent) + ":/run/studio-host:ro",
102 "--volume=" + str(proxy_token) + ":/run/secrets/dashboard-proxy.token:ro",
103 "--volume=" + str(data) + ":/data:rw,nosuid,nodev",
104 "--env=STUDIO_DATA_DIR=/data", "--env=STUDIO_FILES_WRITABLE=1"]
105 run(*launch,
106 "--mount=type=bind,src=" + str(mount) + ",dst=" + str(mount) + ",bind-nonrecursive,bind-propagation=rslave",
107 "--mount=type=bind,src=" + str(mount / "media") + ",dst=" + str(mount / "media") + ",bind-nonrecursive,bind-propagation=rslave",
108 "--mount=type=bind,src=" + str(mount / "docs") + ",dst=" + str(mount / "docs") + ",bind-nonrecursive,bind-propagation=rslave",
109 "--env=STUDIO_STORE_ROOT=" + str(mount),
110 "--env=STUDIO_MEDIA_ROOT=" + str(mount / "media"),
111 "--env=STUDIO_INDEX_POOL=" + dataset, "--env=STUDIO_INDEX_DIR=/data/index", args.image)
112 ready()
113 baseline_size = len("media fixture") + len("document fixture")
114 baseline = indexed(baseline_size, 2)
115 assert subprocess.run(["podman", "exec", container, "/bin/test", "-e", str(private / "secret")], capture_output=True).returncode != 0
116 private_db = data / "index" / (urllib.parse.quote_plus(dataset + "/private", safe="") + ".db")
117 assert not private_db.exists()
118 assert not run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-d", "1", dataset + "/private").strip()
119 shutil.copyfile(data / "index" / (urllib.parse.quote_plus(dataset + "/media", safe="") + ".db"), private_db)
120 os.chown(private_db, 65534, 65534)
121 largest = call("/api/storage/files/largest")
122 assert {item["path"]: item["size"] for item in largest} == {"media/a.txt": len("media fixture"), "docs/notes": len("document fixture")}, largest
123 (mount / "media/a.txt").rename(mount / "media/renamed")
124 (mount / "docs/notes").write_text("updated document fixture")
125 run("podman", "stop", "--time=8", container)
126 run("podman", "start", container)
127 ready()
128 updated = indexed(len("media fixture") + len("updated document fixture"), 2, baseline)
129 assert all(updated[name][1] == baseline[name][1] for name in baseline), (baseline, updated)
130 assert {item["path"] for item in call("/api/storage/files/largest")} == {"media/renamed", "docs/notes"}
131 assert private_db.exists()
132 assert not run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-d", "1", dataset + "/private").strip()
133 print("unmounted dataset and previous catalog isolation passed", flush=True)
134 (mount / "media/renamed").rename(mount / "media/a.txt")
135 (mount / "docs/notes").write_text("document fixture")
136 run("podman", "stop", "--time=8", container)
137 run("podman", "start", container)
138 ready()
139 baseline = indexed(baseline_size, 2, updated)
140 print("fresh and incremental snapshot indexing passed", flush=True)
141
142 media_db = data / "index" / (urllib.parse.quote_plus(dataset + "/media", safe="") + ".db")
143 with sqlite3.connect(media_db) as db:
144 db.execute("UPDATE meta SET scanned=0")
145 locked = mount / "media/locked"
146 locked.mkdir(mode=0o700)
147 (locked / "file").write_text("x")
148 run("podman", "stop", "--time=8", container)
149 run("podman", "start", container)
150 ready()
151 deadline = time.monotonic() + 30
152 while time.monotonic() < deadline:
153 logs = subprocess.run(["podman", "logs", container], check=True, capture_output=True, text=True)
154 if "Permission denied" in logs.stdout + logs.stderr and not call("/api/storage/files/map?width=1000&height=1000")["scanning"]:
155 break
156 time.sleep(.1)
157 else:
158 raise AssertionError("unreadable snapshot was accepted")
159 assert metadata(dataset + "/media")[0] == baseline[dataset + "/media"][0]
160 assert call("/api/storage/files/map?width=1000&height=1000")["tree"][1:3] == [baseline_size, 2]
161 locked.chmod(0o755)
162 run("podman", "stop", "--time=8", container)
163 run("podman", "start", container)
164 ready()
165 recovered = indexed(baseline_size + 1, 3, baseline)
166 assert recovered[dataset + "/media"][1] > 0, recovered
167 print("unreadable snapshot recovery passed", flush=True)
168
169 bulk = mount / "media/bulk"
170 bulk.mkdir()
171 for i in range(20000):
172 (bulk / str(i)).write_bytes(b"x")
173 with sqlite3.connect(media_db) as db:
174 db.execute("UPDATE meta SET scanned=0")
175 run("podman", "stop", "--time=8", container)
176 run("podman", "start", container)
177 deadline = time.monotonic() + 30
178 while time.monotonic() < deadline:
179 snapshots = run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-d", "1", dataset + "/media").splitlines()
180 if any(name != recovered[dataset + "/media"][0] for name in snapshots):
181 run("podman", "kill", "--signal=KILL", container)
182 break
183 time.sleep(.05)
184 else:
185 raise AssertionError("scan did not start")
186 assert metadata(dataset + "/media")[0] == recovered[dataset + "/media"][0], "scan finished before cancellation"
187 run("podman", "start", container)
188 ready()
189 recovered = indexed(baseline_size + 20001, 20003, recovered)
190 assert media_db.stat().st_uid == 65534
191 print("cancelled scan recovery passed", flush=True)
192
193 run("podman", "stop", "--time=8", container)
194 run(sys.executable, "-c", "import os,sqlite3,sys; os.chdir(sys.argv[1]); os.setgroups([]); os.setgid(65534); os.setuid(65534); db=sqlite3.connect(sys.argv[2]); db.executescript('PRAGMA cache_size=1; BEGIN IMMEDIATE; UPDATE file SET size=99;'); os._exit(0)", str(media_db.parent), media_db.name)
195 journal = media_db.with_suffix(".db-journal")
196 assert journal.stat().st_uid == 65534 and any(journal.read_bytes()[:8])
197 run("podman", "start", container)
198 ready()
199 recovered = indexed(baseline_size + 20001, 20003, recovered)
200 assert not journal.exists()
201 print("interrupted incremental transaction recovery passed", flush=True)
202 shutil.rmtree(bulk)
203 shutil.rmtree(locked)
204 run("podman", "stop", "--time=8", container)
205 run("podman", "start", container)
206 ready()
207 baseline = indexed(baseline_size, 2, recovered)
208 remaining = run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset).splitlines()
209 assert set(remaining) == {dataset + "@manual", dataset + "@index-fixture", *(meta[0] for meta in baseline.values())}, remaining
210
211 assert call("/api/media/peek?path=a.txt")["text"] == "media fixture"
212 call("/api/media/list?path=../docs", status=403)
213 (mount / "media/escape").symlink_to(mount / "docs", target_is_directory=True)
214 call("/api/media/list?path=escape", status=403)
215 call("/api/storage/files/delete", {"paths": ["media", "docs"]}, 409)
216 deleted = call("/api/storage/files/delete", {"paths": ["media/a.txt", "docs/notes"]})
217 assert not (mount / "media/a.txt").exists() and not (mount / "docs/notes").exists()
218 run("podman", "stop", "--time=8", container)
219 run("podman", "start", container)
220 ready()
221 assert call("/api/storage/files/ops")[0]["id"] == deleted["id"]
222 call("/api/storage/files/ops/" + deleted["id"] + "/undo", {}, 204)
223 assert (mount / "media/a.txt").read_text() == "media fixture"
224 assert (mount / "docs/notes").read_text() == "document fixture"
225 (mount / "media/escape").unlink()
226 run("podman", "stop", "--time=8", container)
227 run("podman", "start", container)
228 ready()
229 baseline = indexed(baseline_size, 2)
230 remaining = run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset).splitlines()
231 assert set(remaining) == {dataset + "@manual", dataset + "@index-fixture", *(meta[0] for meta in baseline.values())}, remaining
232 call("/api/storage/destroy", {"dataset": dataset, "from": "manual", "to": "manual"}, 204)
233 remaining = run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset).splitlines()
234 assert set(remaining) == {dataset + "@index-fixture", *(meta[0] for meta in baseline.values())}, remaining
235 status = run("podman", "exec", container, "/bin/cat", "/proc/1/status")
236 assert "Uid:\t65534\t65534\t65534\t65534" in status, status
237 assert "CapEff:\t0000000000000000" in status, status
238 run("podman", "rm", "--force", container)
239 partial = private / "data"
240 partial.mkdir()
241 os.chown(partial, 65534, 65534)
242 (partial / "fixture").write_text("partial mount fixture")
243 os.chown(partial / "fixture", 65534, 65534)
244 run(*launch, "--volume=" + str(partial) + ":" + str(partial) + ":rw,nosuid,nodev",
245 "--env=STUDIO_STORE_ROOT=" + str(private), args.image)
246 ready()
247 call("/api/storage/files/delete", {"paths": ["data/fixture"]}, 409)
248 assert (partial / "fixture").read_text() == "partial mount fixture"
249 result = {"image": args.image, "container_multi_dataset_delete_undo": "passed", "journal_survives_restart": "passed",
250 "undo_pruning_preserves_other_snapshots": "passed", "container_snapshot_deletion": "passed",
251 "file_path_and_dataset_confinement": "passed", "nonroot_file_operations": "passed",
252 "read_only_snapshot_index": "passed", "incremental_index_survives_restart": "passed",
253 "unreadable_snapshot_preserves_last_good_index": "passed", "cancelled_scan_recovers": "passed",
254 "interrupted_incremental_transaction_recovers": "passed",
255 "index_pruning_preserves_other_snapshots": "passed", "nonroot_index_database": "passed",
256 "unmounted_dataset_is_not_snapshotted": "passed", "unmounted_previous_catalog_is_not_served": "passed",
257 "partial_mount_delete_preserves_file": "passed"}
258 if args.output:
259 args.output.write_text(json.dumps(result, indent=2) + "\n")
260 print(json.dumps(result))
261 except BaseException as error:
262 if isinstance(error, subprocess.CalledProcessError):
263 print(error.stderr)
264 print(subprocess.run(["podman", "logs", "--tail=15", container], capture_output=True, text=True).stderr)
265 raise
266 finally:
267 subprocess.run(["podman", "rm", "--force", container], capture_output=True)
268 run("zpool", "sync", args.pool)
269 deadline = time.monotonic() + 30
270 while True:
271 cleanup = subprocess.run(["zfs", "destroy", "-r", dataset], capture_output=True, text=True)
272 if cleanup.returncode == 0:
273 break
274 if time.monotonic() >= deadline or "dataset is busy" not in cleanup.stderr:
275 raise RuntimeError(cleanup.stderr)
276 time.sleep(.5)
277
278
279if __name__ == "__main__":
280 main()
tools/dashboard-files-test.py created+83
......@@ -0,0 +1,83 @@
1#!/usr/bin/env python3
2"""Exercise Rust file operations on disposable ZFS datasets in the VM."""
3import argparse,json,os,shlex,subprocess,tempfile,time,urllib.request,urllib.error,uuid
4from pathlib import Path
5parser=argparse.ArgumentParser(description=__doc__)
6parser.add_argument('binary')
7parser.add_argument('--pool',default='studio-demo')
8args=parser.parse_args()
9run=lambda *argv:subprocess.run(argv,check=True,capture_output=True,text=True)
10dataset=args.pool+'/dashboard-test-'+uuid.uuid4().hex[:8]
11unit='studio-dashboard-files-test'
12with tempfile.TemporaryDirectory(prefix='studio-files-test-') as temporary:
13 root=Path(temporary)/'store';media=root/'media';docs=root/'work/docs'
14 variables=dict(v.split('=',1) for v in shlex.split(run('systemctl','show','-P','Environment','studio-dashboard').stdout))
15 for key in ['STUDIO_INDEX_POOL','STUDIO_YT_STATE']:variables.pop(key,None)
16 variables.update(PORT='7074',STUDIO_METRICS='follow',STUDIO_STORE_ROOT=str(root),STUDIO_MEDIA_ROOT=str(media),STUDIO_DATA_DIR=temporary+'/state',STUDIO_FILES_WRITABLE='1',STUDIO_INDEX_POOL=dataset,STUDIO_INDEX_DIR=temporary+'/index')
17 proof={'Studio-Proxy-Token':Path(variables['STUDIO_PROXY_TOKEN_FILE']).read_text().strip()} if 'STUDIO_PROXY_TOKEN_FILE' in variables else {}
18 def start():
19 run('systemd-run','--unit='+unit,'--collect','--property=CPUWeight=1000','--property=CPUQuota=100%','--property=PrivateMounts=yes',*['--setenv='+k+'='+v for k,v in variables.items()],str(Path(args.binary).resolve()))
20 deadline=time.monotonic()+120
21 while time.monotonic()<deadline:
22 try:call('/api/me');return
23 except (OSError,AssertionError):time.sleep(.2)
24 raise RuntimeError('fixture did not start')
25 def call(path,body=None,status=200):
26 request=urllib.request.Request('http://127.0.0.1:7074'+path,data=json.dumps(body).encode() if body is not None else None,headers={'User-Name':'fixture','User-Groups':'infra-admin','Content-Type':'application/json',**proof})
27 try:response=urllib.request.urlopen(request,timeout=30)
28 except urllib.error.HTTPError as error:response=error
29 with response:
30 data=response.read();assert response.status==status,(path,response.status,data[:300])
31 return json.loads(data) if data and response.headers.get('content-type','').startswith('application/json') else data
32 def undo(op,prefix='/api/media'):
33 call(prefix+'/ops/'+op['id']+'/undo',{},204)
34 try:
35 for name,mount in [(dataset,root),(dataset+'/media',media),(dataset+'/docs',docs)]:
36 run('zfs','create','-o','mountpoint='+str(mount),name)
37 for name,size in [('shows/a/file.txt',120),('shows/a/two.txt',250),('seed/x.iso',10),('seed/y.iso',20)]:
38 file=media/name;file.parent.mkdir(parents=True,exist_ok=True);file.write_text('x'*size)
39 (docs/'notes').write_text('private fixture');(root/'local').write_text('top')
40 start()
41 deadline=time.monotonic()+120
42 while time.monotonic()<deadline:
43 if list(Path(temporary+'/index').glob('*.db')):break
44 time.sleep(.2)
45 assert list(Path(temporary+'/index').glob('*.db'))
46 tree=call('/api/media/tree',{'path':'shows','expanded':'all'})
47 assert tree['complete'] and tree['sizes']['shows']['size']==370
48 assert call('/api/media/peek?path=shows/a/file.txt')['text']=='x'*120
49 assert any(entry['name']=='seed' for entry in call('/api/media/list')['entries'])
50 call('/api/media/list?path=../work',status=403)
51 (media/'escape').symlink_to(docs,target_is_directory=True)
52 call('/api/media/list?path=escape',status=403)
53 op=call('/api/media/rename',{'renames':[{'path':'seed/x.iso','name':'first.iso'},{'path':'seed/y.iso','name':'second.iso'}]})
54 assert sorted(p.name for p in (media/'seed').iterdir())==['first.iso','second.iso'];undo(op)
55 call('/api/media/rename',{'renames':[{'path':'seed/x.iso','name':'y.iso'}]},409)
56 assert (media/'seed/x.iso').exists()
57 op=call('/api/media/move',{'paths':['shows/a','shows/a/file.txt'],'to':'seed'})
58 assert (media/'seed/a/file.txt').exists();undo(op);assert (media/'shows/a/file.txt').exists()
59 for name in ['media','work','work/docs']:
60 call('/api/storage/files/delete',{'paths':[name]},409)
61 call('/api/storage/files/move',{'paths':['local'],'to':'media'},409)
62 op=call('/api/storage/files/delete',{'paths':['media/seed/x.iso','work/docs/notes']})
63 assert not (media/'seed/x.iso').exists() and not (docs/'notes').exists()
64 run('systemctl','stop',unit);start()
65 assert call('/api/storage/files/ops')[0]['id']==op['id'];undo(op,'/api/storage/files')
66 assert (media/'seed/x.iso').read_text()=='x'*10 and (docs/'notes').read_text()=='private fixture'
67 op=call('/api/media/folder',{'path':'','name':'new'})
68 assert any(entry['name']=='new' for entry in call('/api/media/list')['entries'])
69 deadline=time.monotonic()+120
70 while time.monotonic()<deadline:
71 if call('/api/storage/files/map?width=800&height=600&path=media/new')['tree'] is not None:break
72 time.sleep(.2)
73 else:raise AssertionError('file changes did not wake the index')
74 (media/'new/extra').write_text('new content')
75 call('/api/media/ops/'+op['id']+'/undo',{},409);assert (media/'new/extra').exists()
76 (media/'new/extra').unlink();undo(op)
77 assert not any(entry['name']=='new' for entry in call('/api/media/list')['entries'])
78 run('systemctl','stop',unit);variables.pop('STUDIO_FILES_WRITABLE');start()
79 call('/api/media/folder',{'path':'','name':'denied'},501);assert not (media/'denied').exists()
80 print('PASS: tree totals, text preview, path confinement, bulk rename, atomic clash refusal, collapsed move, dataset protection, cross-dataset refusal, multi-dataset delete/undo, persistent journal, index wake after mutation, occupied-folder refusal, read-only mode')
81 finally:
82 subprocess.run(['systemctl','stop',unit],capture_output=True)
83 subprocess.run(['zfs','destroy','-r',dataset],check=True,capture_output=True)
tools/dashboard-host-test.py created+142
......@@ -0,0 +1,142 @@
1#!/usr/bin/env python3
2import importlib.util
3import json
4from pathlib import Path
5import sys
6import subprocess
7import unittest
8from unittest.mock import patch
9
10
11spec = importlib.util.spec_from_file_location("dashboard_host", Path(__file__).with_name("dashboard-host.py"))
12host_module = importlib.util.module_from_spec(spec)
13spec.loader.exec_module(host_module)
14
15
16class Boundary(unittest.TestCase):
17 def setUp(self):
18 self.host = host_module.Host("studio-demo")
19
20 def test_rejects_before_executing(self):
21 requests = [None, [], {}, {"operation": []},
22 {"operation": "command", "argv": ["sh", "-c", "id"]},
23 {"operation": "storage.datasets", "pool": "other"},
24 {"operation": "storage.datasets", "env": {"PATH": "/tmp"}},
25 {"operation": "host.sample", "path": "/root/private"},
26 {"operation": "host.usage", "refresh": True, "pid": 1},
27 {"operation": "deploy.managed", "path": "/var/lib/studio/nomad.token"},
28 {"operation": "storage.snapshots"}]
29 for refresh in [None, [], {}, 0, 1, "true"]:
30 requests.append({"operation": "host.usage", "refresh": refresh})
31 for dataset in [None, [], "other", "studio-demo-other", "studio-demo/../other",
32 "studio-demo//child", "studio-demo/child\nother", "-r", "studio-demo/a@snap"]:
33 requests.append({"operation": "storage.snapshots", "dataset": dataset})
34 for snapshot in [None, [], "a%b", "a,b", "a@b", "a\nb", "../other", "a;id"]:
35 requests.append({"operation": "storage.reclaim", "dataset": "studio-demo/a",
36 "from": snapshot, "to": "safe"})
37 with patch.object(host_module, "command") as command:
38 for request in requests:
39 with self.subTest(request=request), self.assertRaises(host_module.Rejected):
40 self.host.handle(request)
41 command.assert_not_called()
42
43 def test_reclaim_is_always_a_dry_run(self):
44 with patch.object(host_module, "command", return_value="reclaim\t1024") as command:
45 self.host.handle({"operation": "storage.reclaim", "dataset": "studio-demo/a",
46 "from": "snapshot one", "to": "snapshot two"})
47 command.assert_called_once_with("zfs", "destroy", "-nvp",
48 "studio-demo/a@snapshot one%snapshot two")
49
50 def test_mounts_exclude_other_pools(self):
51 mounts = {"filesystems": [{"source": source, "target": "/srv"}
52 for source in ["studio-demo", "studio-demo/a", "studio-demo-other/a", "other/a", "studio-demo/a@dash-123"]]}
53 with patch.object(host_module, "command", return_value=json.dumps(mounts)):
54 self.assertEqual([m["source"] for m in self.host.handle({"operation": "storage.mounts"})["filesystems"]],
55 ["studio-demo", "studio-demo/a"])
56
57 def test_snapshot_arguments_cannot_be_options(self):
58 with patch.object(host_module, "command", return_value="") as command:
59 self.host.handle({"operation": "storage.removed", "dataset": "studio-demo/a", "snapshot": "snapshot -r"})
60 command.assert_called_once_with("zfs", "diff", "-H", "studio-demo/a@snapshot -r", "studio-demo/a")
61
62 def test_snapshot_mutations_are_scoped(self):
63 requests = []
64 for datasets in [None, {}, [], [None], ["other/private"], ["studio-demo/../escape"], ["-r"]]:
65 requests.append({"operation": "files.snapshot", "datasets": datasets})
66 for snapshot in ["before", "dash-1%after", "dash-1,after", "dash-../other", "dash--r"]:
67 requests.append({"operation": "files.discard", "dataset": "studio-demo/a", "snapshot": snapshot})
68 for value in ["before%after", "-r", "before,after", "before@after"]:
69 requests.append({"operation": "storage.destroy", "dataset": "studio-demo/a", "from": value, "to": "after"})
70 requests.append({"operation": "files.snapshot", "datasets": ["studio-demo/a"], "recursive": True})
71 with patch.object(host_module, "command") as command:
72 for request in requests:
73 with self.subTest(request=request), self.assertRaises(host_module.Rejected):
74 self.host.handle(request)
75 command.assert_not_called()
76
77 def test_file_snapshots_use_only_the_undo_namespace(self):
78 with patch.object(host_module.time, "time_ns", return_value=123), patch.object(host_module, "command", return_value="") as command:
79 name = self.host.handle({"operation": "files.snapshot", "datasets": ["studio-demo/b", "studio-demo/a", "studio-demo/a"]})
80 self.assertEqual(name, "dash-123")
81 command.assert_called_once_with("zfs", "snapshot", "studio-demo/a@dash-123", "studio-demo/b@dash-123")
82 with patch.object(host_module, "command", return_value="studio-demo/a@before\nstudio-demo/a@dash-123\nstudio-demo/a@dash-manual\n"):
83 self.assertEqual(self.host.handle({"operation": "files.snapshots", "datasets": ["studio-demo/a"]}), ["studio-demo/a@dash-123"])
84 with patch.object(host_module, "command", return_value="") as command:
85 self.host.handle({"operation": "files.discard", "dataset": "studio-demo/a", "snapshot": "dash-123"})
86 command.assert_called_once_with("zfs", "destroy", "studio-demo/a@dash-123")
87
88 def test_child_output_is_bounded(self):
89 with patch.object(host_module, "MAX_RESPONSE", 4096):
90 with self.assertRaisesRegex(RuntimeError, "too large"):
91 host_module.command(sys.executable, "-c", "import os; os.write(2, b'x' * 1000000)")
92
93 def test_index_operations_preserve_other_snapshots(self):
94 dataset = "studio-demo/a"
95 with patch.object(host_module, "command") as command:
96 for name in ["manual", "dash-123", "index-fixture", "index-123%manual", "index-../escape"]:
97 for request in [{"operation": "index.discard", "dataset": dataset, "snapshot": name},
98 {"operation": "index.diff", "dataset": dataset, "from": name, "to": "index-456"}]:
99 with self.subTest(request=request), self.assertRaises(host_module.Rejected):
100 self.host.handle(request)
101 command.assert_not_called()
102 with patch.object(host_module.time, "time_ns", return_value=123), patch.object(host_module, "command", return_value="") as command:
103 self.assertEqual(self.host.handle({"operation": "index.snapshot", "dataset": dataset}), dataset + "@index-123")
104 command.assert_called_once_with("zfs", "snapshot", dataset + "@index-123")
105 legacy = "index-123-12345678-1234-1234-1234-123456789abc"
106 with patch.object(host_module, "command", return_value="\n".join(dataset + "@" + name for name in ["manual", "index-123", legacy, "index-fixture"])):
107 self.assertEqual(self.host.handle({"operation": "index.snapshots", "dataset": dataset}),
108 [dataset + "@index-123", dataset + "@" + legacy])
109 with patch.object(host_module, "command", return_value="") as command:
110 self.host.handle({"operation": "index.diff", "dataset": dataset, "from": legacy, "to": "index-456"})
111 command.assert_called_once_with("zfs", "diff", "-FH", dataset + "@" + legacy, dataset + "@index-456")
112 with patch.object(host_module, "command", return_value="") as command:
113 self.host.handle({"operation": "index.discard", "dataset": dataset, "snapshot": legacy})
114 command.assert_called_once_with("zfs", "destroy", "-d", dataset + "@" + legacy)
115
116 def test_nonzero_exit_is_reported(self):
117 with self.assertRaises(subprocess.CalledProcessError) as failed:
118 host_module.command(sys.executable, "-c", "import sys; sys.stderr.write('fixture problem'); sys.exit(1)")
119 self.assertEqual(failed.exception.stderr, "fixture problem")
120
121 def test_child_deadline_is_enforced(self):
122 with self.assertRaises(TimeoutError):
123 host_module.command(sys.executable, "-c", "import time; time.sleep(10)", timeout=.05)
124
125 def test_vm_requests_reject_before_executing(self):
126 spec = {"name": "fixture", "description": "", "image": "blank", "vcpus": 1,
127 "memory": 2**29, "disk": 2**30, "autostart": False, "start": False}
128 with patch.object(host_module.vms, "node", return_value={"cpus": 8, "memory": 8 * 2**30}), patch.object(host_module, "command") as command:
129 for field, value in [("name", "../escape"), ("image", "/root/disk.img"), ("vcpus", True),
130 ("memory", 16 * 2**30), ("disk", 2**64), ("vcpus", 9), ("argv", ["sh"])]:
131 with self.subTest(field=field), self.assertRaises(host_module.Rejected):
132 self.host.handle({"operation": "vm.create", "payload": {**spec, field: value}})
133 for request in [{"operation": "vm.xml", "payload": {}}, {"operation": "vm.node", "payload": {}},
134 {"operation": "vm.act", "payload": {"name": "fixture", "action": []}},
135 {"operation": "vm.update", "payload": {"name": "fixture"}}]:
136 with self.assertRaises(host_module.Rejected):
137 self.host.handle(request)
138 command.assert_not_called()
139
140
141if __name__ == "__main__":
142 unittest.main()
tools/dashboard-host-vm-test.py created+144
......@@ -0,0 +1,144 @@
1#!/usr/bin/env python3
2import argparse
3import json
4import os
5from pathlib import Path
6import pwd
7import socket
8import struct
9import subprocess
10import sys
11import uuid
12
13
14def receive(connection, length):
15 result = bytearray()
16 while len(result) < length:
17 chunk = connection.recv(length - len(result))
18 if not chunk:
19 raise EOFError("host closed the connection")
20 result.extend(chunk)
21 return bytes(result)
22
23
24def request(socket_path, payload):
25 with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
26 connection.settimeout(70)
27 connection.connect(socket_path)
28 connection.sendall(payload)
29 connection.shutdown(socket.SHUT_WR)
30 length = struct.unpack("!I", receive(connection, 4))[0]
31 assert length <= 16 * 1024 * 1024, length
32 return json.loads(receive(connection, length))
33
34
35def main():
36 parser = argparse.ArgumentParser()
37 parser.add_argument("socket")
38 parser.add_argument("--pool", default="studio-demo")
39 parser.add_argument("--user", default="nobody")
40 parser.add_argument("--client", action="store_true")
41 args = parser.parse_args()
42 if args.client:
43 account = pwd.getpwnam(args.user)
44 os.setgroups([])
45 os.setgid(account.pw_gid)
46 os.setuid(account.pw_uid)
47 json.dump(request(args.socket, sys.stdin.buffer.read()), sys.stdout)
48 return
49
50 def call(value, raw=False):
51 payload = value if raw else json.dumps(value).encode() + b"\n"
52 process = subprocess.run([sys.executable, __file__, args.socket, "--client", "--user", args.user],
53 input=payload, capture_output=True)
54 if process.returncode:
55 raise RuntimeError(process.stderr.decode())
56 return json.loads(process.stdout)
57
58 suffix = uuid.uuid4().hex
59 dataset = args.pool + "/host-boundary-test-" + suffix
60 clone = dataset + "-clone"
61 mount = Path("/srv/.host-boundary-test-" + suffix)
62 sample = call({"operation": "host.sample"})["value"]
63 expected_memory = next(int(line.split()[1]) * 1024 for line in Path("/proc/meminfo").read_text().splitlines() if line.startswith("MemTotal:"))
64 assert sample["memory"]["total"] == expected_memory and sample["memory"]["used"] > 0, sample
65 assert sample["cpu"]["total"] >= sample["cpu"]["busy"] > 0 and sample["arc"] > 0, sample
66 inventory = call({"operation": "host.usage", "refresh": True})["value"]
67 assert inventory["containers"], inventory
68 assert all(set(row) == {"id", "name", "cpu", "memory"} for row in inventory["containers"]), inventory
69 cached = call({"operation": "host.usage", "refresh": False})["value"]
70 before = {row["id"]: row for row in inventory["containers"]}
71 assert any(row["cpu"] > before[row["id"]]["cpu"] for row in cached["containers"] if row["id"] in before), cached
72 subprocess.run(["zfs", "create", "-o", "mountpoint=" + str(mount), dataset], check=True)
73 try:
74 (mount / "fixture").write_bytes(b"host boundary fixture\n" * 8192)
75 subprocess.run(["zfs", "snapshot", dataset + "@before"], check=True)
76 (mount / "fixture").unlink()
77 subprocess.run(["zfs", "snapshot", dataset + "@after"], check=True)
78 rows = call({"operation": "storage.datasets"})["value"]
79 assert any(row["name"] == dataset for row in rows), rows
80 assert all(row["name"] == args.pool or row["name"].startswith(args.pool + "/") for row in rows)
81 assert call({"operation": "storage.pool"})["value"]["name"] == args.pool
82 snapshots = call({"operation": "storage.snapshots", "dataset": dataset})["value"]
83 assert {row["name"] for row in snapshots} == {"before", "after"}, snapshots
84 assert "fixture" in call({"operation": "storage.removed", "dataset": dataset, "snapshot": "before"})["value"]
85 assert "reclaim\t" in call({"operation": "storage.reclaim", "dataset": dataset, "from": "before", "to": "after"})["value"]
86 subprocess.run(["zfs", "clone", "-o", "mountpoint=none", dataset + "@before", clone], check=True)
87 assert call({"operation": "storage.reclaim", "dataset": dataset, "from": "before", "to": "after"})["status"] == 409
88 denied = [
89 {"operation": "command", "argv": ["touch", str(mount / "escaped")]},
90 {"operation": "storage.snapshots", "dataset": "other-pool/private"},
91 {"operation": "storage.datasets", "env": {"PATH": "/tmp"}},
92 {"operation": "storage.reclaim", "dataset": dataset, "from": "before%after", "to": "after"},
93 {"operation": "storage.destroy", "dataset": dataset},
94 {"operation": "host.sample", "file": "/etc/shadow"},
95 {"operation": "host.usage", "refresh": True, "pid": 1},
96 ]
97 for value in denied:
98 assert call(value)["status"] == 400, value
99 assert call(b"x" * 65537 + b"\n", raw=True)["status"] == 400
100 assert call(b'{"operation":"storage.datasets"}', raw=True)["status"] == 400
101 assert call(b'\xff\n', raw=True)["status"] == 400
102 try:
103 request(args.socket, b'{"operation":"storage.datasets"}\n')
104 except (EOFError, ConnectionResetError, BrokenPipeError):
105 pass
106 else:
107 raise AssertionError("a different peer UID was accepted")
108 remaining = subprocess.check_output(["zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset], text=True)
109 assert set(remaining.splitlines()) == {dataset + "@before", dataset + "@after"}, remaining
110 assert not (mount / "escaped").exists()
111 rejected = [
112 {"operation": "files.snapshot", "datasets": [dataset, "other-pool/private"]},
113 {"operation": "files.discard", "dataset": dataset, "snapshot": "before"},
114 {"operation": "storage.destroy", "dataset": dataset, "from": "before%after", "to": "after"},
115 {"operation": "storage.destroy", "dataset": dataset, "from": "before", "to": "after", "recursive": True},
116 ]
117 for value in rejected:
118 assert call(value)["status"] == 400, value
119 child = dataset + "/child"
120 subprocess.run(["zfs", "create", "-o", "mountpoint=none", child], check=True)
121 undo = call({"operation": "files.snapshot", "datasets": [dataset, child]})["value"]
122 assert undo.startswith("dash-") and undo[5:].isdigit(), undo
123 listed = call({"operation": "files.snapshots", "datasets": [dataset, child]})["value"]
124 assert set(listed) == {dataset + "@" + undo, child + "@" + undo}, listed
125 for name in [dataset, child]:
126 assert call({"operation": "files.discard", "dataset": name, "snapshot": undo})["value"] is None
127 assert call({"operation": "storage.destroy", "dataset": dataset, "from": "before", "to": "after"})["status"] == 409
128 subprocess.run(["zfs", "destroy", clone], check=True)
129 call({"operation": "storage.destroy", "dataset": dataset, "from": "before", "to": "after"})["value"]
130 remaining = subprocess.check_output(["zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset], text=True)
131 assert not remaining.strip(), remaining
132 print(json.dumps({"unprivileged_zfs_reads": "passed", "cross_pool_isolation": "passed",
133 "dry_run_preserves_snapshots": "passed", "request_bounds": "passed",
134 "peer_uid_isolation": "passed", "command_injection_rejection": "passed",
135 "atomic_multi_dataset_undo_snapshots": "passed", "undo_namespace_isolation": "passed",
136 "snapshot_range_deletion": "passed", "clone_deletion_refusal": "passed",
137 "host_sample": "passed", "container_counter_inventory": "passed"}))
138 finally:
139 subprocess.run(["zfs", "destroy", clone], capture_output=True)
140 subprocess.run(["zfs", "destroy", "-r", dataset], check=True)
141
142
143if __name__ == "__main__":
144 main()
tools/dashboard-host.py created+347
......@@ -0,0 +1,347 @@
1#!/usr/bin/env python3
2import concurrent.futures
3import importlib
4import json
5import math
6import os
7from pathlib import Path
8import pwd
9import re
10import selectors
11import signal
12import socket
13import struct
14import subprocess
15import threading
16import time
17import vms
18
19dashboard_runs = importlib.import_module("dashboard-run")
20
21
22FIELDS = (
23 "name", "used", "usedbydataset", "usedbysnapshots", "referenced", "available",
24 "compressratio", "logicalused", "compression", "recordsize", "mountpoint",
25 "quota", "origin", "mounted",
26)
27TEXT_FIELDS = {"name", "compression", "mountpoint", "origin", "mounted"}
28MAX_REQUEST = 65536
29MAX_RESPONSE = 16 * 1024 * 1024
30INDEX_SNAPSHOT = r"index-[0-9]+(?:-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})?"
31
32
33class Rejected(Exception):
34 pass
35
36
37class Host:
38 def __init__(self, pool):
39 if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_.:-]*", pool):
40 raise ValueError("incorrect storage pool")
41 self.pool = pool
42 self.containers = None
43 self.inventory_lock = threading.Lock()
44
45 def dataset(self, value):
46 if (not isinstance(value, str) or len(value) > 255
47 or not re.fullmatch(r"[\w.: ][\w.: -]*(/[\w.: -]+)*", value)
48 or any(part in {".", ".."} for part in value.split("/"))
49 or not (value == self.pool or value.startswith(self.pool + "/"))):
50 raise Rejected("Choose a dataset from this storage pool.")
51 return value
52
53 def snapshot(self, value):
54 if (not isinstance(value, str) or len(value) > 255
55 or not re.fullmatch(r"[\w.: ][\w.: -]*", value)
56 or value in {".", ".."}):
57 raise Rejected("Choose a snapshot from the list.")
58 return value
59
60 def handle(self, request):
61 if not isinstance(request, dict):
62 raise Rejected("Choose a supported host operation.")
63 operation = request.get("operation")
64 parameters = {
65 "storage.datasets": set(),
66 "storage.pool": set(),
67 "storage.mounts": set(),
68 "storage.snapshots": {"dataset"},
69 "storage.reclaim": {"dataset", "from", "to"},
70 "storage.destroy": {"dataset", "from", "to"},
71 "storage.removed": {"dataset", "snapshot"},
72 "files.snapshot": {"datasets"},
73 "files.snapshots": {"datasets"},
74 "files.discard": {"dataset", "snapshot"},
75 "index.snapshot": {"dataset"},
76 "index.snapshots": {"dataset"},
77 "index.diff": {"dataset", "from", "to"},
78 "index.discard": {"dataset", "snapshot"},
79 "host.sample": set(),
80 "host.usage": {"refresh"},
81 **dashboard_runs.FIELDS,
82 **{"vm." + action: set() if fields is None else {"payload"} for action, fields in vms.ACTION_FIELDS.items()},
83 }
84 if not isinstance(operation, str) or operation not in parameters:
85 raise Rejected("Choose a supported host operation.")
86 if set(request) != parameters[operation] | {"operation"}:
87 raise Rejected("Use only the fields required by this host operation.")
88 if operation in dashboard_runs.FIELDS:
89 return dashboard_runs.handle(request)
90 if operation == "host.sample":
91 stat = Path("/proc/stat").read_text().splitlines()
92 # Guest columns are already included in user and nice time.
93 cpu = list(map(int, stat[0].split()[1:9]))
94 total = sum(cpu)
95 memory = {line.split()[0].rstrip(":"): int(line.split()[1]) * 1024
96 for line in Path("/proc/meminfo").read_text().splitlines()}
97 arc = None
98 try:
99 arc = next(int(line.split()[-1]) for line in Path("/proc/spl/kstat/zfs/arcstats").read_text().splitlines() if line.split()[0] == "size")
100 except (OSError, StopIteration):
101 pass
102 temperature = None
103 for root in Path("/sys/class/hwmon").glob("hwmon*"):
104 try:
105 if root.joinpath("name").read_text().strip() in {"k10temp", "coretemp", "zenpower"}:
106 value = float(root.joinpath("temp1_input").read_text()) / 1000
107 if math.isfinite(value) and value > 0:
108 temperature = value
109 break
110 except (OSError, ValueError):
111 pass
112 rx, tx = 0, 0
113 for line in Path("/proc/net/dev").read_text().splitlines():
114 name, separator, values = line.partition(":")
115 if separator and name.strip().startswith(("en", "eth", "wl")):
116 values = values.split()
117 rx += int(values[0])
118 tx += int(values[8])
119 gpu = None
120 try:
121 values = [float(line) for line in command("nvidia-smi", "--query-gpu=utilization.gpu", "--format=csv,noheader,nounits", timeout=3).splitlines()]
122 values = [value for value in values if math.isfinite(value)]
123 if values:
124 gpu = sum(values) / len(values)
125 except (OSError, ValueError, subprocess.SubprocessError, TimeoutError):
126 pass
127 return {"at": time.monotonic(), "cpu": {"busy": total - cpu[3] - cpu[4], "total": total},
128 "cores": sum(bool(re.match(r"cpu[0-9]+\b", line)) for line in stat),
129 "memory": {"used": memory["MemTotal"] - memory["MemAvailable"], "total": memory["MemTotal"]},
130 "bootedAt": time.time() - float(Path("/proc/uptime").read_text().split()[0]),
131 "load": os.getloadavg()[1], "arc": arc, "temperature": temperature, "gpu": gpu,
132 "network": {"rx": rx, "tx": tx}}
133 if operation == "host.usage":
134 if type(request["refresh"]) is not bool:
135 raise Rejected("Choose whether to refresh the container list.")
136 with self.inventory_lock:
137 if request["refresh"] or not self.containers:
138 self.containers = json.loads(command("podman", "--remote", "--url", "unix:///run/podman/podman.sock", "ps", "--format", "json", timeout=10))
139 rows = []
140 for container in self.containers:
141 pid, identity = container["Pid"], container["Id"]
142 if not isinstance(pid, int) or pid <= 0 or not re.fullmatch(r"[0-9a-f]{64}", identity):
143 continue
144 try:
145 cgroup = next(line[3:] for line in Path(f"/proc/{pid}/cgroup").read_text().splitlines() if line.startswith("0::"))
146 if f"libpod-{identity}.scope" not in Path(cgroup).parts:
147 raise ValueError("container cgroup changed")
148 root = Path("/sys/fs/cgroup") / cgroup.lstrip("/")
149 cpu = next(int(line.split()[1]) for line in root.joinpath("cpu.stat").read_text().splitlines() if line.startswith("usage_usec "))
150 memory = int(root.joinpath("memory.current").read_text())
151 rows.append({"id": identity, "name": container["Names"][0], "cpu": cpu, "memory": memory})
152 except (OSError, ValueError, StopIteration):
153 self.containers = None
154 return {"at": time.monotonic(), "containers": rows}
155 if operation.startswith("vm."):
156 action = operation[3:]
157 payload = request.get("payload")
158 try:
159 vms.validate(action, payload)
160 except ValueError as error:
161 raise Rejected(str(error)) from error
162 args = ["python3", str(Path(__file__).with_name("vms.py")), action]
163 if payload is not None:
164 args.append(json.dumps(payload))
165 try:
166 return json.loads(command(*args))
167 except subprocess.CalledProcessError as error:
168 if error.returncode == 2:
169 raise Rejected(error.stderr) from error
170 raise
171 if operation in {"files.snapshot", "files.snapshots"}:
172 values = request["datasets"]
173 if not isinstance(values, list) or not values:
174 raise Rejected("Choose at least one dataset.")
175 datasets = sorted({self.dataset(value) for value in values})
176 if operation == "files.snapshot":
177 name = f"dash-{time.time_ns()}"
178 command("zfs", "snapshot", *(f"{dataset}@{name}" for dataset in datasets))
179 return name
180 text = command("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-d", "1", *datasets)
181 return [name for name in text.splitlines() if re.fullmatch(r"dash-[0-9]+", name.partition("@")[2])]
182 dataset = self.dataset(request["dataset"]) if "dataset" in request else None
183 if operation.startswith("index."):
184 if operation == "index.snapshot":
185 snapshot = f"{dataset}@index-{time.time_ns()}"
186 command("zfs", "snapshot", snapshot)
187 return snapshot
188 if operation == "index.snapshots":
189 text = command("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-d", "1", dataset)
190 return [name for name in text.splitlines() if re.fullmatch(INDEX_SNAPSHOT, name.partition("@")[2])]
191 names = [request["from"], request["to"]] if operation == "index.diff" else [request["snapshot"]]
192 if any(not re.fullmatch(INDEX_SNAPSHOT, self.snapshot(name)) for name in names):
193 raise Rejected("Choose an index snapshot.")
194 snapshots = [f"{dataset}@{name}" for name in names]
195 if operation == "index.diff":
196 return command("zfs", "diff", "-FH", *snapshots)
197 command("zfs", "destroy", "-d", *snapshots)
198 return None
199 if operation == "storage.datasets":
200 text = command("zfs", "list", "-Hp", "-r", "-t", "filesystem",
201 "-o", ",".join(FIELDS), self.pool)
202 rows = []
203 for line in text.splitlines():
204 values = line.split("\t")
205 if len(values) != len(FIELDS):
206 raise ValueError("unexpected ZFS dataset response")
207 row = dict(zip(FIELDS, values))
208 for field in FIELDS:
209 if field not in TEXT_FIELDS:
210 row[field] = numeric(row[field])
211 if row.pop("mounted") != "yes" or row["mountpoint"] == "none":
212 row["mountpoint"] = None
213 if row["origin"] == "-":
214 row["origin"] = None
215 rows.append(row)
216 return rows
217 if operation == "storage.pool":
218 fields = ("size", "allocated", "free", "fragmentation")
219 summary = command("zpool", "list", "-Hp", "-o", ",".join(fields), self.pool)
220 status = json.loads(command("zpool", "status", "-jp", "--json-int", self.pool))
221 return {"summary": dict(zip(fields, map(numeric, summary.strip().split("\t")))),
222 "status": status, "name": self.pool}
223 if operation == "storage.mounts":
224 mounts = json.loads(command("findmnt", "-J", "-l", "-t", "zfs", "-o", "SOURCE,TARGET"))
225 return {"filesystems": [item for item in mounts.get("filesystems", [])
226 if "@" not in item["source"]
227 and (item["source"] == self.pool or item["source"].startswith(self.pool + "/"))]}
228 if operation == "storage.snapshots":
229 text = command("zfs", "list", "-Hp", "-t", "snapshot", "-d", "1", "-s", "creation",
230 "-o", "name,creation,used,referenced,clones", dataset)
231 rows = []
232 for line in text.splitlines():
233 name, creation, used, referenced, clones = line.split("\t")
234 rows.append({"name": name.split("@", 1)[1], "creation": numeric(creation),
235 "used": numeric(used), "referenced": numeric(referenced),
236 "clones": [] if clones == "-" else clones.split(",")})
237 return rows
238 if operation in {"storage.reclaim", "storage.destroy"}:
239 start, end = self.snapshot(request["from"]), self.snapshot(request["to"])
240 flags = ["-nvp"] if operation == "storage.reclaim" else []
241 return command("zfs", "destroy", *flags, f"{dataset}@{start}%{end}")
242 snapshot = self.snapshot(request["snapshot"])
243 if operation == "files.discard":
244 if not re.fullmatch(r"dash-[0-9]+", snapshot):
245 raise Rejected("Choose a file undo snapshot.")
246 command("zfs", "destroy", f"{dataset}@{snapshot}")
247 return None
248 return command("zfs", "diff", "-H", f"{dataset}@{snapshot}", dataset)
249
250
251def numeric(value):
252 try:
253 return float(value.rstrip("x"))
254 except ValueError:
255 return 0
256
257
258def command(*args, timeout=60):
259 with subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
260 stderr=subprocess.PIPE, start_new_session=True) as process:
261 output, errors = bytearray(), bytearray()
262 deadline = time.monotonic() + timeout
263 try:
264 with selectors.DefaultSelector() as selector:
265 selector.register(process.stdout, selectors.EVENT_READ, output)
266 selector.register(process.stderr, selectors.EVENT_READ, errors)
267 while selector.get_map():
268 remaining = deadline - time.monotonic()
269 if remaining <= 0:
270 raise TimeoutError("host command timed out")
271 for key, _ in selector.select(remaining):
272 chunk = os.read(key.fd, 65536)
273 if not chunk:
274 selector.unregister(key.fileobj)
275 continue
276 key.data.extend(chunk)
277 if len(output) + len(errors) > MAX_RESPONSE // 2:
278 raise RuntimeError("The host response is too large. Narrow the selection.")
279 process.wait(timeout=max(0.001, deadline - time.monotonic()))
280 except BaseException:
281 try:
282 os.killpg(process.pid, signal.SIGKILL)
283 except ProcessLookupError:
284 pass
285 raise
286 if process.returncode:
287 raise subprocess.CalledProcessError(process.returncode, args, stderr=errors.decode(errors="replace").strip())
288 return output.decode()
289
290
291def serve_connection(connection, host, allowed_uid):
292 with connection:
293 connection.settimeout(65)
294 _, uid, _ = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
295 if uid != allowed_uid:
296 return
297 try:
298 with connection.makefile("rb") as incoming:
299 line = incoming.readline(MAX_REQUEST + 1)
300 if len(line) > MAX_REQUEST or not line.endswith(b"\n"):
301 raise Rejected("The host request is too large or incomplete.")
302 response = {"value": host.handle(json.loads(line))}
303 except Rejected as error:
304 response = {"error": str(error), "status": 400}
305 except dashboard_runs.Error as error:
306 response = {"error": str(error), "status": error.status}
307 except (json.JSONDecodeError, UnicodeDecodeError):
308 response = {"error": "Send the host request as UTF-8 JSON.", "status": 400}
309 except Exception as error:
310 message = error.stderr if isinstance(error, subprocess.CalledProcessError) else str(error)
311 print(f"host operation: {message}", flush=True)
312 if isinstance(error, subprocess.CalledProcessError) and any(line.endswith(": snapshot has dependent clones") for line in message.splitlines()):
313 response = {"error": "A snapshot has clones. Delete its clones before deleting the snapshot.", "status": 409}
314 else:
315 response = {"error": "The host operation couldn't finish. Check its logs, then retry.", "status": 502}
316 payload = json.dumps(response, allow_nan=False).encode()
317 if len(payload) > MAX_RESPONSE:
318 payload = b'{"error":"The host response is too large. Narrow the selection.","status":502}'
319 connection.sendall(struct.pack("!I", len(payload)) + payload)
320
321
322def main():
323 host = Host(os.environ["STUDIO_POOL"])
324 allowed_uid = pwd.getpwnam(os.environ["STUDIO_DASHBOARD_USER"]).pw_uid
325 socket_path = Path(os.environ.get("STUDIO_HOST_SOCKET", "/run/studio-host/host.sock"))
326 socket_path.unlink(missing_ok=True)
327 with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener:
328 listener.bind(str(socket_path))
329 socket_path.chmod(0o660)
330 listener.listen(8)
331 if address := os.environ.get("NOTIFY_SOCKET"):
332 with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as notification:
333 notification.connect("\0" + address[1:] if address.startswith("@") else address)
334 notification.sendall(b"READY=1")
335 slots = threading.BoundedSemaphore(8)
336 with concurrent.futures.ThreadPoolExecutor(max_workers=4) as workers:
337 while True:
338 connection, _ = listener.accept()
339 if not slots.acquire(blocking=False):
340 connection.close()
341 continue
342 work = workers.submit(serve_connection, connection, host, allowed_uid)
343 work.add_done_callback(lambda _: slots.release())
344
345
346if __name__ == "__main__":
347 main()
tools/dashboard-iam-test.py created+156
......@@ -0,0 +1,156 @@
1#!/usr/bin/env python3
2import argparse
3import importlib
4import json
5from pathlib import Path
6import subprocess
7import sys
8import time
9import urllib.error
10import urllib.parse
11import urllib.request
12import uuid
13
14
15def main():
16 parser = argparse.ArgumentParser()
17 parser.add_argument("--url", required=True)
18 parser.add_argument("--socket", required=True)
19 parser.add_argument("--proof-file", type=Path, required=True)
20 parser.add_argument("--user", default="studio-dashboard")
21 parser.add_argument("--output", type=Path)
22 args = parser.parse_args()
23 if args.output:
24 args.output.unlink(missing_ok=True)
25 sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "service/keycloak"))
26 from api import Keycloak
27 keycloak = Keycloak("keycloak.studio.test", importlib.import_module("dashboard-run").secret("get", "keycloak", "password"), attempts=1)
28 keycloak.configure_profile()
29 name = "iam-fixture-" + uuid.uuid4().hex
30 password = uuid.uuid4().hex + "A1!"
31 identity = None
32 proof = args.proof_file.read_text().strip()
33 admin = keycloak.request("/admin/realms/master/users?username=admin&exact=true")[0]
34 admin = keycloak.request("/admin/realms/master/users/" + admin["id"])
35 admin_roles = keycloak.request("/admin/realms/master/users/" + admin["id"] + "/role-mappings/realm")
36 server_role = next(role for role in admin_roles if role["name"] == "admin")
37
38 def http(path, method="GET", body=None, status=200, actor="fixture-operator", groups="infra-admin"):
39 request = urllib.request.Request(args.url + "/api/" + path, method=method,
40 data=json.dumps(body).encode() if body is not None else None,
41 headers={"User-Name": actor, "User-Groups": groups, "Studio-Proxy-Token": proof, "Content-Type": "application/json"})
42 try:
43 response = urllib.request.urlopen(request, timeout=70)
44 except urllib.error.HTTPError as error:
45 response = error
46 with response:
47 content = response.read()
48 assert response.status == status, (path, response.status, content[:200])
49 return json.loads(content) if content and response.headers.get("Content-Type", "").startswith("application/json") else None
50
51 def broker(path=None, method="GET", body=None, status=200, **fields):
52 payload = {"operation": "iam.request", "path": path, "method": method, "body": body} if path is not None else fields
53 process = subprocess.run([sys.executable, str(Path(__file__).with_name("dashboard-host-vm-test.py")), args.socket,
54 "--client", "--user", args.user], input=json.dumps(payload).encode() + b"\n", capture_output=True, timeout=75)
55 assert process.returncode == 0, "broker client failed"
56 result = json.loads(process.stdout)
57 if status != 200:
58 assert result.get("status") == status, (path, result)
59 return None
60 assert "value" in result, (path, result)
61 return result["value"]
62
63 def sign_in(secret):
64 body = urllib.parse.urlencode({"client_id": "admin-cli", "grant_type": "password", "username": name, "password": secret}).encode()
65 return keycloak.request("/realms/master/protocol/openid-connect/token", "POST", body)
66
67 try:
68 started = time.monotonic()
69 directory = http("users")
70 directory_seconds = time.monotonic() - started
71 assert all(user["username"] != "admin" for user in directory["users"])
72 roles = {role["name"]: role for role in directory["groups"]}
73 assert set(roles) == {"infra-admin", "media", "media-manage"}
74 http("users", status=403, groups="media")
75 created = http("users", "POST", {"profile": {"username": name, "email": name + "@fixture.invalid", "firstName": "Native", "lastName": "Fixture"},
76 "groups": [roles["media"]["id"]], "setup": {"kind": "password", "password": password}}, status=201)
77 identity = created["id"]
78 path = "users/" + identity
79 assert keycloak.request("/admin/realms/master/" + path)["username"] == name
80 http(path, "PATCH", {"requiredActions": [], "emailVerified": True, "firstName": "Verified"}, status=204)
81 http(path + "/password", "PUT", {"password": password, "temporary": False}, status=204)
82 tokens = sign_in(password)
83 assert tokens["access_token"]
84 http(path + "/credentials")
85 http(path + "/groups/" + roles["infra-admin"]["id"], "PUT", status=204)
86 http(path + "/groups/" + roles["infra-admin"]["id"], "DELETE", status=400, actor=name)
87 http(path + "/groups/" + roles["infra-admin"]["id"], "DELETE", status=204)
88 mapped = {role["name"] for role in keycloak.request("/admin/realms/master/" + path + "/role-mappings/realm")}
89 assert mapped & {"infra-admin", "media", "media-manage", "admin"} == {"media"}
90 http("account", "PATCH", {"firstName": "Account"}, status=204, actor=name, groups="media")
91 assert http("account", actor=name, groups="media")["firstName"] == "Account"
92 broker("/" + path, "PUT", {"attributes": {"picture": ["https://fixture.invalid/picture"]}})
93 profile = keycloak.request("/admin/realms/master/" + path)
94 assert profile["attributes"]["picture"] == ["https://fixture.invalid/picture"]
95 assert profile["firstName"] == "Account" and profile["email"] == name + "@fixture.invalid"
96 broker("/" + path, "PUT", {"attributes": {"picture": None}})
97 profile = keycloak.request("/admin/realms/master/" + path)
98 assert "picture" not in profile.get("attributes", {})
99 assert profile["firstName"] == "Account" and profile["email"] == name + "@fixture.invalid"
100 http(path + "/logout", "POST", status=204)
101 try:
102 keycloak.request("/realms/master/protocol/openid-connect/token", "POST", urllib.parse.urlencode({
103 "client_id": "admin-cli", "grant_type": "refresh_token", "refresh_token": tokens["refresh_token"]}).encode())
104 except urllib.error.HTTPError as error:
105 assert error.code == 400
106 else:
107 raise AssertionError("logged-out refresh token remained usable")
108 changed = uuid.uuid4().hex + "A1!"
109 http(path + "/password", "PUT", {"password": changed, "temporary": False}, status=204)
110 assert sign_in(changed)["access_token"]
111 http(path, "PATCH", {"enabled": False}, status=204)
112 try:
113 sign_in(changed)
114 except urllib.error.HTTPError as error:
115 assert error.code == 400
116 else:
117 raise AssertionError("disabled fixture signed in")
118 http(path, "PATCH", {"enabled": True}, status=204)
119 for operation in ["get", "set", "rotate"]:
120 fields = {"operation": "deploy.secret." + operation, "service": "keycloak", "key": "password"}
121 if operation == "set":
122 fields["value"] = "fixture-not-applied"
123 broker(status=403, **fields)
124 for forbidden in ["/clients", "/realm-settings", "/users/" + identity + "/../../clients", "/users?max=1000&first=0"]:
125 broker(forbidden, status=400)
126 broker("/users/" + admin["id"] + "/reset-password", "PUT", {"type": "password", "value": "fixture-not-applied", "temporary": False}, status=403)
127 broker("/users/" + admin["id"], "DELETE", status=403)
128 broker("/" + path + "/role-mappings/realm", "POST", [server_role], status=403)
129 broker("/" + path + "/role-mappings/realm", "POST", [{"id": server_role["id"], "name": "infra-admin"}], status=403)
130 assert keycloak.request("/admin/realms/master/users/" + admin["id"]) == admin
131 assert keycloak.request("/admin/realms/master/users/" + admin["id"] + "/role-mappings/realm") == admin_roles
132 Keycloak("keycloak.studio.test", importlib.import_module("dashboard-run").secret("get", "keycloak", "password"), attempts=1)
133 http(path, "DELETE", status=204)
134 identity = None
135 result = {"native_user_crud": True, "password_sign_in_and_reset": True, "disable_rejects_sign_in": True,
136 "dashboard_role_grants": True, "self_lockout_refused": True, "account_profile": True,
137 "logout_revokes_refresh": True, "non_admin_refused": True, "bootstrap_user_hidden_and_protected": True,
138 "credential_export_and_change_refused": True, "admin_paths_and_forged_roles_refused": True,
139 "existing_realm_preserved": True, "directory_seconds": round(directory_seconds, 3)}
140 result["picture_persists_and_preserves_profile"] = True
141 finally:
142 if identity is not None:
143 keycloak.request("/admin/realms/master/users/" + identity, "DELETE")
144 remaining = keycloak.request("/admin/realms/master/users?username=" + name + "&exact=true")
145 for user in remaining:
146 keycloak.request("/admin/realms/master/users/" + user["id"], "DELETE")
147 assert not keycloak.request("/admin/realms/master/users?username=" + name + "&exact=true")
148 result["owned_fixture_removed"] = True
149 if args.output:
150 args.output.parent.mkdir(parents=True, exist_ok=True)
151 args.output.write_text(json.dumps(result, indent=2) + "\n")
152 print(json.dumps(result))
153
154
155if __name__ == "__main__":
156 main()
tools/dashboard-mcp-test.py created+235
......@@ -0,0 +1,235 @@
1#!/usr/bin/env python3
2import argparse
3import base64
4import hashlib
5import importlib
6import json
7from pathlib import Path
8import subprocess
9import sys
10import time
11import urllib.error
12import urllib.parse
13import urllib.request
14import uuid
15
16
17class NoRedirect(urllib.request.HTTPRedirectHandler):
18 def redirect_request(self, request, fp, code, message, headers, newurl):
19 return None
20
21
22def main():
23 parser = argparse.ArgumentParser()
24 parser.add_argument("--url", required=True)
25 parser.add_argument("--proof-file", type=Path, required=True)
26 parser.add_argument("--restart-unit")
27 parser.add_argument("--output", type=Path)
28 args = parser.parse_args()
29 if args.output:
30 args.output.unlink(missing_ok=True)
31 origin = "https://globe.studio.test"
32 resource = origin + "/mcp/observability"
33 opener = urllib.request.build_opener(NoRedirect)
34 proof = args.proof_file.read_text().strip()
35 sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "service/keycloak"))
36 from api import Keycloak
37 keycloak = Keycloak("keycloak.studio.test", importlib.import_module("dashboard-run").secret("get", "keycloak", "password"), attempts=1)
38 marker = "mcp-fixture-" + uuid.uuid4().hex
39 names = [marker + "-one", marker + "-two"]
40 identities = {}
41
42 def http(path, method="GET", body=None, status=200, actor=None, groups="infra-admin", token=None, form=False, headers=None):
43 fields = {"Host": "globe.studio.test", "Content-Type": "application/x-www-form-urlencoded" if form else "application/json"}
44 if actor is not None:
45 fields.update({"Studio-Proxy-Token": proof, "User-Name": actor, "User-Groups": groups, "Origin": origin})
46 if token is not None:
47 fields.update({"Authorization": "Bearer " + token, "Accept": "application/json, text/event-stream", "MCP-Protocol-Version": "2025-11-25"})
48 fields.update(headers or {})
49 encoded = urllib.parse.urlencode(body).encode() if form else json.dumps(body).encode() if body is not None else None
50 request = urllib.request.Request(args.url + path, method=method, data=encoded, headers=fields)
51 try:
52 response = opener.open(request, timeout=70)
53 except urllib.error.HTTPError as error:
54 response = error
55 with response:
56 content = response.read()
57 assert response.status == status, (path, response.status, content[:300])
58 if content and response.headers.get("Content-Type", "").startswith("application/json"):
59 value = json.loads(content)
60 else:
61 value = content.decode()
62 return value, response.headers
63
64 def consent(client, actor, chosen, scope="observability:read offline_access"):
65 verifier = uuid.uuid4().hex + uuid.uuid4().hex
66 challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
67 request = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0],
68 "code_challenge_method": "S256", "code_challenge": challenge, "resource": resource, "scope": scope, "state": marker}
69 _, headers = http("/oauth/authorize?" + urllib.parse.urlencode(request), status=302)
70 pending = urllib.parse.parse_qs(urllib.parse.urlsplit(headers["Location"]).query)["request"][0]
71 path = "/api/mcp/consent/" + pending
72 details, _ = http(path, actor=actor)
73 assert details["client"] == client["client_name"]
74 http(path, actor=names[1] if actor == names[0] else names[0], status=403)
75 http(path, "POST", {"resources": ["outside-grant"]}, actor=actor, status=403)
76 http(path, "POST", {"resources": chosen}, actor=actor, headers={"Origin": "https://other.invalid"}, status=403)
77 result, _ = http(path, "POST", {"resources": chosen}, actor=actor)
78 query = urllib.parse.parse_qs(urllib.parse.urlsplit(result["redirect"]).query)
79 assert query["state"] == [marker] and query["iss"] == [origin + "/"]
80 http(path, actor=actor, status=404)
81 return {"grant_type": "authorization_code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0],
82 "code_verifier": verifier, "code": query["code"][0], "resource": resource}
83
84 def rpc(token, method, params=None):
85 result, _ = http("/mcp/observability", "POST", {"jsonrpc": "2.0", "id": 1, "method": method,
86 **({"params": params} if params is not None else {})}, token=token)
87 assert "error" not in result, (method, result)
88 return result["result"]
89
90 def ingest(service, path, body, content_type="application/json"):
91 readonly = Path("/var/lib/studio/dashboard.token").read_text().strip()
92 request = urllib.request.Request("http://127.0.0.1:4646/v1/service/" + service, headers={"X-Nomad-Token": readonly})
93 with urllib.request.urlopen(request, timeout=10) as response:
94 upstream = json.load(response)[0]
95 request = urllib.request.Request(f"http://{upstream['Address']}:{upstream['Port']}" + path,
96 data=body, headers={"Content-Type": content_type})
97 with urllib.request.urlopen(request, timeout=10) as response:
98 assert response.status == 200
99
100 try:
101 role = keycloak.request("/admin/realms/master/roles/infra-admin")
102 for name in names:
103 result = keycloak.request("/admin/realms/master/users", "POST", {"username": name, "enabled": True}, full=True)
104 identities[name] = result["id"]
105 keycloak.request("/admin/realms/master/users/" + result["id"] + "/role-mappings/realm", "POST", [role])
106 metadata, _ = http("/.well-known/oauth-protected-resource/mcp/observability")
107 assert metadata["resource"] == resource and metadata["authorization_servers"] == [origin + "/"]
108 metadata, _ = http("/.well-known/oauth-authorization-server")
109 assert metadata["issuer"] == origin + "/" and metadata["code_challenge_methods_supported"] == ["S256"]
110 _, headers = http("/mcp/observability", status=401, headers={"User-Name": names[0], "User-Groups": "infra-admin"})
111 assert headers["WWW-Authenticate"].startswith("Bearer resource_metadata=")
112 http("/api/mcp", status=403, headers={"User-Name": names[0], "User-Groups": "infra-admin"})
113 overview, _ = http("/api/mcp", actor=names[0])
114 assert not overview["connections"] and overview["catalogs"][0]["endpoint"] == resource
115 readonly = Path("/var/lib/studio/dashboard.token").read_text().strip()
116 request = urllib.request.Request("http://127.0.0.1:4646/v1/jobs", headers={"X-Nomad-Token": readonly})
117 with urllib.request.urlopen(request, timeout=10) as response:
118 jobs = json.load(response)
119 aliases = {}
120 for job in jobs:
121 request = urllib.request.Request("http://127.0.0.1:4646/v1/job/" + urllib.parse.quote(job["ID"], safe=""), headers={"X-Nomad-Token": readonly})
122 with urllib.request.urlopen(request, timeout=10) as response:
123 aliases[job["ID"]] = json.load(response).get("Meta", {}).get("studio_trace_service")
124 services = [job for job, alias in aliases.items() if alias and list(aliases.values()).count(alias) == 1]
125 assert len(services) >= 2, "need two trace-enabled services for scoped native fixture"
126 allowed, denied = services[:2]
127 client, _ = http("/oauth/register", "POST", {"client_name": marker, "redirect_uris": ["http://127.0.0.1:29999/callback"],
128 "token_endpoint_auth_method": "none"}, status=201)
129 form = consent(client, names[0], [allowed])
130 wrong = {**form, "code_verifier": "wrong"}
131 http("/oauth/token", "POST", wrong, form=True, status=400)
132 tokens, _ = http("/oauth/token", "POST", form, form=True)
133 http("/oauth/token", "POST", form, form=True, status=400)
134 token = tokens["access_token"]
135 assert rpc(token, "initialize", {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": marker, "version": "1"}})["capabilities"]["tools"] == {}
136 tools = rpc(token, "tools/list")["tools"]
137 assert {tool["name"] for tool in tools} == {"get_logs", "get_traces", "get_trace"}
138 assert all(tool["annotations"]["readOnlyHint"] for tool in tools)
139 refused = rpc(token, "tools/call", {"name": "get_logs", "arguments": {"service": denied}})
140 assert refused["isError"]
141 http("/mcp/observability", "POST", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, token=token, headers={"Origin": "https://other.invalid"}, status=403)
142 at = time.time() - 60
143 row = {"_time": str(at), "_msg": marker, "source": "nomad", "job": allowed, "task": "fixture", "stream": "stdout"}
144 ingest("victoria-logs", "/insert/jsonline", (json.dumps(row) + "\n").encode(), "application/stream+json")
145 trace_id = uuid.uuid4().hex
146 spans = []
147 for service, span_id, parent in [(denied, "1234567890abcdef", ""), (allowed, "abcdef1234567890", "1234567890abcdef")]:
148 spans.append({"resource": {"attributes": [{"key": "service.name", "value": {"stringValue": aliases[service]}}]},
149 "scopeSpans": [{"spans": [{"traceId": trace_id, "spanId": span_id, "parentSpanId": parent,
150 "name": marker + ("-foreign-secret" if service == denied else "-allowed"), "kind": 1,
151 "startTimeUnixNano": str(int(at * 1e9)), "endTimeUnixNano": str(int((at + .01) * 1e9)),
152 "status": {"code": 2 if service == denied else 1},
153 "attributes": [{"key": "studio.service", "value": {"stringValue": service}}]}]}]})
154 ingest("victoria-traces", "/insert/opentelemetry/v1/traces", json.dumps({"resourceSpans": spans}).encode())
155 deadline = time.monotonic() + 30
156 while True:
157 try:
158 logs = rpc(token, "tools/call", {"name": "get_logs", "arguments": {"service": allowed, "q": marker}})
159 assert not logs.get("isError") and any(row["text"] == marker for row in logs["structuredContent"]["logs"]), logs
160 trace = rpc(token, "tools/call", {"name": "get_trace", "arguments": {"service": allowed, "trace_id": trace_id}})
161 assert not trace.get("isError") and len(trace["structuredContent"]["trace"]["spans"]) == 1, trace
162 traces = rpc(token, "tools/call", {"name": "get_traces", "arguments": {"service": allowed, "q": marker}})
163 assert not traces.get("isError") and any(row["id"] == trace_id and row["spans"] == 1 for row in traces["structuredContent"]["traces"]), traces
164 assert "foreign-secret" not in json.dumps(trace) + json.dumps(traces)
165 break
166 except (AssertionError, urllib.error.HTTPError):
167 if time.monotonic() > deadline:
168 raise
169 time.sleep(1)
170 overview, _ = http("/api/mcp", actor=names[0])
171 grant_id = overview["connections"][0]["id"]
172 other, _ = http("/api/mcp", actor=names[1])
173 assert not other["connections"]
174 http("/api/mcp/connections/" + grant_id, "DELETE", actor=names[1], status=404)
175 if args.restart_unit:
176 subprocess.run(["systemctl", "restart", args.restart_unit], check=True, capture_output=True, timeout=60)
177 rpc(token, "tools/list")
178 refresh = {"grant_type": "refresh_token", "client_id": client["client_id"], "refresh_token": tokens["refresh_token"], "resource": resource}
179 rotated, _ = http("/oauth/token", "POST", refresh, form=True)
180 assert rotated["refresh_token"] != tokens["refresh_token"]
181 http("/oauth/token", "POST", refresh, form=True, status=400)
182 http("/mcp/observability", token=token, status=401)
183 http("/mcp/observability", token=rotated["access_token"], status=401)
184 second = consent(client, names[0], [allowed], "observability:read")
185 tokens, _ = http("/oauth/token", "POST", second, form=True)
186 assert "refresh_token" not in tokens
187 overview, _ = http("/api/mcp", actor=names[0])
188 http("/api/mcp/connections/" + overview["connections"][0]["id"], "DELETE", actor=names[0], status=204)
189 http("/mcp/observability", token=tokens["access_token"], status=401)
190 for change in ["disable", "remove-role", "delete-user"]:
191 exchange = consent(client, names[0], [allowed])
192 tokens, _ = http("/oauth/token", "POST", exchange, form=True)
193 path = "/admin/realms/master/users/" + identities[names[0]]
194 if change == "disable":
195 keycloak.request(path, "PUT", {"enabled": False})
196 elif change == "remove-role":
197 keycloak.request(path + "/role-mappings/realm", "DELETE", [role])
198 else:
199 keycloak.request(path, "DELETE")
200 refresh = {"grant_type": "refresh_token", "client_id": client["client_id"], "refresh_token": tokens["refresh_token"]}
201 rejected, _ = http("/oauth/token", "POST", refresh, form=True, status=400)
202 assert rejected["error"] == "invalid_grant"
203 http("/mcp/observability", token=tokens["access_token"], status=401)
204 if change == "disable":
205 keycloak.request(path, "PUT", {"enabled": True})
206 elif change == "remove-role":
207 keycloak.request(path + "/role-mappings/realm", "POST", [role])
208 result = {"resource_metadata": True, "pkce_code_single_use": True, "shared_realm_identity": True,
209 "consent_owner_and_origin": True, "sdk_initialization_and_tools": True, "explicit_service_grants": True,
210 "native_log_retrieval": True, "native_trace_retrieval": True, "foreign_spans_and_summary_filtered": True,
211 "cross_user_connections_refused": True, "refresh_rotation_and_replay_revocation": True,
212 "dashboard_revocation_immediate": True, "refresh_requires_consent": True,
213 "refresh_honors_account_disable_role_removal_and_deletion": True,
214 "restart_preserves_tokens": bool(args.restart_unit)}
215 finally:
216 for name in names:
217 try:
218 overview, _ = http("/api/mcp", actor=name)
219 for connection in overview["connections"]:
220 http("/api/mcp/connections/" + connection["id"], "DELETE", actor=name, status=204)
221 except (AssertionError, urllib.error.URLError):
222 pass
223 for name in names:
224 for identity in keycloak.request("/admin/realms/master/users?username=" + name + "&exact=true"):
225 keycloak.request("/admin/realms/master/users/" + identity["id"], "DELETE")
226 assert not keycloak.request("/admin/realms/master/users?username=" + name + "&exact=true")
227 result["owned_users_removed"] = True
228 if args.output:
229 args.output.parent.mkdir(parents=True, exist_ok=True)
230 args.output.write_text(json.dumps(result, indent=2) + "\n")
231 print(json.dumps(result))
232
233
234if __name__ == "__main__":
235 main()
tools/dashboard-metrics-test.py created+110
......@@ -0,0 +1,110 @@
1#!/usr/bin/env python3
2"""Check live metric cache continuity when its upstream stops answering."""
3import argparse
4from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5import json
6from pathlib import Path
7import shlex
8import subprocess
9import tempfile
10import threading
11import time
12import urllib.error
13import urllib.request
14
15parser = argparse.ArgumentParser(description=__doc__)
16parser.add_argument("binary")
17args = parser.parse_args()
18failed = False
19reads = 0
20
21class Metrics(BaseHTTPRequestHandler):
22 def log_message(self, *args):
23 pass
24
25 def do_GET(self):
26 global reads
27 reads += 1
28 self.send_response(503 if failed else 200)
29 self.send_header("Content-Type", "application/json")
30 self.end_headers()
31 self.wfile.write(json.dumps({"status": "success", "data": {"result": [
32 {"metric": {}, "values": [[time.time(), "12.5"]]}
33 ]}}).encode())
34
35metrics = ThreadingHTTPServer(("127.0.0.1", 0), Metrics)
36
37class Nomad(BaseHTTPRequestHandler):
38 def log_message(self, *args):
39 pass
40
41 def do_GET(self):
42 self.send_response(200)
43 self.send_header("Content-Type", "application/json")
44 self.end_headers()
45 self.wfile.write(json.dumps([
46 {"Address": "127.0.0.1", "Port": metrics.server_port}
47 ] if self.path == "/v1/service/victoria-metrics" else []).encode())
48
49nomad = ThreadingHTTPServer(("127.0.0.1", 0), Nomad)
50for server in [metrics, nomad]:
51 threading.Thread(target=server.serve_forever, daemon=True).start()
52
53unit = "studio-dashboard-metrics-test"
54with tempfile.TemporaryDirectory(prefix="studio-metrics-test-") as temporary:
55 environment = subprocess.check_output(["systemctl", "show", "-P", "Environment", "studio-dashboard"], text=True)
56 variables = dict(item.split("=", 1) for item in shlex.split(environment))
57 for key in ["STUDIO_INDEX_POOL", "STUDIO_YT_STATE", "STUDIO_FILES_WRITABLE"]:
58 variables.pop(key, None)
59 variables.update(PORT="7074", STUDIO_METRICS="follow", STUDIO_DATA_DIR=temporary,
60 NOMAD_ADDR=f"http://127.0.0.1:{nomad.server_port}")
61 proof = {"Studio-Proxy-Token": Path(variables["STUDIO_PROXY_TOKEN_FILE"]).read_text().strip()} if "STUDIO_PROXY_TOKEN_FILE" in variables else {}
62 def request(range=3600):
63 started = time.monotonic()
64 req = urllib.request.Request(f"http://127.0.0.1:7074/api/metrics/host.cpu?range={range}",
65 headers={"User-Name": "fixture", "User-Groups": "infra-admin", **proof})
66 with urllib.request.urlopen(req, timeout=10) as response:
67 return json.load(response), time.monotonic() - started
68
69 try:
70 subprocess.run(["systemd-run", "--unit=" + unit, "--collect", "--property=CPUWeight=1000",
71 *["--setenv=" + k + "=" + v for k, v in variables.items()],
72 str(Path(args.binary).resolve())], check=True, capture_output=True)
73 for _ in range(100):
74 try:
75 baseline, _ = request()
76 break
77 except OSError:
78 time.sleep(.1)
79 else:
80 raise AssertionError("fixture did not start")
81 assert baseline[0]["v"] == [12.5], baseline
82 failed = True
83 time.sleep(2.2)
84 cached, elapsed = request()
85 assert cached == baseline, (cached, baseline)
86 assert elapsed < 2, elapsed
87 time.sleep(2.2)
88 cached, elapsed = request()
89 assert cached == baseline, (cached, baseline)
90 try:
91 request(7200)
92 except urllib.error.HTTPError as error:
93 assert error.code == 502, error.code
94 else:
95 raise AssertionError("a different range reused the cached series")
96 failed = False
97 for _ in range(30):
98 cached, _ = request()
99 if cached != baseline:
100 break
101 time.sleep(.2)
102 else:
103 raise AssertionError("the live series did not refresh after recovery")
104 print(json.dumps({"stale_live_series": "passed", "range_isolation": "passed",
105 "recovery_refresh": "passed", "last_cached_request_ms": round(elapsed * 1000, 1),
106 "upstream_reads": reads}))
107 finally:
108 subprocess.run(["systemctl", "stop", unit], capture_output=True)
109 nomad.shutdown()
110 metrics.shutdown()
tools/dashboard-package-test.py created+202
......@@ -0,0 +1,202 @@
1#!/usr/bin/env python3
2import argparse
3from http.client import HTTPConnection
4from http.server import BaseHTTPRequestHandler, HTTPServer
5import json
6import os
7from pathlib import Path
8import subprocess
9import socket
10import tempfile
11import threading
12import time
13import urllib.error
14import urllib.request
15import uuid
16
17import router
18
19
20def main():
21 parser = argparse.ArgumentParser()
22 parser.add_argument("--image", required=True)
23 parser.add_argument("--output", type=Path)
24 args = parser.parse_args()
25 container = "studio-dashboard-package-test-" + uuid.uuid4().hex[:12]
26 proof = uuid.uuid4().hex + uuid.uuid4().hex
27
28 def shell(*argv):
29 return subprocess.run(argv, capture_output=True, text=True, check=True, timeout=60).stdout
30
31 with tempfile.TemporaryDirectory(prefix="studio-dashboard-package-", dir="/run") as temporary:
32 data = Path(temporary) / "data"
33 data.mkdir()
34 os.chown(data, 65534, 65534)
35 token = Path(temporary) / "proxy.token"
36 token.write_text(proof)
37 os.chown(token, 0, 65534)
38 token.chmod(0o440)
39 season = data / "media/jellyfin/Indie Shows/Fixture/Season 1"
40 season.mkdir(parents=True)
41 (season / "S01E01 - Fixture.mp4").write_bytes(b"fixture")
42 (season / "S01E01 - Fixture.nfo").write_text("<episodedetails><title>Packaged worker</title></episodedetails>")
43 try:
44 shell("podman", "run", "--detach", "--name", container, "--read-only", "--cap-drop=ALL",
45 "--security-opt=no-new-privileges", "--memory=512m", "--cpus=2", "--pids-limit=128",
46 "--publish=127.0.0.1::7072", "--volume=" + str(data) + ":/data:rw",
47 "--volume=" + str(token) + ":/run/secrets/dashboard-proxy.token:ro",
48 "--tmpfs=/tmp:rw,noexec,nosuid,size=64m", "--env=STUDIO_DATA_DIR=/data",
49 "--env=STUDIO_DOMAIN=studio.test", "--env=STUDIO_MEDIA_READ_ONLY=true",
50 "--env=STUDIO_YT_STATE=/data/yt", "--env=STUDIO_YT_CONFIG=/data/config",
51 "--env=STUDIO_YT_MEDIA=/data/media", args.image)
52 info = json.loads(shell("podman", "inspect", container))[0]
53 port = info["NetworkSettings"]["Ports"]["7072/tcp"][0]["HostPort"]
54 base = "http://127.0.0.1:" + port
55
56 def get(path, supplied=proof):
57 headers = {
58 "User-Name": "fixture", "User-Groups": "infra-admin,media,media-manage",
59 }
60 if supplied is not None:
61 headers["Studio-Proxy-Token"] = supplied
62 request = urllib.request.Request(base + path, headers=headers)
63 with urllib.request.urlopen(request, timeout=30) as response:
64 return response.read()
65
66 deadline = time.monotonic() + 40
67 while True:
68 try:
69 get("/")
70 break
71 except OSError:
72 if time.monotonic() >= deadline:
73 raise
74 time.sleep(.1)
75 for path in ["/", "/api/me", "/api/launcher", "/assets/probe"]:
76 for supplied in [None, "0" * 64, proof[:-1], proof + "0"]:
77 try:
78 get(path, supplied)
79 raise AssertionError("forged ingress reached " + path)
80 except urllib.error.HTTPError as error:
81 assert error.code == 403, (path, error.code)
82 assert json.loads(get("/api/me"))["name"] == "fixture"
83 python = next(value.split("=", 1)[1] for value in info["Config"]["Env"] if value.startswith("STUDIO_YT_PYTHON="))
84 address = next(network["IPAddress"] for network in info["NetworkSettings"]["Networks"].values() if network["IPAddress"])
85 neighbor = f"""import urllib.request, urllib.error
86request = urllib.request.Request('http://{address}:7072/api/me', headers={{
87 'User-Name': 'snow', 'User-Groups': 'infra-admin', 'Studio-Proxy-Token': '0' * 64,
88}})
89try:
90 urllib.request.urlopen(request, timeout=5)
91 raise AssertionError('forged identity accepted')
92except urllib.error.HTTPError as error:
93 assert error.code == 403, error.code
94print('refused')
95"""
96 assert shell("podman", "run", "--rm", "--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges",
97 "--entrypoint=" + python, args.image, "-c", neighbor).strip() == "refused"
98 unsecured = subprocess.run(["podman", "run", "--rm", "--read-only", "--network=none", "--cap-drop=ALL",
99 "--security-opt=no-new-privileges", "--unsetenv=STUDIO_PROXY_TOKEN_FILE", args.image],
100 capture_output=True, text=True, timeout=10)
101 assert unsecured.returncode != 0 and "Set STUDIO_PROXY_TOKEN_FILE" in unsecured.stderr, unsecured.stderr
102
103 class Auth(BaseHTTPRequestHandler):
104 def do_GET(self):
105 self.send_response(200 if self.headers.get("Cookie") == "fixture=1" else 401)
106 self.send_header("X-Auth-Request-Preferred-Username", "fixture")
107 self.send_header("X-Auth-Request-Groups", "role:media")
108 self.end_headers()
109
110 def log_message(self, *args):
111 pass
112
113 with HTTPServer(("127.0.0.1", 0), Auth) as auth:
114 threading.Thread(target=auth.serve_forever, daemon=True).start()
115 router.nomad = lambda path, _: {
116 "/v1/services": [{"Namespace": "default", "Services": [{"ServiceName": "forward-auth"}]}],
117 "/v1/service/forward-auth": [{"ServiceName": "forward-auth", "AllocID": "fixture", "Address": "127.0.0.1", "Port": auth.server_port, "Tags": []}],
118 "/v1/allocation/fixture/checks": {"ready": {"Status": "success"}},
119 }[path]
120 os.environ.update(STUDIO_DOMAIN="studio.test", STUDIO_DASHBOARD_PORT=port, STUDIO_PROXY_TOKEN_FILE=str(token))
121 with socket.socket() as reservation:
122 reservation.bind(("127.0.0.1", 0))
123 proxy_port = reservation.getsockname()[1]
124 config = Path(temporary) / "Caddyfile"
125 config.write_text("{\n admin off\n auto_https off\n}\n" + router.render("fixture").replace(
126 "globe.studio.test {", "http://127.0.0.1:" + str(proxy_port) + " {"
127 ).replace(" tls internal\n", ""))
128 config.chmod(0o600)
129 log = Path(temporary) / "caddy.log"
130 caddy_binary = (Path("/proc") / shell("systemctl", "show", "-P", "MainPID", "caddy").strip() / "exe").resolve(strict=True)
131 with log.open("w") as output:
132 caddy = subprocess.Popen([str(caddy_binary), "run", "--config", str(config), "--adapter", "caddyfile"],
133 stdout=output, stderr=output, env={**os.environ, "XDG_DATA_HOME": temporary, "XDG_CONFIG_HOME": temporary})
134 try:
135 deadline = time.monotonic() + 10
136 while True:
137 try:
138 with socket.create_connection(("127.0.0.1", proxy_port), timeout=.2):
139 break
140 except OSError:
141 if time.monotonic() >= deadline or caddy.poll() is not None:
142 raise AssertionError(log.read_text().replace(proof, "<redacted>"))
143 time.sleep(.1)
144 forged = {"User-Name": "snow", "User-Groups": "infra-admin", "Studio-Proxy-Token": "0" * 64}
145 request = urllib.request.Request("http://127.0.0.1:" + str(proxy_port) + "/api/me",
146 headers={**forged, "Cookie": "fixture=1"})
147 with urllib.request.urlopen(request, timeout=10) as response:
148 me = json.load(response)
149 assert me["name"] == "fixture" and "infra-admin" not in me["groups"], me
150 request.full_url = "http://127.0.0.1:" + str(proxy_port) + "/api/deploys"
151 try:
152 urllib.request.urlopen(request, timeout=10)
153 raise AssertionError("forged admin role survived Caddy")
154 except urllib.error.HTTPError as error:
155 assert error.code == 403, error.code
156 connection = HTTPConnection("127.0.0.1", proxy_port, timeout=10)
157 try:
158 connection.request("GET", "/api/me", headers=forged)
159 response = connection.getresponse()
160 assert response.status == 302 and response.getheader("Location").startswith("/snow.oauth2/sign_in?"), response.status
161 response.read()
162 finally:
163 connection.close()
164 finally:
165 caddy.terminate()
166 try:
167 caddy.wait(timeout=10)
168 except subprocess.TimeoutExpired:
169 caddy.kill()
170 caddy.wait()
171 auth.shutdown()
172 apps = json.loads(get("/api/launcher"))
173 assert any(app["id"] == "shale" for app in apps), apps
174 assert all(app["url"].endswith(".studio.test") for app in apps), apps
175 icon = next(url for app in apps for url in (app["icon"] or {}).values() if url)
176 assert b"<svg" in get(icon)
177 library = json.loads(get("/api/youtube/library"))
178 assert len(library) == 1 and library[0]["title"] == "Packaged worker" and library[0]["episode"] == 1, library
179 assert "ffmpeg version" in shell("podman", "exec", container, "ffmpeg", "-version")
180 assert shell("podman", "exec", container, "yt-dlp", "--version").strip()
181 yaml = shell("podman", "exec", container, "python3", "-c", "import yaml; print(yaml.safe_load('ready: true')['ready'])")
182 assert yaml.strip() == "True", yaml
183 assert {mount["Destination"] for mount in info["Mounts"]} == {"/data", "/run/secrets/dashboard-proxy.token"}, info["Mounts"]
184 assert not next(mount["RW"] for mount in info["Mounts"] if mount["Destination"] == "/run/secrets/dashboard-proxy.token")
185 assert shell("podman", "exec", container, "id", "-u").strip() == "65534"
186 for denied in ["/opt/studio/current", "/var/lib/studio/nomad.token", "/run/podman/podman.sock", "/dev/zfs"]:
187 assert subprocess.run(["podman", "exec", container, "test", "-e", denied], capture_output=True).returncode == 1, denied
188 result = {"proxy_proof_required": "passed", "neighbor_identity_forgery_refused": "passed",
189 "wildcard_startup_without_proof_refused": "passed",
190 "caddy_identity_scrubbing_and_proof_injection": "passed", "anonymous_forgery_redirected_to_signin": "passed",
191 "launcher_from_immutable_image": "passed", "service_icons": "passed",
192 "unprivileged_youtube_library": "passed", "python_yaml": "passed",
193 "ffmpeg_and_ytdlp": "passed", "host_release_and_control_paths_absent": "passed"}
194 if args.output:
195 args.output.write_text(json.dumps(result, indent=2) + "\n")
196 print(json.dumps(result))
197 finally:
198 subprocess.run(["podman", "rm", "--force", container], capture_output=True)
199
200
201if __name__ == "__main__":
202 main()
tools/dashboard-public-load-test.py created+141
......@@ -0,0 +1,141 @@
1#!/usr/bin/env python3
2import argparse
3from concurrent.futures import ThreadPoolExecutor
4import http.cookiejar
5import importlib
6import json
7from pathlib import Path
8import ssl
9import subprocess
10import sys
11import time
12import urllib.parse
13import urllib.request
14import uuid
15
16
17def main():
18 parser = argparse.ArgumentParser()
19 parser.add_argument('--clients', type=int, default=24)
20 parser.add_argument('--requests', type=int, default=1200)
21 parser.add_argument('--idle-seconds', type=int, default=0)
22 parser.add_argument('--output', type=Path, required=True)
23 args = parser.parse_args()
24 assert 1 <= args.clients <= 64 and 12 <= args.requests <= 50000
25 assert 0 <= args.idle_seconds <= 600
26 for unit in ['studio-dashboard-unit-test', 'studio-host-unit-test']:
27 subprocess.run(['systemctl', 'is-active', '--quiet', unit], check=True)
28 repo = Path(__file__).resolve().parent.parent
29 sys.path.insert(0, str(repo / 'service/keycloak'))
30 from api import Keycloak, LoopbackHTTPS
31 Page = importlib.import_module('dashboard-shale-test').Page
32 secret = importlib.import_module('dashboard-run').secret
33 origin = 'https://globe.studio.test'
34 context = ssl.create_default_context(cafile='/var/lib/studio/ca-bundle.crt')
35
36 class TLS(urllib.request.HTTPSHandler):
37 def https_open(self, request):
38 target = urllib.parse.urlsplit(request.full_url)
39 assert target.scheme == 'https' and target.netloc in ('globe.studio.test', 'keycloak.studio.test')
40 return self.do_open(LoopbackHTTPS, request, context=context)
41
42 class Redirect(urllib.request.HTTPRedirectHandler):
43 def redirect_request(self, request, response, code, message, headers, target):
44 parts = urllib.parse.urlsplit(target)
45 assert parts.scheme == 'https' and parts.netloc in ('globe.studio.test', 'keycloak.studio.test')
46 return super().redirect_request(request, response, code, message, headers, target)
47
48 class NoRedirect(urllib.request.HTTPRedirectHandler):
49 def redirect_request(self, *args):
50 return None
51
52 keycloak = Keycloak('keycloak.studio.test', secret('get', 'keycloak', 'password'), attempts=1)
53 name = 'dashboard-load-' + uuid.uuid4().hex
54 password = uuid.uuid4().hex + 'A1!'
55 user = None
56 try:
57 role = keycloak.request('/admin/realms/master/roles/infra-admin')
58 user = keycloak.request('/admin/realms/master/users', 'POST', {
59 'username': name, 'enabled': True, 'requiredActions': [],
60 'credentials': [{'type': 'password', 'value': password, 'temporary': False}],
61 }, full=True)['id']
62 keycloak.request('/admin/realms/master/users/' + user + '/role-mappings/realm', 'POST', [role])
63 cookies = http.cookiejar.CookieJar()
64 login = urllib.request.build_opener(TLS(), Redirect(), urllib.request.HTTPCookieProcessor(cookies))
65 with login.open(origin + '/snow.oauth2/start?' + urllib.parse.urlencode({'rd': origin + '/'}), timeout=30) as response:
66 target = response.url
67 body = response.read(4 * 1024 * 1024 + 1).decode()
68 assert urllib.parse.urlsplit(target).netloc == 'keycloak.studio.test'
69 forms = [form for form in Page(body).forms if any(field.get('name') == 'password' for field in form['fields'])]
70 assert len(forms) == 1
71 action = urllib.parse.urljoin(target, forms[0]['action'])
72 action_url = urllib.parse.urlsplit(action)
73 assert action_url.scheme == 'https' and action_url.netloc == 'keycloak.studio.test'
74 fields = {field['name']: field.get('value', '') for field in forms[0]['fields'] if field.get('name')}
75 fields.update(username=name, password=password)
76 with login.open(urllib.request.Request(action, data=urllib.parse.urlencode(fields).encode(),
77 headers={'Content-Type': 'application/x-www-form-urlencoded', 'Origin': 'https://keycloak.studio.test'}), timeout=30) as response:
78 assert response.status == 200 and response.url == origin + '/'
79 response.read(4 * 1024 * 1024 + 1)
80 cookie_request = urllib.request.Request(origin + '/api/me')
81 cookies.add_cookie_header(cookie_request)
82 cookie = cookie_request.get_header('Cookie')
83 assert cookie
84 paths = ['/api/me', '/api/host', '/api/services', '/api/launcher', '/api/status', '/api/storage',
85 '/api/mcp', '/api/media/list', *['/api/metrics/' + metric + '?range=3600'
86 for metric in ['host.cpu', 'host.memory', 'service.cpu', 'service.memory']]]
87
88 def request(path):
89 start = time.monotonic()
90 client = urllib.request.build_opener(TLS(), NoRedirect())
91 with client.open(urllib.request.Request(origin + path, headers={'Cookie': cookie}), timeout=30) as response:
92 assert response.status == 200 and response.headers.get_content_type() == 'application/json'
93 body = response.read(8 * 1024 * 1024 + 1)
94 assert len(body) <= 8 * 1024 * 1024
95 value = json.loads(body)
96 if path == '/api/me':
97 assert value['name'] == name
98 if path == '/api/mcp':
99 assert {catalog['endpoint'] for catalog in value['catalogs']} == {origin + '/mcp/' + catalog
100 for catalog in ['observability', 'agents', 'shale']}
101 return path, (time.monotonic() - start) * 1000
102
103 for path in paths:
104 request(path)
105 start = time.monotonic()
106 with ThreadPoolExecutor(max_workers=args.clients) as clients:
107 results = list(clients.map(request, [paths[i % len(paths)] for i in range(args.requests)]))
108 report = {'public_https_and_real_sso': True, 'clients': args.clients, 'requests': len(results),
109 'seconds': round(time.monotonic() - start, 2), 'routes': {}}
110 for path in paths:
111 samples = sorted(ms for route, ms in results if route == path)
112 report['routes'][path] = {'requests': len(samples), 'p95_ms': round(samples[int(len(samples) * .95)], 2),
113 'max_ms': round(samples[-1], 2)}
114 print(json.dumps({'load_complete': report}), flush=True)
115 if args.idle_seconds:
116 units = ['studio-dashboard-unit-test.service', 'studio-host-unit-test.service']
117 before = {}
118 for unit in units:
119 stat = Path('/sys/fs/cgroup/system.slice') / unit / 'cpu.stat'
120 before[unit] = int(dict(line.split() for line in stat.read_text().splitlines())['usage_usec'])
121 start = time.monotonic()
122 time.sleep(args.idle_seconds)
123 elapsed = time.monotonic() - start
124 idle = {'seconds': round(elapsed, 2), 'percent_of_one_core': {}}
125 for unit in units:
126 stat = Path('/sys/fs/cgroup/system.slice') / unit / 'cpu.stat'
127 used = int(dict(line.split() for line in stat.read_text().splitlines())['usage_usec']) - before[unit]
128 idle['percent_of_one_core'][unit] = round(used / elapsed / 10000, 3)
129 request('/api/me')
130 report['idle'] = idle
131 finally:
132 if user:
133 keycloak = Keycloak('keycloak.studio.test', secret('get', 'keycloak', 'password'), attempts=1)
134 keycloak.request('/admin/realms/master/users/' + user, 'DELETE')
135 report['owned_sso_fixture_removed'] = True
136 args.output.write_text(json.dumps(report, indent=2) + '\n')
137 print(json.dumps(report), flush=True)
138
139
140if __name__ == '__main__':
141 main()
tools/dashboard-relay-test.py created+452
......@@ -0,0 +1,452 @@
1#!/usr/bin/env python3
2import argparse
3import base64
4from concurrent.futures import ThreadPoolExecutor
5import hashlib
6import importlib
7import json
8import os
9from pathlib import Path
10import queue
11import re
12import tempfile
13import socket
14import sqlite3
15import struct
16import subprocess
17import sys
18import threading
19import time
20import urllib.error
21import urllib.parse
22import urllib.request
23import uuid
24
25
26class NoRedirect(urllib.request.HTTPRedirectHandler):
27 def redirect_request(self, request, fp, code, message, headers, newurl):
28 return None
29
30
31class Agent:
32 def __init__(self, address, token, status=101, origin=None, host="globe.studio.test"):
33 target = urllib.parse.urlsplit(address)
34 self.socket = socket.create_connection((target.hostname, target.port), timeout=5)
35 self.socket.settimeout(45)
36 key = base64.b64encode(os.urandom(16)).decode()
37 fields = {"Host": host, "Upgrade": "websocket", "Connection": "Upgrade", "Sec-WebSocket-Version": "13",
38 "Sec-WebSocket-Key": key, "Authorization": "Bearer " + token}
39 if origin is not None:
40 fields["Origin"] = origin
41 self.socket.sendall(("GET /agent/connect HTTP/1.1\r\n" + "".join(k + ": " + v + "\r\n" for k, v in fields.items()) + "\r\n").encode())
42 self.stream = self.socket.makefile("rb", buffering=0)
43 response = self.stream.readline().decode().split()
44 assert int(response[1]) == status, response
45 headers = {}
46 while line := self.stream.readline().strip():
47 k, v = line.decode().split(":", 1)
48 headers[k.lower()] = v.strip()
49 if status != 101:
50 self.close()
51 return
52 expected = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
53 assert headers["sec-websocket-accept"] == expected
54 self.lock = threading.Lock()
55 self.frames = queue.Queue()
56 self.mode = "reply"
57 self.pings = 0
58 self.closed = threading.Event()
59 self.machine = self.receive()["machine_id"]
60 self.thread = threading.Thread(target=self.run, daemon=True)
61 self.thread.start()
62
63 def read(self, size):
64 result = b""
65 while len(result) < size:
66 part = self.stream.read(size - len(result))
67 if not part:
68 raise EOFError()
69 result += part
70 return result
71
72 def receive(self):
73 while True:
74 first, size = self.read(2)
75 assert first & 128 and not size & 128
76 size &= 127
77 if size == 126:
78 size = struct.unpack("!H", self.read(2))[0]
79 elif size == 127:
80 size = struct.unpack("!Q", self.read(8))[0]
81 assert size <= 4 * 1024 * 1024
82 data = self.read(size)
83 opcode = first & 15
84 if opcode == 9:
85 self.pings += 1
86 self.send(data, opcode=10)
87 elif opcode == 8:
88 raise EOFError()
89 else:
90 assert opcode == 1
91 return json.loads(data)
92
93 def send(self, value, opcode=1):
94 data = value if isinstance(value, bytes) else json.dumps(value).encode()
95 prefix = bytes([128 | opcode])
96 length = len(data)
97 if length < 126:
98 prefix += bytes([128 | length])
99 elif length < 65536:
100 prefix += bytes([128 | 126]) + struct.pack("!H", length)
101 else:
102 prefix += bytes([128 | 127]) + struct.pack("!Q", length)
103 mask = os.urandom(4)
104 packet = prefix + mask + bytes(byte ^ mask[i % 4] for i, byte in enumerate(data))
105 with self.lock:
106 self.socket.sendall(packet)
107
108 def run(self):
109 try:
110 while True:
111 frame = self.receive()
112 assert set(frame) == {"id", "method", "params"}
113 uuid.UUID(frame["id"])
114 self.frames.put(frame)
115 if self.mode == "reply":
116 self.send({"id": frame["id"], "result": {"fixture": self.machine, "method": frame["method"], "params": frame["params"]}})
117 elif self.mode == "drop":
118 self.socket.shutdown(socket.SHUT_RDWR)
119 break
120 except (EOFError, OSError):
121 pass
122 finally:
123 self.closed.set()
124
125 def close(self):
126 try:
127 self.socket.shutdown(socket.SHUT_RDWR)
128 except OSError:
129 pass
130 self.stream.close()
131 self.socket.close()
132 if hasattr(self, "thread"):
133 self.thread.join(timeout=5)
134
135
136def main():
137 parser = argparse.ArgumentParser()
138 parser.add_argument("--url", required=True)
139 parser.add_argument("--proof-file", type=Path, required=True)
140 parser.add_argument("--data-dir", type=Path, required=True)
141 parser.add_argument("--restart-unit", required=True)
142 parser.add_argument("--output", type=Path)
143 parser.add_argument("--agent-dir", type=Path)
144 parser.add_argument("--agent-origin")
145 parser.add_argument("--agent-ca", type=Path)
146 args = parser.parse_args()
147 if args.output:
148 args.output.unlink(missing_ok=True)
149 origin = "https://globe.studio.test"
150 resource = origin + "/mcp/agents"
151 proof = args.proof_file.read_text().strip()
152 opener = urllib.request.build_opener(NoRedirect)
153 marker = "relay-fixture-" + uuid.uuid4().hex
154 names = [marker + "-one", marker + "-two"]
155 sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "service/keycloak"))
156 from api import Keycloak
157 keycloak = Keycloak("keycloak.studio.test", importlib.import_module("dashboard-run").secret("get", "keycloak", "password"), attempts=1)
158 agents = []
159 credentials = []
160
161 def http(path, method="GET", body=None, actor=None, token=None, status=200, form=False, headers=None):
162 fields = {"Host": "globe.studio.test", "Content-Type": "application/x-www-form-urlencoded" if form else "application/json"}
163 if actor:
164 fields.update({"Studio-Proxy-Token": proof, "User-Name": actor, "User-Groups": "", "Origin": origin})
165 if token:
166 fields.update({"Authorization": "Bearer " + token, "Accept": "application/json, text/event-stream", "MCP-Protocol-Version": "2025-11-25"})
167 fields.update(headers or {})
168 data = urllib.parse.urlencode(body).encode() if form else json.dumps(body).encode() if body is not None else None
169 try:
170 response = opener.open(urllib.request.Request(args.url + path, method=method, headers=fields, data=data), timeout=65)
171 except urllib.error.HTTPError as error:
172 response = error
173 with response:
174 content = response.read()
175 assert response.status == status, (path, response.status, content[:300])
176 value = json.loads(content) if content and response.headers.get("Content-Type", "").startswith("application/json") else content.decode()
177 return value, response.headers
178
179 def pair(actor, label):
180 pending, _ = http("/pairing", "POST", {"name": label, "platform": "fixture"}, status=201)
181 credentials.append(pending["token"])
182 http("/pairing", token=pending["token"], status=202)
183 machine, _ = http("/api/mcp/relay/pair", "POST", {"code": pending["code"]}, actor=actor)
184 http("/api/mcp/relay/pair", "POST", {"code": pending["code"]}, actor=actor, status=410)
185 assert "tokenHash" not in machine and "token" not in machine
186 result, _ = http("/pairing", token=pending["token"])
187 assert result["machine_id"] == machine["id"]
188 return machine, pending["token"]
189
190 def key(actor, machines, write=False):
191 result, _ = http("/api/mcp/relay/keys", "POST", {"name": marker, "resources": machines, "write": write}, actor=actor)
192 credentials.append(result["key"])
193 return result
194
195 def rpc(token, method, params=None, error=False):
196 response, _ = http("/mcp/agents", "POST", {"jsonrpc": "2.0", "id": 1, "method": method, **({"params": params} if params is not None else {})}, token=token)
197 assert "error" not in response, response
198 result = response["result"]
199 if method == "tools/call":
200 assert bool(result.get("isError")) == error, result
201 return result
202
203 def call(token, name, fields=None, error=False):
204 return rpc(token, "tools/call", {"name": name, "arguments": fields or {}}, error=error)
205
206 def command(token, machine, method="list_threads", params=None, status=200):
207 return http("/api/v1/machines/" + machine + "/commands", "POST", {"method": method, "params": params or {}}, token=token, status=status)[0]
208
209 def connect(token):
210 agent = Agent(args.url, token)
211 agents.append(agent)
212 return agent
213
214 try:
215 for name in names:
216 keycloak.request("/admin/realms/master/users", "POST", {"username": name, "enabled": True})
217 first, token1 = pair(names[0], "First fixture")
218 second, token2 = pair(names[0], "Second fixture")
219 foreign, foreign_token = pair(names[1], "Foreign fixture")
220 http("/api/mcp/relay/machines/" + first["id"], "DELETE", actor=names[1], status=404)
221 http("/api/mcp/relay/machines/" + first["id"], "PATCH", {"name": "Renamed fixture"}, actor=names[0])
222 http("/api/mcp/relay/pair", "POST", {"code": "random"}, actor=names[0], status=403, headers={"Origin": "https://other.invalid"})
223 http("/api/mcp/relay/keys", "POST", {"name": marker, "resources": [foreign["id"]]}, actor=names[0], status=403)
224 http("/pairing", "POST", {}, status=421, headers={"Host": "other.invalid"})
225 read = key(names[0], [first["id"], second["id"]])
226 control = key(names[0], [first["id"], second["id"]], write=True)
227 command(control["key"], foreign["id"], status=403)
228 command(read["key"], first["id"], "start_thread", {"provider": "codex", "cwd": "/owned", "message": "fixture"}, status=403)
229 http("/api/v1/machines", status=401, headers={"User-Name": names[0], "User-Groups": "infra-admin", "Studio-Proxy-Token": proof})
230 http("/api/v1/machines", token=read["key"], status=403, headers={"Origin": "https://other.invalid"})
231 Agent(args.url, token1, status=401, origin=origin)
232 Agent(args.url, token1, status=421, host="other.invalid")
233 live = opener.open(urllib.request.Request(args.url + "/api/mcp/relay/live", headers={"Host": "globe.studio.test", "Studio-Proxy-Token": proof, "User-Name": names[0], "User-Groups": ""}), timeout=10)
234 def snapshot():
235 while line := live.readline():
236 if line.startswith(b"data:"):
237 value = json.loads(line.split(b":", 1)[1])
238 assert {m["id"] for m in value} == {first["id"], second["id"]}
239 return value
240 raise AssertionError("Machine stream closed")
241 try:
242 assert not any(m["online"] for m in snapshot())
243 a1 = connect(token1)
244 assert sum(m["online"] for m in snapshot()) == 1
245 a2 = connect(token2)
246 assert all(m["online"] for m in snapshot())
247 http("/api/mcp/relay/machines/" + first["id"], "PATCH", {"name": "Stream renamed fixture"}, actor=names[0])
248 assert next(m for m in snapshot() if m["id"] == first["id"])["name"] == "Stream renamed fixture"
249 finally:
250 live.close()
251 Agent(args.url, token1, status=409)
252 assert a1.machine == first["id"] and a2.machine == second["id"]
253 init = rpc(control["key"], "initialize", {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": marker, "version": "1"}})
254 assert init["capabilities"]["tools"] == {}
255 tools = rpc(control["key"], "tools/list")["tools"]
256 assert len(tools) == 7
257 assert next(t for t in tools if t["name"] == "send_message")["annotations"]["readOnlyHint"] is False
258 call(control["key"], "send_message", {"provider": "codex", "thread_id": str(uuid.uuid4()), "message": "ambiguous fixture"}, error=True)
259 assert a1.frames.empty() and a2.frames.empty()
260 results = call(read["key"], "list_threads")["structuredContent"]["results"]
261 assert len(results) == 2 and all(r["result"]["params"] == {"limit": 30} for r in results)
262 a1.frames.get(timeout=2); a2.frames.get(timeout=2)
263 call(control["key"], "set_target_machines", {"machine_ids": [foreign["id"]]}, error=True)
264 call(control["key"], "set_target_machines", {"machine_ids": [first["id"]]})
265 unchanged, _ = http("/api/v1/machines", token=read["key"])
266 assert len(unchanged) == 2 and all(m["selected"] for m in unchanged)
267 write_params = {"provider": "claude", "thread_id": str(uuid.uuid4()), "message": "owned fixture", "expected_turn_id": str(uuid.uuid4())}
268 call(control["key"], "send_message", write_params)
269 assert a1.frames.get(timeout=2)["params"] == write_params and a2.frames.empty()
270 command(control["key"], first["id"], params={"limit": 101}, status=400)
271 assert a1.frames.empty()
272 call(control["key"], "send_message", {"provider": "codex", "thread_id": str(uuid.uuid4()), "message": "雪" * 100000}, error=True)
273 assert a1.frames.empty()
274 a1.mode = "hold"
275 with ThreadPoolExecutor(max_workers=9) as pool:
276 pending = [pool.submit(command, control["key"], first["id"]) for _ in range(8)]
277 frames = [a1.frames.get(timeout=10) for _ in range(8)]
278 command(control["key"], first["id"], status=429)
279 for frame in frames:
280 a1.send({"id": frame["id"], "result": {"bounded": True}})
281 assert all(future.result(timeout=10)["result"] == {"bounded": True} for future in pending)
282 started = time.monotonic()
283 outcome = command(control["key"], first["id"], status=409)
284 elapsed = time.monotonic() - started
285 frame = a1.frames.get(timeout=2)
286 assert "unknown" in outcome["error"] and 29 <= elapsed < 40 and a1.pings >= 1
287 a1.send({"id": frame["id"], "result": {"late": True}})
288 a1.mode = "reply"
289 assert command(control["key"], first["id"])["result"]["params"] == {"limit": 30}
290 a1.frames.get(timeout=2)
291 a1.mode = "drop"
292 started = time.monotonic()
293 outcome = command(control["key"], first["id"], "send_message", write_params, status=409)
294 assert "unknown" in outcome["error"] and time.monotonic() - started < 5
295 a1.frames.get(timeout=2)
296 a1.close()
297 a1 = connect(token1)
298 time.sleep(.2)
299 assert a1.frames.empty()
300 assert command(control["key"], first["id"])["result"]["method"] == "list_threads"
301 a1.frames.get(timeout=2)
302 metadata, _ = http("/.well-known/oauth-protected-resource/mcp/agents")
303 assert metadata["resource"] == resource and "sessions:write" in metadata["scopes_supported"]
304 client, _ = http("/oauth/register", "POST", {"client_name": marker, "redirect_uris": ["http://127.0.0.1:20001/callback"]}, status=201)
305 verifier = uuid.uuid4().hex + uuid.uuid4().hex
306 fields = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], "resource": resource, "scope": "sessions:read sessions:write offline_access",
307 "code_challenge_method": "S256", "code_challenge": base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")}
308 _, headers = http("/oauth/authorize?" + urllib.parse.urlencode(fields), status=302)
309 request_id = urllib.parse.parse_qs(urllib.parse.urlsplit(headers["Location"]).query)["request"][0]
310 consent_path = "/api/mcp/consent/" + request_id
311 details, _ = http(consent_path, actor=names[0])
312 assert {r["id"] for r in details["resources"]} == {first["id"], second["id"]}
313 http(consent_path, actor=names[1], status=403)
314 result, _ = http(consent_path, "POST", {"resources": [first["id"]]}, actor=names[0])
315 code = urllib.parse.parse_qs(urllib.parse.urlsplit(result["redirect"]).query)["code"][0]
316 tokens, _ = http("/oauth/token", "POST", {"grant_type": "authorization_code", "client_id": client["client_id"], "redirect_uri": fields["redirect_uri"], "resource": resource, "code_verifier": verifier, "code": code}, form=True)
317 credentials.extend([tokens["access_token"], tokens["refresh_token"]])
318 assert len(call(tokens["access_token"], "list_machines")["structuredContent"]["machines"]) == 1
319 http("/mcp/observability", "POST", {}, token=tokens["access_token"], status=401)
320 http("/oauth/token", "POST", {"grant_type": "refresh_token", "client_id": client["client_id"], "refresh_token": tokens["refresh_token"], "resource": origin + "/mcp/observability"}, status=400, form=True)
321 rotated, _ = http("/oauth/token", "POST", {"grant_type": "refresh_token", "client_id": client["client_id"], "refresh_token": tokens["refresh_token"], "resource": resource}, form=True)
322 credentials.extend([rotated["access_token"], rotated["refresh_token"]])
323 with sqlite3.connect(args.data_dir / "connections.sqlite") as db:
324 rows = db.execute("SELECT key,value FROM records").fetchall()
325 assert all(not any(secret in key + value for secret in credentials) for key, value in rows)
326 expired, _ = http("/pairing", "POST", {"name": marker, "platform": "test"}, status=201)
327 code_hash = hashlib.sha256(expired["code"].replace("-", "").encode()).hexdigest()
328 db.execute("UPDATE records SET expires=1 WHERE key=?", ("pair:" + code_hash,))
329 http("/pairing", token=expired["token"], status=410)
330 subprocess.run(["systemctl", "restart", args.restart_unit], check=True, timeout=180, capture_output=True)
331 for agent in agents:
332 assert agent.closed.wait(5)
333 a1, a2 = connect(token1), connect(token2)
334 assert command(read["key"], first["id"])["result"]["method"] == "list_threads"
335 a1.frames.get(timeout=2)
336 assert len(call(rotated["access_token"], "list_machines")["structuredContent"]["machines"]) == 1
337 http("/api/mcp/connections/" + control["id"], "DELETE", actor=names[0], status=204)
338 http("/api/v1/machines", token=control["key"], status=401)
339 assert a1.frames.empty()
340 http("/api/mcp/relay/machines/" + first["id"], "DELETE", actor=names[0], status=204)
341 assert a1.closed.wait(5)
342 Agent(args.url, token1, status=401)
343 http("/pairing", token=token1, status=410)
344 remaining = call(read["key"], "list_machines")["structuredContent"]["machines"]
345 assert len(remaining) == 1 and remaining[0]["id"] == second["id"] and remaining[0]["selected"]
346 http("/api/v1/targets", "PUT", {"machine_ids": [first["id"]]}, token=read["key"], status=403)
347 a2.send({"id": str(uuid.uuid4()), "result": {}, "error": "malformed"})
348 assert a2.closed.wait(5)
349 original_agent_checks = None
350 if args.agent_dir:
351 node = next(Path("/nix/store").glob("*nodejs-24*/bin/node"))
352 with tempfile.TemporaryDirectory(prefix="studio-relay-cli-") as temporary:
353 local = Path(temporary)
354 home = local / "home"
355 home.mkdir()
356 (home / "codex").mkdir()
357 (home / "claude/projects").mkdir(parents=True)
358 with sqlite3.connect(home / "codex/state_5.sqlite") as catalog:
359 catalog.execute("CREATE TABLE threads(id TEXT,title TEXT,cwd TEXT,updated_at INTEGER,rollout_path TEXT,archived INTEGER)")
360 allowed = local / "workspace"
361 allowed.mkdir()
362 resolver = local / "lookup.cjs"
363 resolver.write_text("const dns=require('node:dns'); const original=dns.lookup; dns.lookup=function(host,opts,callback){if(host==='globe.studio.test'){if(typeof opts==='function'){callback=opts;opts={};} process.nextTick(()=>opts?.all?callback(null,[{address:'127.0.0.1',family:4}]):callback(null,'127.0.0.1',4));}else{return original.apply(this,arguments);}};\n")
364 executable = local / "model-fixture"
365 executable.write_text("#!/bin/sh\nexec " + str(node) + " " + str(args.agent_dir / "fake-cli.mjs") + ' "$@"\n')
366 executable.chmod(0o700)
367 log = local / "agent.log"
368 environment = {**os.environ, "HOME": str(home), "CODEX_HOME": str(home / "codex"), "CLAUDE_CONFIG_DIR": str(home / "claude"),
369 "PATH": str(node.parent) + ":" + os.environ.get("PATH", ""), "NODE_OPTIONS": "--require " + str(resolver), "NODE_EXTRA_CA_CERTS": str(args.agent_ca)}
370 output = queue.Queue()
371 with log.open("w") as stderr:
372 process = subprocess.Popen([str(node), str(args.agent_dir / "dist/agent.js"), "run", "--server", args.agent_origin,
373 "--name", marker, "--data-dir", str(local / "identity"), "--allow-root", str(allowed),
374 "--codex-bin", str(executable), "--claude-bin", str(executable)], env=environment, stdout=subprocess.PIPE, stderr=stderr, text=True)
375 reader = threading.Thread(target=lambda: [output.put(line) for line in process.stdout], daemon=True)
376 reader.start()
377 try:
378 deadline = time.monotonic() + 15
379 code = None
380 while not code and time.monotonic() < deadline:
381 line = output.get(timeout=15)
382 match = re.search(r"code ([A-F0-9]{5}-[A-F0-9]{5})", line)
383 if match:
384 code = match[1]
385 assert code, "Original agent did not begin pairing"
386 machine, _ = http("/api/mcp/relay/pair", "POST", {"code": code}, actor=names[0])
387 deadline = time.monotonic() + 15
388 while time.monotonic() < deadline:
389 overview, _ = http("/api/mcp", actor=names[0])
390 if any(m["id"] == machine["id"] and m["online"] for m in overview["machines"]):
391 break
392 time.sleep(.2)
393 else:
394 raise AssertionError("Original agent did not connect")
395 credential_file = local / "identity/agent.json"
396 assert credential_file.stat().st_mode & 0o777 == 0o600
397 native_key = key(names[0], [machine["id"]], write=True)
398 assert command(native_key["key"], machine["id"])["result"] == {"threads": [], "errors": []}
399 for provider in ["codex", "claude"]:
400 started = command(native_key["key"], machine["id"], "start_thread", {"provider": provider, "cwd": str(allowed), "message": "owned fixture"})["result"]
401 thread = started["thread_id"]
402 time.sleep(.05)
403 read_fields = {"provider": provider, "thread_id": thread}
404 transcript = command(native_key["key"], machine["id"], "read_thread", read_fields)["result"]
405 assert "fixture reply" in json.dumps(transcript)
406 command(native_key["key"], machine["id"], "send_message", {**read_fields, "message": "slow"})
407 if provider == "claude":
408 live = command(native_key["key"], machine["id"], "read_thread", read_fields)["result"]
409 command(native_key["key"], machine["id"], "interrupt_thread", {**read_fields, "expected_turn_id": live["active_turn_id"]})
410 command(native_key["key"], machine["id"], "start_thread", {"provider": "codex", "cwd": str(home), "message": "outside allowed directory"}, status=400)
411 http("/api/mcp/relay/machines/" + machine["id"], "DELETE", actor=names[0], status=204)
412 assert process.wait(timeout=10) != 0
413 original_agent_checks = {"tls_pairing_and_outbound_wss": True, "credential_mode_0600": True, "codex_and_claude_owned_session_roundtrip": True,
414 "session_control_and_expected_turn": True, "local_directory_allowlist": True, "unlink_rejects_reconnect": True}
415 finally:
416 if process.poll() is None:
417 process.terminate()
418 process.wait(timeout=10)
419 process.stdout.close()
420 reader.join(timeout=5)
421 result = {"native_pairing_single_use_and_expiry": True, "existing_agents_wire_protocol": True, "ordinary_realm_user_consent": True,
422 "machine_and_cross_catalog_isolation": True, "explicit_control_scope": True, "single_machine_writes": True,
423 "per_connection_target_selection": True, "native_mcp_tools": True, "owned_machine_stream": True, "eight_pending_limit": True, "unicode_command_frame_budget": True,
424 "heartbeat_and_timeout_unknown_outcome": True, "disconnect_unknown_outcome_no_replay": True,
425 "hash_only_credentials": True, "restart_preserves_pairing_keys_and_oauth": True,
426 "revoke_and_unlink_immediate": True, "origin_host_and_malformed_frames_refused": True,
427 "timeout_seconds": round(elapsed, 2)}
428 if original_agent_checks is not None:
429 result["original_node_agent"] = original_agent_checks
430 finally:
431 for agent in agents:
432 agent.close()
433 keycloak = Keycloak("keycloak.studio.test", importlib.import_module("dashboard-run").secret("get", "keycloak", "password"), attempts=1)
434 for name in names:
435 try:
436 overview, _ = http("/api/mcp", actor=name)
437 for connection in overview["connections"]:
438 http("/api/mcp/connections/" + connection["id"], "DELETE", actor=name, status=204)
439 for machine in overview["machines"]:
440 http("/api/mcp/relay/machines/" + machine["id"], "DELETE", actor=name, status=204)
441 finally:
442 for identity in keycloak.request("/admin/realms/master/users?username=" + name + "&exact=true"):
443 keycloak.request("/admin/realms/master/users/" + identity["id"], "DELETE")
444 assert not keycloak.request("/admin/realms/master/users?username=" + name + "&exact=true")
445 result["owned_users_and_machines_removed"] = True
446 if args.output:
447 args.output.write_text(json.dumps(result, indent=2) + "\n")
448 print(json.dumps(result))
449
450
451if __name__ == "__main__":
452 main()
tools/dashboard-routing-test.py created+234
......@@ -0,0 +1,234 @@
1#!/usr/bin/env python3
2import argparse
3from http.server import BaseHTTPRequestHandler, HTTPServer
4import json
5import os
6from pathlib import Path
7import socket
8import subprocess
9import tempfile
10import threading
11import time
12import urllib.error
13import urllib.request
14import uuid
15
16import router
17
18
19def main():
20 parser = argparse.ArgumentParser()
21 parser.add_argument("--image", required=True)
22 parser.add_argument("--output", type=Path)
23 args = parser.parse_args()
24 proof = uuid.uuid4().hex + uuid.uuid4().hex
25 container = "studio-dashboard-routing-test-" + uuid.uuid4().hex[:12]
26 fixture_id = "studio-routing-fixture-" + uuid.uuid4().hex[:12]
27 trace_id = uuid.uuid4().hex
28 redirected = []
29
30 def shell(*argv):
31 result = subprocess.run(argv, capture_output=True, text=True, timeout=60)
32 if result.returncode:
33 raise AssertionError(result.stderr)
34 return result.stdout
35
36 class Fixture(BaseHTTPRequestHandler):
37 def do_GET(self):
38 if self.path.startswith("/api/v2/"):
39 self.send_response(302)
40 self.send_header("Location", f"http://10.88.0.1:{self.server.server_port}/trap")
41 self.end_headers()
42 return
43 if self.path == "/trap":
44 redirected.append(dict(self.headers))
45 self.send_response(200)
46 self.send_header("Content-Type", "application/json")
47 self.end_headers()
48 self.wfile.write(json.dumps({"path": self.path, "host": self.headers.get("Host"),
49 "proof": self.headers.get("Studio-Proxy-Token"),
50 "user": self.headers.get("User-Name")}).encode())
51
52 def log_message(self, *args):
53 pass
54
55 with tempfile.TemporaryDirectory(prefix="studio-dashboard-routing-", dir="/run") as temporary, HTTPServer(("127.0.0.1", 0), Fixture) as fixture:
56 root = Path(temporary)
57 threading.Thread(target=fixture.serve_forever, daemon=True).start()
58 token = root / "proxy.token"
59 token.write_text(proof)
60 readonly = root / "nomad.token"
61 readonly.write_bytes(Path("/var/lib/studio/dashboard.token").read_bytes())
62 for file in [token, readonly]:
63 os.chown(file, 0, 65534)
64 file.chmod(0o440)
65 with socket.socket() as reservation:
66 for port in range(20000, 32001):
67 try:
68 reservation.bind(("0.0.0.0", port))
69 break
70 except OSError:
71 continue
72 else:
73 raise AssertionError("no fixture listener available")
74 os.environ.update(STUDIO_DOMAIN="studio.test", STUDIO_INTERNAL_PORT=str(port),
75 STUDIO_DASHBOARD_PORT="7072", STUDIO_PROXY_TOKEN_FILE=str(token))
76 real_nomad = router.nomad
77
78 def discovery(path, supplied):
79 if path in ["/v1/service/routing-fixture", "/v1/service/qbittorrent"]:
80 return [{"ServiceName": path.rsplit("/", 1)[1], "AllocID": "routing-fixture", "Address": "127.0.0.1",
81 "Port": fixture.server_port, "Tags": ["caddy-host=routing-fixture.studio.test"]}]
82 if path == "/v1/allocation/routing-fixture/checks":
83 return {"ready": {"Status": "failure"}}
84 value = real_nomad(path, supplied)
85 if path == "/v1/services":
86 next(item for item in value if item["Namespace"] == "default")["Services"].append({"ServiceName": "routing-fixture"})
87 return value
88
89 router.nomad = discovery
90 host = "dashboard.internal.studio.test"
91 content = router.render(Path(router.TOKEN).read_text().strip())
92 gateway = content[content.index(host + ":" + str(port) + " {"):]
93 config = root / "Caddyfile"
94 config.write_text("{\n admin off\n auto_https disable_redirects\n skip_install_trust\n}\n" + gateway)
95 config.chmod(0o600)
96 binary = (Path("/proc") / shell("systemctl", "show", "-P", "MainPID", "caddy").strip() / "exe").resolve(strict=True)
97 caddy = None
98 try:
99 with (root / "caddy.log").open("w") as log:
100 caddy = subprocess.Popen([str(binary), "run", "--config", str(config), "--adapter", "caddyfile"],
101 stdout=log, stderr=log, env={**os.environ, "XDG_DATA_HOME": temporary, "XDG_CONFIG_HOME": temporary})
102 ca = root / "caddy/pki/authorities/local/root.crt"
103 deadline = time.monotonic() + 15
104 while not ca.exists():
105 if caddy.poll() is not None or time.monotonic() >= deadline:
106 raise AssertionError((root / "caddy.log").read_text())
107 time.sleep(.1)
108 ca.chmod(0o444)
109 image = json.loads(shell("podman", "image", "inspect", args.image))[0]
110 python = next(value.split("=", 1)[1] for value in image["Config"]["Env"] if value.startswith("STUDIO_YT_PYTHON="))
111 client = f"""import json, ssl, time, urllib.request, urllib.error
112base = 'https://{host}:{port}'
113proof = open('/proxy.token').read().strip()
114nomad = open('/nomad.token').read().strip()
115context = ssl.create_default_context(cafile='/ca.crt')
116def request(path, supplied=proof, method='GET', body=None, content_type='application/json'):
117 headers = {{'User-Name': 'forged', 'X-Nomad-Token': nomad, 'Content-Type': content_type}}
118 if supplied is not None:
119 headers['Studio-Proxy-Token'] = supplied
120 query = urllib.request.Request(base + path, data=body, headers=headers, method=method)
121 try:
122 with urllib.request.urlopen(query, context=context, timeout=15) as response:
123 return response.status, response.read()
124 except urllib.error.HTTPError as error:
125 return error.code, error.read()
126for supplied in [None, '0' * 64, proof[:-1], proof + '0']:
127 assert request('/nomad/v1/services', supplied)[0] == 403
128status, body = request('/nomad/v1/services')
129assert status == 200 and isinstance(json.loads(body), list), (status, body)
130assert request('/nomad/v1/var/studio-routing-fixture')[0] == 403
131status, body = request('/nomad/v1/jobs', method='POST', body=json.dumps({{'Job': {{'ID': 'studio-routing-fixture', 'Name': 'studio-routing-fixture', 'Type': 'service', 'Datacenters': ['clover'], 'TaskGroups': [{{'Name': 'fixture', 'Count': 0, 'Tasks': [{{'Name': 'probe', 'Driver': 'podman', 'Config': {{'image': 'alpine:3.22'}}, 'Resources': {{'CPU': 100, 'MemoryMB': 32}}}}]}}]}}}}).encode())
132assert status == 403, (status, body)
133assert request('/services/unknown-fixture/health')[0] == 404
134for service in ['victoria-metrics', 'victoria-logs', 'victoria-traces']:
135 status, body = request('/services/' + service + '/health')
136 assert status == 200, (service, status, body)
137status, body = request('/services/routing-fixture/probe?fixture=1')
138result = json.loads(body)
139assert status == 200 and result == {{'path': '/probe?fixture=1', 'host': '127.0.0.1:{fixture.server_port}', 'proof': None, 'user': None}}, result
140# Keep fixture samples outside VictoriaMetrics' query latency window.
141at = time.time() - 60
142metric = 'studio_service_cpu_cores{{service="{fixture_id}"}} 0.125 ' + str(int(at * 1000)) + '\\n'
143status, body = request('/services/victoria-metrics/api/v1/import/prometheus', method='POST', body=metric.encode(), content_type='text/plain')
144assert status == 204, (status, body)
145row = {{'_time': str(at), '_msg': 'Routing fixture', 'source': 'nomad', 'job': '{fixture_id}', 'task': 'probe', 'stream': 'stdout'}}
146status, body = request('/services/victoria-logs/insert/jsonline', method='POST', body=(json.dumps(row) + '\\n').encode(), content_type='application/stream+json')
147assert status == 200, (status, body)
148trace = {{'resourceSpans': [{{'resource': {{'attributes': [{{'key': 'service.name', 'value': {{'stringValue': '{fixture_id}'}}}}]}},
149 'scopeSpans': [{{'spans': [{{'traceId': '{trace_id}', 'spanId': '1234567890abcdef', 'name': 'Routing fixture', 'kind': 1,
150 'startTimeUnixNano': str(int(at * 1e9)), 'endTimeUnixNano': str(int((at + .01) * 1e9))}}]}}]}}]}}
151status, body = request('/services/victoria-traces/insert/opentelemetry/v1/traces', method='POST', body=json.dumps(trace).encode())
152assert status == 200, (status, body)
153print(json.dumps({{'private_bridge': True, 'nonroot': __import__('os').getuid() == 65534,
154 'tls_verified': True, 'forged_gateway_refused': 4, 'nomad_metadata': True,
155 'nomad_variables_refused': True, 'nomad_writes_refused': True,
156 'unknown_service_refused': True, 'actual_telemetry_health': 3,
157 'unhealthy_service_reachable': True, 'upstream_headers_scrubbed': True,
158 'upstream_path_and_host': True}}))
159"""
160 output = shell("podman", "run", "--rm", "--name", container, "--read-only", "--cap-drop=ALL",
161 "--security-opt=no-new-privileges", "--memory=256m", "--cpus=1", "--pids-limit=32",
162 "--add-host=" + host + ":host-gateway", "--entrypoint=" + python,
163 "--volume=" + str(ca) + ":/ca.crt:ro", "--volume=" + str(token) + ":/proxy.token:ro",
164 "--volume=" + str(readonly) + ":/nomad.token:ro", args.image, "-c", client)
165 result = json.loads(output)
166 assert result["nonroot"]
167 data = root / "data"
168 data.mkdir()
169 os.chown(data, 65534, 65534)
170 shell("podman", "run", "--detach", "--name", container, "--read-only", "--cap-drop=ALL",
171 "--security-opt=no-new-privileges", "--memory=512m", "--cpus=2", "--pids-limit=128",
172 "--add-host=" + host + ":host-gateway", "--publish=127.0.0.1::7072",
173 "--volume=" + str(ca) + ":/ca.crt:ro", "--volume=" + str(token) + ":/proxy.token:ro",
174 "--volume=" + str(readonly) + ":/nomad.token:ro", "--volume=" + str(data) + ":/data:rw",
175 "--env=STUDIO_PROXY_TOKEN_FILE=/proxy.token", "--env=STUDIO_NOMAD_TOKEN_FILE=/nomad.token",
176 "--env=STUDIO_CA_BUNDLE=/ca.crt", f"--env=STUDIO_INTERNAL_URL=https://{host}:{port}",
177 "--env=STUDIO_DATA_DIR=/data", "--env=STUDIO_DOMAIN=studio.test", args.image)
178 info = json.loads(shell("podman", "inspect", container))[0]
179 app_port = info["NetworkSettings"]["Ports"]["7072/tcp"][0]["HostPort"]
180
181 def dashboard(path):
182 request = urllib.request.Request("http://127.0.0.1:" + app_port + path, headers={
183 "Studio-Proxy-Token": proof, "User-Name": "fixture", "User-Groups": "infra-admin",
184 })
185 with urllib.request.urlopen(request, timeout=20) as response:
186 return json.load(response)
187
188 deadline = time.monotonic() + 30
189 while True:
190 try:
191 assert dashboard("/api/me")["name"] == "fixture"
192 break
193 except OSError:
194 if time.monotonic() >= deadline:
195 raise
196 time.sleep(.1)
197 assert isinstance(dashboard("/api/launcher"), list)
198 deadline = time.monotonic() + 20
199 while True:
200 try:
201 metrics = dashboard("/api/metrics/service.cpu?range=300&service=" + fixture_id)
202 assert any(.125 in item["v"] for item in metrics), metrics
203 logs = dashboard("/api/services/" + fixture_id + "/logs?limit=1")
204 assert logs and logs[0]["text"] == "Routing fixture", logs
205 trace = dashboard("/api/traces/" + trace_id)
206 assert trace["id"] == trace_id and trace["spans"][0]["name"] == "Routing fixture", trace
207 break
208 except (AssertionError, urllib.error.HTTPError):
209 if time.monotonic() >= deadline:
210 raise
211 time.sleep(2)
212 try:
213 dashboard("/api/seedbox")
214 raise AssertionError("backend redirect followed")
215 except urllib.error.HTTPError as error:
216 assert error.code == 502 and b"302" in error.read()
217 assert not redirected, redirected
218 result.update(rust_nomad_metadata=True, rust_actual_metric_ingestion=True,
219 rust_actual_log_ingestion=True, rust_actual_trace_ingestion=True,
220 rust_backend_redirect_refused=True)
221 if args.output:
222 args.output.parent.mkdir(parents=True, exist_ok=True)
223 args.output.write_text(json.dumps(result, indent=2) + "\n")
224 print(json.dumps(result))
225 finally:
226 subprocess.run(["podman", "rm", "--force", container], capture_output=True, timeout=15)
227 if caddy is not None:
228 caddy.terminate()
229 caddy.wait(timeout=10)
230 fixture.shutdown()
231
232
233if __name__ == "__main__":
234 main()
tools/dashboard-run.py created+566
......@@ -0,0 +1,566 @@
1#!/usr/bin/env python3
2import fcntl
3import json
4import os
5from pathlib import Path
6import re
7import selectors
8import secrets
9import signal
10import stat
11import subprocess
12import sys
13import time
14import uuid
15import urllib.error
16import urllib.parse
17import urllib.request
18
19import release
20
21
22FIELDS = {
23 "iam.request": {"path", "method", "body"},
24 "deploy.current": set(), "deploy.main": set(), "deploy.history": set(), "deploy.stages": set(), "deploy.managed": set(),
25 "deploy.release": {"release"}, "deploy.output": {"kind", "target"},
26 "deploy.last": set(), "deploy.run": {"id"}, "deploy.start": {"action", "target"},
27 "deploy.secret.get": {"service", "key"}, "deploy.secret.set": {"service", "key", "value"},
28 "deploy.secret.rotate": {"service", "key"},
29}
30ACTIONS = {"deploy", "destroy", "rollback", "start", "stop", "restart", "secret-set", "secret-rotate"}
31MAX_LOG = 1024 * 1024
32MAX_METADATA = 65536
33HOST_STATE = Path(os.environ.get("STUDIO_HOST_STATE_ROOT", "/var/lib/studio/host"))
34IAM_ROLES = {"infra-admin", "media", "media-manage"}
35IAM_ACTIONS = {"UPDATE_PASSWORD", "VERIFY_EMAIL", "UPDATE_PROFILE", "CONFIGURE_TOTP", "webauthn-register", "webauthn-register-passwordless"}
36
37
38class Error(Exception):
39 def __init__(self, status, message):
40 super().__init__(message)
41 self.status = status
42
43
44def read(path, limit=MAX_METADATA, missing=None):
45 try:
46 fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
47 except FileNotFoundError:
48 return missing
49 with os.fdopen(fd, "rb") as source:
50 if not stat.S_ISREG(os.fstat(source.fileno()).st_mode):
51 raise ValueError("deployment state must be a regular file")
52 data = source.read(limit + 1)
53 if len(data) > limit:
54 raise ValueError("deployment state is too large")
55 return data.decode(errors="replace")
56
57
58def history():
59 entries = json.loads(read(release.HISTORY, missing="[]"))
60 if not isinstance(entries, list) or any(not isinstance(entry, dict) for entry in entries):
61 raise ValueError("invalid deployment history")
62 return entries
63
64
65def managed():
66 jobs = json.loads(read(release.STATE / "managed-jobs.json", missing="[]"))
67 if not isinstance(jobs, list) or any(not isinstance(job, str) or not release.STAGE_ID.fullmatch(job) for job in jobs):
68 raise ValueError("invalid managed job list")
69 return jobs
70
71
72def service(target, key=None):
73 if not isinstance(target, str) or not release.STAGE_ID.fullmatch(target):
74 raise Error(400, "Choose a service from the list.")
75 if target not in managed():
76 raise Error(404, "That service is no longer managed. Reload services.")
77 if key is not None and (not isinstance(key, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,127}", key)):
78 raise Error(400, "Choose a secret from this service's list.")
79
80
81def nomad(path, method="GET", body=None):
82 token = read(release.STATE / "nomad.token", missing="").strip()
83 if not token:
84 raise Error(503, "Nomad credentials are unavailable. Check the host service configuration.")
85 request = urllib.request.Request("http://127.0.0.1:4646/v1/" + path, method=method,
86 data=json.dumps(body).encode() if body is not None else None,
87 headers={"X-Nomad-Token": token, "Content-Type": "application/json"})
88 try:
89 with urllib.request.urlopen(request, timeout=10) as response:
90 data = response.read(MAX_LOG + 1)
91 except urllib.error.HTTPError as error:
92 if error.code == 404 and method == "GET":
93 return None
94 if error.code == 409:
95 raise Error(409, "The secret changed during this update. Reload it before retrying.") from None
96 raise Error(502, "Nomad refused the secret request. Check its service logs.") from None
97 if len(data) > MAX_LOG:
98 raise Error(502, "The secret response is too large. Check the service configuration.")
99 return json.loads(data)
100
101
102def secret(action, target, key, value=None):
103 service(target, key)
104 job = nomad("job/" + target)
105 specs = json.loads((job or {}).get("Meta", {}).get("studio_secrets", "[]"))
106 if not isinstance(specs, list):
107 raise Error(502, "The service's secret names couldn't be read. Deploy its current release again.")
108 matches = [spec for spec in specs if isinstance(spec, dict) and spec.get("name") == key]
109 if len(matches) != 1:
110 raise Error(404, "That secret is unavailable. Reload the service's secret list.")
111 path = "nomad/jobs/" + target
112 existing = nomad("var/" + path)
113 items = dict(existing["Items"]) if existing else {}
114 if action == "get":
115 if key not in items:
116 raise Error(404, "That secret has no value. Set it from the service page.")
117 return items[key]
118 if action == "rotate":
119 count = matches[0].get("bytes")
120 if matches[0].get("generated") is not True or type(count) is not int or not 1 <= count <= 4096:
121 raise Error(409, "This secret comes from outside the server. Set it instead of generating it.")
122 value = secrets.token_hex(count)
123 if not isinstance(value, str) or not value or len(value.encode()) > 8192 or any(c in value for c in "\r\n\0"):
124 raise Error(400, "Enter a single-line secret under 8 KiB.")
125 items[key] = value
126 nomad("var/" + path + "?cas=" + str(existing["ModifyIndex"] if existing else 0), "PUT",
127 {"Namespace": "default", "Path": path, "Items": items})
128 restarted = subprocess.run(["nomad", "job", "restart", "-yes", target], capture_output=True, text=True, timeout=55,
129 env={**os.environ, "NOMAD_TOKEN": read(release.STATE / "nomad.token").strip()})
130 if restarted.returncode:
131 raise Error(502, "The secret was saved, but the service couldn't restart. Check its run before retrying.")
132 print("Updated " + target + "/" + key, flush=True)
133
134
135def stage(target):
136 if not isinstance(target, str) or not release.STAGE_ID.fullmatch(target):
137 raise Error(400, "Choose a stage from the list.")
138 text = read(release.STATE / "stages" / (target + ".json"))
139 if text is None:
140 raise Error(404, "That stage is no longer available. Reload deploys.")
141 return json.loads(text)
142
143
144def entry(target):
145 if not isinstance(target, str) or not re.fullmatch(r"[1-9][0-9]{0,9}", target):
146 raise Error(400, "Choose a deployment from history.")
147 entries = history()
148 if int(target) > len(entries):
149 raise Error(404, "That deployment is outside history. Reload deploys.")
150 return entries[int(target) - 1]
151
152
153def command(action, target, key=None):
154 if not isinstance(action, str) or action not in ACTIONS:
155 raise Error(400, "Choose a supported deployment action.")
156 if action in {"start", "stop", "restart", "secret-set", "secret-rotate"}:
157 service(target, key)
158 if action.startswith("secret-"):
159 if key is None:
160 raise Error(400, "Choose a secret from this service's list.")
161 return [sys.executable, str(Path(__file__)), "--secret", action.removeprefix("secret-"), target, key]
162 if action in {"stop", "restart"}:
163 return ["nomad", "job", action, "-yes", target]
164 version = release.current_release()
165 if version is None:
166 raise Error(409, "No release is running. Deploy a release before starting this service.")
167 return [sys.executable, str(release.check_release(version) / "tools/studio.py"), "deploy", target]
168 if action == "rollback":
169 saved = entry(target)
170 version = saved["release"]
171 release.check_release(version, legacy=saved.get("legacy") is True)
172 return [sys.executable, str(Path(__file__).with_name("release.py")), "rollback", version]
173 if action == "deploy":
174 candidate = release.main_release()
175 if not candidate or target != candidate["release"]:
176 raise Error(409, "Main changed or isn't uploaded. Reload deploys before deploying.")
177 release.check_release(target)
178 return [sys.executable, str(Path(__file__).with_name("release.py")), "deploy", target]
179 metadata = stage(target)
180 version = metadata.get("release")
181 if not isinstance(version, str):
182 raise Error(409, "This stage has no release. Stage it again.")
183 root = release.check_release(version)
184 return [sys.executable, str(root / "tools/studio.py"), "destroy", target]
185
186
187def last():
188 text = read(HOST_STATE / "last-run.json")
189 if text is None:
190 return None
191 return json.loads(text)
192
193
194def run(identity):
195 if not isinstance(identity, str) or not re.fullmatch(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", identity):
196 raise Error(400, "Choose a deployment run from the list.")
197 root = HOST_STATE / "runs"
198 text = read(root / (identity + ".log"), MAX_LOG + 4096)
199 if text is None:
200 legacy = read(release.STATE / "runs" / (identity + ".log"), MAX_LOG)
201 if legacy is not None:
202 root, text = release.STATE / "runs", legacy
203 latest = last()
204 if text is None and (not latest or latest.get("id") != identity):
205 raise Error(404, "That run is no longer available.")
206 code = read(root / (identity + ".exit"), 64)
207 if code is None:
208 shown = subprocess.run(["systemctl", "show", "--property=ActiveState,ExecMainStatus,LoadState", "studio-run-" + identity],
209 capture_output=True, text=True, timeout=5)
210 state = dict(line.split("=", 1) for line in shown.stdout.splitlines() if "=" in line)
211 if shown.returncode and state.get("LoadState") != "not-found":
212 shown.check_returncode()
213 if state.get("ActiveState") not in {"active", "activating", "deactivating"}:
214 code = read(root / (identity + ".exit"), 64)
215 if code is None:
216 code = int(state.get("ExecMainStatus", 0)) or 1
217 return {"lines": [line for line in (text or "").splitlines() if line], "code": int(code) if code is not None else None}
218
219
220def start(action, target, key=None, value=None):
221 if action == "secret-set" and (not isinstance(value, str) or not value or len(value.encode()) > 8192 or any(c in value for c in "\r\n\0")):
222 raise Error(400, "Enter a single-line secret under 8 KiB.")
223 HOST_STATE.mkdir(mode=0o700, parents=True, exist_ok=True)
224 with (HOST_STATE / "run.lock").open("a") as lock:
225 fcntl.flock(lock, fcntl.LOCK_EX)
226 previous = last()
227 if previous and run(previous["id"])["code"] is None:
228 raise Error(409, "A deployment is running. Wait for it to finish, then retry.")
229 command(action, target, key)
230 identity = str(uuid.uuid4())
231 metadata = {"id": identity, "action": action, "target": target}
232 if key is not None:
233 metadata["key"] = key
234 root = HOST_STATE / "runs"
235 root.mkdir(mode=0o700, parents=True, exist_ok=True)
236 input_file = root / (identity + ".input")
237 try:
238 if action == "secret-set":
239 with os.fdopen(os.open(input_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as source:
240 source.write(value)
241 pending = HOST_STATE / "last-run.pending"
242 pending.write_text(json.dumps(metadata) + "\n")
243 pending.replace(HOST_STATE / "last-run.json")
244 args = ["systemd-run", "--unit=studio-run-" + identity, "--collect", "--quiet",
245 "--property=RuntimeMaxSec=3600", "--property=TimeoutStopSec=10",
246 "--property=MemoryMax=4G", "--property=TasksMax=1024", "--property=CPUWeight=10"]
247 args.extend("--setenv=" + key + "=" + value for key, value in os.environ.items() if key == "PATH" or key.startswith("STUDIO_"))
248 subprocess.run([*args, "--", sys.executable, str(Path(__file__)), identity, action, target, *([key] if key is not None else [])],
249 check=True, capture_output=True, timeout=10)
250 except BaseException:
251 input_file.unlink(missing_ok=True)
252 raise
253 return metadata
254
255
256def handle(request):
257 operation = request["operation"]
258 if operation == "iam.request":
259 iam_validate(request)
260 process = subprocess.run([
261 "systemd-run", "--pipe", "--wait", "--collect", "--quiet",
262 "--unit=studio-iam-" + str(uuid.uuid4()), "--property=RuntimeMaxSec=25",
263 "--property=MemoryMax=128M", "--property=TasksMax=8", "--property=ProtectSystem=strict",
264 "--property=ProtectHome=yes", "--property=NoNewPrivileges=yes", "--property=CapabilityBoundingSet=",
265 "--property=RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX", "--property=IPAddressDeny=any",
266 "--property=IPAddressAllow=localhost", "--setenv=STUDIO_DOMAIN=" + os.environ["STUDIO_DOMAIN"],
267 "--setenv=STUDIO_API_TIMEOUT=5", "--", sys.executable, str(Path(__file__)), "--iam"],
268 input=json.dumps(request), capture_output=True, text=True, timeout=30, check=True)
269 response = json.loads(process.stdout)
270 if "error" in response:
271 raise Error(response["status"], response["error"])
272 return response["value"]
273 if operation.startswith("deploy.secret.") and request["service"] == "keycloak":
274 raise Error(403, "Keycloak credentials are managed by the host.")
275 if operation == "deploy.secret.get":
276 if not isinstance(request["key"], str):
277 raise Error(400, "Choose a secret from this service's list.")
278 service(request["service"], request["key"])
279 process = subprocess.run(["systemd-run", "--pipe", "--wait", "--collect", "--quiet",
280 "--unit=studio-secret-read-" + str(uuid.uuid4()), "--property=RuntimeMaxSec=25",
281 "--property=MemoryMax=128M", "--property=TasksMax=8", "--property=ProtectSystem=strict",
282 "--property=ProtectHome=yes", "--property=NoNewPrivileges=yes", "--property=CapabilityBoundingSet=",
283 "--property=RestrictAddressFamilies=AF_INET AF_UNIX", "--property=IPAddressDeny=any",
284 "--property=IPAddressAllow=localhost", "--", sys.executable,
285 str(Path(__file__)), "--secret", "get", request["service"], request["key"]],
286 capture_output=True, text=True, timeout=30, check=True)
287 response = json.loads(process.stdout)
288 if "error" in response:
289 raise Error(response["status"], response["error"])
290 return response["value"]
291 if operation in {"deploy.secret.set", "deploy.secret.rotate"}:
292 return start("secret-" + operation.rpartition(".")[2], request["service"], request["key"], request.get("value"))
293 if operation == "deploy.current":
294 return release.current_release()
295 if operation == "deploy.main":
296 return release.main_release()
297 if operation == "deploy.history":
298 return history()
299 if operation == "deploy.managed":
300 return managed()
301 if operation == "deploy.stages":
302 values = []
303 for path in (release.STATE / "stages").glob("*.json"):
304 metadata = stage(path.stem)
305 values.append({"id": path.stem, "service": metadata["sourceId"], "release": metadata.get("release"),
306 "ready": metadata.get("ready", True), "created": path.stat().st_mtime,
307 "overrides": [value.partition("=")[0] for value in metadata.get("overrides", [])],
308 "clone": metadata.get("clone"), "mount": metadata.get("mount")})
309 return values
310 if operation == "deploy.release":
311 version = request["release"]
312 if not isinstance(version, str) or not release.RELEASE_ID.fullmatch(version):
313 raise Error(400, "Choose a release from deployment history.")
314 root = release.RELEASES / version / "service"
315 if root.parent.is_symlink():
316 raise ValueError("release directory is a symlink")
317 if not root.is_dir():
318 return None
319 values = {}
320 for path in root.iterdir():
321 if path.is_symlink():
322 raise ValueError("release service directory is a symlink")
323 if path.is_dir():
324 text = read(path / "service.pkl")
325 if text is not None:
326 values[path.name] = text
327 return values
328 if operation == "deploy.output":
329 kind, target = request["kind"], request["target"]
330 if kind == "stages":
331 value = stage(target)
332 elif kind == "history":
333 value = entry(target)
334 else:
335 raise Error(400, "Choose a stage or deployment from history.")
336 found = []
337 for path in (release.STATE / "runs").glob("*.log"):
338 name = path.name
339 if kind == "stages":
340 if not name.endswith("-stage-" + value["sourceId"] + ".log"):
341 continue
342 text = read(path, MAX_LOG)
343 if "stage=" + target in text.splitlines():
344 found.append((name, 0, text))
345 else:
346 source, version = value["source"], value["release"]
347 selected = (name.endswith("-prod-" + source + ".log") or name.endswith("-prod-" + version + ".log")) or name.endswith("-rollback-" + version + ".log")
348 if not selected and not re.fullmatch(r"[0-9a-f-]{36}\.log", name):
349 continue
350 delta = abs(path.stat().st_mtime - value["time"])
351 if delta >= 600:
352 continue
353 text = read(path, MAX_LOG)
354 first = next(iter(text.splitlines()), "")
355 if selected or first.endswith(" deploy " + version) or first.endswith(" promote " + source) or first.endswith(" rollback " + version):
356 found.append((name, delta, text))
357 if kind == "history":
358 for path in (HOST_STATE / "runs").glob("*.log"):
359 delta = abs(path.stat().st_mtime - value["time"])
360 if delta >= 600:
361 continue
362 text = read(path, MAX_LOG + 4096)
363 first = next(iter(text.splitlines()), "")
364 if first.endswith(" deploy " + value["release"]) or first.endswith(" promote " + value["source"]) or first.endswith(" rollback " + value["release"]):
365 found.append((path.name, delta, text))
366 found.sort(key=lambda item: item[0] if kind == "stages" else item[1], reverse=kind == "stages")
367 return [line for line in found[0][2].splitlines() if line] if found else None
368 if operation == "deploy.last":
369 value = last()
370 return {**value, "code": run(value["id"])["code"]} if value else None
371 if operation == "deploy.run":
372 return run(request["id"])
373 return start(request["action"], request["target"])
374
375
376def iam_validate(request):
377 path, method, body = request["path"], request["method"], request["body"]
378 if not isinstance(path, str) or not isinstance(method, str):
379 raise Error(400, "Choose a supported user operation.")
380 allowed = {
381 "/roles": {"GET"}, "/users?max=1000": {"GET"}, "/users": {"POST"},
382 "": {"GET", "PUT", "DELETE"}, "/sessions": {"GET"}, "/credentials": {"GET"},
383 "/role-mappings/realm": {"GET", "POST", "DELETE"}, "/logout": {"POST"},
384 "/execute-actions-email": {"PUT"}, "/reset-password": {"PUT"},
385 }
386 user = re.fullmatch(r"/users/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(.*)", path)
387 suffix = user[2] if user else path
388 if user and suffix not in {"", "/sessions", "/credentials", "/role-mappings/realm", "/logout", "/execute-actions-email", "/reset-password"}:
389 raise Error(400, "Choose a supported user operation.")
390 if path.startswith("/users?username="):
391 try:
392 query = urllib.parse.parse_qs(path.partition("?")[2], keep_blank_values=True, strict_parsing=True)
393 except ValueError:
394 raise Error(400, "Choose a username.") from None
395 if (set(query) != {"username", "exact"} or query["exact"] != ["true"]
396 or len(query["username"]) != 1 or not re.fullmatch(r"[a-z0-9][a-z0-9._@-]{0,254}", query["username"][0])):
397 raise Error(400, "Choose a username.")
398 suffix = "/users?max=1000"
399 if (method not in allowed.get(suffix, set()) or not user and suffix == ""
400 or method in {"GET", "DELETE", "POST"} and suffix not in {"/users", "/role-mappings/realm"} and body is not None
401 or method == "GET" and body is not None):
402 raise Error(400, "Choose a supported user operation.")
403 if method == "PUT" and suffix == "/reset-password":
404 if (not isinstance(body, dict) or set(body) != {"type", "value", "temporary"}
405 or body["type"] != "password" or not isinstance(body["value"], str)
406 or not 8 <= len(body["value"]) <= 8192 or type(body["temporary"]) is not bool):
407 raise Error(400, "Enter a password and choose whether it is temporary.")
408 elif method == "PUT" and suffix == "/execute-actions-email":
409 if not isinstance(body, list) or not body or not all(isinstance(action, str) and action in IAM_ACTIONS for action in body):
410 raise Error(400, "Choose a sign-in action.")
411 elif suffix == "/role-mappings/realm" and method != "GET":
412 if (not isinstance(body, list) or len(body) != 1 or not isinstance(body[0], dict)
413 or not isinstance(body[0].get("name"), str) or body[0]["name"] not in IAM_ROLES or not isinstance(body[0].get("id"), str)):
414 raise Error(403, "Choose a dashboard group.")
415 elif method == "POST" and suffix == "/users" or method == "PUT" and suffix == "":
416 if not isinstance(body, dict) or not body or not set(body) <= {"username", "email", "firstName", "lastName", "enabled", "emailVerified", "requiredActions", "attributes"}:
417 raise Error(400, "Enter a user profile.")
418 for key, value in body.items():
419 if key in {"enabled", "emailVerified"}:
420 valid = type(value) is bool
421 elif key == "requiredActions":
422 valid = isinstance(value, list) and all(isinstance(action, str) and action in IAM_ACTIONS for action in value)
423 elif key == "attributes":
424 valid = isinstance(value, dict) and set(value) == {"picture"} and (value["picture"] is None or isinstance(value["picture"], list) and len(value["picture"]) == 1 and isinstance(value["picture"][0], str) and len(value["picture"][0]) <= 8192)
425 else:
426 valid = value is None and key != "username" or isinstance(value, str) and len(value) <= 8192
427 if key == "username":
428 valid = isinstance(value, str) and bool(re.fullmatch(r"[a-z0-9][a-z0-9._@-]{0,254}", value)) and value != "admin"
429 if not valid:
430 raise Error(400, "Enter a valid user profile.")
431 return user
432
433
434def iam(request):
435 user = iam_validate(request)
436 sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "service/keycloak"))
437 from api import Keycloak
438 client = Keycloak("keycloak." + os.environ["STUDIO_DOMAIN"], secret("get", "keycloak", "password"),
439 attempts=1, cafile="/var/lib/studio/ca-bundle.crt")
440 path, method, body = request["path"], request["method"], request["body"]
441 if user:
442 profile = client.request("/admin/realms/master/users/" + user[1])
443 if profile["username"] == "admin":
444 raise Error(403, "The Keycloak administrator is managed outside the dashboard.")
445 if isinstance(body, dict) and "attributes" in body:
446 attributes = dict(profile.get("attributes", {}))
447 picture = body["attributes"]["picture"]
448 if picture is None:
449 attributes.pop("picture", None)
450 else:
451 attributes["picture"] = picture
452 body = {**{key: profile[key] for key in ["username", "email", "firstName", "lastName"] if key in profile},
453 **body, "attributes": attributes}
454 if path.endswith("/role-mappings/realm") and method != "GET":
455 roles = client.request("/admin/realms/master/roles")
456 role = next((role for role in roles if role["name"] in IAM_ROLES and role["id"] == body[0]["id"] and role["name"] == body[0]["name"]), None)
457 if role is None:
458 raise Error(403, "Choose a dashboard group.")
459 body = [{"id": role["id"], "name": role["name"]}]
460 result = client.request("/admin/realms/master" + path, method, body, full=True)
461 if method == "GET" and path.startswith("/users?"):
462 result["body"] = [user for user in result["body"] if user["username"] != "admin"]
463 elif method == "GET" and (path == "/roles" or path.endswith("/role-mappings/realm")):
464 result["body"] = [role for role in result["body"] if role["name"] in IAM_ROLES]
465 return result
466
467
468def worker(identity, action, target, key=None):
469 if not re.fullmatch(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", identity):
470 raise ValueError("incorrect deployment run ID")
471 saved = last()
472 expected = {"id": identity, "action": action, "target": target}
473 if key is not None:
474 expected["key"] = key
475 if saved != expected:
476 raise ValueError("deployment run is outside host state")
477 root = HOST_STATE / "runs"
478 root.mkdir(mode=0o700, parents=True, exist_ok=True)
479 os.umask(0o077)
480 code = 1
481 def interrupted(signum, frame):
482 raise RuntimeError("Deployment stopped before completion.")
483 signal.signal(signal.SIGTERM, interrupted)
484 with (root / (identity + ".log")).open("xb", buffering=0) as output:
485 try:
486 argv = command(action, target, key)
487 output.write(("$ " + " ".join(argv) + "\n").encode())
488 environment = None
489 if action in {"stop", "restart"}:
490 environment = {**os.environ, "NOMAD_TOKEN": read(release.STATE / "nomad.token", missing="").strip()}
491 if not environment["NOMAD_TOKEN"]:
492 raise RuntimeError("Nomad credentials are unavailable. Check the host service configuration.")
493 input_file = root / (identity + ".input")
494 with subprocess.Popen(argv, stdin=subprocess.PIPE if action == "secret-set" else subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
495 start_new_session=True, env=environment) as process:
496 try:
497 if action == "secret-set":
498 process.stdin.write(read(input_file, 8192).encode())
499 process.stdin.close()
500 input_file.unlink()
501 total = 0
502 deadline = time.monotonic() + 3500
503 with selectors.DefaultSelector() as selector:
504 selector.register(process.stdout, selectors.EVENT_READ)
505 while selector.get_map():
506 remaining = deadline - time.monotonic()
507 if remaining <= 0:
508 raise TimeoutError("Deployment exceeded its time limit.")
509 for key, _ in selector.select(remaining):
510 chunk = os.read(key.fd, 65536)
511 if not chunk:
512 selector.unregister(key.fileobj)
513 continue
514 total += len(chunk)
515 if total > MAX_LOG:
516 raise RuntimeError("Deployment output exceeded its size limit.")
517 output.write(chunk)
518 code = process.wait(timeout=max(.001, deadline - time.monotonic()))
519 except BaseException:
520 try:
521 os.killpg(process.pid, signal.SIGKILL)
522 except ProcessLookupError:
523 pass
524 raise
525 except Exception as error:
526 output.write((str(error) + "\n").encode())
527 finally:
528 (root / (identity + ".input")).unlink(missing_ok=True)
529 output.write(("exit " + str(code) + "\n").encode())
530 pending = root / (identity + ".exit.tmp")
531 pending.write_text(str(code))
532 pending.replace(root / (identity + ".exit"))
533 return code
534
535
536if __name__ == "__main__":
537 if sys.argv[1:] == ["--iam"]:
538 try:
539 payload = sys.stdin.read(MAX_METADATA + 1)
540 if len(payload.encode()) > MAX_METADATA:
541 raise Error(400, "The user request is too large. Narrow the selection.")
542 result = {"value": iam(json.loads(payload))}
543 except Error as error:
544 result = {"error": str(error), "status": error.status}
545 except urllib.error.HTTPError as error:
546 result = {"error": "Keycloak refused this change. Reload the page and retry.", "status": error.code if error.code in {404, 409} else 502}
547 except Exception:
548 result = {"error": "Keycloak is unavailable. Check its service logs.", "status": 502}
549 print(json.dumps(result))
550 raise SystemExit(0)
551 if len(sys.argv) == 5 and sys.argv[1] == "--secret" and sys.argv[2] in {"get", "set", "rotate"}:
552 action, target, key = sys.argv[2:]
553 try:
554 result = secret(action, target, key, sys.stdin.read(8193) if action == "set" else None)
555 except Error as error:
556 if action != "get":
557 raise SystemExit(str(error))
558 result = {"error": str(error), "status": error.status}
559 else:
560 result = {"value": result}
561 if action == "get":
562 print(json.dumps(result))
563 raise SystemExit(0)
564 if len(sys.argv) not in {4, 5}:
565 raise SystemExit("Expected a run ID, action, and target")
566 raise SystemExit(worker(*sys.argv[1:]))
tools/dashboard-shale-link-test.py created+420
......@@ -0,0 +1,420 @@
1#!/usr/bin/env python3
2import argparse
3import base64
4from concurrent.futures import ThreadPoolExecutor
5import copy
6import http.cookiejar
7import hashlib
8import importlib
9import json
10import math
11from pathlib import Path
12import sqlite3
13import ssl
14import subprocess
15import sys
16import time
17import urllib.error
18import urllib.parse
19import urllib.request
20import uuid
21
22
23class NoRedirect(urllib.request.HTTPRedirectHandler):
24 def redirect_request(self, request, fp, code, message, headers, newurl):
25 return None
26
27
28def main():
29 parser = argparse.ArgumentParser()
30 parser.add_argument('--url', required=True)
31 parser.add_argument('--proof-file', type=Path, required=True)
32 parser.add_argument('--data-dir', type=Path, required=True)
33 parser.add_argument('--restart-unit', required=True)
34 parser.add_argument('--shale-origin', required=True)
35 parser.add_argument('--shale-database', type=Path, required=True)
36 parser.add_argument('--shale-container', required=True)
37 parser.add_argument('--output', type=Path)
38 args = parser.parse_args()
39 if args.output:
40 args.output.unlink(missing_ok=True)
41 repo = Path(__file__).resolve().parent.parent
42 sys.path.insert(0, str(repo / 'service/keycloak'))
43 from api import Keycloak, LoopbackHTTPS
44 Page = importlib.import_module('dashboard-shale-test').Page
45 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)
46 context = ssl.create_default_context(cafile='/var/lib/studio/ca-bundle.crt')
47 origin = 'https://globe.studio.test'
48 proof = args.proof_file.read_text().strip()
49 marker = 'shale-link-' + uuid.uuid4().hex
50 accounts = [(marker + '-one', uuid.uuid4().hex + 'A1!'), (marker + '-two', uuid.uuid4().hex + 'A1!')]
51 ids = []
52
53 class TLS(urllib.request.HTTPSHandler):
54 def https_open(self, request):
55 parsed = urllib.parse.urlsplit(request.full_url)
56 assert parsed.netloc in ('keycloak.studio.test', urllib.parse.urlsplit(args.shale_origin).netloc)
57 return self.do_open(LoopbackHTTPS, request, context=context)
58
59 def browser():
60 cookies = http.cookiejar.CookieJar()
61 return urllib.request.build_opener(TLS(), NoRedirect(), urllib.request.HTTPCookieProcessor(cookies)), cookies
62
63 def request(client, url, method='GET', body=None, headers=None, status=None):
64 try:
65 response = client.open(urllib.request.Request(url, method=method, data=body, headers=headers or {}), timeout=45)
66 except urllib.error.HTTPError as error:
67 response = error
68 with response:
69 value = response.read(4 * 1024 * 1024 + 1)
70 assert len(value) <= 4 * 1024 * 1024
71 if status is not None:
72 assert response.status == status, (urllib.parse.urlsplit(url).path, response.status, value[:300])
73 return response.status, response.headers, value.decode()
74
75 def api(actor, method='GET', path='/api/mcp', status=200, body=None):
76 _, _, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method,
77 body=json.dumps(body).encode() if body is not None else None,
78 headers={'Host': 'globe.studio.test', 'Studio-Proxy-Token': proof, 'User-Name': actor,
79 'User-Groups': '', 'Origin': origin, 'Content-Type': 'application/json'}, status=status)
80 return json.loads(body) if body and status < 400 else body or None
81
82 def records(prefix):
83 path = args.data_dir / 'connections.sqlite'
84 with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as db:
85 return {key: json.loads(value) for key, value in db.execute('SELECT key,value FROM records WHERE substr(key,1,?)=?', (len(prefix), prefix))}
86
87 def session(index):
88 return records('shale-session:').get('shale-session:' + ids[index])
89
90 def session_count(index):
91 with sqlite3.connect(args.shale_database.as_uri() + '?mode=ro', uri=True) as db:
92 return db.execute('SELECT count(*) FROM sessions s JOIN users u ON s.user=u.id WHERE u.snowflake=?', (ids[index],)).fetchone()[0]
93
94 def start(index, pending=None):
95 target = api(accounts[index][0], 'POST', '/api/mcp/shale', body={'request': pending} if pending else {})['redirect']
96 assert target.startswith(args.shale_origin + '/-/studio-mcp/')
97 client, cookies = browser()
98 _, headers, _ = request(client, target, status=302)
99 link_cookies = [cookie for cookie in cookies if cookie.name == 'studio_mcp_shale_link']
100 assert len(link_cookies) == 1
101 cookie = link_cookies[0]
102 assert not cookie.domain_specified and cookie.secure and cookie.path == '/-/callback'
103 assert cookie.has_nonstandard_attr('HttpOnly') and cookie.get_nonstandard_attr('SameSite') == 'Lax'
104 assert headers.get('Referrer-Policy') == 'no-referrer'
105 assert headers.get('Cache-Control') == 'no-store'
106 request(browser()[0], target, status=410)
107 return client, cookies, headers['Location'], target
108
109 def authorize(client, authorization, index):
110 status, headers, body = request(client, authorization, status=200)
111 form = next(form for form in Page(body).forms if any(field.get('name') == 'password' for field in form['fields']))
112 target = urllib.parse.urljoin(authorization, form['action'])
113 assert urllib.parse.urlsplit(target).hostname == 'keycloak.studio.test'
114 fields = {field['name']: field.get('value', '') for field in form['fields'] if field.get('name')}
115 fields.update(username=accounts[index][0], password=accounts[index][1])
116 status, headers, body = request(client, target, 'POST', urllib.parse.urlencode(fields).encode(),
117 {'Content-Type': 'application/x-www-form-urlencoded', 'Origin': 'https://keycloak.studio.test'})
118 for _ in range(6):
119 assert status in (302, 303), status
120 target = urllib.parse.urljoin(target, headers['Location'])
121 parts = urllib.parse.urlsplit(target)
122 if parts.netloc == urllib.parse.urlsplit(args.shale_origin).netloc:
123 assert parts.path == '/-/callback' and 'code' in dict(urllib.parse.parse_qsl(parts.query))
124 return target
125 assert parts.netloc == 'keycloak.studio.test'
126 status, headers, body = request(client, target)
127 raise AssertionError('too many sign-in redirects')
128
129 def finish(client, target, status=303, pending=None):
130 _, headers, _ = request(client, target, status=status)
131 assert headers.get('Cache-Control') == 'no-store'
132 assert all(not cookie.startswith('SessionID=') for cookie in headers.get_all('Set-Cookie', []))
133 assert any('studio_mcp_shale_link=;' in cookie and 'Max-Age=0' in cookie for cookie in headers.get_all('Set-Cookie', []))
134 if status == 303:
135 assert headers['Location'] == origin + '/mcp' + ('?request=' + pending if pending else '')
136
137 def backend_session(value, status):
138 return request(browser()[0], args.shale_origin + '/-/settings', headers={'Cookie': 'SessionID=' + value['session']}, status=status)
139
140 def link(index, pending=None):
141 client, cookies, authorization, _ = start(index, pending)
142 target = authorize(client, authorization, index)
143 captured = http.cookiejar.CookieJar()
144 for cookie in cookies:
145 captured.set_cookie(copy.copy(cookie))
146 finish(client, target, pending=pending)
147 replay = urllib.request.build_opener(TLS(), NoRedirect(), urllib.request.HTTPCookieProcessor(captured))
148 finish(replay, target, 410)
149 result = session(index)
150 assert result and result['origin'] == args.shale_origin + '/'
151 assert result['linkedAt'] > 0 and len(result['session']) > 20
152 overview = api(accounts[index][0])['shale']
153 assert overview is not None and set(overview) == {'linkedAt'}, overview
154 assert math.isclose(overview['linkedAt'], result['linkedAt'], rel_tol=0, abs_tol=1e-6), overview
155 assert session_count(index) == 1
156 backend_session(result, 200)
157 return result
158
159 def public(path, method='GET', body=None, status=200, token=None, form=False):
160 headers = {'Host': 'globe.studio.test', 'Content-Type': 'application/x-www-form-urlencoded' if form else 'application/json'}
161 if token:
162 headers.update({'Authorization': 'Bearer ' + token, 'Accept': 'application/json, text/event-stream',
163 'MCP-Protocol-Version': '2025-11-25'})
164 encoded = urllib.parse.urlencode(body).encode() if form else json.dumps(body).encode() if body is not None else None
165 _, headers, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method, encoded, headers, status)
166 return (json.loads(body) if body and headers.get('Content-Type', '').startswith('application/json') else body or None), headers
167
168 def pending_request(client, index, scope):
169 verifier = uuid.uuid4().hex + uuid.uuid4().hex
170 challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip('=')
171 _, headers = public('/oauth/authorize?' + urllib.parse.urlencode({'response_type': 'code',
172 'client_id': client['client_id'], 'redirect_uri': client['redirect_uris'][0], 'code_challenge_method': 'S256',
173 'code_challenge': challenge, 'resource': origin + '/mcp/shale', 'scope': scope, 'state': marker}), status=302)
174 pending = urllib.parse.parse_qs(urllib.parse.urlsplit(headers['Location']).query)['request'][0]
175 details = api(accounts[index][0], path='/api/mcp/consent/' + pending)
176 assert details['client'] == marker
177 api(accounts[1-index][0], path='/api/mcp/consent/' + pending, status=403)
178 return pending, verifier, details
179
180 def consent(client, index, repository, scope):
181 pending, verifier, details = pending_request(client, index, scope)
182 available = {r['id'] for r in details['resources']}
183 assert details['linked'] and repository in available, details
184 if index == 0:
185 assert available == {'alpha', 'beta'}, details
186 path = '/api/mcp/consent/' + pending
187 api(accounts[index][0], 'POST', path, 403, {'resources': ['outside-grant']})
188 api(accounts[index][0], 'POST', path, 403 if len(available) >= 2 else 400, {'resources': [repository, repository]})
189 result = api(accounts[index][0], 'POST', path, body={'resources': [repository]})
190 query = urllib.parse.parse_qs(urllib.parse.urlsplit(result['redirect']).query)
191 assert query['state'] == [marker]
192 tokens, _ = public('/oauth/token', 'POST', {'grant_type': 'authorization_code', 'client_id': client['client_id'],
193 'redirect_uri': client['redirect_uris'][0], 'code_verifier': verifier, 'code': query['code'][0],
194 'resource': origin + '/mcp/shale'}, form=True)
195 return tokens
196
197 def rpc(token, method, params=None, error=False):
198 value, _ = public('/mcp/shale', 'POST', {'jsonrpc': '2.0', 'id': 1, 'method': method,
199 **({'params': params} if params is not None else {})}, token=token)
200 assert 'error' not in value, value
201 value = value['result']
202 assert bool(value.get('isError')) == error, value
203 return value if error or 'structuredContent' not in value else value['structuredContent']
204
205 def call(token, tool, fields=None, error=False):
206 return rpc(token, 'tools/call', {'name': tool, 'arguments': fields or {}}, error=error)
207
208 def backend(index, suffix, fields=None):
209 headers = {'Cookie': 'SessionID=' + session(index)['session']}
210 if fields is not None:
211 headers.update({'Origin': args.shale_origin, 'Referer': args.shale_origin + suffix,
212 'Content-Type': 'application/x-www-form-urlencoded'})
213 return request(browser()[0], args.shale_origin + suffix, 'POST' if fields is not None else 'GET',
214 urllib.parse.urlencode(fields).encode() if fields is not None else None, headers)
215
216 def submit_backend(index, path, changes):
217 status, _, body = backend(index, path)
218 assert status == 200, (path, status, body[:700])
219 forms = [form for form in Page(body).forms if changes.keys() <= {field.get('name') for field in form['fields']}
220 and ('t' not in changes or any(field.get('name') == 't' and field.get('value') == changes['t'] for field in form['fields']))]
221 assert len(forms) == 1, path
222 form = forms[0]
223 assert urllib.parse.urljoin(args.shale_origin + path, form.get('action', '')) == args.shale_origin + path
224 fields = {field['name']: field.get('value', '') for field in form['fields']
225 if field.get('name') and field.get('type') == 'hidden'}
226 fields.update(timezone='UTC', tzoffset='+00:00', **changes)
227 return backend(index, path, fields)
228
229 def repository(index, name):
230 status, headers, _ = submit_backend(1, '/-/new', {'name': name, 'description': 'Owned Shale MCP fixture', 'access': 'private'})
231 assert status == 303, status
232 target = urllib.parse.urlsplit(urllib.parse.urljoin(args.shale_origin, headers['Location']))
233 assert target.netloc == urllib.parse.urlsplit(args.shale_origin).netloc
234 assert target.path.rstrip('/') == '/' + name, target.path
235 if index == 0:
236 with sqlite3.connect(args.shale_database) as db:
237 identity = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[index],)).fetchone()[0]
238 assert db.execute('UPDATE repositories SET owner=? WHERE name=?', (identity, name)).rowcount == 1
239
240 try:
241 for name, credential in accounts:
242 keycloak.request('/admin/realms/master/users', 'POST', {'username': name, 'firstName': name,
243 'lastName': 'Fixture', 'email': name + '@fixture.invalid', 'emailVerified': True, 'enabled': True,
244 'credentials': [{'type': 'password', 'value': credential, 'temporary': False}]})
245 found = keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true')
246 assert len(found) == 1
247 ids.append(found[0]['id'])
248 assert all(api(name)['shale'] is None for name, _ in accounts)
249 api(accounts[0][0], 'POST', '/api/mcp/shale', status=200)
250 old_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect']
251 latest_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect']
252 request(browser()[0], old_target, status=410)
253 with sqlite3.connect(args.data_dir / 'connections.sqlite') as db:
254 db.execute("UPDATE records SET expires=1 WHERE substr(key,1,11)='shale-link:'")
255 request(browser()[0], latest_target, status=410)
256 client, cookies, authorization, _ = start(0)
257 request(client, args.shale_origin + '/-/callback?state=other&code=fixture', status=403)
258 request(browser()[0], args.url + '/oauth/shale/link/' + 'a' * 43, headers={'Host': 'globe.studio.test'}, status=403)
259 client, cookies, authorization, _ = start(0)
260 finish(client, authorize(client, authorization, 1), 403)
261 assert session(0) is None and session(1) is None and session_count(1) == 0
262 oauth_client, _ = public('/oauth/register', 'POST', {'client_name': marker,
263 'redirect_uris': ['http://127.0.0.1:29999/shale-callback'], 'token_endpoint_auth_method': 'none'}, status=201)
264 pending, _, details = pending_request(oauth_client, 0, 'shale:read')
265 assert not details['linked'] and not details['resources']
266 api(accounts[1][0], 'POST', '/api/mcp/shale', 403, {'request': pending})
267 first = link(0, pending)
268 api(accounts[0][0], 'POST', '/api/mcp/consent/' + pending, body={'deny': True})
269 assert api(accounts[1][0])['shale'] is None
270 second = link(1)
271 assert first['session'] != second['session']
272 new_first = link(0)
273 assert new_first['session'] != first['session']
274 backend_session(first, 303)
275 backend_session(second, 200)
276 assert session_count(0) == 1
277 for index, name in [(0, 'alpha'), (0, 'beta'), (1, 'foreign')]:
278 repository(index, name)
279 readonly = consent(oauth_client, 0, 'alpha', 'shale:read offline_access')
280 writing = consent(oauth_client, 0, 'alpha', 'shale:read shale:write offline_access')
281 other = consent(oauth_client, 1, 'foreign', 'shale:read shale:write')
282 read = readonly['access_token']
283 write = writing['access_token']
284 assert rpc(read, 'initialize', {'protocolVersion': '2025-11-25', 'capabilities': {},
285 'clientInfo': {'name': marker, 'version': '1'}})['capabilities']['tools'] == {}
286 tools = rpc(read, 'tools/list')['tools']
287 assert {tool['name'] for tool in tools} == {'list_repositories', 'list_issues', 'get_issue', 'create_issue',
288 'comment_issue', 'set_issue_status', 'set_issue_title'}
289 assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools)
290 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha'}
291 assert {repo['id'] for repo in call(other['access_token'], 'list_repositories')['repositories']} == {'foreign'}
292 assert not call(read, 'list_issues', {'repository': 'alpha'})['issues']
293 for token, name in [(read, 'beta'), (read, 'foreign'), (other['access_token'], 'alpha'),
294 (write, 'alpha/../foreign'), (write, 'https://other.invalid')]:
295 call(token, 'list_issues', {'repository': name}, error=True)
296 call(read, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
297 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED', 'url': 'https://other.invalid'}, error=True)
298 public('/mcp/observability', 'POST', {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'}, token=read, status=401)
299 title = 'MCP <angle> & Unicode ☃'
300 created = call(write, 'create_issue', {'repository': 'alpha', 'title': title,
301 'description': 'Owned body <script>fixture</script> & Unicode ☃'})['issue']
302 assert created['title'] == title and created['id'] == 1
303 assert len(created['comments']) == 1 and 'Unicode ☃' in created['comments'][0]['text']
304 assert '<script>' not in json.dumps(created)
305 listed = call(read, 'list_issues', {'repository': 'alpha', 'q': 'is:open'})['issues'][0]
306 assert listed['title'] == title and listed['status'] == created['status']
307 rejected = call(read, 'list_issues', {'repository': 'alpha', 'q': 'Unicode'}, error=True)
308 assert 'filter syntax' in rejected['content'][0]['text'], rejected
309 issue_fields = {'repository': 'alpha', 'id': created['id']}
310 commented = call(write, 'comment_issue', {**issue_fields, 'comment': 'Owned comment & Unicode ☃'})['issue']
311 assert len(commented['comments']) == 2 and commented['comments'][-1]['text'] == 'Owned comment & Unicode ☃'
312 assert call(write, 'set_issue_status', {**issue_fields, 'status': 'done'})['issue']['status'] == 'done'
313 call(write, 'set_issue_status', {**issue_fields, 'status': 'outside-status'}, error=True)
314 changed = call(write, 'set_issue_title', {**issue_fields, 'title': 'Changed ☃'})['issue']
315 assert changed['title'] == 'Changed ☃'
316 assert call(read, 'get_issue', issue_fields)['issue'] == changed
317 status, _, _ = submit_backend(0, '/alpha/issues/labels', {'name': 'Owned ☃', 'description': 'Fixture label', 'color': '#ff40ff'})
318 assert status == 303
319 status, _, _ = submit_backend(0, '/alpha/issues/1', {'t': 'labels', 'labels': '1'})
320 assert status == 303
321 changed = call(read, 'get_issue', issue_fields)['issue']
322 assert changed['labels'] == ['Owned ☃'], changed
323 with sqlite3.connect(args.shale_database) as db:
324 rows = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[1],)).fetchall()
325 assert len(rows) == 1
326 original_owner = db.execute("SELECT owner FROM repositories WHERE name='alpha'").fetchone()[0]
327 db.execute("UPDATE repositories SET owner=? WHERE name='alpha'", (rows[0][0],))
328 try:
329 call(read, 'get_issue', issue_fields, error=True)
330 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
331 finally:
332 with sqlite3.connect(args.shale_database) as db:
333 db.execute("UPDATE repositories SET owner=? WHERE name='alpha'", (original_owner,))
334 with sqlite3.connect(args.shale_database) as db:
335 assert db.execute("SELECT count(*) FROM issues WHERE title='REFUSED'").fetchone()[0] == 0
336 drop = args.shale_database.parent.parent / 'drop-next-write'
337 drop.write_text('/alpha/issues/new')
338 unknown = call(write, 'create_issue', {'repository': 'alpha', 'title': 'Committed with lost response'}, error=True)
339 assert 'outcome is unknown' in unknown['content'][0]['text'], unknown
340 assert not drop.exists(), 'fixture did not drop the committed write response'
341 with sqlite3.connect(args.shale_database) as db:
342 assert db.execute("SELECT count(*) FROM issues WHERE title='Committed with lost response'").fetchone()[0] == 1
343 assert sum(issue['title'] == 'Committed with lost response' for issue in call(read, 'list_issues', {'repository': 'alpha'})['issues']) == 1
344 subprocess.run(['systemctl', 'restart', args.restart_unit], check=True, capture_output=True, timeout=30)
345 assert session(0) == new_first and session(1) == second
346 assert math.isclose(api(accounts[0][0])['shale']['linkedAt'], new_first['linkedAt'], rel_tol=0, abs_tol=1e-6)
347 backend_session(new_first, 200)
348 assert call(read, 'get_issue', issue_fields)['issue'] == changed
349 backend(0, '/-/logout')
350 call(read, 'get_issue', issue_fields, error=True)
351 new_first = link(0)
352 assert call(read, 'get_issue', issue_fields)['issue'] == changed
353 client, cookies, authorization, _ = start(0)
354 target = authorize(client, authorization, 0)
355 subprocess.run(['podman', 'pause', args.shale_container], check=True, capture_output=True)
356 try:
357 with ThreadPoolExecutor(max_workers=2) as pool:
358 callback = pool.submit(finish, client, target, 410)
359 deadline = time.monotonic() + 10
360 while not any(record.get('phase') == 'processing' for record in records('shale-link:').values()):
361 assert time.monotonic() < deadline, 'callback did not reach backend'
362 time.sleep(.05)
363 unlink = pool.submit(api, accounts[0][0], 'DELETE', '/api/mcp/shale', 204)
364 deadline = time.monotonic() + 10
365 while session(0) is not None or records('shale-link:'):
366 assert time.monotonic() < deadline, 'unlink did not cancel the callback'
367 time.sleep(.05)
368 subprocess.run(['podman', 'unpause', args.shale_container], check=True, capture_output=True)
369 callback.result(timeout=30)
370 unlink.result(timeout=30)
371 finally:
372 subprocess.run(['podman', 'unpause', args.shale_container], capture_output=True)
373 assert session(0) is None and session_count(0) == 0
374 assert api(accounts[0][0])['shale'] is None and session(1) == second
375 backend_session(second, 200)
376 public('/mcp/shale', token=read, status=401)
377 public('/mcp/shale', token=write, status=401)
378 public('/oauth/token', 'POST', {'grant_type': 'refresh_token', 'client_id': oauth_client['client_id'],
379 'refresh_token': readonly['refresh_token'], 'resource': origin + '/mcp/shale'}, form=True, status=400)
380 api(accounts[1][0], 'DELETE', '/api/mcp/shale', 204)
381 assert session(1) is None and session_count(1) == 0
382 backend_session(second, 303)
383 assert not records('shale-link:') and not records('shale-session:')
384 result = {'two_user_oidc_linking': True, 'same_realm_ordinary_users': True, 'host_only_callback_cookie': True,
385 'single_use_state_and_expiry': True, 'account_mismatch_refused_and_session_removed': True,
386 'cross_user_session_isolation': True, 'backend_session_not_exposed_to_browser': True,
387 'relink_revokes_previous_backend_session': True, 'restart_preserves_credentials': True,
388 'unlink_cancels_inflight_callback': True, 'unlink_revokes_backend_session': True,
389 'oauth_link_returns_to_consent': True, 'sdk_catalog_and_repository_grants': True,
390 'cross_repository_and_cross_user_tools_refused': True, 'read_only_and_audience_enforced': True,
391 'native_issue_create_read_comment_status_title': True, 'unicode_and_rendered_text': True,
392 'native_issue_labels_read': True,
393 'committed_write_lost_response_reported_without_replay': True,
394 'backend_permission_changes_enforced': True, 'expired_backend_session_refused': True,
395 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True}
396 finally:
397 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)
398 cleanup_errors = []
399 for name, _ in accounts:
400 try:
401 if keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'):
402 api(name, 'DELETE', '/api/mcp/shale', 204)
403 except Exception as error:
404 cleanup_errors.append(error)
405 try:
406 for user in keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'):
407 assert user['username'] == name
408 keycloak.request('/admin/realms/master/users/' + user['id'], 'DELETE')
409 except Exception as error:
410 cleanup_errors.append(error)
411 if cleanup_errors:
412 raise cleanup_errors[0]
413 result['owned_users_and_credentials_removed'] = True
414 if args.output:
415 args.output.write_text(json.dumps(result, indent=2) + '\n')
416 print(json.dumps(result), flush=True)
417
418
419if __name__ == '__main__':
420 main()
tools/dashboard-shale-test.py created+468
......@@ -0,0 +1,468 @@
1#!/usr/bin/env python3
2import argparse
3import base64
4from contextlib import closing, contextmanager
5import copy
6from html.parser import HTMLParser
7import http.client
8import http.cookiejar
9import http.server
10import importlib
11import json
12import os
13from pathlib import Path
14import re
15import sqlite3
16import ssl
17import socket
18import subprocess
19import sys
20import tempfile
21import threading
22import time
23import urllib.error
24import urllib.parse
25import urllib.request
26import uuid
27
28
29class Page(HTMLParser):
30 def __init__(self, text):
31 super().__init__()
32 self.forms = []
33 self.links = []
34 self.codes = []
35 self.form = None
36 self.code = None
37 self.select = None
38 self.feed(text)
39
40 def handle_starttag(self, tag, attrs):
41 attrs = dict(attrs)
42 if tag == "form":
43 self.form = {**attrs, "fields": []}
44 self.forms.append(self.form)
45 if tag in {"input", "select", "textarea", "button"} and self.form is not None:
46 self.form["fields"].append(attrs)
47 if tag == "select":
48 self.select = attrs
49 attrs["options"] = []
50 if tag == "option" and self.select is not None:
51 self.select["options"].append(attrs)
52 if tag == "a":
53 self.links.append(attrs.get("href", ""))
54 if tag in {"code", "pre", "textarea"}:
55 self.code = ""
56
57 def handle_data(self, data):
58 if self.code is not None:
59 self.code += data
60
61 def handle_endtag(self, tag):
62 if tag == "select":
63 self.select = None
64 if tag == "form":
65 self.form = None
66 if tag in {"code", "pre", "textarea"} and self.code is not None:
67 self.codes.append(self.code)
68 self.code = None
69
70
71@contextmanager
72def instance(keycloak, marker, image, traces_endpoint, traces_env, *, host="localhost", dashboard_port=None):
73 container = "studio-shale-backend-test-" + marker
74 client_name = "shale-fixture-" + marker
75 client_secret = uuid.uuid4().hex + uuid.uuid4().hex
76 client_id = None
77 caddy = None
78 proxy = None
79
80 def run(*argv):
81 try:
82 return subprocess.check_output(argv, text=True, stderr=subprocess.PIPE).strip()
83 except subprocess.CalledProcessError as error:
84 raise AssertionError(f"{argv[0]} failed ({error.returncode})") from None
85
86 with tempfile.TemporaryDirectory(prefix="studio-shale-backend-", dir="/run") as temporary:
87 root = Path(temporary)
88 with socket.socket() as reservation:
89 reservation.bind(("127.0.0.1", 0))
90 backend_port = reservation.getsockname()[1]
91 with socket.socket() as reservation:
92 for port in range(20000, 32001):
93 try:
94 reservation.bind(("127.0.0.1", port))
95 break
96 except OSError:
97 continue
98 else:
99 raise AssertionError("no fixture HTTPS port available")
100 origin = "https://" + host + ":" + str(port)
101 try:
102 keycloak.request("/admin/realms/master/clients", "POST", {
103 "clientId": client_name, "protocol": "openid-connect", "publicClient": False,
104 "standardFlowEnabled": True, "secret": client_secret,
105 "redirectUris": [origin + "/-/callback"], "webOrigins": [origin],
106 })
107 clients = keycloak.request("/admin/realms/master/clients?clientId=" + client_name)
108 assert len(clients) == 1
109 client_id = clients[0]["id"]
110 authority = Path("/var/lib/caddy/.local/share/caddy/pki/authorities/local")
111 run("openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-keyout", str(root / "key.pem"),
112 "-out", str(root / "request.pem"), "-subj", "/CN=" + host,
113 "-addext", "subjectAltName=DNS:" + host + ",IP:127.0.0.1")
114 run("openssl", "x509", "-req", "-in", str(root / "request.pem"), "-CA", str(authority / "root.crt"),
115 "-CAkey", str(authority / "root.key"), "-set_serial", "0x" + marker,
116 "-out", str(root / "cert.pem"), "-days", "1", "-copy_extensions", "copyall")
117 config = root / "Caddyfile"
118 bridge = importlib.import_module("router").shale_mcp_routes(dashboard_port) if dashboard_port else []
119 upstream_port = backend_port
120 if dashboard_port:
121 drop = root / "drop-next-write"
122
123 class Proxy(http.server.BaseHTTPRequestHandler):
124 def log_message(self, *args):
125 pass
126
127 def do_GET(self):
128 with closing(http.client.HTTPConnection("127.0.0.1", backend_port, timeout=20)) as upstream:
129 upstream.request(self.command, self.path,
130 self.rfile.read(int(self.headers.get("Content-Length", 0))), dict(self.headers))
131 response = upstream.getresponse()
132 body = response.read()
133 if self.command == "POST" and drop.exists() and drop.read_text() == self.path:
134 drop.unlink()
135 self.close_connection = True
136 self.connection.shutdown(socket.SHUT_RDWR)
137 return
138 self.send_response(response.status)
139 for name, value in response.getheaders():
140 if name.lower() not in {"connection", "transfer-encoding", "content-length"}:
141 self.send_header(name, value)
142 self.send_header("Content-Length", str(len(body)))
143 self.end_headers()
144 self.wfile.write(body)
145
146 do_POST = do_GET
147
148 proxy = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Proxy)
149 proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True)
150 proxy_thread.start()
151 upstream_port = proxy.server_port
152 config.write_text("{\n admin off\n auto_https disable_redirects\n skip_install_trust\n}\n" + origin +
153 " {\n tls " + str(root / "cert.pem") + " " + str(root / "key.pem") +
154 "\n" + "\n".join(bridge) + "\n handle {\n reverse_proxy 127.0.0.1:" + str(upstream_port) + "\n }\n}\n")
155 binary = (Path("/proc") / run("systemctl", "show", "-P", "MainPID", "caddy") / "exe").resolve(strict=True)
156 with (root / "caddy.log").open("w") as output:
157 caddy = subprocess.Popen([str(binary), "run", "--config", str(config), "--adapter", "caddyfile"],
158 stdout=output, stderr=output,
159 env={**os.environ, "XDG_DATA_HOME": temporary, "XDG_CONFIG_HOME": temporary})
160 volumes = []
161 for directory, target in [("data", "/data"), ("owned", "/repositories_owned"), ("mirrors", "/repositories_mirrors"), ("cron", "/etc/crontabs")]:
162 path = root / directory
163 path.mkdir()
164 os.chown(path, 65534, 65534)
165 volumes.append("--volume=" + str(path) + ":" + target + ":rw")
166 run("podman", "run", "-d", "--name=" + container, "--pull=never", "--user=65534:65534",
167 "--read-only", "--cap-drop=all", "--security-opt=no-new-privileges", "--memory=512m", "--cpus=2", "--pids-limit=128",
168 "--tmpfs=/tmp:rw,noexec,nosuid,nodev,size=64m", "--publish=127.0.0.1:" + str(backend_port) + ":8000",
169 "--add-host=keycloak.studio.test:host-gateway", "--volume=/var/lib/studio/ca-bundle.crt:/etc/ssl/certs/ca-certificates.crt:ro",
170 "--env=DOMAIN=" + host + ":" + str(port), "--env=SERVER_TITLE=MCP fixture",
171 "--env=SESSION_SECRET=" + uuid.uuid4().hex + uuid.uuid4().hex,
172 "--env=OAUTH2_CLIENT=oidc,keycloak.studio.test/realms/master|" + client_name + "|" + client_secret,
173 "--env=NPROC=2", "--env=MIRRORS_DISABLE_CLONE=true", "--env=MIRRORS_DISABLE_PULL_POLL=true",
174 *(["--env=OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=" + traces_endpoint] if traces_env else []),
175 *volumes, image, *(["--otel-exporter-otlp-traces-endpoint", traces_endpoint] if traces_endpoint and not traces_env else []))
176 deployed = json.loads(run("podman", "inspect", container))[0]
177 assert deployed["ImageName"] == image
178 assert deployed["Config"]["User"] == "65534:65534"
179 context = ssl.create_default_context(cafile="/var/lib/studio/ca-bundle.crt")
180 from api import LoopbackHTTPS
181
182 class HTTPS(urllib.request.HTTPSHandler):
183 def https_open(self, request):
184 assert urllib.parse.urlsplit(request.full_url).netloc == urllib.parse.urlsplit(origin).netloc
185 return self.do_open(LoopbackHTTPS, request, context=context)
186
187 readiness = urllib.request.build_opener(HTTPS())
188 deadline = time.monotonic() + 15
189 while True:
190 try:
191 with readiness.open(origin, timeout=2) as response:
192 assert response.status == 200
193 break
194 except OSError:
195 if time.monotonic() >= deadline:
196 raise AssertionError((root / "caddy.log").read_text())
197 time.sleep(.1)
198 yield origin, root / "data/astheno.shale.db", container
199 except Exception:
200 if subprocess.run(["podman", "container", "exists", container], capture_output=True).returncode == 0:
201 state = json.loads(run("podman", "inspect", container))[0]["State"]
202 print(json.dumps({"fixture_backend_running": state["Running"], "fixture_backend_exit_code": state["ExitCode"],
203 "fixture_backend_oom_killed": state["OOMKilled"]}), flush=True)
204 raise
205 finally:
206 try:
207 subprocess.run(["podman", "rm", "--ignore", "--force", container], check=True, capture_output=True)
208 finally:
209 try:
210 if caddy:
211 caddy.terminate()
212 try:
213 caddy.wait(timeout=10)
214 except subprocess.TimeoutExpired:
215 caddy.kill()
216 caddy.wait(timeout=10)
217 finally:
218 if proxy:
219 proxy.shutdown()
220 proxy.server_close()
221 proxy_thread.join(timeout=5)
222 from api import Keycloak
223 keycloak = Keycloak(keycloak.host, importlib.import_module("dashboard-run").secret("get", "keycloak", "password"), attempts=1)
224 if client_id is None:
225 clients = keycloak.request("/admin/realms/master/clients?clientId=" + client_name)
226 assert len(clients) <= 1
227 client_id = clients[0]["id"] if clients else None
228 if client_id:
229 keycloak.request("/admin/realms/master/clients/" + client_id, "DELETE")
230
231
232
233def main():
234 parser = argparse.ArgumentParser()
235 parser.add_argument("--output", type=Path)
236 parser.add_argument("--image")
237 parser.add_argument("--traces-endpoint")
238 parser.add_argument("--traces-env", action="store_true")
239 args = parser.parse_args()
240 if args.output:
241 args.output.unlink(missing_ok=True)
242 assert not args.traces_env or args.traces_endpoint
243 if args.traces_endpoint:
244 endpoint = urllib.parse.urlsplit(args.traces_endpoint)
245 assert endpoint.scheme in {"http", "https"} and endpoint.hostname
246 assert not endpoint.username and not endpoint.password
247 repo = Path(__file__).resolve().parent.parent
248 image = args.image or re.search(r'^\s*image = "([^"]+)"', (repo / "service/shale/service.pkl").read_text(), re.MULTILINE).group(1)
249 sys.path.insert(0, str(repo / "tools"))
250 sys.path.insert(0, str(repo / "service/keycloak"))
251 from api import Keycloak, LoopbackHTTPS
252 keycloak_host = "keycloak.studio.test"
253 shale_host = "localhost"
254 context = ssl.create_default_context(cafile="/var/lib/studio/ca-bundle.crt")
255
256 class HTTPS(urllib.request.HTTPSHandler, urllib.request.HTTPHandler):
257 def http_open(self, request):
258 raise AssertionError("unencrypted fixture redirect")
259
260 def https_open(self, request):
261 parts = urllib.parse.urlsplit(request.full_url)
262 assert parts.netloc in {keycloak_host, urllib.parse.urlsplit(shale).netloc}
263 return self.do_open(LoopbackHTTPS, request, context=context)
264
265 class NoRedirect(urllib.request.HTTPRedirectHandler):
266 def redirect_request(self, req, fp, code, msg, headers, newurl):
267 return None
268
269 key = uuid.uuid4().hex
270 name = "mcp-fixture-" + key
271 password = uuid.uuid4().hex + "A1!"
272 keycloak = Keycloak(keycloak_host, importlib.import_module("dashboard-run").secret("get", "keycloak", "password"))
273 cookies = http.cookiejar.CookieJar()
274 opener = urllib.request.build_opener(HTTPS(), urllib.request.HTTPCookieProcessor(cookies))
275
276 def request(url, fields=None, headers=None, client=opener):
277 headers = dict(headers or {})
278 data = None
279 if fields is not None:
280 parts = urllib.parse.urlsplit(url)
281 headers.update(Origin=parts.scheme + "://" + parts.netloc, Referer=parts._replace(fragment="").geturl())
282 data = urllib.parse.urlencode(fields).encode()
283 req = urllib.request.Request(url, headers=headers, data=data)
284 try:
285 response = client.open(req, timeout=15)
286 except urllib.error.HTTPError as error:
287 response = error
288 with response:
289 return response.status, response.url, response.headers, response.read().decode()
290
291 def submit(url, form, changes, client=opener):
292 target = urllib.parse.urljoin(url, form.get("action", ""))
293 assert urllib.parse.urlsplit(target).netloc == urllib.parse.urlsplit(shale).netloc
294 fields = {a["name"]: a.get("value", "") for a in form["fields"] if a.get("name") and a.get("type") == "hidden"}
295 fields.update(timezone="UTC", tzoffset="+00:00", **changes)
296 return request(target, fields, client=client)
297
298 accounts = [(name, password), ("mcp-stranger-" + key, uuid.uuid4().hex + "A1!")]
299 try:
300 for username, credential in accounts:
301 keycloak.request("/admin/realms/master/users", "POST", {
302 "username": username, "firstName": username, "lastName": "Fixture", "email": username + "@fixture.invalid",
303 "emailVerified": True, "enabled": True,
304 "credentials": [{"type": "password", "value": credential, "temporary": False}],
305 })
306 users = keycloak.request("/admin/realms/master/users?username=" + username + "&exact=true")
307 assert len(users) == 1
308 with instance(keycloak, key, image, args.traces_endpoint, args.traces_env) as (shale, database, container):
309 def login(client, username, credential):
310 status, url, _, body = request(shale + "/-/login", client=client)
311 assert status == 200, status
312 form = next(f for f in Page(body).forms if any(a.get("name") == "password" for a in f["fields"]))
313 fields = {a["name"]: a.get("value", "") for a in form["fields"] if a.get("name")}
314 fields.update(username=username, password=credential)
315 status, url, _, _ = request(urllib.parse.urljoin(url, form["action"]), fields, client=client)
316 assert status == 200 and urllib.parse.urlsplit(url).hostname == shale_host
317
318 login(opener, *accounts[0])
319 stranger = urllib.request.build_opener(HTTPS(), urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
320 login(stranger, *accounts[1])
321 status, url, _, body = request(shale + "/-/settings")
322 assert status == 200, status
323 form = next(f for f in Page(body).forms if any(a.get("name") == "label" for a in f["fields"]))
324 assert form.get("enctype", "application/x-www-form-urlencoded") == "application/x-www-form-urlencoded"
325 status, _, _, body = submit(url, form, {"label": name})
326 if status != 200:
327 state = json.loads(subprocess.check_output(["podman", "inspect", container], text=True))[0]["State"]
328 with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) as db:
329 count = db.execute("SELECT count(*) FROM personal_tokens").fetchone()[0]
330 result = {"oidc_fixture_user": True, "mint_status": status,
331 "backend_exit_code": state["ExitCode"], "token_rows_after_response": count}
332 print(json.dumps(result), flush=True)
333 if args.output:
334 args.output.write_text(json.dumps(result, indent=2) + "\n")
335 raise AssertionError("token mint failed; write outcome recorded without replay")
336 with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) as db:
337 users = db.execute("SELECT id FROM users WHERE name=?", (name,)).fetchall()
338 assert len(users) == 1
339 identity = users[0][0]
340 tokens = db.execute("SELECT uuid FROM personal_tokens WHERE agent=? AND label=?", (identity, name)).fetchall()
341 assert len(tokens) == 1
342 token_id = tokens[0][0]
343 page = Page(body)
344 tokens = {text.strip() for text in [*page.codes, *[a.get("value", "") for f in page.forms for a in f["fields"]]]
345 if re.fullmatch(r"[A-Za-z0-9_:.=+-]{24,}", text.strip())}
346 assert len(tokens) == 1
347 token = next(iter(tokens))
348 anonymous = urllib.request.build_opener(HTTPS(), NoRedirect())
349 for authorization in ["Bearer " + token, "token " + token,
350 "Basic " + base64.b64encode((name + ":" + token).encode()).decode()]:
351 status, _, _, body = request(shale + "/-/settings", headers={"Authorization": authorization}, client=anonymous)
352 assert status == 303 and name not in body
353 status, url, _, body = request(shale + "/-/new")
354 assert status == 200
355 form = next(f for f in Page(body).forms if any(a.get("name") == "name" for a in f["fields"]))
356 status, url, _, body = submit(url, form, {"name": "fixture", "description": "Disposable MCP adapter test", "access": "private"})
357 assert status == 200, status
358 with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) as db:
359 assert db.execute("SELECT count(*) FROM repositories WHERE name='fixture' AND owner=?", (identity,)).fetchone()[0] == 1
360 repository_url = url
361 for client in (anonymous, stranger):
362 status, _, _, _ = request(repository_url, client=client)
363 assert status in (403, 404), status
364 issues = next(link for link in Page(body).links if "issues" in link)
365 status, url, _, body = request(urllib.parse.urljoin(repository_url, issues))
366 assert status == 200
367 new_issue = next(link for link in Page(body).links if link == "./new")
368 status, url, _, body = request(urllib.parse.urljoin(url, new_issue))
369 assert status == 200
370 title = "Fixture <angle> & Unicode ☃"
371 description = "Disposable body <script>fixture</script> & Unicode ☃"
372 form = next(f for f in Page(body).forms if any(a.get("name") == "title" for a in f["fields"]))
373 status, url, _, body = submit(url, form, {"title": title, "description": description})
374 assert status == 200, status
375 issue_url = url
376 with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) as db:
377 issues = db.execute("SELECT id,title FROM issues").fetchall()
378 assert len(issues) == 1 and issues[0][1] == title
379 assert db.execute("SELECT payload FROM issue_actions WHERE issue=?", (issues[0][0],)).fetchone()[0] == description
380 status, _, _, _ = request(issue_url, client=anonymous)
381 assert status in (403, 404), status
382 status, _, _, body = request(issue_url)
383 assert status == 200
384 page = Page(body)
385 comment = "Fixture comment <angle> & Unicode ☃"
386 form = next(f for f in page.forms if any(a.get("name") == "t" and a.get("value") == "comment" for a in f["fields"]))
387 status, _, _, body = submit(issue_url, form, {"comment": comment})
388 assert status == 200, status
389 with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) as db:
390 assert db.execute("SELECT count(*) FROM issue_actions WHERE payload=?", (comment,)).fetchone()[0] == 1
391 form = next(f for f in Page(body).forms if any(a.get("name") == "t" and a.get("value") == "status" for a in f["fields"]))
392 status_field = next(a for a in form["fields"] if a.get("name") == "status")
393 assert any(option["value"] == "done" for option in status_field["options"])
394 status, _, _, body = submit(issue_url, form, {"status": "done"})
395 assert status == 200
396 with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) as db:
397 assert db.execute("SELECT status FROM issues").fetchone()[0] == "done"
398 status, _, _, stranger_settings = request(shale + "/-/settings", client=stranger)
399 assert status == 200
400 stranger_csrf = {a["name"]: a["value"] for f in Page(stranger_settings).forms for a in f["fields"] if a.get("name") == "csrf_token"}
401 for client, csrf in [(anonymous, {}), (stranger, stranger_csrf)]:
402 status, _, _, _ = request(issue_url, {"t": "comment", "comment": "UNAUTHORIZED",
403 "timezone": "UTC", "tzoffset": "+00:00", **csrf}, client=client)
404 assert status in ((400, 403, 404) if client is anonymous else (403, 404)), status
405 with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) as db:
406 assert db.execute("SELECT count(*) FROM issue_actions WHERE payload='UNAUTHORIZED'").fetchone()[0] == 0
407 subprocess.run(["podman", "restart", container], check=True, capture_output=True)
408 deadline = time.monotonic() + 15
409 while True:
410 status, _, _, body = request(issue_url)
411 if status == 200:
412 break
413 assert time.monotonic() < deadline, status
414 time.sleep(.1)
415 assert "Unicode ☃" in body
416 for client in (anonymous, stranger):
417 status, _, _, _ = request(issue_url, client=client)
418 assert status in (403, 404), status
419 status, url, _, body = request(shale + "/-/settings")
420 form = next(f for f in Page(body).forms if any(a.get("name") == "label" for a in f["fields"]))
421 status, _, _, body = submit(url, form, {"label": "second"})
422 assert status == 200, status
423 form = next(f for f in Page(body).forms if any(a.get("name") == "t" and a.get("value") == "token_revoke" for a in f["fields"]))
424 status, _, _, _ = submit(url, form, {})
425 assert status == 200
426 with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) as db:
427 assert db.execute("SELECT count(*) FROM personal_tokens WHERE agent=?", (identity,)).fetchone()[0] == 1
428 assert db.execute("SELECT count(*) FROM personal_tokens WHERE uuid=?", (token_id,)).fetchone()[0] == 0
429 captured = http.cookiejar.CookieJar()
430 for cookie in cookies:
431 if cookie.domain.startswith("localhost"):
432 captured.set_cookie(copy.copy(cookie))
433 assert len(captured) > 0
434 replay = urllib.request.build_opener(HTTPS(), NoRedirect(), urllib.request.HTTPCookieProcessor(captured))
435 status, _, _, _ = request(issue_url, client=replay)
436 assert status == 200
437 status, _, _, _ = request(shale + "/-/logout")
438 assert status == 200
439 status, _, _, _ = request(issue_url)
440 assert status in (403, 404), status
441 replay_status, _, _, _ = request(issue_url, client=replay)
442 assert replay_status in (200, 303, 403, 404), replay_status
443 with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) as db:
444 session_rows = db.execute("SELECT count(*) FROM sessions WHERE user=?", (identity,)).fetchone()[0]
445 result = {"backend_image": image,
446 "backend_image_id": json.loads(subprocess.check_output(["podman", "inspect", container], text=True))[0]["Image"],
447 "traces_endpoint": args.traces_endpoint, "traces_environment_variable": args.traces_env,
448 "logout_cookie_replay_status": replay_status,
449 "backend_session_rows_after_logout": session_rows,
450 "checks": ["two_user_oidc", "personal_token_mint", "pat_not_html_auth", "private_repository_create",
451 "anonymous_read_denied", "other_user_read_denied", "issue_create_unicode", "comment_create_unicode",
452 "issue_close", "anonymous_write_denied", "other_user_write_denied", "session_survives_restart",
453 "second_token_mint", "token_revoke", "logout_denies_private_read",
454 "cross_user_denied_after_restart", "captured_session_valid_before_logout", "logout_cookie_replay_probe"]}
455 finally:
456 for username, _ in accounts:
457 users = keycloak.request("/admin/realms/master/users?username=" + username + "&exact=true")
458 for user in users:
459 assert user["username"] == username
460 keycloak.request("/admin/realms/master/users/" + user["id"], "DELETE")
461 result["checks"].append("fixture_cleanup")
462 print(json.dumps(result), flush=True)
463 if args.output:
464 args.output.write_text(json.dumps(result, indent=2) + "\n")
465
466
467if __name__ == "__main__":
468 main()
tools/dashboard-unit-test.py created+303
......@@ -0,0 +1,303 @@
1#!/usr/bin/env python3
2import argparse
3from contextlib import ExitStack
4import importlib
5import json
6import os
7from pathlib import Path
8import pwd
9import re
10import shutil
11import socket
12import subprocess
13import sys
14import tempfile
15import time
16import urllib.request
17import uuid
18
19import router
20
21
22def main():
23 parser = argparse.ArgumentParser()
24 parser.add_argument("--output", type=Path)
25 parser.add_argument("--browser-ready-file", type=Path)
26 parser.add_argument("--browser-timeout", type=int, default=180)
27 parser.add_argument("--relay-agent-dir", type=Path)
28 args = parser.parse_args()
29 assert 1 <= args.browser_timeout <= 600
30 repo = Path(__file__).resolve().parent.parent
31 nix = ["nix", "--extra-experimental-features", "nix-command flakes"]
32 base = "path:" + str(repo) + "#nixosConfigurations.vm.config"
33
34 def shell(*argv, timeout=60):
35 result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
36 if result.returncode:
37 raise AssertionError(result.stderr)
38 return result.stdout
39
40 units = {name: shell(*nix, "eval", base + '.systemd.units."' + name + '.service".text',
41 "--offline", "--raw", "--option", "eval-cache", "false")
42 for name in ["studio-dashboard", "studio-host"]}
43 permissions = [rule for rule in json.loads(shell(*nix, "eval", base + ".systemd.tmpfiles.rules",
44 "--offline", "--json", "--option", "eval-cache", "false"))
45 if rule.startswith("A+ /srv/prod/yt-feed/data ")]
46 assert len(permissions) == 1, permissions
47 scripts = re.findall(r"^Exec(?:Start|StartPre|StartPost)=(/nix/store/\S+)$", units["studio-dashboard"], re.M)
48 assert len(scripts) == 3, scripts
49 shell(*nix, "build", *[base + '.systemd.units."' + name + '.service".unit'
50 for name in units], "--offline", "--no-link", timeout=1200)
51 try:
52 account = pwd.getpwnam("studio-dashboard")
53 except KeyError:
54 shell("useradd", "--system", "--user-group", "--no-create-home", "studio-dashboard")
55 account = pwd.getpwnam("studio-dashboard")
56 assert account.pw_uid != 0 and account.pw_gid != 0
57 app_unit = "studio-dashboard-unit-test"
58 host_unit = "studio-host-unit-test"
59 container = "studio-dashboard-unit-test"
60 unit_files = [Path("/run/systemd/system/" + name + ".service") for name in [app_unit, host_unit]]
61 assert not any(file.exists() for file in unit_files), unit_files
62 assert not json.loads(shell("podman", "ps", "--all", "--filter=name=^" + container + "$", "--format=json"))
63 proof = uuid.uuid4().hex + uuid.uuid4().hex
64
65 with tempfile.TemporaryDirectory(prefix="studio-dashboard-unit-", dir="/run") as temporary, ExitStack() as fixtures:
66 root = Path(temporary)
67 library = root / "clover"
68 season = library / "Media/jellyfin/Indie Shows/Fixture/Season 1"
69 config = library / "Documents/Config/Youtube Downloader"
70 config.mkdir(parents=True)
71 season.mkdir(parents=True)
72 (config / "feed.yaml").write_text("shows: []\n")
73 (season / "S01E01 - Fixture.mp4").write_bytes(b"fixture")
74 (season / "S01E01 - Fixture.nfo").write_text("<episodedetails><title>Unit fixture</title></episodedetails>")
75 for directory in [library, *[p for p in library.rglob("*") if p.is_dir()]]:
76 os.chown(directory, 3000, 3000)
77 directory.chmod(0o2770)
78 data = root / "data"
79 data.mkdir(mode=0o700)
80 (data / "existing.json").write_text('{"fixture":true}\n')
81 state = root / "yt-state"
82 (state / "nested").mkdir(parents=True)
83 (state / "existing.json").write_text('{"fixture":true}\n')
84 for path in [state, *state.rglob("*")]:
85 os.chown(path, 3118, 3118)
86 path.chmod(0o750 if path.is_dir() else 0o640)
87 token = root / "proxy.token"
88 token.write_text(proof)
89 readonly = root / "nomad.token"
90 readonly.write_bytes(Path("/var/lib/studio/dashboard.token").read_bytes())
91 ca = root / "ca.crt"
92 ca.write_bytes(Path("/var/lib/studio/ca-bundle.crt").read_bytes())
93 ca.chmod(0o444)
94 acl = root / "permissions.conf"
95 acl.write_text(permissions[0].replace("/srv/prod/yt-feed/data", str(state)) + "\n")
96 with socket.socket() as reservation:
97 reservation.bind(("127.0.0.1", 0))
98 port = reservation.getsockname()[1]
99 sys.path.insert(0, str(repo / "service/keycloak"))
100 from api import Keycloak
101 keycloak = Keycloak("keycloak.studio.test", importlib.import_module("dashboard-run").secret("get", "keycloak", "password"), attempts=1)
102 shale_image = re.search(r'^\s*image = "([^"]+)"', (repo / "service/shale/service.pkl").read_text(), re.M).group(1)
103 shale_origin, shale_database, shale_container = fixtures.enter_context(importlib.import_module("dashboard-shale-test").instance(
104 keycloak, uuid.uuid4().hex, shale_image, None, False, host="shale.studio.test", dashboard_port=port))
105 with socket.socket() as reservation:
106 for gateway_port in range(20000, 32001):
107 try:
108 reservation.bind(("0.0.0.0", gateway_port))
109 break
110 except OSError:
111 continue
112 else:
113 raise AssertionError("no fixture gateway port available")
114 os.environ.update(STUDIO_DOMAIN="studio.test", STUDIO_INTERNAL_PORT=str(gateway_port),
115 STUDIO_DASHBOARD_PORT=str(port), STUDIO_PROXY_TOKEN_FILE=str(token))
116 rendered = router.render(Path(router.TOKEN).read_text().strip())
117 gateway = rendered[rendered.index(f"dashboard.internal.studio.test:{gateway_port} {{"):]
118 gateway_config = root / "Caddyfile"
119 gateway_config.write_text("{\n admin off\n auto_https disable_redirects\n skip_install_trust\n}\n" + gateway +
120 f"\nglobe.studio.test:{gateway_port} {{\n tls internal\n reverse_proxy 127.0.0.1:{port} {{\n header_up Host globe.studio.test\n }}\n}}\n")
121 gateway_config.chmod(0o600)
122 caddy_binary = (Path("/proc") / shell("systemctl", "show", "-P", "MainPID", "caddy").strip() / "exe").resolve(strict=True)
123 caddy = None
124 replacements = {
125 "https://dashboard.internal.studio.test:8448": f"https://dashboard.internal.studio.test:{gateway_port}",
126 "--name=studio-dashboard ": "--name=" + container + " ",
127 "--volume=/run/studio-host:/run/studio-host:ro": "--volume=/run/" + host_unit + ":/run/studio-host:ro",
128 "/var/lib/studio/dashboard-proxy.token": str(token),
129 "/var/lib/studio/dashboard.token": str(readonly),
130 "/var/lib/studio/ca-bundle.crt": str(ca),
131 "/var/lib/studio/dashboard": str(data),
132 "/srv/prod/yt-feed/data": str(state),
133 "/srv/clover": str(library),
134 "--prefix=" + str(state): "--prefix=" + str(state) + " " + str(acl),
135 "--publish=127.0.0.1:7072:7072": f"--publish=127.0.0.1:{port}:7072",
136 "http://127.0.0.1:7072/": f"http://127.0.0.1:{port}/",
137 }
138 text = units["studio-dashboard"]
139 for source in scripts:
140 content = Path(source).read_text()
141 # Only host mount sources move; container paths retain the generated unit's contract.
142 for original, replacement in replacements.items():
143 if original in ["/srv/prod/yt-feed/data", "/srv/clover"]:
144 content = content.replace("test -d " + original, "test -d " + replacement)
145 content = content.replace("test -d '" + original, "test -d '" + replacement)
146 content = content.replace("src=" + original, "src=" + replacement)
147 content = content.replace("--volume=" + original + ":", "--volume=" + replacement + ":")
148 content = content.replace("--prefix=" + original, "--prefix=" + replacement)
149 else:
150 content = content.replace(original, replacement)
151 destination = root / Path(source).name
152 destination.write_text(content)
153 destination.chmod(0o700)
154 text = text.replace(source, str(destination))
155 text = text.replace("https://dashboard.internal.studio.test:8448", f"https://dashboard.internal.studio.test:{gateway_port}")
156 text = text.replace('Environment="STUDIO_SHALE_URL=https://shale.studio.test"', 'Environment="STUDIO_SHALE_URL=' + shale_origin + '"')
157 text = re.sub(r'^Environment="STUDIO_INDEX_POOL=.*"\n', "", text, flags=re.M)
158 text = text.replace("studio-host.service", host_unit + ".service").replace("studio-router.service", "")
159 text = text.replace("--time=8 studio-dashboard", "--time=8 " + container).replace("--force studio-dashboard", "--force " + container)
160 for original in ["/var/lib/studio/dashboard.token", "/var/lib/studio/ca-bundle.crt"]:
161 text = text.replace("ConditionPathExists=" + original, "ConditionPathExists=" + replacements[original])
162 unit_files[0].write_text(text)
163 host_text = units["studio-host"].replace("RuntimeDirectory=studio-host", "RuntimeDirectory=" + host_unit).replace("StateDirectory=studio/host", "StateDirectory=studio/host-unit-test")
164 host_text = host_text.replace("[Service]\n", "[Service]\n" + f'Environment="STUDIO_HOST_SOCKET=/run/{host_unit}/host.sock"\nEnvironment="STUDIO_HOST_STATE_ROOT=/var/lib/studio/host-unit-test"\n')
165 unit_files[1].write_text(host_text)
166 try:
167 with (root / "caddy.log").open("w") as log:
168 caddy = subprocess.Popen([str(caddy_binary), "run", "--config", str(gateway_config), "--adapter", "caddyfile"],
169 stdout=log, stderr=log, env={**os.environ, "XDG_DATA_HOME": str(root), "XDG_CONFIG_HOME": str(root)})
170 gateway_ca = root / "caddy/pki/authorities/local/root.crt"
171 deadline = time.monotonic() + 15
172 while not gateway_ca.exists():
173 if caddy.poll() is not None or time.monotonic() > deadline:
174 raise AssertionError("fixture gateway did not start")
175 time.sleep(.1)
176 with ca.open("ab") as bundle:
177 bundle.write(gateway_ca.read_bytes())
178 shell("systemctl", "daemon-reload")
179 shell("systemctl", "start", app_unit, timeout=180)
180 info = json.loads(shell("podman", "inspect", container))[0]
181 assert info["Config"]["User"] == f"{account.pw_uid}:{account.pw_gid}", info["Config"]["User"]
182 host = info["HostConfig"]
183 assert host["ReadonlyRootfs"] and not host["Privileged"] and not host["Devices"]
184 assert host["NetworkMode"] != "host" and host["PidsLimit"] == 256
185 assert host["Memory"] == 2 * 1024 ** 3 and host["NanoCpus"] == 4 * 10 ** 9
186 assert "no-new-privileges" in host["SecurityOpt"]
187 mounts = {mount["Destination"]: mount for mount in info["Mounts"]}
188 sources = {"/data": data, "/run/studio-host": Path("/run/" + host_unit),
189 "/run/secrets/dashboard-proxy.token": token, "/run/secrets/dashboard-nomad.token": readonly,
190 "/run/secrets/ca-bundle.crt": ca, "/srv/clover": library,
191 "/srv/clover/Media": library / "Media", "/srv/prod/yt-feed/data": state}
192 assert mounts.keys() == sources.keys(), mounts.keys()
193 for target, source in sources.items():
194 assert mounts[target]["Source"] == str(source), mounts[target]
195 assert mounts[target]["RW"] == (target in ["/data", "/srv/clover", "/srv/prod/yt-feed/data"]), mounts[target]
196 for namespace in ["net", "pid", "mnt"]:
197 assert Path(f"/proc/{info['State']['Pid']}/ns/{namespace}").stat().st_ino != Path(f"/proc/self/ns/{namespace}").stat().st_ino
198 status = shell("podman", "exec", container, "/bin/cat", "/proc/1/status")
199 assert f"Uid:\t{account.pw_uid}\t{account.pw_uid}\t{account.pw_uid}\t{account.pw_uid}" in status
200 assert all(field + ":\t0000000000000000" in status for field in ["CapEff", "CapPrm", "CapBnd", "CapAmb"])
201 for denied in ["/var/lib/studio/nomad.token", "/run/podman/podman.sock", "/run/libvirt/libvirt-sock", "/dev/zfs", "/opt/studio/current", "/srv/vm", "/srv/prod/keycloak"]:
202 result = subprocess.run(["podman", "exec", container, "/bin/test", "-e", denied], capture_output=True)
203 assert result.returncode != 0, denied
204 assert data.stat().st_uid == account.pw_uid and (data / "existing.json").stat().st_uid == account.pw_uid
205 python = next(value.split("=", 1)[1] for value in info["Config"]["Env"] if value.startswith("STUDIO_YT_PYTHON="))
206 shell("podman", "exec", container, python, "-c", "import ssl; assert ssl.create_default_context().cert_store_stats()['x509_ca'] > 0")
207 refused = "import socket; s=socket.socket(socket.AF_UNIX); s.connect('/run/studio-host/host.sock'); " + "\ntry:\n s.sendall(b'{\"operation\":\"host.sample\"}\\n'); assert s.recv(4) == b''\nexcept (BrokenPipeError, ConnectionResetError):\n pass\n"
208 shell("podman", "exec", f"--user=65534:{account.pw_gid}", container, python, "-c", refused)
209 shell("podman", "exec", container, python, "-c", "from pathlib import Path; p=Path('/srv/prod/yt-feed/data'); (p/'existing.json').write_text('fixture'); (p/'nested/new.json').write_text('fixture')")
210 assert (state / "existing.json").stat().st_uid == 3118 and state.stat().st_uid == 3118
211 assert (state / "nested/new.json").exists()
212 shell("podman", "exec", "--user=3118:3000", container, python, "-c", "from pathlib import Path; p=Path('/srv/prod/yt-feed/data'); (p/'nested/new.json').open('a').write('service'); (p/'nested/service.json').write_text('service')")
213 shell("podman", "exec", container, python, "-c", "from pathlib import Path; p=Path('/srv/prod/yt-feed/data/nested/service.json'); assert p.read_text() == 'service'; p.open('a').write('dashboard')")
214 assert subprocess.run(["podman", "exec", container, "/bin/touch", "/srv/clover/Media/escaped"], capture_output=True).returncode != 0
215
216 def get(path):
217 request = urllib.request.Request(f"http://127.0.0.1:{port}" + path, headers={
218 "Studio-Proxy-Token": proof, "User-Name": "fixture", "User-Groups": "infra-admin",
219 })
220 with urllib.request.urlopen(request, timeout=30) as response:
221 return json.load(response)
222
223 machine = get("/api/host")
224 assert machine["cores"] > 0 and machine["memory"] > 2 * 1024 ** 3
225 assert get("/api/media/list?path=jellyfin/Indie%20Shows/Fixture/Season%201")["entries"]
226 assert get("/api/youtube/library")
227 assert get("/api/youtube")
228 iam_output = root / "iam-checks.json"
229 shell(sys.executable, str(repo / "tools/dashboard-iam-test.py"), "--url", f"http://127.0.0.1:{port}",
230 "--socket", f"/run/{host_unit}/host.sock", "--proof-file", str(token),
231 "--output", str(iam_output), timeout=180)
232 iam_checks = json.loads(iam_output.read_text())
233 mcp_output = root / "mcp-checks.json"
234 shell(sys.executable, str(repo / "tools/dashboard-mcp-test.py"), "--url", f"http://127.0.0.1:{port}",
235 "--proof-file", str(token), "--restart-unit", app_unit,
236 "--output", str(mcp_output), timeout=240)
237 mcp_checks = json.loads(mcp_output.read_text())
238 relay_output = root / "relay-checks.json"
239 shell(sys.executable, str(repo / "tools/dashboard-relay-test.py"), "--url", f"http://127.0.0.1:{port}",
240 "--proof-file", str(token), "--data-dir", str(data), "--restart-unit", app_unit,
241 "--output", str(relay_output), *(["--agent-dir", str(args.relay_agent_dir), "--agent-origin", f"https://globe.studio.test:{gateway_port}", "--agent-ca", str(gateway_ca)] if args.relay_agent_dir else []), timeout=240)
242 relay_checks = json.loads(relay_output.read_text())
243 shale_output = root / "shale-link-checks.json"
244 shell(sys.executable, str(repo / "tools/dashboard-shale-link-test.py"), "--url", f"http://127.0.0.1:{port}",
245 "--proof-file", str(token), "--data-dir", str(data), "--restart-unit", app_unit,
246 "--shale-origin", shale_origin, "--shale-database", str(shale_database), "--shale-container", shale_container,
247 "--output", str(shale_output), timeout=240)
248 shale_checks = json.loads(shale_output.read_text())
249 worker = "from pathlib import Path; found=[]\nfor p in Path('/proc').iterdir():\n if p.name.isdigit():\n try:\n argv=(p/'cmdline').read_bytes().rstrip(b'\\0').split(b'\\0')\n if argv[1:] == [b'server/youtube-worker.py']:\n found.append((p/'status').read_text())\n except (FileNotFoundError, PermissionError):\n pass\nassert len(found) == 1, len(found)\n" + f"assert 'Uid:\\t{account.pw_uid}\\t{account.pw_uid}\\t{account.pw_uid}\\t{account.pw_uid}' in found[0]\nassert 'CapEff:\\t0000000000000000' in found[0] and 'NoNewPrivs:\\t1' in found[0]\n"
250 shell("podman", "exec", container, python, "-c", worker)
251 socket_inode = Path("/run/" + host_unit).stat().st_ino
252 shell("systemctl", "restart", host_unit)
253 assert Path("/run/" + host_unit).stat().st_ino == socket_inode
254 time.sleep(1.1)
255 assert get("/api/host")["memory"] == machine["memory"]
256 started = time.monotonic()
257 shell("systemctl", "restart", app_unit, timeout=180)
258 restart_seconds = time.monotonic() - started
259 assert restart_seconds < 15, restart_seconds
260 assert get("/api/me")["name"] == "fixture" and (data / "existing.json").exists()
261 browser_checks = None
262 if args.browser_ready_file:
263 browser_checks = json.loads(shell(sys.executable, str(repo / "tools/dashboard-browser-test.py"),
264 "--ready-file", str(args.browser_ready_file), "--timeout", str(args.browser_timeout), timeout=args.browser_timeout + 60))
265 started = time.monotonic()
266 shell("systemctl", "stop", app_unit, timeout=30)
267 elapsed = time.monotonic() - started
268 assert elapsed < 15 and shell("systemctl", "show", "-P", "Result", app_unit).strip() == "success"
269 result = {"image": info["ImageName"], "generated_nixos_unit": True, "dedicated_uid": account.pw_uid, "readonly_rootfs": True,
270 "zero_capabilities": True, "quotas": True, "private_network": True, "bounded_mounts": True,
271 "host_broker_uid_auth": True, "other_uid_same_group_refused": True,
272 "state_ownership_migration": True, "shared_youtube_acl": True,
273 "youtube_owner_preserved": True, "readonly_media": True, "unprivileged_youtube_worker": True,
274 "python_public_tls_roots": True,
275 "iam": iam_checks, "mcp": mcp_checks, "relay": relay_checks, "shale_link": shale_checks,
276 "broker_restart_socket_preserved": True, "container_restart_state_preserved": True,
277 "cached_image_restart_seconds": round(restart_seconds, 2),
278 "systemd_stop_seconds": round(elapsed, 2)}
279 if browser_checks is not None:
280 result["browser_route_fixture"] = browser_checks
281 if args.output:
282 args.output.parent.mkdir(parents=True, exist_ok=True)
283 args.output.write_text(json.dumps(result, indent=2) + "\n")
284 print(json.dumps(result))
285 finally:
286 subprocess.run(["systemctl", "stop", app_unit, host_unit], capture_output=True, timeout=60)
287 subprocess.run(["podman", "rm", "--ignore", "--force", container], capture_output=True, timeout=15)
288 if caddy is not None:
289 caddy.terminate()
290 try:
291 caddy.wait(timeout=10)
292 except subprocess.TimeoutExpired:
293 caddy.kill()
294 caddy.wait(timeout=5)
295 for file in unit_files:
296 file.unlink(missing_ok=True)
297 for directory in ["/var/lib/studio/host-unit-test", "/run/" + host_unit]:
298 shutil.rmtree(directory, ignore_errors=True)
299 shell("systemctl", "daemon-reload")
300
301
302if __name__ == "__main__":
303 main()
tools/dashboard-vm-boundary-test.py created+164
......@@ -0,0 +1,164 @@
1#!/usr/bin/env python3
2import argparse
3import json
4import os
5from pathlib import Path
6import re
7import subprocess
8import sys
9import time
10import uuid
11import xml.etree.ElementTree as ET
12
13
14def main():
15 parser = argparse.ArgumentParser()
16 parser.add_argument("socket")
17 parser.add_argument("--images", type=Path, required=True)
18 parser.add_argument("--output", type=Path)
19 args = parser.parse_args()
20 client = Path(__file__).with_name("dashboard-host-vm-test.py")
21
22 def call(operation, payload=None):
23 request = {"operation": "vm." + operation}
24 if payload is not None:
25 request["payload"] = payload
26 result = subprocess.run([sys.executable, str(client), args.socket, "--client"],
27 input=json.dumps(request).encode() + b"\n", capture_output=True)
28 assert result.returncode == 0, result.stderr.decode()
29 return json.loads(result.stdout)
30
31 name = "boundary-" + uuid.uuid4().hex
32 existing = set(subprocess.check_output(["virsh", "list", "--all", "--name"], text=True).split())
33 spec = {"name": name, "description": "isolated VM fixture", "image": "blank", "vcpus": 1,
34 "memory": 2**29, "disk": 2**30, "autostart": False, "start": False}
35 node = call("node")["value"]
36 for field, value in [("vcpus", node["cpus"] + 1), ("memory", node["memory"] + 1), ("disk", 2**64),
37 ("vcpus", True), ("image", "../escape.iso"), ("name", "../escape"), ("xml", "<domain/>")]:
38 assert call("create", {**spec, field: value})["status"] == 400, (field, value)
39 assert call("act", {"name": name, "action": ["start"]})["status"] == 400
40 assert call("update", {"name": name})["status"] == 400
41 assert call("images")["value"][0]["volume"] == "blank"
42 images = args.images
43 created_images = not images.exists()
44 images.mkdir(parents=True, exist_ok=True)
45 source = images / (name + ".qcow2")
46 backing = images / (name + "-backed.qcow2")
47 external = images / (name + "-external.qcow2")
48 unsupported = images / (name + "-unsupported.img")
49 installer = images / (name + ".iso")
50 secret = Path("/run/" + name + "-outside-image-directory")
51 secret.write_bytes(b"synthetic private fixture\n" * 4096)
52 secret.chmod(0o600)
53 inactive = re.search(r"^Active:\s+no", subprocess.check_output(["virsh", "net-info", "default"], text=True), re.MULTILINE)
54 try:
55 subprocess.run(["qemu-img", "create", "-f", "qcow2", "-F", "raw", "-b", str(secret), str(backing)], check=True, capture_output=True)
56 rejected = call("create", {**spec, "image": backing.name})
57 assert rejected["status"] == 400 and "standalone" in rejected["error"], rejected
58 assert not (Path("/srv/vm") / name).exists()
59 subprocess.run(["qemu-img", "create", "-f", "qcow2", "-o", "data_file=" + str(secret) + ",data_file_raw=on", str(external), "1048576"], check=True, capture_output=True)
60 assert call("create", {**spec, "image": external.name})["status"] == 400
61 subprocess.run(["qemu-img", "create", "-f", "vmdk", str(unsupported), "1048576"], check=True, capture_output=True)
62 assert call("create", {**spec, "image": unsupported.name})["status"] == 400
63 subprocess.run(["qemu-img", "create", "-f", "qcow2", str(source), str(2**30)], check=True, capture_output=True)
64 assert call("create", {**spec, "image": source.name}) == {"value": None}
65 directory = Path("/srv/vm") / name
66 assert directory.is_dir() and (directory / "disk.qcow2").is_file()
67 assert not (directory / "source-image").exists()
68 description = "--config <literal description>"
69 assert call("update", {"name": name, "description": description, "autostart": True}) == {"value": None}
70 vm = next(vm for vm in call("domains")["value"] if vm["name"] == name)
71 assert vm["description"] == description and vm["autostart"], vm
72 assert call("act", {"name": name, "action": "start"}) == {"value": None}
73 assert name in call("stats")["value"]
74 subprocess.run(["virsh", "suspend", name], check=True, capture_output=True)
75 assert call("act", {"name": name, "action": "resume"}) == {"value": None}
76 assert call("act", {"name": name, "action": "destroy"}) == {"value": None}
77 assert call("remove", {"name": name, "disks": True}) == {"value": None}
78 assert not directory.exists()
79 installer.write_bytes(b"synthetic installer fixture\n" * 1024)
80 interrupted = """import json, sys, time
81sys.path.insert(0, sys.argv[1])
82import vms
83spec = json.load(sys.stdin)
84if sys.argv[2] == 'copy':
85 def copy(incoming, outgoing):
86 outgoing.write(incoming.read(1024))
87 outgoing.flush()
88 time.sleep(60)
89 vms.shutil.copyfileobj = copy
90else:
91 original = vms.virsh
92 def virsh(*args):
93 result = original(*args)
94 if args[0] == 'define':
95 raise OSError('fixture connection lost after definition')
96 return result
97 vms.virsh = virsh
98vms.validate('create', spec)
99try:
100 vms.create(spec)
101except OSError as error:
102 print(str(error), file=sys.stderr)
103 sys.exit(1)
104raise AssertionError('interrupted creation reported success')
105"""
106 for phase in ["copy", "define"]:
107 started = time.monotonic()
108 attempt = subprocess.run([sys.executable, "-c", interrupted, str(Path(__file__).parent), phase],
109 input=json.dumps({**spec, "image": installer.name if phase == "copy" else "blank"}),
110 capture_output=True, text=True, timeout=60,
111 env={**os.environ, "STUDIO_VM_IMAGES_ROOT": str(images)})
112 elapsed = time.monotonic() - started
113 assert attempt.returncode == 1, attempt.stderr
114 if phase == "copy":
115 assert "preparation timed out" in attempt.stderr and elapsed < 60, (elapsed, attempt.stderr)
116 assert not directory.exists() and name not in {vm["name"] for vm in call("domains")["value"]}
117 copy_timeout_seconds = round(elapsed, 2)
118 else:
119 assert "connection lost" in attempt.stderr and (directory / "disk.qcow2").is_file(), attempt.stderr
120 assert name in {vm["name"] for vm in call("domains")["value"]}
121 assert call("remove", {"name": name, "disks": True}) == {"value": None}
122 assert not directory.exists()
123 assert installer.read_bytes() == b"synthetic installer fixture\n" * 1024
124 assert call("create", {**spec, "image": installer.name}) == {"value": None}
125 xml = ET.fromstring(subprocess.check_output(["virsh", "dumpxml", name], text=True))
126 cdrom = xml.find("./devices/disk[@device='cdrom']")
127 assert cdrom.find("driver").get("type") == "raw"
128 assert cdrom.find("source").get("file") == str(directory / "installer.iso")
129 assert call("remove", {"name": name, "disks": True}) == {"value": None}
130 directory.symlink_to(secret.parent, target_is_directory=True)
131 try:
132 assert call("remove", {"name": name, "disks": True})["status"] == 400
133 assert secret.exists()
134 finally:
135 directory.unlink()
136 assert set(subprocess.check_output(["virsh", "list", "--all", "--name"], text=True).split()) == existing
137 result = {"vm_read_queries": "passed", "vm_resource_and_field_bounds": "passed",
138 "vm_create_start_resume_stop_remove": "passed", "literal_description": "passed",
139 "standalone_image_conversion": "passed", "external_backing_file_rejection": "passed",
140 "external_data_file_and_format_rejection": "passed", "pinned_raw_installer": "passed",
141 "symlink_disk_directory_rejection": "passed", "interrupted_copy_cleanup": "passed",
142 "copy_timeout_seconds": copy_timeout_seconds, "uncertain_definition_preserves_disks": "passed",
143 "existing_domains_preserved": "passed"}
144 if args.output:
145 args.output.write_text(json.dumps(result, indent=2) + "\n")
146 print(json.dumps(result))
147 finally:
148 subprocess.run(["virsh", "destroy", name], capture_output=True)
149 subprocess.run(["virsh", "undefine", name], capture_output=True)
150 directory = Path("/srv/vm") / name
151 if directory.is_dir() and not directory.is_symlink():
152 for file in directory.iterdir():
153 file.unlink()
154 directory.rmdir()
155 for file in [source, backing, external, unsupported, installer, secret]:
156 file.unlink(missing_ok=True)
157 if created_images and not any(images.iterdir()):
158 images.rmdir()
159 if inactive and not subprocess.check_output(["virsh", "list", "--name"], text=True).strip():
160 subprocess.run(["virsh", "net-destroy", "default"], check=True, capture_output=True)
161
162
163if __name__ == "__main__":
164 main()
tools/data-restore.md created+27
......@@ -0,0 +1,27 @@
1# Production data rollback
2
3Every promotion and code rollback backs up the active release before switching it. One ZFS snapshot operation captures the mounted service datasets and PostgreSQL data at the same instant. A temporary clone of that PostgreSQL snapshot runs without a network and produces the service-owned database dumps, including services with multiple database inputs. The clone is destroyed before the backup is published. `python3 tools/deploy.py backups` lists the backup IDs and services. A failed promotion can leave a completed backup even when release history has no new entry.
4
5The backup now also saves a private `nomad.snap` and its SHA-256 in the manifest. A disposable backup run against the VM's live Nomad server verified the file and digest, then removed it. This snapshot includes jobs, ACLs, and secret variables; keep the backup directory private. Service data restore does not apply the whole Nomad snapshot, which is reserved for an explicit cluster recovery. The backup directory is still on the OS disk until Snow Globe control state has durable storage.
6
7At inspection, the VM retained 25 release backups: their PostgreSQL dumps and manifests used `275 MB`, and their service snapshots numbered 550. No automatic retention policy deletes these backups yet; choose one before long-running production use. Earlier backup manifests do not contain `nomad.snap`.
8
9To restore one service, roll its code back first, then run:
10
11```sh
12python3 tools/deploy.py backups
13python3 tools/deploy.py rollback <old-release-id>
14python3 tools/deploy.py data-restore <service-id> --backup <backup-id> --discard-writes
15```
16
17The backup's `fromRelease` must match the active release. Data restore stops that service, saves its current dataset and database as a safety copy, restores the chosen ZFS snapshot and PostgreSQL dump, then deploys and checks the service. **Changes written after the chosen backup are discarded from the live service.** The safety snapshot and dump remain on the host. Postgres itself is shared; restore its owner services individually. Shared Clover and Media mounts are outside this service restore; restoring an entire shared dataset would discard unrelated users' writes.
18
19The CLI checks that the backup snapshot and database dumps exist before stopping the service. A disposable missing-snapshot manifest on the VM was rejected while Redis Insight stayed running; the fixture was removed.
20
21On the VM, backup `20260927T030206Z-67f38b` captured 22 service snapshots and four database dumps from a cloned PostgreSQL snapshot. All dump hashes and snapshots verified; the temporary container and dataset were removed while the production PostgreSQL allocation stayed running. An injected dump failure after clone startup left the ZFS dataset/snapshot list and backup directory list unchanged, with no probe container. Earlier rollback and restore tests are recorded in the release history.
22
23# VM restore rehearsal
24
25On 2026-09-27, backup `20260927T033539Z-123cab` captured the running x86 VM's service datasets and PostgreSQL databases. A throwaway file and table were then added to `evil-hedgedoc`. Running `data.py restore 20260927T033539Z-123cab evil-hedgedoc --discard-writes` stopped the job, saved the pre-restore safety snapshot, restored the dataset and database, and redeployed it. Both probes disappeared, `https://md.evil.studio.test/_health` returned 200 from the Mac, and all 19 VM routes passed their health checks. This proves the explicit data restore path on the VM; the production NAS cutover remains separate.
26
27The `20260927T055034Z-27d895` backup retained all 22 service snapshots and four matching PostgreSQL dumps. Its Dawarich dump restored into a new database owned by `svc_dawarich` in an isolated PostgreSQL ZFS clone, with `pgcrypto` and `postgis` created first and `pg_restore --role=svc_dawarich`. The restored `points` table contained 22,666 rows. The probe container and clone were removed, the live PostgreSQL allocation stayed running, and Dawarich still returned HTTP 200.
tools/data.py created+302
......@@ -0,0 +1,302 @@
1#!/usr/bin/env python3
2import argparse
3from contextlib import contextmanager
4from datetime import datetime, timezone
5import hashlib
6import json
7import os
8from pathlib import Path
9import re
10import secrets
11import shutil
12import subprocess
13import time
14
15
16ROOT = Path("/opt/studio")
17STATE = Path("/var/lib/studio")
18BACKUPS = STATE / "backups"
19BACKUP_ID = re.compile(r"\d{8}T\d{6}Z-[0-9a-f]{6}\Z")
20SERVICE_ID = re.compile(r"[a-z][a-z0-9-]*\Z")
21RELEASE_ID = re.compile(r"[0-9a-f]{16}\Z")
22PODMAN = ["podman", "--url", "unix:///run/podman/podman.sock"]
23
24
25def run(*args, capture=False, **kwargs):
26 return subprocess.run(args, check=True, capture_output=capture, **kwargs)
27
28
29def current_release():
30 current = ROOT / "current"
31 if not current.is_symlink():
32 raise ValueError("No active home server release")
33 release = current.resolve()
34 if release.parent != ROOT / "releases" or not RELEASE_ID.fullmatch(release.name):
35 raise ValueError("Active home server release is invalid")
36 return release
37
38
39def postgres_container():
40 allocations = json.loads(run("nomad", "job", "allocs", "-json", "postgres", capture=True, text=True).stdout)
41 running = [a["ID"] for a in allocations if a["ClientStatus"] == "running" and a["DesiredStatus"] == "run"]
42 if len(running) != 1:
43 raise ValueError("Postgres needs one running allocation")
44 containers = run(*PODMAN, "ps", "--format", "{{.ID}} {{.Names}}", capture=True, text=True).stdout.splitlines()
45 matches = [parts[0] for line in containers if len(parts := line.split()) == 2 and parts[1].endswith(running[0])]
46 if len(matches) != 1:
47 raise ValueError("Postgres container is unavailable")
48 return matches[0]
49
50
51def database_inputs(service):
52 prefix = f"nomad/jobs/{service}/inputs/"
53 listed = json.loads(run("nomad", "var", "list", "-out=json", prefix, capture=True, text=True).stdout)
54 databases = {}
55 for item in listed:
56 path = item["Path"]
57 alias = path.removeprefix(prefix)
58 if not path.startswith(prefix) or not SERVICE_ID.fullmatch(alias):
59 raise ValueError(f"Invalid input path for {service}: {path}")
60 values = json.loads(run("nomad", "var", "get", "-out=json", path, capture=True, text=True).stdout)["Items"]
61 if "name" not in values or "username" not in values:
62 continue
63 name, owner = values["name"], values["username"]
64 if not owner.startswith("svc_"):
65 continue
66 if not re.fullmatch(r"[a-z][a-z0-9_]*", name) or owner != "svc_" + name:
67 raise ValueError(f"Invalid database allocation for {service}.{alias}")
68 if any(database == name for database, _ in databases.values()):
69 raise ValueError(f"Database allocated twice for {service}: {name}")
70 databases[alias] = (name, owner)
71 return databases
72
73
74def database_dump(container, database, destination, extensions):
75 with destination.open("wb") as output:
76 run(*PODMAN, "exec", container, "pg_dump", "-U", "postgres", "-Fc", "--no-owner", "--no-acl",
77 *(f"--exclude-extension={extension}" for extension in extensions), database, stdout=output)
78 if not destination.stat().st_size:
79 raise ValueError(f"Empty database backup: {database}")
80
81
82def checksum(path):
83 digest = hashlib.sha256()
84 with path.open("rb") as file:
85 for chunk in iter(lambda: file.read(1024 * 1024), b""):
86 digest.update(chunk)
87 return digest.hexdigest()
88
89
90def dataset_for(service):
91 root = Path("/srv/prod") / service
92 result = subprocess.run(["findmnt", "-n", "-o", "SOURCE,FSTYPE", "--mountpoint", str(root)], capture_output=True, text=True)
93 if result.returncode:
94 return None
95 source, fstype = result.stdout.split()
96 if fstype != "zfs" or not source.endswith("/prod/" + service):
97 raise ValueError(f"Unexpected dataset at {root}: {source}")
98 return source
99
100
101@contextmanager
102def cloned_postgres(snapshot, clone, mountpoint):
103 live_container = postgres_container()
104 inspect = json.loads(run(*PODMAN, "inspect", live_container, capture=True, text=True).stdout)[0]
105 mounts = [mount for mount in inspect["Mounts"] if mount["Destination"] == "/var/lib/postgresql"]
106 if len(mounts) != 1 or mounts[0]["Type"] != "bind":
107 raise ValueError("Postgres data mount is unavailable")
108 data_path = Path(mounts[0]["Source"])
109 if not data_path.is_relative_to("/srv/prod/postgres"):
110 raise ValueError("Postgres data is outside its dataset")
111 user = inspect["Config"]["User"]
112 if not re.fullmatch(r"[0-9]+:[0-9]+", user):
113 raise ValueError("Postgres container user is invalid")
114 pgdata = [env for env in inspect["Config"]["Env"] if env.startswith("PGDATA=")]
115 if len(pgdata) != 1 or not Path(pgdata[0][7:]).is_relative_to("/var/lib/postgresql"):
116 raise ValueError("Postgres data directory is invalid")
117 image = inspect["Image"]
118 if not re.fullmatch(r"[0-9a-f]{64}", image):
119 raise ValueError("Postgres image ID is invalid")
120 probe = "studio-backup-" + clone.rsplit("/", 1)[1]
121 created = False
122 try:
123 run("zfs", "clone", "-o", f"mountpoint={mountpoint}", snapshot, clone)
124 created = True
125 run(*PODMAN, "run", "-d", "--name", probe, "--network", "none", "--user", user,
126 "-e", pgdata[0], "-e", "POSTGRES_PASSWORD=backup-probe",
127 "-v", f"{Path(mountpoint) / data_path.relative_to('/srv/prod/postgres')}:/var/lib/postgresql", image,
128 stdout=subprocess.DEVNULL)
129 for _ in range(60):
130 ready = subprocess.run([*PODMAN, "exec", probe, "pg_isready", "-U", "postgres"], capture_output=True)
131 if ready.returncode == 0:
132 break
133 time.sleep(1)
134 else:
135 raise ValueError("Snapshot PostgreSQL did not become ready")
136 yield probe
137 finally:
138 subprocess.run([*PODMAN, "rm", "-f", probe], check=False, stdout=subprocess.DEVNULL)
139 if created:
140 run("zfs", "destroy", clone)
141
142
143def backup(from_release, to_release):
144 if not RELEASE_ID.fullmatch(from_release) or not RELEASE_ID.fullmatch(to_release):
145 raise ValueError("Invalid release ID")
146 if current_release().name != from_release:
147 raise ValueError("Active release changed before backup")
148 names = json.loads((STATE / "managed-jobs.json").read_text())
149 if not names or any(not SERVICE_ID.fullmatch(name) for name in names):
150 raise ValueError("Managed jobs list is invalid")
151 backup_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + secrets.token_hex(3)
152 directory = BACKUPS / backup_id
153 manifest = {"id": backup_id, "fromRelease": from_release, "toRelease": to_release,
154 "time": int(time.time()), "services": {}}
155 snapshot = "studio-" + backup_id
156 datasets = set()
157 allocations = {}
158 for service in sorted(names):
159 entry = {}
160 dataset = dataset_for(service)
161 if dataset:
162 datasets.add(dataset)
163 entry["dataset"] = dataset
164 entry["snapshot"] = snapshot
165 allocated = database_inputs(service)
166 if allocated:
167 entry["databases"] = {}
168 allocations[service] = allocated
169 if entry:
170 manifest["services"][service] = entry
171 if allocations:
172 postgres_dataset = dataset_for("postgres")
173 if not postgres_dataset:
174 raise ValueError("Postgres needs a ZFS dataset for consistent backups")
175 datasets.add(postgres_dataset)
176 directory.mkdir(parents=True, mode=0o700)
177 snapshots = [f"{dataset}@{snapshot}" for dataset in sorted(datasets)]
178 created = False
179 try:
180 if snapshots:
181 run("zfs", "snapshot", *snapshots)
182 created = True
183 nomad_snapshot = directory / "nomad.snap"
184 run("nomad", "operator", "snapshot", "save", str(nomad_snapshot))
185 manifest["nomadSnapshot"] = {"file": nomad_snapshot.name, "sha256": checksum(nomad_snapshot)}
186 if allocations:
187 clone = postgres_dataset.rsplit("/prod/", 1)[0] + "/staging/" + backup_id + "-postgres"
188 mountpoint = Path("/srv/staging") / (backup_id + "-postgres")
189 with cloned_postgres(f"{postgres_dataset}@{snapshot}", clone, mountpoint) as probe:
190 for service, allocated in allocations.items():
191 entry = manifest["services"][service]
192 for alias, (database, owner) in sorted(allocated.items()):
193 result = run(*PODMAN, "exec", probe, "psql", "-U", "postgres", "-d", database,
194 "-At", "-c", "SELECT extname FROM pg_extension WHERE extname <> 'plpgsql' ORDER BY extname",
195 capture=True, text=True)
196 extensions = result.stdout.splitlines()
197 if any(not re.fullmatch(r"[a-z][a-z0-9_]*", extension) for extension in extensions):
198 raise ValueError(f"Invalid extension in {database}")
199 dump = directory / f"{service}-{alias}.dump"
200 database_dump(probe, database, dump, extensions)
201 entry["databases"][alias] = {"name": database, "owner": owner, "extensions": extensions,
202 "dump": dump.name, "sha256": checksum(dump)}
203 pending = directory / "manifest.pending"
204 pending.write_text(json.dumps(manifest, indent=2) + "\n")
205 pending.replace(directory / "manifest.json")
206 except Exception:
207 if created:
208 for target in snapshots:
209 subprocess.run(["zfs", "destroy", target], check=False)
210 shutil.rmtree(directory)
211 raise
212 print(f"backup={backup_id} services={len(manifest['services'])}")
213 return backup_id
214
215
216def list_backups():
217 for path in sorted(BACKUPS.glob("*/manifest.json")):
218 manifest = json.loads(path.read_text())
219 print(f"{manifest['id']} {manifest['fromRelease']} -> {manifest['toRelease']} {', '.join(sorted(manifest['services']))}")
220
221
222def restore(backup_id, service):
223 if not BACKUP_ID.fullmatch(backup_id) or not SERVICE_ID.fullmatch(service):
224 raise ValueError("Invalid backup or service ID")
225 directory = BACKUPS / backup_id
226 manifest = json.loads((directory / "manifest.json").read_text())
227 if manifest["id"] != backup_id or service not in manifest["services"]:
228 raise ValueError("Backup does not contain that service")
229 if current_release().name != manifest["fromRelease"]:
230 raise ValueError(f"Roll back code to {manifest['fromRelease']} before restoring data")
231 if service == "postgres":
232 raise ValueError("Postgres serves multiple services; restore a specific database owner")
233 entry = manifest["services"][service]
234 dataset = entry.get("dataset")
235 if dataset and dataset_for(service) != dataset:
236 raise ValueError("Service dataset changed since backup")
237 root = Path("/srv/prod") / service
238 source = root / ".zfs/snapshot" / entry["snapshot"] if dataset else None
239 if source and not source.is_dir():
240 raise ValueError("ZFS backup snapshot is unavailable")
241 databases = entry.get("databases", {"database": entry["database"]} if "database" in entry else {})
242 allocated = database_inputs(service)
243 for alias, database in databases.items():
244 if allocated.get(alias) != (database["name"], database["owner"]):
245 raise ValueError(f"Service database changed since backup: {alias}")
246 dump = directory / database["dump"]
247 if not dump.is_file() or checksum(dump) != database["sha256"]:
248 raise ValueError("Database dump is missing or changed")
249 run("nomad", "job", "stop", "-yes", service)
250 for _ in range(30):
251 allocations = json.loads(run("nomad", "job", "allocs", "-json", service, capture=True, text=True).stdout)
252 if not any(a["ClientStatus"] == "running" for a in allocations):
253 break
254 time.sleep(2)
255 else:
256 raise ValueError(f"{service} did not stop; data remains unchanged")
257 safety = "before-restore-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + secrets.token_hex(3)
258 if dataset:
259 run("zfs", "snapshot", f"{dataset}@{safety}")
260 container = postgres_container() if databases else None
261 for alias, database in databases.items():
262 database_dump(container, database["name"], directory / f"{service}-{alias}-{safety}.dump", database["extensions"])
263 if dataset:
264 run("rsync", "-aHAX", "--numeric-ids", "--one-file-system", "--delete", "--exclude=/.zfs/",
265 str(source) + "/", str(root) + "/")
266 for database in databases.values():
267 name, owner = database["name"], database["owner"]
268 run(*PODMAN, "exec", "-i", container, "psql", "-U", "postgres", "-d", "postgres", "-v", "ON_ERROR_STOP=1",
269 input=f"DROP DATABASE {name} WITH (FORCE);\nCREATE DATABASE {name} OWNER {owner};\n", text=True)
270 for extension in database["extensions"]:
271 run(*PODMAN, "exec", "-i", container, "psql", "-U", "postgres", "-d", name, "-v", "ON_ERROR_STOP=1",
272 input=f"CREATE EXTENSION {extension};\n", text=True)
273 with (directory / database["dump"]).open("rb") as input_file:
274 run(*PODMAN, "exec", "-i", container, "pg_restore", "-U", "postgres", "-d", name,
275 "--no-owner", "--no-acl", "--role=" + owner, stdin=input_file)
276 script = current_release() / "tools/studio.py"
277 run("python3", str(script), "deploy", service)
278 run("python3", str(script), "check", service)
279 print(f"restored={service} backup={backup_id} safety={safety}")
280
281
282def main():
283 parser = argparse.ArgumentParser(description="Back up or restore service data on the home server")
284 parser.add_argument("mode", choices=["backup", "list", "restore"])
285 parser.add_argument("first", nargs="?")
286 parser.add_argument("second", nargs="?")
287 parser.add_argument("--discard-writes", action="store_true")
288 args = parser.parse_args()
289 os.umask(0o077)
290 os.environ["NOMAD_TOKEN"] = (STATE / "nomad.token").read_text().strip()
291 if args.mode == "backup" and args.first and args.second and not args.discard_writes:
292 backup(args.first, args.second)
293 elif args.mode == "list" and not args.first and not args.second and not args.discard_writes:
294 list_backups()
295 elif args.mode == "restore" and args.first and args.second and args.discard_writes:
296 restore(args.first, args.second)
297 else:
298 parser.error("Expected backup FROM TO, list, or restore BACKUP SERVICE --discard-writes")
299
300
301if __name__ == "__main__":
302 main()
tools/dawarich-migration.md created+17
......@@ -0,0 +1,17 @@
1# Dawarich preview and fresh production start
2
3Zenith's separate Redis dump is 86,557 bytes and belongs to Dawarich, not Snow Globe's standalone Redis service. DB 0 contains Dawarich cache and track-generation keys; DB 1 held 106 queued `Family::Invitations::CleanupJob` entries at inspection. The old image's nightly schedule sends them to `family`, while its worker listens to `families`; the live `family` queue had grown to 107 and `families` was empty on 2026-09-27. The pinned Snow Globe image schedules that job on `families`, and all 14 of its scheduled queues appear in the worker's queue list. The new Zenith installation starts with fresh PostgreSQL, storage, and Redis; the owner will handle application data migration. The offline PostgreSQL handoff excludes Dawarich. The existing VM production database still holds the copied 22,666 points used for testing.
4
5Zenith's live Dawarich database is PostgreSQL 18 with PostGIS. The VM's Postgres service is also PostgreSQL 18 with PostGIS. The tested transfer used a consistent custom-format dump made with `pg_dump -Fc --no-owner --no-acl --exclude-extension=postgis`, then restored it into a **separate stage database** after creating the PostGIS extension there. It did not write to Zenith or the VM's production database.
6
7The Rails `SECRET_KEY_BASE` must stay the same as the source value so encrypted records remain readable. The old `/var/app/storage` contained one file; its checksum matched the staged copy. The generated `/var/app/public` assets and `/var/app/tmp` cache were not imported. The stage used its own Keycloak client and database; its two imported user records remained intact.
8
9Before the new image ran, the source and staged database both had 22,666 points, 1,011 tracks, 2 users, and 74 visits. The current image rebuilds derived tracks and visits on startup; after a worker restart, the staged database had 22,666 points, 698 tracks, 2 users, 139 visits, and 74 newly generated track segments. Track count is therefore not a row-for-row import check. Point and user counts, storage checksums, HTTPS health, and the SSO login flow should be checked separately.
10
11The first staged boot exposed a startup race: Sidekiq loaded the old `TrackSegment` schema before the web entrypoint finished migrations, then logged `unknown attribute 'start_at'`. Restarting the worker after migrations restored segment creation. The service now runs Redis as a prestart sidecar and completes a migration task before starting web and worker. Its web entrypoint still repeats the upstream migration command, which is idempotent but extends cold startup on the emulated x86 VM.
12
13The corrected deployment is `dawarich-preview-1e4dac4a` at `https://dawarich-preview-1e4dac4a.studio.test` (release `554d23a23d940690`). Nomad marked it healthy; the HTTPS health endpoint returned 200 with certificate verification, and the new worker allocation logged no `start_at` errors.
14
15[import-dawarich.sh](import-dawarich.sh) repeats the preview restore from the latest read-only Zenith app snapshot and copies the small storage directory. It compares the source and target `SECRET_KEY_BASE` by hash, backs up the target ZFS dataset and database, restores the source PostgreSQL dump with PostGIS, and checks point/user counts before starting the preview. The 2026-09-26 run completed successfully: 22,666 points and two users survived migrations, HTTPS health returned 200 from this Mac with certificate verification, Nomad marked the job healthy, and the worker had no `start_at` error.
16
17The old-data importer remains useful for VM previews while Zenith is running. It is not part of the same-machine production cutover.
tools/ddns-migration.md created+9
......@@ -0,0 +1,9 @@
1# DDNS cutover
2
3The `.test` VM does not run DDNS. Evaluating `service/ddns-updater/service.pkl` on the VM with `domain=paperclover.net` produced valid JSON for the Cloudflare `*.paperclover.net` record and required `cloudflare_zone_id` and `cloudflare_api_token`. Zenith's legacy `.env` contains the corresponding `CLOUDFLARE_ZONE_ID` and `CLOUDFLARE_API_TOKEN` keys. No public DNS record was changed during this check.
4
5The current worktree's `config/`, `service/`, and `tools/` generated 27 production-domain jobs, all accepted by `nomad job validate` on the VM. This was validation only; it did not submit jobs or contact Cloudflare.
6
7Snow Globe replaces a service hostname's first label with its stage ID: Shale becomes `shale-preview-12345678.paperclover.net`, while evil.inc Forgejo becomes `evil-forgejo-preview-12345678.evil.paperclover.net`. Cloudflare's wildcard DNS records [cover multiple levels when no specific record takes precedence](https://developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records/). Read-only DNS queries resolved both a sample Shale stage name and an otherwise nonexistent name under `evil.paperclover.net` to the current public address. The README's `.staging.paperclover.net` example describes a different hostname scheme from the current CLI. No public record changed; ACME issuance still needs a live cutover check.
8
9At production cutover, set `STUDIO_DEPLOY_HOST` and `STUDIO_DEPLOY_PORT` for the new host, then run `bash tools/import-legacy-secrets.sh ddns-updater CLOUDFLARE_ZONE_ID CLOUDFLARE_API_TOKEN`. The order matches the required secret fields in the service definition. Start the Snow Globe DDNS job only after the old updater is stopped; otherwise both updaters can write the wildcard record. Verify the provider status and the public record after the new job starts.
tools/deploy-test.py created+71
......@@ -0,0 +1,71 @@
1#!/usr/bin/env python3
2import json
3import os
4from pathlib import Path
5import shutil
6import subprocess
7import tempfile
8import unittest
9from unittest.mock import patch
10
11import deploy
12import release
13
14
15class MainExport(unittest.TestCase):
16 def test_production_exports_main_while_staging_exports_working_files(self):
17 with tempfile.TemporaryDirectory(prefix="studio-main-export-") as temporary:
18 root = Path(temporary)
19 repo = root / "repo"
20 repo.mkdir()
21 remote = root / "remote"
22 subprocess.run(["jj", "git", "init", str(repo)], check=True, capture_output=True)
23 for name in release.SOURCES:
24 path = repo / name
25 if "." in name:
26 path.write_text("fixture")
27 else:
28 path.mkdir()
29 (path / "fixture").write_text("committed")
30 executable = repo / "tools/fixture"
31 executable.chmod(0o755)
32 for args in [["describe", "-m", "Test main"], ["bookmark", "set", "main"]]:
33 subprocess.run(["jj", *args], cwd=repo, check=True, capture_output=True)
34 commit = subprocess.run(["jj", "--ignore-working-copy", "log", "-r", "main", "--no-graph", "-T", "commit_id"], cwd=repo, check=True, capture_output=True, text=True).stdout
35 subprocess.run(["jj", "new"], cwd=repo, check=True, capture_output=True)
36 (repo / "service/fixture").write_text("unfinished")
37 (repo / "service/new").write_text("new working file")
38 original = subprocess.run
39
40 def transport(argv, **kwargs):
41 if argv[0] == "ssh":
42 return subprocess.CompletedProcess(argv, 1)
43 if argv[0] == "rsync" and "-e" in argv:
44 destination = Path(argv[-1].partition(":")[2])
45 destination.mkdir(parents=True)
46 for name in argv[4:-1]:
47 source = Path(name)
48 if source.is_dir():
49 shutil.copytree(source, destination / source.name)
50 else:
51 shutil.copy2(source, destination / source.name)
52 return subprocess.CompletedProcess(argv, 0)
53 return original(argv, **kwargs)
54
55 with patch.object(deploy, "REPO", repo), patch.object(deploy, "REMOTE", remote), patch.object(deploy, "ssh"), patch.object(deploy.subprocess, "run", side_effect=transport):
56 main = remote / "releases" / deploy.upload(main=True)
57 stage = remote / "releases" / deploy.upload()
58 self.assertEqual((main / "service/fixture").read_text(), "committed")
59 self.assertFalse((main / "service/new").exists())
60 self.assertTrue(os.access(main / "tools/fixture", os.X_OK))
61 self.assertEqual(json.loads((main / ".studio-release.json").read_text())["main"], {"commit": commit, "description": "Test main\n"})
62 self.assertEqual((stage / "service/fixture").read_text(), "unfinished")
63 self.assertEqual((stage / "service/new").read_text(), "new working file")
64 self.assertNotIn("main", json.loads((stage / ".studio-release.json").read_text()))
65 subprocess.run(["jj", "bookmark", "set", "main"], cwd=repo, check=True, capture_output=True)
66 with self.assertRaisesRegex(ValueError, "commit description"):
67 deploy.upload(main=True)
68
69
70if __name__ == "__main__":
71 unittest.main()
tools/deploy.py created+224
......@@ -0,0 +1,224 @@
1#!/usr/bin/env python3
2import argparse
3import json
4import os
5from pathlib import Path
6import re
7import shlex
8import shutil
9import subprocess
10import sys
11import tempfile
12import time
13
14from release import SOURCES, tree_digest
15
16
17REPO = Path(__file__).resolve().parent.parent
18HOST = os.environ.get("STUDIO_DEPLOY_HOST", "root@127.0.0.1")
19PORT = os.environ.get("STUDIO_DEPLOY_PORT", "2222")
20REMOTE = Path("/opt/studio")
21NAME = re.compile(r"[a-z][a-z0-9-]*\Z")
22RELEASE_ID = re.compile(r"[0-9a-f]{16}\Z")
23
24
25def ssh(command, capture=False):
26 return subprocess.run(
27 ["ssh", "-p", PORT, "-o", "BatchMode=yes", HOST, command],
28 check=True, text=True, capture_output=capture,
29 )
30
31
32def run_logged(mode, target, command):
33 run = f"{int(time.time() * 1000)}-{mode}-{target or 'current'}"
34 path = f"/var/lib/studio/runs/{run}.log"
35 script = f"umask 077; mkdir -p /var/lib/studio/runs; set -o pipefail; {command} 2>&1 | tee {shlex.quote(path)}"
36 ssh("bash -c " + shlex.quote(script))
37 print(f"run={run}")
38
39
40def sync_manager(release):
41 source = REMOTE / "releases" / release / "tools"
42 ssh(f"cp {source}/release.py {source}/data.py {REMOTE}/")
43
44
45def upload(main=False):
46 with tempfile.TemporaryDirectory() as temporary:
47 snapshot = Path(temporary)
48 revision = None
49 if main:
50 output = subprocess.run(
51 ["jj", "--ignore-working-copy", "log", "-r", "main", "--no-graph", "-T",
52 'json(commit_id) ++ "\\n" ++ json(conflict) ++ "\\n" ++ json(description)'],
53 cwd=REPO, check=True, capture_output=True, text=True,
54 ).stdout.splitlines()
55 commit, conflicted, description = map(json.loads, output)
56 if conflicted or not description.strip():
57 raise ValueError("main needs a commit description and no conflicts before deployment")
58 revision = {"commit": commit, "description": description}
59 entries = subprocess.run(
60 ["jj", "--ignore-working-copy", "file", "list", "-r", commit, "-T",
61 '\"[\" ++ json(path) ++ \",\" ++ json(file_type) ++ \",\" ++ json(executable) ++ \"]\\n\"',
62 *SOURCES], cwd=REPO, check=True, capture_output=True, text=True,
63 ).stdout.splitlines()
64 for entry in entries:
65 name, kind, executable = json.loads(entry)
66 relative = Path(name)
67 if kind != "file" or relative.is_absolute() or ".." in relative.parts:
68 raise ValueError(f"unsupported main release file: {name}")
69 destination = snapshot / relative
70 destination.parent.mkdir(parents=True, exist_ok=True)
71 destination.write_bytes(subprocess.run(
72 ["jj", "--ignore-working-copy", "file", "show", "-r", commit, name],
73 cwd=REPO, check=True, capture_output=True,
74 ).stdout)
75 destination.chmod(0o755 if executable else 0o644)
76 else:
77 subprocess.run(
78 ["rsync", "-a", "--exclude=.DS_Store", "--exclude=__pycache__", "--exclude=*.pyc",
79 "--exclude=.identities.lock", "--exclude=identities.pending", "--exclude=._*",
80 "--exclude=dashboard/node_modules", "--exclude=dashboard/dist",
81 "--exclude=dashboard/.cache", "--exclude=dashboard/data", "--exclude=dashboard/target",
82 *(str(REPO / path) for path in SOURCES),
83 str(snapshot) + "/"], check=True,
84 )
85 for manifest in snapshot.glob("service/*/build-source.json"):
86 spec = json.loads(manifest.read_text())
87 source = (REPO / spec["source"]).resolve(strict=True)
88 if not source.is_dir() or not source.is_relative_to(REPO.parent):
89 raise ValueError(f"invalid build source: {manifest.parent.name}")
90 context = manifest.parent / "build"
91 if context.exists():
92 raise ValueError(f"build context already exists: {manifest.parent.name}")
93 context.mkdir()
94 for name in [*spec["include"], spec["dockerfile"]]:
95 relative = Path(name)
96 if relative.is_absolute() or ".." in relative.parts or not relative.parts:
97 raise ValueError(f"invalid build source path: {name}")
98 origin = source / relative
99 if not origin.resolve(strict=True).is_relative_to(source):
100 raise ValueError(f"build source escapes context: {name}")
101 destination = context / ("Dockerfile" if name == spec["dockerfile"] else name)
102 destination.parent.mkdir(parents=True, exist_ok=True)
103 if origin.is_dir():
104 shutil.copytree(origin, destination, symlinks=True)
105 else:
106 shutil.copy2(origin, destination)
107 digest = tree_digest(snapshot)
108 release = digest[:16]
109 remote_release = REMOTE / "releases" / release
110 details = {"id": release, "digest": digest, "version": 2}
111 if revision:
112 details["main"] = revision
113 (snapshot / ".studio-release.json").write_text(json.dumps(details) + "\n")
114 if subprocess.run(
115 ["ssh", "-p", PORT, "-o", "BatchMode=yes", HOST, f"test -d {remote_release}"],
116 check=False,
117 ).returncode == 0:
118 ssh(f"python3 {remote_release}/tools/release.py verify {release}")
119 if revision:
120 script = "import pathlib,sys; p=pathlib.Path(" + repr(str(remote_release / ".studio-release.json")) + "); t=p.with_suffix('.pending'); t.write_text(sys.stdin.read()); t.chmod(0o444); t.replace(p)"
121 subprocess.run(["ssh", "-p", PORT, "-o", "BatchMode=yes", HOST,
122 "python3 -c " + shlex.quote(script)], input=json.dumps(details) + "\n", text=True, check=True)
123 return release
124 ssh(f"mkdir -p {remote_release}")
125 subprocess.run(
126 ["rsync", "-a", "-e", f"ssh -p {PORT} -o BatchMode=yes",
127 *(str(snapshot / path) for path in SOURCES), str(snapshot / ".studio-release.json"),
128 f"{HOST}:{remote_release}/"], check=True,
129 )
130 ssh(f"chmod -R a-w {remote_release}")
131 return release
132
133
134def main():
135 parser = argparse.ArgumentParser(description="Preview working changes and deploy main")
136 parser.add_argument("mode", choices=["stage", "publish", "prod", "rollback", "history", "backups", "data-restore", "bootstrap", "allocate", "destroy", "secrets"])
137 parser.add_argument("target", nargs="?")
138 parser.add_argument("--env", action="append", default=[])
139 parser.add_argument("--file", type=Path)
140 parser.add_argument("--key", action="append", default=[])
141 parser.add_argument("--backup")
142 parser.add_argument("--discard-writes", action="store_true")
143 args = parser.parse_args()
144 if args.mode in {"stage", "allocate", "destroy", "secrets", "data-restore"} and (not args.target or not NAME.fullmatch(args.target)):
145 parser.error(f"{args.mode} requires a service or stage ID")
146 if args.mode == "rollback" and args.target and not RELEASE_ID.fullmatch(args.target):
147 parser.error("rollback target must be a release ID")
148 if args.mode in {"publish", "prod", "history", "backups", "bootstrap"} and args.target:
149 parser.error(f"{args.mode} takes no target")
150 if args.env and args.mode != "stage":
151 parser.error("--env is available only for stage")
152 if bool(args.file) != (args.mode == "secrets"):
153 parser.error("--file is required only for secrets")
154 if args.key and args.mode != "secrets":
155 parser.error("--key is available only for secrets")
156 if len(set(args.key)) != len(args.key) or any(not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key) for key in args.key):
157 parser.error("--key values must be unique secret names")
158 if args.mode == "data-restore":
159 if not args.backup or not re.fullmatch(r"\d{8}T\d{6}Z-[0-9a-f]{6}", args.backup) or not args.discard_writes:
160 parser.error("data-restore requires --backup ID and --discard-writes")
161 elif args.backup or args.discard_writes:
162 parser.error("--backup and --discard-writes are available only for data-restore")
163 if args.mode == "secrets":
164 if str(args.file) == "-":
165 values = sys.stdin.read()
166 else:
167 if args.file.stat().st_mode & 0o077:
168 raise ValueError("secret file must be readable only by its owner")
169 values = args.file.read_text()
170 if not values.strip():
171 raise ValueError("secret input is empty")
172 manager = f"python3 {REMOTE}/release.py"
173 if args.mode == "stage":
174 release = upload()
175 overrides = "".join(f" --env {shlex.quote(value)}" for value in args.env)
176 run_logged("stage", args.target, f"python3 {REMOTE}/releases/{release}/tools/studio.py stage {args.target}{overrides}")
177 elif args.mode == "secrets":
178 release = upload()
179 keys = "".join(f" --key {shlex.quote(key)}" for key in args.key)
180 subprocess.run(
181 ["ssh", "-p", PORT, "-o", "BatchMode=yes", HOST,
182 f"python3 {REMOTE}/releases/{release}/tools/studio.py secrets {args.target}{keys}"],
183 input=values, text=True, check=True,
184 )
185 elif args.mode == "bootstrap":
186 release = upload(main=True)
187 sync_manager(release)
188 ssh(f"{manager} publish {release}")
189 ssh(f"python3 {REMOTE}/releases/{release}/tools/studio.py pool")
190 ssh(f"python3 {REMOTE}/releases/{release}/tools/studio.py bootstrap")
191 ssh(f"python3 {REMOTE}/releases/{release}/tools/release.py bootstrap {release}")
192 elif args.mode == "allocate":
193 release = upload()
194 ssh(f"python3 {REMOTE}/releases/{release}/tools/studio.py allocate {args.target}")
195 elif args.mode in {"publish", "prod"}:
196 release = upload(main=True)
197 sync_manager(release)
198 ssh(f"{manager} publish {release}")
199 if args.mode == "prod":
200 run_logged("prod", release, f"{manager} deploy {release}")
201 elif args.mode == "rollback":
202 run_logged("rollback", args.target, f"{manager} rollback{(' ' + args.target) if args.target else ''}")
203 elif args.mode == "history":
204 ssh(f"{manager} history")
205 elif args.mode == "backups":
206 release = upload()
207 ssh(f"python3 {REMOTE}/releases/{release}/tools/data.py list")
208 elif args.mode == "data-restore":
209 release = upload()
210 run_logged("data-restore", args.target, f"python3 {REMOTE}/releases/{release}/tools/data.py restore {args.backup} {args.target} --discard-writes")
211 else:
212 metadata = json.loads(ssh(f"cat /var/lib/studio/stages/{args.target}.json", capture=True).stdout)
213 release = metadata.get("release")
214 if not isinstance(release, str) or not RELEASE_ID.fullmatch(release):
215 raise ValueError("stage has no successful release; inspect it before removal")
216 run_logged("destroy", args.target, f"python3 {REMOTE}/releases/{release}/tools/studio.py destroy {args.target}")
217
218
219if __name__ == "__main__":
220 try:
221 main()
222 except (OSError, ValueError, subprocess.CalledProcessError) as error:
223 print(f"Home server deployment stopped: {error}", file=sys.stderr)
224 sys.exit(error.returncode if isinstance(error, subprocess.CalledProcessError) else 1)
tools/evil-forgejo-migration.md created+27
......@@ -0,0 +1,27 @@
1# evil.inc Forgejo cutover
2
3Zenith's Forgejo database is 21 MB; its app directory is about 14 GB. The VM Forgejo has the same usernames for all seven numeric user IDs referenced by the imported HedgeDoc OAuth profiles. This checks identity continuity without inspecting private repository contents. The app directory must move with the database because it holds Git objects, LFS data, attachments, avatars, and indexes.
4
5Snow Globe generates a new automation account password, but the imported Forgejo database needs the old signing and security keys. The named secret import preserves Snow Globe's automation password while replacing only the legacy values. With `STUDIO_DEPLOY_HOST` and `STUDIO_DEPLOY_PORT` pointing at the production host, run:
6
7```sh
8bash tools/import-legacy-secrets.sh evil-forgejo \
9 mailer_address=MAILER_ADDRESS \
10 mailer_username=MAILER_USERNAME \
11 mailer_password=MAILER_PASSWORD \
12 lfs_jwt=EVIL_FORGEJO_SERVER_LFS_JWT_SECRET \
13 oauth_jwt=EVIL_FORGEJO_OAUTH2_JWT_SECRET \
14 security_key=EVIL_FORGEJO_SECURITY_SECRET_KEY \
15 internal_token=EVIL_FORGEJO_SECURITY_INTERNAL_TOKEN \
16 anubis_key=ANUBIS_PRIVATE_KEY
17```
18
19The source `.env` contains one unquoted value for each named key. The importer streams them through stdin and checks the destination field names against the service definition. Stop the old Forgejo and HedgeDoc before the final database and app-directory copy, and keep both stopped until the new Forgejo and HedgeDoc identities are checked. Do not switch the public route before the repository data and database have been restored together.
20
21For a same-machine OS replacement, use the [legacy PostgreSQL handoff](legacy-handoff.md) before reboot. After the encrypted apps dataset is mounted on NixOS, import the original Forgejo secrets with `STUDIO_LEGACY_HANDOFF` set, stop the Snow Globe Forgejo job, then run `bash tools/import-evil-forgejo.sh`. It checks the retained database dump and secret fingerprints, copies `/mnt/storage1/apps/evil-infra/forgejo` directly into the managed service dataset, compares a content digest without exposing file contents, restores the database, and checks user/repository counts. It keeps the new job stopped and prints the pre-import ZFS snapshot and database dump for recovery. The 14 GB production copy has not run on the VM.
22
23A read-only Zenith `pg_dump` restored into a disposable VM PostgreSQL database on 2026-09-26. The source and restored database both had 10 users and 15 repositories; the disposable database was removed afterward. No repository contents were opened or copied. This verifies the logical database restore separately from the 14 GB file transfer.
24
25A disposable VM fixture also passed the importer's `rsync -aH --numeric-ids --one-file-system --delete` copy and `tree-hash.py` comparison, including a hardlink, symlink, owner, group, and mode. The production 14 GB transfer remains untested.
26
27After a database import, Snow Globe's saved HedgeDoc OAuth application ID may point to a different legacy client. The provider now checks the client ID before updating an application and creates a fresh OAuth client when the saved one is missing or belongs to another client. A VM probe used a real ID collision: the unrelated client remained unchanged, the replacement client was created, and the temporary replacement was deleted after the check.
tools/export-legacy-postgres.sh created+80
......@@ -0,0 +1,80 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
5root=/mnt/storage1/apps/studio-handoff
6id=$(date -u +%Y%m%dT%H%M%SZ)-$(python3 -c 'import secrets; print(secrets.token_hex(3))')
7pending=$root/.pending-$id
8final=$root/$id
9
10ssh "$source_host" 'test "$(findmnt -n -o SOURCE --mountpoint /mnt/storage1/apps)" = storage1/apps; test "$(findmnt -n -o FSTYPE --mountpoint /mnt/storage1/apps)" = zfs'
11ssh "$source_host" 'sudo -n docker info >/dev/null'
12
13check_stopped() {
14 [[ $(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' evil-forgejo") == false ]] || {
15 echo 'Stop Zenith evil-forgejo before exporting the final database' >&2
16 exit 1
17 }
18 [[ $(ssh "$source_host" "sudo -n docker ps -a --filter label=com.docker.compose.service=evil-hedgedoc --format '{{.State}}'") == exited ]] || {
19 echo 'Stop Zenith evil-hedgedoc before exporting the final databases' >&2
20 exit 1
21 }
22}
23
24check_stopped
25ssh "$source_host" 'sudo -n docker exec postgres pg_isready -U postgres >/dev/null'
26ssh "$source_host" 'test -r /mnt/storage1/apps/home-infra/.env'
27ssh "$source_host" "set -e; test ! -e '$final'; mkdir -m 700 -p '$root'; mkdir -m 700 '$pending'"
28for database in evil-forgejo evil-hedgedoc; do
29 ssh "$source_host" "set -e; umask 077; sudo -n docker exec postgres pg_dump -U postgres -Fc --no-owner --no-acl '$database' > '$pending/$database.dump'; test -s '$pending/$database.dump'; sudo -n docker exec -i postgres pg_restore -l < '$pending/$database.dump' >/dev/null"
30done
31check_stopped
32ssh "$source_host" python3 - "$pending" "$id" <<'PY'
33import hashlib
34import json
35from pathlib import Path
36import subprocess
37import sys
38
39folder = Path(sys.argv[1])
40manifest = {"id": sys.argv[2], "databases": {}}
41queries = {
42 "evil-forgejo": 'SELECT (SELECT count(*) FROM "user"), (SELECT count(*) FROM repository)',
43 "evil-hedgedoc": 'SELECT (SELECT count(*) FROM "Notes"), (SELECT count(*) FROM "Users"), (SELECT count(*) FROM "Revisions"), (SELECT count(*) FROM "Authors")',
44}
45for name in ("evil-forgejo", "evil-hedgedoc"):
46 source = folder / f"{name}.dump"
47 digest = hashlib.sha256()
48 with source.open("rb") as file:
49 for chunk in iter(lambda: file.read(1024 * 1024), b""):
50 digest.update(chunk)
51 result = subprocess.run(["sudo", "-n", "docker", "exec", "postgres", "psql", "-U", "postgres", "-d", name,
52 "-At", "-c", queries[name]], check=True, capture_output=True, text=True)
53 counts = [int(value) for value in result.stdout.strip().split("|")]
54 if len(counts) != (4 if name == "evil-hedgedoc" else 2):
55 raise ValueError(f"Unexpected count result for {name}")
56 manifest["databases"][name] = {"file": source.name, "bytes": source.stat().st_size,
57 "sha256": digest.hexdigest(), "counts": counts}
58legacy_env = (Path("/mnt/storage1/apps/home-infra/.env")).read_text().splitlines()
59forgejo_secrets = {
60 "lfs_jwt": "EVIL_FORGEJO_SERVER_LFS_JWT_SECRET",
61 "oauth_jwt": "EVIL_FORGEJO_OAUTH2_JWT_SECRET",
62 "security_key": "EVIL_FORGEJO_SECURITY_SECRET_KEY",
63 "internal_token": "EVIL_FORGEJO_SECURITY_INTERNAL_TOKEN",
64 "anubis_key": "ANUBIS_PRIVATE_KEY",
65 "mailer_address": "MAILER_ADDRESS",
66 "mailer_username": "MAILER_USERNAME",
67 "mailer_password": "MAILER_PASSWORD",
68}
69fingerprints = {}
70for target, source in forgejo_secrets.items():
71 values = [line.split("=", 1)[1] for line in legacy_env if line.startswith(source + "=")]
72 if len(values) != 1 or not values[0]:
73 raise ValueError(f"Legacy Forgejo secret is unavailable: {source}")
74 fingerprints[target] = hashlib.sha256(values[0].encode()).hexdigest()
75manifest["databases"]["evil-forgejo"]["secretSha256"] = fingerprints
76(folder / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
77PY
78ssh "$source_host" "set -e; chmod 600 '$pending/manifest.json'; mv '$pending' '$final'; sync"
79check_stopped
80echo "Legacy PostgreSQL handoff: $final"
tools/hedgedoc-migration.md created+9
......@@ -0,0 +1,9 @@
1# HedgeDoc migration
2
3`bash tools/import-hedgedoc.sh evil-hedgedoc-preview-f6eb07a4` restored Zenith's HedgeDoc database into a disposable stage and copied its uploads. The importer checked the upload copy by checksum, backed up the stage database, compared the restored table counts with Zenith, and restarted the stage. On 2026-09-26 both sides had 39 notes, 8 users, 183 revisions, and 68 authors. The stage returned HTTPS 200; `/auth/oauth2` redirected to the staged Forgejo OAuth client at `git.evil.studio.test` with the stage callback URL. No note bodies or repository contents were inspected.
4
5The seven HedgeDoc OAuth profile IDs all resolve to the same Forgejo usernames on Zenith and the VM when matched by numeric user ID. That checks account continuity for the imported database; an interactive sign-in remains the final identity check.
6
7For production, keep the Forgejo user IDs stable. Stop Zenith's HedgeDoc and the Snow Globe HedgeDoc job, set `STUDIO_DEPLOY_HOST` and `STUDIO_DEPLOY_PORT` for the new host, then run `bash tools/import-hedgedoc.sh evil-hedgedoc`. The importer refuses a running source or destination, verifies the uploads and four table counts, leaves a pre-import destination database dump, and leaves Snow Globe stopped. Promote a tested preview to start the new service, then verify HTTPS and a Forgejo sign-in before switching the public route. The old HedgeDoc data stays in place until cutover is accepted.
8
9For a same-machine OS replacement, use the [legacy PostgreSQL handoff](legacy-handoff.md) and set `STUDIO_LEGACY_HANDOFF` when running the production importer. It verifies the retained dump and reads uploads from the mounted old apps dataset without contacting old Docker.
tools/import-arr.sh created+127
......@@ -0,0 +1,127 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4service=${1:?usage: tools/import-arr.sh sonarr|radarr [preview-id]}
5[[ $service == sonarr || $service == radarr ]] || { echo 'Expected sonarr or radarr' >&2; exit 1; }
6instance=${2:-$service}
7[[ $instance == "$service" || $instance =~ ^${service}-preview-[0-9a-f]{8}$ ]] || { echo 'Expected the service ID or its preview ID' >&2; exit 1; }
8
9source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
10target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
11target_port=${STUDIO_DEPLOY_PORT:-2222}
12remote=(ssh -p "$target_port" "$target_host")
13source_copy_host=$source_host
14source_rsh=ssh
15offline=false
16if [[ -n ${STUDIO_LEGACY_HANDOFF:-} ]]; then
17 [[ $instance == "$service" && -n ${STUDIO_DEPLOY_HOST:-} ]] || {
18 echo 'Offline handoff requires a production ARR service and target' >&2; exit 1;
19 }
20 sh "$(dirname "$0")/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$target_host" "$target_port"
21 source_copy_host=$target_host
22 source_rsh="ssh -p $target_port"
23 offline=true
24fi
25source_ssh() {
26 if [[ $offline == true ]]; then
27 "${remote[@]}" "$@"
28 else
29 ssh "$source_host" "$@"
30 fi
31}
32if [[ $instance == "$service" ]]; then
33 root="/srv/prod/$service"
34 if [[ $offline == false ]]; then
35 source_running=$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' $service")
36 [[ $source_running == false ]] || { echo "Stop Zenith $service before importing production data" >&2; exit 1; }
37 fi
38 response=$("${remote[@]}" "curl -s -w '\n%{http_code}' -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/$service")
39 status=${response##*$'\n'}
40 if [[ $status == 200 ]]; then
41 stopped=$(python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())' <<<"${response%$'\n'*}")
42 [[ $stopped == true ]] || { echo "Stop production $service before importing" >&2; exit 1; }
43 elif [[ $status != 404 ]]; then
44 echo "Could not verify production $service job state: $status" >&2
45 exit 1
46 fi
47else
48 root="/srv/staging/$instance"
49 source_id=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'")
50 [[ $source_id == "$service" ]] || { echo 'Preview belongs to another service' >&2; exit 1; }
51fi
52dataset=$("${remote[@]}" "findmnt -n -o SOURCE --mountpoint $root")
53[[ $dataset == */prod/"$service" || $dataset == */staging/"$instance" ]] || { echo "Expected a mounted service dataset at $root" >&2; exit 1; }
54
55scratch=$(mktemp -d)
56source_backup=
57cleanup() {
58 rm -rf "$scratch"
59 if [[ -n $source_backup ]]; then
60 source_ssh "rm -f '$source_backup'"
61 fi
62}
63trap cleanup EXIT
64
65source_backup=$(source_ssh "python3 - '/mnt/storage1/apps/$service/$service.db'" <<'PY'
66import os
67import sqlite3
68import sys
69import tempfile
70
71source = sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True)
72fd, name = tempfile.mkstemp(prefix="studio-arr-", suffix=".db")
73os.close(fd)
74try:
75 target = sqlite3.connect(name)
76 source.backup(target)
77 if target.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
78 raise ValueError("ARR SQLite backup is invalid")
79 target.close()
80 source.close()
81except BaseException:
82 os.unlink(name)
83 raise
84print(name)
85PY
86)
87rsync -a --exclude="/$service.db*" --exclude='/logs.db*' --exclude='/*.pid' --exclude='/logs/' \
88 -e "$source_rsh" "$source_copy_host:/mnt/storage1/apps/$service/" "$scratch/config/"
89if [[ $offline == true ]]; then
90 scp -q -P "$target_port" "$source_copy_host:$source_backup" "$scratch/config/$service.db"
91else
92 scp -q "$source_copy_host:$source_backup" "$scratch/config/$service.db"
93fi
94
95if [[ $instance != "$service" ]]; then
96 "${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
97fi
98for _ in {1..30}; do
99 running=$("${remote[@]}" "curl -s -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/$instance/allocations" |
100 python3 -c 'import json,sys; print(sum(x["ClientStatus"] == "running" for x in json.load(sys.stdin)))')
101 [[ $running == 0 ]] && break
102 sleep 2
103done
104[[ $running == 0 ]] || { echo "$instance still has a running allocation" >&2; exit 1; }
105
106snapshot="$dataset@before-arr-import-$(date +%s)-$$"
107"${remote[@]}" "zfs snapshot $snapshot"
108rsync -a --delete -e "ssh -p $target_port" "$scratch/config/" "$target_host:$root/config/"
109uid=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"$service\"])'")
110"${remote[@]}" "chown -R $uid:$uid $root/config; chmod 750 $root/config; python3 -c 'import sqlite3; c=sqlite3.connect(\"$root/config/$service.db\"); assert c.execute(\"PRAGMA integrity_check\").fetchone()[0] == \"ok\"'"
111local_hash=$(shasum -a 256 "$scratch/config/$service.db" | cut -d ' ' -f 1)
112remote_hash=$("${remote[@]}" "sha256sum $root/config/$service.db" | cut -d ' ' -f 1)
113[[ $local_hash == "$remote_hash" ]] || { echo 'Copied database hash differs from source backup' >&2; exit 1; }
114
115if [[ $instance == "$service" ]]; then
116 if [[ $offline == false ]]; then
117 [[ $(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' $service") == false ]] || {
118 echo "Zenith $service restarted during import; leave the home server stopped" >&2
119 exit 1
120 }
121 fi
122 echo "Imported production $service from Zenith. Previous dataset state: $snapshot"
123else
124 python3 "$(dirname "$0")/deploy.py" stage "$service"
125 "${remote[@]}" "zfs destroy $snapshot"
126 echo "Imported and tested $instance"
127fi
tools/import-dawarich.sh created+73
......@@ -0,0 +1,73 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4instance=${1:?usage: tools/import-dawarich.sh dawarich-preview-XXXXXXXX}
5[[ $instance =~ ^dawarich-preview-[0-9a-f]{8}$ ]] || {
6 echo 'Expected a Dawarich preview ID' >&2
7 exit 1
8}
9
10source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
11target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
12target_port=${STUDIO_DEPLOY_PORT:-2222}
13remote=(ssh -p "$target_port" "$target_host")
14
15root=/srv/staging/$instance
16source_id=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'")
17[[ $source_id == dawarich ]] || { echo 'Preview belongs to another service' >&2; exit 1; }
18snapshot=$(ssh "$source_host" '/usr/sbin/zfs list -H -t snapshot -o name -s creation -r storage1/apps | tail -n 1')
19[[ $snapshot == storage1/apps@* ]] || { echo 'No Zenith app snapshot is available' >&2; exit 1; }
20source_root=/mnt/storage1/apps/.zfs/snapshot/${snapshot#*@}/dawarich_storage
21
22dataset=$("${remote[@]}" "findmnt -n -o SOURCE --mountpoint $root")
23[[ $dataset == */staging/$instance ]] || { echo 'Preview dataset is not mounted' >&2; exit 1; }
24
25source_secret=$(ssh "$source_host" "sudo -n docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' dawarich-app" |
26 python3 -c 'import hashlib,sys; values=[line.split("=",1)[1] for line in sys.stdin.read().splitlines() if line.startswith("SECRET_KEY_BASE=")]; assert len(values)==1; print(hashlib.sha256(values[0].encode()).hexdigest())')
27own=$("${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad var get -out json nomad/jobs/$instance" |
28 python3 -c 'import hashlib,json,sys; print(hashlib.sha256(json.load(sys.stdin)["Items"]["secret_key_base"].encode()).hexdigest())')
29[[ $source_secret == "$own" ]] || { echo 'Dawarich secret differs from Zenith; import the original before restoring data' >&2; exit 1; }
30
31db_json=$("${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad var get -out json nomad/jobs/$instance/inputs/database")
32read -r database owner < <(python3 -c 'import json,sys; d=json.load(sys.stdin)["Items"]; print(d["name"], d["username"])' <<<"$db_json")
33[[ $database =~ ^dawarich_s_[0-9a-f]{8}$ && $owner == svc_$database ]] || { echo 'Unexpected preview database' >&2; exit 1; }
34
35allocation=$("${remote[@]}" 'NOMAD_TOKEN=$(cat /var/lib/studio/nomad.token) nomad job allocs -json postgres' |
36 python3 -c 'import json,sys; ids=[a["ID"] for a in json.load(sys.stdin) if a["ClientStatus"]=="running" and a["DesiredStatus"]=="run"]; assert len(ids)==1; print(ids[0])')
37container=app-$allocation
38podman='podman --url unix:///run/podman/podman.sock'
39scratch=$(mktemp -d)
40trap 'rm -rf "$scratch"' EXIT
41rsync -a "$source_host:$source_root/" "$scratch/"
42drift=$(rsync -rlnc --delete --out-format='%n' "$source_host:$source_root/" "$scratch/")
43[[ -z $drift ]] || { echo 'Zenith storage changed during copy; retry the import' >&2; exit 1; }
44source_counts=$(ssh "$source_host" 'sudo -n docker exec postgres psql -U postgres -d dawarich -At -c "SELECT (SELECT count(*) FROM points), (SELECT count(*) FROM users)"')
45
46"${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
47for _ in {1..30}; do
48 running=$("${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job allocs -json $instance" |
49 python3 -c 'import json,sys; print(sum(a["ClientStatus"]=="running" for a in json.load(sys.stdin)))')
50 [[ $running == 0 ]] && break
51 sleep 2
52done
53[[ $running == 0 ]] || { echo 'Dawarich allocation is still running' >&2; exit 1; }
54
55backup_snapshot=$dataset@before-dawarich-import-$(date +%s)-$$
56"${remote[@]}" "zfs snapshot $backup_snapshot"
57rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/var/app/storage/"
58uid=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"dawarich\"])'")
59"${remote[@]}" "chown -R $uid:$uid $root/var/app/storage"
60drift=$(rsync -rlnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/" "$target_host:$root/var/app/storage/")
61[[ -z $drift ]] || { echo "Storage copy differs from Zenith; restore $backup_snapshot" >&2; exit 1; }
62
63backup=/var/lib/studio/$instance-before-import.dump
64"${remote[@]}" "umask 077; $podman exec $container pg_dump -U postgres -Fc $database > $backup; test -s $backup"
65"${remote[@]}" "$podman exec $container psql -U postgres -d $database -v ON_ERROR_STOP=1 -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public AUTHORIZATION $owner; CREATE EXTENSION postgis;' >/dev/null"
66ssh "$source_host" 'sudo -n docker exec postgres pg_dump -U postgres -Fc --no-owner --no-acl --exclude-extension=postgis dawarich' |
67 "${remote[@]}" "$podman exec -i $container pg_restore -U postgres -d $database --no-owner --no-acl --role=$owner"
68target_counts=$("${remote[@]}" "$podman exec $container psql -U postgres -d $database -At -c 'SELECT (SELECT count(*) FROM points), (SELECT count(*) FROM users)'")
69[[ $source_counts == "$target_counts" ]] || { echo "Database counts differ from Zenith; restore $backup" >&2; exit 1; }
70
71python3 "$(dirname "$0")/deploy.py" stage dawarich
72"${remote[@]}" "zfs destroy $backup_snapshot"
73echo "Imported $instance from $snapshot. Previous database: $backup"
tools/import-evil-forgejo.sh created+77
......@@ -0,0 +1,77 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4: "${STUDIO_DEPLOY_HOST:?Set STUDIO_DEPLOY_HOST to the production target}"
5: "${STUDIO_LEGACY_HANDOFF:?Set STUDIO_LEGACY_HANDOFF to the offline PostgreSQL handoff}"
6
7target_port=${STUDIO_DEPLOY_PORT:-22}
8remote=(ssh -p "$target_port" "$STUDIO_DEPLOY_HOST")
9sh "$(dirname "$0")/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$STUDIO_DEPLOY_HOST" "$target_port"
10root=/srv/prod/evil-forgejo
11source_dir=/mnt/storage1/apps/evil-infra/forgejo
12database=evil_forgejo
13owner=svc_evil_forgejo
14podman='podman --url unix:///run/podman/podman.sock'
15
16entry=$("${remote[@]}" "python3 - '$STUDIO_LEGACY_HANDOFF' evil-forgejo" < "$(dirname "$0")/verify-legacy-dump.py")
17source_counts=$(python3 -c 'import json,sys; print("|".join(map(str,json.load(sys.stdin)["counts"])))' <<<"$entry")
18"${remote[@]}" "test \$(findmnt -n -o FSTYPE --mountpoint $root) = zfs; test -d $source_dir"
19
20response=$("${remote[@]}" 'curl -s -w "\n%{http_code}" -H "X-Nomad-Token: $(cat /var/lib/studio/nomad.token)" http://127.0.0.1:4646/v1/job/evil-forgejo')
21[[ ${response##*$'\n'} == 200 ]] || { echo 'evil.inc Forgejo is unavailable on the home server' >&2; exit 1; }
22stopped=$(python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())' <<<"${response%$'\n'*}")
23[[ $stopped == true ]] || { echo 'Stop evil.inc Forgejo on the home server before importing' >&2; exit 1; }
24for _ in {1..30}; do
25 running=$("${remote[@]}" 'NOMAD_TOKEN=$(cat /var/lib/studio/nomad.token) nomad job allocs -json evil-forgejo' |
26 python3 -c 'import json,sys; print(sum(a["ClientStatus"] == "running" for a in json.load(sys.stdin)))')
27 [[ $running == 0 ]] && break
28 sleep 2
29done
30[[ $running == 0 ]] || { echo 'evil.inc Forgejo did not stop on the home server' >&2; exit 1; }
31
32db_json=$("${remote[@]}" 'NOMAD_TOKEN=$(cat /var/lib/studio/nomad.token) nomad var get -out json nomad/jobs/evil-forgejo/inputs/database')
33read -r actual_database actual_owner < <(python3 -c 'import json,sys; d=json.load(sys.stdin)["Items"]; print(d["name"], d["username"])' <<<"$db_json")
34[[ $actual_database == "$database" && $actual_owner == "$owner" ]] || { echo 'Unexpected evil.inc Forgejo database on the home server' >&2; exit 1; }
35
36"${remote[@]}" "python3 - '$STUDIO_LEGACY_HANDOFF'" <<'PY'
37import hashlib
38import json
39import os
40from pathlib import Path
41import subprocess
42import sys
43
44legacy = json.loads((Path(sys.argv[1]) / "manifest.json").read_text())["databases"]["evil-forgejo"]["secretSha256"]
45environment = {**os.environ, "NOMAD_TOKEN": Path("/var/lib/studio/nomad.token").read_text().strip()}
46result = subprocess.run(["nomad", "var", "get", "-out", "json", "nomad/jobs/evil-forgejo"],
47 check=True, capture_output=True, text=True, env=environment)
48actual = json.loads(result.stdout)["Items"]
49for name, fingerprint in legacy.items():
50 if name not in actual or hashlib.sha256(actual[name].encode()).hexdigest() != fingerprint:
51 raise ValueError(f"Import the original evil.inc Forgejo secret before restoring data: {name}")
52PY
53
54allocation=$("${remote[@]}" 'NOMAD_TOKEN=$(cat /var/lib/studio/nomad.token) nomad job allocs -json postgres' |
55 python3 -c 'import json,sys; ids=[a["ID"] for a in json.load(sys.stdin) if a["ClientStatus"] == "running" and a["DesiredStatus"] == "run"]; assert len(ids) == 1; print(ids[0])')
56container=app-$allocation
57source_manifest=$("${remote[@]}" "python3 - '$source_dir'" < "$(dirname "$0")/tree-hash.py")
58source_bytes=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["bytes"])' <<<"$source_manifest")
59available=$("${remote[@]}" "df -B1 --output=avail $root | tail -n 1")
60(( available > source_bytes )) || { echo 'Too little free space for the evil.inc Forgejo app directory' >&2; exit 1; }
61
62dataset=$("${remote[@]}" "findmnt -n -o SOURCE --mountpoint $root")
63[[ $dataset == */prod/evil-forgejo ]] || { echo 'Unexpected production dataset' >&2; exit 1; }
64snapshot=$dataset@before-evil-forgejo-import-$(date +%s)-$$
65"${remote[@]}" "set -e; zfs snapshot '$snapshot'; mkdir -p '$root/data'; rsync -aH --numeric-ids --one-file-system --delete '$source_dir/' '$root/data/'"
66target_manifest=$("${remote[@]}" "python3 - '$root/data'" < "$(dirname "$0")/tree-hash.py")
67[[ $source_manifest == "$target_manifest" ]] || { echo "Forgejo app-directory copy differs; restore $snapshot" >&2; exit 1; }
68uid=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"evil-forgejo\"])'")
69"${remote[@]}" "chown -R $uid:$uid '$root/data'"
70
71backup=$root/before-import-$(date +%s)-$$.dump
72"${remote[@]}" "umask 077; $podman exec $container pg_dump -U postgres -Fc $database > '$backup'; test -s '$backup'"
73"${remote[@]}" "$podman exec $container psql -U postgres -d $database -v ON_ERROR_STOP=1 -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public AUTHORIZATION $owner;' >/dev/null"
74"${remote[@]}" "set -o pipefail; cat '$STUDIO_LEGACY_HANDOFF/evil-forgejo.dump' | $podman exec -i $container pg_restore -U postgres -d $database --no-owner --no-acl --role=$owner"
75counts=$("${remote[@]}" "$podman exec $container psql -U postgres -d $database -At -c 'SELECT (SELECT count(*) FROM \"user\"), (SELECT count(*) FROM repository)'")
76[[ $counts == "$source_counts" ]] || { echo "Forgejo database counts differ; restore $backup" >&2; exit 1; }
77echo "Imported evil.inc Forgejo app files and database; verified user/repository counts: $counts. Previous dataset: $snapshot. Previous database: $backup"
tools/import-hedgedoc.sh created+123
......@@ -0,0 +1,123 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4instance=${1:?usage: tools/import-hedgedoc.sh evil-hedgedoc[-preview-XXXXXXXX]}
5[[ $instance == evil-hedgedoc || $instance =~ ^evil-hedgedoc-preview-[0-9a-f]{8}$ ]] || { echo 'Expected a HedgeDoc service or preview ID' >&2; exit 1; }
6if [[ $instance == evil-hedgedoc ]]; then
7 root=/srv/prod/evil-hedgedoc
8else
9 root="/srv/staging/$instance"
10fi
11
12source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
13target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
14target_port=${STUDIO_DEPLOY_PORT:-2222}
15remote=(ssh -p "$target_port" "$target_host")
16path="nomad/jobs/$instance/inputs/database"
17handoff=${STUDIO_LEGACY_HANDOFF:-}
18if [[ -n $handoff ]]; then
19 [[ $instance == evil-hedgedoc && -n ${STUDIO_DEPLOY_HOST:-} ]] || {
20 echo 'Offline handoff requires production HedgeDoc and a target' >&2
21 exit 1
22 }
23 sh "$(dirname "$0")/check-legacy-handoff.sh" "$handoff" "$target_host" "$target_port"
24 entry=$("${remote[@]}" "python3 - '$handoff' evil-hedgedoc" < "$(dirname "$0")/verify-legacy-dump.py")
25 source_counts=$(python3 -c 'import json,sys; print("|".join(map(str,json.load(sys.stdin)["counts"])))' <<<"$entry")
26 upload_host=$target_host
27 upload_rsh="ssh -p $target_port"
28else
29 upload_host=$source_host
30 upload_rsh=ssh
31fi
32
33if [[ $instance != evil-hedgedoc ]]; then
34 "${remote[@]}" "test -f /var/lib/studio/stages/$instance.json"
35else
36 [[ -n ${STUDIO_DEPLOY_HOST:-} ]] || { echo 'Set STUDIO_DEPLOY_HOST to the production target' >&2; exit 1; }
37 if [[ -z $handoff ]]; then
38 source_state=$(ssh "$source_host" "sudo -n docker ps -a --filter label=com.docker.compose.service=evil-hedgedoc --format '{{.State}}'")
39 [[ $source_state == exited ]] || { echo 'Stop Zenith evil-hedgedoc before importing production data' >&2; exit 1; }
40 fi
41 job_response=$("${remote[@]}" 'curl -s -w "\n%{http_code}" -H "X-Nomad-Token: $(cat /var/lib/studio/nomad.token)" http://127.0.0.1:4646/v1/job/evil-hedgedoc')
42 job_status=${job_response##*$'\n'}
43 if [[ $job_status == 200 ]]; then
44 stopped=$(python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())' <<<"${job_response%$'\n'*}")
45 [[ $stopped == true ]] || { echo 'Stop production HedgeDoc before importing' >&2; exit 1; }
46 for _ in {1..30}; do
47 running=$("${remote[@]}" 'curl -s -H "X-Nomad-Token: $(cat /var/lib/studio/nomad.token)" http://127.0.0.1:4646/v1/job/evil-hedgedoc/allocations' | python3 -c 'import json,sys; print(sum(x["ClientStatus"] == "running" for x in json.load(sys.stdin)))')
48 [[ $running == 0 ]] && break
49 sleep 2
50 done
51 [[ $running == 0 ]] || { echo 'Production HedgeDoc allocation did not stop' >&2; exit 1; }
52 elif [[ $job_status != 404 ]]; then
53 echo "Could not verify production job state: $job_status" >&2
54 exit 1
55 fi
56fi
57"${remote[@]}" "test \$(findmnt -n -o FSTYPE --mountpoint $root) = zfs"
58db_json=$("${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad var get -out json $path")
59read -r database owner < <(python3 -c 'import json,sys; d=json.load(sys.stdin)["Items"]; print(d["name"], d["username"])' <<<"$db_json")
60if [[ $instance == evil-hedgedoc ]]; then
61 [[ $database == evil_hedgedoc && $owner == svc_evil_hedgedoc ]] || { echo 'Unexpected production database' >&2; exit 1; }
62else
63 [[ $database =~ ^evil_hedgedoc_s_[0-9a-f]{8}$ && $owner =~ ^svc_evil_hedgedoc_s_[0-9a-f]{8}$ ]] || { echo 'Unexpected preview database' >&2; exit 1; }
64fi
65
66alloc_json=$("${remote[@]}" 'NOMAD_TOKEN=$(cat /var/lib/studio/nomad.token) nomad job allocs -json postgres')
67allocation=$(python3 -c 'import json,sys; ids=[x["ID"] for x in json.load(sys.stdin) if x["ClientStatus"] == "running" and x["DesiredStatus"] == "run"]; assert len(ids) == 1; print(ids[0])' <<<"$alloc_json")
68container="app-$allocation"
69podman='podman --url unix:///run/podman/podman.sock'
70scratch=$(mktemp -d)
71trap 'rm -rf "$scratch"' EXIT
72rsync -a -e "$upload_rsh" "$upload_host:/mnt/storage1/apps/evil-infra/hedgedoc/" "$scratch/uploads/"
73drift=$(rsync -rnc --delete --out-format='%n' -e "$upload_rsh" "$upload_host:/mnt/storage1/apps/evil-infra/hedgedoc/" "$scratch/uploads/")
74[[ -z $drift ]] || { echo 'Source uploads changed during copy' >&2; exit 1; }
75
76if [[ $instance != evil-hedgedoc ]]; then
77 "${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
78 for _ in {1..30}; do
79 running=$("${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job allocs -json $instance" | python3 -c 'import json,sys; print(sum(x["ClientStatus"] == "running" for x in json.load(sys.stdin)))')
80 [[ $running == 0 ]] && break
81 sleep 2
82 done
83 [[ $running == 0 ]] || { echo 'Preview did not stop' >&2; exit 1; }
84fi
85
86uploads="$root/hedgedoc/public/uploads"
87rsync -a --delete -e "ssh -p $target_port" "$scratch/uploads/" "$target_host:$uploads/"
88drift=$(rsync -rnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/uploads/" "$target_host:$uploads/")
89[[ -z $drift ]] || { echo 'Target uploads differ from source copy' >&2; exit 1; }
90uid=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"evil-hedgedoc\"])'")
91"${remote[@]}" "chown -R $uid:$uid $uploads; chmod 750 $uploads"
92
93backup="/var/lib/studio/$instance-before-import.dump"
94"${remote[@]}" "umask 077; $podman exec $container pg_dump -U postgres -Fc $database > $backup; test -s $backup"
95"${remote[@]}" "$podman exec $container psql -U postgres -d $database -v ON_ERROR_STOP=1 -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public AUTHORIZATION $owner;' >/dev/null"
96
97if [[ -n $handoff ]]; then
98 "${remote[@]}" "set -o pipefail; cat '$handoff/evil-hedgedoc.dump' | $podman exec -i $container pg_restore -U postgres -d $database --no-owner --no-acl --role=$owner"
99else
100 ssh "$source_host" 'sudo -n docker exec postgres pg_dump -U postgres -Fc evil-hedgedoc' |
101 "${remote[@]}" "$podman exec -i $container pg_restore -U postgres -d $database --no-owner --no-acl --role=$owner"
102fi
103
104counts_sql='SELECT (SELECT count(*) FROM "Notes"), (SELECT count(*) FROM "Users"), (SELECT count(*) FROM "Revisions"), (SELECT count(*) FROM "Authors");'
105if [[ -z $handoff ]]; then
106 source_counts=$(printf '%s\n' "$counts_sql" | ssh "$source_host" sudo -n docker exec -i postgres psql -U postgres -d evil-hedgedoc -At)
107fi
108target_counts=$(printf '%s\n' "$counts_sql" | "${remote[@]}" "$podman exec -i $container psql -U postgres -d $database -At")
109[[ $source_counts == "$target_counts" ]] || { echo 'Restored HedgeDoc record counts differ from Zenith' >&2; exit 1; }
110echo "Verified HedgeDoc Notes, Users, Revisions, and Authors counts: $target_counts"
111
112if [[ $instance == evil-hedgedoc ]]; then
113 if [[ -z $handoff ]]; then
114 [[ $(ssh "$source_host" "sudo -n docker ps -a --filter label=com.docker.compose.service=evil-hedgedoc --format '{{.State}}'") == exited ]] || {
115 echo 'Zenith evil-hedgedoc restarted during import; leave the home server stopped' >&2
116 exit 1
117 }
118 fi
119 echo "Production data imported; promote a tested HedgeDoc preview. Previous database: $backup"
120else
121 python3 "$(dirname "$0")/deploy.py" stage evil-hedgedoc
122 echo "Preview database imported; previous preview dump: $backup"
123fi
tools/import-jackett.sh created+100
......@@ -0,0 +1,100 @@
1#!/bin/sh
2set -eu
3
4instance=${1:?usage: tools/import-jackett.sh jackett|jackett-preview-XXXXXXXX}
5case $instance in
6 jackett)
7 test -n "${STUDIO_DEPLOY_HOST:-}" || { echo 'Set STUDIO_DEPLOY_HOST to the production target' >&2; exit 1; }
8 production=true
9 ;;
10 jackett-preview-*)
11 printf '%s\n' "$instance" | grep -Eq '^jackett-preview-[0-9a-f]{8}$' || {
12 echo 'Expected a Jackett preview ID' >&2; exit 1;
13 }
14 production=false
15 ;;
16 *) echo 'Expected jackett or its preview ID' >&2; exit 1 ;;
17esac
18
19source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
20target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
21target_port=${STUDIO_DEPLOY_PORT:-2222}
22offline=false
23copy_host=$source_host
24copy_rsh=ssh
25if [ -n "${STUDIO_LEGACY_HANDOFF:-}" ]; then
26 test "$production" = true || { echo 'Offline handoff requires production Jackett' >&2; exit 1; }
27 sh "$(dirname "$0")/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$target_host" "$target_port"
28 offline=true
29 copy_host=$target_host
30 copy_rsh="ssh -p $target_port"
31fi
32if [ "$production" = true ]; then
33 root=/srv/prod/jackett/config/Jackett
34 mount=/srv/prod/jackett
35 source_root=/mnt/storage1/apps/jackett
36 if [ "$offline" = false ]; then
37 test "$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' jackett")" = false || {
38 echo 'Stop Zenith jackett before importing production data' >&2; exit 1;
39 }
40 fi
41 response=$(ssh -p "$target_port" "$target_host" 'curl -s -w "\n%{http_code}" -H "X-Nomad-Token: $(cat /var/lib/studio/nomad.token)" http://127.0.0.1:4646/v1/job/jackett')
42 status=$(printf '%s\n' "$response" | tail -n 1)
43 test "$status" = 200 || { echo "Could not verify jackett on the home server: $status" >&2; exit 1; }
44 stopped=$(printf '%s\n' "$response" | sed '$d' | python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())')
45 test "$stopped" = true || { echo 'Stop jackett on the home server before importing production data' >&2; exit 1; }
46else
47 root="/srv/staging/$instance/config/Jackett"
48 mount="/srv/staging/$instance"
49 source_id=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'")
50 test "$source_id" = jackett || { echo 'Preview belongs to another service' >&2; exit 1; }
51 source_snapshot=$(ssh "$source_host" '/usr/sbin/zfs list -H -t snapshot -o name -s creation -r storage1/apps | tail -n 1')
52 case $source_snapshot in storage1/apps@*) ;; *) echo 'No apps ZFS snapshot is available' >&2; exit 1 ;; esac
53 source_root="/mnt/storage1/apps/.zfs/snapshot/${source_snapshot#*@}/jackett"
54fi
55
56dataset=$(ssh -p "$target_port" "$target_host" "findmnt -n -o SOURCE --mountpoint $mount")
57if [ "$production" = true ]; then
58 case $dataset in */prod/jackett) ;; *) echo 'Production dataset is not mounted' >&2; exit 1 ;; esac
59else
60 case $dataset in */staging/"$instance") ;; *) echo 'Preview dataset is not mounted' >&2; exit 1 ;; esac
61fi
62
63scratch=$(mktemp -d)
64trap 'rm -rf "$scratch"' EXIT
65rsync -a --exclude='/log.txt*' -e "$copy_rsh" "$copy_host:$source_root/" "$scratch/"
66python3 - "$scratch" <<'PY'
67import json
68from pathlib import Path
69import sys
70
71root = Path(sys.argv[1])
72config = json.loads((root / "ServerConfig.json").read_text())
73if not config.get("APIKey") or not list((root / "Indexers").glob("*.json")):
74 raise ValueError("Jackett configuration is incomplete")
75PY
76
77if [ "$production" = false ]; then
78 ssh -p "$target_port" "$target_host" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
79fi
80snapshot="$dataset@before-jackett-import-$(date +%s)-$$"
81ssh -p "$target_port" "$target_host" "set -eu; for i in \$(seq 1 30); do running=\$(curl -fsS -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/$instance/allocations | python3 -c 'import json,sys; print(sum(a[\"ClientStatus\"] == \"running\" for a in json.load(sys.stdin)))'); test \"\$running\" = 0 && break; sleep 2; done; test \"\$running\" = 0; zfs snapshot $snapshot"
82
83rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/"
84owner=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"jackett\"])'")
85ssh -p "$target_port" "$target_host" "chown -R $owner:$owner $root"
86drift=$(rsync -rlnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/" "$target_host:$root/")
87test -z "$drift" || { echo "Jackett copy differs from source; restore $snapshot" >&2; exit 1; }
88
89if [ "$production" = true ]; then
90 if [ "$offline" = false ]; then
91 test "$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' jackett")" = false || {
92 echo 'Zenith jackett restarted during import; leave the home server stopped' >&2; exit 1;
93 }
94 fi
95 echo "Imported production Jackett. Previous dataset state: $snapshot"
96else
97 python3 "$(dirname "$0")/deploy.py" stage jackett
98 ssh -p "$target_port" "$target_host" "zfs destroy $snapshot"
99 echo "Imported and tested $instance from $source_snapshot"
100fi
tools/import-jellyfin.sh created+103
......@@ -0,0 +1,103 @@
1#!/bin/sh
2set -eu
3
4instance=${1:?usage: tools/import-jellyfin.sh jellyfin|jellyfin-preview-XXXXXXXX}
5case $instance in
6 jellyfin)
7 test -n "${STUDIO_DEPLOY_HOST:-}" || { echo 'Set STUDIO_DEPLOY_HOST to the production target' >&2; exit 1; }
8 production=true
9 ;;
10 jellyfin-preview-*)
11 printf '%s\n' "$instance" | grep -Eq '^jellyfin-preview-[0-9a-f]{8}$' || {
12 echo 'Expected a Jellyfin preview ID' >&2; exit 1;
13 }
14 production=false
15 ;;
16 *) echo 'Expected jellyfin or its preview ID' >&2; exit 1 ;;
17esac
18
19source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
20target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
21target_port=${STUDIO_DEPLOY_PORT:-2222}
22offline=false
23if [ -n "${STUDIO_LEGACY_HANDOFF:-}" ]; then
24 test "$production" = true || { echo 'Offline handoff requires production Jellyfin' >&2; exit 1; }
25 sh "$(dirname "$0")/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$target_host" "$target_port"
26 offline=true
27fi
28if [ "$production" = true ]; then
29 root=/srv/prod/jellyfin/config
30 mount=/srv/prod/jellyfin
31 source_root=/mnt/storage1/apps/jellyfin
32 if [ "$offline" = false ]; then
33 test "$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' jellyfin")" = false || {
34 echo 'Stop Zenith jellyfin before importing production data' >&2; exit 1;
35 }
36 fi
37 response=$(ssh -p "$target_port" "$target_host" 'curl -s -w "\n%{http_code}" -H "X-Nomad-Token: $(cat /var/lib/studio/nomad.token)" http://127.0.0.1:4646/v1/job/jellyfin')
38 status=$(printf '%s\n' "$response" | tail -n 1)
39 test "$status" = 200 || { echo "Could not verify jellyfin on the home server: $status" >&2; exit 1; }
40 stopped=$(printf '%s\n' "$response" | sed '$d' | python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())')
41 test "$stopped" = true || { echo 'Stop jellyfin on the home server before importing production data' >&2; exit 1; }
42 media_fs=$(ssh -p "$target_port" "$target_host" 'findmnt -n -o FSTYPE --mountpoint /srv/clover/Media')
43 test "$media_fs" = zfs || { echo 'Mount the real Media ZFS dataset before importing production data' >&2; exit 1; }
44 source_label=stopped-live-source
45 if [ "$offline" = true ]; then source_label=mounted-old-apps; fi
46else
47 root="/srv/staging/$instance/config"
48 mount="/srv/staging/$instance"
49 source_id=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'")
50 test "$source_id" = jellyfin || { echo 'Preview belongs to another service' >&2; exit 1; }
51 source_snapshot=$(ssh "$source_host" '/usr/sbin/zfs list -H -t snapshot -o name -s creation -r storage1/apps | tail -n 1')
52 case $source_snapshot in storage1/apps@*) ;; *) echo 'No apps ZFS snapshot is available' >&2; exit 1 ;; esac
53 source_label=${source_snapshot#*@}
54 source_root="/mnt/storage1/apps/.zfs/snapshot/$source_label/jellyfin"
55fi
56
57dataset=$(ssh -p "$target_port" "$target_host" "findmnt -n -o SOURCE --mountpoint $mount")
58if [ "$production" = true ]; then
59 case $dataset in */prod/jellyfin) ;; *) echo 'Production dataset is not mounted' >&2; exit 1 ;; esac
60else
61 case $dataset in */staging/"$instance") ;; *) echo 'Preview dataset is not mounted' >&2; exit 1 ;; esac
62fi
63
64if [ "$offline" = false ]; then
65 scratch=$(mktemp -d)
66 trap 'rm -rf "$scratch"' EXIT
67 rsync -a --exclude='/cache/' --exclude='/log/' --exclude='/transcodes/' --exclude='*-shm' \
68 "$source_host:$source_root/" "$scratch/"
69fi
70
71if [ "$production" = false ]; then
72 ssh -p "$target_port" "$target_host" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
73fi
74snapshot="$dataset@before-jellyfin-import-$(date +%s)-$$"
75ssh -p "$target_port" "$target_host" "set -eu; for i in \$(seq 1 30); do running=\$(curl -fsS -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/$instance/allocations | python3 -c 'import json,sys; print(sum(a[\"ClientStatus\"] == \"running\" for a in json.load(sys.stdin)))'); test \"\$running\" = 0 && break; sleep 2; done; test \"\$running\" = 0; zfs snapshot $snapshot"
76
77if [ "$offline" = true ]; then
78 ssh -p "$target_port" "$target_host" "rsync -a --delete --delete-excluded --exclude='/cache/' --exclude='/log/' --exclude='/transcodes/' --exclude='*-shm' '$source_root/' '$root/'"
79else
80 rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/"
81fi
82owner=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"jellyfin\"])'")
83ssh -p "$target_port" "$target_host" "chown -R $owner:$owner $root"
84if [ "$offline" = true ]; then
85 drift=$(ssh -p "$target_port" "$target_host" "rsync -rlnc --delete --delete-excluded --exclude='/cache/' --exclude='/log/' --exclude='/transcodes/' --exclude='*-shm' --out-format='%n' '$source_root/' '$root/'")
86else
87 drift=$(rsync -rlnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/" "$target_host:$root/")
88fi
89test -z "$drift" || { echo "Jellyfin copy differs from source; restore $snapshot" >&2; exit 1; }
90ssh -p "$target_port" "$target_host" "python3 -c 'import sqlite3; from pathlib import Path; root=Path(\"$root/data\"); assert all((root / name).is_file() and sqlite3.connect(\"file:\" + str(root / name) + \"?mode=ro\", uri=True).execute(\"PRAGMA integrity_check\").fetchone()[0] == \"ok\" for name in (\"jellyfin.db\", \"introskipper/introskipper.db\"))'"
91
92if [ "$production" = true ]; then
93 if [ "$offline" = false ]; then
94 test "$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' jellyfin")" = false || {
95 echo 'Zenith jellyfin restarted during import; leave the home server stopped' >&2; exit 1;
96 }
97 fi
98 echo "Imported production Jellyfin from $source_label. Previous dataset state: $snapshot"
99else
100 python3 "$(dirname "$0")/deploy.py" stage jellyfin
101 ssh -p "$target_port" "$target_host" "zfs destroy $snapshot"
102 echo "Imported and tested $instance from $source_snapshot"
103fi
tools/import-legacy-secrets.sh created+54
......@@ -0,0 +1,54 @@
1#!/bin/bash
2set -euo pipefail
3
4service=${1:?usage: tools/import-legacy-secrets.sh SERVICE ENV_NAME...|KEY=ENV_NAME...}
5shift
6(( $# > 0 )) || { echo 'Expected at least one environment variable name' >&2; exit 1; }
7[[ $service =~ ^[a-z][a-z0-9-]*$ ]] || { echo 'Invalid service name' >&2; exit 1; }
8sources=()
9keys=()
10for item in "$@"; do
11 if [[ $item == *=* ]]; then
12 key=${item%%=*}
13 source=${item#*=}
14 [[ $key =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { echo 'Invalid secret key' >&2; exit 1; }
15 keys+=("$key")
16 else
17 source=$item
18 fi
19 [[ $source =~ ^[A-Z][A-Z0-9_]*$ ]] || { echo 'Invalid environment variable name' >&2; exit 1; }
20 sources+=("$source")
21done
22(( ${#keys[@]} == 0 || ${#keys[@]} == ${#sources[@]} )) || { echo 'Use a target key for every source name' >&2; exit 1; }
23options=()
24for key in "${keys[@]}"; do
25 options+=(--key "$key")
26done
27: "${STUDIO_DEPLOY_HOST:?Set STUDIO_DEPLOY_HOST to the production target}"
28
29source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
30repo=$(cd "$(dirname "$0")/.." && pwd)
31names=$(printf ' %s' "${sources[@]}")
32if [[ -n ${STUDIO_LEGACY_HANDOFF:-} ]]; then
33 sh "$repo/tools/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$STUDIO_DEPLOY_HOST" "${STUDIO_DEPLOY_PORT:-22}"
34 source_remote=(ssh -p "${STUDIO_DEPLOY_PORT:-22}" "$STUDIO_DEPLOY_HOST")
35else
36 source_remote=(ssh "$source_host")
37fi
38
39"${source_remote[@]}" "python3 -$names <<'PY'
40from pathlib import Path
41import sys
42
43lines = Path('/mnt/storage1/apps/home-infra/.env').read_text().splitlines()
44if len(set(sys.argv[1:])) != len(sys.argv[1:]):
45 raise SystemExit('Duplicate source secret name')
46secrets = []
47for name in sys.argv[1:]:
48 values = [line.split('=', 1)[1] for line in lines if line.startswith(name + '=')]
49 if len(values) != 1 or not values[0]:
50 raise SystemExit('Expected one nonempty source secret: ' + name)
51 secrets.append(values[0])
52for value in secrets:
53 print(value)
54PY" | python3 "$repo/tools/deploy.py" secrets "$service" --file - "${options[@]}"
tools/import-navidrome.sh created+102
......@@ -0,0 +1,102 @@
1#!/bin/sh
2set -eu
3
4instance=${1:?usage: tools/import-navidrome.sh navidrome|navidrome-preview-XXXXXXXX}
5case $instance in
6 navidrome)
7 test -n "${STUDIO_DEPLOY_HOST:-}" || { echo 'Set STUDIO_DEPLOY_HOST to the production target' >&2; exit 1; }
8 production=true
9 ;;
10 navidrome-preview-*)
11 printf '%s\n' "$instance" | grep -Eq '^navidrome-preview-[0-9a-f]{8}$' || {
12 echo 'Expected a Navidrome preview ID' >&2; exit 1;
13 }
14 production=false
15 ;;
16 *) echo 'Expected navidrome or its preview ID' >&2; exit 1 ;;
17esac
18
19source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
20target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
21target_port=${STUDIO_DEPLOY_PORT:-2222}
22offline=false
23if [ -n "${STUDIO_LEGACY_HANDOFF:-}" ]; then
24 test "$production" = true || { echo 'Offline handoff requires production Navidrome' >&2; exit 1; }
25 sh "$(dirname "$0")/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$target_host" "$target_port"
26 offline=true
27fi
28if [ "$production" = true ]; then
29 root=/srv/prod/navidrome/data
30 mount=/srv/prod/navidrome
31 source_root=/mnt/storage1/apps/navidrone
32 if [ "$offline" = false ]; then
33 test "$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' navidrome")" = false || {
34 echo 'Stop Zenith navidrome before importing production data' >&2; exit 1;
35 }
36 fi
37 response=$(ssh -p "$target_port" "$target_host" 'curl -s -w "\n%{http_code}" -H "X-Nomad-Token: $(cat /var/lib/studio/nomad.token)" http://127.0.0.1:4646/v1/job/navidrome')
38 status=$(printf '%s\n' "$response" | tail -n 1)
39 test "$status" = 200 || { echo "Could not verify navidrome on the home server: $status" >&2; exit 1; }
40 stopped=$(printf '%s\n' "$response" | sed '$d' | python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())')
41 test "$stopped" = true || { echo 'Stop navidrome on the home server before importing production data' >&2; exit 1; }
42 media_fs=$(ssh -p "$target_port" "$target_host" 'findmnt -n -o FSTYPE --mountpoint /srv/clover/Media')
43 test "$media_fs" = zfs || { echo 'Mount the real Media ZFS dataset before importing production data' >&2; exit 1; }
44else
45 root="/srv/staging/$instance/data"
46 mount="/srv/staging/$instance"
47 source_id=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'")
48 test "$source_id" = navidrome || { echo 'Preview belongs to another service' >&2; exit 1; }
49 source_snapshot=$(ssh "$source_host" '/usr/sbin/zfs list -H -t snapshot -o name -s creation -r storage1/apps | tail -n 1')
50 case $source_snapshot in storage1/apps@*) ;; *) echo 'No apps ZFS snapshot is available' >&2; exit 1 ;; esac
51 source_root="/mnt/storage1/apps/.zfs/snapshot/${source_snapshot#*@}/navidrone"
52fi
53
54dataset=$(ssh -p "$target_port" "$target_host" "findmnt -n -o SOURCE --mountpoint $mount")
55if [ "$production" = true ]; then
56 case $dataset in */prod/navidrome) ;; *) echo 'Production dataset is not mounted' >&2; exit 1 ;; esac
57else
58 case $dataset in */staging/"$instance") ;; *) echo 'Preview dataset is not mounted' >&2; exit 1 ;; esac
59fi
60
61if [ "$offline" = true ]; then
62 ssh -p "$target_port" "$target_host" "test -s '$source_root/navidrome.db'" || { echo 'Navidrome database is missing' >&2; exit 1; }
63else
64 scratch=$(mktemp -d)
65 trap 'rm -rf "$scratch"' EXIT
66 rsync -a --exclude='*-shm' "$source_host:$source_root/" "$scratch/"
67 test -s "$scratch/navidrome.db" || { echo 'Navidrome database is missing' >&2; exit 1; }
68fi
69
70if [ "$production" = false ]; then
71 ssh -p "$target_port" "$target_host" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
72fi
73snapshot="$dataset@before-navidrome-import-$(date +%s)-$$"
74ssh -p "$target_port" "$target_host" "set -eu; for i in \$(seq 1 30); do running=\$(curl -fsS -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/$instance/allocations | python3 -c 'import json,sys; print(sum(a[\"ClientStatus\"] == \"running\" for a in json.load(sys.stdin)))'); test \"\$running\" = 0 && break; sleep 2; done; test \"\$running\" = 0; zfs snapshot $snapshot"
75
76if [ "$offline" = true ]; then
77 ssh -p "$target_port" "$target_host" "rsync -a --delete --delete-excluded --exclude='*-shm' '$source_root/' '$root/'"
78else
79 rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/"
80fi
81owner=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"navidrome\"])'")
82ssh -p "$target_port" "$target_host" "chown -R $owner:$owner $root"
83if [ "$offline" = true ]; then
84 drift=$(ssh -p "$target_port" "$target_host" "rsync -rlnc --delete --delete-excluded --exclude='*-shm' --out-format='%n' '$source_root/' '$root/'")
85else
86 drift=$(rsync -rlnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/" "$target_host:$root/")
87fi
88test -z "$drift" || { echo "Navidrome copy differs from source; restore $snapshot" >&2; exit 1; }
89ssh -p "$target_port" "$target_host" "python3 -c 'import sqlite3; db=sqlite3.connect(\"file:$root/navidrome.db?mode=ro\", uri=True); assert db.execute(\"PRAGMA integrity_check\").fetchone()[0] == \"ok\"; assert db.execute(\"select count(*) from user where user_name=\\\"snow\\\" and is_admin=1\").fetchone()[0] == 1'"
90
91if [ "$production" = true ]; then
92 if [ "$offline" = false ]; then
93 test "$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' navidrome")" = false || {
94 echo 'Zenith navidrome restarted during import; leave the home server stopped' >&2; exit 1;
95 }
96 fi
97 echo "Imported production Navidrome. Previous dataset state: $snapshot"
98else
99 python3 "$(dirname "$0")/deploy.py" stage navidrome
100 ssh -p "$target_port" "$target_host" "zfs destroy $snapshot"
101 echo "Imported and tested $instance from $source_snapshot"
102fi
tools/import-pds-secrets.py created+44
......@@ -0,0 +1,44 @@
1#!/usr/bin/env python3
2import os
3from pathlib import Path
4import re
5import subprocess
6
7
8if not os.environ.get("STUDIO_DEPLOY_HOST"):
9 raise SystemExit("Set STUDIO_DEPLOY_HOST to the production target")
10
11if os.environ.get("STUDIO_LEGACY_HANDOFF"):
12 ssh = ["ssh", "-o", "BatchMode=yes", "-p", os.environ.get("STUDIO_DEPLOY_PORT", "22"), os.environ["STUDIO_DEPLOY_HOST"]]
13else:
14 ssh = ["ssh", "-o", "BatchMode=yes", os.environ.get("STUDIO_MIGRATION_SOURCE", "zenith")]
15
16source = subprocess.run(
17 [*ssh, "cat", "/mnt/storage1/apps/home-infra/.env"],
18 check=True, capture_output=True, text=True,
19).stdout
20names = {
21 "PDS_JWT_SECRET": "jwt_secret",
22 "PDS_ADMIN_PASSWORD": "admin_password",
23 "PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX": "plc_rotation_key",
24 "MAILER_ADDRESS": "mailer_address",
25 "MAILER_USERNAME": "mailer_username",
26 "MAILER_PASSWORD": "mailer_password",
27}
28values = {}
29for line in source.splitlines():
30 key, separator, value = line.partition("=")
31 key = key.strip()
32 if separator and key in names:
33 if names[key] in values:
34 raise ValueError(f"duplicate source secret: {key}")
35 values[names[key]] = value.strip()
36if len(values) != len(names) or not all(values.values()):
37 raise ValueError("PDS source secrets are incomplete")
38if not re.fullmatch(r"[0-9a-fA-F]{64}", values["plc_rotation_key"]):
39 raise ValueError("invalid PDS rotation key")
40
41subprocess.run(
42 ["python3", str(Path(__file__).with_name("deploy.py")), "secrets", "pds", "--file", "-"],
43 input="".join(values[name] + "\n" for name in names.values()), text=True, check=True,
44)
tools/import-pds.sh created+127
......@@ -0,0 +1,127 @@
1#!/bin/sh
2set -eu
3
4instance=${1:?usage: tools/import-pds.sh pds|pds-preview-XXXXXXXX}
5case $instance in
6 pds)
7 test -n "${STUDIO_DEPLOY_HOST:-}" || { echo 'Set STUDIO_DEPLOY_HOST to the production target' >&2; exit 1; }
8 production=true
9 ;;
10 pds-preview-*)
11 printf '%s\n' "$instance" | grep -Eq '^pds-preview-[0-9a-f]{8}$' || {
12 echo 'Expected a PDS preview ID' >&2; exit 1;
13 }
14 production=false
15 ;;
16 *) echo 'Expected pds or its preview ID' >&2; exit 1 ;;
17esac
18
19source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
20target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
21target_port=${STUDIO_DEPLOY_PORT:-2222}
22offline=false
23copy_host=$source_host
24copy_rsh=ssh
25if [ -n "${STUDIO_LEGACY_HANDOFF:-}" ]; then
26 test "$production" = true || { echo 'Offline handoff requires production PDS' >&2; exit 1; }
27 sh "$(dirname "$0")/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$target_host" "$target_port"
28 offline=true
29 copy_host=$target_host
30 copy_rsh="ssh -p $target_port"
31fi
32source_ssh() {
33 if [ "$offline" = true ]; then
34 ssh -p "$target_port" "$target_host" "$@"
35 else
36 ssh "$source_host" "$@"
37 fi
38}
39if [ "$production" = true ]; then
40 root=/srv/prod/pds/pds
41 mount=/srv/prod/pds
42 if [ "$offline" = false ]; then
43 source_running=$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' pds")
44 test "$source_running" = false || { echo 'Stop Zenith pds before importing production data' >&2; exit 1; }
45 fi
46 response=$(ssh -p "$target_port" "$target_host" 'curl -s -w "\n%{http_code}" -H "X-Nomad-Token: $(cat /var/lib/studio/nomad.token)" http://127.0.0.1:4646/v1/job/pds')
47 status=$(printf '%s\n' "$response" | tail -n 1)
48 test "$status" = 200 || { echo "Could not verify pds on the home server: $status" >&2; exit 1; }
49 stopped=$(printf '%s\n' "$response" | sed '$d' | python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())')
50 test "$stopped" = true || { echo 'Stop pds on the home server before importing production data' >&2; exit 1; }
51else
52 root="/srv/staging/$instance/pds"
53 mount="/srv/staging/$instance"
54 source_id=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'")
55 test "$source_id" = pds || { echo 'Preview belongs to another service' >&2; exit 1; }
56fi
57scratch=$(mktemp -d)
58source_backup=
59cleanup() {
60 rm -rf "$scratch"
61 if [ -n "$source_backup" ]; then
62 source_ssh "rm -rf '$source_backup'"
63 fi
64}
65trap cleanup EXIT
66
67dataset=$(ssh -p "$target_port" "$target_host" "findmnt -n -o SOURCE --mountpoint $mount")
68if [ "$production" = true ]; then
69 case $dataset in */prod/pds) ;; *) echo 'Production dataset is not mounted' >&2; exit 1 ;; esac
70 python3 "$(dirname "$0")/import-pds-secrets.py"
71else
72 case $dataset in */staging/"$instance") ;; *) echo 'Preview dataset is not mounted' >&2; exit 1 ;; esac
73 origin=$(ssh -p "$target_port" "$target_host" "zfs get -H -o value origin $dataset")
74 test "$origin" = - || { echo 'Expected a fresh preview dataset' >&2; exit 1; }
75fi
76snapshot="$dataset@before-pds-import-$(date +%s)-$$"
77
78source_backup=$(source_ssh python3 - <<'PY'
79from pathlib import Path
80import shutil
81import sqlite3
82import tempfile
83
84out = Path(tempfile.mkdtemp(prefix="studio-pds-"))
85try:
86 for name in ("account", "sequencer", "did_cache"):
87 source = sqlite3.connect(f"file:/mnt/storage1/apps/pds/{name}.sqlite?mode=ro", uri=True)
88 target = sqlite3.connect(out / f"{name}.sqlite")
89 source.backup(target)
90 if target.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
91 raise ValueError(f"Invalid PDS SQLite copy: {name}")
92 target.close()
93 source.close()
94except BaseException:
95 shutil.rmtree(out)
96 raise
97print(out)
98PY
99)
100rsync -a -e "$copy_rsh" "$copy_host:$source_backup/" "$scratch/"
101rsync -a --exclude='*.sqlite*' -e "$copy_rsh" "$copy_host:/mnt/storage1/apps/pds/" "$scratch/"
102drift=$(rsync -rnc --delete --exclude='*.sqlite*' --out-format='%n' -e "$copy_rsh" "$copy_host:/mnt/storage1/apps/pds/" "$scratch/")
103test -z "$drift" || { echo 'PDS source changed during copy; retry the import' >&2; exit 1; }
104
105if [ "$production" = false ]; then
106 ssh -p "$target_port" "$target_host" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
107fi
108ssh -p "$target_port" "$target_host" "set -eu; for i in \$(seq 1 30); do running=\$(curl -fsS -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/$instance/allocations | python3 -c 'import json,sys; print(sum(a[\"ClientStatus\"] == \"running\" for a in json.load(sys.stdin)))'); test \"\$running\" = 0 && break; sleep 2; done; test \"\$running\" = 0; zfs snapshot $snapshot"
109
110rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/"
111owner=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"pds\"])'")
112ssh -p "$target_port" "$target_host" "chown -R $owner:$owner $root; python3 -c 'import sqlite3; from pathlib import Path; root=Path(\"$root\"); names=(\"account\",\"sequencer\",\"did_cache\"); assert all((root / (name + \".sqlite\")).is_file() and sqlite3.connect(\"file:\" + str(root / (name + \".sqlite\")) + \"?mode=ro&immutable=1\", uri=True).execute(\"PRAGMA integrity_check\").fetchone()[0] == \"ok\" for name in names)'"
113drift=$(rsync -rnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/" "$target_host:$root/")
114test -z "$drift" || { echo "PDS copy differs from source backup; restore $snapshot" >&2; exit 1; }
115
116if [ "$production" = true ]; then
117 if [ "$offline" = false ]; then
118 test "$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' pds")" = false || {
119 echo 'Zenith pds restarted during import; leave the home server stopped' >&2; exit 1;
120 }
121 fi
122 echo "Imported production PDS from Zenith. Previous dataset state: $snapshot"
123else
124 python3 "$(dirname "$0")/deploy.py" stage pds
125 ssh -p "$target_port" "$target_host" "zfs destroy $snapshot"
126 echo "Imported and tested $instance"
127fi
tools/import-qbittorrent.sh created+66
......@@ -0,0 +1,66 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4: "${STUDIO_DEPLOY_HOST:?Set STUDIO_DEPLOY_HOST to the production target}"
5source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
6target_host=$STUDIO_DEPLOY_HOST
7target_port=${STUDIO_DEPLOY_PORT:-2222}
8remote=(ssh -p "$target_port" "$target_host")
9root=/srv/prod/qbittorrent
10source_copy_host=$source_host
11source_rsh=ssh
12offline=false
13if [[ -n ${STUDIO_LEGACY_HANDOFF:-} ]]; then
14 sh "$(dirname "$0")/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$target_host" "$target_port"
15 source_copy_host=$target_host
16 source_rsh="ssh -p $target_port"
17 offline=true
18fi
19
20if [[ $offline == false ]]; then
21 source_running=$(ssh "$source_host" 'sudo -n docker inspect -f "{{.State.Running}}" qbittorrent')
22 [[ $source_running == false ]] || { echo 'Stop Zenith qBittorrent before importing its profile' >&2; exit 1; }
23fi
24"${remote[@]}" 'test "$(findmnt -n -o FSTYPE --mountpoint /srv/clover/Media)" = zfs; test -d /srv/clover/Media/seedbox; test -L /srv/clover/Media/torrent; test "$(readlink /srv/clover/Media/torrent)" = seedbox' || {
25 echo 'Mount Media and establish the seedbox/torrent alias before starting qBittorrent' >&2; exit 1;
26}
27
28response=$("${remote[@]}" 'curl -s -w "\n%{http_code}" -H "X-Nomad-Token: $(cat /var/lib/studio/nomad.token)" http://127.0.0.1:4646/v1/job/qbittorrent')
29status=${response##*$'\n'}
30if [[ $status == 200 ]]; then
31 stopped=$(python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())' <<<"${response%$'\n'*}")
32 [[ $stopped == true ]] || { echo 'Stop qBittorrent on the home server before importing its profile' >&2; exit 1; }
33elif [[ $status != 404 ]]; then
34 echo "Could not verify qBittorrent job state on the home server: $status" >&2
35 exit 1
36fi
37dataset=$("${remote[@]}" "findmnt -n -o SOURCE --mountpoint $root")
38[[ $dataset == */prod/qbittorrent ]] || { echo "Expected a mounted service dataset at $root" >&2; exit 1; }
39
40scratch=$(mktemp -d)
41trap 'rm -rf "$scratch"' EXIT
42mkdir "$scratch/qBittorrent"
43rsync -a --exclude=/config/ipc-socket --exclude=/config/lockfile --exclude=/data/logs/ --exclude=/cache/ \
44 -e "$source_rsh" "$source_copy_host:/mnt/storage1/apps/qbittorrent/qBittorrent/" "$scratch/qBittorrent/"
45resume_count=$(find "$scratch/qBittorrent/data/BT_backup" -maxdepth 1 -type f -name '*.fastresume' | wc -l | tr -d ' ')
46[[ $resume_count -gt 0 ]] || { echo 'No saved torrents copied from Zenith' >&2; exit 1; }
47
48snapshot="$dataset@before-qbittorrent-import-$(date +%s)-$$"
49"${remote[@]}" "zfs snapshot $snapshot"
50rsync -a --delete -e "ssh -p $target_port" "$scratch/qBittorrent/" "$target_host:$root/config/qBittorrent/"
51changes=$(rsync -a --checksum --dry-run --itemize-changes -e "ssh -p $target_port" \
52 "$scratch/qBittorrent/" "$target_host:$root/config/qBittorrent/")
53[[ -z $changes ]] || { echo 'Copied qBittorrent profile differs from source' >&2; exit 1; }
54
55uid=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"qbittorrent\"])'")
56"${remote[@]}" "chown -R $uid:$uid $root/config/qBittorrent && chmod -R u+rwX,go-rwx $root/config/qBittorrent && printf '{\"hostRoot\":\"$root\",\"uid\":$uid}' | python3 /opt/studio/current/service/qbittorrent/prepare.py"
57copied=$("${remote[@]}" "find $root/config/qBittorrent/data/BT_backup -maxdepth 1 -type f -name '*.fastresume' | wc -l")
58[[ $copied -eq $resume_count ]] || { echo 'Saved torrent count changed during import' >&2; exit 1; }
59if [[ $offline == false ]]; then
60 [[ $(ssh "$source_host" 'sudo -n docker inspect -f "{{.State.Running}}" qbittorrent') == false ]] || {
61 echo 'Zenith qBittorrent restarted during import; leave the home server stopped' >&2
62 exit 1
63 }
64fi
65"${remote[@]}" 'python3 /opt/studio/current/tools/studio.py deploy qbittorrent'
66echo "Imported $resume_count saved torrents into $dataset; pre-import snapshot: $snapshot"
tools/import-shale.sh created+106
......@@ -0,0 +1,106 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4instance=${1:?usage: tools/import-shale.sh shale|shale-preview-XXXXXXXX}
5[[ $instance == shale || $instance =~ ^shale-preview-[0-9a-f]{8}$ ]] || { echo 'Expected Shale or its preview ID' >&2; exit 1; }
6source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
7target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
8target_port=${STUDIO_DEPLOY_PORT:-2222}
9remote=(ssh -p "$target_port" "$target_host")
10job_status=200
11handoff=${STUDIO_LEGACY_HANDOFF:-}
12if [[ -n $handoff ]]; then
13 [[ $instance == shale && -n ${STUDIO_DEPLOY_HOST:-} ]] || {
14 echo 'Offline handoff requires production Shale and a target' >&2
15 exit 1
16 }
17 sh "$(dirname "$0")/check-legacy-handoff.sh" "$handoff" "$target_host" "$target_port"
18 source_copy_host=$target_host
19 source_rsh="ssh -p $target_port"
20else
21 source_copy_host=$source_host
22 source_rsh=ssh
23fi
24
25if [[ $instance == shale ]]; then
26 [[ -n ${STUDIO_DEPLOY_HOST:-} ]] || { echo 'Set STUDIO_DEPLOY_HOST to the production target' >&2; exit 1; }
27 if [[ -z $handoff ]]; then
28 [[ $(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' shale") == false ]] || { echo 'Stop Zenith Shale before importing production data' >&2; exit 1; }
29 fi
30 root=/srv/prod/shale
31 source_root=/mnt/storage1/apps/shale
32 job_response=$("${remote[@]}" 'curl -s -w "\n%{http_code}" -H "X-Nomad-Token: $(cat /var/lib/studio/nomad.token)" http://127.0.0.1:4646/v1/job/shale')
33 job_status=${job_response##*$'\n'}
34 if [[ $job_status == 200 ]]; then
35 stopped=$(python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())' <<<"${job_response%$'\n'*}")
36 [[ $stopped == true ]] || { echo 'Stop Shale on the home server before importing production data' >&2; exit 1; }
37 elif [[ $job_status != 404 ]]; then
38 echo "Could not verify production Shale job: $job_status" >&2
39 exit 1
40 fi
41else
42 root=/srv/staging/$instance
43 source_id=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'")
44 [[ $source_id == shale ]] || { echo 'Preview belongs to another service' >&2; exit 1; }
45 source_snapshot=$(ssh "$source_host" '/usr/sbin/zfs list -H -t snapshot -o name -s creation -r storage1/apps | tail -n 1')
46 [[ $source_snapshot == storage1/apps@* ]] || { echo 'No Zenith app snapshot is available' >&2; exit 1; }
47 source_root=/mnt/storage1/apps/.zfs/snapshot/${source_snapshot#*@}/shale
48fi
49
50dataset=$("${remote[@]}" "findmnt -n -o SOURCE --mountpoint $root")
51if [[ $instance == shale ]]; then
52 [[ $dataset == */prod/shale ]] || { echo 'Production Shale dataset is not mounted' >&2; exit 1; }
53else
54 [[ $dataset == */staging/$instance ]] || { echo 'Preview Shale dataset is not mounted' >&2; exit 1; }
55fi
56
57scratch=$(mktemp -d)
58trap 'rm -rf "$scratch"' EXIT
59for name in data repositories_owned repositories_mirrors; do
60 rsync -aH -e "$source_rsh" "$source_copy_host:$source_root/$name/" "$scratch/$name/"
61 drift=$(rsync -rlnc --delete --out-format='%n' -e "$source_rsh" "$source_copy_host:$source_root/$name/" "$scratch/$name/")
62 [[ -z $drift ]] || { echo "Zenith Shale $name changed during copy" >&2; exit 1; }
63done
64python3 - "$scratch/data/astheno.shale.db" <<'PY'
65import sqlite3
66import sys
67
68db = sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True)
69assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
70PY
71
72if [[ $instance != shale ]]; then
73 "${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
74fi
75if [[ $job_status == 200 || $instance != shale ]]; then
76 for _ in {1..30}; do
77 running=$("${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job allocs -json $instance" |
78 python3 -c 'import json,sys; print(sum(a["ClientStatus"] == "running" for a in json.load(sys.stdin)))')
79 [[ $running == 0 ]] && break
80 sleep 2
81 done
82 [[ $running == 0 ]] || { echo 'Shale allocation did not stop' >&2; exit 1; }
83fi
84
85snapshot=$dataset@before-shale-import-$(date +%s)-$$
86"${remote[@]}" "zfs snapshot $snapshot"
87for name in data repositories_owned repositories_mirrors; do
88 "${remote[@]}" "mkdir -p $root/$name"
89 rsync -aH --delete -e "ssh -p $target_port" "$scratch/$name/" "$target_host:$root/$name/"
90 drift=$(rsync -rlnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/$name/" "$target_host:$root/$name/")
91 [[ -z $drift ]] || { echo "Shale $name on the home server differs from the source copy; restore $snapshot" >&2; exit 1; }
92done
93uid=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"shale\"])'")
94"${remote[@]}" "chown -R $uid:$uid $root/data $root/repositories_owned $root/repositories_mirrors"
95"${remote[@]}" "python3 -c 'import sqlite3; db=sqlite3.connect(\"file:$root/data/astheno.shale.db?mode=ro\", uri=True); assert db.execute(\"PRAGMA integrity_check\").fetchone()[0] == \"ok\"'"
96
97if [[ $instance == shale ]]; then
98 if [[ -z $handoff ]]; then
99 [[ $(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' shale") == false ]] || { echo 'Zenith Shale restarted during import; leave the home server stopped' >&2; exit 1; }
100 fi
101 echo "Imported production Shale. Previous dataset state: $snapshot"
102else
103 python3 "$(dirname "$0")/deploy.py" stage shale
104 "${remote[@]}" "zfs destroy $snapshot"
105 echo "Imported and tested $instance from $source_snapshot"
106fi
tools/import-source-data.sh created+68
......@@ -0,0 +1,68 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4: "${STUDIO_DEPLOY_HOST:?Set STUDIO_DEPLOY_HOST to the production target}"
5source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
6target_host=$STUDIO_DEPLOY_HOST
7target_port=${STUDIO_DEPLOY_PORT:-22}
8remote=(ssh -p "$target_port" "$target_host")
9target_dir=/srv/prod/clover-source-of-truth/data
10tree_hash="$(dirname "$0")/tree-hash.py"
11
12if [[ -n ${STUDIO_MIGRATION_SNAPSHOT:-} ]]; then
13 [[ $STUDIO_MIGRATION_SNAPSHOT =~ ^[A-Za-z0-9_-]+$ ]] || { echo 'Invalid source snapshot name' >&2; exit 1; }
14 source=("${remote[@]}")
15 source_dir="/srv/clover/.zfs/snapshot/$STUDIO_MIGRATION_SNAPSHOT/Documents/Config/paper clover"
16 "${source[@]}" "test -d '$source_dir'"
17else
18 source=(ssh "$source_host")
19 source_dir='/mnt/storage1/clover/Documents/Config/paper clover'
20 [[ $("${source[@]}" "sudo -n docker inspect -f '{{.State.Running}}' clover-source-of-truth") == false ]] || {
21 echo 'Stop Zenith Source of Truth before importing production data' >&2
22 exit 1
23 }
24fi
25"${remote[@]}" "set -e; test \$(findmnt -n -o FSTYPE --mountpoint /srv/clover) = zfs; test -d /srv/clover/Published; ! findmnt -n -R '$target_dir' >/dev/null"
26dataset=$("${remote[@]}" 'findmnt -n -o SOURCE --mountpoint /srv/prod/clover-source-of-truth')
27[[ $dataset == */prod/clover-source-of-truth ]] || { echo 'Production service dataset is not mounted' >&2; exit 1; }
28job_response=$("${remote[@]}" 'curl -s -w "\n%{http_code}" -H "X-Nomad-Token: $(cat /var/lib/studio/nomad.token)" http://127.0.0.1:4646/v1/job/clover-source-of-truth')
29job_status=${job_response##*$'\n'}
30if [[ $job_status == 200 ]]; then
31 stopped=$(python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())' <<<"${job_response%$'\n'*}")
32 [[ $stopped == true ]] || { echo 'Stop Source of Truth on the home server before importing production data' >&2; exit 1; }
33 for _ in {1..30}; do
34 running=$("${remote[@]}" 'NOMAD_TOKEN=$(cat /var/lib/studio/nomad.token) nomad job allocs -json clover-source-of-truth' |
35 python3 -c 'import json,sys; print(sum(a["ClientStatus"] == "running" for a in json.load(sys.stdin)))')
36 [[ $running == 0 ]] && break
37 sleep 2
38 done
39 [[ $running == 0 ]] || { echo 'The home server allocation did not stop' >&2; exit 1; }
40elif [[ $job_status != 404 ]]; then
41 echo "Could not verify the home server job: $job_status" >&2
42 exit 1
43fi
44
45source_manifest=$("${source[@]}" "python3 - '$source_dir'" < "$tree_hash")
46source_bytes=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["bytes"])' <<<"$source_manifest")
47available=$("${remote[@]}" "df -B1 --output=avail /srv/prod/clover-source-of-truth | tail -n 1")
48(( available > source_bytes )) || { echo 'Production dataset has too little free space for the source tree' >&2; exit 1; }
49
50snapshot=$dataset@before-source-data-import-$(date +%s)-$$
51"${remote[@]}" "set -e; zfs snapshot $snapshot; rm -rf -- '$target_dir'; mkdir -p -- '$target_dir'"
52if [[ -n ${STUDIO_MIGRATION_SNAPSHOT:-} ]]; then
53 "${remote[@]}" "bash -c \"set -euo pipefail; tar -C '$source_dir' -cf - . | tar -C '$target_dir' -xf -\""
54else
55 "${source[@]}" "tar -C '$source_dir' -cf - ." |
56 "${remote[@]}" "tar -C '$target_dir' -xf -"
57fi
58target_manifest=$("${remote[@]}" "python3 - '$target_dir'" < "$tree_hash")
59[[ $source_manifest == "$target_manifest" ]] || { echo "Source and target trees differ; restore $snapshot" >&2; exit 1; }
60uid=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"clover-source-of-truth\"])'")
61"${remote[@]}" "set -e; chown -R $uid:$uid '$target_dir'; python3 -c 'import sqlite3; db=sqlite3.connect(\"file:$target_dir/cache.sqlite?mode=ro\", uri=True); assert db.execute(\"PRAGMA integrity_check\").fetchone()[0] == \"ok\"'"
62if [[ -z ${STUDIO_MIGRATION_SNAPSHOT:-} ]]; then
63 [[ $("${source[@]}" "sudo -n docker inspect -f '{{.State.Running}}' clover-source-of-truth") == false ]] || {
64 echo 'Zenith Source of Truth restarted during import; leave the home server stopped' >&2
65 exit 1
66 }
67fi
68echo "Imported ${source_bytes} bytes without local staging. Previous dataset state: $snapshot"
tools/import-source-index.sh created+68
......@@ -0,0 +1,68 @@
1#!/bin/sh
2set -eu
3
4instance=${1:?usage: tools/import-source-index.sh clover-source-of-truth-preview-XXXXXXXX}
5printf '%s\n' "$instance" | grep -Eq '^clover-source-of-truth-preview-[0-9a-f]{8}$' || {
6 echo 'Expected a Clover Source of Truth preview ID' >&2
7 exit 1
8}
9
10source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
11target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
12target_port=${STUDIO_DEPLOY_PORT:-2222}
13root="/srv/staging/$instance/data"
14mount="/srv/staging/$instance"
15source_id=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'")
16test "$source_id" = clover-source-of-truth || { echo 'Preview belongs to another service' >&2; exit 1; }
17dataset=$(ssh -p "$target_port" "$target_host" "findmnt -n -o SOURCE --mountpoint $mount")
18case $dataset in */staging/"$instance") ;; *) echo 'Preview dataset is not mounted' >&2; exit 1 ;; esac
19
20scratch=$(mktemp -d)
21source_backup=
22cleanup() {
23 rm -rf "$scratch"
24 if [ -n "$source_backup" ]; then
25 ssh "$source_host" "rm -rf '$source_backup'"
26 fi
27}
28trap cleanup EXIT
29
30source_backup=$(ssh "$source_host" sh -s <<'REMOTE'
31set -eu
32out=$(mktemp -d /tmp/studio-source-index-XXXXXX)
33trap 'rm -rf "$out"' EXIT
34source='/mnt/storage1/clover/Documents/Config/paper clover/cache.sqlite'
35sqlite3 -readonly "$source" ".backup '$out/cache.sqlite'"
36test "$(sqlite3 -readonly "$out/cache.sqlite" 'PRAGMA integrity_check;')" = ok
37trap - EXIT
38printf '%s\n' "$out"
39REMOTE
40)
41rsync -a "$source_host:$source_backup/cache.sqlite" "$scratch/cache.sqlite"
42python3 - "$scratch/cache.sqlite" <<'PY'
43import sqlite3
44import sys
45
46db = sqlite3.connect('file:' + sys.argv[1] + '?mode=ro&immutable=1', uri=True)
47assert db.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
48PY
49
50ssh -p "$target_port" "$target_host" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
51snapshot="$dataset@before-source-index-import-$(date +%s)-$$"
52ssh -p "$target_port" "$target_host" "set -eu; for i in \$(seq 1 30); do running=\$(curl -fsS -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/$instance/allocations | python3 -c 'import json,sys; print(sum(a[\"ClientStatus\"] == \"running\" for a in json.load(sys.stdin)))'); test \"\$running\" = 0 && break; sleep 2; done; test \"\$running\" = 0; zfs snapshot $snapshot"
53
54rsync -a -e "ssh -p $target_port" "$scratch/cache.sqlite" "$target_host:$root/cache.sqlite"
55owner=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"clover-source-of-truth\"])'")
56ssh -p "$target_port" "$target_host" "chown $owner:$owner $root/cache.sqlite; rm -f $root/cache.sqlite-wal $root/cache.sqlite-shm $root/db-snapshot.sqlite"
57drift=$(rsync -rnc --out-format='%n' -e "ssh -p $target_port" "$scratch/cache.sqlite" "$target_host:$root/cache.sqlite")
58test -z "$drift" || { echo "Index copy differs from source backup; restore $snapshot" >&2; exit 1; }
59
60python3 "$(dirname "$0")/deploy.py" stage clover-source-of-truth
61ssh -p "$target_port" "$target_host" "python3 - <<'PY'
62import sqlite3
63source = sqlite3.connect('file:$root/cache.sqlite?mode=ro', uri=True)
64assert source.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
65for table in ('media_files', 'derived_files', 'derived_refs'):
66 print(table, source.execute('SELECT count(*) FROM ' + table).fetchone()[0])
67PY"
68echo "Imported the live SQLite index into $instance; previous dataset: $snapshot"
tools/import-yt-feed.sh created+109
......@@ -0,0 +1,109 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4instance=${1:-yt-feed}
5[[ $instance == yt-feed || $instance =~ ^yt-feed-preview-[0-9a-f]{8}$ ]] || {
6 echo 'Expected yt-feed or its preview ID' >&2
7 exit 1
8}
9
10source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
11target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
12target_port=${STUDIO_DEPLOY_PORT:-2222}
13remote=(ssh -p "$target_port" "$target_host")
14source_copy_host=$source_host
15source_rsh=ssh
16offline=false
17if [[ -n ${STUDIO_LEGACY_HANDOFF:-} ]]; then
18 [[ $instance == yt-feed && -n ${STUDIO_DEPLOY_HOST:-} ]] || {
19 echo 'Offline handoff requires production YouTube Triage and a target' >&2; exit 1;
20 }
21 sh "$(dirname "$0")/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$target_host" "$target_port"
22 source_copy_host=$target_host
23 source_rsh="ssh -p $target_port"
24 offline=true
25fi
26if [[ $instance == yt-feed ]]; then
27 root=/srv/prod/yt-feed
28 "${remote[@]}" 'test "$(findmnt -n -o FSTYPE --mountpoint /srv/clover/Media)" = zfs; test -d /srv/clover/Media/music-intake; test -L /srv/clover/Media/music_intake; test "$(readlink /srv/clover/Media/music_intake)" = music-intake' || {
29 echo 'Mount Media and establish the music-intake alias before importing YouTube Triage' >&2
30 exit 1
31 }
32 if [[ $offline == false ]]; then
33 source_running=$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' yt-feed")
34 [[ $source_running == false ]] || { echo 'Stop Zenith yt-feed before importing production data' >&2; exit 1; }
35 fi
36 response=$("${remote[@]}" "curl -s -w '\n%{http_code}' -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/yt-feed")
37 status=${response##*$'\n'}
38 if [[ $status == 200 ]]; then
39 stopped=$(python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())' <<<"${response%$'\n'*}")
40 [[ $stopped == true ]] || { echo 'Stop production yt-feed before importing' >&2; exit 1; }
41 elif [[ $status != 404 ]]; then
42 echo "Could not verify production yt-feed job state: $status" >&2
43 exit 1
44 fi
45else
46 root=/srv/staging/$instance
47 source_id=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'")
48 [[ $source_id == yt-feed ]] || { echo 'Preview belongs to another service' >&2; exit 1; }
49fi
50dataset=$("${remote[@]}" "findmnt -n -o SOURCE --mountpoint $root")
51[[ $dataset == */prod/yt-feed || $dataset == */staging/"$instance" ]] || {
52 echo "Expected a mounted yt-feed dataset at $root" >&2
53 exit 1
54}
55
56scratch=$(mktemp -d)
57trap 'rm -rf "$scratch"' EXIT
58rsync -a -e "$source_rsh" "$source_copy_host:/mnt/storage1/apps/yt-feed/" "$scratch/"
59python3 - "$scratch" <<'PY'
60import json
61from pathlib import Path
62import sys
63
64root = Path(sys.argv[1])
65files = list(root.glob("*.json"))
66if not files:
67 raise ValueError("no YouTube Triage JSON state was copied")
68for file in files:
69 json.loads(file.read_text())
70print(f"Copied {len(files)} valid JSON state files")
71PY
72drift=$(rsync -rnc --delete --out-format='%n' -e "$source_rsh" "$source_copy_host:/mnt/storage1/apps/yt-feed/" "$scratch/")
73[[ -z $drift ]] || { echo 'Source changed during copy; retry the import' >&2; exit 1; }
74
75if [[ $instance != yt-feed ]]; then
76 "${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
77else
78 "${remote[@]}" 'systemctl stop studio-dashboard'
79fi
80for _ in {1..30}; do
81 running=$("${remote[@]}" "curl -s -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/$instance/allocations" |
82 python3 -c 'import json,sys; print(sum(x["ClientStatus"] == "running" for x in json.load(sys.stdin)))')
83 [[ $running == 0 ]] && break
84 sleep 2
85done
86[[ $running == 0 ]] || { echo "$instance still has a running allocation" >&2; exit 1; }
87
88snapshot="$dataset@before-yt-feed-import-$(date +%s)-$$"
89"${remote[@]}" "zfs snapshot $snapshot"
90rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/data/"
91uid=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"yt-feed\"])'")
92"${remote[@]}" "chown -R $uid:$uid $root/data"
93drift=$(rsync -rnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/" "$target_host:$root/data/")
94[[ -z $drift ]] || { echo "Copied state differs from source; restore $snapshot" >&2; exit 1; }
95
96if [[ $instance == yt-feed ]]; then
97 if [[ $offline == false ]]; then
98 [[ $(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' yt-feed") == false ]] || {
99 echo 'Zenith yt-feed restarted during import; leave the home server stopped' >&2
100 exit 1
101 }
102 fi
103 "${remote[@]}" 'systemctl start studio-dashboard'
104 echo "Imported production yt-feed from Zenith. Previous dataset state: $snapshot"
105else
106 python3 "$(dirname "$0")/deploy.py" stage yt-feed
107 "${remote[@]}" "zfs destroy $snapshot"
108 echo "Imported and tested $instance"
109fi
tools/intake-migration.md created+7
......@@ -0,0 +1,7 @@
1# JSON Intake migration
2
3Zenith's `Documents/Intake` exists but contains no files at the 2026-09-26 inspection. Snow Globe already mounts the equivalent Clover folder, so no application dataset copy is required.
4
5Existing Siri shortcuts and webhooks send Zenith's `INTAKE_KEY`. During production cutover, set `STUDIO_DEPLOY_HOST` and `STUDIO_DEPLOY_PORT` for the production host, then run `bash tools/import-legacy-secrets.sh intake INTAKE_KEY` to stream that value from Zenith's `.env` into Snow Globe's Nomad secret. Redeploy Intake to load it. The command does not print or save the key locally; it refuses to run without an explicit target.
6
7On 2026-09-26, `intake-preview-7f29ae9a` inherited the VM production key and cloned the Clover dataset for its writable mount. Its HTTPS route rejected an incorrect key with 401 and accepted a test row with the inherited key; the row appeared only in the clone, then was removed. Release `7b6aac707cf7c55f` promoted successfully after a backup of 22 service datasets and four databases, and all 19 HTTP routes passed. Destroying the stage removed its Nomad job, secret, ZFS clone, and source snapshot. Production Intake remained healthy and its Clover folder stayed empty.
tools/jellyfin-migration.md created+11
......@@ -0,0 +1,11 @@
1# Jellyfin media migration proof
2
3An initial small preview used a consistent SQLite backup from Zenith, verified by `PRAGMA integrity_check` before and after transfer. Its 4,879 `/media/...` paths matched Zenith's database by sorted path digest. On Zenith, 4,823 resolved in the existing media dataset; the other 56 were already missing at the source (50 videos, 3 trailers, 2 folders, and 1 photo).
4
5The VM mounts Zenith's media tree read-only at `/srv/clover/Media`, which Jellyfin receives as `/media`. Twenty evenly spaced source paths that exist on Zenith also resolved through the VM mount. The staged app is healthy without copying the 3.97 TiB media dataset. [The media cutover plan](media-cutover.md) moves that dataset to the final `/srv/clover/Media` mountpoint while preserving hardlinks and snapshots.
6
7A full preview restore used the existing read-only `storage1/apps@hourly-2026-09-26_01-00` snapshot as its source. [import-jellyfin.sh](import-jellyfin.sh) copied Jellyfin's metadata, artwork, attachments, subtitles, intro-skipper state, and plugins while excluding caches, logs, and transcodes. The copy was 4.7 GB on the Mac and 3.2 GB in the VM's compressed ZFS dataset; checksums matched before the app started. The pinned 10.11.11 image migrated the 10.11.5 database successfully. Both sides had 17,466 base items, 22 users, 7,717 user-data rows, and the same digest for 4,879 media paths after boot. The source and stage also matched counts for 7,149 metadata files, 873 attachments, and 1,574 subtitles. SQLite integrity and HTTPS health passed; the SSO start endpoint redirected to the staged Keycloak client.
8
9Production import (`STUDIO_DEPLOY_HOST=<new-host> STUDIO_DEPLOY_PORT=22 sh tools/import-jellyfin.sh jellyfin`) uses the stopped live source, requires the new Jellyfin job stopped and the real Media ZFS filesystem mounted, snapshots the destination, verifies the copy, assigns Jellyfin's allocated UID, and leaves the new job stopped for cutover. During the preview boot, a three-second Nomad health probe timed out under load and Caddy briefly removed the stage route. The probe now allows 15 seconds; the updated preview deployment passed. The imported Jellyfin settings also automatically installed newer AniList and Intro Skipper plugins after startup.
10
11After a same-machine OS replacement, set `STUDIO_LEGACY_HANDOFF` to the [offline handoff](legacy-handoff.md) directory. The production importer then copies the old app metadata directly between mounted datasets on the new host, without a Mac scratch copy or old Docker daemon. The 3.97 TiB Media dataset remains a ZFS mount and is not copied.
tools/legacy-handoff.md created+11
......@@ -0,0 +1,11 @@
1# Same-machine PostgreSQL handoff
2
3`storage1/apps` is an encrypted ZFS root. Set its mountpoint explicitly to `/mnt/storage1/apps` before changing the pool root, then keep it mounted there after replacing Zenith with NixOS; [media-cutover.md](media-cutover.md) gives the command order. The old app directories and `/mnt/storage1/apps/home-infra/.env` remain there; the new `/srv/prod` root is separate.
4
5At cutover, stop the old apps being migrated, leaving old PostgreSQL running long enough to export. Run `bash tools/export-legacy-postgres.sh` before replacing the OS. Zenith grants the `clo` account passwordless `sudo docker` access for the export; the script checks the exact `storage1/apps` mount and Compose's HedgeDoc label before writing. It writes custom-format dumps and a manifest into a private `studio-handoff/<timestamp>-<suffix>` directory on `storage1/apps`, checks evil.inc Forgejo and HedgeDoc remain stopped, and prints the directory path. Dawarich starts with a fresh database and Redis queue, so its old database is omitted. Snapshot `storage1/apps` after all old writers are stopped and the exporter finishes, then keep the encrypted pool and handoff path intact. The exporter does not stop apps itself.
6
7After NixOS has imported and unlocked `storage1/apps`, set `STUDIO_DEPLOY_HOST`, `STUDIO_DEPLOY_PORT`, and `STUDIO_LEGACY_HANDOFF` to that printed directory. The importers require the exact encrypted `storage1/apps` ZFS mount and a stopped destination job. HedgeDoc and evil.inc Forgejo verify dump hashes and source table counts before restoring their PostgreSQL databases. Shale, Jellyfin, Navidrome, PDS, qBittorrent, Sonarr, Radarr, Jackett, and YouTube Triage can copy their retained app data without the old Docker daemon. Each importer prints its pre-import backup or ZFS snapshot.
8
9`import-legacy-secrets.sh` reads the retained old `.env` from the new host when `STUDIO_LEGACY_HANDOFF` is set. It sends only the requested keys into Snow Globe's Nomad variables; it does not print their values. The handoff directory must exist on the target. Without that variable, the script continues reading from the old Zenith host for a two-host migration.
10
11The handoff contains no Forgejo repository files. [import-evil-forgejo.sh](import-evil-forgejo.sh) checks its `evil-forgejo.dump`, secret fingerprints, and the stopped Snow Globe job, then copies the old 14 GB app directory directly from the mounted dataset. The Forgejo job stays stopped until its files, database, and account identities have been checked together.
tools/local-vm.sh created+55
......@@ -0,0 +1,55 @@
1#!/bin/sh
2set -eu
3
4cache=${STUDIO_VM_CACHE_DIR:-$HOME/Library/Caches/clover-studio-vm-x86}
5mode=${1:-}
6qemu=$(command -v qemu-system-x86_64 || true)
7if [ -z "$qemu" ]; then
8 qemu=/opt/homebrew/bin/qemu-system-x86_64
9fi
10test -x "$qemu" || { echo 'qemu-system-x86_64 is unavailable' >&2; exit 1; }
11share=$(dirname "$(dirname "$qemu")")/share/qemu
12qemu_img=$(dirname "$qemu")/qemu-img
13mkdir -p "$cache"
14
15if [ ! -f "$cache/disk.qcow2" ]; then
16 "$qemu_img" create -f qcow2 "$cache/disk.qcow2" 96G
17fi
18if [ ! -f "$cache/vars.fd" ]; then
19 cp "$share/edk2-i386-vars.fd" "$cache/vars.fd"
20 chmod u+w "$cache/vars.fd"
21fi
22
23set --
24if [ "$mode" != installed ] && [ -f "$cache/nixos-minimal.iso" ]; then
25 set -- -drive "file=$cache/nixos-minimal.iso,media=cdrom,readonly=on"
26fi
27if [ "$mode" = installed ]; then
28 media=${STUDIO_VM_MEDIA_SOURCE:-/Volumes/media}
29 if ! mount | grep -F " on $media (" >/dev/null; then
30 echo "Mount the media share at $media before starting the VM" >&2
31 exit 1
32 fi
33 set -- "$@" -virtfs "local,path=$media,mount_tag=media,security_model=none,readonly=on"
34fi
35if [ "${STUDIO_VM_HEADLESS:-0}" = 1 ]; then
36 set -- "$@" -display none -serial "file:$cache/serial.log" -daemonize -pidfile "$cache/qemu.pid"
37else
38 set -- "$@" -display cocoa -serial mon:stdio
39fi
40
41set -- "$qemu" \
42 -machine q35 -accel tcg,tb-size=4096 -cpu max -smp 8 -m 32768 \
43 -drive "if=pflash,format=raw,readonly=on,file=$share/edk2-x86_64-code.fd" \
44 -drive "if=pflash,format=raw,file=$cache/vars.fd" \
45 -drive "if=virtio,format=qcow2,file=$cache/disk.qcow2" \
46 -netdev user,id=net0,ipv6=off,hostfwd=tcp:127.0.0.1:2222-:22 \
47 -device virtio-net-pci,netdev=net0 \
48 -device virtio-gpu-pci \
49 -qmp "unix:$cache/qmp.sock,server=on,wait=off" \
50 "$@"
51
52if command -v taskpolicy >/dev/null 2>&1; then
53 exec taskpolicy -c utility nice -n 10 "$@"
54fi
55exec nice -n 10 "$@"
tools/log-shipper.py created+117
......@@ -0,0 +1,117 @@
1#!/usr/bin/env python3
2import datetime
3import json
4import os
5from pathlib import Path
6import re
7import subprocess
8import time
9from urllib.request import Request, urlopen
10
11
12STATE = Path("/var/lib/studio/log-shipper.json")
13LOGS = Path("/var/lib/nomad/alloc")
14CRI = re.compile(r"^(\S+) (stdout|stderr) [FP] ?(.*)$")
15
16
17def nomad(path):
18 token = Path("/var/lib/studio/nomad.token").read_text().strip()
19 with urlopen(Request("http://127.0.0.1:4646" + path, headers={"X-Nomad-Token": token}), timeout=10) as response:
20 return json.load(response)
21
22
23def send(base, rows, fields):
24 if not rows:
25 return
26 data = b"\n".join(json.dumps(row, ensure_ascii=False).encode() for row in rows) + b"\n"
27 request = Request(base + "/insert/jsonline", data=data, headers={
28 "Content-Type": "application/stream+json", "VL-Stream-Fields": fields,
29 })
30 with urlopen(request, timeout=30) as response:
31 response.read()
32
33
34def once(state):
35 services = nomad("/v1/service/victoria-logs")
36 if not services:
37 return
38 target = services[0]
39 base = f"http://{target['Address']}:{target['Port']}"
40 jobs = {item["ID"]: item["JobID"] for item in nomad("/v1/allocations")}
41 offsets = state.setdefault("files", {})
42 rows = []
43 pending = {}
44 for file in sorted(LOGS.glob("*/alloc/logs/*")):
45 match = re.fullmatch(r"(.+)\.(stdout|stderr)\.\d+", file.name)
46 job = jobs.get(file.parent.parent.parent.name)
47 if not match or not job:
48 continue
49 info = file.stat()
50 old = offsets.get(str(file), {})
51 offset = old.get("offset", 0) if old.get("inode") == info.st_ino and old.get("offset", 0) <= info.st_size else 0
52 with file.open("rb") as stream:
53 stream.seek(offset)
54 chunk = stream.read(262144)
55 if not chunk:
56 continue
57 if not chunk.endswith(b"\n"):
58 chunk = chunk[:chunk.rfind(b"\n") + 1]
59 if not chunk:
60 continue
61 pending[str(file)] = {"inode": info.st_ino, "offset": offset + len(chunk)}
62 for raw in chunk.decode("utf8", "replace").splitlines():
63 parsed = CRI.match(raw)
64 if not parsed:
65 continue
66 stamp, stream, message = parsed.groups()
67 level = "error" if re.search(r"\b(ERROR|ERR|FATAL|CRITICAL|PANIC)\b|level=(error|fatal)", message, re.I) else "warn" if re.search(r"\b(WARN|WARNING|WRN)\b|level=warn", message, re.I) else "info"
68 rows.append({"_time": stamp, "_msg": message, "source": "nomad", "job": job,
69 "task": match.group(1), "stream": stream, "level": level})
70 send(base, rows, "source,job,task,stream")
71 offsets.update(pending)
72
73 command = ["journalctl", "--no-pager", "--output=json"]
74 command += ["--after-cursor=" + state["cursor"]] if state.get("cursor") else ["--since=-1 hour"]
75 system = []
76 cursor = None
77 process = subprocess.Popen(command, stdout=subprocess.PIPE, text=True)
78 try:
79 for index, line in enumerate(process.stdout):
80 if index == 5000:
81 process.terminate()
82 break
83 entry = json.loads(line)
84 cursor = entry.get("__CURSOR", cursor)
85 message = entry.get("MESSAGE")
86 if not isinstance(message, str):
87 continue
88 stamp = int(entry["__REALTIME_TIMESTAMP"]) / 1_000_000
89 system.append({"_time": datetime.datetime.fromtimestamp(stamp, datetime.timezone.utc).isoformat(),
90 "_msg": message, "source": "journald", "unit": entry.get("_SYSTEMD_UNIT", "system"),
91 "identifier": entry.get("SYSLOG_IDENTIFIER", ""), "priority": entry.get("PRIORITY", "")})
92 finally:
93 process.stdout.close()
94 process.wait()
95 if process.returncode not in (0, -15):
96 raise RuntimeError(f"journalctl exited {process.returncode}")
97 send(base, system, "source,unit")
98 if cursor:
99 state["cursor"] = cursor
100 pending_state = STATE.with_suffix(".pending")
101 pending_state.write_text(json.dumps(state))
102 os.replace(pending_state, STATE)
103
104
105def main():
106 STATE.parent.mkdir(parents=True, exist_ok=True)
107 while True:
108 try:
109 state = json.loads(STATE.read_text()) if STATE.exists() else {}
110 once(state)
111 except Exception as error:
112 print(f"log shipping paused: {error}", flush=True)
113 time.sleep(5)
114
115
116if __name__ == "__main__":
117 main()
tools/mac-domains.py created+248
......@@ -0,0 +1,248 @@
1#!/usr/bin/env python3
2"""Route .studio.test through the rehearsal tunnels on this Mac; stop restores the prior settings."""
3
4import argparse
5import asyncio
6import base64
7import hashlib
8import json
9import os
10from pathlib import Path
11import signal
12import socket
13import ssl
14import subprocess
15import sys
16import time
17
18STATE = Path("/var/db/snowglobe-test")
19RESOLVER = Path("/etc/resolver/studio.test")
20HOSTS = Path("/etc/hosts")
21KEYCHAIN = "/Library/Keychains/System.keychain"
22FORWARDS = ((80, 27080), (443, 27443))
23
24
25def local_hosts(content):
26 lines = []
27 aliases = []
28 for line in content.splitlines(keepends=True):
29 names, _, comment = line.partition("#")
30 fields = names.split()
31 if len(fields) < 2 or not any(name.endswith(".studio.test") for name in fields[1:]):
32 lines.append(line)
33 continue
34 aliases.extend(name for name in fields[1:] if name.endswith(".studio.test"))
35 remaining = [name for name in fields[1:] if not name.endswith(".studio.test")]
36 if remaining:
37 lines.append(" ".join([fields[0], *remaining]) + (" #" + comment.rstrip() if comment else "") + "\n")
38 elif comment:
39 lines.append("#" + comment.rstrip() + "\n")
40 if aliases:
41 if lines and not lines[-1].endswith("\n"):
42 lines[-1] += "\n"
43 lines.append("127.0.0.1 " + " ".join(dict.fromkeys(aliases)) + "\n")
44 return "".join(lines)
45
46
47class DNSRelay(asyncio.DatagramProtocol):
48 def __init__(self):
49 self.requests = set()
50
51 def connection_made(self, transport):
52 self.transport = transport
53
54 def datagram_received(self, data, address):
55 if len(data) < 12 or len(self.requests) >= 64:
56 return
57 request = asyncio.create_task(asyncio.wait_for(self.forward(data, address), timeout=3))
58 self.requests.add(request)
59 request.add_done_callback(self.finished)
60
61 def finished(self, request):
62 self.requests.discard(request)
63 if not request.cancelled():
64 request.exception()
65
66 async def forward(self, data, address):
67 writer = None
68 try:
69 reader, writer = await asyncio.open_connection("127.0.0.1", 53153)
70 writer.write(len(data).to_bytes(2, "big") + data)
71 await writer.drain()
72 length = int.from_bytes(await reader.readexactly(2), "big")
73 response = await reader.readexactly(length)
74 if len(response) >= 12 and response[:2] == data[:2]:
75 self.transport.sendto(response, address)
76 finally:
77 if writer:
78 writer.close()
79
80
81async def relay(listeners):
82 async def connect(reader, writer, upstream):
83 peer_writer = None
84 try:
85 peer_reader, peer_writer = await asyncio.open_connection("127.0.0.1", upstream)
86
87 async def pipe(source, destination):
88 while data := await source.read(65536):
89 destination.write(data)
90 await destination.drain()
91 if destination.can_write_eof():
92 destination.write_eof()
93
94 await asyncio.gather(pipe(reader, peer_writer), pipe(peer_reader, writer))
95 except (OSError, asyncio.CancelledError):
96 pass
97 finally:
98 writer.close()
99 if peer_writer:
100 peer_writer.close()
101
102 servers = []
103 for listener, upstream in listeners:
104 servers.append(await asyncio.start_server(
105 lambda reader, writer, port=upstream: connect(reader, writer, port), sock=listener,
106 ))
107 transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(
108 DNSRelay, local_addr=("127.0.0.1", 53153),
109 )
110 try:
111 await asyncio.gather(*(server.serve_forever() for server in servers))
112 finally:
113 transport.close()
114
115
116def stop():
117 record = STATE / "settings.json"
118 if not record.exists():
119 print("Local test routing is already stopped.")
120 return
121 settings = json.loads(record.read_text())
122 for name, saved in settings["files"].items():
123 path = Path(name)
124 current = hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else None
125 before = hashlib.sha256(base64.b64decode(saved["before"])).hexdigest() if saved["before"] is not None else None
126 if current not in (before, saved["applied"]):
127 raise SystemExit(f"{path} changed after setup. Restore it using {record}; nothing was overwritten.")
128 if pid := settings.get("pid"):
129 command = subprocess.run(["ps", "-p", str(pid), "-o", "command="], capture_output=True, text=True).stdout
130 if str(Path(__file__).resolve()) + " --relay " in command:
131 try:
132 os.kill(pid, signal.SIGTERM)
133 except ProcessLookupError:
134 pass
135 for name, saved in settings["files"].items():
136 path = Path(name)
137 if saved["before"] is None:
138 path.unlink(missing_ok=True)
139 else:
140 path.write_bytes(base64.b64decode(saved["before"]))
141 if settings.get("addedCA"):
142 subprocess.run(["security", "remove-trusted-cert", "-d", str(STATE / "ca.crt")], check=True)
143 subprocess.run(["security", "delete-certificate", "-Z", settings["sha1"], KEYCHAIN], check=True)
144 subprocess.run(["dscacheutil", "-flushcache"], check=True)
145 subprocess.run(["killall", "-HUP", "mDNSResponder"], check=True)
146 record.unlink()
147 print("Previous DNS, hosts entries, and certificate trust restored.")
148
149
150def start(certificate):
151 if (STATE / "settings.json").exists():
152 raise SystemExit("Local test routing is already configured. Run stop before starting it again.")
153 uid, gid = int(os.environ["SUDO_UID"]), int(os.environ["SUDO_GID"])
154 if uid == 0:
155 raise SystemExit("Run this with sudo from your normal Mac account.")
156 for _, port in FORWARDS:
157 with socket.create_connection(("127.0.0.1", port), timeout=3):
158 pass
159 answer = subprocess.check_output(["dig", "+tcp", "@127.0.0.1", "-p", "53153", "globe.studio.test", "+short"])
160 if answer.strip() != b"127.0.0.1":
161 raise SystemExit("The rehearsal DNS tunnel is unavailable. Start its SSH forwards first.")
162 context = ssl.create_default_context(cafile=str(certificate))
163 with socket.create_connection(("127.0.0.1", 27443), timeout=5) as upstream:
164 with context.wrap_socket(upstream, server_hostname="globe.studio.test"):
165 pass
166 for port, _ in FORWARDS:
167 with socket.socket() as probe:
168 probe.bind(("127.0.0.1", port))
169 with socket.socket(type=socket.SOCK_DGRAM) as probe:
170 probe.bind(("127.0.0.1", 53153))
171 pem = certificate.read_bytes()
172 der = subprocess.check_output(["openssl", "x509", "-outform", "DER"], input=pem)
173 existing = subprocess.run(["security", "find-certificate", "-a", "-p", KEYCHAIN], capture_output=True).stdout
174 sha1 = hashlib.sha1(der).hexdigest().upper()
175 installed = False
176 for part in existing.split(b"-----END CERTIFICATE-----")[:-1]:
177 found = subprocess.check_output(["openssl", "x509", "-outform", "DER"], input=part + b"-----END CERTIFICATE-----\n")
178 installed = installed or found == der
179 trusted = subprocess.run(["security", "verify-cert", "-c", str(certificate), "-p", "ssl"], capture_output=True).returncode == 0
180 if installed and not trusted:
181 raise SystemExit("The rehearsal CA has existing custom trust settings. Enable its SSL trust in Keychain Access before setup.")
182 STATE.mkdir(mode=0o700, exist_ok=True)
183 os.chmod(STATE, 0o700)
184 (STATE / "ca.crt").write_bytes(pem)
185 replacements = {HOSTS: local_hosts(HOSTS.read_text()).encode(),
186 RESOLVER: b"nameserver 127.0.0.1\nport 53153\n"}
187 settings = {"files": {}, "addedCA": False, "sha1": sha1}
188 for path, content in replacements.items():
189 settings["files"][str(path)] = {
190 "before": base64.b64encode(path.read_bytes()).decode() if path.exists() else None,
191 "applied": hashlib.sha256(content).hexdigest(),
192 }
193 record = STATE / "settings.json"
194 record.write_text(json.dumps(settings, indent=2) + "\n")
195 os.chmod(record, 0o600)
196 try:
197 for path, content in replacements.items():
198 path.parent.mkdir(exist_ok=True)
199 path.write_bytes(content)
200 os.chmod(path, 0o644)
201 if not trusted:
202 subprocess.run(["security", "add-trusted-cert", "-d", "-r", "trustRoot", "-p", "ssl", "-k", KEYCHAIN, str(STATE / "ca.crt")], check=True)
203 settings["addedCA"] = True
204 record.write_text(json.dumps(settings, indent=2) + "\n")
205 with (STATE / "relay.log").open("ab") as log:
206 process = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "--relay", str(uid), str(gid)],
207 stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
208 settings["pid"] = process.pid
209 record.write_text(json.dumps(settings, indent=2) + "\n")
210 time.sleep(0.3)
211 if process.poll() is not None:
212 raise RuntimeError(f"The HTTPS relay did not start. See {STATE / 'relay.log'}.")
213 subprocess.run(["dscacheutil", "-flushcache"], check=True)
214 subprocess.run(["killall", "-HUP", "mDNSResponder"], check=True)
215 subprocess.run(["curl", "--max-time", "8", "--fail", "--silent", "--show-error",
216 "--output", "/dev/null", "https://globe.studio.test/"], check=True)
217 except BaseException:
218 stop()
219 raise
220 print("Local .studio.test routing is active. Open https://globe.studio.test/.")
221
222
223if __name__ == "__main__":
224 if sys.platform != "darwin" or os.geteuid() != 0:
225 raise SystemExit("This setup needs sudo on the Mac for its resolver, certificate trust, and loopback ports 80/443.")
226 if len(sys.argv) == 4 and sys.argv[1] == "--relay":
227 listeners = []
228 for port, upstream in FORWARDS:
229 listener = socket.socket()
230 listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
231 listener.bind(("127.0.0.1", port))
232 listener.listen()
233 listeners.append((listener, upstream))
234 os.setgroups([])
235 os.setgid(int(sys.argv[3]))
236 os.setuid(int(sys.argv[2]))
237 asyncio.run(relay(listeners))
238 else:
239 parser = argparse.ArgumentParser(description=__doc__)
240 parser.add_argument("action", choices=("start", "stop"))
241 parser.add_argument("certificate", type=Path, nargs="?")
242 args = parser.parse_args()
243 if args.action == "stop":
244 stop()
245 elif args.certificate:
246 start(args.certificate.resolve())
247 else:
248 parser.error("start needs the rehearsal's public CA certificate")
tools/media-cutover.md created+75
......@@ -0,0 +1,75 @@
1# Media dataset cutover
2
3Zenith currently mounts `storage1/media` at `/mnt/storage1/media`. It is one 3.97 TiB ZFS filesystem containing `jellyfin`, `music`, `music_intake`, and `torrent`; `jellyfin` and `torrent` report the same filesystem device. The dataset has one snapshot with no clones. The VM reads it through a read-only mount at `/srv/clover/Media` without copying its contents.
4
5The Mac mounted the VM's `media` SMB share over Tailscale, listed the source folders, and received a write error for a test file. The `clover` share is writable in the VM; both shares use the same `clo` account.
6
7Zenith's NFSv4 ACLs need a separate [permission cutover](storage-acl-cutover.md) before this dataset is served by NixOS.
8
9The new layout needs that same dataset at `/srv/clover/Media`. A disposable VM test moved a ZFS filesystem beneath a different parent with `zfs rename`, changed its mountpoint, and confirmed that its snapshot, inode, and hardlinks survived. A second test renamed an independent encrypted root beneath another encrypted root; it remained its own encryption root. These test the ZFS operations, not the live Zenith cutover.
10
11At cutover, stop the old media writers and share services, take a named ZFS snapshot, then move the existing filesystems after their mountpoints are no longer busy:
12
13```sh
14zfs snapshot storage1/media@before-studio-cutover
15zfs set mountpoint=/mnt/storage1/apps storage1/apps
16zfs set mountpoint=/srv storage1
17zfs set mountpoint=/srv/clover storage1/clover
18zfs rename storage1/media storage1/clover/Media
19zfs set mountpoint=/srv/clover/Media storage1/clover/Media
20test "$(findmnt -n -o SOURCE --mountpoint /mnt/storage1/apps)" = storage1/apps
21test "$(findmnt -n -o SOURCE --mountpoint /srv)" = storage1
22test "$(findmnt -n -o SOURCE --mountpoint /srv/clover)" = storage1/clover
23test "$(findmnt -n -o SOURCE --mountpoint /srv/clover/Media)" = storage1/clover/Media
24```
25
26The snapshot may be taken before reinstalling; run the mountpoint changes after NixOS imports the pool without TrueNAS's `/mnt` altroot. `storage1/apps` currently has a *default* mountpoint; setting it locally before changing the pool root preserves `/mnt/storage1/apps` for the import handoff. A disposable pool with an altroot reproduced the default child's move when the parent mountpoint changed, then kept the old child path after an explicit mountpoint was set. The pool keeps its `storage1` name and uses `/srv` as its root mount. Clover and Media must resolve to distinct ZFS filesystems before starting Snow Globe; `tools/studio.py pool` enforces this. This move does not copy 3.97 TiB of files. Check a known hardlinked download/library pair by device and inode after the move, then verify Jellyfin and Navidrome library paths inside their containers.
27
28The root change also relocates other default-mountpoint children: `backup` (including the 2.07 TB `backup/sandwich`), `mirrors` (266 GB), `agent`, `homes`, `logs`, `.ix-virt`, and five old `apps` clones. They remain datasets under `/srv` until deliberately renamed; the move does not copy their data. `ix-apps` has a local `/mnt/.ix-apps` mountpoint and `.system` uses `legacy`, so neither follows the root. Preserve these datasets during cutover.
29
30Zenith's qBittorrent `BT_backup` is 21 MB. A checksum-verified copy contained 544 complete bencoded resume files. Their `save_path` values include 498 at `/data/media/torrent`, three in its subfolders, and 43 in Jellyfin folders; 543 also set `qBt-downloadPath` to `/data/media/torrent`. Keep these saved paths working by renaming the real media folder and leaving a relative compatibility symlink, after the media dataset is mounted at its new path and the old writers are stopped:
31
32```sh
33test -d /srv/clover/Media/torrent
34test ! -e /srv/clover/Media/seedbox
35mv /srv/clover/Media/torrent /srv/clover/Media/seedbox
36ln -s seedbox /srv/clover/Media/torrent
37test "$(stat -c %d:%i /srv/clover/Media/seedbox)" = "$(stat -Lc %d:%i /srv/clover/Media/torrent)"
38```
39
40The same-dataset rename preserves existing hardlinks. New downloads use `/data/media/seedbox`; saved torrents continue to resolve through `/data/media/torrent`. Rename the existing music intake folder after stopping YouTube Triage, then keep its current container path working through a relative symlink:
41
42```sh
43test -d /srv/clover/Media/music_intake
44test ! -e /srv/clover/Media/music-intake
45mv /srv/clover/Media/music_intake /srv/clover/Media/music-intake
46ln -s music-intake /srv/clover/Media/music_intake
47```
48
49YouTube Archiver keeps its download archive JSON beside the videos in `Media/jellyfin/Independent`, not in its `/config` cache ([upstream archive behavior](https://github.com/jmbannon/ytdl-sub/wiki/5.-Optimizing-Your-First-Config)). Zenith has 12 archive files there (52,345 bytes), and all 12 are visible through the VM's read-only media mount. Preserve these files with the media dataset; the old app directory contains only a lock, cache, and empty work directory.
50
51The complete 33 MB qBittorrent config was also copied into a disposable ZFS clone in the VM. The pinned container started with `--network none` and no media mount; its API loaded all 544 torrents. All reported `missingFiles`, as expected without `/data/media`. One resume file without `qBt-savePath` adopted the new default `/data/media/seedbox`; the other 543 retained their saved paths. The container, clone, and copied config were removed after the test.
52
53A second disposable restore copied Zenith's qBittorrent profile into the VM and mounted the real read-only media through a temporary `/data/media/seedbox` alias, with `torrent -> seedbox` alongside it. The container ran as UID 3000 with no network and a read-only root filesystem. Its API loaded all 544 torrents; after resume checks, all 544 reported 100% progress, with 539 `queuedUP`, three `stalledUP`, two `forcedUP`, and none `missingFiles`. The container resolved the legacy symlink, and its temporary profile was removed. This tests the saved paths and source files through the VM mount; the actual Zenith folder has not yet been renamed.
54
55The old resume files are owned by UID 3000, while Snow Globe assigns qBittorrent UID 3114. [import-qbittorrent.sh](import-qbittorrent.sh) copies the stopped old profile into the stopped Snow Globe service's dataset, snapshots the destination, checks the copy, changes ownership, applies the new save path, and starts the service. It refuses to run while Zenith's qBittorrent is active. In a disposable VM restore, UID 3114 loaded all 544 imported torrents without a media mount; all correctly reported `missingFiles`. With the same read-only media alias and legacy symlink as above, all 544 reached 100% progress and none remained `missingFiles`.
56
57The production importer now requires the real Media ZFS mount and `torrent -> seedbox` alias before it starts qBittorrent. For a same-machine OS replacement, set `STUDIO_LEGACY_HANDOFF` to the [offline handoff](legacy-handoff.md) directory; the profile copy then reads the retained old apps dataset without old Docker.
58
59The PIA credentials are the two lines in `~/pia.txt` on the Mac (username, then password). With `STUDIO_DEPLOY_HOST` and `STUDIO_DEPLOY_PORT` set for production, import them through `python3 tools/deploy.py secrets qbittorrent --file ~/pia.txt --key vpn_user --key vpn_pass` before deploying qBittorrent. The CLI requires a private file, sends its contents to the target over SSH, and does not print the values.
60
61At the 2026-09-26 check, Zenith's Sonarr and Radarr databases each had an enabled qBittorrent client and enabled RSS indexers; the VM's production and preview copies had both disabled. The preview setup disables every indexer's RSS and automatic search, including Sonarr's built-in EZTV indexer, while leaving the source untouched. A production [ARR import](import-arr.sh) now requires the corresponding Zenith container to be stopped before copying its database, and checks it remains stopped when the copy finishes. Reenable downloads only after the media dataset is writable at the final mountpoint and qBittorrent's imported profile is serving the same paths.
62
63After a same-machine OS replacement, `STUDIO_LEGACY_HANDOFF` makes the Sonarr and Radarr importer read their stopped databases and app files from the mounted old apps dataset on the new host. A read-only SQLite backup and integrity check passed for each old database.
64
65The VM runs Sonarr `4.0.20.3014` and Radarr `6.4.4.10685`. [Sonarr v4's External mode](https://wiki.servarr.com/sonarr/settings) delegates authentication to a reverse proxy, and [Radarr's current FAQ](https://wiki.servarr.com/radarr/faq) says native OIDC is unsupported. Sonarr's [v5 development settings](https://github.com/Sonarr/Sonarr/blob/v5-develop/src/Sonarr.Api.V5/Settings/GeneralSettingsResource.cs) contain OIDC fields; the latest stable [Sonarr release](https://github.com/Sonarr/Sonarr/releases) remains v4 at this check. Both pinned services use Caddy Forward Auth with External mode. Unauthenticated UI and API requests redirected to sign-in, and forged identity headers sent from the Mac still received HTTP 302 over trusted TLS. Nomad advertised their application ports only on `127.0.0.1`.
66
67[import-jackett.sh](import-jackett.sh) makes the Jackett dependency repeatable: previews copy the latest read-only Zenith app snapshot; production import requires both old and new Jackett stopped. The restored preview kept the same API key as Zenith and the VM's production Jackett, preserved all three indexer definitions, and served `t=caps` for Nyaa and Pirate Bay with HTTP 200. The imported Sonarr and Radarr Jackett indexers use that same key and point to the current Nomad Jackett endpoint.
68
69With `STUDIO_LEGACY_HANDOFF` set, production Jackett import reads its retained config from the new host's mounted old apps dataset after reboot; it no longer needs the old Docker host.
70
71Sonarr and Radarr persist their Jackett and qBittorrent endpoints in their databases, so those two internal ports are static (`30017` and `30038`) across allocation changes. On the VM, both apps' saved Jackett URLs matched the current reserved port. Their public HTTPS routes still use separate Caddy endpoints and forward auth.
72
73[import-navidrome.sh](import-navidrome.sh) restores the old `navidrone` app directory into Navidrome's managed dataset. The preview imported Zenith's `storage1/apps@hourly-2026-09-26_02-00` snapshot, migrated the database under the pinned image, and passed SQLite integrity and HTTP health checks. Its three users, 10,945 media files, 1,046 albums, and 2,655 artists matched the source snapshot; `snow` remained an administrator. A `Remote-User: snow` request to the production Navidrome API returned the existing `snow` user, proving that its external-auth setting accepts the proxy's header. The preview's database is retained, but its job is stopped while the Jackett preview uses the VM's limited memory. Production import requires both Navidrome jobs stopped and the real Media dataset mounted as ZFS.
74
75For a same-machine OS replacement, set `STUDIO_LEGACY_HANDOFF` to the [offline handoff](legacy-handoff.md) directory when importing production Jellyfin or Navidrome. Both importers read their retained app data directly from the mounted old apps dataset on the new host; the Media library stays on its ZFS dataset.
tools/pds-migration.md created+7
......@@ -0,0 +1,7 @@
1# PDS data migration
2
3The 2026-09-26 preview restore copied Zenith's three SQLite databases with SQLite's backup API and copied its blocks and actor keys without writing to Zenith. The restored stage passed SQLite integrity checks and HTTPS health. Source and stage had the same table and row counts: `account` 18/47, `sequencer` 3/76, and `did_cache` 3/5. The preview uses a separate hostname and disables PLC and crawlers, so it cannot validate public federation.
4
5For production, configure the new host's site domain as `paperclover.net` and verify PDS renders `at.paperclover.net`. Stop the old and new PDS instances, then run `STUDIO_DEPLOY_HOST=<new-host> STUDIO_DEPLOY_PORT=22 sh tools/import-pds.sh pds`. The importer requires both instances stopped, imports the existing JWT/admin/PLC and mail secrets, snapshots the destination, copies and verifies the data, and leaves the new instance stopped. Its snapshot name is printed for recovery. Start the new instance only after checking the account DID and handle against Zenith; switch public routing after its health and identity endpoints agree. The importer rechecks that Zenith did not restart during the copy.
6
7For a same-machine OS replacement, set `STUDIO_LEGACY_HANDOFF` to the [offline handoff](legacy-handoff.md) directory. The importer then reads the retained PDS SQLite files and `.env` from the new host's mounted old apps dataset, without old Docker. Its SQLite backup operation was checked read-only against all three old databases.
tools/release.py created+274
......@@ -0,0 +1,274 @@
1#!/usr/bin/env python3
2import argparse
3from datetime import datetime, timezone
4import fcntl
5import hashlib
6import json
7import os
8from pathlib import Path
9import re
10import subprocess
11import time
12
13
14ROOT = Path("/opt/studio")
15RELEASES = ROOT / "releases"
16STATE = Path("/var/lib/studio")
17HISTORY = STATE / "deployments.json"
18SOURCES = ("config", "service", "tools", "nixos", "dashboard", "flake.nix", "flake.lock", "readme.md")
19RELEASE_ID = re.compile(r"[0-9a-f]{16}\Z")
20STAGE_ID = re.compile(r"[a-z][a-z0-9-]*\Z")
21
22
23def files(root):
24 for name in SOURCES:
25 source = root / name
26 paths = source.rglob("*") if source.is_dir() else [source]
27 for path in sorted(paths):
28 relative = path.relative_to(root)
29 if relative.parts[:2] in (
30 ("dashboard", "node_modules"), ("dashboard", "dist"),
31 ("dashboard", ".cache"), ("dashboard", "data"), ("dashboard", "target"),
32 ):
33 continue
34 if any(part in {".DS_Store", "__pycache__", ".identities.lock", "identities.pending"} or part.startswith("._") or part.endswith(".pyc") for part in relative.parts):
35 continue
36 if path.is_symlink():
37 raise ValueError(f"unsupported release file: {relative}")
38 if path.is_dir():
39 continue
40 if not path.is_file():
41 raise ValueError(f"unsupported release file: {relative}")
42 yield path, relative
43
44
45def _hash_contents(digest, path):
46 with path.open("rb") as source:
47 while chunk := source.read(65536):
48 digest.update(chunk)
49
50
51def tree_digest(root):
52 digest = hashlib.sha256()
53 for path, relative in files(root):
54 digest.update(str(relative).encode() + b"\0")
55 _hash_contents(digest, path)
56 return digest.hexdigest()
57
58
59def host_digest(root):
60 digest = hashlib.sha256()
61 paths = sorted(path for path, relative in files(root)
62 if relative.parts[0] in {"nixos", "dashboard", "config", "service"}
63 or str(relative) in {"flake.nix", "flake.lock", "tools/router.py", "tools/dashboard-host.py", "tools/dashboard-run.py", "tools/release.py", "tools/vms.py"})
64 for path in paths:
65 digest.update(str(path.relative_to(root)).encode())
66 _hash_contents(digest, path)
67 return digest.hexdigest()
68
69
70def check_release(release, legacy=False):
71 if not RELEASE_ID.fullmatch(release):
72 raise ValueError("invalid release ID")
73 path = RELEASES / release
74 if not path.is_dir():
75 raise ValueError(f"release is missing: {release}")
76 manifest = path / ".studio-release.json"
77 if not manifest.exists():
78 if not legacy:
79 raise ValueError(f"release has no manifest: {release}")
80 else:
81 details = json.loads(manifest.read_text())
82 if details.get("id") != release or details.get("digest") != tree_digest(path) or details.get("version", 1) not in (1, 2):
83 raise ValueError(f"release contents changed: {release}")
84 return path
85
86
87def current_release(link="current"):
88 current = ROOT / link
89 if not current.is_symlink():
90 return None
91 target = current.resolve()
92 if target.parent != RELEASES or not RELEASE_ID.fullmatch(target.name):
93 raise ValueError(f"{link} points outside releases: {target}")
94 return target.name
95
96
97def main_release(version=None):
98 version = version or current_release("main")
99 if version is None:
100 return None
101 if not RELEASE_ID.fullmatch(version):
102 raise ValueError("invalid release ID")
103 details = json.loads((RELEASES / version / ".studio-release.json").read_text())
104 revision = details.get("main")
105 if (not isinstance(revision, dict) or not isinstance(revision.get("commit"), str)
106 or not re.fullmatch(r"[0-9a-f]{40}", revision["commit"])
107 or not isinstance(revision.get("description"), str) or not revision["description"].strip()):
108 raise ValueError("This release did not come from a described main commit. Publish main first.")
109 return {"release": version, "commit": revision["commit"], "description": revision["description"]}
110
111
112def history():
113 return json.loads(HISTORY.read_text()) if HISTORY.exists() else []
114
115
116def save_history(entries):
117 pending = HISTORY.with_suffix(".pending")
118 pending.write_text(json.dumps(entries, indent=2) + "\n")
119 pending.replace(HISTORY)
120
121
122def jobs(path):
123 rendered = subprocess.run(
124 ["python3", str(path / "tools/studio.py"), "render"],
125 check=True, capture_output=True, text=True,
126 ).stdout
127 result = set(re.findall(r'^job "([a-z][a-z0-9-]*)" \{$', rendered, re.MULTILINE))
128 if not result:
129 raise ValueError(f"no Nomad jobs rendered by {path}")
130 return result
131
132
133def activate(release, legacy=False, initial=False):
134 path = check_release(release, legacy)
135 script = path / "tools/studio.py"
136 managed = STATE / "managed-jobs.json"
137 current = current_release()
138 previous = set(json.loads(managed.read_text())) if managed.exists() else (
139 jobs(RELEASES / current) if current and not initial else set()
140 )
141 digest = host_digest(path)
142 hostname = os.uname().nodename.split(".", 1)[0]
143 configuration = "vm" if hostname == "clover-demo" else hostname
144 recorded = ROOT / "host.digest"
145 old_digest = recorded.read_text().strip() if recorded.exists() else None
146 if old_digest != digest:
147 subprocess.run(["nixos-rebuild", "dry-build", "--flake", f"path:{path}#{configuration}"], check=True)
148 subprocess.run(["python3", str(script), "preflight"], check=True)
149 backup = None
150 if current and not initial:
151 backup_script = ROOT / "data.py"
152 if not backup_script.is_file():
153 backup_script = path / "tools/data.py"
154 result = subprocess.run(
155 ["python3", str(backup_script), "backup", current, release],
156 check=True, capture_output=True, text=True,
157 )
158 print(result.stdout.strip(), flush=True)
159 backup = result.stdout.split("backup=", 1)[1].split()[0]
160 if old_digest != digest:
161 subprocess.run(["nixos-rebuild", "switch", "--flake", f"path:{path}#{configuration}"], check=True)
162 next_link = ROOT / ("next-" + release)
163 next_link.unlink(missing_ok=True)
164 next_link.symlink_to(path)
165 next_link.replace(ROOT / "current")
166 if old_digest != digest:
167 recorded.write_text(digest + "\n")
168 for _ in range(60):
169 response = subprocess.run(
170 ["curl", "--fail", "--silent", "--max-time", "2", "http://127.0.0.1:4646/v1/status/leader"],
171 capture_output=True, text=True,
172 )
173 if response.returncode == 0 and json.loads(response.stdout):
174 break
175 time.sleep(2)
176 else:
177 raise RuntimeError("Nomad API did not become ready")
178 subprocess.run(["systemctl", "restart", "studio-router.service"], check=True)
179 if (path / "nixos/dashboard.nix").exists():
180 subprocess.run(["systemctl", "start", "studio-dashboard.service"], check=True)
181 subprocess.run(["systemctl", "is-active", "--quiet", "studio-dashboard.service"], check=True)
182 subprocess.run(["python3", str(script), "pool"], check=True)
183 subprocess.run(["python3", str(script), "deploy"], check=True)
184 desired = jobs(path)
185 for name in sorted(previous - desired):
186 subprocess.run(
187 ["nomad", "job", "stop", "-purge", "-yes", name], check=True,
188 env={**os.environ, "NOMAD_TOKEN": (STATE / "nomad.token").read_text().strip()},
189 )
190 pending = managed.with_suffix(".pending")
191 pending.write_text(json.dumps(sorted(desired)) + "\n")
192 pending.replace(managed)
193 if (path / "tools/log-shipper.py").exists():
194 subprocess.run(["systemctl", "restart", "studio-log-shipper.service"], check=True)
195 manifest = path / ".studio-release.json"
196 if not legacy and json.loads(manifest.read_text()).get("version", 1) >= 2:
197 subprocess.run(["python3", str(script), "check"], check=True)
198 return backup
199
200
201def main():
202 parser = argparse.ArgumentParser(description="Deploy main or roll back a home server release")
203 parser.add_argument("mode", choices=["publish", "deploy", "rollback", "history", "bootstrap", "verify"])
204 parser.add_argument("target", nargs="?")
205 args = parser.parse_args()
206 STATE.mkdir(parents=True, exist_ok=True)
207 if args.mode == "verify":
208 if not args.target:
209 parser.error("verify requires a release ID")
210 check_release(args.target)
211 return
212 if args.mode == "history":
213 if args.target:
214 parser.error("history takes no target")
215 current = current_release()
216 entries = history()
217 for index, entry in enumerate(entries[-12:], start=max(0, len(entries) - 12)):
218 marker = "*" if index == len(entries) - 1 and entry["release"] == current else " "
219 when = datetime.fromtimestamp(entry["time"], timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
220 print(f"{marker} {entry['release']} {when} {entry['source']}")
221 if current and (not entries or entries[-1]["release"] != current):
222 print(f"* {current} deployment incomplete")
223 return
224 with (STATE / "release.lock").open("w") as lock:
225 fcntl.flock(lock, fcntl.LOCK_EX)
226 if args.mode == "publish":
227 if not args.target:
228 parser.error("publish requires a main release ID")
229 path = check_release(args.target)
230 main_release(args.target)
231 pending = ROOT / "main.pending"
232 pending.unlink(missing_ok=True)
233 pending.symlink_to(path)
234 pending.replace(ROOT / "main")
235 print(f"main={args.target}")
236 return
237 entries = history()
238 current = current_release()
239 if current and not entries and args.mode != "bootstrap":
240 entries.append({"release": current, "source": "previous", "time": int(time.time()), "legacy": not (RELEASES / current / ".studio-release.json").exists()})
241 save_history(entries)
242 if args.mode == "deploy":
243 candidate = main_release()
244 if not candidate or args.target != candidate["release"]:
245 raise ValueError("main changed or is unavailable. Publish main and retry deployment.")
246 release = candidate["release"]
247 source = "main"
248 legacy = False
249 elif args.mode == "bootstrap":
250 if not args.target or not RELEASE_ID.fullmatch(args.target) or entries:
251 parser.error("bootstrap requires a release ID and no successful deployment")
252 release, source, legacy = args.target, "bootstrap", False
253 main_release(release)
254 else:
255 if args.target:
256 match = next((item for item in reversed(entries) if item["release"] == args.target), None)
257 if not match:
258 parser.error("rollback target is not in production history")
259 else:
260 match = next((item for item in reversed(entries) if item["release"] != current), None)
261 if not match:
262 parser.error("no earlier production release")
263 release, source, legacy = match["release"], "rollback", match.get("legacy", False)
264 if release == current and entries and entries[-1]["release"] == current:
265 print(f"already running {release}")
266 return
267 backup = activate(release, legacy, args.mode == "bootstrap")
268 entries.append({"release": release, "source": source, "time": int(time.time()), "legacy": legacy, "backup": backup})
269 save_history(entries)
270 print(f"production={release} previous={current or ''}")
271
272
273if __name__ == "__main__":
274 main()
tools/router.py created+323
......@@ -0,0 +1,323 @@
1#!/usr/bin/env python3
2import json
3import grp
4import os
5import re
6import subprocess
7import sys
8import time
9import urllib.parse
10import urllib.request
11
12ROUTES = "/var/lib/caddy/routes.caddy"
13TOKEN = "/var/lib/studio/router.token"
14ROUTE_DIR = "/var/lib/studio/routes"
15HOST = re.compile(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\Z")
16
17
18def nomad(path, token):
19 request = urllib.request.Request(
20 "http://127.0.0.1:4646" + path,
21 headers={"X-Nomad-Token": token},
22 )
23 with urllib.request.urlopen(request, timeout=5) as response:
24 return json.load(response)
25
26
27def proxy(upstreams, indent, uncompressed=False, upstream_host=False):
28 lines = [f"{indent}reverse_proxy {upstreams} {{", f"{indent} lb_try_duration 5s",
29 f"{indent} fail_duration 30s"]
30 if uncompressed:
31 lines.append(f"{indent} header_up Accept-Encoding identity")
32 if upstream_host:
33 lines.append(f"{indent} header_up Host {{upstream_hostport}}")
34 return [*lines, f"{indent}}}"]
35
36
37def shale_mcp_routes(port):
38 return [" @shale_mcp_link path_regexp shale_mcp_link ^/-/studio-mcp/([A-Za-z0-9_-]{43})$",
39 " handle @shale_mcp_link {", " rewrite * /oauth/shale/link/{re.shale_mcp_link.1}",
40 " request_header -User-Name", " request_header -User-Groups", " request_header -Studio-Proxy-Token",
41 *proxy(f"127.0.0.1:{port}", " "), " }",
42 " @shale_mcp_callback {", " path /-/callback", " header Cookie *studio_mcp_shale_link=*", " }",
43 " handle @shale_mcp_callback {", " rewrite * /oauth/shale/callback",
44 " request_header -User-Name", " request_header -User-Groups", " request_header -Studio-Proxy-Token",
45 *proxy(f"127.0.0.1:{port}", " "), " }"]
46
47
48def render(token):
49 with open(os.environ["STUDIO_PROXY_TOKEN_FILE"]) as file:
50 dashboard_proof = file.read().strip()
51 if not re.fullmatch(r"[0-9a-fA-F]{64}", dashboard_proof):
52 raise ValueError("invalid dashboard proxy token")
53 routes = {}
54 internal_services = {}
55 auth_upstreams = set()
56 allocation_checks = {}
57 for namespace in nomad("/v1/services", token):
58 if namespace["Namespace"] != "default":
59 continue
60 for service in namespace["Services"]:
61 name = urllib.parse.quote(service["ServiceName"], safe="")
62 for instance in nomad(f"/v1/service/{name}", token):
63 tags = instance.get("Tags") or []
64 if instance["ServiceName"] != "forward-auth" and not any(tag.startswith("caddy-") for tag in tags):
65 continue
66 address = instance["Address"]
67 port = instance["Port"]
68 if not re.fullmatch(r"[0-9a-fA-F:.]+", address) or type(port) is not int or not 1 <= port <= 65535:
69 continue
70 upstream = f"[{address}]:{port}" if ":" in address else f"{address}:{port}"
71 if not re.fullmatch(r"[a-z][a-z0-9-]*", instance["ServiceName"]):
72 raise ValueError("invalid internal service name")
73 internal_services.setdefault(instance["ServiceName"], set()).add(upstream)
74 alloc = instance["AllocID"]
75 if alloc not in allocation_checks:
76 allocation_checks[alloc] = nomad(f"/v1/allocation/{alloc}/checks", token)
77 service_checks = list(allocation_checks[alloc].values())
78 if not service_checks or any(check["Status"] != "success" for check in service_checks):
79 continue
80 if instance["ServiceName"] == "forward-auth":
81 auth_upstreams.add(upstream)
82 access = [tag.split("=", 1)[1] for tag in tags if tag.startswith("caddy-auth-role=")]
83 user_headers = [tag.split("=", 1)[1] for tag in tags if tag.startswith("caddy-user-header=")]
84 if len(access) > 1 or (access and not re.fullmatch(r"[a-z][a-z0-9-]*", access[0])):
85 raise ValueError("invalid route access role")
86 if len(user_headers) > 1 or (user_headers and (not access or not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", user_headers[0]))):
87 raise ValueError("invalid authenticated user header")
88 if access and not any(tag.startswith("caddy-host=") for tag in tags):
89 raise ValueError("route access role has no host")
90 for tag in tags:
91 if tag.startswith("caddy-host="):
92 host = tag.split("=", 1)[1]
93 if not HOST.fullmatch(host):
94 raise ValueError(f"invalid Caddy host: {host!r}")
95 route = routes.setdefault((80, host), {"upstreams": set(), "services": set(), "authRole": None, "userHeader": None, "internal": False, "realIp": False})
96 if access and route["authRole"] not in (None, access[0]):
97 raise ValueError("conflicting route access roles")
98 if user_headers and route["userHeader"] not in (None, user_headers[0]):
99 raise ValueError("conflicting authenticated user headers")
100 if access:
101 route["authRole"] = access[0]
102 if user_headers:
103 route["userHeader"] = user_headers[0]
104 if "caddy-internal=true" in tags:
105 route["internal"] = True
106 if "caddy-real-ip=true" in tags:
107 route["realIp"] = True
108 route["upstreams"].add(upstream)
109 route["services"].add(instance.get("JobID") or instance["ServiceName"])
110 elif tag.startswith("caddy-port="):
111 listener = int(tag.split("=", 1)[1])
112 if not 1 <= listener <= 65535:
113 raise ValueError(f"invalid Caddy port: {listener}")
114 route = routes.setdefault((listener, ""), {"upstreams": set(), "services": set()})
115 route["upstreams"].add(upstream)
116
117 lines = []
118 for (listener, host), route in sorted(routes.items()):
119 if host:
120 role = route["authRole"]
121 if role and not auth_upstreams:
122 continue
123 lines.append(f"{host if listener == 80 else f'{host}:{listener}'} {{")
124 if host.endswith(".test") or route["internal"]:
125 lines.append(" tls internal")
126 if route["realIp"]:
127 lines.append(" request_header X-Real-Ip {remote_host}")
128 if len(route["services"]) != 1:
129 raise ValueError(f"multiple services claim {host}")
130 service = next(iter(route["services"]))
131 if not re.fullmatch(r"[a-z][a-z0-9-]*", service):
132 raise ValueError(f"invalid service name: {service}")
133 if service == "shale":
134 port = int(os.environ["STUDIO_DASHBOARD_PORT"])
135 if not 1 <= port <= 65535:
136 raise ValueError("invalid dashboard port for Shale linking")
137 lines += shale_mcp_routes(port)
138 lines += [" tracing {", f" span {service}", " span_attributes {",
139 f" studio.service {service}", " studio.kind edge", " }", " }"]
140 manifest = os.path.join(ROUTE_DIR, service + ".json")
141 assets = {}
142 headers = {}
143 head_html = {}
144 proof = None
145 scrub = []
146 if os.path.exists(manifest):
147 with open(manifest) as file:
148 assets = json.load(file)
149 identity = assets.get("identity", {})
150 headers = identity.get(host, {})
151 head_html = assets.get("headHtml", {}).get(host, {})
152 proof = assets.get("identityProof", {}).get(host)
153 scrub = sorted({name for configured in identity.values() for name in configured} | set(assets.get("identityProof", {}).values()))
154 if any(not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", name) for name in scrub):
155 raise ValueError(f"invalid identity header: {host}")
156 if proof and not headers:
157 raise ValueError(f"identity proof has no identity headers: {host}")
158 if any(source not in {"X-Auth-Request-User", "X-Auth-Request-Groups", "X-Auth-Request-Preferred-Username"} for source in headers.values()):
159 raise ValueError(f"invalid identity claim: {host}")
160 if role and headers:
161 raise ValueError(f"route has both required and optional auth: {host}")
162 if role and (assets.get("files") or assets.get("dirs")):
163 raise ValueError(f"authenticated route has static overrides: {host}")
164 if head_html and (role or headers):
165 raise ValueError(f"authenticated route has HTML injection: {host}")
166 for request, markup in head_html.items():
167 if not re.fullmatch(r"/[A-Za-z0-9/._-]*", request) or not markup:
168 raise ValueError(f"invalid HTML injection: {host} {request}")
169 for request, path in assets.get("files", {}).items():
170 if not request.startswith("/") or " " in request or "\n" in request:
171 raise ValueError(f"invalid static path: {request}")
172 lines += [f" handle {request} {{", f" root * {json.dumps(os.path.dirname(path))}",
173 f" rewrite * /{os.path.basename(path)}", ' header Cache-Control "no-store"',
174 " file_server", " }"]
175 for request, path in assets.get("dirs", {}).items():
176 if not request.startswith("/") or " " in request or "\n" in request:
177 raise ValueError(f"invalid static prefix: {request}")
178 lines += [f" handle_path {request}* {{", f" root * {json.dumps(path)}",
179 " file_server", " }"]
180 metrics_path = assets.get("metricsPaths", {}).get(host)
181 if metrics_path:
182 if not re.fullmatch(r"/[A-Za-z0-9/._-]+", metrics_path):
183 raise ValueError(f"invalid metrics path: {host}")
184 lines += [f" handle {metrics_path} {{", " respond 404", " }"]
185 upstreams = " ".join(sorted(route["upstreams"]))
186 if role:
187 auth = " ".join(sorted(auth_upstreams))
188 lines += [" handle /snow.oauth2/* {", *proxy(auth, " "), " }", " handle {"]
189 if route["userHeader"]:
190 lines.append(f" request_header -{route['userHeader']}")
191 lines += [
192 f" reverse_proxy {auth} {{", " lb_try_duration 5s", " fail_duration 30s", " method GET",
193 " rewrite /snow.oauth2/auth", " header_up X-Forwarded-Method {method}",
194 " header_up X-Forwarded-Uri {uri}", " @unauthorized status 401",
195 " handle_response @unauthorized {",
196 " redir * /snow.oauth2/sign_in?rd={scheme}://{host}{uri}", " }",
197 f" @allowed header X-Auth-Request-Groups *role:{role}*",
198 " handle_response @allowed {", " method {method}", " rewrite {uri}",
199 ]
200 if route["userHeader"]:
201 lines.append(f" request_header {route['userHeader']} {{rp.header.X-Auth-Request-Preferred-Username}}")
202 lines += [
203 *proxy(upstreams, " "), " }",
204 " handle_response {", " respond 403", " }", " }", " }", "}",
205 ]
206 elif headers and auth_upstreams:
207 auth = " ".join(sorted(auth_upstreams))
208 lines += [" handle /snow.oauth2/* {", *proxy(auth, " "), " }", " handle {"]
209 lines += [f" request_header -{name}" for name in scrub]
210 lines += [f" reverse_proxy {auth} {{", " lb_try_duration 5s", " fail_duration 30s", " method GET", " rewrite /snow.oauth2/auth",
211 " header_up X-Forwarded-Method {method}", " header_up X-Forwarded-Uri {uri}",
212 " @authenticated status 2xx", " handle_response @authenticated {"]
213 lines += [f" request_header {name} {{rp.header.{source}}}" for name, source in headers.items()]
214 if proof:
215 lines.append(f" request_header {proof} 1")
216 lines += [" }", " @anonymous status 4xx", " handle_response @anonymous {",
217 f" request_header -{scrub[0]}", " }", " }",
218 *proxy(upstreams, " "), " }", "}"]
219 else:
220 for index, (request, markup) in enumerate(head_html.items()):
221 name = f"@studio_head_{index}"
222 lines += [f" {name} path {request}", f" handle {name} {{", " route {",
223 f" replace </head> {json.dumps(markup + '</head>')} {{",
224 " match {", " header Content-Type text/html*", " }", " }",
225 *proxy(upstreams, " ", uncompressed=True), " }", " }"]
226 lines += [" handle {", *(f" request_header -{name}" for name in scrub),
227 *proxy(upstreams, " "), " }", "}"]
228 else:
229 lines += [f":{listener} {{", *proxy(" ".join(sorted(route["upstreams"])), " "), "}"]
230 traces = next((route for route in routes.values() if "victoria-traces" in route["services"]), None)
231 if traces:
232 lines += ["http://127.0.0.1:10428 {", " bind 127.0.0.1",
233 *proxy(" ".join(sorted(traces["upstreams"])), " "), "}"]
234 dashboard_host = "globe." + os.environ["STUDIO_DOMAIN"]
235 dashboard_port = int(os.environ["STUDIO_DASHBOARD_PORT"])
236 if not HOST.fullmatch(dashboard_host) or not 1 <= dashboard_port <= 65535:
237 raise ValueError("invalid dashboard route")
238 if (80, dashboard_host) in routes:
239 raise ValueError(f"dashboard route conflicts with a Nomad service: {dashboard_host}")
240 lines.append(f"{dashboard_host} {{")
241 if dashboard_host.endswith(".test"):
242 lines.append(" tls internal")
243 lines += [" encode zstd gzip", " tracing {", " span globe", " span_attributes {", " studio.kind edge", " }", " }"]
244 lines += [" @mcp_public path /oauth/* /mcp/* /.well-known/oauth-* /pairing /agent/connect /api/v1/*",
245 " handle @mcp_public {", " request_header -User-Name", " request_header -User-Groups",
246 " request_header -Studio-Proxy-Token", *proxy(f"127.0.0.1:{dashboard_port}", " "), " }"]
247 if auth_upstreams:
248 auth = " ".join(sorted(auth_upstreams))
249 lines += [
250 " handle /snow.oauth2/* {", *proxy(auth, " "), " }",
251 " handle {", " request_header -User-Name", " request_header -User-Groups", " request_header -Studio-Proxy-Token",
252 f" reverse_proxy {auth} {{", " lb_try_duration 5s", " fail_duration 30s",
253 " method GET", " rewrite /snow.oauth2/auth",
254 " header_up X-Forwarded-Method {method}", " header_up X-Forwarded-Uri {uri}",
255 " @unauthorized status 401", " handle_response @unauthorized {",
256 " redir * /snow.oauth2/sign_in?rd={scheme}://{host}{uri}", " }",
257 " @authenticated status 2xx", " handle_response @authenticated {",
258 " method {method}", " rewrite {uri}",
259 " request_header User-Name {rp.header.X-Auth-Request-Preferred-Username}",
260 " request_header User-Groups {rp.header.X-Auth-Request-Groups}",
261 f" request_header Studio-Proxy-Token {dashboard_proof}",
262 *proxy(f"127.0.0.1:{dashboard_port}", " "),
263 " }", " handle_response {", " respond 403", " }",
264 " }", " }", "}",
265 ]
266 else:
267 lines += [" header Retry-After 5", ' respond "Sign-in is unavailable. Try again in a moment." 503', "}"]
268 internal_port = os.environ.get("STUDIO_INTERNAL_PORT")
269 if internal_port is not None:
270 internal_host = "dashboard.internal." + os.environ["STUDIO_DOMAIN"]
271 if not HOST.fullmatch(internal_host) or not internal_port.isdecimal() or not 1 <= int(internal_port) <= 65535:
272 raise ValueError("invalid internal dashboard route")
273 if (80, internal_host) in routes or (int(internal_port), "") in routes:
274 raise ValueError("internal dashboard route conflicts with a Nomad service")
275 lines += [f"{internal_host}:{internal_port} {{", " tls internal",
276 f" @dashboard header Studio-Proxy-Token {dashboard_proof}",
277 " handle @dashboard {", " request_header -Studio-Proxy-Token",
278 " request_header -User-Name", " request_header -User-Groups",
279 " handle_path /nomad/* {", *proxy("127.0.0.1:4646", " ", upstream_host=True), " }"]
280 for service, upstreams in sorted(internal_services.items()):
281 lines += [f" handle_path /services/{service}/* {{",
282 *proxy(" ".join(sorted(upstreams)), " ", upstream_host=True), " }"]
283 lines += [" handle {", " respond 404", " }", " }", " handle {", " respond 403", " }", "}"]
284 return "\n".join(lines) + "\n"
285
286
287def update(content):
288 try:
289 with open(ROUTES) as file:
290 previous = file.read()
291 except FileNotFoundError:
292 previous = ""
293 if content == previous:
294 return
295 pending = ROUTES + ".pending"
296 def replace(value):
297 with open(pending, "w") as file:
298 file.write(value)
299 os.chown(pending, -1, grp.getgrnam("caddy").gr_gid)
300 os.chmod(pending, 0o640)
301 os.replace(pending, ROUTES)
302
303 replace(content)
304 result = subprocess.run(["systemctl", "reload", "caddy"], capture_output=True, text=True)
305 if result.returncode:
306 replace(previous)
307 raise RuntimeError(result.stderr.strip())
308 print("Caddy routes updated", flush=True)
309
310
311def main():
312 with open(TOKEN) as file:
313 token = file.read().strip()
314 while True:
315 try:
316 update(render(token))
317 except Exception as error:
318 print(f"Caddy route update failed: {error}", file=sys.stderr, flush=True)
319 time.sleep(5)
320
321
322if __name__ == "__main__":
323 main()
tools/service-dependencies.md created+37
......@@ -0,0 +1,37 @@
1# Service connection checks
2
3On 2026-09-26, the VM's pgAdmin database contained one configured server, `Clover Postgres`, at the current Nomad address and port. A query from the pgAdmin container using its rendered environment authenticated to the `postgres` database. The `pg.studio.test` health route passed. Zenith's pgAdmin also had one saved server; Snow Globe creates its connection from the Postgres requirement on boot.
4
5Redis Insight's `/api/databases/0/info` initially connected to the VM Redis service. After stopping Redis allocation `7ca6e34a`, Nomad placed a new one and changed its port from `26848` to `26913`. Redis Insight's watched `nomadService` template restarted its task once; its saved connection then used `26913` and `/api/databases/0/info` connected again. A temporary Redis key survived the reallocation and was deleted afterward. Both `redis.studio.test` and `pg.studio.test` passed their HTTP checks. [Nomad's template `change_mode` defaults to `restart`](https://developer.hashicorp.com/nomad/docs/job-specification/template), and [Redis Insight applies changed preconfigured connections after a restart](https://redis.io/docs/latest/operate/redisinsight/configuration/).
6
7After the x86 VM crashed and rebooted on 2026-09-26, Keycloak needed several minutes to start under emulation. Shale's earlier `wait-keycloak-app` prestart task remained marked complete, so Shale restarted 19 times while its OIDC discovery endpoint returned 404. Forward Auth also retried. Both recovered when Keycloak became healthy. [Nomad does not rerun a successful non-sidecar prestart task after a task restart](https://developer.hashicorp.com/nomad/docs/job-specification/lifecycle).
8
9Evil.inc Forgejo and HedgeDoc remained running but unhealthy after the same reboot. Forgejo had tried PostgreSQL before it was ready and then stopped making progress. Targeted `nomad job restart -all-tasks` calls recovered both; the full 19-route Snow Globe check then passed. Snow Globe now renders a one-minute retry delay for dependent services and [Nomad `check_restart`](https://developer.hashicorp.com/nomad/docs/job-specification/check_restart) for unhealthy services. Disposable one- and two-task Nomad jobs proved that a failing group-level check restarts every running task in its group. Eight generated jobs, including Keycloak, Forgejo, and HedgeDoc, passed `nomad job validate`.
10
11Release `7ec7b25654702110` staged Shale from its ZFS clone and promoted to the VM after backup `20260926T134201Z-22d930` captured 22 service datasets. The promotion passed all 19 HTTPS routes. Live Nomad inspection confirmed Shale's one-minute retry delay and five-minute health-check grace, plus Forgejo's 20-minute grace from its declared healthy deadline.
12
13After a clean VM reboot, PostgreSQL passed first, Keycloak became healthy at 14:04 UTC, and Shale recovered on its next one-minute retry. Forgejo remained running but unhealthy after its early database connection failed. At 14:15 UTC, Nomad's group-level `check_restart` restarted both Forgejo and Anubis without intervention; both checks and Forgejo's HTTPS route passed by 14:17 UTC. The full 19-route check passed after reboot. From the Mac, Shale, Keycloak discovery, and the Shale preview returned 200 through `.studio.test` DNS and trusted TLS. The 20-minute grace made Forgejo's recovery too slow, so its service now declares a two-minute health-restart grace, separate from its deployment deadline.
14
15Keycloak preview `keycloak-preview-dbe9aba6` ran with the production `start` command against a cloned database. Its OIDC discovery advertised the preview hostname. Release `47342c5c49f3eb88` promoted that preview after backup `20260926T141745Z-93733a` captured 22 service datasets. The production Keycloak allocation passed its health check with zero restarts; the promotion completed its dependency allocation and all 19 HTTPS checks. From the Mac, discovery returned HTTP 200 with a trusted certificate and issuer `https://keycloak.studio.test/realms/master`. Live job inspection confirmed `start`, the full production hostname, and Forgejo's two-minute check-restart grace.
16
17Shale and Jellyfin currently use writable SQLite datasets, so their single-allocation `simple` rollouts cannot overlap old and new writers. The owner accepted a brief Shale restart after staged validation. A controlled restart of the Shale preview on the x86 VM returned 46 failed HTTPS probes from this Mac between 4.87 and 23.14 seconds into a 0.25-second sampling run. Nomad reported the task restart complete after two seconds, before the Caddy route served requests again. The preview subsequently returned HTTP 200 with trusted TLS and one running allocation. This measures a restart under x86 emulation, not a production release rollout or physical-host downtime.
18
19Open Speed Test has no writable mounts or fixed ports, so its job now uses Nomad's overlapping canary. The first VM promotion automatically promoted a healthy canary, but one of 200 Mac HTTPS probes received HTTP 502 when Caddy dialed the retiring allocation's closed port before a route reload. The router now gives generated reverse proxies a five-second retry window and remembers failed upstream connections for 30 seconds, using [Caddy's documented load balancing options](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy). The next staged release validated the generated Caddyfile and automatically promoted another healthy canary; 510 consecutive Mac HTTPS probes from 03:19:41 to 03:22:11 UTC returned 200, spanning its 03:21:05–03:21:20 rollout. This is a sampled VM result, not a guarantee that every request in future rollouts will succeed.
20
21For a new clone stage with a PostgreSQL database requirement, Snow Globe now snapshots the service dataset and PostgreSQL dataset in one ZFS operation, which [OpenZFS creates atomically](https://openzfs.github.io/openzfs-docs/man/v2.1/8/zfs-snapshot.8.html). It starts an isolated PostgreSQL container from the latter snapshot and copies the database from that container into a stage database on the live PostgreSQL server. The 2026-09-27 HedgeDoc preview `evil-hedgedoc-preview-31768845` completed its Nomad deployment and returned HTTP 200 from the Mac. Its service snapshot remains for the preview; the temporary PostgreSQL snapshot, clone, and container were removed. This aligns the file and database fork point for these services, while applications remain responsible for their own crash recovery from the snapshots.
22
23Writable external ZFS mounts now join that same snapshot operation. The recreated YouTube Feed preview `yt-feed-preview-63d74b72` mounted cloned service data and a cloned writable Clover configuration folder, then completed its Nomad deployment. Both source snapshots report `createtxg=35540`, and the preview's VM health check passed. The Mac route redirected to authentication as configured.
24
25The preview's web container wrote a disposable file through `/yt-config` as its assigned `3118:3000` identity. It appeared in the cloned Clover configuration folder with that owner and was absent from `/srv/clover/Documents/Config/Youtube Downloader`; the file was removed afterward. This verifies writable external mount isolation and group access through the container, beyond snapshot metadata alone.
26
27An encrypted ZFS rehearsal on the VM created separate source and staging encryption roots, then cloned a source snapshot beneath the staging root. The clone mounted, retained the source encryption root and origin, initially used `0B`, and accepted a write without changing the source file. The temporary datasets and keys were destroyed afterward. Production staging therefore needs the source dataset key loaded while its clone exists; placing the clone below a separately encrypted staging parent does not rekey it.
28
29Promotion of `c1f546fe904f4c33` stopped during Keycloak configuration. The old task digest included every file in each service folder, so a change to PostgreSQL's `provide.py` restarted PostgreSQL despite no database job configuration change. Keycloak's watched database endpoint then restarted its existing task; its group-level health check restarted that task again before x86 startup completed. A fresh Keycloak allocation completed startup, and all 19 production routes passed. Snow Globe now hashes source files into a task environment variable only for services with a `prepare` script, since those scripts change startup files under managed volumes. Editing PostgreSQL's provider script left its rendered job identical in a temporary release copy; editing qBittorrent's prepare script changed its rendered job. Single-container Nomad services now associate their checks with the task, and setup waits for its route before calling the app API. All 26 generated jobs validated. The new Keycloak preview reached HTTPS health and completed setup with zero restarts; after a manual in-place restart, it had no health-triggered restart through its nine-minute startup.
30
31Release `c19bee75fad72499` promoted successfully after backup `20260927T042521Z-9fcd75`; all 19 routes passed. Keycloak's production allocation reached health after its slow x86 build and startup with zero restarts. The Mac reached Keycloak and Shale with HTTP 200 and Jellyfin with its expected login redirect. During pgAdmin startup, QEMU paused with a host disk I/O error. Removing the two detached NixOS installer ISOs and moving the unused arm64 demo disk to `/Volumes/Project/Studio VM Archives/clover-studio-vm-arm64.qcow2` freed host space; QEMU resumed and the same promotion process finished. The resulting release history and `/opt/studio/current` both point at `c19bee75fad72499`.
32
33Read-only `nomad job plan` checks against the active release returned no allocation creates or destroys for PostgreSQL, Keycloak, or Shale. Their rendered HCL files were byte-identical to `nomad job inspect -hcl`. Nomad still described each plan as one in-place update without a field-level diff, so that phrase alone is not evidence of a changed job or a task restart. [The Nomad CLI defines exit code 0 as no allocation creation or destruction](https://developer.hashicorp.com/nomad/commands/job/plan). Resubmitting the identical PostgreSQL job retained its allocation `ff3fc333` and version 14; Keycloak retained allocation `d1c5e27a`.
34
35Release `9ee7ba520b01b1e5` staged YouTube Triage with its writable external clone, passed the VM NixOS dry build, and promoted after backup `20260927T053411Z-183aca`. The NixOS switch raised the demo pool size to 16 GB; all 19 routes passed. PostgreSQL, Keycloak, Jellyfin, Shale, and Dawarich kept allocation IDs `ff3fc333`, `d1c5e27a`, `c2b6c232`, `25050918`, and `abad9408` through this promotion. The Mac received HTTP 302 at the protected preview URL and HTTP 200 from Shale.
36
37The manual CLI rolled back from `9ee7ba520b01b1e5` to `c19bee75fad72499` after backup `20260927T054434Z-67d6c0`, switched the NixOS host configuration, and passed all 19 routes. Promoting the same preview again backed up the rollback state as `20260927T055034Z-27d895`, restored NixOS release `9ee7ba520b01b1e5`, and passed the same route sweep. Those five sampled allocations kept their IDs through both switches; the Mac still received Shale HTTP 200 and the protected preview's HTTP 302. Data restore was not invoked by code rollback.
tools/shale-migration.md created+7
......@@ -0,0 +1,7 @@
1# Shale migration
2
3Zenith's Shale app directory contains a small SQLite database and 419 MB of owned repositories. `bash tools/import-shale.sh shale-preview-4eea0e3b` copied `data`, `repositories_owned`, and `repositories_mirrors` opaquely from the read-only `storage1/apps@hourly-2026-09-26_05-00` snapshot. It verified checksums and SQLite integrity, then restarted the preview. Both sides had 11 top-level owned repository directories; the preview had one healthy Nomad allocation and returned HTTPS 200. Repository contents were not inspected.
4
5For the production copy, stop Zenith's Shale container and the Snow Globe Shale job, set `STUDIO_DEPLOY_HOST` and `STUDIO_DEPLOY_PORT` for the new host, then run `bash tools/import-shale.sh shale`. The importer checks both jobs remain stopped, snapshots the destination dataset, verifies all three copied directories, and leaves Snow Globe stopped. Start the new job after the copy, check the SQLite state and a known login through the new Keycloak client, then switch the public route. The destination snapshot printed by the importer remains available for recovery.
6
7After a same-machine OS replacement, set `STUDIO_LEGACY_HANDOFF` to the [offline handoff](legacy-handoff.md) directory as well. The importer then reads the retained Shale directory from the new host's mounted old apps dataset, with no old Docker dependency.
tools/source-of-truth-migration.md created+19
......@@ -0,0 +1,19 @@
1# Clover Source of Truth migration
2
3The existing preview can take a consistent copy of Zenith's live SQLite index without stopping the old service:
4
5```sh
6sh tools/import-source-index.sh clover-source-of-truth-preview-6311ec97
7```
8
9The script stops only that preview, snapshots its ZFS dataset, imports a SQLite `.backup`, checks integrity and checksums, then stages and checks the HTTP route. It retains the pre-import snapshot for recovery. It leaves the preview's own API key in place; the old key must be preserved for the production endpoint because existing clients send it as their authorization header.
10
11On 2026-09-26, Zenith's live index contained 2,592 media files, 136,842 derived file records, and 15,635 derived references. The imported preview passed SQLite integrity, reported the same counts, returned HTTP 200 at `clover-source-of-truth-preview-6311ec97.studio.test`, and had one healthy Nomad allocation. A nonexistent file returned 404. One indexed public raw file and its derived asset were copied from Zenith into the isolated preview, served over HTTPS with HTTP 200, and matched their source SHA-256 hashes; the test files were then removed. The full Published and `derived` trees remain absent from the VM. Zenith's derived tree holds 143,547 files totaling 39,684,661,494 bytes, beyond the demo pool's capacity.
12
13The `clo` account used by the stream importer had zero unreadable files and zero untraversable directories in the source tree at inspection.
14
15For the real cutover, keep `storage1/clover` mounted at `/srv/clover` and point the service's `/published` mount at `/srv/clover/Published`. That avoids copying Published at all. Stop the old source-of-truth container and the new Nomad job, then run `bash tools/import-source-data.sh` with `STUDIO_DEPLOY_HOST` and `STUDIO_DEPLOY_PORT` set for the new host while Zenith remains reachable over SSH. The importer checks both jobs, streams `Documents/Config/paper clover/` into the service's managed ZFS dataset without storing 40 GB on the Mac, compares content digests and SQLite integrity, and leaves Snow Globe stopped with its pre-import snapshot available. A small cross-host fixture verified the stream, including a symlink and hardlink; the full 40 GB transfer remains a production cutover operation. Later staging clones the managed service dataset with ZFS.
16
17If NixOS replaces Zenith on the same machine, stop the old service and take a named `storage1/clover` snapshot before reinstalling. After the pool is mounted at `/srv/clover`, set `STUDIO_MIGRATION_SNAPSHOT=<name>` when running the same importer. It reads the immutable `/srv/clover/.zfs/snapshot/<name>/Documents/Config/paper clover/` tree on the new host and streams directly into the service dataset; no old Docker daemon or second machine is required. The importer rejects a missing or malformed snapshot name before changing the destination.
18
19Run `bash tools/import-legacy-secrets.sh clover-source-of-truth CLOVER_SOT_KEY` during cutover to transfer the old key directly from Zenith's `.env` into Snow Globe's Nomad secret, then deploy the service so its environment receives the imported value. This command changes the production service key; do not run it against the VM while its generated key is in use. Check a representative public raw and derived file over the new hostname before changing the public route. The old container and dataset snapshots remain available until this check passes.
tools/storage-acl-cutover.md created+33
......@@ -0,0 +1,33 @@
1# Storage ACL cutover
2
3Zenith mounts `storage1/clover` and `storage1/media` with `nfs4acl`. Their NFSv4 ACLs are nontrivial: Clover grants `group:apps` and `user:1000`; Media grants `user:clo` and TrueNAS built-in groups. Media has 2,431 directories and 28,729 files. Of those, 543 directories and 12,885 files are `root:root` with mode `770`, so UID 3000 relies on the NFSv4 ACL to reach them.
4
5[Stock OpenZFS on Linux does not enforce NFSv4 ACLs](https://openzfs.github.io/openzfs-docs/Basic%20Concepts/Datasets/ACLs.html). The NixOS VM accepted `acltype=nfsv4` on a disposable dataset but rejected `setfacl` and denied UID 3000 access to a root-owned `770` directory. Setting `acltype=posixacl` and granting UID 3000 access made the same check pass. A property value of `nfsv4` alone is therefore insufficient proof of access on NixOS.
6
7`tools/studio.py pool` rejects a mounted Clover or Media ZFS dataset with `acltype=nfsv4` before provisioning service volumes. It also rejects NFSv4 ACLs on the production or staging dataset parent, including values inherited from the pool; a disposable pool proved all three cases and accepted POSIX ACLs. Promotion runs the Clover/Media and production preflight before switching `/opt/studio/current`; a disposable NFSv4 dataset caused promotion to fail while its release link and history stayed unchanged.
8
9The media rename and mountpoint change in [media-cutover.md](media-cutover.md) must be paired with a permission migration. Rehearse it on copy-on-write clones of both datasets, leaving the originals and their snapshots intact. A shared numeric group `3000` with group read/write and setgid directories matches the VM's working Clover, Samba, and Copyparty permissions; a clone test must also verify media writers and readers before choosing that policy for the real tree. Changing `acltype` does not translate existing NFSv4 entries into POSIX ACLs, and [TrueNAS warns against recursive ACL changes without a snapshot](https://www.truenas.com/docs/scale/datasets/permissions/permissions/).
10
11A disposable POSIX-ACL ZFS fixture started with root-owned `0770` directories and a `0660` file that UID 3000 could not read. Granting named group 3000 access and a default ACL on directories, without changing owners, let UID 3000 and Jellyfin UID 3106 read the file. qBittorrent UID 3114 created a file that Copyparty UID 3116 could append to and UID 3000 could edit. The new file kept group 0 from its setgid parent, but inherited the named group 3000 ACL; the fixture was destroyed. This proves local service access on representative modes; SMB has a separate limitation below.
12
13A macOS SMB mount reached the VM over Tailscale and wrote a file as `3000:3000` with mode `0644`, despite Samba's `force create mode = 0660`; Copyparty's group identity could not edit it. A temporary Samba configuration with `acl_xattr` and `acl_xattr:ignore system acls = yes` yielded `0666`. Adding a parent default POSIX ACL (`user::rwx,group::rwx,other::---`) yielded files at `0660`, but Mac-created directories at `2777`. Adding `inherit permissions = yes` yielded directories at `2770` and files at `0660`; Copyparty's UID 3116/GID 3000 then appended to a Mac-created file and created a sibling in its directory. `inherit permissions` and the default ACL without `acl_xattr` instead yielded `0755` directories and `0644` files. The tested Samba settings are now in `service/samba/service.pkl`, retaining the image's `catia fruit streams_xattr` Mac VFS modules. A disposable write through the staged Samba share again produced a `0660` file and `2770` directory, both writable by UID 3116/GID 3000; the fixture was removed. VM promotion `b1a2cdde0f6f12a4` passed all 19 HTTP routes and kept four PostgreSQL dumps in its pre-switch backup. The real dataset cutover still needs inherited POSIX ACLs applied through writable directories and verified on clones. [Samba documents the `acl_xattr` behavior](https://www.samba.org/samba/docs/current/man-html/vfs_acl_xattr.8.html).
14
15`acl_xattr:ignore system acls = yes` ignores named POSIX ACL grants for SMB permission checks. A staged Samba version without `acl_xattr` let a Linux SMB client write root-owned files through group ACLs, but a macOS mount then created `0644` files and `0755` directories that Copyparty could not edit. That version was rolled back. With the current Samba settings, a Mac mount created `0660` files and `2770` directories in a group-owned `3000` fixture, and Copyparty could append. The current configuration therefore needs group ownership as well as ACLs on a production clone rehearsal; the local UID test alone does not establish Finder access.
16
17A later disposable Samba test changed only `acl_xattr:ignore system acls` to `no`. In a root-owned, group-0 directory with a named/default POSIX ACL for group 3000, a macOS SMB mount created a file and directory, overwrote an existing root-owned file, and Copyparty UID 3116/GID 3000 appended to the new file. This worked both with and without `inherit permissions`. The new file and directory were `3000:3000` mode `0770`; the existing file retained root ownership. This offers a way to avoid changing ownership across the existing tree, but the executable bits on new regular files need review. The disposable dataset and container were removed, and the managed Samba job was restored without changing its configuration.
18
19An isolated Samba container on port 1445 was reachable from macOS through an SSH tunnel using `mount_smbfs //clo@127.0.0.1:1445/clover`; the managed share stayed online. In that fixture, disabling DOS archive/hidden/system mode mapping and then tightening `create mask` to `0660`, `directory mask` to `0770`, and `acl map full control` to `no` still produced `0770` Finder files and directories. Copyparty could append. The execute bits therefore appear to come from the ACL mapping in this combination, not the tested mode masks. The isolated container, dataset, and tunnel were removed.
20
21The same isolated setup without `acl_xattr` and with `inherit permissions = no` let Finder overwrite an existing root-owned file through its named ACL. New Finder files were `0644` and directories `0755` despite Samba's forced `0660`/`2770` modes, and the new file's inherited group ACL was masked to read-only; Copyparty could not append. That configuration cannot satisfy shared editing without a further permission mechanism. The fixture was removed.
22
23With Samba's default oplocks, a Mac SMB mount returned stale bytes and NULs immediately after Copyparty appended to a mounted file; remounting showed the correct server data. A disposable Samba container with `oplocks = no` returned the correct bytes immediately in the same test. The managed Samba job was restored afterward. Disabling oplocks may reduce SMB client caching performance and has not been promoted.
24
25Zenith's `storage1` pool root is unencrypted; `storage1/apps`, `storage1/clover`, and `storage1/media` are separate AES-256-GCM encryption roots with `keyformat=hex` and `keylocation=prompt`. Creating `storage1/prod` beneath the pool root without encryption would silently expose new app data. Snow Globe now requires an encrypted `prod` dataset mounted at `/srv/prod` whenever Clover or Media is encrypted. A disposable encrypted Clover dataset failed this preflight against the VM's unencrypted demo `prod`. A fresh disposable pool with separate encrypted Clover and `prod` roots passed: the `shale` service dataset inherited `prod` as its encryption root. The current VM predates the mounted-parent change and still has `studio-demo/prod` with `mountpoint=none`; its service children are mounted individually.
26
27The real host must load the selected keys before mounting datasets and starting Nomad; [loading a key does not itself mount the dataset](https://openzfs.github.io/openzfs-docs/man/v2.2/8/zfs-load-key.8.html). Keep keys out of the repository. A separate VM test confirmed that renaming one independent encrypted root beneath another preserves its independent encryption root.
28
29Copyparty's previous `df: 16` reserve rejected every upload on the 8 GB VM pool. Its prepared config now reserves one quarter of the dataset capacity, capped at the original 16 GB. The staged and production VM instances accepted a file and directory upload; the resulting `0664` file and setgid `2775` directory were writable by Clover's separate UID/GID 3000. Test files were removed, the Source of Truth preview was restarted, and the Copyparty preview was stopped to return the VM's reserved memory. Promotion `ec5322fadd8ae7a8` passed all 19 HTTP routes with 22 ZFS snapshots and four PostgreSQL dumps in its backup.
30
31Snow Globe's `zfs clone` of a disposable encrypted Media source stayed AES-256-GCM encrypted under an unencrypted staging parent and shared the Media encryption root; writes to the clone left the source unchanged. [OpenZFS specifies that clones always share their origin's encryption key](https://openzfs.github.io/openzfs-docs/man/master/7/zfsprops.7.html). Fresh stages have no encrypted origin to inherit, so `tools/studio.py stage` now requires an encrypted `pool/staging` mounted at `/srv/staging` when `pool/prod` is encrypted. A disposable pool proved missing and unencrypted staging parents are rejected, while an encrypted parent passes. The VM's existing unencrypted pool still stages normally.
32
33`storage1/apps` is one encrypted dataset with no child datasets: 47.8 GiB live and 65.3 GiB held by 207 snapshots at inspection. It also contains stacks excluded from Snow Globe. Renaming it to `storage1/prod` would preserve that history but leave the service folders as ordinary directories; moving selected data into per-service child datasets would still be necessary. Creating a separate encrypted `prod` root keeps the old tree and its snapshots available during migration.
tools/studio.py created+1241
......@@ -0,0 +1,1241 @@
1#!/usr/bin/env python3
2import argparse
3from contextlib import nullcontext
4import fcntl
5import hashlib
6import json
7import os
8from pathlib import Path
9import re
10import secrets
11import shutil
12import subprocess
13import sys
14import tempfile
15import time
16
17from data import cloned_postgres, dataset_for
18
19
20REPO = Path(__file__).resolve().parent.parent
21SERVICES = REPO / "service"
22STATE = Path("/var/lib/studio")
23IDENTITIES = STATE / "identities.json"
24ASSETS = Path("/var/lib/caddy/studio")
25NAME = re.compile(r"[a-z][a-z0-9-]*\Z")
26
27
28def command(*args, capture=False, **kwargs):
29 return subprocess.run(args, check=True, text=True, capture_output=capture, **kwargs)
30
31
32def service_files():
33 files = {}
34 for path in SERVICES.glob("*/*.pkl"):
35 name = path.parent.name if path.name == "service.pkl" else path.stem
36 if name in files:
37 raise ValueError(f"duplicate service definition: {name}")
38 files[name] = path
39 return files
40
41
42def services():
43 return sorted(service_files())
44
45
46def service_dir(name):
47 path = service_files()[name]
48 return path.parent if path.name == "service.pkl" else path.parent / name
49
50
51def dependencies(data):
52 return set(data["dependsOn"]) | {item["provider"] for item in data["inputs"].values()}
53
54
55def ordered(data):
56 pending = {item["id"]: item for item in data}
57 result = []
58 while pending:
59 ready = [
60 item
61 for item in pending.values()
62 if not dependencies(item) & pending.keys()
63 ]
64 if not ready:
65 raise ValueError("cycle in service dependencies")
66 for item in ready:
67 result.append(item)
68 del pending[item["id"]]
69 return result
70
71
72def identity(name):
73 STATE.mkdir(parents=True, exist_ok=True)
74 if not IDENTITIES.exists():
75 write_private(IDENTITIES, (REPO / "config/identities.json").read_text())
76 with (IDENTITIES.parent / ".identities.lock").open("w") as lock:
77 fcntl.flock(lock, fcntl.LOCK_EX)
78 with IDENTITIES.open() as file:
79 ids = json.load(file)
80 if len(set(ids.values())) != len(ids):
81 raise ValueError("duplicate service UIDs")
82 if name not in ids:
83 used = set(ids.values())
84 ids[name] = next(uid for uid in range(3100, 60000) if uid not in used)
85 pending = IDENTITIES.with_suffix(".pending")
86 pending.write_text(json.dumps(ids, indent=2) + "\n")
87 pending.replace(IDENTITIES)
88 return ids[name]
89
90
91def load(name, properties, instance_id=None):
92 files = service_files()
93 if not NAME.fullmatch(name) or name not in files:
94 raise ValueError(f"unknown service: {name}")
95 instance_id = instance_id or name
96 if not NAME.fullmatch(instance_id):
97 raise ValueError(f"invalid instance ID: {instance_id}")
98 uid = identity(name)
99 result = command(
100 "pkl",
101 "eval",
102 *(
103 part
104 for key, value in {**properties, "serviceId": instance_id, "uid": uid, "preview": str(instance_id != name).lower()}.items()
105 for part in ("-p", f"{key}={value}")
106 ),
107 str(files[name]),
108 capture=True,
109 )
110 data = json.loads(result.stdout)
111 if data["id"] != instance_id:
112 raise ValueError(f"service ID must match deployment: {instance_id}")
113 inputs = {}
114 for requirement in data.pop("requirements"):
115 alias = requirement["alias"]
116 if not NAME.fullmatch(alias) or alias == "own" or alias in inputs:
117 raise ValueError(f"invalid or duplicate requirement alias: {alias}")
118 inputs[alias] = requirement
119 data["inputs"] = inputs
120 data["uid"] = uid
121 data["sourceId"] = name
122 for task_name, task in data["containers"].items():
123 if bool(task.get("image")) == bool(task.get("build")):
124 raise ValueError(f"{name}.{task_name} needs one image or build directory")
125 if task.get("build"):
126 if REPO.parent == Path("/opt/studio/releases"):
127 context = config_path(name, task["build"])
128 if not context.is_dir() or not (context / "Dockerfile").is_file():
129 raise ValueError(f"invalid build context: {name}.{task_name}")
130 digest = hashlib.sha256()
131 for path in sorted(context.rglob("*")):
132 if path.is_file():
133 digest.update(str(path.relative_to(context)).encode() + b"\0")
134 digest.update(path.read_bytes())
135 tag = digest.hexdigest()[:16]
136 elif not (service_dir(name) / task["build"] / "Dockerfile").is_file() and not (service_dir(name) / "build-source.json").is_file():
137 raise ValueError(f"missing build source: {name}.{task_name}")
138 else:
139 tag = REPO.name
140 task["image"] = f"localhost/studio/{name}-{task_name}:{tag}"
141 return data
142
143
144def q(value):
145 return json.dumps(value, ensure_ascii=False)
146
147
148def write_private(path, content):
149 pending = None
150 try:
151 with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False) as file:
152 pending = Path(file.name)
153 file.write(content)
154 pending.replace(path)
155 finally:
156 if pending:
157 pending.unlink(missing_ok=True)
158
159
160def host_path(root, relative):
161 if relative == ".":
162 return root
163 part = Path(relative)
164 if part.is_absolute() or ".." in part.parts or not part.parts:
165 raise ValueError(f"invalid relative path: {relative}")
166 return str(Path(root) / part)
167
168
169def config_path(service, relative):
170 root = service_dir(service).resolve()
171 source = (root / relative).resolve()
172 if not source.is_relative_to(root) or not source.exists():
173 raise ValueError(f"invalid config path: {relative}")
174 return source
175
176
177def config_assets(data):
178 paths = set()
179 for task in data["containers"].values():
180 paths.update(volume["config"] for volume in task["volumes"].values() if volume.get("config"))
181 if task.get("http"):
182 paths.update(task["http"]["overrideFiles"].values())
183 sources = {rel: config_path(data.get("sourceId", data["id"]), rel) for rel in paths}
184 digest = hashlib.sha256()
185 for rel, source in sorted(sources.items()):
186 digest.update(rel.encode())
187 files = sorted(source.rglob("*")) if source.is_dir() else [source]
188 for file in files:
189 if file.is_symlink():
190 raise ValueError(f"config asset cannot be a symlink: {file}")
191 if file.is_file():
192 digest.update(str(Path(rel) / file.relative_to(source)).encode() if source.is_dir() else rel.encode())
193 digest.update(file.read_bytes())
194 return ASSETS / data["id"] / digest.hexdigest()[:16], sources
195
196
197def render(data, definitions):
198 if not data["containers"]:
199 return ""
200 name = data["id"]
201 asset_dir, _ = config_assets(data)
202 prepare_digest = None
203 if data.get("prepare"):
204 source = service_dir(data.get("sourceId", name))
205 digest = hashlib.sha256()
206 for path in sorted(source.rglob("*")):
207 if not path.is_file() or "__pycache__" in path.parts or path.suffix == ".pyc":
208 continue
209 if path.name == "service.pkl" or path.name.startswith("icon"):
210 continue
211 digest.update(str(path.relative_to(source)).encode() + b"\0")
212 digest.update(path.read_bytes())
213 prepare_digest = digest.hexdigest()[:16]
214 routed = [task["http"] for task in data["containers"].values()
215 if task.get("http") and task["http"].get("hostname")]
216 primary = routed[0] if routed else {}
217 requirements = {provider: "startup" for provider in data["dependsOn"]}
218 requirements.update({item["provider"]: item["kind"] for item in data["inputs"].values()})
219 secrets_meta = [
220 {"name": name, "generated": True, "bytes": spec["bytes"]}
221 for name, spec in data["secrets"].items()
222 ] + [{"name": name, "generated": False} for name in data["requiredSecrets"]]
223 lines = [
224 f"job {q(name)} {{",
225 ' datacenters = ["clover"]',
226 ' type = "service"',
227 ' meta {',
228 f" studio_service = {q(data['sourceId'])}",
229 f" studio_name = {q(data['name'])}",
230 f" studio_tagline = {q(data['tagline'])}",
231 f" studio_hostname = {q(primary.get('hostname') or '')}",
232 f" studio_auth_role = {q(primary.get('authRole') or '')}",
233 f" studio_metrics_path = {q(primary.get('metricsPath') or '')}",
234 f" studio_trace_service = {q(data.get('traceServiceName') or '')}",
235 f" studio_metrics_pushed = {q(str(data['metricsPushed']).lower())}",
236 f" studio_requires = {q(json.dumps(requirements))}",
237 f" studio_secrets = {q(json.dumps(secrets_meta))}",
238 ' }',
239 ' group "app" {',
240 ]
241 if dependencies(data):
242 lines += [
243 " restart {",
244 " attempts = 3",
245 ' delay = "1m"',
246 ' interval = "4m"',
247 ' mode = "delay"',
248 " }",
249 ]
250 if data["rollout"] == "overlapped":
251 for task in data["containers"].values():
252 if task["hostNetwork"] or any(
253 endpoint and endpoint.get("hostPort") is not None
254 for endpoint in (task.get("http"), task.get("tcp"))
255 ) or any(not volume["readOnly"] for volume in task["volumes"].values()):
256 raise ValueError(f"overlapped rollout requires dynamic ports and read-only mounts: {name}")
257 elif data["rollout"] != "simple":
258 raise ValueError(f"unknown rollout: {data['rollout']}")
259 if data["rollout"] == "overlapped" or data.get("healthyDeadline"):
260 lines.append(" update {")
261 if data["rollout"] == "overlapped":
262 lines += [" canary = 1", " auto_promote = true", " auto_revert = true"]
263 if data.get("healthyDeadline"):
264 lines += [f" healthy_deadline = {q(data['healthyDeadline'])}", ' progress_deadline = "0"']
265 lines.append(" }")
266 if data.get("healthyDeadline"):
267 lines += [" migrate {", f" healthy_deadline = {q(data['healthyDeadline'])}", " }"]
268 ports = {}
269 network = []
270 host_network = any(task["hostNetwork"] for task in data["containers"].values())
271 if host_network and not all(task["hostNetwork"] for task in data["containers"].values()):
272 raise ValueError("mixed host and bridge networking in one group")
273 for task_name, task in data["containers"].items():
274 for kind in ("http", "tcp"):
275 if task.get(kind):
276 endpoint = task[kind]
277 port_name = endpoint["name"] if kind == "tcp" else kind
278 label = port_name if len(data["containers"]) == 1 else f"{task_name}-{port_name}"
279 ports[(task_name, kind)] = label
280 network.append(f" port {q(label)} {{")
281 if endpoint.get("hostPort") is not None:
282 network.append(f" static = {endpoint['hostPort']}")
283 if not task["hostNetwork"]:
284 network.append(f" to = {endpoint['containerPort']}")
285 if (kind == "http" and endpoint.get("hostPort") is None) or (kind == "tcp" and endpoint["loopback"]):
286 network.append(' host_network = "loopback"')
287 network.append(" }")
288 if network:
289 lines.append(" network {")
290 if host_network:
291 lines.append(' mode = "host"')
292 lines += network + [" }"]
293 for provider_id in sorted(dependencies(data)):
294 provider = definitions[provider_id]
295 for provider_task, container in provider["containers"].items():
296 endpoint = container.get("http")
297 if not endpoint or not endpoint.get("hostname") or endpoint.get("authRole"):
298 continue
299 host = endpoint["hostname"]
300 task_name = f"wait-{provider_id}-{provider_task}"
301 lines += [
302 f" task {q(task_name)} {{",
303 ' driver = "podman"',
304 " lifecycle {",
305 ' hook = "prestart"',
306 " sidecar = false",
307 " }",
308 " config {",
309 ' image = "docker.io/curlimages/curl@sha256:43ebaa53d3806db6b1ce4353b6b26ae638ec1c167ee351524b05690f988bb20d"',
310 f" extra_hosts = {q([host + ':host-gateway'])}",
311 f" args = {q(['--insecure', '--fail', '--silent', '--show-error', '--retry', '999999', '--retry-all-errors', '--retry-delay', '5', '--connect-timeout', '3', '--max-time', '5', 'https://' + host + endpoint['checkPath']])}",
312 " }",
313 " resources {",
314 " cpu = 50",
315 " memory = 64",
316 " }",
317 " }",
318 ]
319 for task_name, task in data["containers"].items():
320 if task.get("http") or task.get("tcp"):
321 kind = "http" if task.get("http") else "tcp"
322 endpoint = task[kind]
323 lines += [" service {", f" name = {q(name if task_name == 'app' else name + '-' + task_name)}", ' provider = "nomad"', f" port = {q(ports[(task_name, kind)])}"]
324 if len(data["containers"]) == 1:
325 lines.append(f" task = {q(task_name)}")
326 if kind == "http" and endpoint.get("hostname"):
327 tags = ["caddy-host=" + host for host in [endpoint["hostname"], *endpoint["plainHostnames"]]]
328 if endpoint.get("authRole"):
329 tags.append("caddy-auth-role=" + endpoint["authRole"])
330 if endpoint.get("userHeader"):
331 tags.append("caddy-user-header=" + endpoint["userHeader"])
332 if endpoint["forwardRealIp"]:
333 tags.append("caddy-real-ip=true")
334 if endpoint["tlsInternal"]:
335 tags.append("caddy-internal=true")
336 lines.append(f" tags = {q(tags)}")
337 lines += [" check {", f" type = {q(kind)}"]
338 if kind == "http":
339 lines.append(f" path = {q(endpoint['checkPath'])}")
340 if endpoint["checkHeaders"]:
341 lines.append(" header {")
342 for key, value in endpoint["checkHeaders"].items():
343 if not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", key):
344 raise ValueError(f"invalid health check header: {key}")
345 lines.append(f" {key} = [{q(value)}]")
346 lines.append(" }")
347 lines += [
348 ' interval = "10s"',
349 ' timeout = "15s"',
350 ' check_restart {',
351 ' limit = 12',
352 f" grace = {q(data.get('healthRestartGrace') or data.get('healthyDeadline') or '5m')}",
353 ' }',
354 ' }',
355 ' }',
356 ]
357 for task_name, task in data["containers"].items():
358 lines += [f" task {q(task_name)} {{", ' driver = "podman"']
359 if task["lifecycle"] != "main":
360 lines += [' lifecycle {', ' hook = "prestart"',
361 f" sidecar = {str(task['lifecycle'] == 'prestartSidecar').lower()}", ' }']
362 if not task["imageUser"]:
363 uses_clover = any(
364 volume.get("clover") or (
365 volume.get("src")
366 and Path(volume["src"]).resolve().is_relative_to(Path(data["cloverRoot"]).resolve())
367 )
368 for volume in task["volumes"].values()
369 )
370 gid = data["cloverGid"] if uses_clover else 0 if task["rootGroup"] else data["uid"]
371 lines.append(f" user = {q(str(data['uid']) + ':' + str(gid))}")
372 lines += [" config {", f" image = {q(task['image'])}"]
373 if task.get("entrypoint"):
374 lines.append(f" entrypoint = {q(task['entrypoint'])}")
375 if task["args"]:
376 lines.append(f" args = {q(task['args'])}")
377 if task["hostNetwork"]:
378 lines.append(' network_mode = "host"')
379 task_ports = [label for (owner, _), label in ports.items() if owner == task_name]
380 if task_ports and not task["hostNetwork"]:
381 lines.append(f" ports = {q(task_ports)}")
382 for field, values in (("cap_add", task["capAdd"]), ("devices", task["devices"]), ("extra_hosts", task["extraHosts"])):
383 if values:
384 lines.append(f" {field} = {q(values)}")
385 volumes = []
386 for target, volume in task["volumes"].items():
387 if not target.startswith("/") or ":" in target:
388 raise ValueError(f"invalid volume target: {target}")
389 if volume.get("config") is not None:
390 if volume.get("src") is not None or not volume["readOnly"]:
391 raise ValueError("config mounts must be read only and have no host source")
392 config_path(data.get("sourceId", name), volume["config"])
393 source = host_path(str(asset_dir), volume["config"])
394 elif volume.get("src") is not None:
395 source = volume["src"]
396 if not Path(source).is_absolute():
397 raise ValueError(f"volume source must be absolute: {source}")
398 else:
399 source = host_path(data["hostRoot"], target.lstrip("/"))
400 if ":" in source:
401 raise ValueError(f"invalid volume source: {source}")
402 volumes.append(f"{source}:{target}" + (":ro" if volume["readOnly"] else ""))
403 if volumes:
404 lines.append(f" volumes = {q(volumes)}")
405 if task["tmpfs"]:
406 lines.append(f" tmpfs = {q(task['tmpfs'])}")
407 lines.append(" }")
408 secret_env = []
409 plain_env = {}
410 for key, value in task["env"].items():
411 if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
412 raise ValueError(f"invalid env key: {key}")
413 parts = re.split(r"(\$\{secret\.[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\})", value)
414 if len(parts) == 1:
415 if "${secret." in value:
416 raise ValueError(f"invalid secret reference: {value}")
417 plain_env[key] = value
418 continue
419 args = []
420 paths = {}
421 for part in parts:
422 match = re.fullmatch(r"\$\{secret\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)\}", part)
423 if match:
424 alias, field = match.groups()
425 if alias != "own" and alias not in data["inputs"]:
426 raise ValueError(f"unknown secret alias: {alias}")
427 path = f"nomad/jobs/{name}" + (f"/inputs/{alias}" if alias != "own" else "")
428 variable = paths.setdefault(path, f"$s{len(paths)}")
429 args.append(f"(index {variable} {q(field)}).Value")
430 elif part:
431 args.append(q(part))
432 start = "".join(f'{{{{ with {variable} := nomadVar {q(path)} }}}}' for path, variable in paths.items())
433 end = "{{ end }}" * len(paths)
434 secret_env.append(f'{start}{key}={{{{ print {" ".join(args)} | toJSON }}}}{end}')
435 if "STUDIO_PREPARE_DIGEST" in task["env"]:
436 raise ValueError("STUDIO_PREPARE_DIGEST is reserved")
437 lines.append(" env {")
438 for key, value in plain_env.items():
439 lines.append(f" {key} = {q(value)}")
440 if prepare_digest:
441 lines.append(f" STUDIO_PREPARE_DIGEST = {q(prepare_digest)}")
442 lines.append(" }")
443 if secret_env:
444 lines += [" template {", " data = <<EOF", *secret_env, "EOF", f' destination = {q("secrets/" + task_name + "-secret.env")}', " env = true", " error_on_missing_key = true", " }"]
445 if task.get("envTemplate"):
446 if "\nEOF\n" in task["envTemplate"]:
447 raise ValueError("invalid env template delimiter")
448 lines += [" template {", " data = <<EOF", task["envTemplate"].rstrip(), "EOF", f' destination = {q("secrets/" + task_name + ".env")}', " env = true", " }"]
449 lines += [" resources {", f" cpu = {task['cpu']}", f" memory = {task['memory']}", " }", " }"]
450 lines += [" }", "}"]
451 return "\n".join(lines) + "\n"
452
453
454def token():
455 if "NOMAD_TOKEN" not in os.environ:
456 os.environ["NOMAD_TOKEN"] = (STATE / "nomad.token").read_text().strip()
457
458
459def get_variable(path):
460 result = subprocess.run(["nomad", "var", "get", "-out=json", path], capture_output=True, text=True)
461 if result.returncode == 0:
462 return json.loads(result.stdout)
463 if "Variable not found" in result.stderr + result.stdout:
464 return None
465 raise RuntimeError(f"Nomad variable read failed for {path}: {result.stderr.strip()}")
466
467
468def provision(data):
469 if not data["containers"]:
470 return
471 if os.geteuid() != 0:
472 raise PermissionError("provisioning requires root")
473 root = Path(data["hostRoot"])
474 if data["hasManagedVolumes"]:
475 root.mkdir(parents=True, exist_ok=True)
476 if command(
477 "findmnt", "-n", "-o", "FSTYPE", "--mountpoint", str(root), capture=True
478 ).stdout.strip() != "zfs":
479 raise ValueError(f"expected ZFS dataset at {root}")
480 for task in data["containers"].values():
481 gid = 0 if task["rootGroup"] else data["uid"]
482 for target, volume in task["volumes"].items():
483 if volume.get("config") is not None or volume.get("src") is not None:
484 continue
485 path = Path(host_path(str(root), target.lstrip("/")))
486 path.mkdir(parents=True, exist_ok=True)
487 os.chown(path, data["uid"], gid)
488 path.chmod(0o750)
489 if data["id"] == "yt-feed" and target == "/data":
490 command("systemd-tmpfiles", "--create", "--prefix=" + str(path))
491 bundle = STATE / "ca-bundle.crt"
492 if any(
493 volume.get("src") == str(bundle)
494 for task in data["containers"].values()
495 for volume in task["volumes"].values()
496 ):
497 contents = Path("/etc/ssl/certs/ca-certificates.crt").read_bytes()
498 local_ca = Path("/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt")
499 if local_ca.exists():
500 contents += b"\n" + local_ca.read_bytes()
501 if not bundle.exists() or bundle.read_bytes() != contents:
502 pending = bundle.with_suffix(".pending")
503 pending.write_bytes(contents)
504 pending.chmod(0o644)
505 pending.replace(bundle)
506 route_dir = STATE / "routes"
507 route_dir.mkdir(parents=True, exist_ok=True)
508 asset_dir, assets = config_assets(data)
509 static = {}
510 directories = {}
511 identity = {}
512 head_html = {}
513 metrics_paths = {}
514 for task in data["containers"].values():
515 if task.get("http"):
516 http = task["http"]
517 if http["identityHeaders"]:
518 identity[http["hostname"]] = http["identityHeaders"]
519 if http["headHtml"]:
520 head_html[http["hostname"]] = http["headHtml"]
521 if http.get("metricsPath") and http["hostname"]:
522 metrics_paths[http["hostname"]] = http["metricsPath"]
523 for request, rel in http["overrideFiles"].items():
524 origin = assets[rel]
525 target = directories if origin.is_dir() else static
526 target[request] = host_path(str(asset_dir), rel)
527 if assets:
528 asset_dir.parent.mkdir(parents=True, exist_ok=True)
529 asset_dir.parent.chmod(0o755)
530 if assets and not asset_dir.exists():
531 temporary = tempfile.mkdtemp(dir=asset_dir.parent)
532 try:
533 for rel, origin in assets.items():
534 destination = Path(temporary) / rel
535 destination.parent.mkdir(parents=True, exist_ok=True)
536 if origin.is_dir():
537 shutil.copytree(origin, destination)
538 for child in destination.rglob("*"):
539 child.chmod(0o755 if child.is_dir() else 0o644)
540 else:
541 shutil.copy2(origin, destination)
542 destination.chmod(0o644)
543 Path(temporary).chmod(0o755)
544 os.replace(temporary, asset_dir)
545 finally:
546 shutil.rmtree(temporary, ignore_errors=True)
547 secret_dir = STATE / "secrets"
548 secret_dir.mkdir(parents=True, exist_ok=True)
549 secret_file = secret_dir / f"{data['id']}.nv.hcl"
550 generated = data["secrets"]
551 required = data["requiredSecrets"]
552 secret_path = "nomad/jobs/" + data["id"]
553 existing = get_variable(secret_path) if generated or required else None
554 if generated and not existing and secret_file.exists():
555 command("nomad", "var", "put", "-in=hcl", "-out=none", secret_path, "@" + str(secret_file))
556 existing = get_variable(secret_path)
557 values = dict(existing["Items"]) if existing else {}
558 if generated:
559 for item, spec in generated.items():
560 if item not in values:
561 values[item] = secrets.token_hex(spec["bytes"])
562 if not existing or values != existing["Items"]:
563 put_variable(secret_path, values, existing["ModifyIndex"] if existing else 0)
564 backup = "items {\n" + "".join(f" {item} = {q(value)}\n" for item, value in values.items()) + "}\n"
565 if not secret_file.exists() or secret_file.read_text() != backup:
566 write_private(secret_file, backup)
567 if required:
568 missing = [name for name in required if not values.get(name)]
569 if missing:
570 raise ValueError(f"missing required secrets for {data['id']}: {', '.join(missing)}")
571 proof = {}
572 for task in data["containers"].values():
573 http = task.get("http")
574 if http and http.get("identityProofSecret"):
575 name = http["identityProofSecret"]
576 if not http["identityHeaders"] or name not in values:
577 raise ValueError(f"missing identity proof secret for {data['id']}")
578 proof[http["hostname"]] = "X-Studio-Idp-" + values[name]
579 write_private(route_dir / f"{data['id']}.json", json.dumps({
580 "files": static, "dirs": directories, "identity": identity,
581 "identityProof": proof, "headHtml": head_html, "metricsPaths": metrics_paths,
582 }))
583
584
585def submit(data, mode, definitions):
586 if not data["containers"]:
587 return
588 with tempfile.NamedTemporaryFile("w", suffix=".nomad.hcl", delete=False) as file:
589 file.write(render(data, definitions))
590 path = file.name
591 try:
592 command("nomad", "job", mode, path)
593 finally:
594 Path(path).unlink()
595
596
597def build_images(data):
598 for task in data["containers"].values():
599 if task.get("build"):
600 image = task["image"]
601 if subprocess.run(["podman", "image", "exists", image]).returncode:
602 command("podman", "build", "-t", image, str(config_path(data["sourceId"], task["build"])))
603
604
605def clone_dataset(source, snapshot, dataset, mount):
606 acltype = command("zfs", "get", "-H", "-o", "value", "acltype", source, capture=True).stdout.strip()
607 command("zfs", "clone", "-o", "mountpoint=" + mount, "-o", "acltype=" + acltype, snapshot, dataset)
608
609
610def check_http(host, path, tls_internal=False, attempts=120):
611 for attempt in range(attempts):
612 try:
613 if subprocess.run(
614 ["curl", "--noproxy", "*", "-sf", *(["--cacert", "/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt"] if tls_internal else []), "--resolve", f"{host}:443:127.0.0.1", f"https://{host}{path}"],
615 capture_output=True, timeout=3,
616 ).returncode == 0:
617 print(f"Healthy {host}")
618 return
619 except subprocess.TimeoutExpired:
620 pass
621 time.sleep(2)
622 raise RuntimeError(f"HTTPS route is unhealthy: {host}")
623
624
625def put_variable(path, items, index=None):
626 with tempfile.NamedTemporaryFile("w", suffix=".nv.hcl", delete=False) as file:
627 file.write("items {\n" + "".join(f" {key} = {q(value)}\n" for key, value in items.items()) + "}\n")
628 filename = file.name
629 try:
630 command("nomad", "var", "put", "-in=hcl", "-out=none", *([f"-check-index={index}"] if index is not None else []), path, "@" + filename)
631 finally:
632 Path(filename).unlink()
633
634
635def configure(data):
636 if not data.get("setup"):
637 return
638 if data["setup"].startswith("tools/"):
639 script = (REPO / data["setup"]).resolve()
640 if not script.is_relative_to(REPO / "tools") or not script.is_file():
641 raise ValueError(f"invalid shared setup script: {data['setup']}")
642 else:
643 script = config_path(data.get("sourceId", data["id"]), data["setup"])
644 routes = [task["http"] for task in data["containers"].values()
645 if task.get("http") and task["http"].get("hostname")]
646 if len(routes) != 1:
647 raise ValueError("service setup requires one HTTP hostname")
648 route = routes[0]
649 check_http(route["hostname"], route["checkPath"], route["tlsInternal"], attempts=600)
650 variable = get_variable("nomad/jobs/" + data["id"])
651 own = variable["Items"] if variable else {}
652 command("python3", str(script), input=json.dumps({
653 "serviceId": data["id"], "host": route["hostname"], "hostRoot": data["hostRoot"],
654 "preview": data["id"] != data.get("sourceId", data["id"]),
655 "ownerEmail": data["ownerEmail"], **own,
656 }))
657 print(f"Configured {data['id']}")
658
659
660def prepare(data):
661 if data.get("prepare"):
662 script = config_path(data.get("sourceId", data["id"]), data["prepare"])
663 command("python3", str(script), input=json.dumps({
664 "hostRoot": data["hostRoot"], "uid": data["uid"],
665 }))
666
667
668def provision_inputs(data, definitions, stage_id=None, postgres_source=None):
669 for alias, request in data["inputs"].items():
670 if not NAME.fullmatch(alias):
671 raise ValueError(f"invalid input alias: {alias}")
672 provider = definitions[request["provider"]]
673 if not provider.get("provide"):
674 raise ValueError(f"{provider['id']} does not provide inputs")
675 path = f"nomad/jobs/{data['id']}/inputs/{alias}"
676 variable = get_variable(path)
677 own = (get_variable("nomad/jobs/" + provider["id"]) or {}).get("Items", {})
678 http = next((task["http"] for task in provider["containers"].values()
679 if task.get("http") and task["http"].get("hostname")), None)
680 if http:
681 check_http(http["hostname"], http["checkPath"], http["tlsInternal"])
682 payload = {
683 "request": request,
684 "existing": variable["Items"] if variable else None,
685 "providerSecrets": own,
686 "host": http["hostname"] if http else None,
687 }
688 if stage_id:
689 payload["stageId"] = stage_id
690 if postgres_source and provider["id"] == "postgres":
691 payload["sourceContainer"] = postgres_source
692 script = config_path(provider["id"], provider["provide"])
693 result = command("python3", str(script), input=json.dumps(payload), capture=True)
694 values = json.loads(result.stdout)
695 if not isinstance(values, dict) or not values or any(
696 not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key) or not isinstance(value, str) or not value
697 for key, value in values.items()
698 ):
699 raise ValueError(f"invalid outputs from {provider['id']}")
700 if not variable or variable["Items"] != values:
701 put_variable(path, values, variable["ModifyIndex"] if variable else None)
702 print(f"Allocated {data['id']}.{alias} from {provider['id']}")
703
704
705def bootstrap(all_data):
706 if os.geteuid() != 0:
707 raise PermissionError("bootstrap requires root")
708 STATE.mkdir(parents=True, exist_ok=True)
709 if not (STATE / "nomad.token").exists():
710 result = command("nomad", "acl", "bootstrap", "-json", capture=True)
711 write_private(STATE / "nomad.token", json.loads(result.stdout)["SecretID"] + "\n")
712 token()
713 command(
714 "nomad",
715 "acl",
716 "policy",
717 "apply",
718 "studio-router",
719 str(REPO / "config/policies/router.hcl"),
720 )
721 command("nomad", "acl", "policy", "apply", "studio-dashboard",
722 str(REPO / "config/policies/dashboard.hcl"))
723 router_token = STATE / "router.token"
724 if not router_token.exists():
725 result = command(
726 "nomad",
727 "acl",
728 "token",
729 "create",
730 "-policy=studio-router",
731 "-name=studio-router",
732 "-json",
733 capture=True,
734 )
735 write_private(router_token, json.loads(result.stdout)["SecretID"] + "\n")
736 dashboard_token = STATE / "dashboard.token"
737 if not dashboard_token.exists():
738 result = command("nomad", "acl", "token", "create", "-policy=studio-dashboard",
739 "-name=studio-dashboard", "-json", capture=True)
740 write_private(dashboard_token, json.loads(result.stdout)["SecretID"] + "\n")
741 for data in all_data:
742 paths = {
743 f"nomad/jobs/{data['id']}/inputs/{alias}" for alias in data["inputs"]
744 }
745 if paths and data["containers"]:
746 policy = (
747 'namespace "default" {\n variables {\n'
748 + "".join(
749 f' path {q(path)} {{ capabilities = ["read"] }}\n'
750 for path in sorted(paths)
751 )
752 + " }\n}\n"
753 )
754 with tempfile.NamedTemporaryFile("w", delete=False) as file:
755 file.write(policy)
756 path = file.name
757 try:
758 for task_name in data["containers"]:
759 command(
760 "nomad", "acl", "policy", "apply", "-namespace", "default",
761 "-job", data["id"], "-group", "app", "-task", task_name,
762 data["id"] + "-" + task_name + "-imports", path,
763 )
764 finally:
765 Path(path).unlink()
766 if (Path("/opt/studio/current")).is_symlink():
767 command("systemctl", "start", "studio-router.service")
768
769
770def destroy_stage(stage, metadata, definitions):
771 subprocess.run(["nomad", "job", "stop", "-purge", "-yes", stage], check=False)
772 for alias, request in metadata["inputs"].items():
773 provider = definitions[request["provider"]]
774 own = (get_variable("nomad/jobs/" + provider["id"]) or {}).get("Items", {})
775 variable = get_variable(f"nomad/jobs/{stage}/inputs/{alias}")
776 existing = variable["Items"] if variable else None
777 hosts = [task["http"]["hostname"] for task in provider["containers"].values()
778 if task.get("http") and task["http"].get("hostname")]
779 script = config_path(provider["id"], provider["provide"])
780 command("python3", str(script), input=json.dumps({
781 "operation": "delete", "request": request, "stageId": stage,
782 "providerSecrets": own, "host": hosts[0] if hosts else None,
783 "existing": existing,
784 }))
785 datasets = list(metadata.get("external", {}).values())
786 if metadata.get("clone"):
787 datasets.append({"clone": metadata["clone"], "snapshot": metadata.get("snapshot")})
788 for dataset in datasets:
789 for attempt in range(30):
790 if subprocess.run(["zfs", "destroy", dataset["clone"]], capture_output=True).returncode == 0:
791 break
792 time.sleep(1)
793 else:
794 raise RuntimeError("preview dataset is still in use")
795 if dataset.get("snapshot"):
796 command("zfs", "destroy", dataset["snapshot"])
797 if not metadata.get("clone"):
798 shutil.rmtree(metadata["mount"], ignore_errors=True)
799 for alias in metadata["inputs"]:
800 subprocess.run(["nomad", "var", "purge", f"nomad/jobs/{stage}/inputs/{alias}"], check=False)
801 subprocess.run(["nomad", "var", "purge", "nomad/jobs/" + stage], check=False)
802 for task in metadata["tasks"]:
803 subprocess.run(["nomad", "acl", "policy", "delete", stage + "-" + task + "-imports"], check=False)
804 for path in (
805 STATE / "stages" / f"{stage}.json",
806 STATE / "routes" / f"{stage}.json",
807 STATE / "secrets" / f"{stage}.nv.hcl",
808 ):
809 path.unlink(missing_ok=True)
810 shutil.rmtree(ASSETS / stage, ignore_errors=True)
811
812
813def check_pool(data):
814 if os.geteuid() != 0:
815 raise PermissionError("pool setup requires root")
816 clover = subprocess.run(
817 ["findmnt", "-n", "-o", "SOURCE,FSTYPE", "--mountpoint", data[0]["cloverRoot"]],
818 capture_output=True, text=True,
819 )
820 clover_info = clover.stdout.split()
821 if clover.returncode or len(clover_info) != 2 or clover_info[1] != "zfs":
822 raise ValueError(f"clover must be a mounted ZFS dataset: {data[0]['cloverRoot']}")
823 media = data[0]["mediaRoot"]
824 mounted = subprocess.run(
825 ["findmnt", "-n", "-o", "SOURCE,FSTYPE", "--mountpoint", media],
826 capture_output=True, text=True,
827 )
828 media_info = mounted.stdout.split()
829 expected = {"fuse"} if os.environ.get("STUDIO_MEDIA_READ_ONLY") == "true" else {"zfs"}
830 if mounted.returncode or len(media_info) != 2 or media_info[1] not in expected or media_info[0] == clover_info[0]:
831 raise ValueError(f"media must be a separate mounted dataset: {media}")
832 for path, info in ((data[0]["cloverRoot"], clover_info), (media, media_info)):
833 if info[1] == "zfs":
834 acltype = command("zfs", "get", "-H", "-o", "value", "acltype", info[0], capture=True).stdout.strip()
835 if acltype == "nfsv4":
836 raise ValueError(f"{path} uses NFSv4 ACLs, which NixOS cannot enforce. Rehearse a POSIX permission conversion on a ZFS clone before migration.")
837 pool = data[0]["pool"]
838 if subprocess.run(["zpool", "list", pool], capture_output=True).returncode:
839 raise ValueError(f"ZFS pool is unavailable: {pool}")
840 prod = pool + "/prod"
841 acl_source = prod if subprocess.run(["zfs", "list", prod], capture_output=True).returncode == 0 else pool
842 if command("zfs", "get", "-H", "-o", "value", "acltype", acl_source, capture=True).stdout.strip() == "nfsv4":
843 raise ValueError(f"{acl_source} uses NFSv4 ACLs; service datasets need POSIX ACLs on NixOS.")
844 encrypted = any(
845 command("zfs", "get", "-H", "-o", "value", "encryption", info[0], capture=True).stdout.strip() != "off"
846 for info in (clover_info, media_info) if info[1] == "zfs"
847 )
848 if encrypted:
849 root = str(Path(data[0]["hostRoot"]).parent)
850 mounted = subprocess.run(["findmnt", "-n", "-o", "SOURCE", "--mountpoint", root], capture_output=True, text=True)
851 encryption = subprocess.run(["zfs", "get", "-H", "-o", "value", "encryption", prod], capture_output=True, text=True)
852 if encryption.returncode or encryption.stdout.strip() == "off" or mounted.stdout.strip() != prod:
853 raise ValueError(f"Create and mount encrypted {prod} at {root} before deployment; the existing storage is encrypted.")
854
855
856def check_staging_pool(data):
857 check_pool([data])
858 prod = data["pool"] + "/prod"
859 staging = data["pool"] + "/staging"
860 acl_source = staging if subprocess.run(["zfs", "list", staging], capture_output=True).returncode == 0 else data["pool"]
861 if command("zfs", "get", "-H", "-o", "value", "acltype", acl_source, capture=True).stdout.strip() == "nfsv4":
862 raise ValueError(f"{acl_source} uses NFSv4 ACLs; staged datasets need POSIX ACLs on NixOS.")
863 encryption = subprocess.run(["zfs", "get", "-H", "-o", "value", "encryption", prod], capture_output=True, text=True)
864 if encryption.returncode or encryption.stdout.strip() == "off":
865 return
866 mounted = subprocess.run(["findmnt", "-n", "-o", "SOURCE", "--mountpoint", data["stagingRoot"]], capture_output=True, text=True)
867 staging_encryption = subprocess.run(["zfs", "get", "-H", "-o", "value", "encryption", staging], capture_output=True, text=True)
868 if staging_encryption.returncode or staging_encryption.stdout.strip() == "off" or mounted.stdout.strip() != staging:
869 raise ValueError(f"Create and mount encrypted {staging} at {data['stagingRoot']} before staging.")
870
871
872def ensure_pool(data):
873 check_pool(data)
874 STATE.mkdir(parents=True, exist_ok=True)
875 pool = data[0]["pool"]
876 for item in data:
877 if item["hasManagedVolumes"]:
878 dataset = pool + "/prod/" + item["id"]
879 if subprocess.run(["zfs", "list", dataset], capture_output=True).returncode:
880 root = Path(item["hostRoot"])
881 if root.exists() and any(root.iterdir()):
882 raise ValueError(f"refusing to mount over {root}")
883 if subprocess.run(["zfs", "list", pool + "/prod"], capture_output=True).returncode:
884 parent = str(root.parent)
885 if Path(parent).exists() and any(Path(parent).iterdir()):
886 raise ValueError(f"refusing to mount over {parent}")
887 command("zfs", "create", "-o", "mountpoint=" + parent, pool + "/prod")
888 command("zfs", "create", "-o", "mountpoint=" + str(root), dataset)
889
890
891def main():
892 parser = argparse.ArgumentParser()
893 parser.add_argument(
894 "mode",
895 choices=[
896 "render",
897 "secrets",
898 "validate",
899 "check",
900 "bootstrap",
901 "deploy",
902 "preflight",
903 "pool",
904 "allocate",
905 "stage",
906 "destroy",
907 ],
908 )
909 parser.add_argument("name", nargs="?")
910 parser.add_argument("--base-domain")
911 parser.add_argument("--root")
912 parser.add_argument("--pool")
913 parser.add_argument("--media-root")
914 parser.add_argument("--env", action="append", default=[])
915 parser.add_argument("--key", action="append", default=[])
916 args = parser.parse_args()
917 if args.mode == "allocate" and not args.name:
918 parser.error("allocate requires a service name")
919 if args.key and args.mode != "secrets":
920 parser.error("--key is available only for secrets")
921 properties = {
922 key: value
923 for key, value in (("domain", args.base_domain), ("root", args.root), ("pool", args.pool), ("mediaRoot", args.media_root))
924 if value
925 }
926 names = [args.name] if args.name else services()
927 if args.mode == "secrets":
928 if not args.name or not NAME.fullmatch(args.name):
929 parser.error("secrets requires a service name")
930 data = load(args.name, properties)
931 declared = [*data["requiredSecrets"], *data["secrets"]]
932 fields = args.key or declared
933 if not declared or len(set(declared)) != len(declared) or any(not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", field) for field in declared):
934 raise ValueError(f"invalid required secrets for {args.name}")
935 if len(set(fields)) != len(fields) or any(field not in declared for field in fields):
936 raise ValueError(f"unknown or duplicate secret key for {args.name}")
937 values = sys.stdin.read().splitlines()
938 if len(values) != len(fields) or any(not value for value in values):
939 raise ValueError(f"expected {len(fields)} nonempty secret lines for {args.name}")
940 token()
941 path = "nomad/jobs/" + args.name
942 existing = get_variable(path)
943 items = dict(existing["Items"]) if existing else {}
944 items.update(zip(fields, values))
945 put_variable(path, items, existing["ModifyIndex"] if existing else 0)
946 print(f"Imported {len(fields)} secrets for {args.name}")
947 return
948 if args.mode == "destroy":
949 if not args.name or not NAME.fullmatch(args.name):
950 parser.error("destroy requires a stage ID")
951 token()
952 metadata = json.loads((STATE / "stages" / f"{args.name}.json").read_text())
953 names = {metadata["sourceId"]} | {request["provider"] for request in metadata["inputs"].values()}
954 definitions = {name: load(name, properties) for name in names}
955 destroy_stage(args.name, metadata, definitions)
956 return
957 if args.mode == "stage":
958 if not args.name:
959 parser.error("stage requires a service name")
960 token()
961 data = load(args.name, properties)
962 check_staging_pool(data)
963 definitions = {args.name: data}
964 definitions.update({provider: load(provider, properties) for provider in dependencies(data)})
965 if not data["containers"]:
966 raise ValueError(f"service has no container to stage: {args.name}")
967 stage_dir = STATE / "stages"
968 previous = []
969 for path in stage_dir.glob("*.json"):
970 saved = json.loads(path.read_text())
971 if saved.get("sourceId") == args.name:
972 previous.append((path, saved))
973 if len(previous) > 1:
974 raise ValueError(f"multiple stages exist for {args.name}; destroy one first")
975 created = not previous
976 stage = f"{args.name}-preview-{secrets.token_hex(4)}" if created else previous[0][0].stem
977 http = [task["http"] for task in data["containers"].values() if task.get("http") and task["http"].get("hostname")]
978 if len(http) > 1:
979 raise ValueError("preview requires at most one HTTP route")
980 original_host = http[0].get("hostname") if http else None
981 hostname = stage + "." + original_host.split(".", 1)[1] if original_host else None
982 data = load(args.name, properties, stage)
983 data["rollout"] = "simple"
984 data["hostRoot"] = str(Path(data["stagingRoot"]) / stage)
985 if data["stageIsolation"] == "fresh" and any(
986 volume.get("src") and not volume["readOnly"]
987 for task in data["containers"].values()
988 for volume in task["volumes"].values()
989 ):
990 raise ValueError("fresh stages cannot use writable external mounts")
991 for task in data["containers"].values():
992 if task.get("tcp") and not task["hostNetwork"]:
993 task["tcp"]["hostPort"] = None
994 if hostname:
995 for task in data["containers"].values():
996 if task.get("http") and task["http"].get("hostname") == original_host:
997 task["http"]["hostname"] = hostname
998 task["http"]["plainHostnames"] = [stage + "-" + host for host in task["http"]["plainHostnames"]]
999 task["env"] = {key: value.replace(original_host, hostname) for key, value in task["env"].items()}
1000 for assignment in args.env:
1001 key, separator, value = assignment.partition("=")
1002 if not separator or len(data["containers"]) != 1:
1003 parser.error(f"unknown environment override: {key}")
1004 env = next(iter(data["containers"].values()))["env"]
1005 if key not in env:
1006 parser.error(f"unknown environment override: {key}")
1007 env[key] = value
1008 metadata = (
1009 {"mount": data["hostRoot"], "inputs": data["inputs"],
1010 "tasks": list(data["containers"]), "sourceId": args.name,
1011 "stageIsolation": data["stageIsolation"]}
1012 if created else previous[0][1]
1013 )
1014 if not created:
1015 if metadata.get("stageIsolation", "clone") != data["stageIsolation"]:
1016 raise ValueError("stage isolation changed; destroy the stage before recreating it")
1017 if bool(metadata.get("clone")) != data["hasManagedVolumes"]:
1018 raise ValueError("storage layout changed; destroy the stage before recreating it")
1019 for alias, request in data["inputs"].items():
1020 if alias in metadata["inputs"] and metadata["inputs"][alias] != request:
1021 raise ValueError(f"requirement {alias} changed; destroy the stage before recreating it")
1022 if alias not in metadata["inputs"] and request["provider"] == "postgres":
1023 raise ValueError("new database requirement needs a new stage")
1024 metadata["inputs"].update(data["inputs"])
1025 metadata["tasks"] = sorted(set(metadata["tasks"]) | set(data["containers"]))
1026 stage_dir.mkdir(parents=True, exist_ok=True)
1027 postgres_snapshot = None
1028 pending_snapshots = set()
1029 try:
1030 postgres_inputs = created and data["stageIsolation"] == "clone" and any(
1031 request["provider"] == "postgres" and request["kind"] == "database"
1032 for request in data["inputs"].values()
1033 )
1034 snapshots = []
1035 source = None
1036 if created and data["hasManagedVolumes"]:
1037 pool = data["pool"]
1038 source = subprocess.run(
1039 ["findmnt", "-n", "-o", "SOURCE", "--mountpoint", definitions[args.name]["hostRoot"]],
1040 capture_output=True, text=True,
1041 ).stdout.strip()
1042 dataset = pool + "/staging/" + stage
1043 if subprocess.run(["zfs", "list", pool + "/staging"], capture_output=True).returncode:
1044 command("zfs", "create", "-o", "mountpoint=none", pool + "/staging")
1045 if source and data["stageIsolation"] == "clone":
1046 if source.split("/", 1)[0] != pool:
1047 raise ValueError(f"production dataset belongs to another pool: {source}")
1048 snapshot = source + "@" + stage
1049 snapshots.append(snapshot)
1050 if postgres_inputs:
1051 postgres_dataset = dataset_for("postgres")
1052 if not postgres_dataset:
1053 raise ValueError("Postgres needs a ZFS dataset for consistent stages")
1054 postgres_snapshot = postgres_dataset + "@" + stage
1055 snapshots.append(postgres_snapshot)
1056 external = {}
1057 for task in data["containers"].values():
1058 for volume in task["volumes"].values():
1059 if volume.get("src") and not volume["readOnly"]:
1060 external.setdefault(volume["src"], []).append(volume)
1061 external_mounts = {}
1062 for external_source in external:
1063 path = Path(external_source).resolve(strict=True)
1064 mount = json.loads(command(
1065 "findmnt", "--json", "-o", "SOURCE,TARGET,FSTYPE,OPTIONS", "--target", str(path), capture=True,
1066 ).stdout)["filesystems"][0]
1067 if "ro" not in mount["options"].split(",") and mount["fstype"] != "zfs":
1068 raise ValueError(f"writable external mount is not ZFS: {external_source}")
1069 external_mounts[external_source] = (path, mount)
1070 if created and mount["fstype"] == "zfs" and "ro" not in mount["options"].split(","):
1071 target = mount["source"] + "@" + stage
1072 if target not in snapshots:
1073 snapshots.append(target)
1074 if snapshots:
1075 if len({target.split("/", 1)[0] for target in snapshots}) != 1:
1076 raise ValueError("staged datasets must belong to one ZFS pool")
1077 command("zfs", "snapshot", *snapshots)
1078 pending_snapshots.update(snapshots)
1079 if created and data["hasManagedVolumes"]:
1080 if source and data["stageIsolation"] == "clone":
1081 try:
1082 clone_dataset(source, snapshot, dataset, data["hostRoot"])
1083 except Exception:
1084 command("zfs", "destroy", snapshot)
1085 pending_snapshots.remove(snapshot)
1086 raise
1087 metadata["snapshot"] = snapshot
1088 pending_snapshots.remove(snapshot)
1089 else:
1090 command("zfs", "create", "-o", "mountpoint=" + data["hostRoot"], dataset)
1091 metadata["clone"] = dataset
1092 bindings = {}
1093 for source, volumes in sorted(external.items()):
1094 path, mount = external_mounts[source]
1095 if "ro" in mount["options"].split(","):
1096 binding = {"src": source, "readOnly": True}
1097 elif mount["fstype"] == "zfs":
1098 dataset = mount["source"]
1099 if metadata.get("snapshot") == dataset + "@" + stage:
1100 clone_mount = data["hostRoot"]
1101 else:
1102 staged = metadata.setdefault("external", {})
1103 if dataset not in staged:
1104 if not created:
1105 raise ValueError("external storage changed; destroy the stage before recreating it")
1106 suffix = hashlib.sha256(dataset.encode()).hexdigest()[:8]
1107 pool = dataset.split("/", 1)[0]
1108 clone = pool + "/staging/" + stage + "-external-" + suffix
1109 clone_mount = str(Path(data["stagingRoot"]) / (stage + "-external-" + suffix))
1110 snapshot = dataset + "@" + stage
1111 if subprocess.run(["zfs", "list", pool + "/staging"], capture_output=True).returncode:
1112 command("zfs", "create", "-o", "mountpoint=none", pool + "/staging")
1113 try:
1114 clone_dataset(dataset, snapshot, clone, clone_mount)
1115 except Exception:
1116 command("zfs", "destroy", snapshot)
1117 pending_snapshots.remove(snapshot)
1118 raise
1119 staged[dataset] = {"clone": clone, "snapshot": snapshot, "mount": clone_mount}
1120 pending_snapshots.remove(snapshot)
1121 clone_mount = staged[dataset]["mount"]
1122 relative = path.relative_to(Path(mount["target"]).resolve())
1123 binding = {"src": str(Path(clone_mount) / relative), "readOnly": False}
1124 else:
1125 raise ValueError(f"writable external mount is not ZFS: {source}")
1126 bindings[source] = binding
1127 for volume in volumes:
1128 if path.is_relative_to(Path(data["cloverRoot"]).resolve()):
1129 volume["clover"] = True
1130 volume.update(binding)
1131 if not created and bindings != metadata.get("externalSources", {}):
1132 raise ValueError("external storage changed; destroy the stage before recreating it")
1133 metadata["externalSources"] = bindings
1134 if REPO.parent == Path("/opt/studio/releases"):
1135 metadata["release"] = REPO.name
1136 metadata["ready"] = False
1137 write_private(stage_dir / f"{stage}.json", json.dumps(metadata))
1138 bootstrap([data])
1139 if created and data["stageIsolation"] == "clone" and (data["secrets"] or data["requiredSecrets"]):
1140 source = get_variable("nomad/jobs/" + args.name)
1141 if source:
1142 put_variable("nomad/jobs/" + stage, source["Items"], 0)
1143 if postgres_snapshot:
1144 postgres_clone = postgres_dataset.rsplit("/prod/", 1)[0] + "/staging/" + stage + "-postgres"
1145 postgres_mount = Path(data["stagingRoot"]) / (stage + "-postgres")
1146 source_context = cloned_postgres(postgres_snapshot, postgres_clone, postgres_mount)
1147 else:
1148 source_context = nullcontext(None)
1149 with source_context as postgres_source:
1150 provision_inputs(data, definitions, stage, postgres_source)
1151 provision(data)
1152 prepare(data)
1153 build_images(data)
1154 submit(data, "run", definitions)
1155 except Exception:
1156 if created:
1157 destroy_stage(stage, metadata, definitions)
1158 raise
1159 finally:
1160 for snapshot in sorted(pending_snapshots):
1161 command("zfs", "destroy", snapshot)
1162 if hostname:
1163 check_http(hostname, http[0]["checkPath"], http[0]["tlsInternal"])
1164 configure(data)
1165 if REPO.parent == Path("/opt/studio/releases"):
1166 metadata["ready"] = True
1167 metadata["overrides"] = [assignment.partition("=")[0] for assignment in args.env]
1168 write_private(stage_dir / f"{stage}.json", json.dumps(metadata))
1169 print(f"stage={stage}\nrelease={metadata.get('release', '')}\nhost={hostname}\ndataset={metadata.get('clone', '')}")
1170 return
1171 definitions = {name: load(name, properties) for name in services()}
1172 if args.name and args.name not in definitions:
1173 raise ValueError(f"unknown service: {args.name}")
1174 if not args.name and args.mode == "deploy":
1175 names = [name for name in names if definitions[name]["containers"]]
1176 selected = set(names)
1177 while True:
1178 required = selected | set().union(*(dependencies(definitions[name]) for name in selected))
1179 missing = required - definitions.keys()
1180 if missing:
1181 raise ValueError(f"unknown dependency: {sorted(missing)}")
1182 if required == selected:
1183 break
1184 selected = required
1185 data = ordered([definitions[name] for name in sorted(selected)])
1186 if args.mode == "render":
1187 for item in data:
1188 if item["containers"]:
1189 print(render(item, definitions))
1190 elif args.mode == "validate":
1191 token()
1192 for item in data:
1193 submit(item, "validate", definitions)
1194 elif args.mode == "check":
1195 for item in data:
1196 for task in item["containers"].values():
1197 if task.get("http") and task["http"].get("hostname"):
1198 check_http(task["http"]["hostname"], task["http"]["checkPath"], task["http"]["tlsInternal"])
1199 elif args.mode == "preflight":
1200 check_pool(data)
1201 token()
1202 missing_secrets = {}
1203 for item in data:
1204 if item["containers"] and item["requiredSecrets"]:
1205 variable = get_variable("nomad/jobs/" + item["id"])
1206 values = variable["Items"] if variable else {}
1207 missing = [name for name in item["requiredSecrets"] if not values.get(name)]
1208 if missing:
1209 missing_secrets[item["id"]] = missing
1210 if missing_secrets:
1211 raise ValueError("missing required secrets: " + "; ".join(
1212 f"{name}: {', '.join(keys)}" for name, keys in missing_secrets.items()))
1213 elif args.mode == "pool":
1214 ensure_pool(data)
1215 elif args.mode == "allocate":
1216 ensure_pool(data)
1217 bootstrap(data)
1218 for item in data:
1219 provision_inputs(item, definitions)
1220 provision(item)
1221 prepare(item)
1222 build_images(item)
1223 elif args.mode == "bootstrap":
1224 bootstrap(data)
1225 elif args.mode == "deploy":
1226 bootstrap(data)
1227 for item in data:
1228 provision_inputs(item, definitions)
1229 provision(item)
1230 prepare(item)
1231 build_images(item)
1232 submit(item, "run", definitions)
1233 configure(item)
1234
1235
1236if __name__ == "__main__":
1237 try:
1238 main()
1239 except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as error:
1240 print(error, file=sys.stderr)
1241 sys.exit(1)
tools/tree-hash.py created+48
......@@ -0,0 +1,48 @@
1#!/usr/bin/env python3
2import hashlib
3import json
4import os
5from pathlib import Path
6import stat
7import sys
8
9
10root = Path(sys.argv[1]).resolve(strict=True)
11if not root.is_dir():
12 raise ValueError("Expected a directory")
13digest = hashlib.sha256()
14files = 0
15size = 0
16
17
18def fail(error):
19 raise error
20
21
22for directory, children, names in os.walk(root, followlinks=False, onerror=fail):
23 children.sort()
24 for name in sorted([*children, *names]):
25 entry = Path(directory) / name
26 relative = os.fsencode(entry.relative_to(root))
27 mode = entry.lstat().st_mode
28 if stat.S_ISDIR(mode):
29 kind = b"d"
30 elif stat.S_ISLNK(mode):
31 kind = b"l"
32 elif stat.S_ISREG(mode):
33 kind = b"f"
34 else:
35 raise ValueError(f"Unsupported file type: {entry}")
36 digest.update(kind + len(relative).to_bytes(8, "big") + relative)
37 if kind == b"l":
38 target = os.fsencode(os.readlink(entry))
39 digest.update(len(target).to_bytes(8, "big") + target)
40 elif kind == b"f":
41 files += 1
42 length = entry.stat().st_size
43 size += length
44 digest.update(length.to_bytes(8, "big"))
45 with entry.open("rb") as source:
46 for chunk in iter(lambda: source.read(1024 * 1024), b""):
47 digest.update(chunk)
48print(json.dumps({"sha256": digest.hexdigest(), "files": files, "bytes": size}))
tools/verify-legacy-dump.py created+37
......@@ -0,0 +1,37 @@
1#!/usr/bin/env python3
2import hashlib
3import json
4from pathlib import Path
5import re
6import sys
7
8
9folder = Path(sys.argv[1])
10if not folder.is_dir():
11 raise SystemExit("Legacy handoff directory is unavailable")
12folder = folder.resolve(strict=True)
13database = sys.argv[2]
14if folder.parent != Path("/mnt/storage1/apps/studio-handoff") or not re.fullmatch(r"\d{8}T\d{6}Z-[0-9a-f]{6}", folder.name):
15 raise ValueError("Unexpected legacy handoff directory")
16if database not in {"evil-forgejo", "evil-hedgedoc"}:
17 raise ValueError("Unexpected legacy database")
18manifest = json.loads((folder / "manifest.json").read_text())
19if manifest["id"] != folder.name:
20 raise ValueError("Legacy handoff ID changed")
21entry = manifest["databases"][database]
22if entry["file"] != database + ".dump":
23 raise ValueError("Legacy dump filename changed")
24dump = folder / entry["file"]
25digest = hashlib.sha256()
26with dump.open("rb") as file:
27 for chunk in iter(lambda: file.read(1024 * 1024), b""):
28 digest.update(chunk)
29if dump.stat().st_size != entry["bytes"] or digest.hexdigest() != entry["sha256"]:
30 raise ValueError("Legacy dump checksum mismatch")
31if len(entry["counts"]) != (4 if database == "evil-hedgedoc" else 2) or any(not isinstance(value, int) or value < 0 for value in entry["counts"]):
32 raise ValueError("Legacy database counts are invalid")
33if database == "evil-forgejo":
34 keys = {"lfs_jwt", "oauth_jwt", "security_key", "internal_token", "anubis_key", "mailer_address", "mailer_username", "mailer_password"}
35 if set(entry["secretSha256"]) != keys or any(not re.fullmatch(r"[0-9a-f]{64}", value) for value in entry["secretSha256"].values()):
36 raise ValueError("Legacy Forgejo secret fingerprints are invalid")
37print(json.dumps(entry))
tools/vms.py created+366
......@@ -0,0 +1,366 @@
1#!/usr/bin/env python3
2import json
3import os
4from pathlib import Path
5import re
6import shutil
7import signal
8import stat
9import subprocess
10import sys
11import xml.etree.ElementTree as ET
12
13
14DISKS = Path("/srv/vm")
15IMAGES = Path(os.environ.get("STUDIO_VM_IMAGES_ROOT", "/srv/clover/Media/vm"))
16NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,62}\Z")
17ACTION_FIELDS = {
18 "node": None, "domains": None, "stats": None, "images": None,
19 "create": {"name", "description", "image", "vcpus", "memory", "disk", "autostart", "start"},
20 "act": {"name", "action"}, "update": {"name", "description", "autostart"},
21 "remove": {"name", "disks"},
22}
23
24
25def node():
26 memory = next(int(line.split()[1]) * 1024 for line in Path("/proc/meminfo").read_text().splitlines() if line.startswith("MemTotal:"))
27 return {"cpus": os.cpu_count(), "memory": memory}
28
29
30def validate(action, payload):
31 if action not in ACTION_FIELDS:
32 raise ValueError("Choose a supported VM action.")
33 fields = ACTION_FIELDS[action]
34 if fields is None:
35 if payload is not None:
36 raise ValueError("This VM query doesn't take any fields.")
37 return
38 if (not isinstance(payload, dict) or not set(payload) <= fields
39 or (action != "update" and set(payload) != fields)
40 or (action == "update" and ("name" not in payload or len(payload) < 2))):
41 raise ValueError("Use only the fields required by this VM action.")
42 ensure_name(payload["name"])
43 if "description" in payload and (not isinstance(payload["description"], str) or len(payload["description"]) > 200):
44 raise ValueError("Keep the description under 200 characters.")
45 for field in ("autostart", "start", "disks"):
46 if field in payload and not isinstance(payload[field], bool):
47 raise ValueError(f"Choose whether to enable {field}.")
48 if action == "act" and (not isinstance(payload["action"], str) or payload["action"] not in {"start", "shutdown", "reboot", "destroy", "resume"}):
49 raise ValueError("Choose a supported VM action.")
50 if action == "create":
51 image = payload["image"]
52 if not isinstance(image, str) or not image or len(image) > 255 or image != Path(image).name or image in {".", ".."}:
53 raise ValueError("Choose an OS image from the list.")
54 host = node()
55 for field, minimum, maximum in [("vcpus", 1, host["cpus"]), ("memory", 2**29, host["memory"]), ("disk", 2**30, 2**63 - 1)]:
56 if type(payload[field]) is not int or not minimum <= payload[field] <= maximum:
57 raise ValueError(f"Choose {field} within the host's supported range.")
58 if DISKS.is_symlink() or (DISKS / payload["name"]).is_symlink():
59 raise ValueError("The VM disk directory is a symbolic link. Remove the link before continuing.")
60
61
62def command(*args):
63 return subprocess.run(args, check=True, text=True, capture_output=True).stdout.strip()
64
65
66def virsh(*args):
67 return command("virsh", "-c", "qemu:///system", *args)
68
69
70def info(file):
71 return json.loads(command("qemu-img", "info", "-U", "--output=json", str(file)))
72
73
74def standalone(details):
75 return (details.get("format") in {"raw", "qcow2"} and not details.get("backing-filename")
76 and not details.get("format-specific", {}).get("data", {}).get("data-file"))
77
78
79def disk(file, target, pool):
80 details = info(file) if file.is_file() else {}
81 return {
82 "target": target,
83 "pool": pool,
84 "source": file.name if pool else str(file),
85 "capacity": details.get("virtual-size", file.stat().st_size if file.exists() else 0),
86 "allocation": details.get("actual-size", 0),
87 }
88
89
90def image(file):
91 extension = file.suffix.lower()
92 if extension not in {".iso", ".qcow2", ".img", ".raw"} or not file.is_file():
93 return None
94 os_name = file.stem.replace("_", " ").replace("-", " ")
95 windows = "windows" in os_name.lower() or "win10" in os_name.lower() or "win11" in os_name.lower()
96 details = info(file) if extension != ".iso" else {}
97 if extension != ".iso" and not standalone(details):
98 return None
99 capacity = details.get("virtual-size", file.stat().st_size)
100 return {
101 "volume": file.name,
102 "os": os_name,
103 "kind": "installer" if extension == ".iso" else "disk",
104 "capacity": capacity,
105 "recommended": {
106 "vcpus": 4 if windows else 2,
107 "memory": (4 if windows else 2) * 2**30,
108 "disk": max((64 if windows else 20) * 2**30, capacity),
109 },
110 }
111
112
113def domains():
114 result = []
115 for name in virsh("list", "--all", "--name").splitlines():
116 if not name:
117 continue
118 root = ET.fromstring(virsh("dumpxml", name))
119 state_line = virsh("domstate", name, "--reason").splitlines()[0].lower()
120 state = next((value for value in ("running", "blocked", "paused", "shutdown", "crashed", "pmsuspended") if state_line.startswith(value)), "shutoff")
121 reason = state_line.split("(", 1)[1].rstrip(")") if state in {"paused", "crashed"} and "(" in state_line else None
122 memory = int(root.findtext("memory", "0")) * 1024
123 balloon = int(root.findtext("currentMemory", str(memory // 1024))) * 1024
124 disks = []
125 for element in root.findall("./devices/disk"):
126 source = element.find("source")
127 target = element.find("target")
128 if source is None or target is None:
129 continue
130 file = source.get("file") or source.get("dev")
131 if not file:
132 continue
133 target_name = target.get("dev", "")
134 pool = "vms" if Path(file).is_relative_to(DISKS / name) else None
135 disks.append(disk(Path(file), target_name, pool))
136 interfaces = []
137 for element in root.findall("./devices/interface"):
138 mac = element.find("mac")
139 source = element.find("source")
140 interfaces.append({
141 "mac": mac.get("address", "") if mac is not None else "",
142 "source": source.get("network", source.get("bridge", "")) if source is not None else "",
143 "addresses": [],
144 })
145 if state == "running":
146 for line in virsh("domifaddr", name, "--source", "lease").splitlines():
147 fields = line.split()
148 if len(fields) >= 4:
149 interface = next((item for item in interfaces if item["mac"].lower() == fields[1].lower()), None)
150 if interface:
151 interface["addresses"].append(fields[3].split("/", 1)[0])
152 pid_file = Path("/run/libvirt/qemu") / f"{name}.pid"
153 started = None
154 if state == "running" and pid_file.exists():
155 pid = pid_file.read_text().strip()
156 boot = next(int(line.split()[1]) for line in Path("/proc/stat").read_text().splitlines() if line.startswith("btime "))
157 ticks = int(Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()[19])
158 started = boot + ticks / os.sysconf("SC_CLK_TCK")
159 result.append({
160 "name": name,
161 "description": root.findtext("description", ""),
162 "state": state,
163 "reason": reason,
164 "os": root.findtext("metadata/{https://paperclover.net/studio}os", "Linux"),
165 "vcpus": int(root.findtext("vcpu", "1")),
166 "pinned": None,
167 "memory": memory,
168 "balloon": balloon,
169 "autostart": re.search(r"^Autostart:\s+enable", virsh("dominfo", name), re.MULTILINE) is not None,
170 "startedAt": started,
171 "agent": None,
172 "disks": disks,
173 "interfaces": interfaces,
174 "hostdevs": [],
175 })
176 return result
177
178
179def ensure_name(name):
180 if not isinstance(name, str) or not NAME.fullmatch(name):
181 raise ValueError("invalid VM name")
182
183
184def ensure_network():
185 if re.search(r"^Active:\s+no", virsh("net-info", "default"), re.MULTILINE):
186 virsh("net-start", "default")
187 virsh("net-autostart", "default")
188
189
190def create(spec):
191 name = spec["name"]
192 ensure_name(name)
193 if name in virsh("list", "--all", "--name").splitlines():
194 raise ValueError("VM already exists")
195 selected = spec["image"]
196 available = {item.name: item for item in IMAGES.iterdir() if item.is_file() and not item.is_symlink()} if IMAGES.is_dir() else {}
197 if selected != "blank" and selected not in available:
198 raise ValueError("OS image is unavailable")
199 source = available.get(selected)
200 if source and source.suffix.lower() not in {".iso", ".qcow2", ".img", ".raw"}:
201 raise ValueError("unsupported OS image")
202 if "vms" not in virsh("pool-list", "--all", "--name").splitlines():
203 DISKS.mkdir(parents=True, exist_ok=True)
204 virsh("pool-define-as", "vms", "dir", "--target", str(DISKS))
205 if re.search(r"^State:\s+inactive", virsh("pool-info", "vms"), re.MULTILINE):
206 virsh("pool-start", "vms")
207 virsh("pool-autostart", "vms")
208 directory = DISKS / name
209 directory.mkdir(parents=True, exist_ok=False)
210 drive = directory / "disk.qcow2"
211
212 def expired(_signum, _frame):
213 raise TimeoutError("VM image preparation timed out.")
214
215 previous = signal.signal(signal.SIGALRM, expired)
216 signal.alarm(50)
217 try:
218 os_name = "Other"
219 if source:
220 directory_fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
221 try:
222 for part in IMAGES.parts[1:]:
223 child_fd = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory_fd)
224 os.close(directory_fd)
225 directory_fd = child_fd
226 source_fd = os.open(source.name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory_fd)
227 finally:
228 os.close(directory_fd)
229 frozen = directory / ("installer.iso" if source.suffix.lower() == ".iso" else "source-image")
230 with os.fdopen(source_fd, "rb") as incoming:
231 if not stat.S_ISREG(os.fstat(incoming.fileno()).st_mode):
232 raise ValueError("Choose a regular OS image file.")
233 with frozen.open("xb") as outgoing:
234 shutil.copyfileobj(incoming, outgoing)
235 details = info(frozen) if source.suffix.lower() != ".iso" else {}
236 if source.suffix.lower() != ".iso" and not standalone(details):
237 raise ValueError("Use a standalone raw or QCOW2 image without external data files.")
238 os_name = source.stem.replace("_", " ").replace("-", " ")
239 if details.get("virtual-size", 0) > spec["disk"]:
240 raise ValueError("Choose a disk at least as large as the OS image.")
241 source = frozen
242 if source and source.suffix.lower() != ".iso":
243 command("qemu-img", "convert", "-O", "qcow2", str(source), str(drive))
244 if spec["disk"] > info(drive)["virtual-size"]:
245 command("qemu-img", "resize", str(drive), str(spec["disk"]))
246 source.unlink()
247 else:
248 command("qemu-img", "create", "-f", "qcow2", str(drive), str(spec["disk"]))
249 virsh("pool-refresh", "vms")
250 root = ET.Element("domain", type="qemu" if os.environ.get("STUDIO_VM_ACCEL") == "qemu" else "kvm")
251 ET.SubElement(root, "name").text = name
252 ET.SubElement(root, "description").text = spec["description"]
253 ET.SubElement(ET.SubElement(root, "metadata"), "{https://paperclover.net/studio}os").text = os_name
254 ET.SubElement(root, "memory", unit="bytes").text = str(spec["memory"])
255 ET.SubElement(root, "vcpu").text = str(spec["vcpus"])
256 os_element = ET.SubElement(root, "os")
257 ET.SubElement(os_element, "type", arch="x86_64", machine="q35").text = "hvm"
258 ET.SubElement(os_element, "boot", dev="cdrom" if source and source.suffix.lower() == ".iso" else "hd")
259 devices = ET.SubElement(root, "devices")
260 ET.SubElement(devices, "emulator").text = "/run/current-system/sw/bin/qemu-system-x86_64"
261 primary = ET.SubElement(devices, "disk", type="file", device="disk")
262 ET.SubElement(primary, "driver", name="qemu", type="qcow2")
263 ET.SubElement(primary, "source", file=str(drive))
264 ET.SubElement(primary, "target", dev="vda", bus="virtio")
265 if source and source.suffix.lower() == ".iso":
266 cd = ET.SubElement(devices, "disk", type="file", device="cdrom")
267 ET.SubElement(cd, "driver", name="qemu", type="raw")
268 ET.SubElement(cd, "source", file=str(source))
269 ET.SubElement(cd, "target", dev="sda", bus="sata")
270 ET.SubElement(cd, "readonly")
271 nic = ET.SubElement(devices, "interface", type="network")
272 ET.SubElement(nic, "source", network="default")
273 ET.SubElement(nic, "model", type="virtio")
274 ET.SubElement(devices, "graphics", type="vnc", port="-1", autoport="yes", listen="127.0.0.1")
275 ET.SubElement(devices, "console", type="pty")
276 ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name="org.qemu.guest_agent.0"))
277 xml = directory / "domain.xml"
278 xml.write_bytes(ET.tostring(root))
279 signal.alarm(0)
280 virsh("define", str(xml))
281 if spec["autostart"]:
282 virsh("autostart", name)
283 if spec["start"]:
284 ensure_network()
285 virsh("start", name)
286 except Exception:
287 signal.alarm(0)
288 if name not in virsh("list", "--all", "--name").splitlines():
289 for file in directory.iterdir():
290 file.unlink()
291 directory.rmdir()
292 raise
293 finally:
294 signal.alarm(0)
295 signal.signal(signal.SIGALRM, previous)
296
297
298def main():
299 action = sys.argv[1]
300 payload = json.loads(sys.argv[2]) if len(sys.argv) > 2 else None
301 validate(action, payload)
302 if action == "node":
303 return node()
304 if action == "domains":
305 return domains()
306 if action == "stats":
307 result = {}
308 for name in virsh("list", "--name").splitlines():
309 pid_file = Path("/run/libvirt/qemu") / f"{name}.pid"
310 if not pid_file.exists():
311 continue
312 pid = pid_file.read_text().strip()
313 fields = Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()
314 resident = int(Path(f"/proc/{pid}/statm").read_text().split()[1]) * os.sysconf("SC_PAGE_SIZE")
315 result[name] = {"cpu": (int(fields[11]) + int(fields[12])) / os.sysconf("SC_CLK_TCK"), "memory": resident,
316 "vcpus": int(ET.fromstring(virsh("dumpxml", name)).findtext("vcpu", "1"))}
317 return result
318 if action == "images":
319 images = [image(file) for file in sorted(IMAGES.iterdir()) if not file.is_symlink()] if IMAGES.is_dir() else []
320 return [{"volume": "blank", "os": "Blank disk", "kind": "installer", "capacity": 0,
321 "recommended": {"vcpus": 2, "memory": 2 * 2**30, "disk": 20 * 2**30}}] + [item for item in images if item]
322 if action == "create":
323 create(payload)
324 elif action == "act":
325 name = payload["name"]
326 ensure_name(name)
327 if payload["action"] == "start":
328 ensure_network()
329 virsh({"start": "start", "shutdown": "shutdown", "reboot": "reboot", "destroy": "destroy", "resume": "resume"}[payload["action"]], name)
330 elif action == "update":
331 name = payload["name"]
332 ensure_name(name)
333 if "autostart" in payload:
334 virsh("autostart", *([] if payload["autostart"] else ["--disable"]), name)
335 if "description" in payload:
336 flags = ["--config"]
337 if virsh("domstate", name).strip() != "shut off":
338 flags.append("--live")
339 virsh("desc", name, *flags, "--", payload["description"])
340 elif action == "remove":
341 name = payload["name"]
342 ensure_name(name)
343 if name not in virsh("list", "--all", "--name").splitlines():
344 raise ValueError("VM does not exist")
345 if virsh("domstate", name).strip() != "shut off":
346 virsh("destroy", name)
347 virsh("undefine", name)
348 directory = DISKS / name
349 if payload["disks"] and directory.is_dir():
350 for file in directory.iterdir():
351 file.unlink()
352 directory.rmdir()
353 else:
354 raise ValueError("unsupported VM action")
355 return None
356
357
358if __name__ == "__main__":
359 try:
360 print(json.dumps(main()))
361 except ValueError as failure:
362 print(str(failure), file=sys.stderr)
363 sys.exit(2)
364 except (OSError, subprocess.CalledProcessError) as failure:
365 print(str(failure), file=sys.stderr)
366 sys.exit(1)
tools/yt-feed-migration.md created+9
......@@ -0,0 +1,9 @@
1# YouTube Triage migration
2
3The service keeps six JSON state files in `/mnt/storage1/apps/yt-feed` and editable `feed.yaml` and `subscriptions.yaml` in Clover's `Documents/Config/Youtube Downloader`. Its downloaded videos and upscaler output live in the existing Media dataset, so that dataset moves by ZFS rename as described in [media-cutover.md](media-cutover.md).
4
5On 2026-09-26, all six JSON files in `yt-feed-preview-e87b1ca1` matched Zenith by SHA-256 and parsed as JSON. Both Clover configuration files had matching SHA-256 hashes in the VM. The preview Media mount was read-only. Globe now owns the review API and worker; Nomad's `yt-feed` job runs only the thumbnail upscaler.
6
7For production, set `STUDIO_DEPLOY_HOST` and `STUDIO_DEPLOY_PORT` for the new host, then run `bash tools/import-legacy-secrets.sh yt-feed MAILER_ADDRESS MAILER_USERNAME MAILER_PASSWORD`. The order matches `smtp_host`, `smtp_user`, and `smtp_pass` in `service/youtube/yt-feed.pkl`. Stop Zenith's `yt-feed` and the Snow Globe `yt-feed` job before running `bash tools/import-yt-feed.sh yt-feed`; the importer stops Globe's worker, snapshots the Snow Globe dataset, verifies the JSON copy, then restarts Globe. Clover's configuration and the Media dataset must be mounted at their final paths before import. Verify Globe's YouTube queue and one manual review action before cutover.
8
9For a same-machine OS replacement, set `STUDIO_LEGACY_HANDOFF` to the [offline handoff](legacy-handoff.md) directory while importing both secrets and JSON state. The production importer then reads the retained files from the mounted old apps dataset on the new host, without old Docker.
tools/zenith-hardware.md created+25
......@@ -0,0 +1,25 @@
1# Zenith source host inventory
2
3Read-only inspection on 2026-09-27 found a BIOS-booted Ryzen 9 5950X host with a Realtek RTL8111/8168/8411 NIC (`r8169`), RTX 3090, one 500 GB WD Blue SN5000 NVMe boot disk, and four 8 TB WD80EFPX disks. The NVMe is `/dev/disk/by-id/nvme-WD_Blue_SN5000_500GB_24261Z806200`; its GPT has a 1 MB BIOS boot partition, a 512 MB EFI partition, and a TrueNAS `boot-pool` partition with the running root at `boot-pool/ROOT/25.04.2.4`. The four other disks form the healthy `storage1` RAIDZ1 pool. The VM's UEFI boot configuration does not describe this host.
4
5`enp4s0` has static `10.0.0.1/24` and `192.168.0.1/24` addresses, default gateway `10.0.0.2`, and resolvers `1.1.1.1` and `1.0.0.1`. The NixOS target retains these; DHCP would not preserve the NAS address.
6
7`storage1` is unencrypted and mounted at `/mnt/storage1`. Its `apps`, `clover`, and `media` children are separate AES-256-GCM encryption roots. `apps` uses POSIX ACLs; `clover` and `media` use NFSv4 ACLs. Their mountpoints are `/mnt/storage1/apps`, `/mnt/storage1/clover`, and `/mnt/storage1/media`. The pool reported about 16.1 TB free. The [ACL cutover](storage-acl-cutover.md) and [Media cutover](media-cutover.md) describe the data-side migration; no disk or pool changes were made during this inventory.
8
9On 2026-09-27, Zenith ran OpenZFS 2.3.0 and reported `storage1` healthy. The pinned NixOS VM ran OpenZFS 2.4.4; its `zpool upgrade -v` listed every feature currently enabled or active on `storage1`, including encryption and block cloning. This checks feature support, not an actual import of the four-disk pool.
10
11TrueNAS currently snapshots Clover hourly for one week, daily for four weeks, and monthly for two years; apps hourly for one week and daily for one month. Media has no scheduled snapshot task. NixOS does not yet replace this retention policy. The pinned NixOS `services.zfs.autoSnapshot` module has global retention counts, so it cannot express these different dataset schedules. Its `services.sanoid` module supports retention per dataset; the owner is choosing the replacement policy before it is enabled.
12
13The `zenith` NixOS target uses the observed BIOS boot mode, NVMe disk ID, ZFS host ID, `storage1` pool, and `paperclover.net` domain. The configured host ID `4fa19ccb` matches live `hostid`, and the configured NVMe by-id path resolves to the live boot disk; the four pool members have distinct partition UUIDs and no reported read, write, or checksum errors. Generate `nixos/hardware-configuration.nix` from the installer after partitioning the NVMe; its placeholder intentionally prevents building the target before the new root filesystem is known. NixOS imports `storage1` without requesting encryption credentials during boot. Set `storage1/apps` to the explicit `/mnt/storage1/apps` mountpoint before changing the pool root, as [media-cutover.md](media-cutover.md) specifies; its current default mountpoint would otherwise move with the pool. After loading the keys and mounting the renamed datasets, start Nomad; its startup check requires `storage1` at `/srv`, `storage1/clover` at `/srv/clover`, `storage1/clover/Media` at `/srv/clover/Media`, and `storage1/prod` and `storage1/staging` at their corresponding paths. Snow Globe's preflight separately checks encryption and ACL type. Keep `storage1/apps` mounted at `/mnt/storage1/apps` through the import handoff. Dataset renames, encryption keys, ACL conversion, and the OS installation are owner-run downtime steps.
14
15The target authorizes this Mac's existing ED25519 key for `clo` and root SSH. Its fingerprint `SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU` matches the key Zenith currently accepts from this Mac. The other three keys in Zenith's `clo` authorized-keys file are not copied into the new root account. A synthetic NixOS evaluation confirmed both accounts receive exactly this key.
16
17[Nomad stores its server state under `data_dir`](https://developer.hashicorp.com/nomad/docs/configuration), including [variables and their encrypted secret values](https://developer.hashicorp.com/nomad/docs/concepts/variables). Snow Globe stores generated service UIDs, deployment history, backup manifests/dumps, and dashboard state under `/var/lib/studio`. Both paths are on the VM's OS disk, outside its ZFS service datasets. Nomad used `66 MB` at inspection; Snow Globe's `8.7 GB` includes an `8.5 GB` VM-only `zpool.img`, while its release backups used `275 MB` across 25 runs. Reusing or replacing Zenith's NVMe would lose the control records unless they are migrated to encrypted ZFS or backed up separately. The pinned NixOS Nomad module accepts `services.nomad.settings.data_dir = "/srv/prod/nomad"` when `dropPrivileges = false`; a synthetic target evaluation passed. The corresponding Snow Globe state mount and dataset boundary await the storage policy decision before the physical cutover.
18
19A `nomad operator snapshot save` on the VM produced a private, compressed 143,608-byte snapshot in `/run`. Inspection reported 46 variables, 31 jobs, and 25 ACL policies. The snapshot restored into a fresh server-only Nomad agent in an isolated network namespace: its API listed all 31 jobs and 46 variables, and a hash comparison of Shale's secret variable items matched the live server without printing the values. The temporary agent, data, and snapshot were removed; the live leader and jobs stayed healthy. This proves [Nomad's server-state snapshot and restore](https://developer.hashicorp.com/nomad/commands/operator/snapshot/restore) on the pinned version, but Snow Globe's separate UID registry and backup files still need durable storage.
20
21The production target selects NixOS's stable NVIDIA driver for the RTX 3090 and its headless persistence daemon; the synthetic install evaluated with driver 595.71.05. Jellyfin has no GPU device allocation yet, so hardware transcoding still needs a physical-host test.
22
23With root SSH access and Nomad running, `STUDIO_DEPLOY_HOST=root@10.0.0.1 STUDIO_DEPLOY_PORT=22 python3 tools/deploy.py bootstrap` creates the Nomad ACL tokens and service datasets before checking external secrets. The first run reports all missing secret names; load those with `tools/import-legacy-secrets.sh` or `deploy.py secrets`, then rerun bootstrap to launch the jobs. Generated service secrets are created automatically. No Snow Globe job starts before the external-secret check passes. A bootstrap interrupted after switching the release link can be retried until deployment history records success.
24
25The ACL bootstrap ran twice against a disposable Nomad dev agent on the VM, created management, router, and dashboard token files, and left 14 policies. The dev agent and temporary state were removed; the VM's production Nomad agent was untouched.