| author | |
| committer | |
| log | 76765cb2aed4394bf710c2bb4d026ec80115f593 |
| tree | 46475555873058708de0aae1e334b3518e68cbc1 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Assisted-by: gpt-6328 files changed, 46869 insertions(+), 0 deletions(-)
.gitignore created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | result | ||
| 2 | secrets/ | ||
| 3 | config/.identities.lock | ||
| 4 | config/identities.pending | ||
config/Service.pkl created+184| ... | @@ -0,0 +1,184 @@ | ||
| 1 | open module Service | ||
| 2 | |||
| 3 | import "site.pkl" as site | ||
| 4 | |||
| 5 | const nomadHostPort = "{{ if regexMatch \":\" .Address }}[{{ .Address }}]{{ else }}{{ .Address }}{{ end }}:{{ .Port }}" | ||
| 6 | |||
| 7 | /// Name shown in the management UI and service listings. | ||
| 8 | class Metadata { | ||
| 9 | name: String | ||
| 10 | tagline: String = "" | ||
| 11 | } | ||
| 12 | |||
| 13 | /// One HTTP listener and its public route. | ||
| 14 | class 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. | ||
| 42 | class 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. | ||
| 50 | class 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. | ||
| 59 | class GeneratedSecret { | ||
| 60 | bytes: Int(isBetween(16, 128)) = 32 | ||
| 61 | } | ||
| 62 | |||
| 63 | /// Provider-owned resource request; the alias names its allocated secret. | ||
| 64 | open 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. | ||
| 71 | class 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. | ||
| 99 | id: String = read?("prop:serviceId") | ||
| 100 | /// Stable numeric owner assigned by the deployment tool. | ||
| 101 | uid: Int = read("prop:uid").toInt() | ||
| 102 | meta: Metadata | ||
| 103 | /// Allows site-specific services to stay out of deployments where they would cause side effects. | ||
| 104 | enabled: Boolean = true | ||
| 105 | /// Simple replaces one allocation; overlapped runs a canary alongside it. | ||
| 106 | rollout: String = "simple" | ||
| 107 | /// Fresh previews create empty storage and new secrets instead of forking production. | ||
| 108 | stageIsolation: "clone"|"fresh" = "clone" | ||
| 109 | /// Time allowed for a new allocation to pass its service checks. | ||
| 110 | healthyDeadline: String? | ||
| 111 | /// Delay before persistent health-check failures may restart a running allocation. | ||
| 112 | healthRestartGrace: String? | ||
| 113 | /// Repeatable service configuration run after a healthy deployment. | ||
| 114 | setup: String? | ||
| 115 | /// Service-owned data preparation before its container starts. | ||
| 116 | prepare: String? | ||
| 117 | /// Handler for input requests owned by this service. | ||
| 118 | provide: String? | ||
| 119 | |||
| 120 | /// Use this for one container; it becomes the "app" task in the output. | ||
| 121 | container: Container? | ||
| 122 | |||
| 123 | /// Use this instead of `container` when the service has named tasks. | ||
| 124 | containers: Mapping<String, Container>? | ||
| 125 | |||
| 126 | secrets: Mapping<String, GeneratedSecret> = new {} | ||
| 127 | /// Secret names supplied from outside the release, in import-file line order. | ||
| 128 | requiredSecrets: Listing<String> = new {} | ||
| 129 | requirements: Listing<Requirement> = new {} | ||
| 130 | /// Service startup dependency with no resource to allocate. | ||
| 131 | dependsOn: Listing<String> = new {} | ||
| 132 | /// Resource service.name emitted by this app's trace exporter. | ||
| 133 | traceServiceName: String? | ||
| 134 | /// Metrics sent by the app over OTLP instead of a Prometheus HTTP endpoint. | ||
| 135 | metricsPushed: Boolean = false | ||
| 136 | |||
| 137 | local 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 | |||
| 151 | local vmStartupGrace: String? = if (read?("env:STUDIO_VM_ACCEL") == "qemu") "60m" else null | ||
| 152 | |||
| 153 | output { | ||
| 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 @@ | ||
| 1 | namespace "default" { | ||
| 2 | capabilities = ["list-jobs", "read-job", "read-logs"] | ||
| 3 | } | ||
| 4 | |||
| 5 | node { | ||
| 6 | policy = "read" | ||
| 7 | } | ||
config/policies/router.hcl created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | namespace "default" { | ||
| 2 | policy = "read" | ||
| 3 | } | ||
config/site.pkl created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | module Site | ||
| 2 | |||
| 3 | /// The path to the root ZFS store | ||
| 4 | root: String = read?("prop:root") ?? read?("env:STUDIO_ROOT") ?? "/srv" | ||
| 5 | pool: String = read?("prop:pool") ?? read?("env:STUDIO_POOL") ?? "studio-demo" | ||
| 6 | nodeName: String = read?("prop:nodeName") ?? read?("env:STUDIO_NODE_NAME") ?? "clover-demo" | ||
| 7 | /// The base domain that the infrastructure runs on | ||
| 8 | domain: String = read?("prop:domain") ?? read?("env:STUDIO_DOMAIN") ?? "studio.test" | ||
| 9 | ownerEmail: String = read?("prop:ownerEmail") ?? read?("env:STUDIO_OWNER_EMAIL") ?? "account@paperclover.net" | ||
| 10 | cloverRoot: String = read?("prop:cloverRoot") ?? read?("env:STUDIO_CLOVER_ROOT") ?? "\(root)/clover" | ||
| 11 | cloverUid: Int = (read?("prop:cloverUid") ?? read?("env:STUDIO_CLOVER_UID") ?? "3000").toInt() | ||
| 12 | cloverGid: Int = (read?("prop:cloverGid") ?? read?("env:STUDIO_CLOVER_GID") ?? "3000").toInt() | ||
| 13 | cloverReadOnly: Boolean = (read?("prop:cloverReadOnly") ?? read?("env:STUDIO_CLOVER_READ_ONLY") ?? "false") == "true" | ||
| 14 | mediaRoot: String = read?("prop:mediaRoot") ?? read?("env:STUDIO_MEDIA_ROOT") ?? "\(cloverRoot)/Media" | ||
| 15 | mediaReadOnly: Boolean = (read?("prop:mediaReadOnly") ?? read?("env:STUDIO_MEDIA_READ_ONLY") ?? "false") == "true" | ||
| 16 | tlsInternal: Boolean = (read?("prop:tlsInternal") ?? read?("env:STUDIO_TLS_INTERNAL") ?? "false") == "true" || domain.endsWith(".test") | ||
| 17 | preview: Boolean = (read?("prop:preview") ?? "false") == "true" | ||
dashboard/.gitignore created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | node_modules/ | ||
| 2 | dist/ | ||
| 3 | .cache/ | ||
| 4 | data/ | ||
| 5 | target/ | ||
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. | ||
| 3 | version = 4 | ||
| 4 | |||
| 5 | [[package]] | ||
| 6 | name = "aho-corasick" | ||
| 7 | version = "1.1.5" | ||
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 9 | checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" | ||
| 10 | dependencies = [ | ||
| 11 | "memchr", | ||
| 12 | ] | ||
| 13 | |||
| 14 | [[package]] | ||
| 15 | name = "android_system_properties" | ||
| 16 | version = "0.1.6" | ||
| 17 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 18 | checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" | ||
| 19 | dependencies = [ | ||
| 20 | "libc", | ||
| 21 | ] | ||
| 22 | |||
| 23 | [[package]] | ||
| 24 | name = "async-trait" | ||
| 25 | version = "0.1.92" | ||
| 26 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 27 | checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" | ||
| 28 | dependencies = [ | ||
| 29 | "proc-macro2", | ||
| 30 | "quote", | ||
| 31 | "syn 3.0.6", | ||
| 32 | ] | ||
| 33 | |||
| 34 | [[package]] | ||
| 35 | name = "atomic-waker" | ||
| 36 | version = "1.1.2" | ||
| 37 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 38 | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | ||
| 39 | |||
| 40 | [[package]] | ||
| 41 | name = "autocfg" | ||
| 42 | version = "1.5.1" | ||
| 43 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 44 | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | ||
| 45 | |||
| 46 | [[package]] | ||
| 47 | name = "axum" | ||
| 48 | version = "0.8.9" | ||
| 49 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 50 | checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" | ||
| 51 | dependencies = [ | ||
| 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]] | ||
| 84 | name = "axum-core" | ||
| 85 | version = "0.5.6" | ||
| 86 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 87 | checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" | ||
| 88 | dependencies = [ | ||
| 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]] | ||
| 103 | name = "base64" | ||
| 104 | version = "0.22.1" | ||
| 105 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 106 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | ||
| 107 | |||
| 108 | [[package]] | ||
| 109 | name = "base64" | ||
| 110 | version = "0.23.1" | ||
| 111 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 112 | checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" | ||
| 113 | |||
| 114 | [[package]] | ||
| 115 | name = "bitflags" | ||
| 116 | version = "2.13.2" | ||
| 117 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 118 | checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" | ||
| 119 | |||
| 120 | [[package]] | ||
| 121 | name = "block-buffer" | ||
| 122 | version = "0.10.4" | ||
| 123 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 124 | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | ||
| 125 | dependencies = [ | ||
| 126 | "generic-array", | ||
| 127 | ] | ||
| 128 | |||
| 129 | [[package]] | ||
| 130 | name = "bstr" | ||
| 131 | version = "1.13.1" | ||
| 132 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 133 | checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" | ||
| 134 | dependencies = [ | ||
| 135 | "memchr", | ||
| 136 | "serde_core", | ||
| 137 | ] | ||
| 138 | |||
| 139 | [[package]] | ||
| 140 | name = "bumpalo" | ||
| 141 | version = "3.20.3" | ||
| 142 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 143 | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | ||
| 144 | |||
| 145 | [[package]] | ||
| 146 | name = "bytes" | ||
| 147 | version = "1.12.1" | ||
| 148 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 149 | checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" | ||
| 150 | |||
| 151 | [[package]] | ||
| 152 | name = "cc" | ||
| 153 | version = "1.5.1" | ||
| 154 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 155 | checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" | ||
| 156 | dependencies = [ | ||
| 157 | "find-msvc-tools", | ||
| 158 | "shlex", | ||
| 159 | ] | ||
| 160 | |||
| 161 | [[package]] | ||
| 162 | name = "cfg-if" | ||
| 163 | version = "1.0.5" | ||
| 164 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 165 | checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" | ||
| 166 | |||
| 167 | [[package]] | ||
| 168 | name = "cfg_aliases" | ||
| 169 | version = "0.2.2" | ||
| 170 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 171 | checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" | ||
| 172 | |||
| 173 | [[package]] | ||
| 174 | name = "chacha20" | ||
| 175 | version = "0.10.2" | ||
| 176 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 177 | checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" | ||
| 178 | dependencies = [ | ||
| 179 | "cfg-if", | ||
| 180 | "cpufeatures 0.3.1", | ||
| 181 | "rand_core 0.10.1", | ||
| 182 | ] | ||
| 183 | |||
| 184 | [[package]] | ||
| 185 | name = "chrono" | ||
| 186 | version = "0.4.45" | ||
| 187 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 188 | checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" | ||
| 189 | dependencies = [ | ||
| 190 | "iana-time-zone", | ||
| 191 | "js-sys", | ||
| 192 | "num-traits", | ||
| 193 | "serde", | ||
| 194 | "wasm-bindgen", | ||
| 195 | "windows-link", | ||
| 196 | ] | ||
| 197 | |||
| 198 | [[package]] | ||
| 199 | name = "core-foundation-sys" | ||
| 200 | version = "0.8.7" | ||
| 201 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 202 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | ||
| 203 | |||
| 204 | [[package]] | ||
| 205 | name = "core_detect" | ||
| 206 | version = "1.0.0" | ||
| 207 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 208 | checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" | ||
| 209 | |||
| 210 | [[package]] | ||
| 211 | name = "cpufeatures" | ||
| 212 | version = "0.2.17" | ||
| 213 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 214 | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | ||
| 215 | dependencies = [ | ||
| 216 | "libc", | ||
| 217 | ] | ||
| 218 | |||
| 219 | [[package]] | ||
| 220 | name = "cpufeatures" | ||
| 221 | version = "0.3.1" | ||
| 222 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 223 | checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" | ||
| 224 | dependencies = [ | ||
| 225 | "libc", | ||
| 226 | ] | ||
| 227 | |||
| 228 | [[package]] | ||
| 229 | name = "crypto-common" | ||
| 230 | version = "0.1.7" | ||
| 231 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 232 | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | ||
| 233 | dependencies = [ | ||
| 234 | "generic-array", | ||
| 235 | "typenum", | ||
| 236 | ] | ||
| 237 | |||
| 238 | [[package]] | ||
| 239 | name = "cssparser" | ||
| 240 | version = "0.37.0" | ||
| 241 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 242 | checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" | ||
| 243 | dependencies = [ | ||
| 244 | "cssparser-macros", | ||
| 245 | "dtoa-short", | ||
| 246 | "itoa", | ||
| 247 | "phf", | ||
| 248 | "smallvec", | ||
| 249 | ] | ||
| 250 | |||
| 251 | [[package]] | ||
| 252 | name = "cssparser-macros" | ||
| 253 | version = "0.7.1" | ||
| 254 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 255 | checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" | ||
| 256 | dependencies = [ | ||
| 257 | "quote", | ||
| 258 | "syn 3.0.6", | ||
| 259 | ] | ||
| 260 | |||
| 261 | [[package]] | ||
| 262 | name = "data-encoding" | ||
| 263 | version = "2.11.1" | ||
| 264 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 265 | checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" | ||
| 266 | |||
| 267 | [[package]] | ||
| 268 | name = "derive_more" | ||
| 269 | version = "2.1.1" | ||
| 270 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 271 | checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" | ||
| 272 | dependencies = [ | ||
| 273 | "derive_more-impl", | ||
| 274 | ] | ||
| 275 | |||
| 276 | [[package]] | ||
| 277 | name = "derive_more-impl" | ||
| 278 | version = "2.1.1" | ||
| 279 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 280 | checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" | ||
| 281 | dependencies = [ | ||
| 282 | "proc-macro2", | ||
| 283 | "quote", | ||
| 284 | "rustc_version", | ||
| 285 | "syn 2.0.119", | ||
| 286 | ] | ||
| 287 | |||
| 288 | [[package]] | ||
| 289 | name = "digest" | ||
| 290 | version = "0.10.7" | ||
| 291 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 292 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | ||
| 293 | dependencies = [ | ||
| 294 | "block-buffer", | ||
| 295 | "crypto-common", | ||
| 296 | ] | ||
| 297 | |||
| 298 | [[package]] | ||
| 299 | name = "displaydoc" | ||
| 300 | version = "0.2.7" | ||
| 301 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 302 | checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" | ||
| 303 | dependencies = [ | ||
| 304 | "proc-macro2", | ||
| 305 | "quote", | ||
| 306 | "syn 3.0.6", | ||
| 307 | ] | ||
| 308 | |||
| 309 | [[package]] | ||
| 310 | name = "dtoa" | ||
| 311 | version = "1.0.11" | ||
| 312 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 313 | checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" | ||
| 314 | |||
| 315 | [[package]] | ||
| 316 | name = "dtoa-short" | ||
| 317 | version = "0.3.5" | ||
| 318 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 319 | checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" | ||
| 320 | dependencies = [ | ||
| 321 | "dtoa", | ||
| 322 | ] | ||
| 323 | |||
| 324 | [[package]] | ||
| 325 | name = "dyn-clone" | ||
| 326 | version = "1.0.20" | ||
| 327 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 328 | checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" | ||
| 329 | |||
| 330 | [[package]] | ||
| 331 | name = "ego-tree" | ||
| 332 | version = "0.11.0" | ||
| 333 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 334 | checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" | ||
| 335 | |||
| 336 | [[package]] | ||
| 337 | name = "encoding_rs" | ||
| 338 | version = "0.8.42" | ||
| 339 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 340 | checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" | ||
| 341 | dependencies = [ | ||
| 342 | "cfg-if", | ||
| 343 | "core_detect", | ||
| 344 | "multiversion_no_op", | ||
| 345 | "rustversion", | ||
| 346 | "scopeguard", | ||
| 347 | "simdutf8", | ||
| 348 | ] | ||
| 349 | |||
| 350 | [[package]] | ||
| 351 | name = "equivalent" | ||
| 352 | version = "1.0.2" | ||
| 353 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 354 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | ||
| 355 | |||
| 356 | [[package]] | ||
| 357 | name = "errno" | ||
| 358 | version = "0.3.14" | ||
| 359 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 360 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | ||
| 361 | dependencies = [ | ||
| 362 | "libc", | ||
| 363 | "windows-sys 0.61.2", | ||
| 364 | ] | ||
| 365 | |||
| 366 | [[package]] | ||
| 367 | name = "fallible-iterator" | ||
| 368 | version = "0.3.0" | ||
| 369 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 370 | checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" | ||
| 371 | |||
| 372 | [[package]] | ||
| 373 | name = "fallible-streaming-iterator" | ||
| 374 | version = "0.1.9" | ||
| 375 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 376 | checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" | ||
| 377 | |||
| 378 | [[package]] | ||
| 379 | name = "fastrand" | ||
| 380 | version = "2.5.0" | ||
| 381 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 382 | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" | ||
| 383 | |||
| 384 | [[package]] | ||
| 385 | name = "find-msvc-tools" | ||
| 386 | version = "0.1.14" | ||
| 387 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 388 | checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" | ||
| 389 | |||
| 390 | [[package]] | ||
| 391 | name = "foldhash" | ||
| 392 | version = "0.1.5" | ||
| 393 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 394 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" | ||
| 395 | |||
| 396 | [[package]] | ||
| 397 | name = "form_urlencoded" | ||
| 398 | version = "1.2.2" | ||
| 399 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 400 | checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" | ||
| 401 | dependencies = [ | ||
| 402 | "percent-encoding", | ||
| 403 | ] | ||
| 404 | |||
| 405 | [[package]] | ||
| 406 | name = "futures" | ||
| 407 | version = "0.3.34" | ||
| 408 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 409 | checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" | ||
| 410 | dependencies = [ | ||
| 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]] | ||
| 421 | name = "futures-channel" | ||
| 422 | version = "0.3.34" | ||
| 423 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 424 | checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" | ||
| 425 | dependencies = [ | ||
| 426 | "futures-core", | ||
| 427 | "futures-sink", | ||
| 428 | ] | ||
| 429 | |||
| 430 | [[package]] | ||
| 431 | name = "futures-core" | ||
| 432 | version = "0.3.34" | ||
| 433 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 434 | checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" | ||
| 435 | |||
| 436 | [[package]] | ||
| 437 | name = "futures-executor" | ||
| 438 | version = "0.3.34" | ||
| 439 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 440 | checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" | ||
| 441 | dependencies = [ | ||
| 442 | "futures-core", | ||
| 443 | "futures-task", | ||
| 444 | "futures-util", | ||
| 445 | ] | ||
| 446 | |||
| 447 | [[package]] | ||
| 448 | name = "futures-io" | ||
| 449 | version = "0.3.34" | ||
| 450 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 451 | checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" | ||
| 452 | |||
| 453 | [[package]] | ||
| 454 | name = "futures-macro" | ||
| 455 | version = "0.3.34" | ||
| 456 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 457 | checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" | ||
| 458 | dependencies = [ | ||
| 459 | "proc-macro2", | ||
| 460 | "quote", | ||
| 461 | "syn 3.0.6", | ||
| 462 | ] | ||
| 463 | |||
| 464 | [[package]] | ||
| 465 | name = "futures-sink" | ||
| 466 | version = "0.3.34" | ||
| 467 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 468 | checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" | ||
| 469 | |||
| 470 | [[package]] | ||
| 471 | name = "futures-task" | ||
| 472 | version = "0.3.34" | ||
| 473 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 474 | checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" | ||
| 475 | |||
| 476 | [[package]] | ||
| 477 | name = "futures-util" | ||
| 478 | version = "0.3.34" | ||
| 479 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 480 | checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" | ||
| 481 | dependencies = [ | ||
| 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]] | ||
| 494 | name = "generic-array" | ||
| 495 | version = "0.14.7" | ||
| 496 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 497 | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" | ||
| 498 | dependencies = [ | ||
| 499 | "typenum", | ||
| 500 | "version_check", | ||
| 501 | ] | ||
| 502 | |||
| 503 | [[package]] | ||
| 504 | name = "getrandom" | ||
| 505 | version = "0.2.17" | ||
| 506 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 507 | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" | ||
| 508 | dependencies = [ | ||
| 509 | "cfg-if", | ||
| 510 | "js-sys", | ||
| 511 | "libc", | ||
| 512 | "wasi", | ||
| 513 | "wasm-bindgen", | ||
| 514 | ] | ||
| 515 | |||
| 516 | [[package]] | ||
| 517 | name = "getrandom" | ||
| 518 | version = "0.3.4" | ||
| 519 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 520 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" | ||
| 521 | dependencies = [ | ||
| 522 | "cfg-if", | ||
| 523 | "libc", | ||
| 524 | "r-efi 5.3.0", | ||
| 525 | "wasip2", | ||
| 526 | ] | ||
| 527 | |||
| 528 | [[package]] | ||
| 529 | name = "getrandom" | ||
| 530 | version = "0.4.3" | ||
| 531 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 532 | checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" | ||
| 533 | dependencies = [ | ||
| 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]] | ||
| 543 | name = "globset" | ||
| 544 | version = "0.4.20" | ||
| 545 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 546 | checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" | ||
| 547 | dependencies = [ | ||
| 548 | "aho-corasick", | ||
| 549 | "bstr", | ||
| 550 | "log", | ||
| 551 | "regex-automata", | ||
| 552 | "regex-syntax", | ||
| 553 | ] | ||
| 554 | |||
| 555 | [[package]] | ||
| 556 | name = "hashbrown" | ||
| 557 | version = "0.15.5" | ||
| 558 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 559 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" | ||
| 560 | dependencies = [ | ||
| 561 | "foldhash", | ||
| 562 | ] | ||
| 563 | |||
| 564 | [[package]] | ||
| 565 | name = "hashbrown" | ||
| 566 | version = "0.17.1" | ||
| 567 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 568 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | ||
| 569 | |||
| 570 | [[package]] | ||
| 571 | name = "hashlink" | ||
| 572 | version = "0.10.0" | ||
| 573 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 574 | checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" | ||
| 575 | dependencies = [ | ||
| 576 | "hashbrown 0.15.5", | ||
| 577 | ] | ||
| 578 | |||
| 579 | [[package]] | ||
| 580 | name = "home-dashboard" | ||
| 581 | version = "0.1.0" | ||
| 582 | dependencies = [ | ||
| 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]] | ||
| 608 | name = "html5ever" | ||
| 609 | version = "0.39.0" | ||
| 610 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 611 | checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" | ||
| 612 | dependencies = [ | ||
| 613 | "log", | ||
| 614 | "markup5ever", | ||
| 615 | ] | ||
| 616 | |||
| 617 | [[package]] | ||
| 618 | name = "http" | ||
| 619 | version = "1.5.0" | ||
| 620 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 621 | checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" | ||
| 622 | dependencies = [ | ||
| 623 | "bytes", | ||
| 624 | "itoa", | ||
| 625 | ] | ||
| 626 | |||
| 627 | [[package]] | ||
| 628 | name = "http-body" | ||
| 629 | version = "1.1.0" | ||
| 630 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 631 | checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" | ||
| 632 | dependencies = [ | ||
| 633 | "bytes", | ||
| 634 | "http", | ||
| 635 | ] | ||
| 636 | |||
| 637 | [[package]] | ||
| 638 | name = "http-body-util" | ||
| 639 | version = "0.1.5" | ||
| 640 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 641 | checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" | ||
| 642 | dependencies = [ | ||
| 643 | "bytes", | ||
| 644 | "futures-core", | ||
| 645 | "http", | ||
| 646 | "http-body", | ||
| 647 | "pin-project-lite", | ||
| 648 | ] | ||
| 649 | |||
| 650 | [[package]] | ||
| 651 | name = "http-range-header" | ||
| 652 | version = "0.4.2" | ||
| 653 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 654 | checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" | ||
| 655 | |||
| 656 | [[package]] | ||
| 657 | name = "httparse" | ||
| 658 | version = "1.10.1" | ||
| 659 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 660 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | ||
| 661 | |||
| 662 | [[package]] | ||
| 663 | name = "httpdate" | ||
| 664 | version = "1.0.3" | ||
| 665 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 666 | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" | ||
| 667 | |||
| 668 | [[package]] | ||
| 669 | name = "hyper" | ||
| 670 | version = "1.11.1" | ||
| 671 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 672 | checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" | ||
| 673 | dependencies = [ | ||
| 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]] | ||
| 690 | name = "hyper-rustls" | ||
| 691 | version = "0.27.10" | ||
| 692 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 693 | checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" | ||
| 694 | dependencies = [ | ||
| 695 | "http", | ||
| 696 | "hyper", | ||
| 697 | "hyper-util", | ||
| 698 | "rustls", | ||
| 699 | "tokio", | ||
| 700 | "tokio-rustls", | ||
| 701 | "tower-service", | ||
| 702 | "webpki-roots", | ||
| 703 | ] | ||
| 704 | |||
| 705 | [[package]] | ||
| 706 | name = "hyper-util" | ||
| 707 | version = "0.1.21" | ||
| 708 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 709 | checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" | ||
| 710 | dependencies = [ | ||
| 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]] | ||
| 730 | name = "iana-time-zone" | ||
| 731 | version = "0.1.65" | ||
| 732 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 733 | checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" | ||
| 734 | dependencies = [ | ||
| 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]] | ||
| 745 | name = "iana-time-zone-haiku" | ||
| 746 | version = "0.1.2" | ||
| 747 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 748 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" | ||
| 749 | dependencies = [ | ||
| 750 | "cc", | ||
| 751 | ] | ||
| 752 | |||
| 753 | [[package]] | ||
| 754 | name = "icu_collections" | ||
| 755 | version = "2.3.0" | ||
| 756 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 757 | checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" | ||
| 758 | dependencies = [ | ||
| 759 | "displaydoc", | ||
| 760 | "potential_utf", | ||
| 761 | "utf8_iter", | ||
| 762 | "yoke", | ||
| 763 | "zerofrom", | ||
| 764 | "zerovec", | ||
| 765 | ] | ||
| 766 | |||
| 767 | [[package]] | ||
| 768 | name = "icu_locale_core" | ||
| 769 | version = "2.3.0" | ||
| 770 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 771 | checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" | ||
| 772 | dependencies = [ | ||
| 773 | "displaydoc", | ||
| 774 | "litemap", | ||
| 775 | "tinystr", | ||
| 776 | "writeable", | ||
| 777 | "zerovec", | ||
| 778 | ] | ||
| 779 | |||
| 780 | [[package]] | ||
| 781 | name = "icu_normalizer" | ||
| 782 | version = "2.3.0" | ||
| 783 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 784 | checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" | ||
| 785 | dependencies = [ | ||
| 786 | "icu_collections", | ||
| 787 | "icu_normalizer_data", | ||
| 788 | "icu_properties", | ||
| 789 | "icu_provider", | ||
| 790 | "smallvec", | ||
| 791 | "zerovec", | ||
| 792 | ] | ||
| 793 | |||
| 794 | [[package]] | ||
| 795 | name = "icu_normalizer_data" | ||
| 796 | version = "2.3.0" | ||
| 797 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 798 | checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" | ||
| 799 | |||
| 800 | [[package]] | ||
| 801 | name = "icu_properties" | ||
| 802 | version = "2.3.0" | ||
| 803 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 804 | checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" | ||
| 805 | dependencies = [ | ||
| 806 | "displaydoc", | ||
| 807 | "icu_collections", | ||
| 808 | "icu_locale_core", | ||
| 809 | "icu_properties_data", | ||
| 810 | "icu_provider", | ||
| 811 | "zerotrie", | ||
| 812 | "zerovec", | ||
| 813 | ] | ||
| 814 | |||
| 815 | [[package]] | ||
| 816 | name = "icu_properties_data" | ||
| 817 | version = "2.3.0" | ||
| 818 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 819 | checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" | ||
| 820 | |||
| 821 | [[package]] | ||
| 822 | name = "icu_provider" | ||
| 823 | version = "2.3.1" | ||
| 824 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 825 | checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" | ||
| 826 | dependencies = [ | ||
| 827 | "displaydoc", | ||
| 828 | "icu_locale_core", | ||
| 829 | "writeable", | ||
| 830 | "yoke", | ||
| 831 | "zerofrom", | ||
| 832 | "zerotrie", | ||
| 833 | "zerovec", | ||
| 834 | ] | ||
| 835 | |||
| 836 | [[package]] | ||
| 837 | name = "idna" | ||
| 838 | version = "1.1.0" | ||
| 839 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 840 | checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" | ||
| 841 | dependencies = [ | ||
| 842 | "idna_adapter", | ||
| 843 | "smallvec", | ||
| 844 | "utf8_iter", | ||
| 845 | ] | ||
| 846 | |||
| 847 | [[package]] | ||
| 848 | name = "idna_adapter" | ||
| 849 | version = "1.2.2" | ||
| 850 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 851 | checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" | ||
| 852 | dependencies = [ | ||
| 853 | "icu_normalizer", | ||
| 854 | "icu_properties", | ||
| 855 | ] | ||
| 856 | |||
| 857 | [[package]] | ||
| 858 | name = "indexmap" | ||
| 859 | version = "2.14.2" | ||
| 860 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 861 | checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" | ||
| 862 | dependencies = [ | ||
| 863 | "equivalent", | ||
| 864 | "hashbrown 0.17.1", | ||
| 865 | "serde", | ||
| 866 | "serde_core", | ||
| 867 | ] | ||
| 868 | |||
| 869 | [[package]] | ||
| 870 | name = "ipnet" | ||
| 871 | version = "2.12.2" | ||
| 872 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 873 | checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" | ||
| 874 | |||
| 875 | [[package]] | ||
| 876 | name = "itoa" | ||
| 877 | version = "1.0.18" | ||
| 878 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 879 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | ||
| 880 | |||
| 881 | [[package]] | ||
| 882 | name = "js-sys" | ||
| 883 | version = "0.3.106" | ||
| 884 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 885 | checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" | ||
| 886 | dependencies = [ | ||
| 887 | "cfg-if", | ||
| 888 | "futures-util", | ||
| 889 | "wasm-bindgen", | ||
| 890 | ] | ||
| 891 | |||
| 892 | [[package]] | ||
| 893 | name = "libc" | ||
| 894 | version = "0.2.189" | ||
| 895 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 896 | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" | ||
| 897 | |||
| 898 | [[package]] | ||
| 899 | name = "libsqlite3-sys" | ||
| 900 | version = "0.35.0" | ||
| 901 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 902 | checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" | ||
| 903 | dependencies = [ | ||
| 904 | "cc", | ||
| 905 | "pkg-config", | ||
| 906 | "vcpkg", | ||
| 907 | ] | ||
| 908 | |||
| 909 | [[package]] | ||
| 910 | name = "litemap" | ||
| 911 | version = "0.8.3" | ||
| 912 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 913 | checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" | ||
| 914 | |||
| 915 | [[package]] | ||
| 916 | name = "lock_api" | ||
| 917 | version = "0.4.14" | ||
| 918 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 919 | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" | ||
| 920 | dependencies = [ | ||
| 921 | "scopeguard", | ||
| 922 | ] | ||
| 923 | |||
| 924 | [[package]] | ||
| 925 | name = "log" | ||
| 926 | version = "0.4.34" | ||
| 927 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 928 | checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" | ||
| 929 | |||
| 930 | [[package]] | ||
| 931 | name = "lru-slab" | ||
| 932 | version = "0.1.3" | ||
| 933 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 934 | checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" | ||
| 935 | |||
| 936 | [[package]] | ||
| 937 | name = "markup5ever" | ||
| 938 | version = "0.39.0" | ||
| 939 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 940 | checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" | ||
| 941 | dependencies = [ | ||
| 942 | "log", | ||
| 943 | "tendril", | ||
| 944 | "web_atoms", | ||
| 945 | ] | ||
| 946 | |||
| 947 | [[package]] | ||
| 948 | name = "matchit" | ||
| 949 | version = "0.8.4" | ||
| 950 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 951 | checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" | ||
| 952 | |||
| 953 | [[package]] | ||
| 954 | name = "memchr" | ||
| 955 | version = "2.8.3" | ||
| 956 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 957 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | ||
| 958 | |||
| 959 | [[package]] | ||
| 960 | name = "mime" | ||
| 961 | version = "0.3.17" | ||
| 962 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 963 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" | ||
| 964 | |||
| 965 | [[package]] | ||
| 966 | name = "mime_guess" | ||
| 967 | version = "2.0.5" | ||
| 968 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 969 | checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" | ||
| 970 | dependencies = [ | ||
| 971 | "mime", | ||
| 972 | "unicase", | ||
| 973 | ] | ||
| 974 | |||
| 975 | [[package]] | ||
| 976 | name = "mio" | ||
| 977 | version = "1.2.3" | ||
| 978 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 979 | checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" | ||
| 980 | dependencies = [ | ||
| 981 | "libc", | ||
| 982 | "wasi", | ||
| 983 | "windows-sys 0.61.2", | ||
| 984 | ] | ||
| 985 | |||
| 986 | [[package]] | ||
| 987 | name = "multer" | ||
| 988 | version = "3.1.0" | ||
| 989 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 990 | checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" | ||
| 991 | dependencies = [ | ||
| 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]] | ||
| 1004 | name = "multiversion_no_op" | ||
| 1005 | version = "1.0.0" | ||
| 1006 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1007 | checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" | ||
| 1008 | |||
| 1009 | [[package]] | ||
| 1010 | name = "new_debug_unreachable" | ||
| 1011 | version = "1.0.6" | ||
| 1012 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1013 | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" | ||
| 1014 | |||
| 1015 | [[package]] | ||
| 1016 | name = "num-traits" | ||
| 1017 | version = "0.2.19" | ||
| 1018 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1019 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | ||
| 1020 | dependencies = [ | ||
| 1021 | "autocfg", | ||
| 1022 | ] | ||
| 1023 | |||
| 1024 | [[package]] | ||
| 1025 | name = "once_cell" | ||
| 1026 | version = "1.21.4" | ||
| 1027 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1028 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | ||
| 1029 | |||
| 1030 | [[package]] | ||
| 1031 | name = "parking_lot" | ||
| 1032 | version = "0.12.5" | ||
| 1033 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1034 | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" | ||
| 1035 | dependencies = [ | ||
| 1036 | "lock_api", | ||
| 1037 | "parking_lot_core", | ||
| 1038 | ] | ||
| 1039 | |||
| 1040 | [[package]] | ||
| 1041 | name = "parking_lot_core" | ||
| 1042 | version = "0.9.12" | ||
| 1043 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1044 | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" | ||
| 1045 | dependencies = [ | ||
| 1046 | "cfg-if", | ||
| 1047 | "libc", | ||
| 1048 | "redox_syscall", | ||
| 1049 | "smallvec", | ||
| 1050 | "windows-link", | ||
| 1051 | ] | ||
| 1052 | |||
| 1053 | [[package]] | ||
| 1054 | name = "pastey" | ||
| 1055 | version = "0.2.3" | ||
| 1056 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1057 | checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" | ||
| 1058 | |||
| 1059 | [[package]] | ||
| 1060 | name = "percent-encoding" | ||
| 1061 | version = "2.3.2" | ||
| 1062 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1063 | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" | ||
| 1064 | |||
| 1065 | [[package]] | ||
| 1066 | name = "phf" | ||
| 1067 | version = "0.13.1" | ||
| 1068 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1069 | checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" | ||
| 1070 | dependencies = [ | ||
| 1071 | "phf_macros", | ||
| 1072 | "phf_shared", | ||
| 1073 | "serde", | ||
| 1074 | ] | ||
| 1075 | |||
| 1076 | [[package]] | ||
| 1077 | name = "phf_codegen" | ||
| 1078 | version = "0.13.1" | ||
| 1079 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1080 | checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" | ||
| 1081 | dependencies = [ | ||
| 1082 | "phf_generator", | ||
| 1083 | "phf_shared", | ||
| 1084 | ] | ||
| 1085 | |||
| 1086 | [[package]] | ||
| 1087 | name = "phf_generator" | ||
| 1088 | version = "0.13.1" | ||
| 1089 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1090 | checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" | ||
| 1091 | dependencies = [ | ||
| 1092 | "fastrand", | ||
| 1093 | "phf_shared", | ||
| 1094 | ] | ||
| 1095 | |||
| 1096 | [[package]] | ||
| 1097 | name = "phf_macros" | ||
| 1098 | version = "0.13.1" | ||
| 1099 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1100 | checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" | ||
| 1101 | dependencies = [ | ||
| 1102 | "phf_generator", | ||
| 1103 | "phf_shared", | ||
| 1104 | "proc-macro2", | ||
| 1105 | "quote", | ||
| 1106 | "syn 2.0.119", | ||
| 1107 | ] | ||
| 1108 | |||
| 1109 | [[package]] | ||
| 1110 | name = "phf_shared" | ||
| 1111 | version = "0.13.1" | ||
| 1112 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1113 | checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" | ||
| 1114 | dependencies = [ | ||
| 1115 | "siphasher", | ||
| 1116 | ] | ||
| 1117 | |||
| 1118 | [[package]] | ||
| 1119 | name = "pin-project-lite" | ||
| 1120 | version = "0.2.17" | ||
| 1121 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1122 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | ||
| 1123 | |||
| 1124 | [[package]] | ||
| 1125 | name = "pkg-config" | ||
| 1126 | version = "0.3.34" | ||
| 1127 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1128 | checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" | ||
| 1129 | |||
| 1130 | [[package]] | ||
| 1131 | name = "potential_utf" | ||
| 1132 | version = "0.1.6" | ||
| 1133 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1134 | checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" | ||
| 1135 | dependencies = [ | ||
| 1136 | "zerovec", | ||
| 1137 | ] | ||
| 1138 | |||
| 1139 | [[package]] | ||
| 1140 | name = "ppv-lite86" | ||
| 1141 | version = "0.2.21" | ||
| 1142 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1143 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" | ||
| 1144 | dependencies = [ | ||
| 1145 | "zerocopy", | ||
| 1146 | ] | ||
| 1147 | |||
| 1148 | [[package]] | ||
| 1149 | name = "precomputed-hash" | ||
| 1150 | version = "0.1.1" | ||
| 1151 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1152 | checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" | ||
| 1153 | |||
| 1154 | [[package]] | ||
| 1155 | name = "proc-macro2" | ||
| 1156 | version = "1.0.107" | ||
| 1157 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1158 | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | ||
| 1159 | dependencies = [ | ||
| 1160 | "unicode-ident", | ||
| 1161 | ] | ||
| 1162 | |||
| 1163 | [[package]] | ||
| 1164 | name = "quinn" | ||
| 1165 | version = "0.11.12" | ||
| 1166 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1167 | checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" | ||
| 1168 | dependencies = [ | ||
| 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]] | ||
| 1184 | name = "quinn-proto" | ||
| 1185 | version = "0.11.18" | ||
| 1186 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1187 | checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" | ||
| 1188 | dependencies = [ | ||
| 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]] | ||
| 1206 | name = "quinn-udp" | ||
| 1207 | version = "0.5.15" | ||
| 1208 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1209 | checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" | ||
| 1210 | dependencies = [ | ||
| 1211 | "cfg_aliases", | ||
| 1212 | "libc", | ||
| 1213 | "once_cell", | ||
| 1214 | "socket2", | ||
| 1215 | "tracing", | ||
| 1216 | "windows-sys 0.61.2", | ||
| 1217 | ] | ||
| 1218 | |||
| 1219 | [[package]] | ||
| 1220 | name = "quote" | ||
| 1221 | version = "1.0.47" | ||
| 1222 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1223 | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | ||
| 1224 | dependencies = [ | ||
| 1225 | "proc-macro2", | ||
| 1226 | ] | ||
| 1227 | |||
| 1228 | [[package]] | ||
| 1229 | name = "r-efi" | ||
| 1230 | version = "5.3.0" | ||
| 1231 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1232 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" | ||
| 1233 | |||
| 1234 | [[package]] | ||
| 1235 | name = "r-efi" | ||
| 1236 | version = "6.0.0" | ||
| 1237 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1238 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | ||
| 1239 | |||
| 1240 | [[package]] | ||
| 1241 | name = "rand" | ||
| 1242 | version = "0.9.5" | ||
| 1243 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1244 | checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" | ||
| 1245 | dependencies = [ | ||
| 1246 | "rand_chacha", | ||
| 1247 | "rand_core 0.9.5", | ||
| 1248 | ] | ||
| 1249 | |||
| 1250 | [[package]] | ||
| 1251 | name = "rand" | ||
| 1252 | version = "0.10.3" | ||
| 1253 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1254 | checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" | ||
| 1255 | dependencies = [ | ||
| 1256 | "chacha20", | ||
| 1257 | "getrandom 0.4.3", | ||
| 1258 | "rand_core 0.10.1", | ||
| 1259 | ] | ||
| 1260 | |||
| 1261 | [[package]] | ||
| 1262 | name = "rand_chacha" | ||
| 1263 | version = "0.9.0" | ||
| 1264 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1265 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" | ||
| 1266 | dependencies = [ | ||
| 1267 | "ppv-lite86", | ||
| 1268 | "rand_core 0.9.5", | ||
| 1269 | ] | ||
| 1270 | |||
| 1271 | [[package]] | ||
| 1272 | name = "rand_core" | ||
| 1273 | version = "0.9.5" | ||
| 1274 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1275 | checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" | ||
| 1276 | dependencies = [ | ||
| 1277 | "getrandom 0.3.4", | ||
| 1278 | ] | ||
| 1279 | |||
| 1280 | [[package]] | ||
| 1281 | name = "rand_core" | ||
| 1282 | version = "0.10.1" | ||
| 1283 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1284 | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" | ||
| 1285 | |||
| 1286 | [[package]] | ||
| 1287 | name = "rand_pcg" | ||
| 1288 | version = "0.10.2" | ||
| 1289 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1290 | checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" | ||
| 1291 | dependencies = [ | ||
| 1292 | "rand_core 0.10.1", | ||
| 1293 | ] | ||
| 1294 | |||
| 1295 | [[package]] | ||
| 1296 | name = "redox_syscall" | ||
| 1297 | version = "0.5.18" | ||
| 1298 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1299 | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" | ||
| 1300 | dependencies = [ | ||
| 1301 | "bitflags", | ||
| 1302 | ] | ||
| 1303 | |||
| 1304 | [[package]] | ||
| 1305 | name = "ref-cast" | ||
| 1306 | version = "1.0.27" | ||
| 1307 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1308 | checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" | ||
| 1309 | dependencies = [ | ||
| 1310 | "ref-cast-impl", | ||
| 1311 | ] | ||
| 1312 | |||
| 1313 | [[package]] | ||
| 1314 | name = "ref-cast-impl" | ||
| 1315 | version = "1.0.27" | ||
| 1316 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1317 | checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" | ||
| 1318 | dependencies = [ | ||
| 1319 | "proc-macro2", | ||
| 1320 | "quote", | ||
| 1321 | "syn 3.0.6", | ||
| 1322 | ] | ||
| 1323 | |||
| 1324 | [[package]] | ||
| 1325 | name = "regex" | ||
| 1326 | version = "1.13.1" | ||
| 1327 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1328 | checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" | ||
| 1329 | dependencies = [ | ||
| 1330 | "aho-corasick", | ||
| 1331 | "memchr", | ||
| 1332 | "regex-automata", | ||
| 1333 | "regex-syntax", | ||
| 1334 | ] | ||
| 1335 | |||
| 1336 | [[package]] | ||
| 1337 | name = "regex-automata" | ||
| 1338 | version = "0.4.18" | ||
| 1339 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1340 | checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" | ||
| 1341 | dependencies = [ | ||
| 1342 | "aho-corasick", | ||
| 1343 | "memchr", | ||
| 1344 | "regex-syntax", | ||
| 1345 | ] | ||
| 1346 | |||
| 1347 | [[package]] | ||
| 1348 | name = "regex-syntax" | ||
| 1349 | version = "0.8.11" | ||
| 1350 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1351 | checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | ||
| 1352 | |||
| 1353 | [[package]] | ||
| 1354 | name = "reqwest" | ||
| 1355 | version = "0.12.28" | ||
| 1356 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1357 | checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" | ||
| 1358 | dependencies = [ | ||
| 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]] | ||
| 1394 | name = "ring" | ||
| 1395 | version = "0.17.14" | ||
| 1396 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1397 | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" | ||
| 1398 | dependencies = [ | ||
| 1399 | "cc", | ||
| 1400 | "cfg-if", | ||
| 1401 | "getrandom 0.2.17", | ||
| 1402 | "libc", | ||
| 1403 | "untrusted", | ||
| 1404 | "windows-sys 0.52.0", | ||
| 1405 | ] | ||
| 1406 | |||
| 1407 | [[package]] | ||
| 1408 | name = "rmcp" | ||
| 1409 | version = "3.5.0" | ||
| 1410 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1411 | checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" | ||
| 1412 | dependencies = [ | ||
| 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]] | ||
| 1439 | name = "rusqlite" | ||
| 1440 | version = "0.37.0" | ||
| 1441 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1442 | checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" | ||
| 1443 | dependencies = [ | ||
| 1444 | "bitflags", | ||
| 1445 | "fallible-iterator", | ||
| 1446 | "fallible-streaming-iterator", | ||
| 1447 | "hashlink", | ||
| 1448 | "libsqlite3-sys", | ||
| 1449 | "smallvec", | ||
| 1450 | ] | ||
| 1451 | |||
| 1452 | [[package]] | ||
| 1453 | name = "rustc-hash" | ||
| 1454 | version = "2.1.3" | ||
| 1455 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1456 | checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" | ||
| 1457 | |||
| 1458 | [[package]] | ||
| 1459 | name = "rustc_version" | ||
| 1460 | version = "0.4.1" | ||
| 1461 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1462 | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" | ||
| 1463 | dependencies = [ | ||
| 1464 | "semver", | ||
| 1465 | ] | ||
| 1466 | |||
| 1467 | [[package]] | ||
| 1468 | name = "rustls" | ||
| 1469 | version = "0.23.45" | ||
| 1470 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1471 | checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" | ||
| 1472 | dependencies = [ | ||
| 1473 | "once_cell", | ||
| 1474 | "ring", | ||
| 1475 | "rustls-pki-types", | ||
| 1476 | "rustls-webpki", | ||
| 1477 | "subtle", | ||
| 1478 | "zeroize", | ||
| 1479 | ] | ||
| 1480 | |||
| 1481 | [[package]] | ||
| 1482 | name = "rustls-pki-types" | ||
| 1483 | version = "1.15.1" | ||
| 1484 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1485 | checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" | ||
| 1486 | dependencies = [ | ||
| 1487 | "web-time", | ||
| 1488 | "zeroize", | ||
| 1489 | ] | ||
| 1490 | |||
| 1491 | [[package]] | ||
| 1492 | name = "rustls-webpki" | ||
| 1493 | version = "0.103.15" | ||
| 1494 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1495 | checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" | ||
| 1496 | dependencies = [ | ||
| 1497 | "ring", | ||
| 1498 | "rustls-pki-types", | ||
| 1499 | "untrusted", | ||
| 1500 | ] | ||
| 1501 | |||
| 1502 | [[package]] | ||
| 1503 | name = "rustversion" | ||
| 1504 | version = "1.0.23" | ||
| 1505 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1506 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | ||
| 1507 | |||
| 1508 | [[package]] | ||
| 1509 | name = "ryu" | ||
| 1510 | version = "1.0.23" | ||
| 1511 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1512 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | ||
| 1513 | |||
| 1514 | [[package]] | ||
| 1515 | name = "same-file" | ||
| 1516 | version = "1.0.6" | ||
| 1517 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1518 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" | ||
| 1519 | dependencies = [ | ||
| 1520 | "winapi-util", | ||
| 1521 | ] | ||
| 1522 | |||
| 1523 | [[package]] | ||
| 1524 | name = "schemars" | ||
| 1525 | version = "1.2.2" | ||
| 1526 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1527 | checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" | ||
| 1528 | dependencies = [ | ||
| 1529 | "chrono", | ||
| 1530 | "dyn-clone", | ||
| 1531 | "ref-cast", | ||
| 1532 | "schemars_derive", | ||
| 1533 | "serde", | ||
| 1534 | "serde_json", | ||
| 1535 | ] | ||
| 1536 | |||
| 1537 | [[package]] | ||
| 1538 | name = "schemars_derive" | ||
| 1539 | version = "1.2.2" | ||
| 1540 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1541 | checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" | ||
| 1542 | dependencies = [ | ||
| 1543 | "proc-macro2", | ||
| 1544 | "quote", | ||
| 1545 | "serde_derive_internals", | ||
| 1546 | "syn 3.0.6", | ||
| 1547 | ] | ||
| 1548 | |||
| 1549 | [[package]] | ||
| 1550 | name = "scopeguard" | ||
| 1551 | version = "1.2.0" | ||
| 1552 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1553 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | ||
| 1554 | |||
| 1555 | [[package]] | ||
| 1556 | name = "scraper" | ||
| 1557 | version = "0.27.0" | ||
| 1558 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1559 | checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" | ||
| 1560 | dependencies = [ | ||
| 1561 | "cssparser", | ||
| 1562 | "ego-tree", | ||
| 1563 | "html5ever", | ||
| 1564 | "precomputed-hash", | ||
| 1565 | "selectors", | ||
| 1566 | "tendril", | ||
| 1567 | ] | ||
| 1568 | |||
| 1569 | [[package]] | ||
| 1570 | name = "selectors" | ||
| 1571 | version = "0.38.0" | ||
| 1572 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1573 | checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" | ||
| 1574 | dependencies = [ | ||
| 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]] | ||
| 1589 | name = "semver" | ||
| 1590 | version = "1.0.28" | ||
| 1591 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1592 | checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" | ||
| 1593 | |||
| 1594 | [[package]] | ||
| 1595 | name = "serde" | ||
| 1596 | version = "1.0.229" | ||
| 1597 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1598 | checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" | ||
| 1599 | dependencies = [ | ||
| 1600 | "serde_core", | ||
| 1601 | "serde_derive", | ||
| 1602 | ] | ||
| 1603 | |||
| 1604 | [[package]] | ||
| 1605 | name = "serde_core" | ||
| 1606 | version = "1.0.229" | ||
| 1607 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1608 | checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" | ||
| 1609 | dependencies = [ | ||
| 1610 | "serde_derive", | ||
| 1611 | ] | ||
| 1612 | |||
| 1613 | [[package]] | ||
| 1614 | name = "serde_derive" | ||
| 1615 | version = "1.0.229" | ||
| 1616 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1617 | checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" | ||
| 1618 | dependencies = [ | ||
| 1619 | "proc-macro2", | ||
| 1620 | "quote", | ||
| 1621 | "syn 3.0.6", | ||
| 1622 | ] | ||
| 1623 | |||
| 1624 | [[package]] | ||
| 1625 | name = "serde_derive_internals" | ||
| 1626 | version = "0.30.0" | ||
| 1627 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1628 | checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" | ||
| 1629 | dependencies = [ | ||
| 1630 | "proc-macro2", | ||
| 1631 | "quote", | ||
| 1632 | "syn 3.0.6", | ||
| 1633 | ] | ||
| 1634 | |||
| 1635 | [[package]] | ||
| 1636 | name = "serde_json" | ||
| 1637 | version = "1.0.151" | ||
| 1638 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1639 | checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" | ||
| 1640 | dependencies = [ | ||
| 1641 | "itoa", | ||
| 1642 | "memchr", | ||
| 1643 | "serde", | ||
| 1644 | "serde_core", | ||
| 1645 | "zmij", | ||
| 1646 | ] | ||
| 1647 | |||
| 1648 | [[package]] | ||
| 1649 | name = "serde_path_to_error" | ||
| 1650 | version = "0.1.20" | ||
| 1651 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1652 | checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" | ||
| 1653 | dependencies = [ | ||
| 1654 | "itoa", | ||
| 1655 | "serde", | ||
| 1656 | "serde_core", | ||
| 1657 | ] | ||
| 1658 | |||
| 1659 | [[package]] | ||
| 1660 | name = "serde_urlencoded" | ||
| 1661 | version = "0.7.1" | ||
| 1662 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1663 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" | ||
| 1664 | dependencies = [ | ||
| 1665 | "form_urlencoded", | ||
| 1666 | "itoa", | ||
| 1667 | "ryu", | ||
| 1668 | "serde", | ||
| 1669 | ] | ||
| 1670 | |||
| 1671 | [[package]] | ||
| 1672 | name = "servo_arc" | ||
| 1673 | version = "0.4.3" | ||
| 1674 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1675 | checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" | ||
| 1676 | dependencies = [ | ||
| 1677 | "stable_deref_trait", | ||
| 1678 | ] | ||
| 1679 | |||
| 1680 | [[package]] | ||
| 1681 | name = "sha1" | ||
| 1682 | version = "0.10.7" | ||
| 1683 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1684 | checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" | ||
| 1685 | dependencies = [ | ||
| 1686 | "cfg-if", | ||
| 1687 | "cpufeatures 0.2.17", | ||
| 1688 | "digest", | ||
| 1689 | ] | ||
| 1690 | |||
| 1691 | [[package]] | ||
| 1692 | name = "sha2" | ||
| 1693 | version = "0.10.9" | ||
| 1694 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1695 | checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" | ||
| 1696 | dependencies = [ | ||
| 1697 | "cfg-if", | ||
| 1698 | "cpufeatures 0.2.17", | ||
| 1699 | "digest", | ||
| 1700 | ] | ||
| 1701 | |||
| 1702 | [[package]] | ||
| 1703 | name = "shlex" | ||
| 1704 | version = "2.0.1" | ||
| 1705 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1706 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | ||
| 1707 | |||
| 1708 | [[package]] | ||
| 1709 | name = "signal-hook-registry" | ||
| 1710 | version = "1.4.8" | ||
| 1711 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1712 | checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" | ||
| 1713 | dependencies = [ | ||
| 1714 | "errno", | ||
| 1715 | "libc", | ||
| 1716 | ] | ||
| 1717 | |||
| 1718 | [[package]] | ||
| 1719 | name = "simdutf8" | ||
| 1720 | version = "0.1.5" | ||
| 1721 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1722 | checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" | ||
| 1723 | |||
| 1724 | [[package]] | ||
| 1725 | name = "siphasher" | ||
| 1726 | version = "1.0.4" | ||
| 1727 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1728 | checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" | ||
| 1729 | |||
| 1730 | [[package]] | ||
| 1731 | name = "slab" | ||
| 1732 | version = "0.4.12" | ||
| 1733 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1734 | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | ||
| 1735 | |||
| 1736 | [[package]] | ||
| 1737 | name = "smallvec" | ||
| 1738 | version = "1.16.2" | ||
| 1739 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1740 | checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" | ||
| 1741 | |||
| 1742 | [[package]] | ||
| 1743 | name = "socket2" | ||
| 1744 | version = "0.6.5" | ||
| 1745 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1746 | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | ||
| 1747 | dependencies = [ | ||
| 1748 | "libc", | ||
| 1749 | "windows-sys 0.61.2", | ||
| 1750 | ] | ||
| 1751 | |||
| 1752 | [[package]] | ||
| 1753 | name = "spin" | ||
| 1754 | version = "0.9.9" | ||
| 1755 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1756 | checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" | ||
| 1757 | |||
| 1758 | [[package]] | ||
| 1759 | name = "sse-stream" | ||
| 1760 | version = "0.2.6" | ||
| 1761 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1762 | checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" | ||
| 1763 | dependencies = [ | ||
| 1764 | "bytes", | ||
| 1765 | "futures-util", | ||
| 1766 | "http-body", | ||
| 1767 | "http-body-util", | ||
| 1768 | "pin-project-lite", | ||
| 1769 | ] | ||
| 1770 | |||
| 1771 | [[package]] | ||
| 1772 | name = "stable_deref_trait" | ||
| 1773 | version = "1.2.1" | ||
| 1774 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1775 | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" | ||
| 1776 | |||
| 1777 | [[package]] | ||
| 1778 | name = "string_cache" | ||
| 1779 | version = "0.9.0" | ||
| 1780 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1781 | checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" | ||
| 1782 | dependencies = [ | ||
| 1783 | "new_debug_unreachable", | ||
| 1784 | "parking_lot", | ||
| 1785 | "phf_shared", | ||
| 1786 | "precomputed-hash", | ||
| 1787 | ] | ||
| 1788 | |||
| 1789 | [[package]] | ||
| 1790 | name = "string_cache_codegen" | ||
| 1791 | version = "0.6.1" | ||
| 1792 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1793 | checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" | ||
| 1794 | dependencies = [ | ||
| 1795 | "phf_generator", | ||
| 1796 | "phf_shared", | ||
| 1797 | "proc-macro2", | ||
| 1798 | "quote", | ||
| 1799 | ] | ||
| 1800 | |||
| 1801 | [[package]] | ||
| 1802 | name = "subtle" | ||
| 1803 | version = "2.6.1" | ||
| 1804 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1805 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" | ||
| 1806 | |||
| 1807 | [[package]] | ||
| 1808 | name = "syn" | ||
| 1809 | version = "2.0.119" | ||
| 1810 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1811 | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | ||
| 1812 | dependencies = [ | ||
| 1813 | "proc-macro2", | ||
| 1814 | "quote", | ||
| 1815 | "unicode-ident", | ||
| 1816 | ] | ||
| 1817 | |||
| 1818 | [[package]] | ||
| 1819 | name = "syn" | ||
| 1820 | version = "3.0.6" | ||
| 1821 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1822 | checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" | ||
| 1823 | dependencies = [ | ||
| 1824 | "proc-macro2", | ||
| 1825 | "quote", | ||
| 1826 | "unicode-ident", | ||
| 1827 | ] | ||
| 1828 | |||
| 1829 | [[package]] | ||
| 1830 | name = "sync_wrapper" | ||
| 1831 | version = "1.0.2" | ||
| 1832 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1833 | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" | ||
| 1834 | dependencies = [ | ||
| 1835 | "futures-core", | ||
| 1836 | ] | ||
| 1837 | |||
| 1838 | [[package]] | ||
| 1839 | name = "synstructure" | ||
| 1840 | version = "0.14.0" | ||
| 1841 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1842 | checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" | ||
| 1843 | dependencies = [ | ||
| 1844 | "proc-macro2", | ||
| 1845 | "quote", | ||
| 1846 | "syn 3.0.6", | ||
| 1847 | ] | ||
| 1848 | |||
| 1849 | [[package]] | ||
| 1850 | name = "tendril" | ||
| 1851 | version = "0.5.1" | ||
| 1852 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1853 | checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" | ||
| 1854 | dependencies = [ | ||
| 1855 | "new_debug_unreachable", | ||
| 1856 | ] | ||
| 1857 | |||
| 1858 | [[package]] | ||
| 1859 | name = "thiserror" | ||
| 1860 | version = "2.0.21" | ||
| 1861 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1862 | checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" | ||
| 1863 | dependencies = [ | ||
| 1864 | "thiserror-impl", | ||
| 1865 | ] | ||
| 1866 | |||
| 1867 | [[package]] | ||
| 1868 | name = "thiserror-impl" | ||
| 1869 | version = "2.0.21" | ||
| 1870 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1871 | checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" | ||
| 1872 | dependencies = [ | ||
| 1873 | "proc-macro2", | ||
| 1874 | "quote", | ||
| 1875 | "syn 3.0.6", | ||
| 1876 | ] | ||
| 1877 | |||
| 1878 | [[package]] | ||
| 1879 | name = "tinystr" | ||
| 1880 | version = "0.8.4" | ||
| 1881 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1882 | checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" | ||
| 1883 | dependencies = [ | ||
| 1884 | "displaydoc", | ||
| 1885 | "zerovec", | ||
| 1886 | ] | ||
| 1887 | |||
| 1888 | [[package]] | ||
| 1889 | name = "tinyvec" | ||
| 1890 | version = "1.13.3" | ||
| 1891 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1892 | checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" | ||
| 1893 | |||
| 1894 | [[package]] | ||
| 1895 | name = "tokio" | ||
| 1896 | version = "1.53.1" | ||
| 1897 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1898 | checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" | ||
| 1899 | dependencies = [ | ||
| 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]] | ||
| 1912 | name = "tokio-macros" | ||
| 1913 | version = "2.7.2" | ||
| 1914 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1915 | checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" | ||
| 1916 | dependencies = [ | ||
| 1917 | "proc-macro2", | ||
| 1918 | "quote", | ||
| 1919 | "syn 3.0.6", | ||
| 1920 | ] | ||
| 1921 | |||
| 1922 | [[package]] | ||
| 1923 | name = "tokio-rustls" | ||
| 1924 | version = "0.26.6" | ||
| 1925 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1926 | checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" | ||
| 1927 | dependencies = [ | ||
| 1928 | "rustls", | ||
| 1929 | "tokio", | ||
| 1930 | ] | ||
| 1931 | |||
| 1932 | [[package]] | ||
| 1933 | name = "tokio-stream" | ||
| 1934 | version = "0.1.19" | ||
| 1935 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1936 | checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" | ||
| 1937 | dependencies = [ | ||
| 1938 | "futures-core", | ||
| 1939 | "pin-project-lite", | ||
| 1940 | "tokio", | ||
| 1941 | "tokio-util", | ||
| 1942 | ] | ||
| 1943 | |||
| 1944 | [[package]] | ||
| 1945 | name = "tokio-tungstenite" | ||
| 1946 | version = "0.29.0" | ||
| 1947 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1948 | checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" | ||
| 1949 | dependencies = [ | ||
| 1950 | "futures-util", | ||
| 1951 | "log", | ||
| 1952 | "tokio", | ||
| 1953 | "tungstenite", | ||
| 1954 | ] | ||
| 1955 | |||
| 1956 | [[package]] | ||
| 1957 | name = "tokio-util" | ||
| 1958 | version = "0.7.19" | ||
| 1959 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1960 | checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" | ||
| 1961 | dependencies = [ | ||
| 1962 | "bytes", | ||
| 1963 | "futures-core", | ||
| 1964 | "futures-sink", | ||
| 1965 | "libc", | ||
| 1966 | "pin-project-lite", | ||
| 1967 | "tokio", | ||
| 1968 | ] | ||
| 1969 | |||
| 1970 | [[package]] | ||
| 1971 | name = "tower" | ||
| 1972 | version = "0.5.3" | ||
| 1973 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1974 | checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" | ||
| 1975 | dependencies = [ | ||
| 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]] | ||
| 1987 | name = "tower-http" | ||
| 1988 | version = "0.6.11" | ||
| 1989 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1990 | checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" | ||
| 1991 | dependencies = [ | ||
| 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]] | ||
| 2014 | name = "tower-layer" | ||
| 2015 | version = "0.3.3" | ||
| 2016 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2017 | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" | ||
| 2018 | |||
| 2019 | [[package]] | ||
| 2020 | name = "tower-service" | ||
| 2021 | version = "0.3.3" | ||
| 2022 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2023 | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" | ||
| 2024 | |||
| 2025 | [[package]] | ||
| 2026 | name = "tracing" | ||
| 2027 | version = "0.1.44" | ||
| 2028 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2029 | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" | ||
| 2030 | dependencies = [ | ||
| 2031 | "log", | ||
| 2032 | "pin-project-lite", | ||
| 2033 | "tracing-attributes", | ||
| 2034 | "tracing-core", | ||
| 2035 | ] | ||
| 2036 | |||
| 2037 | [[package]] | ||
| 2038 | name = "tracing-attributes" | ||
| 2039 | version = "0.1.31" | ||
| 2040 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2041 | checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" | ||
| 2042 | dependencies = [ | ||
| 2043 | "proc-macro2", | ||
| 2044 | "quote", | ||
| 2045 | "syn 2.0.119", | ||
| 2046 | ] | ||
| 2047 | |||
| 2048 | [[package]] | ||
| 2049 | name = "tracing-core" | ||
| 2050 | version = "0.1.36" | ||
| 2051 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2052 | checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" | ||
| 2053 | dependencies = [ | ||
| 2054 | "once_cell", | ||
| 2055 | ] | ||
| 2056 | |||
| 2057 | [[package]] | ||
| 2058 | name = "try-lock" | ||
| 2059 | version = "0.2.5" | ||
| 2060 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2061 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | ||
| 2062 | |||
| 2063 | [[package]] | ||
| 2064 | name = "tungstenite" | ||
| 2065 | version = "0.29.0" | ||
| 2066 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2067 | checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" | ||
| 2068 | dependencies = [ | ||
| 2069 | "bytes", | ||
| 2070 | "data-encoding", | ||
| 2071 | "http", | ||
| 2072 | "httparse", | ||
| 2073 | "log", | ||
| 2074 | "rand 0.9.5", | ||
| 2075 | "sha1", | ||
| 2076 | "thiserror", | ||
| 2077 | ] | ||
| 2078 | |||
| 2079 | [[package]] | ||
| 2080 | name = "typenum" | ||
| 2081 | version = "1.20.1" | ||
| 2082 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2083 | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" | ||
| 2084 | |||
| 2085 | [[package]] | ||
| 2086 | name = "unicase" | ||
| 2087 | version = "2.9.0" | ||
| 2088 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2089 | checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" | ||
| 2090 | |||
| 2091 | [[package]] | ||
| 2092 | name = "unicode-ident" | ||
| 2093 | version = "1.0.26" | ||
| 2094 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2095 | checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" | ||
| 2096 | |||
| 2097 | [[package]] | ||
| 2098 | name = "untrusted" | ||
| 2099 | version = "0.9.0" | ||
| 2100 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2101 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" | ||
| 2102 | |||
| 2103 | [[package]] | ||
| 2104 | name = "url" | ||
| 2105 | version = "2.5.8" | ||
| 2106 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2107 | checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" | ||
| 2108 | dependencies = [ | ||
| 2109 | "form_urlencoded", | ||
| 2110 | "idna", | ||
| 2111 | "percent-encoding", | ||
| 2112 | "serde", | ||
| 2113 | ] | ||
| 2114 | |||
| 2115 | [[package]] | ||
| 2116 | name = "utf8_iter" | ||
| 2117 | version = "1.0.4" | ||
| 2118 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2119 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | ||
| 2120 | |||
| 2121 | [[package]] | ||
| 2122 | name = "uuid" | ||
| 2123 | version = "1.26.1" | ||
| 2124 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2125 | checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" | ||
| 2126 | dependencies = [ | ||
| 2127 | "getrandom 0.4.3", | ||
| 2128 | "js-sys", | ||
| 2129 | "wasm-bindgen", | ||
| 2130 | ] | ||
| 2131 | |||
| 2132 | [[package]] | ||
| 2133 | name = "vcpkg" | ||
| 2134 | version = "0.2.15" | ||
| 2135 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2136 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | ||
| 2137 | |||
| 2138 | [[package]] | ||
| 2139 | name = "version_check" | ||
| 2140 | version = "0.9.5" | ||
| 2141 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2142 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | ||
| 2143 | |||
| 2144 | [[package]] | ||
| 2145 | name = "walkdir" | ||
| 2146 | version = "2.5.0" | ||
| 2147 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2148 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" | ||
| 2149 | dependencies = [ | ||
| 2150 | "same-file", | ||
| 2151 | "winapi-util", | ||
| 2152 | ] | ||
| 2153 | |||
| 2154 | [[package]] | ||
| 2155 | name = "want" | ||
| 2156 | version = "0.3.1" | ||
| 2157 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2158 | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" | ||
| 2159 | dependencies = [ | ||
| 2160 | "try-lock", | ||
| 2161 | ] | ||
| 2162 | |||
| 2163 | [[package]] | ||
| 2164 | name = "wasi" | ||
| 2165 | version = "0.11.1+wasi-snapshot-preview1" | ||
| 2166 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2167 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" | ||
| 2168 | |||
| 2169 | [[package]] | ||
| 2170 | name = "wasip2" | ||
| 2171 | version = "1.0.4+wasi-0.2.12" | ||
| 2172 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2173 | checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" | ||
| 2174 | dependencies = [ | ||
| 2175 | "wit-bindgen", | ||
| 2176 | ] | ||
| 2177 | |||
| 2178 | [[package]] | ||
| 2179 | name = "wasm-bindgen" | ||
| 2180 | version = "0.2.129" | ||
| 2181 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2182 | checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" | ||
| 2183 | dependencies = [ | ||
| 2184 | "cfg-if", | ||
| 2185 | "once_cell", | ||
| 2186 | "rustversion", | ||
| 2187 | "wasm-bindgen-macro", | ||
| 2188 | "wasm-bindgen-shared", | ||
| 2189 | ] | ||
| 2190 | |||
| 2191 | [[package]] | ||
| 2192 | name = "wasm-bindgen-futures" | ||
| 2193 | version = "0.4.79" | ||
| 2194 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2195 | checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" | ||
| 2196 | dependencies = [ | ||
| 2197 | "js-sys", | ||
| 2198 | "tokio", | ||
| 2199 | "wasm-bindgen", | ||
| 2200 | ] | ||
| 2201 | |||
| 2202 | [[package]] | ||
| 2203 | name = "wasm-bindgen-macro" | ||
| 2204 | version = "0.2.129" | ||
| 2205 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2206 | checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" | ||
| 2207 | dependencies = [ | ||
| 2208 | "quote", | ||
| 2209 | "wasm-bindgen-macro-support", | ||
| 2210 | ] | ||
| 2211 | |||
| 2212 | [[package]] | ||
| 2213 | name = "wasm-bindgen-macro-support" | ||
| 2214 | version = "0.2.129" | ||
| 2215 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2216 | checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" | ||
| 2217 | dependencies = [ | ||
| 2218 | "bumpalo", | ||
| 2219 | "proc-macro2", | ||
| 2220 | "quote", | ||
| 2221 | "syn 3.0.6", | ||
| 2222 | "wasm-bindgen-shared", | ||
| 2223 | ] | ||
| 2224 | |||
| 2225 | [[package]] | ||
| 2226 | name = "wasm-bindgen-shared" | ||
| 2227 | version = "0.2.129" | ||
| 2228 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2229 | checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" | ||
| 2230 | dependencies = [ | ||
| 2231 | "unicode-ident", | ||
| 2232 | ] | ||
| 2233 | |||
| 2234 | [[package]] | ||
| 2235 | name = "web-sys" | ||
| 2236 | version = "0.3.106" | ||
| 2237 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2238 | checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" | ||
| 2239 | dependencies = [ | ||
| 2240 | "js-sys", | ||
| 2241 | "wasm-bindgen", | ||
| 2242 | ] | ||
| 2243 | |||
| 2244 | [[package]] | ||
| 2245 | name = "web-time" | ||
| 2246 | version = "1.1.0" | ||
| 2247 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2248 | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" | ||
| 2249 | dependencies = [ | ||
| 2250 | "js-sys", | ||
| 2251 | "wasm-bindgen", | ||
| 2252 | ] | ||
| 2253 | |||
| 2254 | [[package]] | ||
| 2255 | name = "web_atoms" | ||
| 2256 | version = "0.2.6" | ||
| 2257 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2258 | checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" | ||
| 2259 | dependencies = [ | ||
| 2260 | "phf", | ||
| 2261 | "phf_codegen", | ||
| 2262 | "string_cache", | ||
| 2263 | "string_cache_codegen", | ||
| 2264 | ] | ||
| 2265 | |||
| 2266 | [[package]] | ||
| 2267 | name = "webpki-roots" | ||
| 2268 | version = "1.0.9" | ||
| 2269 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2270 | checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" | ||
| 2271 | dependencies = [ | ||
| 2272 | "rustls-pki-types", | ||
| 2273 | ] | ||
| 2274 | |||
| 2275 | [[package]] | ||
| 2276 | name = "winapi-util" | ||
| 2277 | version = "0.1.11" | ||
| 2278 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2279 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | ||
| 2280 | dependencies = [ | ||
| 2281 | "windows-sys 0.61.2", | ||
| 2282 | ] | ||
| 2283 | |||
| 2284 | [[package]] | ||
| 2285 | name = "windows-core" | ||
| 2286 | version = "0.62.2" | ||
| 2287 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2288 | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | ||
| 2289 | dependencies = [ | ||
| 2290 | "windows-implement", | ||
| 2291 | "windows-interface", | ||
| 2292 | "windows-link", | ||
| 2293 | "windows-result", | ||
| 2294 | "windows-strings", | ||
| 2295 | ] | ||
| 2296 | |||
| 2297 | [[package]] | ||
| 2298 | name = "windows-implement" | ||
| 2299 | version = "0.60.2" | ||
| 2300 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2301 | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | ||
| 2302 | dependencies = [ | ||
| 2303 | "proc-macro2", | ||
| 2304 | "quote", | ||
| 2305 | "syn 2.0.119", | ||
| 2306 | ] | ||
| 2307 | |||
| 2308 | [[package]] | ||
| 2309 | name = "windows-interface" | ||
| 2310 | version = "0.59.3" | ||
| 2311 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2312 | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | ||
| 2313 | dependencies = [ | ||
| 2314 | "proc-macro2", | ||
| 2315 | "quote", | ||
| 2316 | "syn 2.0.119", | ||
| 2317 | ] | ||
| 2318 | |||
| 2319 | [[package]] | ||
| 2320 | name = "windows-link" | ||
| 2321 | version = "0.2.1" | ||
| 2322 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2323 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | ||
| 2324 | |||
| 2325 | [[package]] | ||
| 2326 | name = "windows-result" | ||
| 2327 | version = "0.4.1" | ||
| 2328 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2329 | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | ||
| 2330 | dependencies = [ | ||
| 2331 | "windows-link", | ||
| 2332 | ] | ||
| 2333 | |||
| 2334 | [[package]] | ||
| 2335 | name = "windows-strings" | ||
| 2336 | version = "0.5.1" | ||
| 2337 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2338 | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | ||
| 2339 | dependencies = [ | ||
| 2340 | "windows-link", | ||
| 2341 | ] | ||
| 2342 | |||
| 2343 | [[package]] | ||
| 2344 | name = "windows-sys" | ||
| 2345 | version = "0.52.0" | ||
| 2346 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2347 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" | ||
| 2348 | dependencies = [ | ||
| 2349 | "windows-targets", | ||
| 2350 | ] | ||
| 2351 | |||
| 2352 | [[package]] | ||
| 2353 | name = "windows-sys" | ||
| 2354 | version = "0.61.2" | ||
| 2355 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2356 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | ||
| 2357 | dependencies = [ | ||
| 2358 | "windows-link", | ||
| 2359 | ] | ||
| 2360 | |||
| 2361 | [[package]] | ||
| 2362 | name = "windows-targets" | ||
| 2363 | version = "0.52.6" | ||
| 2364 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2365 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" | ||
| 2366 | dependencies = [ | ||
| 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]] | ||
| 2378 | name = "windows_aarch64_gnullvm" | ||
| 2379 | version = "0.52.6" | ||
| 2380 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2381 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" | ||
| 2382 | |||
| 2383 | [[package]] | ||
| 2384 | name = "windows_aarch64_msvc" | ||
| 2385 | version = "0.52.6" | ||
| 2386 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2387 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" | ||
| 2388 | |||
| 2389 | [[package]] | ||
| 2390 | name = "windows_i686_gnu" | ||
| 2391 | version = "0.52.6" | ||
| 2392 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2393 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" | ||
| 2394 | |||
| 2395 | [[package]] | ||
| 2396 | name = "windows_i686_gnullvm" | ||
| 2397 | version = "0.52.6" | ||
| 2398 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2399 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" | ||
| 2400 | |||
| 2401 | [[package]] | ||
| 2402 | name = "windows_i686_msvc" | ||
| 2403 | version = "0.52.6" | ||
| 2404 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2405 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" | ||
| 2406 | |||
| 2407 | [[package]] | ||
| 2408 | name = "windows_x86_64_gnu" | ||
| 2409 | version = "0.52.6" | ||
| 2410 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2411 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" | ||
| 2412 | |||
| 2413 | [[package]] | ||
| 2414 | name = "windows_x86_64_gnullvm" | ||
| 2415 | version = "0.52.6" | ||
| 2416 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2417 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" | ||
| 2418 | |||
| 2419 | [[package]] | ||
| 2420 | name = "windows_x86_64_msvc" | ||
| 2421 | version = "0.52.6" | ||
| 2422 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2423 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" | ||
| 2424 | |||
| 2425 | [[package]] | ||
| 2426 | name = "wit-bindgen" | ||
| 2427 | version = "0.57.1" | ||
| 2428 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2429 | checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" | ||
| 2430 | |||
| 2431 | [[package]] | ||
| 2432 | name = "writeable" | ||
| 2433 | version = "0.6.4" | ||
| 2434 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2435 | checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" | ||
| 2436 | |||
| 2437 | [[package]] | ||
| 2438 | name = "yoke" | ||
| 2439 | version = "0.8.3" | ||
| 2440 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2441 | checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" | ||
| 2442 | dependencies = [ | ||
| 2443 | "stable_deref_trait", | ||
| 2444 | "yoke-derive", | ||
| 2445 | "zerofrom", | ||
| 2446 | ] | ||
| 2447 | |||
| 2448 | [[package]] | ||
| 2449 | name = "yoke-derive" | ||
| 2450 | version = "0.8.3" | ||
| 2451 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2452 | checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" | ||
| 2453 | dependencies = [ | ||
| 2454 | "proc-macro2", | ||
| 2455 | "quote", | ||
| 2456 | "syn 3.0.6", | ||
| 2457 | "synstructure", | ||
| 2458 | ] | ||
| 2459 | |||
| 2460 | [[package]] | ||
| 2461 | name = "zerocopy" | ||
| 2462 | version = "0.8.59" | ||
| 2463 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2464 | checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" | ||
| 2465 | dependencies = [ | ||
| 2466 | "zerocopy-derive", | ||
| 2467 | ] | ||
| 2468 | |||
| 2469 | [[package]] | ||
| 2470 | name = "zerocopy-derive" | ||
| 2471 | version = "0.8.59" | ||
| 2472 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2473 | checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" | ||
| 2474 | dependencies = [ | ||
| 2475 | "proc-macro2", | ||
| 2476 | "quote", | ||
| 2477 | "syn 2.0.119", | ||
| 2478 | ] | ||
| 2479 | |||
| 2480 | [[package]] | ||
| 2481 | name = "zerofrom" | ||
| 2482 | version = "0.1.8" | ||
| 2483 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2484 | checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" | ||
| 2485 | dependencies = [ | ||
| 2486 | "zerofrom-derive", | ||
| 2487 | ] | ||
| 2488 | |||
| 2489 | [[package]] | ||
| 2490 | name = "zerofrom-derive" | ||
| 2491 | version = "0.1.8" | ||
| 2492 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2493 | checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" | ||
| 2494 | dependencies = [ | ||
| 2495 | "proc-macro2", | ||
| 2496 | "quote", | ||
| 2497 | "syn 3.0.6", | ||
| 2498 | "synstructure", | ||
| 2499 | ] | ||
| 2500 | |||
| 2501 | [[package]] | ||
| 2502 | name = "zeroize" | ||
| 2503 | version = "1.9.0" | ||
| 2504 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2505 | checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" | ||
| 2506 | |||
| 2507 | [[package]] | ||
| 2508 | name = "zerotrie" | ||
| 2509 | version = "0.2.5" | ||
| 2510 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2511 | checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" | ||
| 2512 | dependencies = [ | ||
| 2513 | "displaydoc", | ||
| 2514 | "yoke", | ||
| 2515 | "zerofrom", | ||
| 2516 | ] | ||
| 2517 | |||
| 2518 | [[package]] | ||
| 2519 | name = "zerovec" | ||
| 2520 | version = "0.11.8" | ||
| 2521 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2522 | checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" | ||
| 2523 | dependencies = [ | ||
| 2524 | "yoke", | ||
| 2525 | "zerofrom", | ||
| 2526 | "zerovec-derive", | ||
| 2527 | ] | ||
| 2528 | |||
| 2529 | [[package]] | ||
| 2530 | name = "zerovec-derive" | ||
| 2531 | version = "0.11.6" | ||
| 2532 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2533 | checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" | ||
| 2534 | dependencies = [ | ||
| 2535 | "proc-macro2", | ||
| 2536 | "quote", | ||
| 2537 | "syn 3.0.6", | ||
| 2538 | ] | ||
| 2539 | |||
| 2540 | [[package]] | ||
| 2541 | name = "zmij" | ||
| 2542 | version = "1.0.23" | ||
| 2543 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2544 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | ||
dashboard/Cargo.toml created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | [package] | ||
| 2 | name = "home-dashboard" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | |||
| 6 | [dependencies] | ||
| 7 | axum = { version = "0.8.9", features = ["multipart", "ws"] } | ||
| 8 | base64 = "0.22" | ||
| 9 | bytes = "1" | ||
| 10 | chrono = "0.4" | ||
| 11 | futures = "0.3" | ||
| 12 | globset = "0.4" | ||
| 13 | rand = "0.9" | ||
| 14 | regex = "1" | ||
| 15 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] } | ||
| 16 | rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] } | ||
| 17 | rusqlite = { version = "0.37", features = ["bundled"] } | ||
| 18 | scraper = { version = "0.27", default-features = false } | ||
| 19 | serde_json = "1" | ||
| 20 | sha1 = "0.10" | ||
| 21 | sha2 = "0.10.9" | ||
| 22 | subtle = "2.6" | ||
| 23 | tokio = { version = "1", features = ["full"] } | ||
| 24 | tokio-stream = { version = "0.1", features = ["sync"] } | ||
| 25 | tower-http = { version = "0.6", features = ["fs"] } | ||
| 26 | url = "2" | ||
| 27 | uuid = { version = "1", features = ["v4"] } | ||
| 28 | walkdir = "2" | ||
| 29 | |||
| 30 | [profile.release] | ||
| 31 | lto = "thin" | ||
| 32 | codegen-units = 1 | ||
| 33 | strip = true | ||
dashboard/dev.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import { execFile, spawn } from "node:child_process"; | ||
| 2 | import { watch } from "node:fs"; | ||
| 3 | import { promisify } from "node:util"; | ||
| 4 | |||
| 5 | const host = process.env.STUDIO_DEPLOY_HOST ?? "root@127.0.0.1"; | ||
| 6 | const ssh = ["-p", process.env.STUDIO_DEPLOY_PORT ?? "2222", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"]; | ||
| 7 | const dir = "/root/studio-dashboard-dev"; | ||
| 8 | const run = promisify(execFile); | ||
| 9 | const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'"; | ||
| 10 | const restart = `import os, shlex, subprocess | ||
| 11 | variables = dict(item.split("=", 1) for item in shlex.split(subprocess.check_output(["systemctl", "show", "-P", "Environment", "studio-dashboard"], text=True))) | ||
| 12 | for key in ["STUDIO_INDEX_POOL", "STUDIO_YT_STATE", "STUDIO_FILES_WRITABLE"]: variables.pop(key, None) | ||
| 13 | variables.update(PORT="7070", STUDIO_METRICS="follow", STUDIO_DATA_DIR="${dir}/data") | ||
| 14 | subprocess.run(["systemctl", "stop", "studio-dashboard-dev"], check=False) | ||
| 15 | subprocess.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)`; | ||
| 16 | let pending = false; | ||
| 17 | let rebuilding = false; | ||
| 18 | async 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 | } | ||
| 31 | await rebuild(); | ||
| 32 | watch("src", { recursive: true }, () => rebuild().catch(console.error)); | ||
| 33 | const tunnel = spawn("ssh", [...ssh, "-N", "-o", "ExitOnForwardFailure=yes", "-L", "7070:127.0.0.1:7070", host], { stdio: "inherit" }); | ||
| 34 | tunnel.on("exit", code => process.exit(code ?? 1)); | ||
| 35 | for (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 @@ | ||
| 1 | lockfileVersion: '9.0' | ||
| 2 | |||
| 3 | settings: | ||
| 4 | autoInstallPeers: true | ||
| 5 | excludeLinksFromLockfile: false | ||
| 6 | |||
| 7 | importers: | ||
| 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 | |||
| 43 | packages: | ||
| 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 | |||
| 941 | snapshots: | ||
| 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 | ||
| 2 | import sys | ||
| 3 | import json | ||
| 4 | import os | ||
| 5 | import queue | ||
| 6 | import re | ||
| 7 | import smtplib | ||
| 8 | import subprocess | ||
| 9 | import threading | ||
| 10 | import time | ||
| 11 | import urllib.request | ||
| 12 | import xml.etree.ElementTree as ET | ||
| 13 | from email.message import EmailMessage | ||
| 14 | from email.utils import formatdate | ||
| 15 | from html import unescape | ||
| 16 | from xml.sax.saxutils import escape | ||
| 17 | |||
| 18 | import yaml | ||
| 19 | |||
| 20 | os.umask(0o007) | ||
| 21 | |||
| 22 | YT_CONFIG_DIR = os.environ.get("STUDIO_YT_CONFIG", "/srv/clover/Documents/Config/Youtube Downloader") | ||
| 23 | FEED_CONFIG = os.path.join(YT_CONFIG_DIR, "feed.yaml") | ||
| 24 | STATE_DIR = os.environ.get("STUDIO_YT_STATE", "/srv/prod/yt-feed/data") | ||
| 25 | INTERVAL = int(os.environ.get("CHECK_INTERVAL", "1800")) | ||
| 26 | SMTP_HOST = os.environ.get("SMTP_HOST", "") | ||
| 27 | SMTP_PORT = int(os.environ.get("SMTP_PORT", "465")) | ||
| 28 | SMTP_USER = os.environ.get("SMTP_USER", "") | ||
| 29 | SMTP_PASS = os.environ.get("SMTP_PASS", "") | ||
| 30 | MAIL_FROM = os.environ.get("MAIL_FROM", f"yt-feed@{os.environ.get('STUDIO_DOMAIN', 'studio.test')}") | ||
| 31 | MAIL_TO = os.environ.get("MAIL_TO", os.environ.get("STUDIO_OWNER_EMAIL", "account@paperclover.net")) | ||
| 32 | BASE_URL = os.environ.get("BASE_URL", f"https://globe.{os.environ.get('STUDIO_DOMAIN', 'studio.test')}/youtube").rstrip("/") | ||
| 33 | READ_ONLY = os.environ.get("STUDIO_MEDIA_READ_ONLY") == "true" | ||
| 34 | |||
| 35 | MEDIA_ROOT = os.environ.get("STUDIO_YT_MEDIA", "/srv/clover/Media") | ||
| 36 | INDIE_DIR = os.path.join(MEDIA_ROOT, "jellyfin/Indie Shows") | ||
| 37 | INDEP_DIR = os.path.join(MEDIA_ROOT, "jellyfin/Independent") | ||
| 38 | MUSIC_DIR = os.path.join(MEDIA_ROOT, "music_intake") | ||
| 39 | VIDEO_EXTS = (".webm", ".mp4", ".mkv") | ||
| 40 | |||
| 41 | ATOM = "{http://www.w3.org/2005/Atom}" | ||
| 42 | YT = "{http://www.youtube.com/xml/schemas/2015}" | ||
| 43 | MEDIA = "{http://search.yahoo.com/mrss/}" | ||
| 44 | UA = {"User-Agent": "Mozilla/5.0 (The Snow Globe; +https://paperclover.net)"} | ||
| 45 | SEEN_CAP = 300 | ||
| 46 | |||
| 47 | state_lock = threading.Lock() | ||
| 48 | job_queue = queue.Queue() | ||
| 49 | shows_cache = [] | ||
| 50 | |||
| 51 | # RSS keeps working during a YouTube bot wall, so probing can resume downloads later. | ||
| 52 | WALL_RE = re.compile(r"confirm you.re not a bot", re.I) | ||
| 53 | WALL_PROBE_INTERVAL = int(os.environ.get("WALL_PROBE_INTERVAL", "10800")) | ||
| 54 | PROBE_VIDEO = "https://www.youtube.com/watch?v=jNQXAC9IVRw" | ||
| 55 | |||
| 56 | |||
| 57 | class WallError(Exception): | ||
| 58 | pass | ||
| 59 | |||
| 60 | |||
| 61 | def wall_active(): | ||
| 62 | return load_json("wall.json", {}).get("walled", False) | ||
| 63 | |||
| 64 | |||
| 65 | def 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 | |||
| 71 | def 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 | |||
| 86 | def 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 | |||
| 95 | def log(msg): | ||
| 96 | print(msg, file=sys.stderr, flush=True) | ||
| 97 | |||
| 98 | |||
| 99 | def 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 | |||
| 105 | def state_path(name): | ||
| 106 | return os.path.join(STATE_DIR, name) | ||
| 107 | |||
| 108 | |||
| 109 | def 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 | |||
| 117 | def 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 | |||
| 124 | def safe_name(name): | ||
| 125 | return re.sub(r'[/\\:*?"<>|]', "-", name).strip(" .") or "untitled" | ||
| 126 | |||
| 127 | |||
| 128 | def stable_key(v): | ||
| 129 | return v.get("stub_id") or v["id"] | ||
| 130 | |||
| 131 | |||
| 132 | def safe_listdir(p): | ||
| 133 | try: | ||
| 134 | return os.listdir(p) | ||
| 135 | except OSError: | ||
| 136 | return [] | ||
| 137 | |||
| 138 | |||
| 139 | def 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 | |||
| 149 | def 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 | |||
| 193 | def 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 | |||
| 240 | def 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 | |||
| 255 | def 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 | |||
| 276 | def 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 | |||
| 308 | def 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 | |||
| 346 | def 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 | |||
| 355 | def 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 | |||
| 364 | def 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 | |||
| 378 | def 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 | |||
| 397 | def 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 | |||
| 404 | def 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 | |||
| 419 | def 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 | |||
| 484 | def 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 | |||
| 502 | def writable(): | ||
| 503 | if READ_ONLY: | ||
| 504 | raise PermissionError("Media is read-only on this host") | ||
| 505 | |||
| 506 | |||
| 507 | def 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 | |||
| 529 | def 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 | |||
| 664 | def 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 | |||
| 672 | def progress(value): | ||
| 673 | try: | ||
| 674 | return float(str(value).rstrip("%")) / 100 | ||
| 675 | except ValueError: | ||
| 676 | return None | ||
| 677 | |||
| 678 | |||
| 679 | def 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 | |||
| 695 | def 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 | |||
| 707 | if __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 @@ | ||
| 1 | use crate::*; | ||
| 2 | |||
| 3 | pub 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 | } | ||
| 18 | fn 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 | } | ||
| 28 | async 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 | } | ||
| 61 | async 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 | } | ||
| 89 | async 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 | |||
| 97 | fn 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 | } | ||
| 107 | fn 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 | } | ||
| 141 | async 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 | |||
| 195 | pub 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 @@ | ||
| 1 | use crate::{Document, Error, Result}; | ||
| 2 | use std::{ | ||
| 3 | collections::HashMap, | ||
| 4 | future::Future, | ||
| 5 | sync::{Arc, Mutex}, | ||
| 6 | time::{Duration, Instant}, | ||
| 7 | }; | ||
| 8 | use tokio::sync::Mutex as AsyncMutex; | ||
| 9 | |||
| 10 | #[derive(Default)] | ||
| 11 | pub struct Cache(Mutex<HashMap<String, Arc<Entry>>>); | ||
| 12 | |||
| 13 | #[derive(Default)] | ||
| 14 | struct Entry { | ||
| 15 | state: Mutex<Cached>, | ||
| 16 | loading: Arc<AsyncMutex<()>>, | ||
| 17 | } | ||
| 18 | |||
| 19 | struct Cached { | ||
| 20 | value: Option<(Instant, Arc<Document>)>, | ||
| 21 | failure: Option<(Instant, Error)>, | ||
| 22 | used: Instant, | ||
| 23 | } | ||
| 24 | impl Default for Cached { | ||
| 25 | fn default() -> Self { | ||
| 26 | Self { | ||
| 27 | value: None, | ||
| 28 | failure: None, | ||
| 29 | used: Instant::now(), | ||
| 30 | } | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | impl 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 | |||
| 160 | impl 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)] | ||
| 179 | mod 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use futures::{StreamExt, stream}; | ||
| 3 | use sha1::{Digest, Sha1}; | ||
| 4 | |||
| 5 | pub 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 | } | ||
| 12 | pub 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 | |||
| 55 | fn live_alloc(alloc: &Value) -> bool { | ||
| 56 | alloc["DesiredStatus"] == "run" | ||
| 57 | && matches!(string(&alloc["ClientStatus"]), "pending" | "running") | ||
| 58 | } | ||
| 59 | pub 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 | |||
| 95 | pub 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 | |||
| 197 | pub 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 | } | ||
| 206 | pub 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 | } | ||
| 213 | pub 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 | } | ||
| 220 | pub 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 | } | ||
| 237 | pub 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 | |||
| 245 | async 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 | } | ||
| 262 | async 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 | } | ||
| 281 | pub 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 | |||
| 313 | async 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 | } | ||
| 341 | async 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 | |||
| 364 | pub 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 | } | ||
| 370 | pub 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 | } | ||
| 392 | pub 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 | } | ||
| 395 | fn 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 | } | ||
| 412 | async 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 | |||
| 441 | async 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 | ||
| 446 | output {{ renderer = new JsonRenderer {{ omitNullProperties = false }} | ||
| 447 | value = services.toMap().filter((_, s) -> s.enabled).mapValues((_, s) -> | ||
| 448 | let (route = (s.containers?.toMap()?.values ?? List()).add(s.container).filterNonNull().map((task) -> task.http).filterNonNull().findOrNull((http) -> http.hostname != null)) | ||
| 449 | new 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 | |||
| 463 | async 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 | |||
| 551 | async 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 | |||
| 586 | pub 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 | |||
| 706 | pub 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 | |||
| 741 | async 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)] | ||
| 780 | mod 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use futures::stream; | ||
| 3 | |||
| 4 | async fn history() -> Result<Value> { | ||
| 5 | host::call(json!({"operation":"deploy.history"})).await | ||
| 6 | } | ||
| 7 | async fn current() -> Result<Option<String>> { | ||
| 8 | Ok(serde_json::from_value( | ||
| 9 | host::call(json!({"operation":"deploy.current"})).await?, | ||
| 10 | )?) | ||
| 11 | } | ||
| 12 | async 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 | } | ||
| 25 | async 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 | } | ||
| 37 | async 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 | } | ||
| 57 | fn 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 | } | ||
| 95 | async 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 | } | ||
| 113 | fn 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 | } | ||
| 130 | struct Scope { | ||
| 131 | jobs: Vec<String>, | ||
| 132 | allocations: Vec<Value>, | ||
| 133 | from: f64, | ||
| 134 | to: Option<f64>, | ||
| 135 | } | ||
| 136 | async 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 | } | ||
| 191 | async 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 | } | ||
| 221 | fn 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 | } | ||
| 249 | async 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 | } | ||
| 255 | pub 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 | |||
| 322 | pub 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 | |||
| 451 | async 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use std::{ | ||
| 3 | collections::HashSet, | ||
| 4 | io::Read, | ||
| 5 | os::unix::fs::MetadataExt, | ||
| 6 | path::{Component, Path}, | ||
| 7 | }; | ||
| 8 | |||
| 9 | pub 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 | } | ||
| 39 | fn 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)] | ||
| 48 | struct Explorer { | ||
| 49 | root: PathBuf, | ||
| 50 | base: PathBuf, | ||
| 51 | scope: PathBuf, | ||
| 52 | } | ||
| 53 | impl 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 | } | ||
| 319 | fn 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 | } | ||
| 335 | async 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 | } | ||
| 341 | fn 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 | } | ||
| 346 | fn 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 | } | ||
| 371 | fn item_count(count: usize) -> String { | ||
| 372 | format!("{count} {}", if count == 1 { "item" } else { "items" }) | ||
| 373 | } | ||
| 374 | async 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 | } | ||
| 395 | fn 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 | } | ||
| 418 | async 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 | |||
| 557 | async fn journal(app: &App) -> Result<Value> { | ||
| 558 | core::read_json(&app.data.join("file-ops.json"), json!([])).await | ||
| 559 | } | ||
| 560 | fn 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 | } | ||
| 571 | async 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 | |||
| 598 | pub 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 | |||
| 716 | async 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 | } | ||
| 987 | async 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 | } | ||
| 1066 | async 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)] | ||
| 1115 | mod 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | ||
| 3 | |||
| 4 | static SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); | ||
| 5 | |||
| 6 | pub 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 | |||
| 14 | pub 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use rusqlite::{Connection, OptionalExtension, params}; | ||
| 3 | use sha1::{Digest, Sha1}; | ||
| 4 | use std::{ | ||
| 5 | collections::{BTreeSet, HashSet}, | ||
| 6 | os::unix::fs::MetadataExt, | ||
| 7 | sync::atomic::{AtomicBool, Ordering}, | ||
| 8 | }; | ||
| 9 | |||
| 10 | const 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);"; | ||
| 11 | const 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 | |||
| 13 | pub struct Index { | ||
| 14 | pool: String, | ||
| 15 | dir: PathBuf, | ||
| 16 | active: Mutex<BTreeSet<String>>, | ||
| 17 | scanning: AtomicBool, | ||
| 18 | wake: tokio::sync::Notify, | ||
| 19 | } | ||
| 20 | impl 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 | } | ||
| 373 | fn 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 | } | ||
| 398 | fn 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 | } | ||
| 415 | fn 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 | } | ||
| 418 | fn 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 | } | ||
| 429 | fn 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 | } | ||
| 482 | fn 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)] | ||
| 557 | struct Change { | ||
| 558 | kind: char, | ||
| 559 | path: String, | ||
| 560 | to: Option<String>, | ||
| 561 | } | ||
| 562 | fn 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 | } | ||
| 599 | fn 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 | |||
| 811 | pub 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)] | ||
| 846 | mod 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 @@ | ||
| 1 | mod apps; | ||
| 2 | mod cache; | ||
| 3 | mod core; | ||
| 4 | mod deploys; | ||
| 5 | mod files; | ||
| 6 | mod host; | ||
| 7 | mod index; | ||
| 8 | mod mcp; | ||
| 9 | mod observability; | ||
| 10 | mod relay; | ||
| 11 | mod shale; | ||
| 12 | mod storage; | ||
| 13 | mod telemetry; | ||
| 14 | mod users; | ||
| 15 | mod youtube; | ||
| 16 | |||
| 17 | use 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 | }; | ||
| 25 | use serde_json::{Value, json}; | ||
| 26 | use std::{ | ||
| 27 | collections::HashMap, | ||
| 28 | path::PathBuf, | ||
| 29 | sync::{Arc, Mutex}, | ||
| 30 | time::Duration, | ||
| 31 | }; | ||
| 32 | use subtle::ConstantTimeEq; | ||
| 33 | use tokio::sync::{Semaphore, watch}; | ||
| 34 | use tokio_stream::{StreamExt, wrappers::WatchStream}; | ||
| 35 | use tower_http::services::{ServeDir, ServeFile}; | ||
| 36 | |||
| 37 | type Result<T> = std::result::Result<T, Error>; | ||
| 38 | |||
| 39 | #[derive(Clone, Debug)] | ||
| 40 | struct Error { | ||
| 41 | status: u16, | ||
| 42 | message: String, | ||
| 43 | } | ||
| 44 | impl Error { | ||
| 45 | fn new(status: u16, message: impl Into<String>) -> Self { | ||
| 46 | Self { | ||
| 47 | status, | ||
| 48 | message: message.into(), | ||
| 49 | } | ||
| 50 | } | ||
| 51 | } | ||
| 52 | impl<E: std::error::Error> From<E> for Error { | ||
| 53 | fn from(error: E) -> Self { | ||
| 54 | Self::new(500, error.to_string()) | ||
| 55 | } | ||
| 56 | } | ||
| 57 | impl 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 | |||
| 70 | struct Document { | ||
| 71 | value: Value, | ||
| 72 | bytes: Bytes, | ||
| 73 | } | ||
| 74 | impl 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 | |||
| 84 | struct 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 | } | ||
| 104 | impl 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 | |||
| 115 | fn env(name: &str, default: &str) -> String { | ||
| 116 | std::env::var(name).unwrap_or_else(|_| default.into()) | ||
| 117 | } | ||
| 118 | fn array(value: &Value) -> &[Value] { | ||
| 119 | value.as_array().map(Vec::as_slice).unwrap_or_default() | ||
| 120 | } | ||
| 121 | fn string(value: &Value) -> &str { | ||
| 122 | value.as_str().unwrap_or_default() | ||
| 123 | } | ||
| 124 | fn 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 | } | ||
| 130 | fn now() -> f64 { | ||
| 131 | std::time::SystemTime::now() | ||
| 132 | .duration_since(std::time::UNIX_EPOCH) | ||
| 133 | .unwrap() | ||
| 134 | .as_secs_f64() | ||
| 135 | } | ||
| 136 | fn encoded(value: &str) -> String { | ||
| 137 | url::form_urlencoded::byte_serialize(value.as_bytes()).collect() | ||
| 138 | } | ||
| 139 | fn 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 | |||
| 145 | fn 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 | } | ||
| 184 | fn can_open(groups: &[&str], access: Option<&str>) -> bool { | ||
| 185 | access.is_none() || groups.contains(&"infra-admin") || groups.contains(&access.unwrap()) | ||
| 186 | } | ||
| 187 | fn 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 | |||
| 198 | async 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 | |||
| 232 | async 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)] | ||
| 310 | async 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)] | ||
| 517 | mod 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use base64::{ | ||
| 3 | Engine, | ||
| 4 | engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, | ||
| 5 | }; | ||
| 6 | use rusqlite::{Connection, OpenFlags, OptionalExtension}; | ||
| 7 | use sha2::{Digest, Sha256}; | ||
| 8 | use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; | ||
| 9 | |||
| 10 | const 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 | |||
| 28 | pub struct Store { | ||
| 29 | pub(crate) db: Mutex<Connection>, | ||
| 30 | pub origin: url::Url, | ||
| 31 | } | ||
| 32 | pub(crate) fn secret() -> String { | ||
| 33 | URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()) | ||
| 34 | } | ||
| 35 | pub(crate) fn hash(value: &str) -> String { | ||
| 36 | format!("{:x}", Sha256::digest(value.as_bytes())) | ||
| 37 | } | ||
| 38 | pub(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 | } | ||
| 51 | pub(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 | } | ||
| 73 | pub(crate) fn delete(db: &Connection, key: &str) -> Result<()> { | ||
| 74 | db.execute("DELETE FROM records WHERE key=?", [key])?; | ||
| 75 | Ok(()) | ||
| 76 | } | ||
| 77 | pub(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 | } | ||
| 84 | pub(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 | } | ||
| 93 | fn fail(code: &str) -> Error { | ||
| 94 | Error::new(400, code) | ||
| 95 | } | ||
| 96 | fn 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 | } | ||
| 110 | impl 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)] | ||
| 492 | pub(crate) struct Grant(pub Value); | ||
| 493 | pub 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 | |||
| 555 | pub 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 | } | ||
| 563 | pub 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 | |||
| 636 | pub 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)] | ||
| 851 | mod 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use 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)] | ||
| 12 | struct Observability(Arc<App>); | ||
| 13 | impl 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 | } | ||
| 130 | fn 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 | } | ||
| 143 | pub 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)] | ||
| 151 | mod 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; | ||
| 3 | use futures::SinkExt; | ||
| 4 | use mcp::{delete, get, hash, list, put, secret}; | ||
| 5 | use rmcp::{ | ||
| 6 | ErrorData, RoleServer, ServerHandler, | ||
| 7 | model::{ | ||
| 8 | CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult, | ||
| 9 | PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, ToolAnnotations, | ||
| 10 | }, | ||
| 11 | service::RequestContext, | ||
| 12 | }; | ||
| 13 | use tokio::sync::{mpsc, oneshot}; | ||
| 14 | |||
| 15 | pub struct Broker { | ||
| 16 | connections: Mutex<HashMap<String, Arc<Connection>>>, | ||
| 17 | pending_slots: Semaphore, | ||
| 18 | pub(crate) changes: watch::Sender<()>, | ||
| 19 | } | ||
| 20 | impl 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 | } | ||
| 29 | struct Connection { | ||
| 30 | messages: mpsc::Sender<Message>, | ||
| 31 | pending: Mutex<HashMap<String, oneshot::Sender<Result<Value>>>>, | ||
| 32 | closed: watch::Sender<bool>, | ||
| 33 | } | ||
| 34 | struct Pending { | ||
| 35 | connection: Arc<Connection>, | ||
| 36 | id: String, | ||
| 37 | } | ||
| 38 | impl Drop for Pending { | ||
| 39 | fn drop(&mut self) { | ||
| 40 | self.connection.pending.lock().unwrap().remove(&self.id); | ||
| 41 | } | ||
| 42 | } | ||
| 43 | fn unknown() -> Error { | ||
| 44 | Error::new( | ||
| 45 | 409, | ||
| 46 | "Command outcome is unknown. Read the thread before retrying.", | ||
| 47 | ) | ||
| 48 | } | ||
| 49 | impl 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 | } | ||
| 182 | pub(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 | } | ||
| 188 | fn 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 | } | ||
| 207 | fn 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 | } | ||
| 213 | pub(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 | } | ||
| 311 | fn 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 | } | ||
| 328 | fn 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 | } | ||
| 352 | fn 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 | } | ||
| 383 | fn 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 | } | ||
| 431 | async 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 | } | ||
| 493 | async 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 | } | ||
| 538 | async 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 | } | ||
| 611 | async 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)] | ||
| 655 | struct Agents(Arc<App>); | ||
| 656 | impl 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 | } | ||
| 781 | pub 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)] | ||
| 807 | mod 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use 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 | use scraper::{Html, Selector}; | ||
| 11 | |||
| 12 | pub struct Backend { | ||
| 13 | pub(crate) origin: url::Url, | ||
| 14 | http: reqwest::Client, | ||
| 15 | slots: Semaphore, | ||
| 16 | } | ||
| 17 | impl 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 | |||
| 124 | fn 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 | } | ||
| 146 | fn text(element: scraper::ElementRef<'_>) -> String { | ||
| 147 | element.text().collect::<String>().trim().to_owned() | ||
| 148 | } | ||
| 149 | fn 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 | } | ||
| 171 | fn 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 | } | ||
| 187 | pub 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 | } | ||
| 237 | fn 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 | } | ||
| 291 | fn 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)] | ||
| 306 | struct Shale(Arc<App>); | ||
| 307 | impl 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 | } | ||
| 481 | pub fn router(app: Arc<App>) -> Router { | ||
| 482 | let state = app.clone(); | ||
| 483 | mcp::router(app, "shale", move || Ok(Shale(state.clone()))) | ||
| 484 | } | ||
| 485 | |||
| 486 | fn 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 | } | ||
| 507 | fn 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 | } | ||
| 528 | fn 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 | |||
| 550 | pub 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 | |||
| 649 | pub 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)] | ||
| 771 | mod 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | |||
| 3 | async fn datasets() -> Result<Value> { | ||
| 4 | host::call(json!({"operation":"storage.datasets"})).await | ||
| 5 | } | ||
| 6 | fn 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 | } | ||
| 28 | async 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 | } | ||
| 88 | pub async fn snapshots(dataset: &str) -> Result<Value> { | ||
| 89 | validate(dataset, false)?; | ||
| 90 | host::call(json!({"operation":"storage.snapshots","dataset":dataset})).await | ||
| 91 | } | ||
| 92 | pub 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 | } | ||
| 107 | async 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 | } | ||
| 118 | pub 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 | } | ||
| 139 | pub 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use futures::{StreamExt, stream}; | ||
| 3 | |||
| 4 | pub 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 | |||
| 40 | pub 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 | |||
| 74 | const 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 | |||
| 87 | fn 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 | |||
| 132 | pub 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 | } | ||
| 150 | pub 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 | } | ||
| 223 | pub 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)] | ||
| 249 | struct Term { | ||
| 250 | field: Option<String>, | ||
| 251 | op: String, | ||
| 252 | value: String, | ||
| 253 | exclude: bool, | ||
| 254 | } | ||
| 255 | fn 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 | } | ||
| 298 | pub 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 | |||
| 359 | fn 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 | } | ||
| 415 | async 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 | } | ||
| 461 | pub 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 | } | ||
| 473 | pub 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 | } | ||
| 496 | pub 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)] | ||
| 629 | struct Recorder { | ||
| 630 | signature: Value, | ||
| 631 | cpu: HashMap<String, (f64, f64)>, | ||
| 632 | vms: HashMap<String, (f64, f64)>, | ||
| 633 | network: Option<(f64, f64, f64)>, | ||
| 634 | } | ||
| 635 | impl 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 | } | ||
| 775 | pub 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)] | ||
| 882 | mod 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use axum::extract::{FromRequest, Multipart}; | ||
| 3 | use futures::{StreamExt, stream}; | ||
| 4 | |||
| 5 | async 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 | } | ||
| 10 | async fn get(path: &str) -> Result<Value> { | ||
| 11 | Ok(call(path, Method::GET, None).await?["body"].take()) | ||
| 12 | } | ||
| 13 | async 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 | } | ||
| 36 | async 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 | } | ||
| 60 | async 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 | } | ||
| 67 | async 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 | } | ||
| 83 | fn 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 | } | ||
| 95 | fn 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 | } | ||
| 151 | fn 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 | |||
| 158 | pub 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 | } | ||
| 309 | async 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 | } | ||
| 323 | pub 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 | } | ||
| 357 | fn 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 | } | ||
| 366 | pub 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 | } | ||
| 392 | pub 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 @@ | ||
| 1 | use crate::*; | ||
| 2 | use tokio::{ | ||
| 3 | io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, | ||
| 4 | sync::{mpsc, oneshot}, | ||
| 5 | }; | ||
| 6 | |||
| 7 | pub struct Worker(mpsc::Sender<Call>); | ||
| 8 | struct Call { | ||
| 9 | method: String, | ||
| 10 | args: Value, | ||
| 11 | reply: oneshot::Sender<Result<Value>>, | ||
| 12 | } | ||
| 13 | |||
| 14 | impl 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 | |||
| 136 | pub 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 | } | ||
| 289 | fn 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 @@ | ||
| 1 | import { defineConfig } from "vite"; | ||
| 2 | import solid from "vite-plugin-solid"; | ||
| 3 | |||
| 4 | export 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 @@ | ||
| 1 | import type { Connections, Consent } from "./types/mcp.ts"; | ||
| 2 | import type { Pool, Vdev, Disk, Dataset, Snapshot } from "./types/storage.ts"; | ||
| 3 | import type { Torrent, TorrentFile, ServerState } from "./types/seedbox.ts"; | ||
| 4 | import type { Video, Show, Job, Wall, Archive, Upscaler, Channels, ConfigFile, LibraryEntry, Ingest } from "./types/youtube.ts"; | ||
| 5 | import type { User, Group, Session, Credential } from "./types/users.ts"; | ||
| 6 | import type { Hono } from "hono"; | ||
| 7 | import type { Health } from "./types/model.ts"; | ||
| 8 | import type { Domain, Image, History, NewDomain } from "./types/vms.ts"; | ||
| 9 | import type { Me, ServiceSummary, ServiceDetail, ServiceDefinition, HostInfo, Issue, Series, LogLine, TraceSummary, Trace } from "./types/model.ts"; | ||
| 10 | import type { MapNode } from "./types/storage.index.ts"; | ||
| 11 | import type { ProgressNode, PaperCloverStats } from "./types/paperClover.ts"; | ||
| 12 | |||
| 13 | type Endpoint<Output, Input = {}, Status extends number = 200, Format extends string = "json"> = { | ||
| 14 | input: Input; output: Output; status: Status; outputFormat: Format; | ||
| 15 | }; | ||
| 16 | |||
| 17 | type 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 | |||
| 19 | type 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 | }; | ||
| 54 | type ExplorerRoutes<Prefix extends string> = { [P in keyof Explorer as `${Prefix}${P}`]: Explorer[P] }; | ||
| 55 | |||
| 56 | export 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 @@ | ||
| 1 | import { DetailedError, hc, parseResponse } from "hono/client"; | ||
| 2 | import { createResource, untrack } from "solid-js"; | ||
| 3 | import type { Api } from "./api.contract.ts"; | ||
| 4 | import type { Metric } from "./types/model.ts"; | ||
| 5 | |||
| 6 | export 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 | */ | ||
| 18 | export 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. */ | ||
| 21 | export 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. */ | ||
| 32 | const 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. */ | ||
| 34 | const 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 | */ | ||
| 40 | export 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. */ | ||
| 75 | export 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 @@ | ||
| 1 | import { ago, datetime } from "../format.ts"; | ||
| 2 | |||
| 3 | /** A relative time with the full date and time on hover. `t` is unix seconds. */ | ||
| 4 | export 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 @@ | ||
| 1 | import { Show } from "solid-js"; | ||
| 2 | import type { ServiceSummary } from "../types/model.ts"; | ||
| 3 | |||
| 4 | function 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. */ | ||
| 11 | export 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 @@ | ||
| 1 | import Check from "lucide-solid/icons/check"; | ||
| 2 | import Minus from "lucide-solid/icons/minus"; | ||
| 3 | import { 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 | */ | ||
| 10 | export 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 @@ | ||
| 1 | import { 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 | */ | ||
| 7 | export 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 @@ | ||
| 1 | import { createSignal, type JSX, onMount, Show } from "solid-js"; | ||
| 2 | import { render } from "solid-js/web"; | ||
| 3 | import { reason } from "../api.ts"; | ||
| 4 | |||
| 5 | type Content = JSX.Element | (() => JSX.Element); | ||
| 6 | const build = (content: Content) => (typeof content === "function" ? content() : content); | ||
| 7 | |||
| 8 | interface 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 | |||
| 29 | interface 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 | |||
| 39 | const SHAKE = [0, -8, 8, -8, 8, -8, 8, 0].map((x) => ({ transform: `translateX(${x}px)` })); | ||
| 40 | const reduced = matchMedia("(prefers-reduced-motion: reduce)"); | ||
| 41 | let 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 | */ | ||
| 47 | export const showConfirmDialog = (options: DialogOptions) => open(options); | ||
| 48 | |||
| 49 | /** Like showConfirmDialog with a text field; Enter submits once `validateInput` passes. */ | ||
| 50 | export const showTextDialog = (options: TextDialogOptions) => | ||
| 51 | open({ ...options, onConfirm: (form) => options.onConfirm(String(form.get("value")), form) }, options); | ||
| 52 | |||
| 53 | function 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 @@ | ||
| 1 | import { 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(--…)`. */ | ||
| 4 | export 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 @@ | ||
| 1 | import { useLocation, useSearchParams } from "@solidjs/router"; | ||
| 2 | import { Checkbox } from "./Checkbox.tsx"; | ||
| 3 | import { type InferResponseType, parseResponse } from "hono/client"; | ||
| 4 | import ChevronRight from "lucide-solid/icons/chevron-right"; | ||
| 5 | import CornerLeftUp from "lucide-solid/icons/corner-left-up"; | ||
| 6 | import Database from "lucide-solid/icons/database"; | ||
| 7 | import FileIcon from "lucide-solid/icons/file"; | ||
| 8 | import FileCode from "lucide-solid/icons/file-code"; | ||
| 9 | import FileImage from "lucide-solid/icons/file-image"; | ||
| 10 | import FileMusic from "lucide-solid/icons/file-music"; | ||
| 11 | import FilePlay from "lucide-solid/icons/file-play"; | ||
| 12 | import FileText from "lucide-solid/icons/file-text"; | ||
| 13 | import Folder from "lucide-solid/icons/folder"; | ||
| 14 | import History from "lucide-solid/icons/rotate-ccw-clock"; | ||
| 15 | import LayoutGrid from "lucide-solid/icons/layout-grid"; | ||
| 16 | import TextSearch from "lucide-solid/icons/text-search"; | ||
| 17 | import X from "lucide-solid/icons/x"; | ||
| 18 | import { | ||
| 19 | createEffect, createMemo, createResource, createSignal, For, type JSX, on, onCleanup, onMount, Show, | ||
| 20 | } from "solid-js"; | ||
| 21 | import { api, queries, query, reason } from "../api.ts"; | ||
| 22 | import { ago, bytes, count, percent, plural } from "../format.ts"; | ||
| 23 | import { Ago } from "./Ago.tsx"; | ||
| 24 | import { showConfirmDialog, showTextDialog } from "./Dialog.tsx"; | ||
| 25 | import { lastGood, Loaded } from "./Loaded.tsx"; | ||
| 26 | import { OpenApp } from "./OpenApp.tsx"; | ||
| 27 | import { Reveal } from "./Reveal.tsx"; | ||
| 28 | import { type Sort, SortHeader } from "./SortHeader.tsx"; | ||
| 29 | import { TabBar } from "./TabBar.tsx"; | ||
| 30 | import { toast } from "./Toast.tsx"; | ||
| 31 | import "./Explorer.css"; | ||
| 32 | |||
| 33 | /** A file browser's routes: the library's, or the whole store's under storage. */ | ||
| 34 | type Client = typeof api.media | typeof api.storage.files; | ||
| 35 | type Entry = InferResponseType<typeof api.media.list.$get, 200>["entries"][number]; | ||
| 36 | type Tree = InferResponseType<typeof api.media.tree.$post, 200>; | ||
| 37 | type Key = "name" | "size" | "modified"; | ||
| 38 | type Op = { id: string; label: string }; | ||
| 39 | /** A visible line of the file tree; `path` is root-relative. */ | ||
| 40 | type 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. */ | ||
| 43 | export 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. */ | ||
| 52 | export 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. */ | ||
| 84 | export 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 | |||
| 110 | const 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 | ]; | ||
| 117 | const 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. */ | ||
| 120 | const MAC = /^(Mac|iP)/.test(navigator.platform); | ||
| 121 | |||
| 122 | /** Rows are keyed by entry object, so reading props once is safe. */ | ||
| 123 | function 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 | |||
| 130 | const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name); | ||
| 131 | const parentOf = (rel: string) => rel.split("/").slice(0, -1).join("/"); | ||
| 132 | /** Whether `rel` is strictly inside `dir`. */ | ||
| 133 | const within = (dir: string, rel: string) => rel !== dir && (!dir || rel.startsWith(dir + "/")); | ||
| 134 | const 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. */ | ||
| 137 | const TINY = 0.004; | ||
| 138 | const REST = 0.04; | ||
| 139 | |||
| 140 | /** The folder's entries as blocks sized by bytes, each colored as its row's icon. */ | ||
| 141 | function 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 | */ | ||
| 193 | export 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 @@ | ||
| 1 | import ChevronUp from "lucide-solid/icons/chevron-up"; | ||
| 2 | import { 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 | */ | ||
| 9 | export 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 @@ | ||
| 1 | import { type Accessor, createMemo, For, type JSX, type Resource, Show } from "solid-js"; | ||
| 2 | import Unplug from "lucide-solid/icons/unplug"; | ||
| 3 | import { reason, unconnected } from "../api.ts"; | ||
| 4 | |||
| 5 | /** The latest value that loaded, kept through later failures; reading an errored resource directly throws. */ | ||
| 6 | export 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. */ | ||
| 11 | export 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 | */ | ||
| 20 | export 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 @@ | ||
| 1 | import ArrowDown from "lucide-solid/icons/arrow-down"; | ||
| 2 | import { createMemo, type JSX, Show } from "solid-js"; | ||
| 3 | import type { LogLine } from "../types/model.ts"; | ||
| 4 | import { clock, count, date, datetime } from "../format.ts"; | ||
| 5 | import { VirtualList } from "./VirtualList.tsx"; | ||
| 6 | import "./LogView.css"; | ||
| 7 | |||
| 8 | /** SGR sequences capture their parameters; every other escape and control character is dropped. */ | ||
| 9 | const 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. */ | ||
| 11 | const ANSI = ["var(--muted)", "var(--critical)", "var(--good)", "var(--warning)", "var(--series-1)", "var(--series-5)", | ||
| 12 | "var(--series-3)", "var(--text)"]; | ||
| 13 | |||
| 14 | function 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 | |||
| 20 | function 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. */ | ||
| 50 | function 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. */ | ||
| 91 | const LINE = 18.6; | ||
| 92 | const CHAR = 7.2; | ||
| 93 | /** Everything in a log row beside the text: the gutters, the time column, and the gap after it. */ | ||
| 94 | const ROW_CHROME = 2 * 28 + 62 + 12; | ||
| 95 | |||
| 96 | function 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 | */ | ||
| 107 | export 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 @@ | ||
| 1 | import { Show } from "solid-js"; | ||
| 2 | |||
| 3 | /** A thin progress bar; `failed` stacks a critical segment after the done one. */ | ||
| 4 | export 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 @@ | ||
| 1 | import { createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js"; | ||
| 2 | import type { Metric, Series } from "../types/model.ts"; | ||
| 3 | import { queries } from "../api.ts"; | ||
| 4 | import { lastGood, Loaded } from "./Loaded.tsx"; | ||
| 5 | import { TabBar } from "./TabBar.tsx"; | ||
| 6 | import { TimeChart, type ChartProps } from "./TimeChart.tsx"; | ||
| 7 | |||
| 8 | const RANGES = [["1h", 3600], ["6h", 21600], ["24h", 86400], ["7d", 604800]] as const; | ||
| 9 | |||
| 10 | export 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. */ | ||
| 23 | function 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 | |||
| 39 | export 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. */ | ||
| 49 | export 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 | |||
| 60 | interface MetricCardProps extends Omit<ChartProps, "series" | "colors" | "onHover">, MetricQuery { | ||
| 61 | title: string; | ||
| 62 | sub?: string; | ||
| 63 | actions?: JSX.Element; | ||
| 64 | } | ||
| 65 | |||
| 66 | export 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 @@ | ||
| 1 | import ArrowUpRight from "lucide-solid/icons/arrow-up-right"; | ||
| 2 | import type { JSX } from "solid-js"; | ||
| 3 | import type { ServiceSummary } from "../types/model.ts"; | ||
| 4 | import { 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 | */ | ||
| 11 | export 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. */ | ||
| 2 | export 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 @@ | ||
| 1 | import { createUniqueId } from "solid-js"; | ||
| 2 | |||
| 3 | /** Lucide's flag, filled, with a skull and crossbones knocked out of it. */ | ||
| 4 | export 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 @@ | ||
| 1 | import 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 | */ | ||
| 7 | export 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 @@ | ||
| 1 | import { A, useLocation, useMatch } from "@solidjs/router"; | ||
| 2 | import ArrowUpRight from "lucide-solid/icons/arrow-up-right"; | ||
| 3 | import Boxes from "lucide-solid/icons/boxes"; | ||
| 4 | import ChevronRight from "lucide-solid/icons/chevron-right"; | ||
| 5 | import ChevronsUpDown from "lucide-solid/icons/chevrons-up-down"; | ||
| 6 | import Clapperboard from "lucide-solid/icons/clapperboard"; | ||
| 7 | import Eye from "lucide-solid/icons/eye"; | ||
| 8 | import HardDrive from "lucide-solid/icons/hard-drive"; | ||
| 9 | import House from "lucide-solid/icons/house"; | ||
| 10 | import LogOut from "lucide-solid/icons/log-out"; | ||
| 11 | import Monitor from "lucide-solid/icons/monitor"; | ||
| 12 | import Rocket from "lucide-solid/icons/rocket"; | ||
| 13 | import SquarePlay from "lucide-solid/icons/square-play"; | ||
| 14 | import UserRound from "lucide-solid/icons/user-round"; | ||
| 15 | import Plug from "lucide-solid/icons/plug"; | ||
| 16 | import Users from "lucide-solid/icons/users"; | ||
| 17 | import { createSignal, For, type JSX, Show } from "solid-js"; | ||
| 18 | import { type Health, type Me, type Section, trouble, VIEW_AS } from "../types/model.ts"; | ||
| 19 | import { queries } from "../api.ts"; | ||
| 20 | import snowflake from "../snowflake.svg"; | ||
| 21 | import { bytes, cores, plural } from "../format.ts"; | ||
| 22 | import { account, Avatar, displayName } from "../pages/Account.tsx"; | ||
| 23 | import { status } from "../pages/Overview.tsx"; | ||
| 24 | import { TABS as STORAGE_TABS } from "../pages/Storage.tsx"; | ||
| 25 | import { TABS as YOUTUBE_TABS } from "../pages/YouTube.tsx"; | ||
| 26 | import { stream } from "../live.ts"; | ||
| 27 | import { AppIcon } from "./AppIcon.tsx"; | ||
| 28 | import { lastGood, Loaded } from "./Loaded.tsx"; | ||
| 29 | import { PaperCloverMark } from "./PaperCloverMark.tsx"; | ||
| 30 | import { PirateFlag } from "./PirateFlag.tsx"; | ||
| 31 | import { Spark } from "./Spark.tsx"; | ||
| 32 | import { Status, StatusLabel } from "./Status.tsx"; | ||
| 33 | |||
| 34 | type Icon = (props: { class: string }) => JSX.Element; | ||
| 35 | |||
| 36 | interface 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 | |||
| 45 | const BEFORE: Page[] = [{ href: "/", label: "overview", icon: House, section: "launcher" }]; | ||
| 46 | |||
| 47 | const 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. */ | ||
| 66 | export const PAGES = [...BEFORE, ...AFTER]; | ||
| 67 | |||
| 68 | /** Groups an admin can preview the dashboard as. */ | ||
| 69 | const 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. */ | ||
| 72 | function 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. */ | ||
| 88 | function 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 | |||
| 97 | function 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. */ | ||
| 140 | const 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 | |||
| 148 | function 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. */ | ||
| 221 | export 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. */ | ||
| 227 | function 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 | |||
| 270 | export 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 @@ | ||
| 1 | import ArrowDown from "lucide-solid/icons/arrow-down"; | ||
| 2 | import ArrowUp from "lucide-solid/icons/arrow-up"; | ||
| 3 | import { Show } from "solid-js"; | ||
| 4 | |||
| 5 | export 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. */ | ||
| 11 | export 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 @@ | ||
| 1 | import { createEffect, createMemo, on } from "solid-js"; | ||
| 2 | import { LIVE_INTERVAL } from "../types/model.ts"; | ||
| 3 | |||
| 4 | const W = 44; | ||
| 5 | const H = 14; | ||
| 6 | /** Samples averaged into each point, aligned to the stream's sample count so a finished point never moves. */ | ||
| 7 | const PER_POINT = 7; | ||
| 8 | const GAP = 2; | ||
| 9 | const 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. */ | ||
| 12 | function 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 | */ | ||
| 21 | export 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 @@ | ||
| 1 | import type { JSX } from "solid-js"; | ||
| 2 | import 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. */ | ||
| 5 | export type StatusKind = Health | "working"; | ||
| 6 | |||
| 7 | /** Each state has its own shape so it survives without color; the in-between states share a spinner. */ | ||
| 8 | function 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 | |||
| 19 | export 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. */ | ||
| 29 | export 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 @@ | ||
| 1 | import { createMemo, createSignal, type JSX, Show } from "solid-js"; | ||
| 2 | import type { Series } from "../types/model.ts"; | ||
| 3 | import { lastGood, Loaded } from "./Loaded.tsx"; | ||
| 4 | import { type MetricQuery, useMetric } from "./Metric.tsx"; | ||
| 5 | import { 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. */ | ||
| 8 | export 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. */ | ||
| 11 | export 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 @@ | ||
| 1 | import { 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 | */ | ||
| 8 | export 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 @@ | ||
| 1 | import { createEffect, createSignal, Index, onCleanup, onMount, Show } from "solid-js"; | ||
| 2 | import uPlot from "uplot"; | ||
| 3 | import type { Series } from "../types/model.ts"; | ||
| 4 | import { bytes, clock, rate } from "../format.ts"; | ||
| 5 | |||
| 6 | /** Card chart height; a legend takes its bottom 26px. */ | ||
| 7 | const HEIGHT = 196; | ||
| 8 | const minute = (t: number) => new Date(t * 1000).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); | ||
| 9 | const 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. */ | ||
| 11 | const BINARY = Array.from({ length: 60 }, (_, i) => 2 ** i); | ||
| 12 | /** Synced charts share one y-axis width so their times line up. */ | ||
| 13 | const GUTTER = 56; | ||
| 14 | /** Line height of an inline value; closer values are pushed apart. */ | ||
| 15 | const MARK = 16; | ||
| 16 | const 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. */ | ||
| 18 | const resolve = (color: string) => (color.startsWith("var(") ? token(color.slice(4, -1)) : color); | ||
| 19 | |||
| 20 | export 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 | |||
| 43 | interface Mark { | ||
| 44 | name: string; | ||
| 45 | value: string; | ||
| 46 | color: string; | ||
| 47 | y: number; | ||
| 48 | } | ||
| 49 | |||
| 50 | export 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 @@ | ||
| 1 | import X from "lucide-solid/icons/x"; | ||
| 2 | import { createSignal, Show } from "solid-js"; | ||
| 3 | import { reason } from "../api.ts"; | ||
| 4 | |||
| 5 | interface Shown { | ||
| 6 | text: string; | ||
| 7 | action?: { label: string; run: () => Promise<unknown> }; | ||
| 8 | } | ||
| 9 | |||
| 10 | const [shown, setShown] = createSignal<Shown>(); | ||
| 11 | let timer: ReturnType<typeof setTimeout> | undefined; | ||
| 12 | |||
| 13 | const dismiss = () => { | ||
| 14 | clearTimeout(timer); | ||
| 15 | setShown(); | ||
| 16 | }; | ||
| 17 | const 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. */ | ||
| 23 | export 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. */ | ||
| 29 | export 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 @@ | ||
| 1 | import { createSignal, onCleanup, onMount } from "solid-js"; | ||
| 2 | import { Portal } from "solid-js/web"; | ||
| 3 | |||
| 4 | type Anchor = HTMLElement | SVGElement; | ||
| 5 | |||
| 6 | const GAP = 8; | ||
| 7 | const EDGE = 6; | ||
| 8 | /** The innermost tip wins; an empty one defers to the tip around it. */ | ||
| 9 | const 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 | */ | ||
| 15 | export 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 @@ | ||
| 1 | import { createEffect, createSignal, For, type JSX, on, onCleanup, onMount } from "solid-js"; | ||
| 2 | |||
| 3 | /** Rows rendered past each edge of the view, in px. */ | ||
| 4 | const 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 | */ | ||
| 12 | export 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 @@ | ||
| 1 | const UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; | ||
| 2 | |||
| 3 | /** Binary units, matching what ZFS and the kernel report. */ | ||
| 4 | export 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 | |||
| 13 | export const rate = (value: number) => bytes(value) + "/s"; | ||
| 14 | export const percent = (value: number) => `${value.toFixed(Number.isInteger(value) || value >= 10 ? 0 : 1)}%`; | ||
| 15 | export const cores = (value: number) => value.toFixed(value < 10 ? 2 : 1); | ||
| 16 | export const watts = (value: number) => `${Math.round(value)} W`; | ||
| 17 | export const count = (value: number) => value.toLocaleString(); | ||
| 18 | export const plural = (n: number, one: string, many = one + "s") => `${count(n)} ${n === 1 ? one : many}`; | ||
| 19 | export const celsius = (value: number) => `${Math.round(value)} °C`; | ||
| 20 | |||
| 21 | const 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". */ | ||
| 24 | export 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. */ | ||
| 34 | export 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. */ | ||
| 42 | export function until(t: number, now = Date.now() / 1000) { | ||
| 43 | return t > now ? `in ${duration(t - now)}` : "soon"; | ||
| 44 | } | ||
| 45 | |||
| 46 | export function clock(t: number) { | ||
| 47 | return new Date(t * 1000).toLocaleTimeString(undefined, { hour12: false }); | ||
| 48 | } | ||
| 49 | |||
| 50 | export const date = (t: number) => new Date(t * 1000).toLocaleDateString(undefined, { dateStyle: "medium" }); | ||
| 51 | export 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 @@ | ||
| 1 | import { batch, createRoot, createSignal } from "solid-js"; | ||
| 2 | import { LIVE_INTERVAL, type Live, type Series } from "./types/model.ts"; | ||
| 3 | import { parseResponse } from "hono/client"; | ||
| 4 | import { api } from "./api.ts"; | ||
| 5 | |||
| 6 | /** Samples per sidebar sparkline: five minutes of ticks. */ | ||
| 7 | const WINDOW = 150; | ||
| 8 | |||
| 9 | function 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 | |||
| 16 | function 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. */ | ||
| 21 | export 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 @@ | ||
| 1 | import { Route, Router, type RouteSectionProps, useLocation } from "@solidjs/router"; | ||
| 2 | import Eye from "lucide-solid/icons/eye"; | ||
| 3 | import { createResource, Show } from "solid-js"; | ||
| 4 | import { render } from "solid-js/web"; | ||
| 5 | import { parseResponse } from "hono/client"; | ||
| 6 | import { api, queries } from "./api.ts"; | ||
| 7 | import { Loaded } from "./components/Loaded.tsx"; | ||
| 8 | import { PAGES, Sidebar, viewAs } from "./components/Sidebar.tsx"; | ||
| 9 | import { Toasts } from "./components/Toast.tsx"; | ||
| 10 | import { Tooltips } from "./components/Tooltip.tsx"; | ||
| 11 | import { Overview } from "./pages/Overview.tsx"; | ||
| 12 | import { Service } from "./pages/Service.tsx"; | ||
| 13 | import { Storage } from "./pages/Storage.tsx"; | ||
| 14 | import { Media } from "./pages/Media.tsx"; | ||
| 15 | import { Seedbox } from "./pages/Seedbox.tsx"; | ||
| 16 | import { YouTube } from "./pages/YouTube.tsx"; | ||
| 17 | import { PaperClover } from "./pages/PaperClover.tsx"; | ||
| 18 | import { directory, Users } from "./pages/Users.tsx"; | ||
| 19 | import { Deploys } from "./pages/Deploys.tsx"; | ||
| 20 | import { Deploy } from "./pages/Deploy.tsx"; | ||
| 21 | import { VMs } from "./pages/VMs.tsx"; | ||
| 22 | import { MCP } from "./pages/MCP.tsx"; | ||
| 23 | import { Account } from "./pages/Account.tsx"; | ||
| 24 | import "./styles.css"; | ||
| 25 | |||
| 26 | const [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. */ | ||
| 29 | const preload = (...list: { preload(): void }[]) => () => list.forEach((query) => query.preload()); | ||
| 30 | |||
| 31 | function 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 | |||
| 64 | render(() => ( | ||
| 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 @@ | ||
| 1 | import { useNavigate, useSearchParams } from "@solidjs/router"; | ||
| 2 | import { createResource, createSignal, For, onMount, Show } from "solid-js"; | ||
| 3 | import { parseResponse } from "hono/client"; | ||
| 4 | import type { User } from "../types/users.ts"; | ||
| 5 | import { api, queries, reason } from "../api.ts"; | ||
| 6 | import { Ago } from "../components/Ago.tsx"; | ||
| 7 | import { lastGood, Loaded } from "../components/Loaded.tsx"; | ||
| 8 | import { OpenApp } from "../components/OpenApp.tsx"; | ||
| 9 | import { Reveal } from "../components/Reveal.tsx"; | ||
| 10 | import { toast } from "../components/Toast.tsx"; | ||
| 11 | import "./Account.css"; | ||
| 12 | |||
| 13 | /** The signed-in user's Keycloak record, shared with the sidebar corner. */ | ||
| 14 | export const [account, { refetch: refetchAccount }] = createResource(() => parseResponse(api.account.$get())); | ||
| 15 | |||
| 16 | export const displayName = (user: Pick<User, "username" | "firstName" | "lastName">) => | ||
| 17 | [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username; | ||
| 18 | |||
| 19 | export 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 | |||
| 27 | const PICTURE_SIZE = 256; | ||
| 28 | const FIELDS = ["firstName", "lastName"] as const; | ||
| 29 | |||
| 30 | const 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. */ | ||
| 38 | async 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 | |||
| 50 | export 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 | |||
| 87 | type Self = NonNullable<typeof account.latest>; | ||
| 88 | |||
| 89 | function 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 | |||
| 206 | function 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)); } | ||
| 23 | a.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 @@ | ||
| 1 | import { A, useNavigate, useParams, useSearchParams } from "@solidjs/router"; | ||
| 2 | import { type InferResponseType, parseResponse } from "hono/client"; | ||
| 3 | import ExternalLink from "lucide-solid/icons/external-link"; | ||
| 4 | import { | ||
| 5 | createEffect, createMemo, createResource, createSignal, For, type JSX, Match, on, onCleanup, type Resource, Show, Switch, | ||
| 6 | untrack, | ||
| 7 | } from "solid-js"; | ||
| 8 | import type { Health, LogLine } from "../types/model.ts"; | ||
| 9 | import { api, query } from "../api.ts"; | ||
| 10 | import { Ago } from "../components/Ago.tsx"; | ||
| 11 | import { AppIcon } from "../components/AppIcon.tsx"; | ||
| 12 | import { Copy } from "../components/Copy.tsx"; | ||
| 13 | import { showConfirmDialog } from "../components/Dialog.tsx"; | ||
| 14 | import { ListPage } from "../components/ListPage.tsx"; | ||
| 15 | import { LogView } from "../components/LogView.tsx"; | ||
| 16 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; | ||
| 17 | import { OpenApp } from "../components/OpenApp.tsx"; | ||
| 18 | import { StatusLabel } from "../components/Status.tsx"; | ||
| 19 | import { TabBar } from "../components/TabBar.tsx"; | ||
| 20 | import { TimeChart } from "../components/TimeChart.tsx"; | ||
| 21 | import { toast } from "../components/Toast.tsx"; | ||
| 22 | import { ago, bytes, clock, cores, count, datetime, duration, plural } from "../format.ts"; | ||
| 23 | import { confirmDestroyStage, type Deployed, EVENT, followRun, type StageInfo, useDeploys } from "./Deploys.tsx"; | ||
| 24 | import "./Deploy.css"; | ||
| 25 | |||
| 26 | interface 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. */ | ||
| 37 | const [dismissed, setDismissed] = createSignal<string[]>([]); | ||
| 38 | |||
| 39 | function 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 | |||
| 64 | type Line = [op: " " | "-" | "+", text: string]; | ||
| 65 | |||
| 66 | /** One service file's diff, with long unchanged runs folded behind a button. */ | ||
| 67 | function 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 | |||
| 130 | function 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. */ | ||
| 154 | function 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 | |||
| 171 | type Runtime = InferResponseType<(typeof api.deploys.stages)[":id"]["runtime"]["$get"], 200>; | ||
| 172 | type Allocation = Runtime["jobs"][number]["allocations"][number]; | ||
| 173 | type LogQuery = { after?: string; before?: string; limit?: string }; | ||
| 174 | |||
| 175 | /** Allocations and usage of a stage or deploy, polled while its jobs still run. */ | ||
| 176 | function 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. */ | ||
| 185 | const 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 | |||
| 190 | function 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. */ | ||
| 203 | function 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 | |||
| 242 | const 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. */ | ||
| 248 | function 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 | */ | ||
| 306 | function 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 | |||
| 362 | export 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 @@ | ||
| 1 | import { A } from "@solidjs/router"; | ||
| 2 | import { type InferResponseType, parseResponse } from "hono/client"; | ||
| 3 | import { createMemo, createResource, For, type JSX, onCleanup, Show } from "solid-js"; | ||
| 4 | import { api, queries } from "../api.ts"; | ||
| 5 | import { Ago } from "../components/Ago.tsx"; | ||
| 6 | import { showConfirmDialog } from "../components/Dialog.tsx"; | ||
| 7 | import { ListPage } from "../components/ListPage.tsx"; | ||
| 8 | import { lastGood, Loaded } from "../components/Loaded.tsx"; | ||
| 9 | import { Status } from "../components/Status.tsx"; | ||
| 10 | import "./Deploys.css"; | ||
| 11 | |||
| 12 | type Overview = InferResponseType<typeof api.deploys.$get>; | ||
| 13 | export type StageInfo = Overview["stages"][number]; | ||
| 14 | export type Deployed = Overview["history"][number]; | ||
| 15 | |||
| 16 | export 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 | |||
| 33 | export 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 | |||
| 52 | export 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. */ | ||
| 55 | export 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 | |||
| 65 | export 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. */ | ||
| 85 | type Mark = "start" | "pass" | "merge"; | ||
| 86 | |||
| 87 | interface 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 | |||
| 101 | export 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 @@ | ||
| 1 | import { useLocation } from "@solidjs/router"; | ||
| 2 | import { parseResponse } from "hono/client"; | ||
| 3 | import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; | ||
| 4 | import { api, reason } from "../api.ts"; | ||
| 5 | import { Ago } from "../components/Ago.tsx"; | ||
| 6 | import { Checkbox } from "../components/Checkbox.tsx"; | ||
| 7 | import { Copy } from "../components/Copy.tsx"; | ||
| 8 | import { Loaded } from "../components/Loaded.tsx"; | ||
| 9 | import { toast } from "../components/Toast.tsx"; | ||
| 10 | import "./MCP.css"; | ||
| 11 | |||
| 12 | export 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 @@ | ||
| 1 | import { useSearchParams } from "@solidjs/router"; | ||
| 2 | import { parseResponse } from "hono/client"; | ||
| 3 | import { createSignal, Show } from "solid-js"; | ||
| 4 | import { api, queries, reason } from "../api.ts"; | ||
| 5 | import { Explorer } from "../components/Explorer.tsx"; | ||
| 6 | import { ListPage } from "../components/ListPage.tsx"; | ||
| 7 | import { lastGood } from "../components/Loaded.tsx"; | ||
| 8 | import { OpenApp } from "../components/OpenApp.tsx"; | ||
| 9 | import { toast } from "../components/Toast.tsx"; | ||
| 10 | |||
| 11 | export 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 @@ | ||
| 1 | import { type InferResponseType, parseResponse } from "hono/client"; | ||
| 2 | import ArrowDown from "lucide-solid/icons/arrow-down"; | ||
| 3 | import ArrowUp from "lucide-solid/icons/arrow-up"; | ||
| 4 | import BatteryWarning from "lucide-solid/icons/battery-warning"; | ||
| 5 | import HardDrive from "lucide-solid/icons/hard-drive"; | ||
| 6 | import Power from "lucide-solid/icons/power"; | ||
| 7 | import Rocket from "lucide-solid/icons/rocket"; | ||
| 8 | import RotateCcw from "lucide-solid/icons/rotate-ccw"; | ||
| 9 | import ShieldCheck from "lucide-solid/icons/shield-check"; | ||
| 10 | import Trash from "lucide-solid/icons/trash"; | ||
| 11 | import Undo2 from "lucide-solid/icons/undo-2"; | ||
| 12 | import X from "lucide-solid/icons/x"; | ||
| 13 | import { createMemo, createResource, createRoot, createSignal, For, type JSX, onCleanup, Show } from "solid-js"; | ||
| 14 | import { type Health, type HostInfo, type Live, type Me, type Series, type ServiceSummary, trouble } from "../types/model.ts"; | ||
| 15 | import { api, queries, reason } from "../api.ts"; | ||
| 16 | import { Ago } from "../components/Ago.tsx"; | ||
| 17 | import { AppIcon } from "../components/AppIcon.tsx"; | ||
| 18 | import { Donut } from "../components/Donut.tsx"; | ||
| 19 | import { lastGood, Loaded } from "../components/Loaded.tsx"; | ||
| 20 | import { Meter } from "../components/Meter.tsx"; | ||
| 21 | import { OpenApp } from "../components/OpenApp.tsx"; | ||
| 22 | import { RangePicker } from "../components/Metric.tsx"; | ||
| 23 | import { Status, StatusLabel } from "../components/Status.tsx"; | ||
| 24 | import { type Reading, Strip } from "../components/Strip.tsx"; | ||
| 25 | import { TabBar } from "../components/TabBar.tsx"; | ||
| 26 | import { toast } from "../components/Toast.tsx"; | ||
| 27 | import { ago, bytes, celsius, cores, duration, percent, plural, rate, watts } from "../format.ts"; | ||
| 28 | import { stream } from "../live.ts"; | ||
| 29 | import snowflake from "../snowflake.svg"; | ||
| 30 | import { confirmDestroyStage, followRun, type StageInfo } from "./Deploys.tsx"; | ||
| 31 | import "./Overview.css"; | ||
| 32 | |||
| 33 | /** How busy the machine is and what needs a look, polled for the overview and the sidebar's badge. */ | ||
| 34 | export 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. */ | ||
| 41 | function 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. */ | ||
| 62 | function 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. */ | ||
| 73 | function 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. */ | ||
| 119 | function 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 | |||
| 165 | type Storage = InferResponseType<typeof api.storage.$get, 200>; | ||
| 166 | |||
| 167 | const 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. */ | ||
| 170 | function 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. */ | ||
| 445 | function 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 | |||
| 521 | export 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 @@ | ||
| 1 | import RotateCw from "lucide-solid/icons/rotate-cw"; | ||
| 2 | import { createSignal, For, Index, onCleanup, type Resource, Show } from "solid-js"; | ||
| 3 | import { A } from "@solidjs/router"; | ||
| 4 | import { parseResponse } from "hono/client"; | ||
| 5 | import type { PaperCloverStats, ProgressNode } from "../types/paperClover.ts"; | ||
| 6 | import { api, queries, unconnected } from "../api.ts"; | ||
| 7 | import { Ago } from "../components/Ago.tsx"; | ||
| 8 | import { showTextDialog } from "../components/Dialog.tsx"; | ||
| 9 | import { lastGood, Loaded } from "../components/Loaded.tsx"; | ||
| 10 | import { type StatusKind, StatusLabel } from "../components/Status.tsx"; | ||
| 11 | import { TabBar } from "../components/TabBar.tsx"; | ||
| 12 | import { toast } from "../components/Toast.tsx"; | ||
| 13 | import { bytes, clock, count, datetime, duration, percent, plural, until } from "../format.ts"; | ||
| 14 | import "./PaperClover.css"; | ||
| 15 | |||
| 16 | const SITE = "https://paperclover.net/file"; | ||
| 17 | /** The runtime side of the source of truth: its logs, restarts and resources. */ | ||
| 18 | const SERVICE = "/services/clover-source-of-truth"; | ||
| 19 | /** Lines a progress node lists before folding the rest into "[N more]". */ | ||
| 20 | const SHOWN = 6; | ||
| 21 | |||
| 22 | const scan = (path: string | null) => parseResponse(api["paper-clover"].scan.$post({ json: { path } })); | ||
| 23 | const 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 | */ | ||
| 29 | function 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 | |||
| 42 | function 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 | |||
| 52 | function 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. */ | ||
| 149 | function 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]". */ | ||
| 163 | function 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 | |||
| 230 | function 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". */ | ||
| 247 | const 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 | */ | ||
| 253 | function 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. */ | ||
| 267 | function 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 | |||
| 349 | function 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 | |||
| 395 | function 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 | |||
| 428 | export 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 @@ | ||
| 1 | import { useSearchParams } from "@solidjs/router"; | ||
| 2 | import { Checkbox } from "../components/Checkbox.tsx"; | ||
| 3 | import { type InferResponseType, parseResponse } from "hono/client"; | ||
| 4 | import ArrowDown from "lucide-solid/icons/arrow-down"; | ||
| 5 | import ArrowDownToLine from "lucide-solid/icons/arrow-down-to-line"; | ||
| 6 | import ArrowUp from "lucide-solid/icons/arrow-up"; | ||
| 7 | import ArrowUpToLine from "lucide-solid/icons/arrow-up-to-line"; | ||
| 8 | import ChevronDown from "lucide-solid/icons/chevron-down"; | ||
| 9 | import ChevronUp from "lucide-solid/icons/chevron-up"; | ||
| 10 | import Download from "lucide-solid/icons/download"; | ||
| 11 | import ArrowUpRight from "lucide-solid/icons/arrow-up-right"; | ||
| 12 | import HardDrive from "lucide-solid/icons/hard-drive"; | ||
| 13 | import Scale from "lucide-solid/icons/scale"; | ||
| 14 | import { createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js"; | ||
| 15 | import { UNKNOWN_ETA } from "../types/model.ts"; | ||
| 16 | import type { Torrent, TorrentState, TORRENT_ACTIONS } from "../types/seedbox.ts"; | ||
| 17 | import { api, queries, reason, unconnected } from "../api.ts"; | ||
| 18 | import { Ago } from "../components/Ago.tsx"; | ||
| 19 | import { Copy } from "../components/Copy.tsx"; | ||
| 20 | import { showConfirmDialog } from "../components/Dialog.tsx"; | ||
| 21 | import { ListPage } from "../components/ListPage.tsx"; | ||
| 22 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; | ||
| 23 | import { Meter } from "../components/Meter.tsx"; | ||
| 24 | import { OpenApp } from "../components/OpenApp.tsx"; | ||
| 25 | import { Reveal } from "../components/Reveal.tsx"; | ||
| 26 | import { type Sort, SortHeader } from "../components/SortHeader.tsx"; | ||
| 27 | import { type StatusKind, StatusLabel } from "../components/Status.tsx"; | ||
| 28 | import { TabBar } from "../components/TabBar.tsx"; | ||
| 29 | import { TimeChart } from "../components/TimeChart.tsx"; | ||
| 30 | import { toast } from "../components/Toast.tsx"; | ||
| 31 | import { bytes, count, duration, percent, rate } from "../format.ts"; | ||
| 32 | import "./Seedbox.css"; | ||
| 33 | |||
| 34 | type Listed = InferResponseType<typeof api.seedbox.$get>["torrents"][number]; | ||
| 35 | type Group = "downloading" | "seeding" | "paused" | "error"; | ||
| 36 | |||
| 37 | const 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 | |||
| 60 | const GROUPS: [Group | "", string][] = [ | ||
| 61 | ["", "all"], ["downloading", "downloading"], ["seeding", "seeding"], ["paused", "paused"], ["error", "errors"], | ||
| 62 | ]; | ||
| 63 | |||
| 64 | const 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 | |||
| 71 | type Action = (typeof TORRENT_ACTIONS)[number]; | ||
| 72 | |||
| 73 | const 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 | |||
| 78 | function 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 | |||
| 191 | function 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 | |||
| 222 | export 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 @@ | ||
| 1 | import { A } from "@solidjs/router"; | ||
| 2 | import { parseResponse } from "hono/client"; | ||
| 3 | import LockKeyhole from "lucide-solid/icons/lock-keyhole"; | ||
| 4 | import { For, Show } from "solid-js"; | ||
| 5 | import type { Container, ServiceDetail, ServiceLink, TaskDefinition } from "../types/model.ts"; | ||
| 6 | import { api, queries, query } from "../api.ts"; | ||
| 7 | import { Ago } from "../components/Ago.tsx"; | ||
| 8 | import { Copy } from "../components/Copy.tsx"; | ||
| 9 | import { Loaded } from "../components/Loaded.tsx"; | ||
| 10 | import { StatusLabel } from "../components/Status.tsx"; | ||
| 11 | import { ago, bytes, cores, duration, plural } from "../format.ts"; | ||
| 12 | import { stream } from "../live.ts"; | ||
| 13 | |||
| 14 | /** Null links come from a source the host doesn't expose yet. */ | ||
| 15 | function 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. */ | ||
| 40 | function 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 | |||
| 63 | function 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 | |||
| 83 | function 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 | |||
| 173 | const 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. */ | ||
| 176 | export 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 @@ | ||
| 1 | import { A } from "@solidjs/router"; | ||
| 2 | import ChevronRight from "lucide-solid/icons/chevron-right"; | ||
| 3 | import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from "solid-js"; | ||
| 4 | import type { Span, Trace } from "../types/model.ts"; | ||
| 5 | import { Copy } from "../components/Copy.tsx"; | ||
| 6 | import { 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. */ | ||
| 9 | export 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 | |||
| 16 | const ROW = 20; | ||
| 17 | const AXIS = 20; | ||
| 18 | /** Narrower bars go unlabelled. */ | ||
| 19 | const LABEL = 28; | ||
| 20 | /** How much of a service's color each shade mixes into the page. */ | ||
| 21 | const SHADES = [0.36, 0.48, 0.6, 0.72]; | ||
| 22 | |||
| 23 | interface 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 | */ | ||
| 43 | function 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`. */ | ||
| 97 | function 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 | |||
| 102 | const token = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); | ||
| 103 | /** `a` over `b` at `amount`, both `#rrggbb`. */ | ||
| 104 | function 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 | |||
| 109 | function 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 | */ | ||
| 128 | export 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 @@ | ||
| 1 | import { A, useParams, useSearchParams } from "@solidjs/router"; | ||
| 2 | import { parseResponse } from "hono/client"; | ||
| 3 | import Eye from "lucide-solid/icons/eye"; | ||
| 4 | import EyeOff from "lucide-solid/icons/eye-off"; | ||
| 5 | import HardDrive from "lucide-solid/icons/hard-drive"; | ||
| 6 | import Package from "lucide-solid/icons/package"; | ||
| 7 | import RotateCcw from "lucide-solid/icons/rotate-ccw"; | ||
| 8 | import Search from "lucide-solid/icons/search"; | ||
| 9 | import X from "lucide-solid/icons/x"; | ||
| 10 | import { | ||
| 11 | createEffect, createMemo, createResource, createSignal, createUniqueId, For, type JSX, Match, onCleanup, Show, Switch, | ||
| 12 | } from "solid-js"; | ||
| 13 | import { Portal } from "solid-js/web"; | ||
| 14 | import { | ||
| 15 | type Action, type LogLine, type Series, type ServiceDetail, type TraceSummary, unacknowledgedRestart, | ||
| 16 | } from "../types/model.ts"; | ||
| 17 | import { logField, needles, parseSearch, traceField, type TraceSort } from "../types/search.ts"; | ||
| 18 | import { api, queries, query, reason } from "../api.ts"; | ||
| 19 | import { Ago } from "../components/Ago.tsx"; | ||
| 20 | import { AppIcon } from "../components/AppIcon.tsx"; | ||
| 21 | import { Copy } from "../components/Copy.tsx"; | ||
| 22 | import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx"; | ||
| 23 | import { ListPage } from "../components/ListPage.tsx"; | ||
| 24 | import { LogView } from "../components/LogView.tsx"; | ||
| 25 | import { OpenApp } from "../components/OpenApp.tsx"; | ||
| 26 | import { lastGood, Loaded } from "../components/Loaded.tsx"; | ||
| 27 | import { RangePicker } from "../components/Metric.tsx"; | ||
| 28 | import { TimeChart } from "../components/TimeChart.tsx"; | ||
| 29 | import { type Sort, SortHeader } from "../components/SortHeader.tsx"; | ||
| 30 | import { Status, StatusLabel } from "../components/Status.tsx"; | ||
| 31 | import { Strip } from "../components/Strip.tsx"; | ||
| 32 | import { TabBar } from "../components/TabBar.tsx"; | ||
| 33 | import { toast } from "../components/Toast.tsx"; | ||
| 34 | import { ago, bytes, clock, cores, datetime, duration, plural } from "../format.ts"; | ||
| 35 | import { stream } from "../live.ts"; | ||
| 36 | import { Definition } from "./Service.definition.tsx"; | ||
| 37 | import { Flame, ms } from "./Service.flame.tsx"; | ||
| 38 | import "./Service.css"; | ||
| 39 | |||
| 40 | const TABS = [["", "logs"], ["traces", "traces"], ["metrics", "metrics"], ["secrets", "secrets"], ["definition", "definition"]] as const; | ||
| 41 | const 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. */ | ||
| 44 | type 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. */ | ||
| 47 | const 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. */ | ||
| 50 | function 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 | |||
| 206 | const 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 }))); | ||
| 208 | const oneTrace = query("trace", (id: string) => parseResponse(api.traces[":id"].$get({ param: { id } }))); | ||
| 209 | const PAGE = 50; | ||
| 210 | |||
| 211 | function 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 | |||
| 239 | function 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. */ | ||
| 390 | function 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 | |||
| 400 | function 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 | */ | ||
| 495 | function 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. */ | ||
| 557 | export function Service() { | ||
| 558 | const params = useParams<{ id: string }>(); | ||
| 559 | return <Show when={params.id} keyed><ServicePage /></Show>; | ||
| 560 | } | ||
| 561 | |||
| 562 | function 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 @@ | ||
| 1 | import { useSearchParams } from "@solidjs/router"; | ||
| 2 | import { parseResponse } from "hono/client"; | ||
| 3 | import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; | ||
| 4 | import type { ServiceSummary } from "../types/model.ts"; | ||
| 5 | import type { MapNode } from "../types/storage.index.ts"; | ||
| 6 | import { api, reason } from "../api.ts"; | ||
| 7 | import { Crumbs, squarify } from "../components/Explorer.tsx"; | ||
| 8 | import { lastGood } from "../components/Loaded.tsx"; | ||
| 9 | import { OpenApp } from "../components/OpenApp.tsx"; | ||
| 10 | import { bytes, percent, plural } from "../format.ts"; | ||
| 11 | |||
| 12 | /** Extensions by type, in categorical color order: video, audio, image, archive, document, code, data, font. */ | ||
| 13 | const 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 | |||
| 24 | const extension = (name: string) => (name.lastIndexOf(".") > 0 ? name.slice(name.lastIndexOf(".") + 1).toLowerCase() : ""); | ||
| 25 | const 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). */ | ||
| 31 | const HEIGHT = 0.38; | ||
| 32 | const FALLOFF = 0.91; | ||
| 33 | const AMBIENT = 0.13; | ||
| 34 | const LIGHT_XY = -1 / Math.hypot(1, 1, 10); | ||
| 35 | const 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. */ | ||
| 37 | const 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. */ | ||
| 39 | const 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. */ | ||
| 42 | interface 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 | |||
| 57 | function 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 | |||
| 74 | const 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. */ | ||
| 77 | function 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 | |||
| 82 | function 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. */ | ||
| 90 | export 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 @@ | ||
| 1 | import { useSearchParams } from "@solidjs/router"; | ||
| 2 | import ArrowDownWideNarrow from "lucide-solid/icons/arrow-down-wide-narrow"; | ||
| 3 | import Disc3 from "lucide-solid/icons/disc-3"; | ||
| 4 | import FolderTree from "lucide-solid/icons/folder-tree"; | ||
| 5 | import LayoutGrid from "lucide-solid/icons/layout-grid"; | ||
| 6 | import { type InferResponseType, parseResponse } from "hono/client"; | ||
| 7 | import ChevronRight from "lucide-solid/icons/chevron-right"; | ||
| 8 | import Clapperboard from "lucide-solid/icons/clapperboard"; | ||
| 9 | import Database from "lucide-solid/icons/database"; | ||
| 10 | import ExternalLink from "lucide-solid/icons/external-link"; | ||
| 11 | import FolderOpen from "lucide-solid/icons/folder-open"; | ||
| 12 | import { createEffect, createMemo, createResource, createSignal, For, type JSX, Match, Show, Switch } from "solid-js"; | ||
| 13 | import type { Health } from "../types/model.ts"; | ||
| 14 | import type { VdevState } from "../types/storage.ts"; | ||
| 15 | import { api, queries, reason } from "../api.ts"; | ||
| 16 | import { Ago } from "../components/Ago.tsx"; | ||
| 17 | import { Copy } from "../components/Copy.tsx"; | ||
| 18 | import { Explorer, REGIONS, squarify } from "../components/Explorer.tsx"; | ||
| 19 | import { showConfirmDialog } from "../components/Dialog.tsx"; | ||
| 20 | import { ListPage } from "../components/ListPage.tsx"; | ||
| 21 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; | ||
| 22 | import { StatusLabel } from "../components/Status.tsx"; | ||
| 23 | import { TabBar } from "../components/TabBar.tsx"; | ||
| 24 | import { ago, bytes, celsius, count, date, datetime, duration, percent, plural } from "../format.ts"; | ||
| 25 | import "./Storage.css"; | ||
| 26 | import { StorageMap } from "./Storage.map.tsx"; | ||
| 27 | |||
| 28 | type Overview = InferResponseType<typeof api.storage.$get, 200>; | ||
| 29 | type Disk = Overview["pool"]["vdevs"][number]["disks"][number]; | ||
| 30 | |||
| 31 | const HEALTH: Record<VdevState, Health> = { | ||
| 32 | ONLINE: "healthy", DEGRADED: "degraded", FAULTED: "down", UNAVAIL: "down", REMOVED: "down", OFFLINE: "stopped", | ||
| 33 | AVAIL: "stopped", INUSE: "degraded", | ||
| 34 | }; | ||
| 35 | |||
| 36 | export const TABS = [ | ||
| 37 | ["datasets", "datasets", Database], ["files", "files", FolderTree], ["map", "map", LayoutGrid], | ||
| 38 | ["largest", "largest files", ArrowDownWideNarrow], ["disks", "disks", Disc3], | ||
| 39 | ] as const; | ||
| 40 | type Tab = (typeof TABS)[number][0]; | ||
| 41 | |||
| 42 | const href = (tab: Tab, path?: string) => `/storage?${new URLSearchParams(path ? { tab, path } : { tab })}`; | ||
| 43 | |||
| 44 | const onActivate = (action: (event: KeyboardEvent) => void) => (event: KeyboardEvent) => { | ||
| 45 | if (event.key !== "Enter" && event.key !== " ") return; | ||
| 46 | event.preventDefault(); | ||
| 47 | action(event); | ||
| 48 | }; | ||
| 49 | |||
| 50 | const 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. */ | ||
| 53 | function 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 | |||
| 62 | const exact = (size: number) => (size >= 1024 ? `${count(size)} bytes` : undefined); | ||
| 63 | |||
| 64 | /** ATA attributes 5 and 197 together; null for NVMe drives, which report neither. */ | ||
| 65 | const badSectors = (disk: Disk) => (disk.smart?.reallocated == null ? null : disk.smart.reallocated + (disk.smart.pending ?? 0)); | ||
| 66 | const 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. */ | ||
| 69 | function 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 | |||
| 138 | function 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 | |||
| 208 | function 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 | |||
| 322 | const W = 1000; | ||
| 323 | const H = 300; | ||
| 324 | type Dataset = Overview["datasets"][number]; | ||
| 325 | /** What a dataset takes in the pool itself: its files and snapshots, without child datasets. */ | ||
| 326 | const within = (path: string, root: string) => path === root || path.startsWith(root + "/"); | ||
| 327 | const own = (dataset: Dataset) => dataset.usedbydataset + dataset.usedbysnapshots; | ||
| 328 | /** Room between regions, and the area under which datasets merge into one block, in treemap units. */ | ||
| 329 | const GAP = 3; | ||
| 330 | const CELL = 400; | ||
| 331 | /** The smallest share a region is drawn at, so prod and staging keep room to point at. */ | ||
| 332 | const FLOOR = 0.1; | ||
| 333 | |||
| 334 | function 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 | |||
| 529 | function 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 | |||
| 570 | export 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 | |||
| 76 | input.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 | |||
| 88 | input.inline:hover { border-color: var(--axis); } | ||
| 89 | input.inline:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-wash); } | ||
| 90 | input.inline[aria-invalid="true"] { border-color: var(--critical); } | ||
| 91 | input.inline[aria-busy="true"] { cursor: progress; opacity: 0.7; } | ||
| 92 | input.inline::placeholder { color: var(--muted); } | ||
| 93 | |||
| 94 | a.grant { border-radius: 3px; transition: color 150ms; } | ||
| 95 | a.grant:hover { color: var(--accent); } | ||
| 96 | .passkeys span + span::before { content: ", "; } | ||
dashboard/web/pages/Users.tsx created+697| ... | @@ -0,0 +1,697 @@ | ||
| 1 | import { A, useNavigate, useParams, useSearchParams } from "@solidjs/router"; | ||
| 2 | import ArrowLeft from "lucide-solid/icons/arrow-left"; | ||
| 3 | import Search from "lucide-solid/icons/search"; | ||
| 4 | import UserPlus from "lucide-solid/icons/user-plus"; | ||
| 5 | import X from "lucide-solid/icons/x"; | ||
| 6 | import { type Accessor, createSignal, For, type Resource, Show } from "solid-js"; | ||
| 7 | import { Dynamic } from "solid-js/web"; | ||
| 8 | import { parseResponse } from "hono/client"; | ||
| 9 | import { canOpen, sectionsOf, type ServiceSummary } from "../types/model.ts"; | ||
| 10 | import type { Group } from "../types/users.ts"; | ||
| 11 | import { api, query, reason } from "../api.ts"; | ||
| 12 | import { Ago } from "../components/Ago.tsx"; | ||
| 13 | import { Checkbox } from "../components/Checkbox.tsx"; | ||
| 14 | import { AppIcon } from "../components/AppIcon.tsx"; | ||
| 15 | import { Copy } from "../components/Copy.tsx"; | ||
| 16 | import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx"; | ||
| 17 | import { ListPage } from "../components/ListPage.tsx"; | ||
| 18 | import { OpenApp } from "../components/OpenApp.tsx"; | ||
| 19 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; | ||
| 20 | import { Reveal } from "../components/Reveal.tsx"; | ||
| 21 | import { PAGES } from "../components/Sidebar.tsx"; | ||
| 22 | import { TabBar } from "../components/TabBar.tsx"; | ||
| 23 | import { toast } from "../components/Toast.tsx"; | ||
| 24 | import { ago, count, date, datetime, plural } from "../format.ts"; | ||
| 25 | import "./Users.css"; | ||
| 26 | |||
| 27 | const 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 | |||
| 35 | const STATES = { all: "all", disabled: "disabled", pending: "setup pending" } as const; | ||
| 36 | |||
| 37 | type Params = { q?: string; group?: string; state?: keyof typeof STATES }; | ||
| 38 | |||
| 39 | const load = () => Promise.all([parseResponse(api.users.$get()), parseResponse(api.services.$get())]) | ||
| 40 | .then(([{ users, groups }, services]) => ({ users, groups, services })); | ||
| 41 | type Data = Awaited<ReturnType<typeof load>>; | ||
| 42 | export const directory = query("users", load); | ||
| 43 | const credentialsOf = query("credentials", (id: string) => parseResponse(api.users[":id"].credentials.$get({ param: { id } }))); | ||
| 44 | type User = Data["users"][number]; | ||
| 45 | |||
| 46 | const fullName = (user: User) => [user.firstName, user.lastName].filter(Boolean).join(" "); | ||
| 47 | const names = (user: User) => user.groups.map((group) => group.name); | ||
| 48 | const 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. */ | ||
| 52 | function 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. */ | ||
| 62 | function 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" */ | ||
| 73 | function 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. */ | ||
| 83 | const 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. */ | ||
| 87 | function 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. */ | ||
| 95 | function 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. */ | ||
| 102 | function 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. */ | ||
| 111 | let listScroll = 0; | ||
| 112 | /** Whether the open user page was reached from the list, so "back" can return to it with its filters. */ | ||
| 113 | let fromList = false; | ||
| 114 | |||
| 115 | export 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 | |||
| 126 | function 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. */ | ||
| 140 | function 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 | |||
| 163 | function 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. */ | ||
| 381 | function useBack() { | ||
| 382 | const navigate = useNavigate(); | ||
| 383 | return () => (fromList ? history.back() : navigate("/users")); | ||
| 384 | } | ||
| 385 | |||
| 386 | function 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 | |||
| 420 | function 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. */ | ||
| 661 | function 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 @@ | ||
| 1 | import { useSearchParams } from "@solidjs/router"; | ||
| 2 | import CircuitBoard from "lucide-solid/icons/circuit-board"; | ||
| 3 | import HardDrive from "lucide-solid/icons/hard-drive"; | ||
| 4 | import Network from "lucide-solid/icons/network"; | ||
| 5 | import Plus from "lucide-solid/icons/plus"; | ||
| 6 | import Power from "lucide-solid/icons/power"; | ||
| 7 | import Usb from "lucide-solid/icons/usb"; | ||
| 8 | import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; | ||
| 9 | import { LIVE_INTERVAL } from "../types/model.ts"; | ||
| 10 | import type { Domain, DOMAIN_ACTIONS, Hostdev, Image } from "../types/vms.ts"; | ||
| 11 | import { parseResponse } from "hono/client"; | ||
| 12 | import { api, queries, reason, unconnected } from "../api.ts"; | ||
| 13 | import { Checkbox } from "../components/Checkbox.tsx"; | ||
| 14 | import { Copy } from "../components/Copy.tsx"; | ||
| 15 | import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx"; | ||
| 16 | import { ListPage } from "../components/ListPage.tsx"; | ||
| 17 | import { lastGood, Loaded } from "../components/Loaded.tsx"; | ||
| 18 | import { Meter } from "../components/Meter.tsx"; | ||
| 19 | import { type StatusKind, StatusLabel } from "../components/Status.tsx"; | ||
| 20 | import { TimeChart } from "../components/TimeChart.tsx"; | ||
| 21 | import { toast } from "../components/Toast.tsx"; | ||
| 22 | import { bytes, datetime, duration, percent, plural } from "../format.ts"; | ||
| 23 | import "./VMs.css"; | ||
| 24 | |||
| 25 | const GiB = 2 ** 30; | ||
| 26 | /** A guest whose agent hasn't answered this long after power-on counts as up anyway; many never install one. */ | ||
| 27 | const STARTING = 180; | ||
| 28 | |||
| 29 | const 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 | |||
| 36 | const isOff = (vm: Domain) => vm.state === "shutoff" || vm.state === "crashed"; | ||
| 37 | const running = (vm: Domain) => vm.state === "running" || vm.state === "blocked"; | ||
| 38 | |||
| 39 | function 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. */ | ||
| 59 | const drive = (path: string) => path.split("/").at(-1)!.replace(/^[a-z]+-/, "").replace(/_[^_]+$/, "").replaceAll("_", " "); | ||
| 60 | |||
| 61 | /** [8, 9, 10, 12] reads "8–10, 12". */ | ||
| 62 | const 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. */ | ||
| 73 | const 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. */ | ||
| 76 | function 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. */ | ||
| 84 | function 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 | |||
| 90 | async 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 | |||
| 95 | const 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 | |||
| 103 | const 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 | |||
| 110 | const 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 | |||
| 118 | const 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 | |||
| 131 | function 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 | |||
| 158 | function 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 | |||
| 234 | function 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 | |||
| 354 | function 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 | |||
| 472 | export 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; } | ||
| 66 | input.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 | } | ||
| 79 | input.yt-title:hover { border-style: solid; } | ||
| 80 | input.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 @@ | ||
| 1 | import { A, useSearchParams } from "@solidjs/router"; | ||
| 2 | import Clock from "lucide-solid/icons/clock"; | ||
| 3 | import ListVideo from "lucide-solid/icons/list-video"; | ||
| 4 | import Tv from "lucide-solid/icons/tv"; | ||
| 5 | import Library from "lucide-solid/icons/library"; | ||
| 6 | import Code from "lucide-solid/icons/code"; | ||
| 7 | import Pause from "lucide-solid/icons/pause"; | ||
| 8 | import Play from "lucide-solid/icons/play"; | ||
| 9 | import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"; | ||
| 10 | import { parseResponse } from "hono/client"; | ||
| 11 | import type { Channels, ConfigFile, Ingest, JobStatus, LibraryEntry, Rules, Show as IndieShow, Video } from "../types/youtube.ts"; | ||
| 12 | import { api, reason, unconnected } from "../api.ts"; | ||
| 13 | import { Ago } from "../components/Ago.tsx"; | ||
| 14 | import { showConfirmDialog } from "../components/Dialog.tsx"; | ||
| 15 | import { ListPage } from "../components/ListPage.tsx"; | ||
| 16 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; | ||
| 17 | import { Meter } from "../components/Meter.tsx"; | ||
| 18 | import { type StatusKind, StatusLabel } from "../components/Status.tsx"; | ||
| 19 | import { TabBar } from "../components/TabBar.tsx"; | ||
| 20 | import { toast } from "../components/Toast.tsx"; | ||
| 21 | import { ago, count, date, datetime, duration, plural, until } from "../format.ts"; | ||
| 22 | import "./YouTube.css"; | ||
| 23 | |||
| 24 | const 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 | |||
| 34 | export const TABS = [["", "queue", ListVideo], ["history", "history", Clock], ["channels", "channels", Tv], ["library", "library", Library], ["config", "YAML", Code]] as const; | ||
| 35 | |||
| 36 | function 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 | |||
| 72 | function 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 | |||
| 106 | const 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 | |||
| 111 | const EVERYTHING = "19700101"; | ||
| 112 | |||
| 113 | function 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 | |||
| 120 | const 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. */ | ||
| 122 | const local = (day: string) => Date.parse(`${day}T00:00`) / 1000; | ||
| 123 | |||
| 124 | function 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 | |||
| 131 | function 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 | |||
| 136 | const 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 | |||
| 142 | const pad = (n: number) => String(n).padStart(2, "0"); | ||
| 143 | const rows = () => [...document.querySelectorAll<HTMLFormElement>(".yt-item:not(.gone)")]; | ||
| 144 | |||
| 145 | function 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 | |||
| 299 | const 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 | |||
| 316 | const 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. */ | ||
| 319 | function 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 | |||
| 404 | function 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 | |||
| 471 | export 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 | |||
| 91 | html, body, #root { height: 100%; margin: 0; } | ||
| 92 | |||
| 93 | body { | ||
| 94 | background: var(--page); | ||
| 95 | color: var(--text); | ||
| 96 | font: 14px/1.4 var(--sans); | ||
| 97 | -webkit-font-smoothing: antialiased; | ||
| 98 | } | ||
| 99 | |||
| 100 | a { color: inherit; text-decoration: none; } | ||
| 101 | button { 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. */ | ||
| 374 | body: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 | |||
| 387 | h2 { | ||
| 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 | |||
| 520 | kbd { | ||
| 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; } | ||
| 614 | picture { 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 | |||
| 636 | table.data { width: 100%; border-collapse: collapse; font-size: 13px; } | ||
| 637 | table.data th { text-align: left; font-weight: 500; color: var(--muted); padding: 6px 8px; border-bottom: 1px solid var(--line); } | ||
| 638 | table.data th.num { text-align: right; } | ||
| 639 | table.data td { padding: 6px 8px; border-bottom: 1px solid var(--grid); font-variant-numeric: tabular-nums; } | ||
| 640 | table.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 | } | ||
| 764 | button.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); } | ||
| 789 | a.stat { transition: border-color 150ms, background-color 150ms; } | ||
| 790 | a.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. */ | ||
| 799 | label.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%. */ | ||
| 822 | td.actions { width: 1%; height: 1px; padding: 0; } | ||
| 823 | td.actions > div { display: flex; justify-content: flex-end; height: 100%; } | ||
| 824 | td.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 | } | ||
| 840 | td.actions :is(button, a):hover:not(:disabled) { background: var(--raised); } | ||
| 841 | td.actions :is(button, a).danger { color: color-mix(in srgb, var(--critical) 80%, var(--text)); } | ||
| 842 | td.actions :is(button, a).danger:hover:not(:disabled) { background: color-mix(in srgb, var(--critical) 12%, transparent); } | ||
| 843 | td.actions button:disabled { opacity: 0.45; cursor: default; } | ||
| 844 | td.actions button[aria-busy="true"] { opacity: 1; cursor: progress; } | ||
| 845 | td.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 | } | ||
| 854 | td.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 @@ | ||
| 1 | export 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 | } | ||
| 7 | export 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 | |||
| 3 | export type Health = "healthy" | "degraded" | "down" | "stopped" | "deploying" | "restarting" | "starting"; | ||
| 4 | |||
| 5 | /** Columnar series, the layout uPlot consumes directly. Times are unix seconds. */ | ||
| 6 | export interface Series { | ||
| 7 | name: string; | ||
| 8 | t: number[]; | ||
| 9 | v: (number | null)[]; | ||
| 10 | } | ||
| 11 | |||
| 12 | export 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. */ | ||
| 21 | export const VIEW_AS = "view-as"; | ||
| 22 | |||
| 23 | /** The Keycloak group that opens each dashboard section; null opens it to everyone. */ | ||
| 24 | const SECTION_GROUPS = { launcher: null, admin: "infra-admin", metrics: "metrics", media: "media-manage", vms: "vm" } as const; | ||
| 25 | export type Section = keyof typeof SECTION_GROUPS; | ||
| 26 | |||
| 27 | /** Admins reach everything; `access` null is open to every signed-in user. */ | ||
| 28 | export const canOpen = (groups: string[], access: string | null) => | ||
| 29 | access === null || groups.includes("infra-admin") || groups.includes(access); | ||
| 30 | |||
| 31 | export const sectionsOf = (groups: string[]) => | ||
| 32 | (Object.keys(SECTION_GROUPS) as Section[]).filter((section) => canOpen(groups, SECTION_GROUPS[section])); | ||
| 33 | |||
| 34 | export 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. */ | ||
| 54 | export 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. */ | ||
| 57 | export 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. */ | ||
| 66 | export type Hook = "prestart" | "poststart" | "poststop" | null; | ||
| 67 | |||
| 68 | /** A Nomad task; its restarts count within the current allocation. */ | ||
| 69 | export 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"). */ | ||
| 81 | export 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. */ | ||
| 89 | export 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. */ | ||
| 95 | export 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. */ | ||
| 120 | export 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 | |||
| 149 | export 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. */ | ||
| 174 | export interface AppLink { | ||
| 175 | app: Pick<ServiceSummary, "id" | "name" | "icon">; | ||
| 176 | url: string; | ||
| 177 | } | ||
| 178 | |||
| 179 | /** An OpenTelemetry span; times are unix seconds. */ | ||
| 180 | export 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. */ | ||
| 193 | export interface Trace { | ||
| 194 | id: string; | ||
| 195 | spans: Span[]; | ||
| 196 | } | ||
| 197 | |||
| 198 | /** A trace as a list shows it, without the spans under its root. */ | ||
| 199 | export 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 | |||
| 208 | export interface LogLine { | ||
| 209 | t: number; | ||
| 210 | container: string; | ||
| 211 | stream: "stdout" | "stderr"; | ||
| 212 | level: "debug" | "info" | "warn" | "error" | null; | ||
| 213 | text: string; | ||
| 214 | } | ||
| 215 | |||
| 216 | export 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 | |||
| 227 | export 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. */ | ||
| 238 | export const LIVE_INTERVAL = 2; | ||
| 239 | |||
| 240 | /** qBittorrent's `eta` when it has no estimate. */ | ||
| 241 | export const UNKNOWN_ETA = 8640000; | ||
| 242 | |||
| 243 | /** One tick of the live stream; every value is the latest sample. */ | ||
| 244 | export 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 | |||
| 254 | export 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; | ||
| 258 | export type Metric = (typeof METRICS)[number]; | ||
| 259 | |||
| 260 | export const ACTIONS = ["restart", "stop", "start"] as const; | ||
| 261 | export 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. */ | ||
| 2 | export 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 | |||
| 23 | export 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 | */ | ||
| 51 | export 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 @@ | ||
| 1 | import assert from "node:assert/strict"; | ||
| 2 | import { test } from "node:test"; | ||
| 3 | import { logField, matches, needles, parseSearch, traceField } from "./search.ts"; | ||
| 4 | |||
| 5 | test("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 | |||
| 18 | test("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. */ | ||
| 2 | export 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. */ | ||
| 12 | export const logField = (field: string) => field === "container" || field === "stream"; | ||
| 13 | |||
| 14 | /** Short names for the request attributes Caddy records on every trace. */ | ||
| 15 | export 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`. */ | ||
| 24 | export const traceField = (field: string) => field in TRACE_ALIASES || field === "duration" || field.includes("."); | ||
| 25 | |||
| 26 | export const TRACE_SORTS = ["newest", "oldest", "slowest", "fastest"] as const; | ||
| 27 | export 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 | */ | ||
| 33 | export 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. */ | ||
| 47 | export 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. */ | ||
| 58 | export 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. */ | ||
| 2 | export 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 | |||
| 7 | export 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 | |||
| 34 | export interface TorrentFile { | ||
| 35 | name: string; | ||
| 36 | size: number; | ||
| 37 | progress: number; | ||
| 38 | } | ||
| 39 | |||
| 40 | export 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 | |||
| 53 | export 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. */ | ||
| 2 | export type MapNode = [name: string, size: number] | [name: string, size: number, files: number, children: MapNodes]; | ||
| 3 | |||
| 4 | export 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. */ | ||
| 2 | export type VdevState = "ONLINE" | "DEGRADED" | "FAULTED" | "OFFLINE" | "UNAVAIL" | "REMOVED" | "AVAIL" | "INUSE"; | ||
| 3 | |||
| 4 | export 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. */ | ||
| 27 | export interface Vdev { | ||
| 28 | name: string; | ||
| 29 | state: VdevState; | ||
| 30 | disks: Disk[]; | ||
| 31 | } | ||
| 32 | |||
| 33 | export 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. */ | ||
| 56 | export 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 | |||
| 77 | export 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. */ | ||
| 2 | export 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 | |||
| 17 | export interface Group { | ||
| 18 | id: string; | ||
| 19 | name: string; | ||
| 20 | } | ||
| 21 | |||
| 22 | export interface Credential { | ||
| 23 | id: string; | ||
| 24 | type: "password" | "webauthn-passwordless" | "webauthn" | "otp"; | ||
| 25 | userLabel: string | null; | ||
| 26 | createdDate: number; | ||
| 27 | } | ||
| 28 | |||
| 29 | export 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. */ | ||
| 2 | export type DomainState = "running" | "blocked" | "paused" | "shutdown" | "shutoff" | "crashed" | "pmsuspended"; | ||
| 3 | |||
| 4 | export 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 | |||
| 15 | export 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 | |||
| 24 | export 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. */ | ||
| 54 | export 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. */ | ||
| 64 | export interface History { | ||
| 65 | t: number[]; | ||
| 66 | domains: Record<string, { cpu: (number | null)[]; memory: (number | null)[] }>; | ||
| 67 | } | ||
| 68 | |||
| 69 | export const DOMAIN_ACTIONS = ["start", "shutdown", "reboot", "destroy", "resume"] as const; | ||
| 70 | export 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 @@ | ||
| 1 | export 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 | |||
| 15 | export interface Show { | ||
| 16 | name: string; | ||
| 17 | seasons: { number: number; episodes: number }[]; | ||
| 18 | } | ||
| 19 | |||
| 20 | export 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 | |||
| 30 | export type JobStatus = "queued" | "resolving" | "downloading" | "retrying" | "waiting" | "done" | "error"; | ||
| 31 | |||
| 32 | export 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. */ | ||
| 49 | export 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. */ | ||
| 55 | export interface Archive { | ||
| 56 | started: number | null; | ||
| 57 | finished: number | null; | ||
| 58 | walled: boolean; | ||
| 59 | next: number; | ||
| 60 | } | ||
| 61 | |||
| 62 | export interface Upscaler { | ||
| 63 | enabled: boolean; | ||
| 64 | running: boolean; | ||
| 65 | done: number; | ||
| 66 | total: number; | ||
| 67 | current: string; | ||
| 68 | errors: number; | ||
| 69 | } | ||
| 70 | |||
| 71 | export type ConfigFile = { name: string; body: string }; | ||
| 72 | export type Ingest = {dest:"independent"}|{dest:"music"}|{dest:"indie";show:string;season:number;episode:number;title:string}; | ||
| 73 | export interface Rules {download_after?:string;title_include_keywords?:string[];title_exclude_keywords?:string[];description_include_keywords?:string[];description_exclude_keywords?:string[];} | ||
| 74 | export 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, ... }: | ||
| 2 | let | ||
| 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 { }; | ||
| 12 | in | ||
| 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 }: | ||
| 2 | let | ||
| 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 | ''; | ||
| 66 | in dashboard | ||
nixos/hardware-configuration.nix created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | throw "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 }: | ||
| 2 | stdenv.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, ... }: | ||
| 2 | let | ||
| 3 | adminKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMpxNpkRLTUijhd3HSaOvKYn2MWXEY+YEFdsPNZhBROn clo@sandwich.local"; | ||
| 4 | in | ||
| 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 | |||
| 3 | This codebase contains declarative configuration to run my home server, a | ||
| 4 | computer sitting in my closet used daily to power my activities. It first and | ||
| 5 | foremost 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, | ||
| 7 | from Jellyfin and Navidrome for media consumption to git hosting and render | ||
| 8 | farms. | ||
| 9 | |||
| 10 | The system is built out of a NixOS base (just to install the system itself), | ||
| 11 | using managed docker containers (managed via Nomad) and virtual machines to run | ||
| 12 | all the real services. There is a lot of custom tooling to spawn interactive | ||
| 13 | testing environments. For example, without even pushing any code, I can edit the | ||
| 14 | configuration 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 | ||
| 16 | production ensures that the new deployment is healthy before routing traffic to | ||
| 17 | it -- It's kind of like "easy kubernetes." | ||
| 18 | |||
| 19 | [paper clover]: https://paperclover.net | ||
| 20 | |||
| 21 | ## deployment loop | ||
| 22 | |||
| 23 | ```sh | ||
| 24 | python3 tools/deploy.py stage shale # preview the working files, even without a commit message | ||
| 25 | python3 tools/deploy.py stage shale # update the same URL and staging data after another edit | ||
| 26 | python3 tools/deploy.py publish # upload the described, conflict-free main commit for GUI review | ||
| 27 | python3 tools/deploy.py prod # upload and deploy main directly | ||
| 28 | ``` | ||
| 29 | |||
| 30 | Production always deploys a snapshot exported from `main`, never working files | ||
| 31 | or a stage. The dashboard's **deploy main** page shows the uploaded commit and | ||
| 32 | its message; deploying applies the full repository configuration and retains | ||
| 33 | production data. A newer upload invalidates an older deployment confirmation. | ||
| 34 | Sibling application build sources declared in `build-source.json` are bundled | ||
| 35 | at 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; | ||
| 38 | the retired configuration remains available in the home-infra parent history. | ||
| 39 | |||
| 40 | ## filesystem layout | ||
| 41 | |||
| 42 | The computer mounts the ZFS root dataset under `/srv`, meaning "server," loosely | ||
| 43 | following the linux convention. Within, it's a unique structure: | ||
| 44 | |||
| 45 | ``` | ||
| 46 | srv/ | ||
| 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 | |||
| 74 | Media is it's own dataset so it can be snapshotted independently of my personal | ||
| 75 | data (less frequent, lower retention), and all my personal files are on the same | ||
| 76 | dataset to allow fast move/copying between top level folders. Services use their | ||
| 77 | own 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 | ||
| 82 | subdomains through the rehearsal VM's HTTPS, DNS, and login services. Start the | ||
| 83 | rehearsal SSH forwards first, then run: | ||
| 84 | |||
| 85 | ```sh | ||
| 86 | sudo /usr/bin/python3 tools/mac-domains.py start /path/to/rehearsal-ca.crt | ||
| 87 | ``` | ||
| 88 | |||
| 89 | The relay binds loopback ports, drops administrator privileges, and passes TLS | ||
| 90 | through to the VM. Local UDP DNS queries use the SSH tunnel's TCP DNS connection. | ||
| 91 | Existing certificate trust is preserved. Mac DNS and hosts | ||
| 92 | settings are backed up before editing; `sudo /usr/bin/python3 tools/mac-domains.py | ||
| 93 | stop` restores them and stops the relay. Undo refuses to overwrite later edits. | ||
| 94 | After a Mac restart, run stop and start again to restart the relay. | ||
| 95 | |||
| 96 | The dashboard package uses `home-dashboard`. Existing state paths, environment | ||
| 97 | names, service IDs, and telemetry names keep their old names for compatibility. | ||
| 98 | |||
| 99 | ## dashboard boundary | ||
| 100 | |||
| 101 | NixOS runs `studio-dashboard` in a non-root Podman container with a read-only | ||
| 102 | root, private network, resource limits, and explicit data mounts. The small | ||
| 103 | `studio-host` service authenticates the dashboard UID on its Unix socket and | ||
| 104 | performs bounded ZFS, VM, deployment, host-sampling, and identity operations. | ||
| 105 | Host control sockets and management credentials stay outside the container. | ||
| 106 | |||
| 107 | The MCP tab manages separate observability, agent, and Shale catalogs through | ||
| 108 | the existing Keycloak realm. Each connection has explicit service, machine, or | ||
| 109 | repository grants; Shale credentials belong to the signed-in user. Agent Relay's | ||
| 110 | existing outbound client protocol connects to the Rust server. | ||
| 111 | |||
| 112 | Build the image with `nix build .#dashboard-image` on Linux. Run | ||
| 113 | `python3 tools/dashboard-unit-test.py --output /tmp/dashboard-checks.json` on the | ||
| 114 | rehearsal VM to exercise the generated NixOS units, containment, IAM, and MCP | ||
| 115 | connectors with disposable fixtures. `--relay-agent-dir` includes the existing | ||
| 116 | Agent Relay client interoperability check; `--browser-ready-file` temporarily | ||
| 117 | routes 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | meta { name = "Clover DB" } | ||
| 6 | |||
| 7 | local isTest = site.domain.endsWith(".test") | ||
| 8 | |||
| 9 | secrets = if (isTest) new { ["key"] {} } else new {} | ||
| 10 | requiredSecrets = if (isTest) new {} else new { "key" } | ||
| 11 | |||
| 12 | container { | ||
| 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 | ||
| 2 | import json | ||
| 3 | import os | ||
| 4 | from pathlib import Path | ||
| 5 | import re | ||
| 6 | import subprocess | ||
| 7 | import sys | ||
| 8 | |||
| 9 | |||
| 10 | data = json.load(sys.stdin) | ||
| 11 | root = Path(data["hostRoot"]) / "cfg" | ||
| 12 | logs = Path(data["hostRoot"]) / "w/logs" | ||
| 13 | logs.mkdir(parents=True, exist_ok=True) | ||
| 14 | os.chown(logs, data["uid"], data["uid"]) | ||
| 15 | service = Path(data["hostRoot"]).name | ||
| 16 | secret = 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"] | ||
| 20 | if not re.fullmatch(r"[0-9a-f]{32}", secret): | ||
| 21 | raise ValueError("invalid Copyparty identity secret") | ||
| 22 | config = Path(__file__).with_name("copyparty.conf").read_text() | ||
| 23 | if config.count("STUDIO_IDP_HEADER") != 1: | ||
| 24 | raise ValueError("expected one Copyparty identity header marker") | ||
| 25 | config = config.replace("STUDIO_IDP_HEADER", "X-Studio-Idp-" + secret) | ||
| 26 | if config.count("STUDIO_MIN_FREE_GB") != 1: | ||
| 27 | raise ValueError("expected one Copyparty free-space marker") | ||
| 28 | capacity = os.statvfs(data["hostRoot"]) | ||
| 29 | reserve = max(1, min(16, capacity.f_blocks * capacity.f_frsize // (4 * 10**9))) | ||
| 30 | config = config.replace("STUDIO_MIN_FREE_GB", str(reserve)) | ||
| 31 | target = root / "copyparty.conf" | ||
| 32 | if 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) | ||
| 38 | os.chmod(target, 0o600) | ||
| 39 | os.chown(target, data["uid"], data["uid"]) | ||
service/copyparty/reject-apple-cruft.py created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import os | ||
| 3 | import sys | ||
| 4 | |||
| 5 | |||
| 6 | BAD_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 | |||
| 13 | name = os.path.basename(sys.argv[1]) | ||
| 14 | sys.exit(1 if name.startswith("._") or name in BAD_EXACT else 0) | ||
service/copyparty/service.pkl created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | meta { name = "Copyparty" } | ||
| 6 | rollout = "simple" | ||
| 7 | prepare = "prepare.py" | ||
| 8 | |||
| 9 | secrets { | ||
| 10 | ["idp_header"] { bytes = 16 } | ||
| 11 | } | ||
| 12 | |||
| 13 | container { | ||
| 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 | |||
| 43 | body { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | meta { name = "Redis Insight" } | ||
| 4 | dependsOn { "dawarich" } | ||
| 5 | |||
| 6 | secrets { | ||
| 7 | ["encryption_key"] {} | ||
| 8 | } | ||
| 9 | |||
| 10 | container { | ||
| 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 @@ | ||
| 1 | extends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/Service.pkl" as service | ||
| 4 | import "../keycloak/service.pkl" as keycloak | ||
| 5 | import "../postgres/service.pkl" as postgres | ||
| 6 | |||
| 7 | local dawarichImage = "docker.io/freikin/dawarich@sha256:76ec5fa62f414a5ca9e6dd71a9a5b09088c0011075c41644ba35bbb67627a943" | ||
| 8 | |||
| 9 | local database = new postgres.Database { | ||
| 10 | name = "dawarich" | ||
| 11 | extensions { "postgis" } | ||
| 12 | } | ||
| 13 | |||
| 14 | local 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 | |||
| 30 | local 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 | |||
| 40 | local 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 | |||
| 46 | meta { name = "Dawarich" } | ||
| 47 | healthyDeadline = "20m" | ||
| 48 | |||
| 49 | requirements { | ||
| 50 | database | ||
| 51 | new keycloak.OpenIDClient { | ||
| 52 | clientId = module.id | ||
| 53 | name = module.meta.name | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | secrets { | ||
| 58 | ["secret_key_base"] { bytes = 64 } | ||
| 59 | } | ||
| 60 | |||
| 61 | containers { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | meta { name = "DDNS" } | ||
| 6 | enabled = !site.preview && !site.domain.endsWith(".test") | ||
| 7 | |||
| 8 | requiredSecrets { "cloudflare_zone_id"; "cloudflare_api_token" } | ||
| 9 | |||
| 10 | container { | ||
| 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 @@ | ||
| 1 | import json | ||
| 2 | import os | ||
| 3 | import subprocess | ||
| 4 | import urllib.request | ||
| 5 | |||
| 6 | |||
| 7 | def 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 | |||
| 16 | def 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 @@ | ||
| 1 | APP_NAME="evil inc" | ||
| 2 | APP_SLOGAL="be evil. take control back." | ||
| 3 | RUN_MODE=prod | ||
| 4 | RUN_USER=git | ||
| 5 | WORK_PATH=/data/work | ||
| 6 | |||
| 7 | [repository] | ||
| 8 | MAX_CREATION_LIMIT=20000 | ||
| 9 | ROOT=/data/git/repositories | ||
| 10 | DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH=true | ||
| 11 | DEFAULT_REPO_UNITS=repo.code,repo.issues,repo.pulls | ||
| 12 | DISABLE_STARS=true | ||
| 13 | DEFAULT_BRANCH=master | ||
| 14 | |||
| 15 | [repository.local] | ||
| 16 | LOCAL_COPY_PATH=/data/work/tmp/local-repo | ||
| 17 | |||
| 18 | [repository.upload] | ||
| 19 | TEMP_PATH=/data/work/uploads | ||
| 20 | |||
| 21 | [ui] | ||
| 22 | EXPLORE_PAGING_NUM=100 | ||
| 23 | ISSUE_PAGING_NUM=500 | ||
| 24 | MEMBERS_PAGING_NUM=100 | ||
| 25 | THEMES=evil,evil-dark,evil-light | ||
| 26 | DEFAULT_THEME=evil | ||
| 27 | |||
| 28 | [server] | ||
| 29 | APP_DATA_PATH=/data/work | ||
| 30 | DOMAIN=$GIT_HOST | ||
| 31 | SSH_DOMAIN=$GIT_HOST | ||
| 32 | HTTP_ADDR=0.0.0.0 | ||
| 33 | HTTP_PORT=$FORGEJO_HTTP_PORT | ||
| 34 | ROOT_URL=https://$GIT_HOST/ | ||
| 35 | LFS_JWT_SECRET=$FORGEJO_SERVER_LFS_JWT_SECRET | ||
| 36 | LFS_START_SERVER=true | ||
| 37 | OFFLINE_MODE=true | ||
| 38 | LANDING_PAGE=/evil | ||
| 39 | START_SSH_SERVER=false | ||
| 40 | SSH_CREATE_AUTHORIZED_KEYS_FILE=false | ||
| 41 | |||
| 42 | [database] | ||
| 43 | DB_TYPE=postgres | ||
| 44 | HOST=$DB_HOST | ||
| 45 | NAME=$DB_NAME | ||
| 46 | USER=$DB_USER | ||
| 47 | PASSWD=$DB_PASSWORD | ||
| 48 | LOG_SQL=false | ||
| 49 | SCHEMA= | ||
| 50 | SSL_MODE=disable | ||
| 51 | |||
| 52 | [indexer] | ||
| 53 | ISSUE_INDEXER_PATH=/data/work/indexers/issues.bleve | ||
| 54 | |||
| 55 | [session] | ||
| 56 | PROVIDER_CONFIG=/data/work/sessions | ||
| 57 | PROVIDER=file | ||
| 58 | |||
| 59 | [picture] | ||
| 60 | AVATAR_UPLOAD_PATH=/data/work/avatars | ||
| 61 | REPOSITORY_AVATAR_UPLOAD_PATH=/data/work/repo-avatars | ||
| 62 | |||
| 63 | [admin] | ||
| 64 | DISABLE_REGULAR_ORG_CREATION=true | ||
| 65 | |||
| 66 | [attachment] | ||
| 67 | PATH=/data/work/attachments | ||
| 68 | |||
| 69 | [log] | ||
| 70 | MODE=console | ||
| 71 | LEVEL=info | ||
| 72 | ROOT_PATH=/data/work/log | ||
| 73 | |||
| 74 | [security] | ||
| 75 | INSTALL_LOCK=true | ||
| 76 | REVERSE_PROXY_LIMIT=1 | ||
| 77 | REVERSE_PROXY_TRUSTED_PROXIES=* | ||
| 78 | PASSWORD_HASH_ALGO=pbkdf2_hi | ||
| 79 | SECRET_KEY=$FORGEJO_SECURITY_SECRET_KEY | ||
| 80 | INTERNAL_TOKEN=$FORGEJO_SECURITY_INTERNAL_TOKEN | ||
| 81 | |||
| 82 | [service] | ||
| 83 | ENABLE_INTERNAL_SIGNIN=true | ||
| 84 | DISABLE_REGISTRATION=false | ||
| 85 | ALLOW_ONLY_EXTERNAL_REGISTRATION=true | ||
| 86 | SHOW_REGISTRATION_BUTTON=false | ||
| 87 | REQUIRE_SIGNIN_VIEW=false | ||
| 88 | REGISTER_EMAIL_CONFIRM=true | ||
| 89 | ENABLE_NOTIFY_MAIL=true | ||
| 90 | DEFAULT_KEEP_EMAIL_PRIVATE=false | ||
| 91 | DEFAULT_ALLOW_CREATE_ORGANIZATION=true | ||
| 92 | DEFAULT_ENABLE_TIMETRACKING=false | ||
| 93 | |||
| 94 | [lfs] | ||
| 95 | PATH = /data/git/lfs | ||
| 96 | |||
| 97 | [mailer] | ||
| 98 | ENABLED=$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 | ||
| 101 | FROM=noreply.evil-git@paperclover.net | ||
| 102 | PROTOCOL=smtps | ||
| 103 | SMTP_PORT=465 | ||
| 104 | SMTP_ADDR=$MAILER_ADDRESS | ||
| 105 | USER=$MAILER_USERNAME | ||
| 106 | PASSWD=$MAILER_PASSWORD | ||
| 107 | |||
| 108 | [repository.pull-request] | ||
| 109 | DEFAULT_MERGE_STYLE=squash | ||
| 110 | |||
| 111 | [repository.signing] | ||
| 112 | DEFAULT_TRUST_MODEL=committer | ||
| 113 | |||
| 114 | [oauth2] | ||
| 115 | JWT_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 | ||
| 2 | import json | ||
| 3 | import sys | ||
| 4 | import urllib.error | ||
| 5 | import urllib.request | ||
| 6 | |||
| 7 | from api import instance | ||
| 8 | |||
| 9 | |||
| 10 | data = json.load(sys.stdin) | ||
| 11 | request = data["request"] | ||
| 12 | if request["kind"] != "oauth-client": | ||
| 13 | raise ValueError("unsupported Forgejo input") | ||
| 14 | stage = data.get("stageId") | ||
| 15 | existing = data.get("existing") or {} | ||
| 16 | if data.get("operation") == "delete" and not stage: | ||
| 17 | raise ValueError("refusing to delete a production OAuth client") | ||
| 18 | _, address = instance() | ||
| 19 | authorization = "token " + data["providerSecrets"]["automation_token"] | ||
| 20 | |||
| 21 | |||
| 22 | def 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 | |||
| 38 | application_id = existing.get("applicationId") | ||
| 39 | if data.get("operation") == "delete": | ||
| 40 | if application_id: | ||
| 41 | api("/user/applications/oauth2/" + application_id, "DELETE") | ||
| 42 | sys.exit(0) | ||
| 43 | |||
| 44 | suffix = request["name"] + (":" + stage if stage else "") | ||
| 45 | name = "Snow Globe: " + suffix | ||
| 46 | desired = {"name": name, "redirect_uris": request["redirectUris"], "confidential_client": True} | ||
| 47 | if 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 | ||
| 56 | if 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"] | ||
| 64 | if not client_id or not client_secret: | ||
| 65 | raise ValueError("Forgejo OAuth client has no credentials") | ||
| 66 | print(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 @@ | ||
| 1 | extends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/Service.pkl" as service | ||
| 4 | import "../../config/site.pkl" as site | ||
| 5 | import "../postgres/service.pkl" as postgres | ||
| 6 | |||
| 7 | class 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 | |||
| 15 | local gitHost = if (site.domain.endsWith(".test")) "git.evil.\(site.domain)" else "git.evil.inc" | ||
| 16 | |||
| 17 | meta { name = "evil.inc Git" } | ||
| 18 | healthyDeadline = "20m" | ||
| 19 | healthRestartGrace = "2m" | ||
| 20 | setup = "setup.py" | ||
| 21 | provide = "provide.py" | ||
| 22 | |||
| 23 | requirements { new postgres.Database { name = "evil_forgejo" } } | ||
| 24 | |||
| 25 | secrets { | ||
| 26 | ["lfs_jwt"] {} | ||
| 27 | ["oauth_jwt"] {} | ||
| 28 | ["security_key"] {} | ||
| 29 | ["internal_token"] {} | ||
| 30 | ["anubis_key"] {} | ||
| 31 | ["automation_password"] {} | ||
| 32 | } | ||
| 33 | |||
| 34 | requiredSecrets = if (site.domain.endsWith(".test")) new Listing {} else new Listing { | ||
| 35 | "mailer_address" | ||
| 36 | "mailer_username" | ||
| 37 | "mailer_password" | ||
| 38 | } | ||
| 39 | |||
| 40 | containers { | ||
| 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 | ||
| 2 | import json | ||
| 3 | import os | ||
| 4 | import secrets | ||
| 5 | import subprocess | ||
| 6 | import sys | ||
| 7 | import tempfile | ||
| 8 | import urllib.error | ||
| 9 | import urllib.request | ||
| 10 | |||
| 11 | from api import instance | ||
| 12 | |||
| 13 | |||
| 14 | data = json.load(sys.stdin) | ||
| 15 | container, address = instance(data["serviceId"]) | ||
| 16 | script = """ | ||
| 17 | IFS= read -r password | ||
| 18 | cd /app/gitea | ||
| 19 | su-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 | ||
| 22 | su-exec git /usr/local/bin/gitea admin user change-password \ | ||
| 23 | --username studio-automation --password "$password" >/dev/null | ||
| 24 | su-exec git /usr/local/bin/gitea admin user must-change-password \ | ||
| 25 | --unset studio-automation >/dev/null | ||
| 26 | """ | ||
| 27 | result = 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 | ) | ||
| 32 | if result.returncode: | ||
| 33 | raise RuntimeError("could not prepare Forgejo automation account") | ||
| 34 | |||
| 35 | path = "nomad/jobs/" + data["serviceId"] | ||
| 36 | request = urllib.request.Request( | ||
| 37 | "http://127.0.0.1:4646/v1/var/" + path, | ||
| 38 | headers={"X-Nomad-Token": os.environ["NOMAD_TOKEN"]}, | ||
| 39 | ) | ||
| 40 | with urllib.request.urlopen(request, timeout=5) as response: | ||
| 41 | variable = json.load(response) | ||
| 42 | current = variable["Items"].get("automation_token") | ||
| 43 | if 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 | ||
| 52 | if 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 | ||
| 2 | set -eu | ||
| 3 | |||
| 4 | mkdir -p /custom/conf | ||
| 5 | envsubst < /studio/app.ini > /custom/conf/app.ini | ||
| 6 | chown "$USER_UID:$USER_GID" /custom/conf /custom/conf/app.ini | ||
| 7 | chmod 750 /custom/conf | ||
| 8 | chmod 600 /custom/conf/app.ini | ||
| 9 | # The image's s6 bundle starts an SSH daemon on the host's port 22. | ||
| 10 | exec /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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | import "../evil-forgejo/service.pkl" as forgejo | ||
| 5 | import "../postgres/service.pkl" as postgres | ||
| 6 | |||
| 7 | local host = if (site.preview) "\(module.id).evil.\(site.domain)" else if (site.domain.endsWith(".test")) "md.evil.\(site.domain)" else "md.evil.inc" | ||
| 8 | |||
| 9 | meta { name = "evil.inc Notes" } | ||
| 10 | healthyDeadline = "15m" | ||
| 11 | healthRestartGrace = "5m" | ||
| 12 | |||
| 13 | requirements { | ||
| 14 | new postgres.Database { name = "evil_hedgedoc" } | ||
| 15 | new forgejo.OAuthClient { | ||
| 16 | name = "HedgeDoc" | ||
| 17 | redirectUris { "https://\(host)/auth/oauth2/callback" } | ||
| 18 | } | ||
| 19 | } | ||
| 20 | |||
| 21 | secrets { ["session_secret"] {} } | ||
| 22 | |||
| 23 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | meta { name = "FlareSolverr" } | ||
| 4 | rollout = "overlapped" | ||
| 5 | |||
| 6 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | import "../keycloak/service.pkl" as keycloak | ||
| 5 | |||
| 6 | meta { name = "Forward Auth" } | ||
| 7 | rollout = "overlapped" | ||
| 8 | |||
| 9 | requirements { | ||
| 10 | new keycloak.OpenIDClient { | ||
| 11 | clientId = module.id | ||
| 12 | name = module.meta.name | ||
| 13 | } | ||
| 14 | } | ||
| 15 | |||
| 16 | secrets { | ||
| 17 | ["cookie"] { bytes = 16 } | ||
| 18 | } | ||
| 19 | |||
| 20 | container { | ||
| 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 @@ | ||
| 1 | FROM docker.io/library/python:3.13-slim@sha256:37134a49d21d2120e4c4d73bb76f8a4ab9aef31f096f7ec2ead48c2feead4332 | ||
| 2 | COPY requirements.txt /app/requirements.txt | ||
| 3 | RUN pip install --no-cache-dir -r /app/requirements.txt | ||
| 4 | COPY app.py /app/app.py | ||
| 5 | CMD ["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. | ||
| 5 | import hmac | ||
| 6 | import json | ||
| 7 | import os | ||
| 8 | import re | ||
| 9 | import threading | ||
| 10 | import time | ||
| 11 | |||
| 12 | from flask import Flask, Response, jsonify, request | ||
| 13 | |||
| 14 | os.umask(0o007) | ||
| 15 | |||
| 16 | DATA_DIR = os.environ.get("DATA_DIR", "/data") | ||
| 17 | KEY = os.environ["INTAKE_KEY"] | ||
| 18 | # filenames are user-supplied path segments; anything outside this can escape | ||
| 19 | NAME_RE = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}\Z") | ||
| 20 | |||
| 21 | app = Flask(__name__) | ||
| 22 | app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 * 1024 | ||
| 23 | write_lock = threading.Lock() | ||
| 24 | |||
| 25 | |||
| 26 | def 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 | |||
| 35 | def 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 | ||
| 42 | def 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") | ||
| 50 | def health(): | ||
| 51 | return "", 204 | ||
| 52 | |||
| 53 | |||
| 54 | @app.get("/") | ||
| 55 | def 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>") | ||
| 65 | def 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>") | ||
| 89 | def 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 | |||
| 97 | if __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 @@ | ||
| 1 | blinker==1.9.0 | ||
| 2 | click==8.5.0 | ||
| 3 | Flask==3.1.3 | ||
| 4 | itsdangerous==2.2.0 | ||
| 5 | Jinja2==3.1.6 | ||
| 6 | MarkupSafe==3.0.3 | ||
| 7 | Werkzeug==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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | meta { name = "JSON Intake" } | ||
| 6 | |||
| 7 | secrets = if (site.domain.endsWith(".test")) new { ["key"] {} } else new {} | ||
| 8 | requiredSecrets = if (site.domain.endsWith(".test")) new {} else new { "key" } | ||
| 9 | |||
| 10 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | meta { name = "Jackett" } | ||
| 4 | |||
| 5 | container { | ||
| 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 | |||
| 11 | and 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 { | ||
| 27 | display: block; | ||
| 28 | content: &quot; &quot;; | ||
| 29 | width:24px; | ||
| 30 | height:24px; | ||
| 31 | margin-right: 0.5rem; | ||
| 32 | } | ||
| 33 | .snow-sign-on:before{ | ||
| 34 | background:url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9Ii0yIC0yIDI4IDI4IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNMTAgMjBMOC43NSAxNy41TDYgMThNMTAgNEw4Ljc1IDYuNUw2IDZNMTQgMjBMMTUuMjUgMTcuNUwxOCAxOE0xNCA0TDE1LjI1IDYuNUwxOCA2TTE3IDIxTDE0IDE1TTE0IDE1SDEwTTE0IDE1TDE1LjUgMTJNMTAgMTVMNyAyMU0xMCAxNUw4LjUgMTJNMTcgM0wxNCA5TTE0IDlMMTUuNSAxMk0xNCA5SDEwTTE1LjUgMTJIMjJNMiAxMkg4LjVNOC41IDEyTDEwIDlNMTAgOUw3IDNNMjAgMTBMMTguNSAxMkwyMCAxNE00IDEwTDUuNSAxMkw0IDE0IiBzdHJva2U9InVybCgjcGFpbnQwX2xpbmVhcl81NDhfMTkpIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8cGF0aCBkPSJNMTAgMjBMOC43NSAxNy41TDYgMThNMTAgNEw4Ljc1IDYuNUw2IDZNMTQgMjBMMTUuMjUgMTcuNUwxOCAxOE0xNCA0TDE1LjI1IDYuNUwxOCA2TTE3IDIxTDE0IDE1TTE0IDE1SDEwTTE0IDE1TDE1LjUgMTJNMTAgMTVMNyAyMU0xMCAxNUw4LjUgMTJNMTcgM0wxNCA5TTE0IDlMMTUuNSAxMk0xNCA5SDEwTTE1LjUgMTJIMjJNMiAxMkg4LjVNOC41IDEyTDEwIDlNMTAgOUw3IDNNMjAgMTBMMTguNSAxMkwyMCAxNE00IDEwTDUuNSAxMkw0IDE0IiBzdHJva2U9InVybCgjcGFpbnQxX2xpbmVhcl81NDhfMTkpIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzU0OF8xOSIgeDE9IjQiIHkxPSItMiIgeDI9IjE4LjUiIHkyPSIyNSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjMjIzRDk5Ii8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzE1NDM5MiIvPgo8L2xpbmVhckdyYWRpZW50Pgo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MV9saW5lYXJfNTQ4XzE5IiB4MT0iMTAiIHkxPSItMyIgeDI9IjE4IiB5Mj0iMjciIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KPHN0b3Agc3RvcC1jb2xvcj0iI0YyRTNGRiIvPgo8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGMkY4RkYiLz4KPC9saW5lYXJHcmFkaWVudD4KPC9kZWZzPgo8L3N2Zz4=); | ||
| 35 | } | ||
| 36 | .btnQuick:before{ | ||
| 37 | mask-image:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNoaWVsZC1lbGxpcHNpcy1pY29uIGx1Y2lkZS1zaGllbGQtZWxsaXBzaXMiPjxwYXRoIGQ9Ik0yMCAxM2MwIDUtMy41IDcuNS03LjY2IDguOTVhMSAxIDAgMCAxLS42Ny0uMDFDNy41IDIwLjUgNCAxOCA0IDEzVjZhMSAxIDAgMCAxIDEtMWMyIDAgNC41LTEuMiA2LjI0LTIuNzJhMS4xNyAxLjE3IDAgMCAxIDEuNTIgMEMxNC41MSAzLjgxIDE3IDUgMTkgNWExIDEgMCAwIDEgMSAxeiIvPjxwYXRoIGQ9Ik04IDEyaC4wMSIvPjxwYXRoIGQ9Ik0xMiAxMmguMDEiLz48cGF0aCBkPSJNMTYgMTJoLjAxIi8+PC9zdmc+); | ||
| 38 | mask-size:cover; | ||
| 39 | background: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 | ||
| 2 | import json | ||
| 3 | import ssl | ||
| 4 | import sys | ||
| 5 | import time | ||
| 6 | import urllib.error | ||
| 7 | import urllib.parse | ||
| 8 | import urllib.request | ||
| 9 | from pathlib import Path | ||
| 10 | |||
| 11 | |||
| 12 | config = json.load(sys.stdin) | ||
| 13 | base = f"https://{config['host']}" | ||
| 14 | ca = Path("/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt") | ||
| 15 | context = ssl.create_default_context(cafile=str(ca) if ca.exists() else None) | ||
| 16 | |||
| 17 | |||
| 18 | def 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 | |||
| 32 | for _ 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) | ||
| 44 | else: | ||
| 45 | raise RuntimeError("Jellyfin did not finish loading") | ||
| 46 | |||
| 47 | if completed: | ||
| 48 | sys.exit(0) | ||
| 49 | |||
| 50 | request("/Startup/User", {"Name": "snow", "Password": config["admin_password"]}) | ||
| 51 | request("/Startup/Configuration", { | ||
| 52 | "ServerName": "Jellyfin", "UICulture": "en-US", "MetadataCountryCode": "US", "PreferredMetadataLanguage": "en", | ||
| 53 | }) | ||
| 54 | request("/Startup/Complete", {}) | ||
| 55 | |||
| 56 | auth = request("/Users/AuthenticateByName", { | ||
| 57 | "Username": "snow", "Pw": config["admin_password"], | ||
| 58 | }) | ||
| 59 | token = auth["AccessToken"] | ||
| 60 | libraries = ( | ||
| 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 | ) | ||
| 67 | for 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 | ||
| 2 | import json | ||
| 3 | import os | ||
| 4 | from pathlib import Path | ||
| 5 | import subprocess | ||
| 6 | import sys | ||
| 7 | import xml.etree.ElementTree as ET | ||
| 8 | import zipfile | ||
| 9 | |||
| 10 | |||
| 11 | data = json.load(sys.stdin) | ||
| 12 | root = Path(data["hostRoot"]) / "config" | ||
| 13 | uid = data["uid"] | ||
| 14 | service = Path(data["hostRoot"]).name | ||
| 15 | variable = 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 | |||
| 20 | plugins = root / "plugins" | ||
| 21 | plugins.mkdir(parents=True, exist_ok=True) | ||
| 22 | os.chown(plugins, uid, uid) | ||
| 23 | plugin = plugins / "SSO Authentication_4.0.0.4" | ||
| 24 | files = {"meta.json", "Duende.IdentityModel.dll", "SSO-Auth.dll", "Duende.IdentityModel.OidcClient.dll"} | ||
| 25 | if 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 | |||
| 36 | configs = plugins / "configurations" | ||
| 37 | configs.mkdir(parents=True, exist_ok=True) | ||
| 38 | os.chown(configs, uid, uid) | ||
| 39 | path = configs / "SSO-Auth.xml" | ||
| 40 | ET.register_namespace("xsi", "http://www.w3.org/2001/XMLSchema-instance") | ||
| 41 | tree = ET.parse(path if path.exists() else Path(__file__).with_name("sso.xml")) | ||
| 42 | items = tree.getroot().findall("./OidConfigs/item") | ||
| 43 | matches = [item for item in items if item.findtext("./key/string") == "snow"] | ||
| 44 | if len(matches) != 1: | ||
| 45 | raise ValueError("expected one Jellyfin SSO provider named snow") | ||
| 46 | config = matches[0].find("./value/PluginConfiguration") | ||
| 47 | for 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 | ||
| 58 | content = ET.tostring(tree.getroot(), encoding="utf-8", xml_declaration=True) | ||
| 59 | if 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 | |||
| 66 | branding = root / "config" | ||
| 67 | branding.mkdir(exist_ok=True) | ||
| 68 | os.chown(branding, uid, uid) | ||
| 69 | target = branding / "branding.xml" | ||
| 70 | if not target.exists(): | ||
| 71 | target.touch() | ||
| 72 | os.chown(target, uid, uid) | ||
| 73 | |||
| 74 | system = branding / "system.xml" | ||
| 75 | if 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) | ||
| 78 | settings = ET.parse(system) | ||
| 79 | metrics = settings.getroot().find("EnableMetrics") | ||
| 80 | if metrics is None: | ||
| 81 | metrics = ET.SubElement(settings.getroot(), "EnableMetrics") | ||
| 82 | if 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | import "../keycloak/service.pkl" as keycloak | ||
| 5 | |||
| 6 | meta { name = "Jellyfin" } | ||
| 7 | // SQLite under /config requires one writer during rollout. | ||
| 8 | rollout = "simple" | ||
| 9 | healthyDeadline = "20m" | ||
| 10 | healthRestartGrace = "20m" | ||
| 11 | prepare = "prepare.py" | ||
| 12 | setup = "configure.py" | ||
| 13 | |||
| 14 | secrets { ["admin_password"] {} } | ||
| 15 | |||
| 16 | requirements { | ||
| 17 | new keycloak.OpenIDClient { | ||
| 18 | clientId = module.id | ||
| 19 | name = module.meta.name | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | container { | ||
| 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 | ||
| 2 | import hashlib | ||
| 3 | from pathlib import Path | ||
| 4 | import urllib.request | ||
| 5 | |||
| 6 | |||
| 7 | version = "4.0.0.4" | ||
| 8 | digest = "c09f16ba31059a434ddd7f811e4f9608d4b4c4514cc80a5bf1ca33bee61e1107" | ||
| 9 | name = f"sso-authentication_{version}.zip" | ||
| 10 | url = f"https://github.com/9p4/jellyfin-plugin-sso/releases/download/v{version}/{name}" | ||
| 11 | with urllib.request.urlopen(url, timeout=30) as response: | ||
| 12 | archive = response.read() | ||
| 13 | if hashlib.sha256(archive).hexdigest() != digest: | ||
| 14 | raise ValueError("SSO plugin archive checksum mismatch") | ||
| 15 | Path(__file__).with_name(name).write_bytes(archive) | ||
service/keycloak/api.py created+83| ... | @@ -0,0 +1,83 @@ | ||
| 1 | import http.client | ||
| 2 | import io | ||
| 3 | import json | ||
| 4 | import os | ||
| 5 | import socket | ||
| 6 | import ssl | ||
| 7 | import time | ||
| 8 | import urllib.error | ||
| 9 | import urllib.parse | ||
| 10 | |||
| 11 | |||
| 12 | class 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 | |||
| 18 | class 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 | ||
| 2 | import json | ||
| 3 | import sys | ||
| 4 | |||
| 5 | from api import Keycloak | ||
| 6 | |||
| 7 | |||
| 8 | config = json.load(sys.stdin) | ||
| 9 | keycloak = Keycloak(config["host"], config["password"]) | ||
| 10 | keycloak.configure_profile() | ||
| 11 | keycloak.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 | ) | ||
| 27 | roles_path = "/admin/realms/master/roles" | ||
| 28 | roles = {role["name"]: role for role in keycloak.request(roles_path)} | ||
| 29 | for 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 | |||
| 34 | users = keycloak.request("/admin/realms/master/users?username=admin&exact=true") | ||
| 35 | if len(users) != 1: | ||
| 36 | raise ValueError("expected one bootstrap admin user") | ||
| 37 | if 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 | ) | ||
| 42 | mapping_path = f"/admin/realms/master/users/{users[0]['id']}/role-mappings/realm" | ||
| 43 | assigned = {role["name"] for role in keycloak.request(mapping_path)} | ||
| 44 | missing = [roles[name] for name in ("admin", "media", "media-manage") if name not in assigned] | ||
| 45 | if missing: | ||
| 46 | keycloak.request(mapping_path, "POST", missing) | ||
| 47 | |||
| 48 | snow = keycloak.request("/admin/realms/master/users?username=snow&exact=true") | ||
| 49 | if 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") | ||
| 56 | if len(snow) != 1: | ||
| 57 | raise ValueError("expected one snow user") | ||
| 58 | if 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 | ) | ||
| 63 | mapping_path = f"/admin/realms/master/users/{snow[0]['id']}/role-mappings/realm" | ||
| 64 | assigned = {role["name"] for role in keycloak.request(mapping_path)} | ||
| 65 | missing = [roles[name] for name in ("infra-admin", "media", "media-manage") if name not in assigned] | ||
| 66 | if 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 | ||
| 2 | import json | ||
| 3 | import sys | ||
| 4 | import urllib.parse | ||
| 5 | |||
| 6 | from api import Keycloak | ||
| 7 | |||
| 8 | |||
| 9 | data = json.load(sys.stdin) | ||
| 10 | request = data["request"] | ||
| 11 | if request["kind"] != "client": | ||
| 12 | raise ValueError("unsupported Keycloak input") | ||
| 13 | stage_id = data.get("stageId") | ||
| 14 | client_id = request["clientId"] | ||
| 15 | if stage_id and client_id != stage_id: | ||
| 16 | client_id += "-" + stage_id[-8:] | ||
| 17 | if data.get("operation") == "delete" and not stage_id: | ||
| 18 | raise ValueError("refusing to delete a production client") | ||
| 19 | existing = data.get("existing") or {} | ||
| 20 | if existing.get("clientId") and existing["clientId"] != client_id: | ||
| 21 | raise ValueError("client ID changed; migrate the existing client before deployment") | ||
| 22 | keycloak = Keycloak(data["host"], data["providerSecrets"]["password"]) | ||
| 23 | desired = { | ||
| 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 | } | ||
| 37 | path = "/admin/realms/master/clients" | ||
| 38 | query = "?" + urllib.parse.urlencode({"clientId": client_id}) | ||
| 39 | found = keycloak.request(path + query) | ||
| 40 | matches = [client for client in found if client["clientId"] == client_id] | ||
| 41 | if len(matches) > 1: | ||
| 42 | raise ValueError(f"duplicate Keycloak client: {client_id}") | ||
| 43 | if data.get("operation") == "delete": | ||
| 44 | if matches: | ||
| 45 | keycloak.request(f"{path}/{matches[0]['id']}", "DELETE") | ||
| 46 | sys.exit(0) | ||
| 47 | if 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}") | ||
| 53 | uuid = matches[0]["id"] | ||
| 54 | current = keycloak.request(f"{path}/{uuid}") | ||
| 55 | attributes = {**(current.get("attributes") or {}), **desired["attributes"]} | ||
| 56 | if 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}) | ||
| 58 | secret = keycloak.request(f"{path}/{uuid}/client-secret")["value"] | ||
| 59 | if not secret: | ||
| 60 | raise ValueError(f"Keycloak client has no secret: {client_id}") | ||
| 61 | print(json.dumps({"clientId": client_id, "clientSecret": secret, "issuerUrl": f"https://{data['host']}/realms/master"})) | ||
service/keycloak/service.pkl created+70| ... | @@ -0,0 +1,70 @@ | ||
| 1 | extends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/Service.pkl" as service | ||
| 4 | import "../postgres/service.pkl" as postgres | ||
| 5 | |||
| 6 | class 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 | |||
| 15 | local database = new postgres.Database { name = "keycloak_next" } | ||
| 16 | |||
| 17 | meta { name = "Keycloak" } | ||
| 18 | traceServiceName = module.id | ||
| 19 | dependsOn { "victoria-traces" } | ||
| 20 | healthyDeadline = "20m" | ||
| 21 | setup = "configure.py" | ||
| 22 | provide = "provide.py" | ||
| 23 | |||
| 24 | requirements { database } | ||
| 25 | |||
| 26 | container { | ||
| 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 | |||
| 67 | secrets { | ||
| 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 */ | ||
| 4 | html, | ||
| 5 | body { | ||
| 6 | height: 100%; | ||
| 7 | } | ||
| 8 | |||
| 9 | h1, | ||
| 10 | h2, | ||
| 11 | h3, | ||
| 12 | h4, | ||
| 13 | h5, | ||
| 14 | h6 { | ||
| 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 */ | ||
| 54 | body { | ||
| 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 | } | ||
| 115 | a { | ||
| 116 | color: var(--header); | ||
| 117 | text-decoration: dotted underline; | ||
| 118 | } | ||
| 119 | a: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, | ||
| 137 | a, | ||
| 138 | #kc-form-buttons, | ||
| 139 | .pf-v5-c-helper-text__item-text, | ||
| 140 | .pf-v5-c-alert__title, | ||
| 141 | input[type="submit"], | ||
| 142 | input[type="button"], | ||
| 143 | button { | ||
| 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 | } | ||
| 288 | input[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 createdservice/keycloak/theme/login/resources/img/license.txt created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | https://safebooru.org/index.php?page=post&s=view&id=4405346 | ||
| 2 | dark 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 @@ | ||
| 1 | const 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 | |||
| 74 | function 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 | |||
| 89 | function 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 @@ | ||
| 1 | import { base64url } from "rfc4648"; | ||
| 2 | |||
| 3 | export 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 | |||
| 69 | function doRegister(publicKey) { | ||
| 70 | return navigator.credentials.create({ publicKey }); | ||
| 71 | } | ||
| 72 | |||
| 73 | function 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 | |||
| 90 | function 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 | |||
| 106 | function getTransportsAsString(transportsList) { | ||
| 107 | if (!Array.isArray(transportsList)) { | ||
| 108 | return ""; | ||
| 109 | } | ||
| 110 | |||
| 111 | return transportsList.join(); | ||
| 112 | } | ||
| 113 | |||
| 114 | function 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 | |||
| 151 | function 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 @@ | ||
| 1 | parent=base | ||
| 2 | import=common/keycloak | ||
| 3 | |||
| 4 | styles=css/styles.css | ||
| 5 | stylesCommon= | ||
| 6 | |||
| 7 | darkMode=false | ||
| 8 | |||
| 9 | kcFormGroupClass=pf-v5-c-form__group | ||
| 10 | kcFormGroupLabelClass=pf-v5-c-form__group-label pf-v5-u-pb-xs | ||
| 11 | kcFormLabelClass=pf-v5-c-form__label | ||
| 12 | kcFormLabelTextClass=pf-v5-c-form__label-text | ||
| 13 | |||
| 14 | kcLabelClass=pf-v5-c-form__label | ||
| 15 | kcInputClass=pf-v5-c-form-control | ||
| 16 | kcInputGroup=pf-v5-c-input-group | ||
| 17 | kcFormHelperTextClass=pf-v5-c-form__helper-text | ||
| 18 | kcInputHelperTextClass=pf-v5-c-helper-text pf-v5-u-display-flex pf-v5-u-justify-content-space-between | ||
| 19 | kcInputHelperTextItemClass=pf-v5-c-helper-text__item | ||
| 20 | kcInputHelperTextItemTextClass=pf-v5-c-helper-text__item-text | ||
| 21 | kcInputGroupItemClass=pf-v5-c-input-group__item | ||
| 22 | kcFill=pf-m-fill | ||
| 23 | kcError=pf-m-error | ||
| 24 | kcLoginFooterBand=pf-v5-c-login__main-footer-band | ||
| 25 | kcLoginFooterBandItem=pf-v5-c-login__main-footer-band-item | ||
| 26 | |||
| 27 | kcCheckboxClass=pf-v5-c-check | ||
| 28 | kcCheckboxInputClass=pf-v5-c-check__input | ||
| 29 | kcCheckboxLabelClass=pf-v5-c-check__label | ||
| 30 | kcCheckboxLabelRequiredClass=pf-v5-c-check__label-required | ||
| 31 | |||
| 32 | kcInputRequiredClass=pf-v5-c-form__label-required | ||
| 33 | kcInputErrorMessageClass=pf-v5-c-helper-text__item-text pf-m-error kc-feedback-text | ||
| 34 | kcFormControlUtilClass=pf-v5-c-form-control__utilities | ||
| 35 | kcInputErrorIconStatusClass=pf-v5-c-form-control__icon pf-m-status | ||
| 36 | kcInputErrorIconClass=fas fa-exclamation-circle | ||
| 37 | kcAlertClass=pf-v5-c-alert pf-m-inline pf-v5-u-mb-md | ||
| 38 | kcAlertIconClass=pf-v5-c-alert__icon | ||
| 39 | kcAlertTitleClass=pf-v5-c-alert__title | ||
| 40 | kcAlertDescriptionClass=pf-v5-c-alert__description | ||
| 41 | kcFormPasswordVisibilityButtonClass=pf-v5-c-button pf-m-control | ||
| 42 | kcFormControlToggleIcon=pf-v5-c-form-control__toggle-icon | ||
| 43 | kcFormActionGroupClass=pf-v5-c-form__actions pf-v5-u-pt-xs | ||
| 44 | kcFormReadOnlyClass=pf-m-readonly | ||
| 45 | |||
| 46 | kcPanelClass=pf-v5-c-panel pf-m-raised | ||
| 47 | kcPanelMainClass=pf-v5-c-panel__main | ||
| 48 | kcPanelMainBodyClass=pf-v5-c-panel__main-body | ||
| 49 | kcListClass=pf-v5-c-list | ||
| 50 | |||
| 51 | kcButtonClass=pf-v5-c-button | ||
| 52 | kcButtonPrimaryClass=pf-v5-c-button pf-m-primary | ||
| 53 | kcButtonSecondaryClass=pf-v5-c-button pf-m-secondary | ||
| 54 | kcButtonBlockClass=pf-m-block | ||
| 55 | kcButtonLinkClass=pf-v5-c-button pf-m-link | ||
| 56 | kcCommonLogoIdP=pf-v5-c-login__main-footer-links-item | ||
| 57 | kcFormSocialAccountListClass=pf-v5-c-login__main-body pf-v5-u-pl-0 pf-v5-u-pr-0 | ||
| 58 | kcFormSocialAccountListItemClass=pf-v5-u-pb-sm | ||
| 59 | kcFormSocialAccountNameClass=pf-v5-u-m-auto | ||
| 60 | kcFormSocialAccountListButtonClass=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 | ||
| 61 | kcFormSocialAccountListButtonDisabledClass=pf-m-aria-disabled | ||
| 62 | kcFormSocialAccountListGridClass=pf-v5-l-grid pf-m-gutter pf-m-all-6-col-on-xl pf-m-all-6-col-on-sm | ||
| 63 | kcFormSocialAccountGridItem=pf-v5-l-grid__item | ||
| 64 | |||
| 65 | kcLoginClass=pf-v5-c-login__main | ||
| 66 | kcFormClass=pf-v5-c-form pf-v5-u-w-100 | ||
| 67 | kcFormCardClass=card-pf | ||
| 68 | |||
| 69 | kcResetFlowIcon=pf-icon fas fa-share-square | ||
| 70 | |||
| 71 | kcSelectAuthListClass=pf-v5-c-data-list select-auth-container | ||
| 72 | kcSelectAuthListItemWrapperClass=pf-v5-c-data-list__item pf-m-clickable | ||
| 73 | kcSelectAuthListItemClass=pf-v5-c-data-list__item-row select-auth-box-parent | ||
| 74 | kcSelectAuthListItemHeadingClass=pf-v5-u-font-family-heading select-auth-box-headline | ||
| 75 | kcSelectAuthListItemBodyClass=pf-v5-c-data-list__cell pf-m-no-fill pf-v5-u-pt-md pf-v5-u-pb-md | ||
| 76 | kcSelectAuthListItemIconClass=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 | ||
| 77 | kcSelectAuthListItemFillClass=pf-v5-c-data-list__item-action | ||
| 78 | kcSelectAuthListItemDescriptionClass=pf-v5-c-data-list__cell pf-m-no-fill select-auth-box-desc | ||
| 79 | |||
| 80 | kcRecoveryCodesWarning=pf-v5-c-alert pf-m-warning pf-m-inline pf-v5-u-mb-md kc-recovery-codes-warning | ||
| 81 | kcLogin=pf-v5-c-login | ||
| 82 | kcLoginContainer=pf-v5-c-login__container | ||
| 83 | kcLoginMain=pf-v5-c-login__main | ||
| 84 | kcLoginMainHeader=pf-v5-c-login__main-header | ||
| 85 | kcLoginMainFooter=pf-v5-c-login__main-footer | ||
| 86 | kcLoginMainFooterBand=pf-v5-c-login__main-footer-band | ||
| 87 | kcLoginMainFooterBandItem=pf-v5-c-login__main-footer-band-item | ||
| 88 | kcLoginMainFooterHelperText=pf-v5-u-font-size-sm pf-v5-u-color-200 | ||
| 89 | kcLoginMainTitle=pf-v5-c-title pf-m-3xl | ||
| 90 | kcLoginMainHeaderUtilities=pf-v5-c-login__main-header-utilities | ||
| 91 | kcLoginMainBody=pf-v5-c-login__main-body | ||
| 92 | |||
| 93 | kcContentWrapperClass=pf-v5-u-mb-md-on-md | ||
| 94 | kcWebAuthnDefaultIcon=pf-v5-c-icon pf-m-lg | ||
| 95 | kcMarginTopClass=pf-v5-u-mt-md-on-md | ||
| 96 | |||
| 97 | kcLoginOTPListClass=pf-v5-c-tile | ||
| 98 | kcLoginOTPListItemHeaderClass=pf-v5-c-tile__header pf-m-stacked | ||
| 99 | kcLoginOTPListItemIconBodyClass=pf-v5-c-tile__icon | ||
| 100 | kcLoginOTPListItemIconClass=fa fa-mobile | ||
| 101 | kcLoginOTPListItemTitleClass=pf-v5-c-tile__title | ||
| 102 | kcLoginOTPListSelectedClass=pf-m-selected | ||
| 103 | |||
| 104 | kcCopyIconClass=fas fa-copy | ||
| 105 | kcCheckIconClass=fas fa-check | ||
| 106 | kcAngleRightIconClass=fas fa-angle-right | ||
| 107 | kcAngleDownIconClass=fas fa-angle-down | ||
| 108 | kcExpandedClass=pf-m-expanded | ||
| 109 | |||
| 110 | kcCodeClipboardCopyClass=pf-v5-c-clipboard-copy | ||
| 111 | kcCodeClipboardCopyGroupClass=pf-v5-c-clipboard-copy__group | ||
| 112 | kcCodeClipboardCopyContentClass=pf-v5-c-clipboard-copy__expandable-content | ||
| 113 | |||
| 114 | kcDarkModeClass=pf-v5-theme-dark | ||
| 115 | |||
| 116 | kcHtmlClass=login-pf | ||
| 117 | kcLogoIdP-facebook= | ||
| 118 | kcLogoIdP-google= | ||
| 119 | kcLogoIdP-github= | ||
| 120 | kcLogoIdP-linkedin= | ||
| 121 | kcLogoIdP-instagram= | ||
| 122 | kcLogoIdP-microsoft= | ||
| 123 | kcLogoIdP-bitbucket= | ||
| 124 | kcLogoIdP-gitlab= | ||
| 125 | kcLogoIdP-paypal= | ||
| 126 | kcLogoIdP-stackoverflow= | ||
| 127 | kcLogoIdP-twitter= | ||
| 128 | kcLogoIdP-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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | meta { name = "Navidrome" } | ||
| 6 | |||
| 7 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | meta { name = "Open Speed Test" } | ||
| 4 | rollout = "overlapped" | ||
| 5 | healthRestartGrace = "30s" | ||
| 6 | |||
| 7 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | local isolated = site.preview || site.domain.endsWith(".test") | ||
| 6 | |||
| 7 | meta { name = "ATProto PDS" } | ||
| 8 | traceServiceName = module.id | ||
| 9 | metricsPushed = true | ||
| 10 | rollout = "simple" | ||
| 11 | stageIsolation = "fresh" | ||
| 12 | dependsOn { "victoria-metrics"; "victoria-traces" } | ||
| 13 | |||
| 14 | secrets = if (isolated) new { | ||
| 15 | ["jwt_secret"] {} | ||
| 16 | ["admin_password"] {} | ||
| 17 | ["plc_rotation_key"] {} | ||
| 18 | } else new {} | ||
| 19 | |||
| 20 | requiredSecrets = 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 | |||
| 29 | container { | ||
| 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 | ||
| 2 | set -eu | ||
| 3 | |||
| 4 | if [ -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}" | ||
| 9 | fi | ||
| 10 | |||
| 11 | exec 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "service.pkl" as postgres | ||
| 4 | |||
| 5 | meta { name = "pgAdmin" } | ||
| 6 | healthyDeadline = "15m" | ||
| 7 | requirements { new postgres.AdminConnection {} } | ||
| 8 | |||
| 9 | secrets { | ||
| 10 | ["password"] {} | ||
| 11 | } | ||
| 12 | |||
| 13 | container { | ||
| 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 | ||
| 2 | set -eu | ||
| 3 | |||
| 4 | storage=/var/lib/pgadmin/storage/pgadmin4_pgadmin.org | ||
| 5 | mkdir -p "$storage" | ||
| 6 | printf '%s:%s:*:postgres:%s\n' "$POSTGRES_HOST" "$POSTGRES_PORT" "$POSTGRES_PASSWORD" >"$storage/pgpass" | ||
| 7 | chmod 600 "$storage/pgpass" | ||
| 8 | printf '{"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 | ||
| 10 | export PGADMIN_SERVER_JSON_FILE=/var/lib/pgadmin/servers.json | ||
| 11 | exec /entrypoint.sh python3 | ||
service/postgres/provide.py created+108| ... | @@ -0,0 +1,108 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import json | ||
| 3 | import os | ||
| 4 | import re | ||
| 5 | import secrets | ||
| 6 | import subprocess | ||
| 7 | import sys | ||
| 8 | import urllib.request | ||
| 9 | |||
| 10 | |||
| 11 | data = json.load(sys.stdin) | ||
| 12 | request = data["request"] | ||
| 13 | if request["kind"] == "admin": | ||
| 14 | if data.get("operation") != "delete": | ||
| 15 | print(json.dumps({"username": "postgres", "password": data["providerSecrets"]["password"]})) | ||
| 16 | sys.exit(0) | ||
| 17 | if request["kind"] != "database": | ||
| 18 | raise ValueError("unsupported Postgres input") | ||
| 19 | source_database = request["name"] | ||
| 20 | if not re.fullmatch(r"[a-z][a-z0-9_]{0,55}", source_database): | ||
| 21 | raise ValueError("invalid database name") | ||
| 22 | extensions = request.get("extensions", []) | ||
| 23 | if any(not re.fullmatch(r"[a-z][a-z0-9_]*", extension) for extension in extensions): | ||
| 24 | raise ValueError("invalid extension name") | ||
| 25 | stage_id = data.get("stageId") | ||
| 26 | database = source_database[:42] + "_s_" + stage_id[-8:] if stage_id else source_database | ||
| 27 | username = "svc_" + database | ||
| 28 | existing = data.get("existing") or {} | ||
| 29 | password = existing.get("password") or secrets.token_urlsafe(32) | ||
| 30 | if data.get("operation") == "delete" and not stage_id: | ||
| 31 | raise ValueError("refusing to delete a production database") | ||
| 32 | if existing.get("name") and existing["name"] != database: | ||
| 33 | raise ValueError("database name changed; migrate the existing database before deployment") | ||
| 34 | |||
| 35 | nomad_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 | ) | ||
| 39 | with urllib.request.urlopen(nomad_request, timeout=5) as response: | ||
| 40 | allocations = json.load(response) | ||
| 41 | running = [item["ID"] for item in allocations if item["ClientStatus"] == "running" and item["DesiredStatus"] == "run"] | ||
| 42 | if len(running) != 1: | ||
| 43 | raise ValueError("Postgres needs exactly one running allocation") | ||
| 44 | result = subprocess.run( | ||
| 45 | ["podman", "--url", "unix:///run/podman/podman.sock", "ps", "--format", "{{.ID}} {{.Names}}"], | ||
| 46 | text=True, capture_output=True, check=True, | ||
| 47 | ) | ||
| 48 | containers = [parts[0] for line in result.stdout.splitlines() if len(parts := line.split()) == 2 and parts[1].endswith(running[0])] | ||
| 49 | if len(containers) != 1: | ||
| 50 | raise ValueError("Postgres allocation container is unavailable") | ||
| 51 | container = containers[0] | ||
| 52 | podman = ["podman", "--url", "unix:///run/podman/podman.sock", "exec"] | ||
| 53 | |||
| 54 | |||
| 55 | def 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 | |||
| 64 | if 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 | |||
| 69 | quoted_password = password.replace("'", "''") | ||
| 70 | role = sql(f"SELECT rolname FROM pg_roles WHERE rolname = '{username}';") | ||
| 71 | if not role: | ||
| 72 | sql(f"CREATE ROLE {username} LOGIN PASSWORD '{quoted_password}';") | ||
| 73 | elif not existing.get("password"): | ||
| 74 | sql(f"ALTER ROLE {username} PASSWORD '{quoted_password}';") | ||
| 75 | owner = sql(f"SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname = '{database}';") | ||
| 76 | if 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")) | ||
| 102 | elif owner != username: | ||
| 103 | raise ValueError(f"database {database} is owned by {owner}") | ||
| 104 | |||
| 105 | for extension in extensions: | ||
| 106 | sql(f"CREATE EXTENSION IF NOT EXISTS {extension};", database) | ||
| 107 | |||
| 108 | print(json.dumps({"name": database, "username": username, "password": password})) | ||
service/postgres/service.pkl created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | extends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/Service.pkl" as service | ||
| 4 | |||
| 5 | class 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 | |||
| 13 | class AdminConnection extends service.Requirement { | ||
| 14 | alias: String = "admin" | ||
| 15 | fixed provider = "postgres" | ||
| 16 | fixed kind = "admin" | ||
| 17 | } | ||
| 18 | |||
| 19 | meta { name = "Postgres" } | ||
| 20 | provide = "provide.py" | ||
| 21 | |||
| 22 | container { | ||
| 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 | |||
| 44 | secrets { | ||
| 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 @@ | ||
| 1 | client | ||
| 2 | dev tun | ||
| 3 | proto udp | ||
| 4 | remote us-siliconvalley.privacy.network 1198 | ||
| 5 | resolv-retry infinite | ||
| 6 | nobind | ||
| 7 | persist-key | ||
| 8 | cipher aes-128-cbc | ||
| 9 | auth sha1 | ||
| 10 | tls-client | ||
| 11 | remote-cert-tls server | ||
| 12 | |||
| 13 | auth-user-pass credentials.conf | ||
| 14 | comp-lzo no | ||
| 15 | verb 1 | ||
| 16 | |||
| 17 | <ca> | ||
| 18 | -----BEGIN CERTIFICATE----- | ||
| 19 | MIIFqzCCBJOgAwIBAgIJAKZ7D5Yv87qDMA0GCSqGSIb3DQEBDQUAMIHoMQswCQYD | ||
| 20 | VQQGEwJVUzELMAkGA1UECBMCQ0ExEzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNV | ||
| 21 | BAoTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIElu | ||
| 22 | dGVybmV0IEFjY2VzczEgMB4GA1UEAxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3Mx | ||
| 23 | IDAeBgNVBCkTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkB | ||
| 24 | FiBzZWN1cmVAcHJpdmF0ZWludGVybmV0YWNjZXNzLmNvbTAeFw0xNDA0MTcxNzM1 | ||
| 25 | MThaFw0zNDA0MTIxNzM1MThaMIHoMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex | ||
| 26 | EzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNVBAoTF1ByaXZhdGUgSW50ZXJuZXQg | ||
| 27 | QWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UE | ||
| 28 | AxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBCkTF1ByaXZhdGUgSW50 | ||
| 29 | ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkBFiBzZWN1cmVAcHJpdmF0ZWludGVy | ||
| 30 | bmV0YWNjZXNzLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPXD | ||
| 31 | L1L9tX6DGf36liA7UBTy5I869z0UVo3lImfOs/GSiFKPtInlesP65577nd7UNzzX | ||
| 32 | lH/P/CnFPdBWlLp5ze3HRBCc/Avgr5CdMRkEsySL5GHBZsx6w2cayQ2EcRhVTwWp | ||
| 33 | cdldeNO+pPr9rIgPrtXqT4SWViTQRBeGM8CDxAyTopTsobjSiYZCF9Ta1gunl0G/ | ||
| 34 | 8Vfp+SXfYCC+ZzWvP+L1pFhPRqzQQ8k+wMZIovObK1s+nlwPaLyayzw9a8sUnvWB | ||
| 35 | /5rGPdIYnQWPgoNlLN9HpSmsAcw2z8DXI9pIxbr74cb3/HSfuYGOLkRqrOk6h4RC | ||
| 36 | OfuWoTrZup1uEOn+fw8CAwEAAaOCAVQwggFQMB0GA1UdDgQWBBQv63nQ/pJAt5tL | ||
| 37 | y8VJcbHe22ZOsjCCAR8GA1UdIwSCARYwggESgBQv63nQ/pJAt5tLy8VJcbHe22ZO | ||
| 38 | sqGB7qSB6zCB6DELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRMwEQYDVQQHEwpM | ||
| 39 | b3NBbmdlbGVzMSAwHgYDVQQKExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4G | ||
| 40 | A1UECxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBAMTF1ByaXZhdGUg | ||
| 41 | SW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQpExdQcml2YXRlIEludGVybmV0IEFjY2Vz | ||
| 42 | czEvMC0GCSqGSIb3DQEJARYgc2VjdXJlQHByaXZhdGVpbnRlcm5ldGFjY2Vzcy5j | ||
| 43 | b22CCQCmew+WL/O6gzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBDQUAA4IBAQAn | ||
| 44 | a5PgrtxfwTumD4+3/SYvwoD66cB8IcK//h1mCzAduU8KgUXocLx7QgJWo9lnZ8xU | ||
| 45 | ryXvWab2usg4fqk7FPi00bED4f4qVQFVfGfPZIH9QQ7/48bPM9RyfzImZWUCenK3 | ||
| 46 | 7pdw4Bvgoys2rHLHbGen7f28knT2j/cbMxd78tQc20TIObGjo8+ISTRclSTRBtyC | ||
| 47 | GohseKYpTS9himFERpUgNtefvYHbn70mIOzfOJFTVqfrptf9jXa9N8Mpy3ayfodz | ||
| 48 | 1wiqdteqFXkTYoSDctgKMiZ6GdocK9nMroQipIQtpnwd4yBDWIyC6Bvlkrq5TQUt | ||
| 49 | YDQ8z9v+DMO6iwyIDRiU | ||
| 50 | -----END CERTIFICATE----- | ||
| 51 | </ca> | ||
| 52 | |||
| 53 | disable-occ | ||
service/qbittorrent/prepare.py created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | import json | ||
| 2 | import os | ||
| 3 | from pathlib import Path | ||
| 4 | import sys | ||
| 5 | import tempfile | ||
| 6 | |||
| 7 | |||
| 8 | data = json.load(sys.stdin) | ||
| 9 | path = Path(data["hostRoot"]) / "config/qBittorrent/config/qBittorrent.conf" | ||
| 10 | for 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) | ||
| 14 | lines = path.read_text().splitlines() if path.exists() else [] | ||
| 15 | |||
| 16 | for 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 | |||
| 35 | content = "\n".join(lines) + "\n" | ||
| 36 | if 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | meta { name = "qBittorrent" } | ||
| 6 | rollout = "simple" | ||
| 7 | healthyDeadline = "15m" | ||
| 8 | prepare = "prepare.py" | ||
| 9 | |||
| 10 | requiredSecrets { | ||
| 11 | "vpn_user" | ||
| 12 | "vpn_pass" | ||
| 13 | } | ||
| 14 | |||
| 15 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | meta { name = "Radarr" } | ||
| 6 | setup = "tools/configure-arr.py" | ||
| 7 | dependsOn { | ||
| 8 | "qbittorrent" | ||
| 9 | "jackett" | ||
| 10 | } | ||
| 11 | |||
| 12 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | local 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 | |||
| 7 | meta { name = "Samba" } | ||
| 8 | |||
| 9 | container { | ||
| 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 | |||
| 42 | secrets { | ||
| 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 @@ | ||
| 1 | Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin. | ||
| 2 | |||
| 3 | Permission is hereby granted, free of charge, to any person | ||
| 4 | obtaining a copy of this software and associated documentation | ||
| 5 | files (the "Software"), to deal in the Software without | ||
| 6 | restriction, including without limitation the rights to use, | ||
| 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 8 | copies of the Software, and to permit persons to whom the | ||
| 9 | Software is furnished to do so, subject to the following | ||
| 10 | conditions: | ||
| 11 | |||
| 12 | The above copyright notice and this permission notice shall be | ||
| 13 | included in all copies or substantial portions of the Software. | ||
| 14 | |||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
| 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES | ||
| 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | ||
| 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | ||
| 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | ||
| 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | ||
| 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR | ||
| 22 | OTHER 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../keycloak/service.pkl" as keycloak | ||
| 4 | |||
| 5 | meta { | ||
| 6 | name = "Shale" | ||
| 7 | } | ||
| 8 | traceServiceName = "astheno.shale" | ||
| 9 | dependsOn { "victoria-traces" } | ||
| 10 | |||
| 11 | requirements { | ||
| 12 | new keycloak.OpenIDClient { | ||
| 13 | clientId = module.id | ||
| 14 | name = module.meta.name | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | container { | ||
| 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 | |||
| 63 | secrets { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | meta { name = "Sonarr" } | ||
| 6 | setup = "tools/configure-arr.py" | ||
| 7 | dependsOn { | ||
| 8 | "qbittorrent" | ||
| 9 | "jackett" | ||
| 10 | } | ||
| 11 | |||
| 12 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | meta { name = "Tailscale" } | ||
| 6 | |||
| 7 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | meta { name = "VictoriaLogs"; tagline = "Searchable service and system logs" } | ||
| 4 | rollout = "simple" | ||
| 5 | |||
| 6 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | meta { name = "VictoriaMetrics"; tagline = "Metrics history" } | ||
| 4 | rollout = "simple" | ||
| 5 | |||
| 6 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | meta { name = "VictoriaTraces"; tagline = "Request traces" } | ||
| 4 | rollout = "simple" | ||
| 5 | |||
| 6 | container { | ||
| 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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | local passive = site.preview || site.mediaReadOnly | ||
| 6 | |||
| 7 | meta { name = "YouTube Thumbnails" } | ||
| 8 | |||
| 9 | requiredSecrets { | ||
| 10 | "smtp_host" | ||
| 11 | "smtp_user" | ||
| 12 | "smtp_pass" | ||
| 13 | } | ||
| 14 | |||
| 15 | container { | ||
| 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 @@ | ||
| 1 | FROM docker.io/library/python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b | ||
| 2 | RUN pip install --no-cache-dir onnxruntime==1.30.0 numpy==2.5.3 Pillow==12.3.0 | ||
| 3 | RUN 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)' | ||
| 4 | COPY upscale.py /app/upscale.py | ||
| 5 | CMD ["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. | ||
| 8 | import io | ||
| 9 | import json | ||
| 10 | import os | ||
| 11 | import re | ||
| 12 | import sys | ||
| 13 | import time | ||
| 14 | import urllib.request | ||
| 15 | |||
| 16 | import numpy as np | ||
| 17 | import onnxruntime as ort | ||
| 18 | from PIL import Image | ||
| 19 | |||
| 20 | INDEP_DIR = os.environ.get("INDEP_DIR", "/media/jellyfin/Independent") | ||
| 21 | STATE_DIR = os.environ.get("STATE_DIR", "/state") | ||
| 22 | MODEL = os.environ.get("SR_MODEL", "/app/realesr-general-x4v3.onnx") | ||
| 23 | MIN_H = int(os.environ.get("SR_MIN_H", "1400")) # >= this tall ⇒ already done | ||
| 24 | CAP_H = int(os.environ.get("SR_CAP_H", "2160")) # cap output height (4k-ish) | ||
| 25 | TILE = int(os.environ.get("SR_TILE", "256")) # tile size to bound memory | ||
| 26 | THREADS = int(os.environ.get("SR_THREADS", "4")) # leave cpu for other services | ||
| 27 | SCAN_INTERVAL = int(os.environ.get("SR_SCAN_INTERVAL", "1800")) | ||
| 28 | VIDEO_EXTS = (".webm", ".mp4", ".mkv") | ||
| 29 | UA = {"User-Agent": "Mozilla/5.0 (yt-upscaler; +https://paperclover.net)"} | ||
| 30 | |||
| 31 | status = {"enabled": True, "running": False, "done": 0, "total": 0, | ||
| 32 | "current": "", "errors": 0} | ||
| 33 | _sess = None | ||
| 34 | |||
| 35 | |||
| 36 | def log(m): | ||
| 37 | print(m, flush=True) | ||
| 38 | |||
| 39 | |||
| 40 | def 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 | |||
| 50 | def 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 | |||
| 58 | def 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 | |||
| 69 | def 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 | |||
| 88 | def 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 | |||
| 109 | def 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 | |||
| 123 | def img_height(path): | ||
| 124 | try: | ||
| 125 | with Image.open(path) as im: | ||
| 126 | return im.height | ||
| 127 | except Exception: | ||
| 128 | return 0 | ||
| 129 | |||
| 130 | |||
| 131 | def 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 | |||
| 158 | def 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 | |||
| 170 | def 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 | |||
| 212 | if __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 @@ | ||
| 1 | amends "../../config/Service.pkl" | ||
| 2 | |||
| 3 | import "../../config/site.pkl" as site | ||
| 4 | |||
| 5 | local passive = site.preview || site.mediaReadOnly | ||
| 6 | |||
| 7 | meta { name = "YouTube Archiver" } | ||
| 8 | |||
| 9 | container { | ||
| 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. | ||
| 5 | while 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 | ||
| 19 | done | ||
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 | ||
| 2 | configuration: | ||
| 3 | working_directory: "/config/work" | ||
| 4 | |||
| 5 | presets: | ||
| 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 | ||
| 2 | set -eu | ||
| 3 | |||
| 4 | handoff=${1:?Expected a legacy handoff directory} | ||
| 5 | target=${2:?Expected a production target} | ||
| 6 | port=${3:?Expected a target SSH port} | ||
| 7 | printf '%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 | } | ||
| 11 | ssh -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 @@ | ||
| 1 | import json | ||
| 2 | import os | ||
| 3 | from pathlib import Path | ||
| 4 | import sys | ||
| 5 | from urllib.parse import urlsplit, urlunsplit | ||
| 6 | import urllib.request | ||
| 7 | import xml.etree.ElementTree as ET | ||
| 8 | |||
| 9 | |||
| 10 | data = json.load(sys.stdin) | ||
| 11 | service = data["serviceId"] | ||
| 12 | api_key = ET.parse(Path(data["hostRoot"]) / "config/config.xml").findtext("ApiKey") | ||
| 13 | if not api_key: | ||
| 14 | raise ValueError(f"{service} has no API key in config.xml") | ||
| 15 | |||
| 16 | nomad_headers = {"X-Nomad-Token": os.environ["NOMAD_TOKEN"]} | ||
| 17 | |||
| 18 | |||
| 19 | def 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 | |||
| 36 | nomad = urllib.request.Request( | ||
| 37 | f"http://127.0.0.1:4646/v1/service/{service}", | ||
| 38 | headers=nomad_headers, | ||
| 39 | ) | ||
| 40 | allocations = json.load(urllib.request.urlopen(nomad, timeout=10)) | ||
| 41 | if len(allocations) != 1: | ||
| 42 | raise ValueError(f"{service} needs one running allocation before configuring download clients") | ||
| 43 | allocation = allocations[0] | ||
| 44 | base = f"http://{allocation['Address']}:{allocation['Port']}/api/v3/downloadclient" | ||
| 45 | headers = {"X-Api-Key": api_key} | ||
| 46 | clients = json.load(urllib.request.urlopen(urllib.request.Request(base, headers=headers), timeout=10)) | ||
| 47 | qbittorrent_host, qbittorrent_port = internal_endpoint("qbittorrent") | ||
| 48 | if 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 | |||
| 65 | for 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 | |||
| 88 | jackett_host, jackett_port = internal_endpoint("jackett") | ||
| 89 | indexer_base = base.replace("/downloadclient", "/indexer") | ||
| 90 | indexers = json.load(urllib.request.urlopen(urllib.request.Request(indexer_base, headers=headers), timeout=10)) | ||
| 91 | for 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.""" | ||
| 3 | import argparse | ||
| 4 | import concurrent.futures | ||
| 5 | import json | ||
| 6 | from pathlib import Path | ||
| 7 | import time | ||
| 8 | import urllib.request | ||
| 9 | |||
| 10 | |||
| 11 | parser = argparse.ArgumentParser(description=__doc__) | ||
| 12 | parser.add_argument("--clients", type=int, default=24) | ||
| 13 | parser.add_argument("--rounds", type=int, default=20) | ||
| 14 | parser.add_argument("--port", type=int, default=7072) | ||
| 15 | parser.add_argument("--unit", default="studio-dashboard.service") | ||
| 16 | parser.add_argument("--include-files", action="store_true") | ||
| 17 | args = parser.parse_args() | ||
| 18 | if args.clients < 1 or args.rounds < 1: | ||
| 19 | parser.error("clients and rounds must be positive") | ||
| 20 | |||
| 21 | paths = ["/", "/api/me", "/api/host", "/api/live", "/api/services", "/api/launcher", "/api/status", "/api/storage"] | ||
| 22 | paths += [f"/api/metrics/{metric}?range=3600" for metric in ["host.cpu", "host.memory", "service.cpu", "service.memory"]] | ||
| 23 | if args.include_files: | ||
| 24 | paths.append("/api/media/list") | ||
| 25 | |||
| 26 | |||
| 27 | def 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 | |||
| 44 | def 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 | |||
| 49 | for path in paths: | ||
| 50 | request(path) | ||
| 51 | started = time.monotonic() | ||
| 52 | before = cpu() | ||
| 53 | with concurrent.futures.ThreadPoolExecutor(max_workers=args.clients) as pool: | ||
| 54 | results = list(pool.map(request, paths * args.rounds)) | ||
| 55 | elapsed = time.monotonic() - started | ||
| 56 | report = {} | ||
| 57 | for 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 | } | ||
| 66 | print(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 | ||
| 2 | import argparse | ||
| 3 | import hashlib | ||
| 4 | import json | ||
| 5 | import os | ||
| 6 | from pathlib import Path | ||
| 7 | import subprocess | ||
| 8 | import time | ||
| 9 | |||
| 10 | import router | ||
| 11 | |||
| 12 | |||
| 13 | def 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 | |||
| 70 | if __name__ == "__main__": | ||
| 71 | main() | ||
tools/dashboard-container-test.py created+94| ... | @@ -0,0 +1,94 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | import concurrent.futures | ||
| 4 | import json | ||
| 5 | from pathlib import Path | ||
| 6 | import subprocess | ||
| 7 | import time | ||
| 8 | import urllib.request | ||
| 9 | |||
| 10 | |||
| 11 | def 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 | |||
| 93 | if __name__ == "__main__": | ||
| 94 | main() | ||
tools/dashboard-deploy-test.py created+300| ... | @@ -0,0 +1,300 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import importlib | ||
| 3 | import json | ||
| 4 | import os | ||
| 5 | from pathlib import Path | ||
| 6 | import subprocess | ||
| 7 | import tempfile | ||
| 8 | import unittest | ||
| 9 | import uuid | ||
| 10 | from unittest.mock import patch | ||
| 11 | |||
| 12 | import release | ||
| 13 | |||
| 14 | runs = importlib.import_module("dashboard-run") | ||
| 15 | host = importlib.import_module("dashboard-host") | ||
| 16 | |||
| 17 | |||
| 18 | class 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 | |||
| 299 | if __name__ == "__main__": | ||
| 300 | unittest.main() | ||
tools/dashboard-deploy-vm-test.py created+303| ... | @@ -0,0 +1,303 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | from concurrent.futures import ThreadPoolExecutor | ||
| 4 | import json | ||
| 5 | import os | ||
| 6 | from pathlib import Path | ||
| 7 | import shutil | ||
| 8 | import subprocess | ||
| 9 | import sys | ||
| 10 | import tempfile | ||
| 11 | import time | ||
| 12 | import urllib.error | ||
| 13 | import urllib.request | ||
| 14 | import uuid | ||
| 15 | |||
| 16 | import release | ||
| 17 | |||
| 18 | |||
| 19 | def 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 | |||
| 302 | if __name__ == "__main__": | ||
| 303 | main() | ||
tools/dashboard-file-boundary-test.py created+280| ... | @@ -0,0 +1,280 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | import json | ||
| 4 | import os | ||
| 5 | from pathlib import Path | ||
| 6 | import shutil | ||
| 7 | import sqlite3 | ||
| 8 | import subprocess | ||
| 9 | import sys | ||
| 10 | import tempfile | ||
| 11 | import time | ||
| 12 | import urllib.error | ||
| 13 | import urllib.parse | ||
| 14 | import urllib.request | ||
| 15 | import uuid | ||
| 16 | |||
| 17 | |||
| 18 | def 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 | |||
| 279 | if __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.""" | ||
| 3 | import argparse,json,os,shlex,subprocess,tempfile,time,urllib.request,urllib.error,uuid | ||
| 4 | from pathlib import Path | ||
| 5 | parser=argparse.ArgumentParser(description=__doc__) | ||
| 6 | parser.add_argument('binary') | ||
| 7 | parser.add_argument('--pool',default='studio-demo') | ||
| 8 | args=parser.parse_args() | ||
| 9 | run=lambda *argv:subprocess.run(argv,check=True,capture_output=True,text=True) | ||
| 10 | dataset=args.pool+'/dashboard-test-'+uuid.uuid4().hex[:8] | ||
| 11 | unit='studio-dashboard-files-test' | ||
| 12 | with 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 | ||
| 2 | import importlib.util | ||
| 3 | import json | ||
| 4 | from pathlib import Path | ||
| 5 | import sys | ||
| 6 | import subprocess | ||
| 7 | import unittest | ||
| 8 | from unittest.mock import patch | ||
| 9 | |||
| 10 | |||
| 11 | spec = importlib.util.spec_from_file_location("dashboard_host", Path(__file__).with_name("dashboard-host.py")) | ||
| 12 | host_module = importlib.util.module_from_spec(spec) | ||
| 13 | spec.loader.exec_module(host_module) | ||
| 14 | |||
| 15 | |||
| 16 | class 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 | |||
| 141 | if __name__ == "__main__": | ||
| 142 | unittest.main() | ||
tools/dashboard-host-vm-test.py created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | import json | ||
| 4 | import os | ||
| 5 | from pathlib import Path | ||
| 6 | import pwd | ||
| 7 | import socket | ||
| 8 | import struct | ||
| 9 | import subprocess | ||
| 10 | import sys | ||
| 11 | import uuid | ||
| 12 | |||
| 13 | |||
| 14 | def 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 | |||
| 24 | def 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 | |||
| 35 | def 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 | |||
| 143 | if __name__ == "__main__": | ||
| 144 | main() | ||
tools/dashboard-host.py created+347| ... | @@ -0,0 +1,347 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import concurrent.futures | ||
| 3 | import importlib | ||
| 4 | import json | ||
| 5 | import math | ||
| 6 | import os | ||
| 7 | from pathlib import Path | ||
| 8 | import pwd | ||
| 9 | import re | ||
| 10 | import selectors | ||
| 11 | import signal | ||
| 12 | import socket | ||
| 13 | import struct | ||
| 14 | import subprocess | ||
| 15 | import threading | ||
| 16 | import time | ||
| 17 | import vms | ||
| 18 | |||
| 19 | dashboard_runs = importlib.import_module("dashboard-run") | ||
| 20 | |||
| 21 | |||
| 22 | FIELDS = ( | ||
| 23 | "name", "used", "usedbydataset", "usedbysnapshots", "referenced", "available", | ||
| 24 | "compressratio", "logicalused", "compression", "recordsize", "mountpoint", | ||
| 25 | "quota", "origin", "mounted", | ||
| 26 | ) | ||
| 27 | TEXT_FIELDS = {"name", "compression", "mountpoint", "origin", "mounted"} | ||
| 28 | MAX_REQUEST = 65536 | ||
| 29 | MAX_RESPONSE = 16 * 1024 * 1024 | ||
| 30 | INDEX_SNAPSHOT = r"index-[0-9]+(?:-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})?" | ||
| 31 | |||
| 32 | |||
| 33 | class Rejected(Exception): | ||
| 34 | pass | ||
| 35 | |||
| 36 | |||
| 37 | class 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 | |||
| 251 | def numeric(value): | ||
| 252 | try: | ||
| 253 | return float(value.rstrip("x")) | ||
| 254 | except ValueError: | ||
| 255 | return 0 | ||
| 256 | |||
| 257 | |||
| 258 | def 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 | |||
| 291 | def 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 | |||
| 322 | def 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 | |||
| 346 | if __name__ == "__main__": | ||
| 347 | main() | ||
tools/dashboard-iam-test.py created+156| ... | @@ -0,0 +1,156 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | import importlib | ||
| 4 | import json | ||
| 5 | from pathlib import Path | ||
| 6 | import subprocess | ||
| 7 | import sys | ||
| 8 | import time | ||
| 9 | import urllib.error | ||
| 10 | import urllib.parse | ||
| 11 | import urllib.request | ||
| 12 | import uuid | ||
| 13 | |||
| 14 | |||
| 15 | def 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 | |||
| 155 | if __name__ == "__main__": | ||
| 156 | main() | ||
tools/dashboard-mcp-test.py created+235| ... | @@ -0,0 +1,235 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | import base64 | ||
| 4 | import hashlib | ||
| 5 | import importlib | ||
| 6 | import json | ||
| 7 | from pathlib import Path | ||
| 8 | import subprocess | ||
| 9 | import sys | ||
| 10 | import time | ||
| 11 | import urllib.error | ||
| 12 | import urllib.parse | ||
| 13 | import urllib.request | ||
| 14 | import uuid | ||
| 15 | |||
| 16 | |||
| 17 | class NoRedirect(urllib.request.HTTPRedirectHandler): | ||
| 18 | def redirect_request(self, request, fp, code, message, headers, newurl): | ||
| 19 | return None | ||
| 20 | |||
| 21 | |||
| 22 | def 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 | |||
| 234 | if __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.""" | ||
| 3 | import argparse | ||
| 4 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | ||
| 5 | import json | ||
| 6 | from pathlib import Path | ||
| 7 | import shlex | ||
| 8 | import subprocess | ||
| 9 | import tempfile | ||
| 10 | import threading | ||
| 11 | import time | ||
| 12 | import urllib.error | ||
| 13 | import urllib.request | ||
| 14 | |||
| 15 | parser = argparse.ArgumentParser(description=__doc__) | ||
| 16 | parser.add_argument("binary") | ||
| 17 | args = parser.parse_args() | ||
| 18 | failed = False | ||
| 19 | reads = 0 | ||
| 20 | |||
| 21 | class 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 | |||
| 35 | metrics = ThreadingHTTPServer(("127.0.0.1", 0), Metrics) | ||
| 36 | |||
| 37 | class 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 | |||
| 49 | nomad = ThreadingHTTPServer(("127.0.0.1", 0), Nomad) | ||
| 50 | for server in [metrics, nomad]: | ||
| 51 | threading.Thread(target=server.serve_forever, daemon=True).start() | ||
| 52 | |||
| 53 | unit = "studio-dashboard-metrics-test" | ||
| 54 | with 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 | ||
| 2 | import argparse | ||
| 3 | from http.client import HTTPConnection | ||
| 4 | from http.server import BaseHTTPRequestHandler, HTTPServer | ||
| 5 | import json | ||
| 6 | import os | ||
| 7 | from pathlib import Path | ||
| 8 | import subprocess | ||
| 9 | import socket | ||
| 10 | import tempfile | ||
| 11 | import threading | ||
| 12 | import time | ||
| 13 | import urllib.error | ||
| 14 | import urllib.request | ||
| 15 | import uuid | ||
| 16 | |||
| 17 | import router | ||
| 18 | |||
| 19 | |||
| 20 | def 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 | ||
| 86 | request = urllib.request.Request('http://{address}:7072/api/me', headers={{ | ||
| 87 | 'User-Name': 'snow', 'User-Groups': 'infra-admin', 'Studio-Proxy-Token': '0' * 64, | ||
| 88 | }}) | ||
| 89 | try: | ||
| 90 | urllib.request.urlopen(request, timeout=5) | ||
| 91 | raise AssertionError('forged identity accepted') | ||
| 92 | except urllib.error.HTTPError as error: | ||
| 93 | assert error.code == 403, error.code | ||
| 94 | print('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 | |||
| 201 | if __name__ == "__main__": | ||
| 202 | main() | ||
tools/dashboard-public-load-test.py created+141| ... | @@ -0,0 +1,141 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | from concurrent.futures import ThreadPoolExecutor | ||
| 4 | import http.cookiejar | ||
| 5 | import importlib | ||
| 6 | import json | ||
| 7 | from pathlib import Path | ||
| 8 | import ssl | ||
| 9 | import subprocess | ||
| 10 | import sys | ||
| 11 | import time | ||
| 12 | import urllib.parse | ||
| 13 | import urllib.request | ||
| 14 | import uuid | ||
| 15 | |||
| 16 | |||
| 17 | def 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 | |||
| 140 | if __name__ == '__main__': | ||
| 141 | main() | ||
tools/dashboard-relay-test.py created+452| ... | @@ -0,0 +1,452 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | import base64 | ||
| 4 | from concurrent.futures import ThreadPoolExecutor | ||
| 5 | import hashlib | ||
| 6 | import importlib | ||
| 7 | import json | ||
| 8 | import os | ||
| 9 | from pathlib import Path | ||
| 10 | import queue | ||
| 11 | import re | ||
| 12 | import tempfile | ||
| 13 | import socket | ||
| 14 | import sqlite3 | ||
| 15 | import struct | ||
| 16 | import subprocess | ||
| 17 | import sys | ||
| 18 | import threading | ||
| 19 | import time | ||
| 20 | import urllib.error | ||
| 21 | import urllib.parse | ||
| 22 | import urllib.request | ||
| 23 | import uuid | ||
| 24 | |||
| 25 | |||
| 26 | class NoRedirect(urllib.request.HTTPRedirectHandler): | ||
| 27 | def redirect_request(self, request, fp, code, message, headers, newurl): | ||
| 28 | return None | ||
| 29 | |||
| 30 | |||
| 31 | class 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 | |||
| 136 | def 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 | |||
| 451 | if __name__ == "__main__": | ||
| 452 | main() | ||
tools/dashboard-routing-test.py created+234| ... | @@ -0,0 +1,234 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | from http.server import BaseHTTPRequestHandler, HTTPServer | ||
| 4 | import json | ||
| 5 | import os | ||
| 6 | from pathlib import Path | ||
| 7 | import socket | ||
| 8 | import subprocess | ||
| 9 | import tempfile | ||
| 10 | import threading | ||
| 11 | import time | ||
| 12 | import urllib.error | ||
| 13 | import urllib.request | ||
| 14 | import uuid | ||
| 15 | |||
| 16 | import router | ||
| 17 | |||
| 18 | |||
| 19 | def 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 | ||
| 112 | base = 'https://{host}:{port}' | ||
| 113 | proof = open('/proxy.token').read().strip() | ||
| 114 | nomad = open('/nomad.token').read().strip() | ||
| 115 | context = ssl.create_default_context(cafile='/ca.crt') | ||
| 116 | def 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() | ||
| 126 | for supplied in [None, '0' * 64, proof[:-1], proof + '0']: | ||
| 127 | assert request('/nomad/v1/services', supplied)[0] == 403 | ||
| 128 | status, body = request('/nomad/v1/services') | ||
| 129 | assert status == 200 and isinstance(json.loads(body), list), (status, body) | ||
| 130 | assert request('/nomad/v1/var/studio-routing-fixture')[0] == 403 | ||
| 131 | status, 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()) | ||
| 132 | assert status == 403, (status, body) | ||
| 133 | assert request('/services/unknown-fixture/health')[0] == 404 | ||
| 134 | for service in ['victoria-metrics', 'victoria-logs', 'victoria-traces']: | ||
| 135 | status, body = request('/services/' + service + '/health') | ||
| 136 | assert status == 200, (service, status, body) | ||
| 137 | status, body = request('/services/routing-fixture/probe?fixture=1') | ||
| 138 | result = json.loads(body) | ||
| 139 | assert 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. | ||
| 141 | at = time.time() - 60 | ||
| 142 | metric = 'studio_service_cpu_cores{{service="{fixture_id}"}} 0.125 ' + str(int(at * 1000)) + '\\n' | ||
| 143 | status, body = request('/services/victoria-metrics/api/v1/import/prometheus', method='POST', body=metric.encode(), content_type='text/plain') | ||
| 144 | assert status == 204, (status, body) | ||
| 145 | row = {{'_time': str(at), '_msg': 'Routing fixture', 'source': 'nomad', 'job': '{fixture_id}', 'task': 'probe', 'stream': 'stdout'}} | ||
| 146 | status, body = request('/services/victoria-logs/insert/jsonline', method='POST', body=(json.dumps(row) + '\\n').encode(), content_type='application/stream+json') | ||
| 147 | assert status == 200, (status, body) | ||
| 148 | trace = {{'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))}}]}}]}}]}} | ||
| 151 | status, body = request('/services/victoria-traces/insert/opentelemetry/v1/traces', method='POST', body=json.dumps(trace).encode()) | ||
| 152 | assert status == 200, (status, body) | ||
| 153 | print(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 | |||
| 233 | if __name__ == "__main__": | ||
| 234 | main() | ||
tools/dashboard-run.py created+566| ... | @@ -0,0 +1,566 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import fcntl | ||
| 3 | import json | ||
| 4 | import os | ||
| 5 | from pathlib import Path | ||
| 6 | import re | ||
| 7 | import selectors | ||
| 8 | import secrets | ||
| 9 | import signal | ||
| 10 | import stat | ||
| 11 | import subprocess | ||
| 12 | import sys | ||
| 13 | import time | ||
| 14 | import uuid | ||
| 15 | import urllib.error | ||
| 16 | import urllib.parse | ||
| 17 | import urllib.request | ||
| 18 | |||
| 19 | import release | ||
| 20 | |||
| 21 | |||
| 22 | FIELDS = { | ||
| 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 | } | ||
| 30 | ACTIONS = {"deploy", "destroy", "rollback", "start", "stop", "restart", "secret-set", "secret-rotate"} | ||
| 31 | MAX_LOG = 1024 * 1024 | ||
| 32 | MAX_METADATA = 65536 | ||
| 33 | HOST_STATE = Path(os.environ.get("STUDIO_HOST_STATE_ROOT", "/var/lib/studio/host")) | ||
| 34 | IAM_ROLES = {"infra-admin", "media", "media-manage"} | ||
| 35 | IAM_ACTIONS = {"UPDATE_PASSWORD", "VERIFY_EMAIL", "UPDATE_PROFILE", "CONFIGURE_TOTP", "webauthn-register", "webauthn-register-passwordless"} | ||
| 36 | |||
| 37 | |||
| 38 | class Error(Exception): | ||
| 39 | def __init__(self, status, message): | ||
| 40 | super().__init__(message) | ||
| 41 | self.status = status | ||
| 42 | |||
| 43 | |||
| 44 | def 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 | |||
| 58 | def 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 | |||
| 65 | def 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 | |||
| 72 | def 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 | |||
| 81 | def 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 | |||
| 102 | def 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 | |||
| 135 | def 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 | |||
| 144 | def 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 | |||
| 153 | def 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 | |||
| 187 | def last(): | ||
| 188 | text = read(HOST_STATE / "last-run.json") | ||
| 189 | if text is None: | ||
| 190 | return None | ||
| 191 | return json.loads(text) | ||
| 192 | |||
| 193 | |||
| 194 | def 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 | |||
| 220 | def 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 | |||
| 256 | def 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 | |||
| 376 | def 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 | |||
| 434 | def 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 | |||
| 468 | def 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 | |||
| 536 | if __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 | ||
| 2 | import argparse | ||
| 3 | import base64 | ||
| 4 | from concurrent.futures import ThreadPoolExecutor | ||
| 5 | import copy | ||
| 6 | import http.cookiejar | ||
| 7 | import hashlib | ||
| 8 | import importlib | ||
| 9 | import json | ||
| 10 | import math | ||
| 11 | from pathlib import Path | ||
| 12 | import sqlite3 | ||
| 13 | import ssl | ||
| 14 | import subprocess | ||
| 15 | import sys | ||
| 16 | import time | ||
| 17 | import urllib.error | ||
| 18 | import urllib.parse | ||
| 19 | import urllib.request | ||
| 20 | import uuid | ||
| 21 | |||
| 22 | |||
| 23 | class NoRedirect(urllib.request.HTTPRedirectHandler): | ||
| 24 | def redirect_request(self, request, fp, code, message, headers, newurl): | ||
| 25 | return None | ||
| 26 | |||
| 27 | |||
| 28 | def 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 | |||
| 419 | if __name__ == '__main__': | ||
| 420 | main() | ||
tools/dashboard-shale-test.py created+468| ... | @@ -0,0 +1,468 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | import base64 | ||
| 4 | from contextlib import closing, contextmanager | ||
| 5 | import copy | ||
| 6 | from html.parser import HTMLParser | ||
| 7 | import http.client | ||
| 8 | import http.cookiejar | ||
| 9 | import http.server | ||
| 10 | import importlib | ||
| 11 | import json | ||
| 12 | import os | ||
| 13 | from pathlib import Path | ||
| 14 | import re | ||
| 15 | import sqlite3 | ||
| 16 | import ssl | ||
| 17 | import socket | ||
| 18 | import subprocess | ||
| 19 | import sys | ||
| 20 | import tempfile | ||
| 21 | import threading | ||
| 22 | import time | ||
| 23 | import urllib.error | ||
| 24 | import urllib.parse | ||
| 25 | import urllib.request | ||
| 26 | import uuid | ||
| 27 | |||
| 28 | |||
| 29 | class 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 | ||
| 72 | def 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 | |||
| 233 | def 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 | |||
| 467 | if __name__ == "__main__": | ||
| 468 | main() | ||
tools/dashboard-unit-test.py created+303| ... | @@ -0,0 +1,303 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | from contextlib import ExitStack | ||
| 4 | import importlib | ||
| 5 | import json | ||
| 6 | import os | ||
| 7 | from pathlib import Path | ||
| 8 | import pwd | ||
| 9 | import re | ||
| 10 | import shutil | ||
| 11 | import socket | ||
| 12 | import subprocess | ||
| 13 | import sys | ||
| 14 | import tempfile | ||
| 15 | import time | ||
| 16 | import urllib.request | ||
| 17 | import uuid | ||
| 18 | |||
| 19 | import router | ||
| 20 | |||
| 21 | |||
| 22 | def 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 | |||
| 302 | if __name__ == "__main__": | ||
| 303 | main() | ||
tools/dashboard-vm-boundary-test.py created+164| ... | @@ -0,0 +1,164 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | import json | ||
| 4 | import os | ||
| 5 | from pathlib import Path | ||
| 6 | import re | ||
| 7 | import subprocess | ||
| 8 | import sys | ||
| 9 | import time | ||
| 10 | import uuid | ||
| 11 | import xml.etree.ElementTree as ET | ||
| 12 | |||
| 13 | |||
| 14 | def 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 | ||
| 81 | sys.path.insert(0, sys.argv[1]) | ||
| 82 | import vms | ||
| 83 | spec = json.load(sys.stdin) | ||
| 84 | if 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 | ||
| 90 | else: | ||
| 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 | ||
| 98 | vms.validate('create', spec) | ||
| 99 | try: | ||
| 100 | vms.create(spec) | ||
| 101 | except OSError as error: | ||
| 102 | print(str(error), file=sys.stderr) | ||
| 103 | sys.exit(1) | ||
| 104 | raise 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 | |||
| 163 | if __name__ == "__main__": | ||
| 164 | main() | ||
tools/data-restore.md created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | # Production data rollback | ||
| 2 | |||
| 3 | Every 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 | |||
| 5 | The 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 | |||
| 7 | At 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 | |||
| 9 | To restore one service, roll its code back first, then run: | ||
| 10 | |||
| 11 | ```sh | ||
| 12 | python3 tools/deploy.py backups | ||
| 13 | python3 tools/deploy.py rollback <old-release-id> | ||
| 14 | python3 tools/deploy.py data-restore <service-id> --backup <backup-id> --discard-writes | ||
| 15 | ``` | ||
| 16 | |||
| 17 | The 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 | |||
| 19 | The 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 | |||
| 21 | On 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 | |||
| 25 | On 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 | |||
| 27 | The `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 | ||
| 2 | import argparse | ||
| 3 | from contextlib import contextmanager | ||
| 4 | from datetime import datetime, timezone | ||
| 5 | import hashlib | ||
| 6 | import json | ||
| 7 | import os | ||
| 8 | from pathlib import Path | ||
| 9 | import re | ||
| 10 | import secrets | ||
| 11 | import shutil | ||
| 12 | import subprocess | ||
| 13 | import time | ||
| 14 | |||
| 15 | |||
| 16 | ROOT = Path("/opt/studio") | ||
| 17 | STATE = Path("/var/lib/studio") | ||
| 18 | BACKUPS = STATE / "backups" | ||
| 19 | BACKUP_ID = re.compile(r"\d{8}T\d{6}Z-[0-9a-f]{6}\Z") | ||
| 20 | SERVICE_ID = re.compile(r"[a-z][a-z0-9-]*\Z") | ||
| 21 | RELEASE_ID = re.compile(r"[0-9a-f]{16}\Z") | ||
| 22 | PODMAN = ["podman", "--url", "unix:///run/podman/podman.sock"] | ||
| 23 | |||
| 24 | |||
| 25 | def run(*args, capture=False, **kwargs): | ||
| 26 | return subprocess.run(args, check=True, capture_output=capture, **kwargs) | ||
| 27 | |||
| 28 | |||
| 29 | def 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 | |||
| 39 | def 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 | |||
| 51 | def 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 | |||
| 74 | def 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 | |||
| 82 | def 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 | |||
| 90 | def 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 | ||
| 102 | def 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 | |||
| 143 | def 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 | |||
| 216 | def 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 | |||
| 222 | def 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 | |||
| 282 | def 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 | |||
| 301 | if __name__ == "__main__": | ||
| 302 | main() | ||
tools/dawarich-migration.md created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | # Dawarich preview and fresh production start | ||
| 2 | |||
| 3 | Zenith'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 | |||
| 5 | Zenith'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 | |||
| 7 | The 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 | |||
| 9 | Before 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 | |||
| 11 | The 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 | |||
| 13 | The 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 | |||
| 17 | The 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 | |||
| 3 | The `.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 | |||
| 5 | The 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 | |||
| 7 | Snow 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 | |||
| 9 | At 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 | ||
| 2 | import json | ||
| 3 | import os | ||
| 4 | from pathlib import Path | ||
| 5 | import shutil | ||
| 6 | import subprocess | ||
| 7 | import tempfile | ||
| 8 | import unittest | ||
| 9 | from unittest.mock import patch | ||
| 10 | |||
| 11 | import deploy | ||
| 12 | import release | ||
| 13 | |||
| 14 | |||
| 15 | class 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 | |||
| 70 | if __name__ == "__main__": | ||
| 71 | unittest.main() | ||
tools/deploy.py created+224| ... | @@ -0,0 +1,224 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | import json | ||
| 4 | import os | ||
| 5 | from pathlib import Path | ||
| 6 | import re | ||
| 7 | import shlex | ||
| 8 | import shutil | ||
| 9 | import subprocess | ||
| 10 | import sys | ||
| 11 | import tempfile | ||
| 12 | import time | ||
| 13 | |||
| 14 | from release import SOURCES, tree_digest | ||
| 15 | |||
| 16 | |||
| 17 | REPO = Path(__file__).resolve().parent.parent | ||
| 18 | HOST = os.environ.get("STUDIO_DEPLOY_HOST", "root@127.0.0.1") | ||
| 19 | PORT = os.environ.get("STUDIO_DEPLOY_PORT", "2222") | ||
| 20 | REMOTE = Path("/opt/studio") | ||
| 21 | NAME = re.compile(r"[a-z][a-z0-9-]*\Z") | ||
| 22 | RELEASE_ID = re.compile(r"[0-9a-f]{16}\Z") | ||
| 23 | |||
| 24 | |||
| 25 | def 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 | |||
| 32 | def 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 | |||
| 40 | def sync_manager(release): | ||
| 41 | source = REMOTE / "releases" / release / "tools" | ||
| 42 | ssh(f"cp {source}/release.py {source}/data.py {REMOTE}/") | ||
| 43 | |||
| 44 | |||
| 45 | def 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 | |||
| 134 | def 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 | |||
| 219 | if __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 | |||
| 3 | Zenith'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 | |||
| 5 | Snow 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 | ||
| 8 | bash 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 | |||
| 19 | The 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 | |||
| 21 | For 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 | |||
| 23 | A 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 | |||
| 25 | A 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 | |||
| 27 | After 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 | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 5 | root=/mnt/storage1/apps/studio-handoff | ||
| 6 | id=$(date -u +%Y%m%dT%H%M%SZ)-$(python3 -c 'import secrets; print(secrets.token_hex(3))') | ||
| 7 | pending=$root/.pending-$id | ||
| 8 | final=$root/$id | ||
| 9 | |||
| 10 | ssh "$source_host" 'test "$(findmnt -n -o SOURCE --mountpoint /mnt/storage1/apps)" = storage1/apps; test "$(findmnt -n -o FSTYPE --mountpoint /mnt/storage1/apps)" = zfs' | ||
| 11 | ssh "$source_host" 'sudo -n docker info >/dev/null' | ||
| 12 | |||
| 13 | check_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 | |||
| 24 | check_stopped | ||
| 25 | ssh "$source_host" 'sudo -n docker exec postgres pg_isready -U postgres >/dev/null' | ||
| 26 | ssh "$source_host" 'test -r /mnt/storage1/apps/home-infra/.env' | ||
| 27 | ssh "$source_host" "set -e; test ! -e '$final'; mkdir -m 700 -p '$root'; mkdir -m 700 '$pending'" | ||
| 28 | for 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" | ||
| 30 | done | ||
| 31 | check_stopped | ||
| 32 | ssh "$source_host" python3 - "$pending" "$id" <<'PY' | ||
| 33 | import hashlib | ||
| 34 | import json | ||
| 35 | from pathlib import Path | ||
| 36 | import subprocess | ||
| 37 | import sys | ||
| 38 | |||
| 39 | folder = Path(sys.argv[1]) | ||
| 40 | manifest = {"id": sys.argv[2], "databases": {}} | ||
| 41 | queries = { | ||
| 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 | } | ||
| 45 | for 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} | ||
| 58 | legacy_env = (Path("/mnt/storage1/apps/home-infra/.env")).read_text().splitlines() | ||
| 59 | forgejo_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 | } | ||
| 69 | fingerprints = {} | ||
| 70 | for 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() | ||
| 75 | manifest["databases"]["evil-forgejo"]["secretSha256"] = fingerprints | ||
| 76 | (folder / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") | ||
| 77 | PY | ||
| 78 | ssh "$source_host" "set -e; chmod 600 '$pending/manifest.json'; mv '$pending' '$final'; sync" | ||
| 79 | check_stopped | ||
| 80 | echo "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 | |||
| 5 | The 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 | |||
| 7 | For 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 | |||
| 9 | For 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 | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | service=${1:?usage: tools/import-arr.sh sonarr|radarr [preview-id]} | ||
| 5 | [[ $service == sonarr || $service == radarr ]] || { echo 'Expected sonarr or radarr' >&2; exit 1; } | ||
| 6 | instance=${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 | |||
| 9 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 10 | target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1} | ||
| 11 | target_port=${STUDIO_DEPLOY_PORT:-2222} | ||
| 12 | remote=(ssh -p "$target_port" "$target_host") | ||
| 13 | source_copy_host=$source_host | ||
| 14 | source_rsh=ssh | ||
| 15 | offline=false | ||
| 16 | if [[ -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 | ||
| 24 | fi | ||
| 25 | source_ssh() { | ||
| 26 | if [[ $offline == true ]]; then | ||
| 27 | "${remote[@]}" "$@" | ||
| 28 | else | ||
| 29 | ssh "$source_host" "$@" | ||
| 30 | fi | ||
| 31 | } | ||
| 32 | if [[ $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 | ||
| 47 | else | ||
| 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; } | ||
| 51 | fi | ||
| 52 | dataset=$("${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 | |||
| 55 | scratch=$(mktemp -d) | ||
| 56 | source_backup= | ||
| 57 | cleanup() { | ||
| 58 | rm -rf "$scratch" | ||
| 59 | if [[ -n $source_backup ]]; then | ||
| 60 | source_ssh "rm -f '$source_backup'" | ||
| 61 | fi | ||
| 62 | } | ||
| 63 | trap cleanup EXIT | ||
| 64 | |||
| 65 | source_backup=$(source_ssh "python3 - '/mnt/storage1/apps/$service/$service.db'" <<'PY' | ||
| 66 | import os | ||
| 67 | import sqlite3 | ||
| 68 | import sys | ||
| 69 | import tempfile | ||
| 70 | |||
| 71 | source = sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True) | ||
| 72 | fd, name = tempfile.mkstemp(prefix="studio-arr-", suffix=".db") | ||
| 73 | os.close(fd) | ||
| 74 | try: | ||
| 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() | ||
| 81 | except BaseException: | ||
| 82 | os.unlink(name) | ||
| 83 | raise | ||
| 84 | print(name) | ||
| 85 | PY | ||
| 86 | ) | ||
| 87 | rsync -a --exclude="/$service.db*" --exclude='/logs.db*' --exclude='/*.pid' --exclude='/logs/' \ | ||
| 88 | -e "$source_rsh" "$source_copy_host:/mnt/storage1/apps/$service/" "$scratch/config/" | ||
| 89 | if [[ $offline == true ]]; then | ||
| 90 | scp -q -P "$target_port" "$source_copy_host:$source_backup" "$scratch/config/$service.db" | ||
| 91 | else | ||
| 92 | scp -q "$source_copy_host:$source_backup" "$scratch/config/$service.db" | ||
| 93 | fi | ||
| 94 | |||
| 95 | if [[ $instance != "$service" ]]; then | ||
| 96 | "${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance" | ||
| 97 | fi | ||
| 98 | for _ 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 | ||
| 103 | done | ||
| 104 | [[ $running == 0 ]] || { echo "$instance still has a running allocation" >&2; exit 1; } | ||
| 105 | |||
| 106 | snapshot="$dataset@before-arr-import-$(date +%s)-$$" | ||
| 107 | "${remote[@]}" "zfs snapshot $snapshot" | ||
| 108 | rsync -a --delete -e "ssh -p $target_port" "$scratch/config/" "$target_host:$root/config/" | ||
| 109 | uid=$("${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\"'" | ||
| 111 | local_hash=$(shasum -a 256 "$scratch/config/$service.db" | cut -d ' ' -f 1) | ||
| 112 | remote_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 | |||
| 115 | if [[ $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" | ||
| 123 | else | ||
| 124 | python3 "$(dirname "$0")/deploy.py" stage "$service" | ||
| 125 | "${remote[@]}" "zfs destroy $snapshot" | ||
| 126 | echo "Imported and tested $instance" | ||
| 127 | fi | ||
tools/import-dawarich.sh created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | #!/usr/bin/env bash | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | instance=${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 | |||
| 10 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 11 | target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1} | ||
| 12 | target_port=${STUDIO_DEPLOY_PORT:-2222} | ||
| 13 | remote=(ssh -p "$target_port" "$target_host") | ||
| 14 | |||
| 15 | root=/srv/staging/$instance | ||
| 16 | source_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; } | ||
| 18 | snapshot=$(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; } | ||
| 20 | source_root=/mnt/storage1/apps/.zfs/snapshot/${snapshot#*@}/dawarich_storage | ||
| 21 | |||
| 22 | dataset=$("${remote[@]}" "findmnt -n -o SOURCE --mountpoint $root") | ||
| 23 | [[ $dataset == */staging/$instance ]] || { echo 'Preview dataset is not mounted' >&2; exit 1; } | ||
| 24 | |||
| 25 | source_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())') | ||
| 27 | own=$("${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 | |||
| 31 | db_json=$("${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad var get -out json nomad/jobs/$instance/inputs/database") | ||
| 32 | read -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 | |||
| 35 | allocation=$("${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])') | ||
| 37 | container=app-$allocation | ||
| 38 | podman='podman --url unix:///run/podman/podman.sock' | ||
| 39 | scratch=$(mktemp -d) | ||
| 40 | trap 'rm -rf "$scratch"' EXIT | ||
| 41 | rsync -a "$source_host:$source_root/" "$scratch/" | ||
| 42 | drift=$(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; } | ||
| 44 | source_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" | ||
| 47 | for _ 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 | ||
| 52 | done | ||
| 53 | [[ $running == 0 ]] || { echo 'Dawarich allocation is still running' >&2; exit 1; } | ||
| 54 | |||
| 55 | backup_snapshot=$dataset@before-dawarich-import-$(date +%s)-$$ | ||
| 56 | "${remote[@]}" "zfs snapshot $backup_snapshot" | ||
| 57 | rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/var/app/storage/" | ||
| 58 | uid=$("${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" | ||
| 60 | drift=$(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 | |||
| 63 | backup=/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" | ||
| 66 | ssh "$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" | ||
| 68 | target_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 | |||
| 71 | python3 "$(dirname "$0")/deploy.py" stage dawarich | ||
| 72 | "${remote[@]}" "zfs destroy $backup_snapshot" | ||
| 73 | echo "Imported $instance from $snapshot. Previous database: $backup" | ||
tools/import-evil-forgejo.sh created+77| ... | @@ -0,0 +1,77 @@ | ||
| 1 | #!/usr/bin/env bash | ||
| 2 | set -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 | |||
| 7 | target_port=${STUDIO_DEPLOY_PORT:-22} | ||
| 8 | remote=(ssh -p "$target_port" "$STUDIO_DEPLOY_HOST") | ||
| 9 | sh "$(dirname "$0")/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$STUDIO_DEPLOY_HOST" "$target_port" | ||
| 10 | root=/srv/prod/evil-forgejo | ||
| 11 | source_dir=/mnt/storage1/apps/evil-infra/forgejo | ||
| 12 | database=evil_forgejo | ||
| 13 | owner=svc_evil_forgejo | ||
| 14 | podman='podman --url unix:///run/podman/podman.sock' | ||
| 15 | |||
| 16 | entry=$("${remote[@]}" "python3 - '$STUDIO_LEGACY_HANDOFF' evil-forgejo" < "$(dirname "$0")/verify-legacy-dump.py") | ||
| 17 | source_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 | |||
| 20 | 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-forgejo') | ||
| 21 | [[ ${response##*$'\n'} == 200 ]] || { echo 'evil.inc Forgejo is unavailable on the home server' >&2; exit 1; } | ||
| 22 | stopped=$(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; } | ||
| 24 | for _ 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 | ||
| 29 | done | ||
| 30 | [[ $running == 0 ]] || { echo 'evil.inc Forgejo did not stop on the home server' >&2; exit 1; } | ||
| 31 | |||
| 32 | db_json=$("${remote[@]}" 'NOMAD_TOKEN=$(cat /var/lib/studio/nomad.token) nomad var get -out json nomad/jobs/evil-forgejo/inputs/database') | ||
| 33 | read -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' | ||
| 37 | import hashlib | ||
| 38 | import json | ||
| 39 | import os | ||
| 40 | from pathlib import Path | ||
| 41 | import subprocess | ||
| 42 | import sys | ||
| 43 | |||
| 44 | legacy = json.loads((Path(sys.argv[1]) / "manifest.json").read_text())["databases"]["evil-forgejo"]["secretSha256"] | ||
| 45 | environment = {**os.environ, "NOMAD_TOKEN": Path("/var/lib/studio/nomad.token").read_text().strip()} | ||
| 46 | result = subprocess.run(["nomad", "var", "get", "-out", "json", "nomad/jobs/evil-forgejo"], | ||
| 47 | check=True, capture_output=True, text=True, env=environment) | ||
| 48 | actual = json.loads(result.stdout)["Items"] | ||
| 49 | for 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}") | ||
| 52 | PY | ||
| 53 | |||
| 54 | allocation=$("${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])') | ||
| 56 | container=app-$allocation | ||
| 57 | source_manifest=$("${remote[@]}" "python3 - '$source_dir'" < "$(dirname "$0")/tree-hash.py") | ||
| 58 | source_bytes=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["bytes"])' <<<"$source_manifest") | ||
| 59 | available=$("${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 | |||
| 62 | dataset=$("${remote[@]}" "findmnt -n -o SOURCE --mountpoint $root") | ||
| 63 | [[ $dataset == */prod/evil-forgejo ]] || { echo 'Unexpected production dataset' >&2; exit 1; } | ||
| 64 | snapshot=$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/'" | ||
| 66 | target_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; } | ||
| 68 | uid=$("${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 | |||
| 71 | backup=$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" | ||
| 75 | counts=$("${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; } | ||
| 77 | echo "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 | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | instance=${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; } | ||
| 6 | if [[ $instance == evil-hedgedoc ]]; then | ||
| 7 | root=/srv/prod/evil-hedgedoc | ||
| 8 | else | ||
| 9 | root="/srv/staging/$instance" | ||
| 10 | fi | ||
| 11 | |||
| 12 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 13 | target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1} | ||
| 14 | target_port=${STUDIO_DEPLOY_PORT:-2222} | ||
| 15 | remote=(ssh -p "$target_port" "$target_host") | ||
| 16 | path="nomad/jobs/$instance/inputs/database" | ||
| 17 | handoff=${STUDIO_LEGACY_HANDOFF:-} | ||
| 18 | if [[ -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" | ||
| 28 | else | ||
| 29 | upload_host=$source_host | ||
| 30 | upload_rsh=ssh | ||
| 31 | fi | ||
| 32 | |||
| 33 | if [[ $instance != evil-hedgedoc ]]; then | ||
| 34 | "${remote[@]}" "test -f /var/lib/studio/stages/$instance.json" | ||
| 35 | else | ||
| 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 | ||
| 56 | fi | ||
| 57 | "${remote[@]}" "test \$(findmnt -n -o FSTYPE --mountpoint $root) = zfs" | ||
| 58 | db_json=$("${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad var get -out json $path") | ||
| 59 | read -r database owner < <(python3 -c 'import json,sys; d=json.load(sys.stdin)["Items"]; print(d["name"], d["username"])' <<<"$db_json") | ||
| 60 | if [[ $instance == evil-hedgedoc ]]; then | ||
| 61 | [[ $database == evil_hedgedoc && $owner == svc_evil_hedgedoc ]] || { echo 'Unexpected production database' >&2; exit 1; } | ||
| 62 | else | ||
| 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; } | ||
| 64 | fi | ||
| 65 | |||
| 66 | alloc_json=$("${remote[@]}" 'NOMAD_TOKEN=$(cat /var/lib/studio/nomad.token) nomad job allocs -json postgres') | ||
| 67 | allocation=$(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") | ||
| 68 | container="app-$allocation" | ||
| 69 | podman='podman --url unix:///run/podman/podman.sock' | ||
| 70 | scratch=$(mktemp -d) | ||
| 71 | trap 'rm -rf "$scratch"' EXIT | ||
| 72 | rsync -a -e "$upload_rsh" "$upload_host:/mnt/storage1/apps/evil-infra/hedgedoc/" "$scratch/uploads/" | ||
| 73 | drift=$(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 | |||
| 76 | if [[ $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; } | ||
| 84 | fi | ||
| 85 | |||
| 86 | uploads="$root/hedgedoc/public/uploads" | ||
| 87 | rsync -a --delete -e "ssh -p $target_port" "$scratch/uploads/" "$target_host:$uploads/" | ||
| 88 | drift=$(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; } | ||
| 90 | uid=$("${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 | |||
| 93 | backup="/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 | |||
| 97 | if [[ -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" | ||
| 99 | else | ||
| 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" | ||
| 102 | fi | ||
| 103 | |||
| 104 | counts_sql='SELECT (SELECT count(*) FROM "Notes"), (SELECT count(*) FROM "Users"), (SELECT count(*) FROM "Revisions"), (SELECT count(*) FROM "Authors");' | ||
| 105 | if [[ -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) | ||
| 107 | fi | ||
| 108 | target_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; } | ||
| 110 | echo "Verified HedgeDoc Notes, Users, Revisions, and Authors counts: $target_counts" | ||
| 111 | |||
| 112 | if [[ $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" | ||
| 120 | else | ||
| 121 | python3 "$(dirname "$0")/deploy.py" stage evil-hedgedoc | ||
| 122 | echo "Preview database imported; previous preview dump: $backup" | ||
| 123 | fi | ||
tools/import-jackett.sh created+100| ... | @@ -0,0 +1,100 @@ | ||
| 1 | #!/bin/sh | ||
| 2 | set -eu | ||
| 3 | |||
| 4 | instance=${1:?usage: tools/import-jackett.sh jackett|jackett-preview-XXXXXXXX} | ||
| 5 | case $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 ;; | ||
| 17 | esac | ||
| 18 | |||
| 19 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 20 | target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1} | ||
| 21 | target_port=${STUDIO_DEPLOY_PORT:-2222} | ||
| 22 | offline=false | ||
| 23 | copy_host=$source_host | ||
| 24 | copy_rsh=ssh | ||
| 25 | if [ -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" | ||
| 31 | fi | ||
| 32 | if [ "$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; } | ||
| 46 | else | ||
| 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" | ||
| 54 | fi | ||
| 55 | |||
| 56 | dataset=$(ssh -p "$target_port" "$target_host" "findmnt -n -o SOURCE --mountpoint $mount") | ||
| 57 | if [ "$production" = true ]; then | ||
| 58 | case $dataset in */prod/jackett) ;; *) echo 'Production dataset is not mounted' >&2; exit 1 ;; esac | ||
| 59 | else | ||
| 60 | case $dataset in */staging/"$instance") ;; *) echo 'Preview dataset is not mounted' >&2; exit 1 ;; esac | ||
| 61 | fi | ||
| 62 | |||
| 63 | scratch=$(mktemp -d) | ||
| 64 | trap 'rm -rf "$scratch"' EXIT | ||
| 65 | rsync -a --exclude='/log.txt*' -e "$copy_rsh" "$copy_host:$source_root/" "$scratch/" | ||
| 66 | python3 - "$scratch" <<'PY' | ||
| 67 | import json | ||
| 68 | from pathlib import Path | ||
| 69 | import sys | ||
| 70 | |||
| 71 | root = Path(sys.argv[1]) | ||
| 72 | config = json.loads((root / "ServerConfig.json").read_text()) | ||
| 73 | if not config.get("APIKey") or not list((root / "Indexers").glob("*.json")): | ||
| 74 | raise ValueError("Jackett configuration is incomplete") | ||
| 75 | PY | ||
| 76 | |||
| 77 | if [ "$production" = false ]; then | ||
| 78 | ssh -p "$target_port" "$target_host" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance" | ||
| 79 | fi | ||
| 80 | snapshot="$dataset@before-jackett-import-$(date +%s)-$$" | ||
| 81 | ssh -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 | |||
| 83 | rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/" | ||
| 84 | owner=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"jackett\"])'") | ||
| 85 | ssh -p "$target_port" "$target_host" "chown -R $owner:$owner $root" | ||
| 86 | drift=$(rsync -rlnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/" "$target_host:$root/") | ||
| 87 | test -z "$drift" || { echo "Jackett copy differs from source; restore $snapshot" >&2; exit 1; } | ||
| 88 | |||
| 89 | if [ "$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" | ||
| 96 | else | ||
| 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" | ||
| 100 | fi | ||
tools/import-jellyfin.sh created+103| ... | @@ -0,0 +1,103 @@ | ||
| 1 | #!/bin/sh | ||
| 2 | set -eu | ||
| 3 | |||
| 4 | instance=${1:?usage: tools/import-jellyfin.sh jellyfin|jellyfin-preview-XXXXXXXX} | ||
| 5 | case $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 ;; | ||
| 17 | esac | ||
| 18 | |||
| 19 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 20 | target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1} | ||
| 21 | target_port=${STUDIO_DEPLOY_PORT:-2222} | ||
| 22 | offline=false | ||
| 23 | if [ -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 | ||
| 27 | fi | ||
| 28 | if [ "$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 | ||
| 46 | else | ||
| 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" | ||
| 55 | fi | ||
| 56 | |||
| 57 | dataset=$(ssh -p "$target_port" "$target_host" "findmnt -n -o SOURCE --mountpoint $mount") | ||
| 58 | if [ "$production" = true ]; then | ||
| 59 | case $dataset in */prod/jellyfin) ;; *) echo 'Production dataset is not mounted' >&2; exit 1 ;; esac | ||
| 60 | else | ||
| 61 | case $dataset in */staging/"$instance") ;; *) echo 'Preview dataset is not mounted' >&2; exit 1 ;; esac | ||
| 62 | fi | ||
| 63 | |||
| 64 | if [ "$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/" | ||
| 69 | fi | ||
| 70 | |||
| 71 | if [ "$production" = false ]; then | ||
| 72 | ssh -p "$target_port" "$target_host" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance" | ||
| 73 | fi | ||
| 74 | snapshot="$dataset@before-jellyfin-import-$(date +%s)-$$" | ||
| 75 | ssh -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 | |||
| 77 | if [ "$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/'" | ||
| 79 | else | ||
| 80 | rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/" | ||
| 81 | fi | ||
| 82 | owner=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"jellyfin\"])'") | ||
| 83 | ssh -p "$target_port" "$target_host" "chown -R $owner:$owner $root" | ||
| 84 | if [ "$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/'") | ||
| 86 | else | ||
| 87 | drift=$(rsync -rlnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/" "$target_host:$root/") | ||
| 88 | fi | ||
| 89 | test -z "$drift" || { echo "Jellyfin copy differs from source; restore $snapshot" >&2; exit 1; } | ||
| 90 | ssh -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 | |||
| 92 | if [ "$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" | ||
| 99 | else | ||
| 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" | ||
| 103 | fi | ||
tools/import-legacy-secrets.sh created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | #!/bin/bash | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | service=${1:?usage: tools/import-legacy-secrets.sh SERVICE ENV_NAME...|KEY=ENV_NAME...} | ||
| 5 | shift | ||
| 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; } | ||
| 8 | sources=() | ||
| 9 | keys=() | ||
| 10 | for 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") | ||
| 21 | done | ||
| 22 | (( ${#keys[@]} == 0 || ${#keys[@]} == ${#sources[@]} )) || { echo 'Use a target key for every source name' >&2; exit 1; } | ||
| 23 | options=() | ||
| 24 | for key in "${keys[@]}"; do | ||
| 25 | options+=(--key "$key") | ||
| 26 | done | ||
| 27 | : "${STUDIO_DEPLOY_HOST:?Set STUDIO_DEPLOY_HOST to the production target}" | ||
| 28 | |||
| 29 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 30 | repo=$(cd "$(dirname "$0")/.." && pwd) | ||
| 31 | names=$(printf ' %s' "${sources[@]}") | ||
| 32 | if [[ -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") | ||
| 35 | else | ||
| 36 | source_remote=(ssh "$source_host") | ||
| 37 | fi | ||
| 38 | |||
| 39 | "${source_remote[@]}" "python3 -$names <<'PY' | ||
| 40 | from pathlib import Path | ||
| 41 | import sys | ||
| 42 | |||
| 43 | lines = Path('/mnt/storage1/apps/home-infra/.env').read_text().splitlines() | ||
| 44 | if len(set(sys.argv[1:])) != len(sys.argv[1:]): | ||
| 45 | raise SystemExit('Duplicate source secret name') | ||
| 46 | secrets = [] | ||
| 47 | for 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]) | ||
| 52 | for value in secrets: | ||
| 53 | print(value) | ||
| 54 | PY" | python3 "$repo/tools/deploy.py" secrets "$service" --file - "${options[@]}" | ||
tools/import-navidrome.sh created+102| ... | @@ -0,0 +1,102 @@ | ||
| 1 | #!/bin/sh | ||
| 2 | set -eu | ||
| 3 | |||
| 4 | instance=${1:?usage: tools/import-navidrome.sh navidrome|navidrome-preview-XXXXXXXX} | ||
| 5 | case $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 ;; | ||
| 17 | esac | ||
| 18 | |||
| 19 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 20 | target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1} | ||
| 21 | target_port=${STUDIO_DEPLOY_PORT:-2222} | ||
| 22 | offline=false | ||
| 23 | if [ -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 | ||
| 27 | fi | ||
| 28 | if [ "$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; } | ||
| 44 | else | ||
| 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" | ||
| 52 | fi | ||
| 53 | |||
| 54 | dataset=$(ssh -p "$target_port" "$target_host" "findmnt -n -o SOURCE --mountpoint $mount") | ||
| 55 | if [ "$production" = true ]; then | ||
| 56 | case $dataset in */prod/navidrome) ;; *) echo 'Production dataset is not mounted' >&2; exit 1 ;; esac | ||
| 57 | else | ||
| 58 | case $dataset in */staging/"$instance") ;; *) echo 'Preview dataset is not mounted' >&2; exit 1 ;; esac | ||
| 59 | fi | ||
| 60 | |||
| 61 | if [ "$offline" = true ]; then | ||
| 62 | ssh -p "$target_port" "$target_host" "test -s '$source_root/navidrome.db'" || { echo 'Navidrome database is missing' >&2; exit 1; } | ||
| 63 | else | ||
| 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; } | ||
| 68 | fi | ||
| 69 | |||
| 70 | if [ "$production" = false ]; then | ||
| 71 | ssh -p "$target_port" "$target_host" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance" | ||
| 72 | fi | ||
| 73 | snapshot="$dataset@before-navidrome-import-$(date +%s)-$$" | ||
| 74 | ssh -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 | |||
| 76 | if [ "$offline" = true ]; then | ||
| 77 | ssh -p "$target_port" "$target_host" "rsync -a --delete --delete-excluded --exclude='*-shm' '$source_root/' '$root/'" | ||
| 78 | else | ||
| 79 | rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/" | ||
| 80 | fi | ||
| 81 | owner=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"navidrome\"])'") | ||
| 82 | ssh -p "$target_port" "$target_host" "chown -R $owner:$owner $root" | ||
| 83 | if [ "$offline" = true ]; then | ||
| 84 | drift=$(ssh -p "$target_port" "$target_host" "rsync -rlnc --delete --delete-excluded --exclude='*-shm' --out-format='%n' '$source_root/' '$root/'") | ||
| 85 | else | ||
| 86 | drift=$(rsync -rlnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/" "$target_host:$root/") | ||
| 87 | fi | ||
| 88 | test -z "$drift" || { echo "Navidrome copy differs from source; restore $snapshot" >&2; exit 1; } | ||
| 89 | ssh -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 | |||
| 91 | if [ "$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" | ||
| 98 | else | ||
| 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" | ||
| 102 | fi | ||
tools/import-pds-secrets.py created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import os | ||
| 3 | from pathlib import Path | ||
| 4 | import re | ||
| 5 | import subprocess | ||
| 6 | |||
| 7 | |||
| 8 | if not os.environ.get("STUDIO_DEPLOY_HOST"): | ||
| 9 | raise SystemExit("Set STUDIO_DEPLOY_HOST to the production target") | ||
| 10 | |||
| 11 | if 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"]] | ||
| 13 | else: | ||
| 14 | ssh = ["ssh", "-o", "BatchMode=yes", os.environ.get("STUDIO_MIGRATION_SOURCE", "zenith")] | ||
| 15 | |||
| 16 | source = subprocess.run( | ||
| 17 | [*ssh, "cat", "/mnt/storage1/apps/home-infra/.env"], | ||
| 18 | check=True, capture_output=True, text=True, | ||
| 19 | ).stdout | ||
| 20 | names = { | ||
| 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 | } | ||
| 28 | values = {} | ||
| 29 | for 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() | ||
| 36 | if len(values) != len(names) or not all(values.values()): | ||
| 37 | raise ValueError("PDS source secrets are incomplete") | ||
| 38 | if not re.fullmatch(r"[0-9a-fA-F]{64}", values["plc_rotation_key"]): | ||
| 39 | raise ValueError("invalid PDS rotation key") | ||
| 40 | |||
| 41 | subprocess.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 | ||
| 2 | set -eu | ||
| 3 | |||
| 4 | instance=${1:?usage: tools/import-pds.sh pds|pds-preview-XXXXXXXX} | ||
| 5 | case $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 ;; | ||
| 17 | esac | ||
| 18 | |||
| 19 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 20 | target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1} | ||
| 21 | target_port=${STUDIO_DEPLOY_PORT:-2222} | ||
| 22 | offline=false | ||
| 23 | copy_host=$source_host | ||
| 24 | copy_rsh=ssh | ||
| 25 | if [ -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" | ||
| 31 | fi | ||
| 32 | source_ssh() { | ||
| 33 | if [ "$offline" = true ]; then | ||
| 34 | ssh -p "$target_port" "$target_host" "$@" | ||
| 35 | else | ||
| 36 | ssh "$source_host" "$@" | ||
| 37 | fi | ||
| 38 | } | ||
| 39 | if [ "$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; } | ||
| 51 | else | ||
| 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; } | ||
| 56 | fi | ||
| 57 | scratch=$(mktemp -d) | ||
| 58 | source_backup= | ||
| 59 | cleanup() { | ||
| 60 | rm -rf "$scratch" | ||
| 61 | if [ -n "$source_backup" ]; then | ||
| 62 | source_ssh "rm -rf '$source_backup'" | ||
| 63 | fi | ||
| 64 | } | ||
| 65 | trap cleanup EXIT | ||
| 66 | |||
| 67 | dataset=$(ssh -p "$target_port" "$target_host" "findmnt -n -o SOURCE --mountpoint $mount") | ||
| 68 | if [ "$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" | ||
| 71 | else | ||
| 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; } | ||
| 75 | fi | ||
| 76 | snapshot="$dataset@before-pds-import-$(date +%s)-$$" | ||
| 77 | |||
| 78 | source_backup=$(source_ssh python3 - <<'PY' | ||
| 79 | from pathlib import Path | ||
| 80 | import shutil | ||
| 81 | import sqlite3 | ||
| 82 | import tempfile | ||
| 83 | |||
| 84 | out = Path(tempfile.mkdtemp(prefix="studio-pds-")) | ||
| 85 | try: | ||
| 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() | ||
| 94 | except BaseException: | ||
| 95 | shutil.rmtree(out) | ||
| 96 | raise | ||
| 97 | print(out) | ||
| 98 | PY | ||
| 99 | ) | ||
| 100 | rsync -a -e "$copy_rsh" "$copy_host:$source_backup/" "$scratch/" | ||
| 101 | rsync -a --exclude='*.sqlite*' -e "$copy_rsh" "$copy_host:/mnt/storage1/apps/pds/" "$scratch/" | ||
| 102 | drift=$(rsync -rnc --delete --exclude='*.sqlite*' --out-format='%n' -e "$copy_rsh" "$copy_host:/mnt/storage1/apps/pds/" "$scratch/") | ||
| 103 | test -z "$drift" || { echo 'PDS source changed during copy; retry the import' >&2; exit 1; } | ||
| 104 | |||
| 105 | if [ "$production" = false ]; then | ||
| 106 | ssh -p "$target_port" "$target_host" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance" | ||
| 107 | fi | ||
| 108 | ssh -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 | |||
| 110 | rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/" | ||
| 111 | owner=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"pds\"])'") | ||
| 112 | ssh -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)'" | ||
| 113 | drift=$(rsync -rnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/" "$target_host:$root/") | ||
| 114 | test -z "$drift" || { echo "PDS copy differs from source backup; restore $snapshot" >&2; exit 1; } | ||
| 115 | |||
| 116 | if [ "$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" | ||
| 123 | else | ||
| 124 | python3 "$(dirname "$0")/deploy.py" stage pds | ||
| 125 | ssh -p "$target_port" "$target_host" "zfs destroy $snapshot" | ||
| 126 | echo "Imported and tested $instance" | ||
| 127 | fi | ||
tools/import-qbittorrent.sh created+66| ... | @@ -0,0 +1,66 @@ | ||
| 1 | #!/usr/bin/env bash | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | : "${STUDIO_DEPLOY_HOST:?Set STUDIO_DEPLOY_HOST to the production target}" | ||
| 5 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 6 | target_host=$STUDIO_DEPLOY_HOST | ||
| 7 | target_port=${STUDIO_DEPLOY_PORT:-2222} | ||
| 8 | remote=(ssh -p "$target_port" "$target_host") | ||
| 9 | root=/srv/prod/qbittorrent | ||
| 10 | source_copy_host=$source_host | ||
| 11 | source_rsh=ssh | ||
| 12 | offline=false | ||
| 13 | if [[ -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 | ||
| 18 | fi | ||
| 19 | |||
| 20 | if [[ $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; } | ||
| 23 | fi | ||
| 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 | |||
| 28 | 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/qbittorrent') | ||
| 29 | status=${response##*$'\n'} | ||
| 30 | if [[ $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; } | ||
| 33 | elif [[ $status != 404 ]]; then | ||
| 34 | echo "Could not verify qBittorrent job state on the home server: $status" >&2 | ||
| 35 | exit 1 | ||
| 36 | fi | ||
| 37 | dataset=$("${remote[@]}" "findmnt -n -o SOURCE --mountpoint $root") | ||
| 38 | [[ $dataset == */prod/qbittorrent ]] || { echo "Expected a mounted service dataset at $root" >&2; exit 1; } | ||
| 39 | |||
| 40 | scratch=$(mktemp -d) | ||
| 41 | trap 'rm -rf "$scratch"' EXIT | ||
| 42 | mkdir "$scratch/qBittorrent" | ||
| 43 | rsync -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/" | ||
| 45 | resume_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 | |||
| 48 | snapshot="$dataset@before-qbittorrent-import-$(date +%s)-$$" | ||
| 49 | "${remote[@]}" "zfs snapshot $snapshot" | ||
| 50 | rsync -a --delete -e "ssh -p $target_port" "$scratch/qBittorrent/" "$target_host:$root/config/qBittorrent/" | ||
| 51 | changes=$(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 | |||
| 55 | uid=$("${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" | ||
| 57 | copied=$("${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; } | ||
| 59 | if [[ $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 | } | ||
| 64 | fi | ||
| 65 | "${remote[@]}" 'python3 /opt/studio/current/tools/studio.py deploy qbittorrent' | ||
| 66 | echo "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 | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | instance=${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; } | ||
| 6 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 7 | target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1} | ||
| 8 | target_port=${STUDIO_DEPLOY_PORT:-2222} | ||
| 9 | remote=(ssh -p "$target_port" "$target_host") | ||
| 10 | job_status=200 | ||
| 11 | handoff=${STUDIO_LEGACY_HANDOFF:-} | ||
| 12 | if [[ -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" | ||
| 20 | else | ||
| 21 | source_copy_host=$source_host | ||
| 22 | source_rsh=ssh | ||
| 23 | fi | ||
| 24 | |||
| 25 | if [[ $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 | ||
| 41 | else | ||
| 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 | ||
| 48 | fi | ||
| 49 | |||
| 50 | dataset=$("${remote[@]}" "findmnt -n -o SOURCE --mountpoint $root") | ||
| 51 | if [[ $instance == shale ]]; then | ||
| 52 | [[ $dataset == */prod/shale ]] || { echo 'Production Shale dataset is not mounted' >&2; exit 1; } | ||
| 53 | else | ||
| 54 | [[ $dataset == */staging/$instance ]] || { echo 'Preview Shale dataset is not mounted' >&2; exit 1; } | ||
| 55 | fi | ||
| 56 | |||
| 57 | scratch=$(mktemp -d) | ||
| 58 | trap 'rm -rf "$scratch"' EXIT | ||
| 59 | for 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; } | ||
| 63 | done | ||
| 64 | python3 - "$scratch/data/astheno.shale.db" <<'PY' | ||
| 65 | import sqlite3 | ||
| 66 | import sys | ||
| 67 | |||
| 68 | db = sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True) | ||
| 69 | assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok" | ||
| 70 | PY | ||
| 71 | |||
| 72 | if [[ $instance != shale ]]; then | ||
| 73 | "${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance" | ||
| 74 | fi | ||
| 75 | if [[ $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; } | ||
| 83 | fi | ||
| 84 | |||
| 85 | snapshot=$dataset@before-shale-import-$(date +%s)-$$ | ||
| 86 | "${remote[@]}" "zfs snapshot $snapshot" | ||
| 87 | for 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; } | ||
| 92 | done | ||
| 93 | uid=$("${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 | |||
| 97 | if [[ $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" | ||
| 102 | else | ||
| 103 | python3 "$(dirname "$0")/deploy.py" stage shale | ||
| 104 | "${remote[@]}" "zfs destroy $snapshot" | ||
| 105 | echo "Imported and tested $instance from $source_snapshot" | ||
| 106 | fi | ||
tools/import-source-data.sh created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | #!/usr/bin/env bash | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | : "${STUDIO_DEPLOY_HOST:?Set STUDIO_DEPLOY_HOST to the production target}" | ||
| 5 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 6 | target_host=$STUDIO_DEPLOY_HOST | ||
| 7 | target_port=${STUDIO_DEPLOY_PORT:-22} | ||
| 8 | remote=(ssh -p "$target_port" "$target_host") | ||
| 9 | target_dir=/srv/prod/clover-source-of-truth/data | ||
| 10 | tree_hash="$(dirname "$0")/tree-hash.py" | ||
| 11 | |||
| 12 | if [[ -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'" | ||
| 17 | else | ||
| 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 | } | ||
| 24 | fi | ||
| 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" | ||
| 26 | dataset=$("${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; } | ||
| 28 | 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/clover-source-of-truth') | ||
| 29 | job_status=${job_response##*$'\n'} | ||
| 30 | if [[ $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; } | ||
| 40 | elif [[ $job_status != 404 ]]; then | ||
| 41 | echo "Could not verify the home server job: $job_status" >&2 | ||
| 42 | exit 1 | ||
| 43 | fi | ||
| 44 | |||
| 45 | source_manifest=$("${source[@]}" "python3 - '$source_dir'" < "$tree_hash") | ||
| 46 | source_bytes=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["bytes"])' <<<"$source_manifest") | ||
| 47 | available=$("${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 | |||
| 50 | snapshot=$dataset@before-source-data-import-$(date +%s)-$$ | ||
| 51 | "${remote[@]}" "set -e; zfs snapshot $snapshot; rm -rf -- '$target_dir'; mkdir -p -- '$target_dir'" | ||
| 52 | if [[ -n ${STUDIO_MIGRATION_SNAPSHOT:-} ]]; then | ||
| 53 | "${remote[@]}" "bash -c \"set -euo pipefail; tar -C '$source_dir' -cf - . | tar -C '$target_dir' -xf -\"" | ||
| 54 | else | ||
| 55 | "${source[@]}" "tar -C '$source_dir' -cf - ." | | ||
| 56 | "${remote[@]}" "tar -C '$target_dir' -xf -" | ||
| 57 | fi | ||
| 58 | target_manifest=$("${remote[@]}" "python3 - '$target_dir'" < "$tree_hash") | ||
| 59 | [[ $source_manifest == "$target_manifest" ]] || { echo "Source and target trees differ; restore $snapshot" >&2; exit 1; } | ||
| 60 | uid=$("${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\"'" | ||
| 62 | if [[ -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 | } | ||
| 67 | fi | ||
| 68 | echo "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 | ||
| 2 | set -eu | ||
| 3 | |||
| 4 | instance=${1:?usage: tools/import-source-index.sh clover-source-of-truth-preview-XXXXXXXX} | ||
| 5 | printf '%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 | |||
| 10 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 11 | target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1} | ||
| 12 | target_port=${STUDIO_DEPLOY_PORT:-2222} | ||
| 13 | root="/srv/staging/$instance/data" | ||
| 14 | mount="/srv/staging/$instance" | ||
| 15 | source_id=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'") | ||
| 16 | test "$source_id" = clover-source-of-truth || { echo 'Preview belongs to another service' >&2; exit 1; } | ||
| 17 | dataset=$(ssh -p "$target_port" "$target_host" "findmnt -n -o SOURCE --mountpoint $mount") | ||
| 18 | case $dataset in */staging/"$instance") ;; *) echo 'Preview dataset is not mounted' >&2; exit 1 ;; esac | ||
| 19 | |||
| 20 | scratch=$(mktemp -d) | ||
| 21 | source_backup= | ||
| 22 | cleanup() { | ||
| 23 | rm -rf "$scratch" | ||
| 24 | if [ -n "$source_backup" ]; then | ||
| 25 | ssh "$source_host" "rm -rf '$source_backup'" | ||
| 26 | fi | ||
| 27 | } | ||
| 28 | trap cleanup EXIT | ||
| 29 | |||
| 30 | source_backup=$(ssh "$source_host" sh -s <<'REMOTE' | ||
| 31 | set -eu | ||
| 32 | out=$(mktemp -d /tmp/studio-source-index-XXXXXX) | ||
| 33 | trap 'rm -rf "$out"' EXIT | ||
| 34 | source='/mnt/storage1/clover/Documents/Config/paper clover/cache.sqlite' | ||
| 35 | sqlite3 -readonly "$source" ".backup '$out/cache.sqlite'" | ||
| 36 | test "$(sqlite3 -readonly "$out/cache.sqlite" 'PRAGMA integrity_check;')" = ok | ||
| 37 | trap - EXIT | ||
| 38 | printf '%s\n' "$out" | ||
| 39 | REMOTE | ||
| 40 | ) | ||
| 41 | rsync -a "$source_host:$source_backup/cache.sqlite" "$scratch/cache.sqlite" | ||
| 42 | python3 - "$scratch/cache.sqlite" <<'PY' | ||
| 43 | import sqlite3 | ||
| 44 | import sys | ||
| 45 | |||
| 46 | db = sqlite3.connect('file:' + sys.argv[1] + '?mode=ro&immutable=1', uri=True) | ||
| 47 | assert db.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' | ||
| 48 | PY | ||
| 49 | |||
| 50 | ssh -p "$target_port" "$target_host" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance" | ||
| 51 | snapshot="$dataset@before-source-index-import-$(date +%s)-$$" | ||
| 52 | ssh -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 | |||
| 54 | rsync -a -e "ssh -p $target_port" "$scratch/cache.sqlite" "$target_host:$root/cache.sqlite" | ||
| 55 | owner=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"clover-source-of-truth\"])'") | ||
| 56 | ssh -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" | ||
| 57 | drift=$(rsync -rnc --out-format='%n' -e "ssh -p $target_port" "$scratch/cache.sqlite" "$target_host:$root/cache.sqlite") | ||
| 58 | test -z "$drift" || { echo "Index copy differs from source backup; restore $snapshot" >&2; exit 1; } | ||
| 59 | |||
| 60 | python3 "$(dirname "$0")/deploy.py" stage clover-source-of-truth | ||
| 61 | ssh -p "$target_port" "$target_host" "python3 - <<'PY' | ||
| 62 | import sqlite3 | ||
| 63 | source = sqlite3.connect('file:$root/cache.sqlite?mode=ro', uri=True) | ||
| 64 | assert source.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' | ||
| 65 | for table in ('media_files', 'derived_files', 'derived_refs'): | ||
| 66 | print(table, source.execute('SELECT count(*) FROM ' + table).fetchone()[0]) | ||
| 67 | PY" | ||
| 68 | echo "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 | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | instance=${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 | |||
| 10 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} | ||
| 11 | target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1} | ||
| 12 | target_port=${STUDIO_DEPLOY_PORT:-2222} | ||
| 13 | remote=(ssh -p "$target_port" "$target_host") | ||
| 14 | source_copy_host=$source_host | ||
| 15 | source_rsh=ssh | ||
| 16 | offline=false | ||
| 17 | if [[ -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 | ||
| 25 | fi | ||
| 26 | if [[ $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 | ||
| 45 | else | ||
| 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; } | ||
| 49 | fi | ||
| 50 | dataset=$("${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 | |||
| 56 | scratch=$(mktemp -d) | ||
| 57 | trap 'rm -rf "$scratch"' EXIT | ||
| 58 | rsync -a -e "$source_rsh" "$source_copy_host:/mnt/storage1/apps/yt-feed/" "$scratch/" | ||
| 59 | python3 - "$scratch" <<'PY' | ||
| 60 | import json | ||
| 61 | from pathlib import Path | ||
| 62 | import sys | ||
| 63 | |||
| 64 | root = Path(sys.argv[1]) | ||
| 65 | files = list(root.glob("*.json")) | ||
| 66 | if not files: | ||
| 67 | raise ValueError("no YouTube Triage JSON state was copied") | ||
| 68 | for file in files: | ||
| 69 | json.loads(file.read_text()) | ||
| 70 | print(f"Copied {len(files)} valid JSON state files") | ||
| 71 | PY | ||
| 72 | drift=$(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 | |||
| 75 | if [[ $instance != yt-feed ]]; then | ||
| 76 | "${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance" | ||
| 77 | else | ||
| 78 | "${remote[@]}" 'systemctl stop studio-dashboard' | ||
| 79 | fi | ||
| 80 | for _ 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 | ||
| 85 | done | ||
| 86 | [[ $running == 0 ]] || { echo "$instance still has a running allocation" >&2; exit 1; } | ||
| 87 | |||
| 88 | snapshot="$dataset@before-yt-feed-import-$(date +%s)-$$" | ||
| 89 | "${remote[@]}" "zfs snapshot $snapshot" | ||
| 90 | rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/data/" | ||
| 91 | uid=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"yt-feed\"])'") | ||
| 92 | "${remote[@]}" "chown -R $uid:$uid $root/data" | ||
| 93 | drift=$(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 | |||
| 96 | if [[ $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" | ||
| 105 | else | ||
| 106 | python3 "$(dirname "$0")/deploy.py" stage yt-feed | ||
| 107 | "${remote[@]}" "zfs destroy $snapshot" | ||
| 108 | echo "Imported and tested $instance" | ||
| 109 | fi | ||
tools/intake-migration.md created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | # JSON Intake migration | ||
| 2 | |||
| 3 | Zenith'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 | |||
| 5 | Existing 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 | |||
| 7 | On 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 | |||
| 3 | An 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 | |||
| 5 | The 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 | |||
| 7 | A 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 | |||
| 9 | Production 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 | |||
| 11 | After 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 | |||
| 5 | At 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 | |||
| 7 | After 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 | |||
| 11 | The 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 | ||
| 2 | set -eu | ||
| 3 | |||
| 4 | cache=${STUDIO_VM_CACHE_DIR:-$HOME/Library/Caches/clover-studio-vm-x86} | ||
| 5 | mode=${1:-} | ||
| 6 | qemu=$(command -v qemu-system-x86_64 || true) | ||
| 7 | if [ -z "$qemu" ]; then | ||
| 8 | qemu=/opt/homebrew/bin/qemu-system-x86_64 | ||
| 9 | fi | ||
| 10 | test -x "$qemu" || { echo 'qemu-system-x86_64 is unavailable' >&2; exit 1; } | ||
| 11 | share=$(dirname "$(dirname "$qemu")")/share/qemu | ||
| 12 | qemu_img=$(dirname "$qemu")/qemu-img | ||
| 13 | mkdir -p "$cache" | ||
| 14 | |||
| 15 | if [ ! -f "$cache/disk.qcow2" ]; then | ||
| 16 | "$qemu_img" create -f qcow2 "$cache/disk.qcow2" 96G | ||
| 17 | fi | ||
| 18 | if [ ! -f "$cache/vars.fd" ]; then | ||
| 19 | cp "$share/edk2-i386-vars.fd" "$cache/vars.fd" | ||
| 20 | chmod u+w "$cache/vars.fd" | ||
| 21 | fi | ||
| 22 | |||
| 23 | set -- | ||
| 24 | if [ "$mode" != installed ] && [ -f "$cache/nixos-minimal.iso" ]; then | ||
| 25 | set -- -drive "file=$cache/nixos-minimal.iso,media=cdrom,readonly=on" | ||
| 26 | fi | ||
| 27 | if [ "$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" | ||
| 34 | fi | ||
| 35 | if [ "${STUDIO_VM_HEADLESS:-0}" = 1 ]; then | ||
| 36 | set -- "$@" -display none -serial "file:$cache/serial.log" -daemonize -pidfile "$cache/qemu.pid" | ||
| 37 | else | ||
| 38 | set -- "$@" -display cocoa -serial mon:stdio | ||
| 39 | fi | ||
| 40 | |||
| 41 | set -- "$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 | |||
| 52 | if command -v taskpolicy >/dev/null 2>&1; then | ||
| 53 | exec taskpolicy -c utility nice -n 10 "$@" | ||
| 54 | fi | ||
| 55 | exec nice -n 10 "$@" | ||
tools/log-shipper.py created+117| ... | @@ -0,0 +1,117 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import datetime | ||
| 3 | import json | ||
| 4 | import os | ||
| 5 | from pathlib import Path | ||
| 6 | import re | ||
| 7 | import subprocess | ||
| 8 | import time | ||
| 9 | from urllib.request import Request, urlopen | ||
| 10 | |||
| 11 | |||
| 12 | STATE = Path("/var/lib/studio/log-shipper.json") | ||
| 13 | LOGS = Path("/var/lib/nomad/alloc") | ||
| 14 | CRI = re.compile(r"^(\S+) (stdout|stderr) [FP] ?(.*)$") | ||
| 15 | |||
| 16 | |||
| 17 | def 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 | |||
| 23 | def 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 | |||
| 34 | def 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 | |||
| 105 | def 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 | |||
| 116 | if __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 | |||
| 4 | import argparse | ||
| 5 | import asyncio | ||
| 6 | import base64 | ||
| 7 | import hashlib | ||
| 8 | import json | ||
| 9 | import os | ||
| 10 | from pathlib import Path | ||
| 11 | import signal | ||
| 12 | import socket | ||
| 13 | import ssl | ||
| 14 | import subprocess | ||
| 15 | import sys | ||
| 16 | import time | ||
| 17 | |||
| 18 | STATE = Path("/var/db/snowglobe-test") | ||
| 19 | RESOLVER = Path("/etc/resolver/studio.test") | ||
| 20 | HOSTS = Path("/etc/hosts") | ||
| 21 | KEYCHAIN = "/Library/Keychains/System.keychain" | ||
| 22 | FORWARDS = ((80, 27080), (443, 27443)) | ||
| 23 | |||
| 24 | |||
| 25 | def 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 | |||
| 47 | class 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 | |||
| 81 | async 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 | |||
| 116 | def 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 | |||
| 150 | def 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 | |||
| 223 | if __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 | |||
| 3 | Zenith 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 | |||
| 5 | The 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 | |||
| 7 | Zenith's NFSv4 ACLs need a separate [permission cutover](storage-acl-cutover.md) before this dataset is served by NixOS. | ||
| 8 | |||
| 9 | The 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 | |||
| 11 | At 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 | ||
| 14 | zfs snapshot storage1/media@before-studio-cutover | ||
| 15 | zfs set mountpoint=/mnt/storage1/apps storage1/apps | ||
| 16 | zfs set mountpoint=/srv storage1 | ||
| 17 | zfs set mountpoint=/srv/clover storage1/clover | ||
| 18 | zfs rename storage1/media storage1/clover/Media | ||
| 19 | zfs set mountpoint=/srv/clover/Media storage1/clover/Media | ||
| 20 | test "$(findmnt -n -o SOURCE --mountpoint /mnt/storage1/apps)" = storage1/apps | ||
| 21 | test "$(findmnt -n -o SOURCE --mountpoint /srv)" = storage1 | ||
| 22 | test "$(findmnt -n -o SOURCE --mountpoint /srv/clover)" = storage1/clover | ||
| 23 | test "$(findmnt -n -o SOURCE --mountpoint /srv/clover/Media)" = storage1/clover/Media | ||
| 24 | ``` | ||
| 25 | |||
| 26 | The 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 | |||
| 28 | The 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 | |||
| 30 | Zenith'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 | ||
| 33 | test -d /srv/clover/Media/torrent | ||
| 34 | test ! -e /srv/clover/Media/seedbox | ||
| 35 | mv /srv/clover/Media/torrent /srv/clover/Media/seedbox | ||
| 36 | ln -s seedbox /srv/clover/Media/torrent | ||
| 37 | test "$(stat -c %d:%i /srv/clover/Media/seedbox)" = "$(stat -Lc %d:%i /srv/clover/Media/torrent)" | ||
| 38 | ``` | ||
| 39 | |||
| 40 | The 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 | ||
| 43 | test -d /srv/clover/Media/music_intake | ||
| 44 | test ! -e /srv/clover/Media/music-intake | ||
| 45 | mv /srv/clover/Media/music_intake /srv/clover/Media/music-intake | ||
| 46 | ln -s music-intake /srv/clover/Media/music_intake | ||
| 47 | ``` | ||
| 48 | |||
| 49 | YouTube 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 | |||
| 51 | The 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 | |||
| 53 | A 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 | |||
| 55 | The 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 | |||
| 57 | The 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 | |||
| 59 | The 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 | |||
| 61 | At 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 | |||
| 63 | After 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 | |||
| 65 | The 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 | |||
| 69 | With `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 | |||
| 71 | Sonarr 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 | |||
| 75 | For 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 | |||
| 3 | The 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 | |||
| 5 | For 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 | |||
| 7 | For 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 | ||
| 2 | import argparse | ||
| 3 | from datetime import datetime, timezone | ||
| 4 | import fcntl | ||
| 5 | import hashlib | ||
| 6 | import json | ||
| 7 | import os | ||
| 8 | from pathlib import Path | ||
| 9 | import re | ||
| 10 | import subprocess | ||
| 11 | import time | ||
| 12 | |||
| 13 | |||
| 14 | ROOT = Path("/opt/studio") | ||
| 15 | RELEASES = ROOT / "releases" | ||
| 16 | STATE = Path("/var/lib/studio") | ||
| 17 | HISTORY = STATE / "deployments.json" | ||
| 18 | SOURCES = ("config", "service", "tools", "nixos", "dashboard", "flake.nix", "flake.lock", "readme.md") | ||
| 19 | RELEASE_ID = re.compile(r"[0-9a-f]{16}\Z") | ||
| 20 | STAGE_ID = re.compile(r"[a-z][a-z0-9-]*\Z") | ||
| 21 | |||
| 22 | |||
| 23 | def 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 | |||
| 45 | def _hash_contents(digest, path): | ||
| 46 | with path.open("rb") as source: | ||
| 47 | while chunk := source.read(65536): | ||
| 48 | digest.update(chunk) | ||
| 49 | |||
| 50 | |||
| 51 | def 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 | |||
| 59 | def 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 | |||
| 70 | def 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 | |||
| 87 | def 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 | |||
| 97 | def 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 | |||
| 112 | def history(): | ||
| 113 | return json.loads(HISTORY.read_text()) if HISTORY.exists() else [] | ||
| 114 | |||
| 115 | |||
| 116 | def 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 | |||
| 122 | def 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 | |||
| 133 | def 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 | |||
| 201 | def 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 | |||
| 273 | if __name__ == "__main__": | ||
| 274 | main() | ||
tools/router.py created+323| ... | @@ -0,0 +1,323 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import json | ||
| 3 | import grp | ||
| 4 | import os | ||
| 5 | import re | ||
| 6 | import subprocess | ||
| 7 | import sys | ||
| 8 | import time | ||
| 9 | import urllib.parse | ||
| 10 | import urllib.request | ||
| 11 | |||
| 12 | ROUTES = "/var/lib/caddy/routes.caddy" | ||
| 13 | TOKEN = "/var/lib/studio/router.token" | ||
| 14 | ROUTE_DIR = "/var/lib/studio/routes" | ||
| 15 | HOST = re.compile(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\Z") | ||
| 16 | |||
| 17 | |||
| 18 | def 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 | |||
| 27 | def 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 | |||
| 37 | def 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 | |||
| 48 | def 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 | |||
| 287 | def 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 | |||
| 311 | def 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 | |||
| 322 | if __name__ == "__main__": | ||
| 323 | main() | ||
tools/service-dependencies.md created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | # Service connection checks | ||
| 2 | |||
| 3 | On 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 | |||
| 5 | Redis 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 | |||
| 7 | After 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 | |||
| 9 | Evil.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 | |||
| 11 | Release `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 | |||
| 13 | After 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 | |||
| 15 | Keycloak 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 | |||
| 17 | Shale 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 | |||
| 19 | Open 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 | |||
| 21 | For 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 | |||
| 23 | Writable 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 | |||
| 25 | The 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 | |||
| 27 | An 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 | |||
| 29 | Promotion 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 | |||
| 31 | Release `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 | |||
| 33 | Read-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 | |||
| 35 | Release `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 | |||
| 37 | The 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 | |||
| 3 | Zenith'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 | |||
| 5 | For 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 | |||
| 7 | After 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 | |||
| 3 | The existing preview can take a consistent copy of Zenith's live SQLite index without stopping the old service: | ||
| 4 | |||
| 5 | ```sh | ||
| 6 | sh tools/import-source-index.sh clover-source-of-truth-preview-6311ec97 | ||
| 7 | ``` | ||
| 8 | |||
| 9 | The 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 | |||
| 11 | On 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 | |||
| 13 | The `clo` account used by the stream importer had zero unreadable files and zero untraversable directories in the source tree at inspection. | ||
| 14 | |||
| 15 | For 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 | |||
| 17 | If 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 | |||
| 19 | Run `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 | |||
| 3 | Zenith 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 | |||
| 9 | The 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 | |||
| 11 | A 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 | |||
| 13 | A 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 | |||
| 17 | A 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 | |||
| 19 | An 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 | |||
| 21 | The 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 | |||
| 23 | With 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 | |||
| 25 | Zenith'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 | |||
| 27 | The 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 | |||
| 29 | Copyparty'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 | |||
| 31 | Snow 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 | ||
| 2 | import argparse | ||
| 3 | from contextlib import nullcontext | ||
| 4 | import fcntl | ||
| 5 | import hashlib | ||
| 6 | import json | ||
| 7 | import os | ||
| 8 | from pathlib import Path | ||
| 9 | import re | ||
| 10 | import secrets | ||
| 11 | import shutil | ||
| 12 | import subprocess | ||
| 13 | import sys | ||
| 14 | import tempfile | ||
| 15 | import time | ||
| 16 | |||
| 17 | from data import cloned_postgres, dataset_for | ||
| 18 | |||
| 19 | |||
| 20 | REPO = Path(__file__).resolve().parent.parent | ||
| 21 | SERVICES = REPO / "service" | ||
| 22 | STATE = Path("/var/lib/studio") | ||
| 23 | IDENTITIES = STATE / "identities.json" | ||
| 24 | ASSETS = Path("/var/lib/caddy/studio") | ||
| 25 | NAME = re.compile(r"[a-z][a-z0-9-]*\Z") | ||
| 26 | |||
| 27 | |||
| 28 | def command(*args, capture=False, **kwargs): | ||
| 29 | return subprocess.run(args, check=True, text=True, capture_output=capture, **kwargs) | ||
| 30 | |||
| 31 | |||
| 32 | def 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 | |||
| 42 | def services(): | ||
| 43 | return sorted(service_files()) | ||
| 44 | |||
| 45 | |||
| 46 | def service_dir(name): | ||
| 47 | path = service_files()[name] | ||
| 48 | return path.parent if path.name == "service.pkl" else path.parent / name | ||
| 49 | |||
| 50 | |||
| 51 | def dependencies(data): | ||
| 52 | return set(data["dependsOn"]) | {item["provider"] for item in data["inputs"].values()} | ||
| 53 | |||
| 54 | |||
| 55 | def 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 | |||
| 72 | def 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 | |||
| 91 | def 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 | |||
| 144 | def q(value): | ||
| 145 | return json.dumps(value, ensure_ascii=False) | ||
| 146 | |||
| 147 | |||
| 148 | def 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 | |||
| 160 | def 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 | |||
| 169 | def 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 | |||
| 177 | def 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 | |||
| 197 | def 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 | |||
| 454 | def token(): | ||
| 455 | if "NOMAD_TOKEN" not in os.environ: | ||
| 456 | os.environ["NOMAD_TOKEN"] = (STATE / "nomad.token").read_text().strip() | ||
| 457 | |||
| 458 | |||
| 459 | def 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 | |||
| 468 | def 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 | |||
| 585 | def 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 | |||
| 597 | def 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 | |||
| 605 | def 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 | |||
| 610 | def 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 | |||
| 625 | def 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 | |||
| 635 | def 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 | |||
| 660 | def 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 | |||
| 668 | def 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 | |||
| 705 | def 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 | |||
| 770 | def 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 | |||
| 813 | def 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 | |||
| 856 | def 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 | |||
| 872 | def 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 | |||
| 891 | def 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 | |||
| 1236 | if __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 | ||
| 2 | import hashlib | ||
| 3 | import json | ||
| 4 | import os | ||
| 5 | from pathlib import Path | ||
| 6 | import stat | ||
| 7 | import sys | ||
| 8 | |||
| 9 | |||
| 10 | root = Path(sys.argv[1]).resolve(strict=True) | ||
| 11 | if not root.is_dir(): | ||
| 12 | raise ValueError("Expected a directory") | ||
| 13 | digest = hashlib.sha256() | ||
| 14 | files = 0 | ||
| 15 | size = 0 | ||
| 16 | |||
| 17 | |||
| 18 | def fail(error): | ||
| 19 | raise error | ||
| 20 | |||
| 21 | |||
| 22 | for 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) | ||
| 48 | print(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 | ||
| 2 | import hashlib | ||
| 3 | import json | ||
| 4 | from pathlib import Path | ||
| 5 | import re | ||
| 6 | import sys | ||
| 7 | |||
| 8 | |||
| 9 | folder = Path(sys.argv[1]) | ||
| 10 | if not folder.is_dir(): | ||
| 11 | raise SystemExit("Legacy handoff directory is unavailable") | ||
| 12 | folder = folder.resolve(strict=True) | ||
| 13 | database = sys.argv[2] | ||
| 14 | if 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") | ||
| 16 | if database not in {"evil-forgejo", "evil-hedgedoc"}: | ||
| 17 | raise ValueError("Unexpected legacy database") | ||
| 18 | manifest = json.loads((folder / "manifest.json").read_text()) | ||
| 19 | if manifest["id"] != folder.name: | ||
| 20 | raise ValueError("Legacy handoff ID changed") | ||
| 21 | entry = manifest["databases"][database] | ||
| 22 | if entry["file"] != database + ".dump": | ||
| 23 | raise ValueError("Legacy dump filename changed") | ||
| 24 | dump = folder / entry["file"] | ||
| 25 | digest = hashlib.sha256() | ||
| 26 | with dump.open("rb") as file: | ||
| 27 | for chunk in iter(lambda: file.read(1024 * 1024), b""): | ||
| 28 | digest.update(chunk) | ||
| 29 | if dump.stat().st_size != entry["bytes"] or digest.hexdigest() != entry["sha256"]: | ||
| 30 | raise ValueError("Legacy dump checksum mismatch") | ||
| 31 | if 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") | ||
| 33 | if 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") | ||
| 37 | print(json.dumps(entry)) | ||
tools/vms.py created+366| ... | @@ -0,0 +1,366 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import json | ||
| 3 | import os | ||
| 4 | from pathlib import Path | ||
| 5 | import re | ||
| 6 | import shutil | ||
| 7 | import signal | ||
| 8 | import stat | ||
| 9 | import subprocess | ||
| 10 | import sys | ||
| 11 | import xml.etree.ElementTree as ET | ||
| 12 | |||
| 13 | |||
| 14 | DISKS = Path("/srv/vm") | ||
| 15 | IMAGES = Path(os.environ.get("STUDIO_VM_IMAGES_ROOT", "/srv/clover/Media/vm")) | ||
| 16 | NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,62}\Z") | ||
| 17 | ACTION_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 | |||
| 25 | def 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 | |||
| 30 | def 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 | |||
| 62 | def command(*args): | ||
| 63 | return subprocess.run(args, check=True, text=True, capture_output=True).stdout.strip() | ||
| 64 | |||
| 65 | |||
| 66 | def virsh(*args): | ||
| 67 | return command("virsh", "-c", "qemu:///system", *args) | ||
| 68 | |||
| 69 | |||
| 70 | def info(file): | ||
| 71 | return json.loads(command("qemu-img", "info", "-U", "--output=json", str(file))) | ||
| 72 | |||
| 73 | |||
| 74 | def 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 | |||
| 79 | def 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 | |||
| 90 | def 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 | |||
| 113 | def 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 | |||
| 179 | def ensure_name(name): | ||
| 180 | if not isinstance(name, str) or not NAME.fullmatch(name): | ||
| 181 | raise ValueError("invalid VM name") | ||
| 182 | |||
| 183 | |||
| 184 | def 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 | |||
| 190 | def 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 | |||
| 298 | def 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 | |||
| 358 | if __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 | |||
| 3 | The 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 | |||
| 5 | On 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 | |||
| 7 | For 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 | |||
| 9 | For 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 | |||
| 3 | Read-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 | |||
| 9 | On 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 | |||
| 11 | TrueNAS 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 | |||
| 13 | The `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 | |||
| 15 | The 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 | |||
| 19 | A `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 | |||
| 21 | The 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 | |||
| 23 | With 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 | |||
| 25 | The 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. | ||