diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..f3f9da3ec390b5c0d3e2a6da055b34500a26a08c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +result +secrets/ +config/.identities.lock +config/identities.pending diff --git a/config/Service.pkl b/config/Service.pkl new file mode 100644 index 0000000000000000000000000000000000000000..51e54fb872d6804b9a9e8b5b8e93d6f0cefcaaad --- /dev/null +++ b/config/Service.pkl @@ -0,0 +1,184 @@ +open module Service + +import "site.pkl" as site + +const nomadHostPort = "{{ if regexMatch \":\" .Address }}[{{ .Address }}]{{ else }}{{ .Address }}{{ end }}:{{ .Port }}" + +/// Name shown in the management UI and service listings. +class Metadata { + name: String + tagline: String = "" +} + +/// One HTTP listener and its public route. +class Http { + containerPort: UInt16 + hostPort: UInt16? + subdomain: String? + authRole: String? + /// Backend header set from the authenticated OIDC preferred username. + userHeader: String? + /// Identity headers copied from a valid SSO session; anonymous requests continue. + identityHeaders: Mapping = new {} + /// Set X-Real-Ip from Caddy's observed client address before proxying. + forwardRealIp: Boolean = false + /// Generated secret whose value becomes the private proxy header name. + identityProofSecret: String? + /// Additional hosts routed to this container without SSO headers. + plainHostnames: Listing = new {} + hostname: String? = read?("prop:hostname") ?? if (subdomain != null) "\(subdomain).\(site.domain)" else null + tlsInternal: Boolean = site.tlsInternal + checkPath: String = "/" + /// Internal Prometheus endpoint collected by the home server dashboard. + metricsPath: String? + checkHeaders: Mapping = new {} + /// Request path to a file or directory in this service's folder. + overrideFiles: Mapping = new {} + /// HTML inserted before for the listed page paths. + headHtml: Mapping = new {} +} + +/// One TCP listener published through Nomad. +class Tcp { + name: String + containerPort: UInt16 + hostPort: UInt16? + loopback: Boolean = true +} + +/// A container mount, keyed by its absolute path inside the container. +class Volume { + /// Host source; absent means the same path beneath this service's data root. + src: String? + /// File or directory relative to this service's assets folder; copied into a read-only mount. + config: String? + readOnly: Boolean = config != null +} + +/// A persistent value generated when the service is first provisioned. +class GeneratedSecret { + bytes: Int(isBetween(16, 128)) = 32 +} + +/// Provider-owned resource request; the alias names its allocated secret. +open class Requirement { + alias: String + fixed provider: String + fixed kind: String +} + +/// One Podman task. A service can contain one or several of these. +class Container { + /// Use either an image or a build directory in this service's release folder. + image: String? + build: String? + entrypoint: String? + http: Http? + tcp: Tcp? + volumes: Mapping = new {} + /// Podman tmpfs mounts for data that must not persist in service storage. + tmpfs: Listing = new {} + /// `${secret.own.key}` and `${secret.alias.key}` resolve from Nomad variables. + env: Mapping = new {} + /// Nomad template for environment values resolved after allocation. + envTemplate: String? + args: Listing = new {} + capAdd: Listing = new {} + devices: Listing = new {} + extraHosts: Listing = new {} + hostNetwork: Boolean = false + imageUser: Boolean = false + /// Prestart tasks finish first; prestart sidecars stay up for the main tasks. + lifecycle: "main"|"prestart"|"prestartSidecar" = "main" + rootGroup: Boolean = false + cpu: Int = 200 + memory: Int = 512 +} + +/// Stable internal ID supplied from the service definition name by the caller. +id: String = read?("prop:serviceId") +/// Stable numeric owner assigned by the deployment tool. +uid: Int = read("prop:uid").toInt() +meta: Metadata +/// Allows site-specific services to stay out of deployments where they would cause side effects. +enabled: Boolean = true +/// Simple replaces one allocation; overlapped runs a canary alongside it. +rollout: String = "simple" +/// Fresh previews create empty storage and new secrets instead of forking production. +stageIsolation: "clone"|"fresh" = "clone" +/// Time allowed for a new allocation to pass its service checks. +healthyDeadline: String? +/// Delay before persistent health-check failures may restart a running allocation. +healthRestartGrace: String? +/// Repeatable service configuration run after a healthy deployment. +setup: String? +/// Service-owned data preparation before its container starts. +prepare: String? +/// Handler for input requests owned by this service. +provide: String? + +/// Use this for one container; it becomes the "app" task in the output. +container: Container? + +/// Use this instead of `container` when the service has named tasks. +containers: Mapping? + +secrets: Mapping = new {} +/// Secret names supplied from outside the release, in import-file line order. +requiredSecrets: Listing = new {} +requirements: Listing = new {} +/// Service startup dependency with no resource to allocate. +dependsOn: Listing = new {} +/// Resource service.name emitted by this app's trace exporter. +traceServiceName: String? +/// Metrics sent by the app over OTLP instead of a Prometheus HTTP endpoint. +metricsPushed: Boolean = false + +local deploymentContainers: Mapping = + if (!enabled) + new Mapping {} + else if (container != null && containers != null) + throw("Declare either container or containers, not both") + else if (container != null) + new Mapping { ["app"] = container!! } + else if (containers != null && !containers!!.isEmpty) + containers!! + else if (!requirements.isEmpty) + new Mapping {} + else + throw("Declare a container or an input") + +local vmStartupGrace: String? = if (read?("env:STUDIO_VM_ACCEL") == "qemu") "60m" else null + +output { + renderer = new JsonRenderer {} + value = new { + id = module.id + name = module.meta.name + tagline = module.meta.tagline + rollout = module.rollout + stageIsolation = module.stageIsolation + healthyDeadline = vmStartupGrace ?? module.healthyDeadline + healthRestartGrace = vmStartupGrace ?? module.healthRestartGrace + hostRoot = "\(site.root)/prod/\(module.id)" + stagingRoot = "\(site.root)/staging" + pool = site.pool + cloverRoot = site.cloverRoot + cloverGid = site.cloverGid + mediaRoot = site.mediaRoot + ownerEmail = site.ownerEmail + containers = deploymentContainers + setup = module.setup + prepare = module.prepare + provide = module.provide + // External mounts do not require this service's dataset. + hasManagedVolumes = deploymentContainers.toMap().values.any((task) -> + task.volumes.toMap().values.any((volume) -> volume.src == null && volume.config == null)) + secrets = module.secrets + requiredSecrets = module.requiredSecrets + requirements = module.requirements + dependsOn = module.dependsOn + traceServiceName = module.traceServiceName + metricsPushed = module.metricsPushed + } +} diff --git a/config/identities.json b/config/identities.json new file mode 100644 index 0000000000000000000000000000000000000000..b48a81647e3526d58cdaf8727a13451037b93d4f --- /dev/null +++ b/config/identities.json @@ -0,0 +1,10 @@ +{ + "shale": 3101, + "samba": 3102, + "postgres": 3103, + "keycloak": 1000, + "tailscale": 0, + "dawarich": 3100, + "forward-auth": 3105, + "jellyfin": 3106 +} diff --git a/config/policies/dashboard.hcl b/config/policies/dashboard.hcl new file mode 100644 index 0000000000000000000000000000000000000000..6bddf9f13f6447a9365a3b5d212342e74f7df787 --- /dev/null +++ b/config/policies/dashboard.hcl @@ -0,0 +1,7 @@ +namespace "default" { + capabilities = ["list-jobs", "read-job", "read-logs"] +} + +node { + policy = "read" +} diff --git a/config/policies/router.hcl b/config/policies/router.hcl new file mode 100644 index 0000000000000000000000000000000000000000..592d246c89415b05ceb8ab6ac66df17f6f443a2f --- /dev/null +++ b/config/policies/router.hcl @@ -0,0 +1,3 @@ +namespace "default" { + policy = "read" +} diff --git a/config/site.pkl b/config/site.pkl new file mode 100644 index 0000000000000000000000000000000000000000..06e59911b56e8d14f6125cde6800dca52bc79353 --- /dev/null +++ b/config/site.pkl @@ -0,0 +1,17 @@ +module Site + +/// The path to the root ZFS store +root: String = read?("prop:root") ?? read?("env:STUDIO_ROOT") ?? "/srv" +pool: String = read?("prop:pool") ?? read?("env:STUDIO_POOL") ?? "studio-demo" +nodeName: String = read?("prop:nodeName") ?? read?("env:STUDIO_NODE_NAME") ?? "clover-demo" +/// The base domain that the infrastructure runs on +domain: String = read?("prop:domain") ?? read?("env:STUDIO_DOMAIN") ?? "studio.test" +ownerEmail: String = read?("prop:ownerEmail") ?? read?("env:STUDIO_OWNER_EMAIL") ?? "account@paperclover.net" +cloverRoot: String = read?("prop:cloverRoot") ?? read?("env:STUDIO_CLOVER_ROOT") ?? "\(root)/clover" +cloverUid: Int = (read?("prop:cloverUid") ?? read?("env:STUDIO_CLOVER_UID") ?? "3000").toInt() +cloverGid: Int = (read?("prop:cloverGid") ?? read?("env:STUDIO_CLOVER_GID") ?? "3000").toInt() +cloverReadOnly: Boolean = (read?("prop:cloverReadOnly") ?? read?("env:STUDIO_CLOVER_READ_ONLY") ?? "false") == "true" +mediaRoot: String = read?("prop:mediaRoot") ?? read?("env:STUDIO_MEDIA_ROOT") ?? "\(cloverRoot)/Media" +mediaReadOnly: Boolean = (read?("prop:mediaReadOnly") ?? read?("env:STUDIO_MEDIA_READ_ONLY") ?? "false") == "true" +tlsInternal: Boolean = (read?("prop:tlsInternal") ?? read?("env:STUDIO_TLS_INTERNAL") ?? "false") == "true" || domain.endsWith(".test") +preview: Boolean = (read?("prop:preview") ?? "false") == "true" diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c9b60c56f8344e5229a9ef30ed024babbcbbc819 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.cache/ +data/ +target/ diff --git a/dashboard/Cargo.lock b/dashboard/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..5625afbec5c4d972b7fdcffa373943742a7398bc --- /dev/null +++ b/dashboard/Cargo.lock @@ -0,0 +1,2544 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.6", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ego-tree" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "home-dashboard" +version = "0.1.0" +dependencies = [ + "axum", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "globset", + "rand 0.9.5", + "regex", + "reqwest", + "rmcp", + "rusqlite", + "scraper", + "serde_json", + "sha1", + "sha2", + "subtle", + "tokio", + "tokio-stream", + "tower-http", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap", + "pastey", + "pin-project-lite", + "rand 0.10.3", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.6", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" +dependencies = [ + "cssparser", + "ego-tree", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..430eb082959dd61a6413c1a308de3475e1d363da --- /dev/null +++ b/dashboard/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "home-dashboard" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = { version = "0.8.9", features = ["multipart", "ws"] } +base64 = "0.22" +bytes = "1" +chrono = "0.4" +futures = "0.3" +globset = "0.4" +rand = "0.9" +regex = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] } +rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] } +rusqlite = { version = "0.37", features = ["bundled"] } +scraper = { version = "0.27", default-features = false } +serde_json = "1" +sha1 = "0.10" +sha2 = "0.10.9" +subtle = "2.6" +tokio = { version = "1", features = ["full"] } +tokio-stream = { version = "0.1", features = ["sync"] } +tower-http = { version = "0.6", features = ["fs"] } +url = "2" +uuid = { version = "1", features = ["v4"] } +walkdir = "2" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true diff --git a/dashboard/dev.ts b/dashboard/dev.ts new file mode 100644 index 0000000000000000000000000000000000000000..745ef565255688fc3efc45d1876f2d2ef55f4122 --- /dev/null +++ b/dashboard/dev.ts @@ -0,0 +1,37 @@ +import { execFile, spawn } from "node:child_process"; +import { watch } from "node:fs"; +import { promisify } from "node:util"; + +const host = process.env.STUDIO_DEPLOY_HOST ?? "root@127.0.0.1"; +const ssh = ["-p", process.env.STUDIO_DEPLOY_PORT ?? "2222", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"]; +const dir = "/root/studio-dashboard-dev"; +const run = promisify(execFile); +const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'"; +const restart = `import os, shlex, subprocess +variables = dict(item.split("=", 1) for item in shlex.split(subprocess.check_output(["systemctl", "show", "-P", "Environment", "studio-dashboard"], text=True))) +for key in ["STUDIO_INDEX_POOL", "STUDIO_YT_STATE", "STUDIO_FILES_WRITABLE"]: variables.pop(key, None) +variables.update(PORT="7070", STUDIO_METRICS="follow", STUDIO_DATA_DIR="${dir}/data") +subprocess.run(["systemctl", "stop", "studio-dashboard-dev"], check=False) +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)`; +let pending = false; +let rebuilding = false; +async function rebuild() { + pending = true; + if (rebuilding) return; + rebuilding = true; + try { + while (pending) { + pending = false; + await run("rsync", ["-a", "--delete", ...[".jj", ".git", "node_modules", "target", "dist", ".cache", "data", "result"].map(name => "--exclude=" + name), "-e", ["ssh", ...ssh].join(" "), "../", `${host}:${dir}/`]); + 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 }); + console.log("Rust dashboard ready"); + } + } finally { rebuilding = false; } +} +await rebuild(); +watch("src", { recursive: true }, () => rebuild().catch(console.error)); +const tunnel = spawn("ssh", [...ssh, "-N", "-o", "ExitOnForwardFailure=yes", "-L", "7070:127.0.0.1:7070", host], { stdio: "inherit" }); +tunnel.on("exit", code => process.exit(code ?? 1)); +for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => { + run("ssh", [...ssh, host, "systemctl stop studio-dashboard-dev"]).finally(() => tunnel.kill()); +}); diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f68270bc15b2d0f0979450e128c60186b7d8540c --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,25 @@ +{ + "name": "home-dashboard", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently -k -n server,web \"node dev.ts\" \"vite\"", + "build": "vite build", + "check": "tsc --noEmit", + "start": "cargo run --release" + }, + "dependencies": { + "hono": "^4.13.9" + }, + "devDependencies": { + "@solidjs/router": "^1.0.0", + "@types/node": "^26.6.2", + "concurrently": "^10.0.5", + "lucide-solid": "^1.48.0", + "solid-js": "^1.9.15", + "typescript": "^7.0.2", + "uplot": "^1.6.32", + "vite": "^8.3.1", + "vite-plugin-solid": "^2.11.14" + } +} diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9f20c2d77993dc2e60ced953a345506606446a3e --- /dev/null +++ b/dashboard/pnpm-lock.yaml @@ -0,0 +1,1654 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + hono: + specifier: ^4.13.9 + version: 4.13.9 + devDependencies: + '@solidjs/router': + specifier: ^1.0.0 + version: 1.0.0(solid-js@1.9.15) + '@types/node': + specifier: ^26.6.2 + version: 26.6.2 + concurrently: + specifier: ^10.0.5 + version: 10.0.5 + lucide-solid: + specifier: ^1.48.0 + version: 1.48.0(solid-js@1.9.15) + solid-js: + specifier: ^1.9.15 + version: 1.9.15 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + uplot: + specifier: ^1.6.32 + version: 1.6.32 + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15) + vite-plugin-solid: + specifier: ^2.11.14 + 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)) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + + '@rolldown/binding-android-arm-eabi@1.2.11': + resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.11': + resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.11': + resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.11': + resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.11': + resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.11': + resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.11': + resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.11': + resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.11': + resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.11': + resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@solidjs/router@1.0.0': + resolution: {integrity: sha512-cCSk1hvgCowiMa9bzzYWHiLu1U4E22+DfJe6/rOwAyECKrxc3jrd5QnoW3sDDJtW+e077cz/M67bPl3DqOBw1Q==} + peerDependencies: + solid-js: ^1.8.6 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/node@26.6.2': + resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + babel-plugin-jsx-dom-expressions@0.40.10: + resolution: {integrity: sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-preset-solid@1.9.15: + resolution: {integrity: sha512-GBmg1OiPb+OwcH51XbDAKPtvrPfQW7rCJTJxcp8+yhtWwN+kqnbEJk2SgVybd+uhTxTKAvjaFyiQSr/eUZBwzg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.15 + peerDependenciesMeta: + solid-js: + optional: true + + baseline-browser-mapping@2.11.26: + resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.29.1: + resolution: {integrity: sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001812: + resolution: {integrity: sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} + engines: {node: '>=22'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.439: + resolution: {integrity: sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + hono@4.13.9: + resolution: {integrity: sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==} + engines: {node: '>=16.9.0'} + + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-solid@1.48.0: + resolution: {integrity: sha512-tes5UEPXUpMYwk8MRgFS9EtV90miJ8P/19H8NR4hhaiVDBfZY39YG0zm7Ofxv3YVNNdKP95XckdYXXc0CtQaoQ==} + peerDependencies: + solid-js: ^1.4.7 + + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + engines: {node: '>=18'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.11: + resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + engines: {node: '>= 0.4'} + + solid-js@1.9.15: + resolution: {integrity: sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uplot@1.6.32: + resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==} + + vite-plugin-solid@2.11.14: + resolution: {integrity: sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0 + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.151.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.11': + optional: true + + '@rolldown/binding-android-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-x64@1.2.11': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.11': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.11': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.11': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.11': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.11': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@solidjs/router@1.0.0(solid-js@1.9.15)': + dependencies: + solid-js: 1.9.15 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/node@26.6.2': + dependencies: + undici-types: 8.9.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + ansi-regex@6.3.0: {} + + ansi-styles@6.2.3: {} + + babel-plugin-jsx-dom-expressions@0.40.10(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-preset-solid@1.9.15(@babel/core@7.29.7)(solid-js@1.9.15): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.10(@babel/core@7.29.7) + optionalDependencies: + solid-js: 1.9.15 + + baseline-browser-mapping@2.11.26: {} + + browserslist@4.29.1: + dependencies: + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001812 + electron-to-chromium: 1.5.439 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.29.1) + + caniuse-lite@1.0.30001812: {} + + chalk@5.6.2: {} + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + concurrently@10.0.5: + dependencies: + chalk: 5.6.2 + rxjs: 7.8.2 + shell-quote: 1.9.0 + supports-color: 10.2.2 + tree-kill: 1.2.2 + yargs: 18.0.0 + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.439: {} + + emoji-regex@10.6.0: {} + + entities@6.0.1: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + optional: true + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.7.0: {} + + hono@4.13.9: {} + + html-entities@2.3.3: {} + + is-what@4.1.16: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-solid@1.48.0(solid-js@1.9.15): + dependencies: + solid-js: 1.9.15 + + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + node-releases@2.0.57: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.11: + dependencies: + '@oxc-project/types': 0.151.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.11 + '@rolldown/binding-android-arm64': 1.2.11 + '@rolldown/binding-darwin-arm64': 1.2.11 + '@rolldown/binding-darwin-x64': 1.2.11 + '@rolldown/binding-freebsd-x64': 1.2.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.11 + '@rolldown/binding-linux-arm64-gnu': 1.2.11 + '@rolldown/binding-linux-arm64-musl': 1.2.11 + '@rolldown/binding-linux-ppc64-gnu': 1.2.11 + '@rolldown/binding-linux-s390x-gnu': 1.2.11 + '@rolldown/binding-linux-x64-gnu': 1.2.11 + '@rolldown/binding-linux-x64-musl': 1.2.11 + '@rolldown/binding-openharmony-arm64': 1.2.11 + '@rolldown/binding-win32-arm64-msvc': 1.2.11 + '@rolldown/binding-win32-x64-msvc': 1.2.11 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + semver@6.3.1: {} + + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + + shell-quote@1.9.0: {} + + solid-js@1.9.15: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + + solid-refresh@0.6.3(solid-js@1.9.15): + dependencies: + '@babel/generator': 7.29.8 + '@babel/helper-module-imports': 7.29.7 + '@babel/types': 7.29.8 + solid-js: 1.9.15 + transitivePeerDependencies: + - supports-color + + source-map-js@1.2.1: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + supports-color@10.2.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + optional: true + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.9.0: {} + + update-browserslist-db@1.3.3(browserslist@4.29.1): + dependencies: + browserslist: 4.29.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uplot@1.6.32: {} + + 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)): + dependencies: + '@babel/core': 7.29.7 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.15(@babel/core@7.29.7)(solid-js@1.9.15) + merge-anything: 5.1.7 + solid-js: 1.9.15 + solid-refresh: 0.6.3(solid-js@1.9.15) + vite: 8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15) + vitefu: 1.1.3(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15)) + transitivePeerDependencies: + - supports-color + + vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.11 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.2 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.15 + + vitefu@1.1.3(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15)): + optionalDependencies: + vite: 8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(tsx@4.23.15) + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 diff --git a/dashboard/server/icons/victoria-logs/icon.svg b/dashboard/server/icons/victoria-logs/icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..72ee8f21ccaf104a6b438f7f70ce097f045ef17a --- /dev/null +++ b/dashboard/server/icons/victoria-logs/icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/dashboard/server/youtube-worker.py b/dashboard/server/youtube-worker.py new file mode 100644 index 0000000000000000000000000000000000000000..99d677570633e7724351984b3b7e332499386d9c --- /dev/null +++ b/dashboard/server/youtube-worker.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +import sys +import json +import os +import queue +import re +import smtplib +import subprocess +import threading +import time +import urllib.request +import xml.etree.ElementTree as ET +from email.message import EmailMessage +from email.utils import formatdate +from html import unescape +from xml.sax.saxutils import escape + +import yaml + +os.umask(0o007) + +YT_CONFIG_DIR = os.environ.get("STUDIO_YT_CONFIG", "/srv/clover/Documents/Config/Youtube Downloader") +FEED_CONFIG = os.path.join(YT_CONFIG_DIR, "feed.yaml") +STATE_DIR = os.environ.get("STUDIO_YT_STATE", "/srv/prod/yt-feed/data") +INTERVAL = int(os.environ.get("CHECK_INTERVAL", "1800")) +SMTP_HOST = os.environ.get("SMTP_HOST", "") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "465")) +SMTP_USER = os.environ.get("SMTP_USER", "") +SMTP_PASS = os.environ.get("SMTP_PASS", "") +MAIL_FROM = os.environ.get("MAIL_FROM", f"yt-feed@{os.environ.get('STUDIO_DOMAIN', 'studio.test')}") +MAIL_TO = os.environ.get("MAIL_TO", os.environ.get("STUDIO_OWNER_EMAIL", "account@paperclover.net")) +BASE_URL = os.environ.get("BASE_URL", f"https://globe.{os.environ.get('STUDIO_DOMAIN', 'studio.test')}/youtube").rstrip("/") +READ_ONLY = os.environ.get("STUDIO_MEDIA_READ_ONLY") == "true" + +MEDIA_ROOT = os.environ.get("STUDIO_YT_MEDIA", "/srv/clover/Media") +INDIE_DIR = os.path.join(MEDIA_ROOT, "jellyfin/Indie Shows") +INDEP_DIR = os.path.join(MEDIA_ROOT, "jellyfin/Independent") +MUSIC_DIR = os.path.join(MEDIA_ROOT, "music_intake") +VIDEO_EXTS = (".webm", ".mp4", ".mkv") + +ATOM = "{http://www.w3.org/2005/Atom}" +YT = "{http://www.youtube.com/xml/schemas/2015}" +MEDIA = "{http://search.yahoo.com/mrss/}" +UA = {"User-Agent": "Mozilla/5.0 (The Snow Globe; +https://paperclover.net)"} +SEEN_CAP = 300 + +state_lock = threading.Lock() +job_queue = queue.Queue() +shows_cache = [] + +# RSS keeps working during a YouTube bot wall, so probing can resume downloads later. +WALL_RE = re.compile(r"confirm you.re not a bot", re.I) +WALL_PROBE_INTERVAL = int(os.environ.get("WALL_PROBE_INTERVAL", "10800")) +PROBE_VIDEO = "https://www.youtube.com/watch?v=jNQXAC9IVRw" + + +class WallError(Exception): + pass + + +def wall_active(): + return load_json("wall.json", {}).get("walled", False) + + +def set_wall(walled): + with state_lock: + save_json("wall.json", {"walled": walled, "since": int(time.time())}) + log(f"bot wall {'detected — downloads paused' if walled else 'lifted — downloads resumed'}") + + +def wall_prober(): + while True: + time.sleep(WALL_PROBE_INTERVAL if wall_active() else 600) + if not wall_active(): + continue + probe = subprocess.run( + ["yt-dlp", "--simulate", "--print", "%(id)s", PROBE_VIDEO], + capture_output=True, text=True, timeout=120) + if probe.returncode == 0: + set_wall(False) + resolve_stragglers() + else: + log("wall probe: still walled") + + +def resolve_stragglers(): + with state_lock: + pending = load_json("pending.json", {}) + for v in pending.values(): + if v.get("unresolved"): + threading.Thread(target=resolve_and_update, args=(v["id"], v["link"]), + daemon=True).start() + + +def log(msg): + print(msg, file=sys.stderr, flush=True) + + +def fetch(url): + req = urllib.request.Request(url, headers=UA) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read().decode("utf-8", errors="replace") + + +def state_path(name): + return os.path.join(STATE_DIR, name) + + +def load_json(name, fallback): + try: + with open(state_path(name)) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return fallback + + +def save_json(name, data): + tmp = state_path(name) + ".tmp" + with open(tmp, "w") as f: + json.dump(data, f, indent=1) + os.replace(tmp, state_path(name)) + + +def safe_name(name): + return re.sub(r'[/\\:*?"<>|]', "-", name).strip(" .") or "untitled" + + +def stable_key(v): + return v.get("stub_id") or v["id"] + + +def safe_listdir(p): + try: + return os.listdir(p) + except OSError: + return [] + + +def nfo_fields(file): + try: + with open(file) as stream: + text = stream.read() + except OSError: + return {name: "" for name in ("title", "plot")} + return {name: unescape(match.group(1)).strip() if (match := re.search( + rf"<{name}>(.*?)", text, re.S)) else "" for name in ("title", "plot")} + + +def library_entries(): + entries = [] + for show in sorted(safe_listdir(INDIE_DIR)): + root = os.path.join(INDIE_DIR, show) + if not os.path.isdir(root): + continue + for sub in sorted(safe_listdir(root)): + season = re.fullmatch(r"Season (\d+)", sub) + if not season: + continue + folder = os.path.join(root, sub) + for name in sorted(safe_listdir(folder)): + if not name.lower().endswith(VIDEO_EXTS): + continue + stem = os.path.splitext(name)[0] + episode = re.match(r"S\d+E(\d+) - (.*)", stem) + nfo = nfo_fields(os.path.join(folder, stem + ".nfo")) + entries.append({ + "type": "indie", "path": os.path.relpath(os.path.join(folder, name), MEDIA_ROOT), + "context": f"{show} · S{int(season.group(1))}E{int(episode.group(1)) if episode else 1}", + "title": nfo["title"] or (episode.group(2) if episode else stem), + "link": nfo["plot"] if nfo["plot"].startswith(("http://", "https://")) else "", + "season": int(season.group(1)), "episode": int(episode.group(1)) if episode else 1, + }) + for channel in sorted(safe_listdir(INDEP_DIR)): + folder = os.path.join(INDEP_DIR, channel) + if not os.path.isdir(folder): + continue + for name in sorted(safe_listdir(folder)): + if not name.lower().endswith(VIDEO_EXTS): + continue + stem = os.path.splitext(name)[0] + dated = re.match(r"(\d{4}-\d{2}-\d{2}) - (.*)", stem) + nfo = nfo_fields(os.path.join(folder, stem + ".nfo")) + entries.append({ + "type": "independent", "path": os.path.relpath(os.path.join(folder, name), MEDIA_ROOT), + "context": f"{channel} · {dated.group(1) if dated else ''}", + "title": nfo["title"] or (dated.group(2) if dated else stem), + "link": nfo["plot"] if nfo["plot"].startswith(("http://", "https://")) else "", + "season": None, "episode": None, + }) + return entries + + +def rename_entry(args): + full = os.path.realpath(os.path.join(MEDIA_ROOT, args["path"])) + indie = os.path.commonpath((full, INDIE_DIR)) == INDIE_DIR + independent = os.path.commonpath((full, INDEP_DIR)) == INDEP_DIR + if not (indie or independent) or not os.path.isfile(full): + raise ValueError("Video is outside the managed YouTube libraries") + title = args["title"].strip() + if not title: + raise ValueError("Video title is empty") + folder, name = os.path.split(full) + stem, ext = os.path.splitext(name) + if ext.lower() not in VIDEO_EXTS: + raise ValueError("Video format is unsupported") + if indie: + season, episode = args["season"], args["episode"] + if not isinstance(season, int) or not isinstance(episode, int) or min(season, episode) < 1: + raise ValueError("Season and episode must be positive numbers") + new_stem = f"S{season:02d}E{episode:02d} - {safe_name(title)}" + updates = {"title": title, "season": season, "episode": episode} + else: + date = re.match(r"(\d{4}-\d{2}-\d{2}) - ", stem) + new_stem = (date.group(1) + " - " if date else "") + safe_name(title) + updates = {"title": title} + sidecars = [name for name in safe_listdir(folder) if name.startswith(stem) + and (name[len(stem):].startswith(".") or name[len(stem):].startswith("-thumb"))] + moves = [(os.path.join(folder, name), os.path.join(folder, new_stem + name[len(stem):])) for name in sidecars] + if any(src != dst and os.path.exists(dst) for src, dst in moves): + raise FileExistsError("A file already has that title") + for src, dst in moves: + if src != dst: + os.rename(src, dst) + nfo_file = os.path.join(folder, new_stem + ".nfo") + try: + with open(nfo_file) as stream: + text = stream.read() + except FileNotFoundError: + return None + for key, value in updates.items(): + encoded = f"<{key}>{escape(str(value))}" + text = re.sub(rf"<{key}>.*?", lambda _: encoded, text, count=1, flags=re.S) if re.search( + rf"<{key}>.*?", text, re.S) else re.sub( + r"(\n\s*)\Z", lambda match: f"\n {encoded}{match.group(1)}", text) + with open(nfo_file, "w") as stream: + stream.write(text) + return None + + +def channel_id_for(url, cache): + if url in cache: + return cache[url] + m = re.search(r"/channel/(UC[0-9A-Za-z_-]{22})", url) + if not m: + html = fetch(url) + m = re.search(r"channel_id=(UC[0-9A-Za-z_-]{22})", html) or re.search( + r'"channelId":"(UC[0-9A-Za-z_-]{22})"', html + ) + if not m: + raise ValueError(f"could not resolve channel id for {url}") + cache[url] = m.group(1) + return cache[url] + + +def feed_entries(channel_id): + text = fetch(f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}") + entries = [] + for e in ET.fromstring(text).findall(ATOM + "entry"): + vid = e.find(YT + "videoId") + title = e.find(ATOM + "title") + link = e.find(ATOM + "link") + published = e.find(ATOM + "published") + thumb = e.find(f"{MEDIA}group/{MEDIA}thumbnail") + if vid is None or vid.text is None or title is None: + continue + entries.append({ + "id": vid.text, + "title": title.text or "(untitled)", + "link": link.get("href") if link is not None else f"https://youtu.be/{vid.text}", + "published": published.text[:10] if published is not None and published.text else "", + "thumb": thumb.get("url") if thumb is not None else "", + }) + return entries + + +def send_notification(channel, entry): + msg = EmailMessage() + slug = re.sub(r"[^a-z0-9]+", "-", channel.lower()).strip("-") + review = BASE_URL + msg["Subject"] = f"[yt] {channel}: {entry['title']}" + msg["From"] = MAIL_FROM + msg["To"] = MAIL_TO + msg["Date"] = formatdate(localtime=True) + msg["Message-ID"] = f"" + msg["References"] = f"" + msg["In-Reply-To"] = f"" + msg.set_content( + f"{channel} uploaded: {entry['title']}\n\n" + f" {entry['link']}\n published {entry['published']}\n\n" + f"review and ingest: {review}\n" + ) + h = escape(entry["title"]) + msg.add_alternative( + f'
' + f'

{escape(channel)} uploaded:

' + f'

' + f'

' + f'

{h} · {entry["published"]}

' + f'

review & ingest → · watch

' + f"
", + subtype="html", + ) + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as s: + s.login(SMTP_USER, SMTP_PASS) + s.send_message(msg) + + +def poll_once(): + with open(FEED_CONFIG) as f: + channels = (yaml.safe_load(f) or {}).get("channels") or {} + with state_lock: + ids = load_json("channel-ids.json", {}) + seen = load_json("seen.json", {}) + pending = load_json("pending.json", {}) + for name, url in channels.items(): + try: + cid = channel_id_for(url, ids) + entries = feed_entries(cid) + except Exception as e: + log(f"{name}: fetch failed: {e}") + continue + if cid not in seen: + seen[cid] = [e["id"] for e in entries] + log(f"{name}: now tracking ({len(entries)} existing videos skipped)") + continue + known = set(seen[cid]) + for entry in reversed(entries): + if entry["id"] in known: + continue + entry["channel"] = name + pending[entry["id"]] = entry + seen[cid].append(entry["id"]) + known.add(entry["id"]) + log(f"{name}: queued {entry['title']!r}") + try: + send_notification(name, entry) + except Exception as e: + log(f"{name}: notification failed: {e}") + seen[cid] = seen[cid][-SEEN_CAP:] + with state_lock: + save_json("channel-ids.json", ids) + save_json("seen.json", seen) + save_json("pending.json", pending) + + +def poller(): + while True: + try: + poll_once() + except Exception as e: + log(f"poll failed: {e}") + time.sleep(INTERVAL) + + +def update_job(job_id, **fields): + with state_lock: + jobs = load_json("jobs.json", []) + for j in jobs: + if j["id"] == job_id: + j.update(fields) + save_json("jobs.json", jobs) + + +def resolve_url(url): + out = subprocess.run( + ["yt-dlp", "--no-playlist", "--print", + "%(id)s\t%(title)s\t%(channel)s\t%(upload_date>%Y-%m-%d)s\t%(thumbnail)s", url], + capture_output=True, text=True, timeout=90) + if out.returncode != 0: + if WALL_RE.search(out.stderr): + raise WallError(url) + raise ValueError(out.stderr[-300:]) + vid, title, channel, published, thumb = out.stdout.strip().split("\t") + return {"id": vid, "title": title, "channel": channel, "published": published, + "thumb": thumb, "link": f"https://www.youtube.com/watch?v={vid}"} + + +def resolve_and_update(stub_id, url): + try: + entry = resolve_url(url) + except WallError: + set_wall(True) + return + except Exception as e: + log(f"resolve failed for {url}: {e}") + return + with state_lock: + pending = load_json("pending.json", {}) + # if the stub is gone the user already ingested or skipped it + if stub_id in pending: + del pending[stub_id] + entry["stub_id"] = stub_id + pending[entry["id"]] = entry + save_json("pending.json", pending) + + +def write_nfo(filepath, root, tags): + base, _ = os.path.splitext(filepath) + body = "\n".join(f" <{k}>{escape(str(v))}" for k, v in tags.items() if v != "") + with open(base + ".nfo", "w") as f: + f.write(f"\n<{root}>\n{body}\n\n") + + +def run_job(job): + for delay in (0, 30, 90): + if delay: + update_job(job["id"], status="retrying", progress="") + time.sleep(delay) + try: + if run_job_once(job): + return + except WallError: + raise + except Exception as e: + log(f"job {job['id']} attempt failed: {e}") + update_job(job["id"], status="error", progress="") + + +def run_job_once(job): + if job.get("unresolved"): + update_job(job["id"], status="resolving") + meta = resolve_url(job["url"]) + job.update(title=meta["title"], channel=meta["channel"], + published=meta["published"], url=meta["link"], unresolved=False) + update_job(job["id"], title=meta["title"]) + dest, url = job["dest"], job["url"] + if dest == "indie": + outdir = os.path.join(INDIE_DIR, safe_name(job["show"]), f"Season {job['season']}") + prefix = f"S{job['season']:02d}E{job['episode']:02d}" + ep_name = safe_name(job.get("ep_title") or job["title"]) + out = f"{outdir}/{prefix} - {ep_name}.%(ext)s" + thumb_out = f"thumbnail:{outdir}/{prefix} - {ep_name}-thumb.%(ext)s" + elif dest == "independent": + outdir = os.path.join(INDEP_DIR, safe_name(job["channel"])) + out = f"{outdir}/%(upload_date>%Y-%m-%d)s - %(title)s.%(ext)s" + thumb_out = f"thumbnail:{outdir}/%(upload_date>%Y-%m-%d)s - %(title)s.%(ext)s" + else: + outdir = os.path.join(MUSIC_DIR, safe_name(job["channel"])) + out = f"{outdir}/%(title)s.%(ext)s" + thumb_out = None + os.makedirs(outdir, exist_ok=True) + cmd = ["yt-dlp", "--newline", "--no-playlist", "--embed-chapters", + "--sleep-requests", "0.75", + "--print", "after_move:filepath", "--no-simulate", "-o", out] + if dest == "music": + cmd += ["-x"] + else: + cmd += ["-f", "bv*+ba/b", "--write-thumbnail", "--convert-thumbnails", "jpg", + "-o", thumb_out] + cmd.append(url) + update_job(job["id"], status="downloading") + filepath = None + tail = [] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + for line in proc.stdout: + line = line.rstrip() + tail = (tail + [line])[-30:] + m = re.search(r"\[download\]\s+([\d.]+%)", line) + if m: + update_job(job["id"], progress=m.group(1)) + elif line.startswith("/"): + filepath = line + proc.wait() + if proc.returncode != 0 or (dest != "music" and not filepath): + if WALL_RE.search("\n".join(tail)): + raise WallError(url) + log(f"job {job['id']} attempt failed (exit {proc.returncode})") + return False + if dest == "indie": + write_nfo(filepath, "episodedetails", { + "title": job.get("ep_title") or job["title"], + "season": job["season"], "episode": job["episode"], + "aired": job.get("published", ""), "plot": url, + }) + elif dest == "independent": + write_nfo(filepath, "movie", { + "title": job["title"], "premiered": job.get("published", ""), "plot": url, + }) + update_job(job["id"], status="done", progress="") + log(f"job {job['id']} done: {filepath or job['title']}") + return True + + +def worker(): + while True: + job = job_queue.get() + if wall_active(): + update_job(job["id"], status="waiting", progress="") + while wall_active(): + time.sleep(30) + try: + run_job(job) + except WallError: + set_wall(True) + update_job(job["id"], status="queued", progress="") + job_queue.put(job) + except Exception as e: + update_job(job["id"], status="error") + log(f"job {job['id']} crashed: {e}") + + +def writable(): + if READ_ONLY: + raise PermissionError("Media is read-only on this host") + + +def subscriptions(): + try: + with open(os.path.join(YT_CONFIG_DIR, "subscriptions.yaml")) as f: + data = yaml.safe_load(f) or {} + except FileNotFoundError: + data = {} + preset = next((key for key in data if key != "__preset__"), "Jellyfin TV Show by Date | only-after | flat-videos") + sections = data.get(preset) or {} + section = next(iter(sections), "= Independent Creators") + channels = sections.get(section) or {} + result = [] + fields = ("download_after", "title_include_keywords", "title_exclude_keywords", + "description_include_keywords", "description_exclude_keywords") + for name, value in channels.items(): + if isinstance(value, str): + result.append({"name": name.lstrip("~"), "url": value, "rules": {}}) + elif isinstance(value, dict): + result.append({"name": name.lstrip("~"), "url": value.get("url", ""), + "rules": {key: value[key] for key in fields if key in value}}) + return data, preset, section, result + + +def command(name, args): + if name == "snapshot": + with state_lock: + pending = list(load_json("pending.json", {}).values()) + jobs = load_json("jobs.json", []) + wall = load_json("wall.json", {}) + upscale = load_json("upscale-status.json", {}) + enabled = load_json("upscale-enabled.json", {"enabled": True})["enabled"] + return { + "pending": [{"key": stable_key(v), "title": v["title"], "channel": v.get("channel", ""), + "published": v.get("published", ""), "duration": None, + "thumb": v.get("thumb"), "link": v["link"], "resolving": bool(v.get("unresolved"))} + for v in pending], + "shows": shows_cache, + "jobs": [{"id": j["id"], "title": j["title"], "channel": j.get("channel", ""), + "destination": j.get("dest_label", j.get("dest", "")), + "status": j["status"], "url": j["url"], + "folder": job_folder(j), "progress": progress(j.get("progress")), + "queuedAt": int(j["id"][1:]) / 1000} for j in jobs], + "wall": {"walled": bool(wall.get("walled")), + "nextProbe": wall.get("since", 0) + WALL_PROBE_INTERVAL if wall.get("walled") else None}, + "archive": {"started": None, "finished": None, "walled": False, "next": 0}, + "upscaler": {"enabled": enabled, "running": bool(upscale.get("running")), + "done": upscale.get("done", 0), "total": upscale.get("total", 0), + "current": upscale.get("current", ""), "errors": upscale.get("errors", 0)}, + } + if name == "channels": + try: + with open(FEED_CONFIG) as f: + notify = (yaml.safe_load(f) or {}).get("channels") or {} + except FileNotFoundError: + notify = {} + return {"notify": [{"name": key, "url": value} for key, value in notify.items()], + "archive": subscriptions()[3]} + if name == "configs": + files = [] + for filename in sorted(safe_listdir(YT_CONFIG_DIR)): + if filename.endswith((".yaml", ".yml")): + with open(os.path.join(YT_CONFIG_DIR, filename)) as f: + files.append({"name": filename, "body": f.read()}) + return files + if name == "saveConfig": + filename = args["name"] + if filename not in safe_listdir(YT_CONFIG_DIR) or not filename.endswith((".yaml", ".yml")): + raise ValueError("Unknown YouTube config file") + target = os.path.join(YT_CONFIG_DIR, filename) + with open(target) as f: + if f.read() != args["original"]: + raise ValueError("Config changed since it was opened. Reload before saving") + yaml.safe_load(args["body"]) + tmp = target + ".tmp" + with open(tmp, "w") as f: + f.write(args["body"]) + os.replace(tmp, target) + return None + writable() + if name == "rename": + return rename_entry(args) + if name == "add": + with state_lock: + pending = load_json("pending.json", {}) + for i, url in enumerate(args["urls"]): + key = f"u{int(time.time() * 1000)}{i}" + pending[key] = {"id": key, "title": url, "channel": "", "published": "", + "thumb": "", "link": url, "unresolved": True} + threading.Thread(target=resolve_and_update, args=(key, url), daemon=True).start() + save_json("pending.json", pending) + return None + if name in ("ingest", "skip"): + with state_lock: + pending = load_json("pending.json", {}) + key = next((key for key, v in pending.items() if stable_key(v) == args["key"]), None) + if key is None: + raise KeyError("Video is no longer in the queue") + v = pending.pop(key) + save_json("pending.json", pending) + if name == "skip": + return None + choice = args["choice"] + job = {"id": f"j{int(time.time() * 1000)}", "url": v["link"], "title": v["title"], + "channel": v.get("channel", "unknown"), "published": v.get("published", ""), + "unresolved": v.get("unresolved", False), "status": "queued", "progress": "", + "dest": choice["dest"]} + if choice["dest"] == "indie": + job.update(show=choice["show"], season=choice["season"], episode=choice["episode"], + ep_title=choice["title"], + dest_label=f"{choice['show']} S{choice['season']:02d}E{choice['episode']:02d}") + else: + job["dest_label"] = "Independent" if choice["dest"] == "independent" else "music_intake" + jobs = load_json("jobs.json", []) + jobs.insert(0, job) + save_json("jobs.json", jobs[:50]) + job_queue.put(job) + return None + if name == "retry": + with state_lock: + jobs = load_json("jobs.json", []) + job = next((j for j in jobs if j["id"] == args["id"]), None) + if not job: + raise KeyError("Download is no longer in history") + job.update(status="queued", progress="") + save_json("jobs.json", jobs) + job_queue.put(job) + return None + if name == "setUpscaler": + with state_lock: + save_json("upscale-enabled.json", {"enabled": args["enabled"]}) + return None + if name == "setChannels": + try: + with open(FEED_CONFIG) as f: + feed = yaml.safe_load(f) or {} + except FileNotFoundError: + feed = {} + feed["channels"] = {ch["name"]: ch["url"] for ch in args["notify"]} + data, preset, section, _ = subscriptions() + fields = ("download_after", "title_include_keywords", "title_exclude_keywords", + "description_include_keywords", "description_exclude_keywords") + archive = {} + for ch in args["archive"]: + rules = {key: value for key in fields if (value := ch["rules"].get(key))} + archive[("~" if rules else "") + ch["name"]] = {"url": ch["url"], **rules} if rules else ch["url"] + sections = data.get(preset) or {} + sections[section] = archive + data[preset] = sections + for filename, body in (("feed.yaml", feed), ("subscriptions.yaml", data)): + target = os.path.join(YT_CONFIG_DIR, filename) + tmp = target + ".tmp" + with open(tmp, "w") as f: + yaml.safe_dump(body, f, sort_keys=False, allow_unicode=True) + os.replace(tmp, target) + return None + raise ValueError(f"Unknown YouTube operation: {name}") + + +def job_folder(job): + if job["dest"] == "indie": + return os.path.join(INDIE_DIR, safe_name(job["show"]), f"Season {job['season']}") + if job["dest"] == "independent": + return os.path.join(INDEP_DIR, safe_name(job.get("channel", "unknown"))) + return os.path.join(MUSIC_DIR, safe_name(job.get("channel", "unknown"))) + + +def progress(value): + try: + return float(str(value).rstrip("%")) / 100 + except ValueError: + return None + + +def indie_shows(): + shows = {} + for name in sorted(safe_listdir(INDIE_DIR)): + path = os.path.join(INDIE_DIR, name) + if not os.path.isdir(path): + continue + seasons = {} + for sub in safe_listdir(path): + match = re.fullmatch(r"Season (\d+)", sub) + if match and os.path.isdir(os.path.join(path, sub)): + seasons[int(match.group(1))] = sum(f.lower().endswith(VIDEO_EXTS) + for f in safe_listdir(os.path.join(path, sub))) + shows[name] = seasons + return shows + + +def refresh_shows(): + global shows_cache + while True: + try: + shows = indie_shows() + shows_cache = [{"name": name, "seasons": [{"number": n, "episodes": count} + for n, count in seasons.items()]} for name, seasons in shows.items()] + except Exception as error: + log(f"show scan failed: {error}") + time.sleep(300) + + +if __name__ == "__main__": + if sys.argv[1:] == ["--library"]: + print(json.dumps(library_entries()), flush=True) + sys.exit(0) + threading.Thread(target=refresh_shows, daemon=True).start() + if not READ_ONLY: + with state_lock: + for job in reversed(load_json("jobs.json", [])): + if job.get("status") in ("queued", "resolving", "downloading", "retrying"): + job_queue.put(job) + for loop in (poller, worker, wall_prober): + threading.Thread(target=loop, daemon=True).start() + for line in sys.stdin: + try: + request = json.loads(line) + result = command(request["method"], request.get("args", {})) + response = {"id": request["id"], "result": result} + except Exception as error: + response = {"id": request.get("id") if "request" in locals() else None, + "error": str(error)} + print(json.dumps(response), flush=True) diff --git a/dashboard/src/apps.rs b/dashboard/src/apps.rs new file mode 100644 index 0000000000000000000000000000000000000000..5099862ab0b44d6189299b84b5f20d94005463d4 --- /dev/null +++ b/dashboard/src/apps.rs @@ -0,0 +1,438 @@ +use crate::*; + +pub fn file_link(path: &str, zip: bool) -> Value { + let root = env("STUDIO_STORE_ROOT", "/srv"); + let Some(relative) = std::path::Path::new(path).strip_prefix(&root).ok() else { + return Value::Null; + }; + let path = relative + .components() + .map(|c| encoded(&c.as_os_str().to_string_lossy())) + .collect::>() + .join("/"); + let Ok(files) = std::env::var("STUDIO_FILES_URL") else { + return Value::Null; + }; + json!(format!("{files}/{path}{}", if zip { "?zip" } else { "" })) +} +fn qbt_link(path: &str, zip: bool) -> Value { + file_link( + &if path == "/data/media" || path.starts_with("/data/media/") { + format!("/srv/clover/Media{}", &path[11..]) + } else { + path.into() + }, + zip, + ) +} +async fn qbt(app: Arc, route: &str, form: Option) -> Result { + let base = telemetry::endpoint(app.clone(), "qbittorrent") + .await + .map_err(|_| Error::new(503, "qBittorrent is unavailable. Check its service status."))?; + let mut request = if form.is_some() { + app.request(Method::POST, &format!("{base}/api/v2/{route}"))? + } else { + app.request(Method::GET, &format!("{base}/api/v2/{route}"))? + }; + if let Some(form) = form { + request = request.form(&form); + } + let response = request.send().await?; + if !response.status().is_success() { + return Err(Error::new( + 502, + format!( + "qBittorrent refused the request ({}). Retry from its page.", + response.status() + ), + )); + } + if response + .headers() + .get("content-type") + .and_then(|h| h.to_str().ok()) + .is_some_and(|h| h.contains("json")) + { + Ok(response.json().await?) + } else { + Ok(json!(response.text().await?)) + } +} +async fn seed_state(app: Arc) -> Result> { + let state = app.clone(); + app.cache + .get( + "seed-state".into(), + Duration::from_secs(5), + move || async move { + let (data, prefs) = tokio::try_join!( + qbt(state.clone(), "sync/maindata?rid=0", None), + qbt(state.clone(), "app/preferences", None) + )?; + let mut value = data["server_state"].clone(); + if let (Some(value), Some(prefs)) = (value.as_object_mut(), prefs.as_object()) { + value.extend(prefs.clone()); + } + let t = (now() / 5.0).floor() * 5.0; + let mut samples = state.seed_samples.lock().unwrap(); + if samples.last().is_none_or(|s| s["t"] != t) { + samples.push( + json!({"t":t,"down":value["dl_info_speed"],"up":value["up_info_speed"]}), + ); + } + samples.retain(|s| number(&s["t"]) >= t - 3600.0); + Ok(value) + }, + ) + .await +} +async fn vm_call(action: &str, payload: Option) -> Result { + let mut request = json!({"operation":format!("vm.{action}")}); + if let Some(payload) = payload { + request["payload"] = payload; + } + host::call(request).await +} + +fn vm_name(name: &str) -> Result<()> { + if regex::Regex::new(r"^[a-z0-9][a-z0-9-]{0,62}$") + .unwrap() + .is_match(name) + { + Ok(()) + } else { + Err(Error::new(400, "No VM has that name.")) + } +} +fn validate_vm(mut spec: Value) -> Result { + vm_name(string(&spec["name"]))?; + let description = spec["description"] + .as_str() + .ok_or_else(|| Error::new(400, "Enter a description."))? + .trim(); + if description.chars().count() > 200 { + return Err(Error::new( + 400, + "Keep the description under 200 characters.", + )); + } + spec["description"] = json!(description); + if string(&spec["image"]).is_empty() { + return Err(Error::new(400, "Pick an OS image.")); + } + for (key, min) in [ + ("vcpus", 1.0), + ("memory", 536870912.0), + ("disk", 1073741824.0), + ] { + let n = number(&spec[key]); + if !spec[key].is_number() || n.fract() != 0.0 || n < min { + return Err(Error::new(400, format!("Invalid {key}."))); + } + } + if !spec["autostart"].is_boolean() || !spec["start"].is_boolean() { + return Err(Error::new( + 400, + "Choose whether this VM starts automatically.", + )); + } + Ok(spec) +} +async fn paper(app: Arc, path: &str, body: Option) -> Result { + let jobs = core::scan(app.clone()).await?; + let host = jobs.value["clover-source-of-truth"]["job"]["Meta"]["studio_hostname"] + .as_str() + .ok_or_else(|| Error::new(502, "Paper Clover is unavailable."))?; + let key = host::call( + json!({"operation":"deploy.secret.get","service":"clover-source-of-truth","key":"key"}), + ) + .await?; + let key = key + .as_str() + .ok_or_else(|| Error::new(502, "Paper Clover is unavailable."))?; + let url = format!( + "https://{host}/{}", + if path == "scan" { + path.into() + } else { + format!("studio/{path}") + } + ); + let request = if let Some(body) = body { + app.http.post(&url).json(&body) + } else { + app.http.get(&url) + }; + let response = request + .header("Authorization", key) + .timeout(Duration::from_secs(10)) + .send() + .await?; + if path == "scan" { + if response.status() == 409 { + return Err(Error::new( + 409, + "File scans are off on this host. Turn on the Paper Clover indexer to use this action.", + )); + } + if response.status() != 202 { + return Err(Error::new( + 502, + "Paper Clover couldn't start the scan. Check its service logs.", + )); + } + return Ok(Value::Null); + } + if !response.status().is_success() { + return Err(Error::new( + 502, + format!("Paper Clover answered {}.", response.status()), + )); + } + Ok(response.json().await?) +} + +pub async fn route( + app: Arc, + method: &Method, + parts: &[&str], + query: &HashMap, + body: Value, +) -> Result { + let value = match parts { + ["seedbox"] if method == Method::GET => { + let (state, mut torrents) = tokio::try_join!( + seed_state(app.clone()), + qbt(app.clone(), "torrents/info", None) + )?; + for torrent in torrents.as_array_mut().unwrap() { + let root = torrent["root_path"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or(string(&torrent["save_path"])) + .to_owned(); + torrent["folder"] = qbt_link(&root, false); + torrent["zip"] = if string(&torrent["root_path"]).is_empty() { + Value::Null + } else { + qbt_link(string(&torrent["root_path"]), true) + }; + } + json!({"state":state.value,"downloads":qbt_link(string(&state.value["save_path"]),false),"torrents":torrents}) + } + ["seedbox", "history"] if method == Method::GET => { + seed_state(app.clone()).await?; + let samples = app.seed_samples.lock().unwrap(); + json!([("download","down"),("upload","up")].map(|(name,key)| json!({"name":name,"t":samples.iter().map(|s| s["t"].clone()).collect::>(),"v":samples.iter().map(|s| s[key].clone()).collect::>()}))) + } + ["seedbox", "torrents"] if method == Method::POST => { + let url = string(&body["url"]).trim(); + if !regex::Regex::new(r"(?i)^(magnet:\?|https?://)") + .unwrap() + .is_match(url) + { + return Err(Error::new( + 400, + "Paste a magnet link or a link to a .torrent file", + )); + } + qbt(app, "torrents/add", Some(json!({"urls":url}))).await?; + Value::Null + } + ["seedbox", "torrents", hash, tail @ ..] => { + if !regex::Regex::new(r"^[0-9a-f]{40}$").unwrap().is_match(hash) { + return Err(Error::new( + 400, + "Torrent hashes are 40 lowercase hex characters.", + )); + } + match tail { + ["files"] if method == Method::GET => { + let route = format!("torrents/files?{}", params(&[("hash", hash.to_string())])); + let (torrents, mut files) = tokio::try_join!( + qbt(app.clone(), "torrents/info", None), + qbt(app.clone(), &route, None) + )?; + let root = array(&torrents) + .iter() + .find(|t| t["hash"] == *hash) + .and_then(|t| t["save_path"].as_str()); + for file in files.as_array_mut().unwrap() { + file["link"] = root + .map(|root| { + qbt_link(&format!("{root}/{}", string(&file["name"])), false) + }) + .unwrap_or(Value::Null); + } + files + } + [action] + if method == Method::POST + && [ + "stop", + "start", + "topPrio", + "increasePrio", + "decreasePrio", + "bottomPrio", + ] + .contains(action) => + { + qbt( + app, + &format!("torrents/{action}"), + Some(json!({"hashes":hash})), + ) + .await?; + Value::Null + } + [] if method == Method::DELETE => { + let files = query.get("files").map(String::as_str).unwrap_or("0"); + if !["0", "1"].contains(&files) { + return Err(Error::new(400, "Invalid files option.")); + } + qbt(app,"torrents/delete",Some(json!({"hashes":hash,"deleteFiles":if files=="1" {"true"} else {"false"}}))).await?; + Value::Null + } + _ => return Err(Error::new(404, "Not Found")), + } + } + ["vms"] if method == Method::GET => { + let mut values = Vec::new(); + for (action, ttl) in [("node", 600), ("domains", 5), ("images", 60)] { + let value = app + .cache + .get( + format!("vms:{action}"), + Duration::from_secs(ttl), + move || async move { vm_call(action, None).await }, + ) + .await?; + values.push(value.value.clone()); + } + for domain in values[1].as_array_mut().unwrap() { + domain["usage"] = app + .vm_usage + .lock() + .unwrap() + .get(string(&domain["name"])) + .cloned() + .unwrap_or(Value::Null); + } + json!({"node":values[0],"domains":values[1],"images":values[2]}) + } + ["vms", "history"] if method == Method::GET => { + let range = telemetry::query_number(query, "range", 300.0, 60.0, 86400.0)?; + let mut query = query.clone(); + query.insert("range".into(), range.to_string()); + if let Some(name) = query.get("name").cloned() { + query.insert("service".into(), name); + } + let (cpu, memory) = tokio::try_join!( + telemetry::metrics(app.clone(), "vm.cpu", &query, None, None), + telemetry::metrics(app.clone(), "vm.memory", &query, None, None) + )?; + let mut domains = serde_json::Map::new(); + for (values, key) in [(&cpu.value, "cpu"), (&memory.value, "memory")] { + for series in array(values) { + let domain = domains + .entry(string(&series["name"]).to_owned()) + .or_insert_with(|| json!({"cpu":[],"memory":[]})); + domain[key] = series["v"].clone(); + } + } + json!({"t":cpu.value[0]["t"].as_array().or(memory.value[0]["t"].as_array()).cloned().unwrap_or_default(),"domains":domains}) + } + ["vms"] if method == Method::POST => { + vm_call("create", Some(validate_vm(body)?)).await?; + app.cache.invalidate("vms:domains"); + Value::Null + } + ["vms", name, tail @ ..] => { + vm_name(name)?; + match tail { + [] if method == Method::PATCH => { + let mut payload = json!({"name":name}); + if let Some(v) = body.get("autostart") { + if !v.is_boolean() { + return Err(Error::new(400, "Invalid autostart.")); + } + payload["autostart"] = v.clone(); + } + if let Some(v) = body.get("description") { + let v = v + .as_str() + .ok_or_else(|| Error::new(400, "Enter a description."))? + .trim(); + if v.chars().count() > 200 { + return Err(Error::new( + 400, + "Keep the description under 200 characters.", + )); + } + payload["description"] = json!(v); + } + vm_call("update", Some(payload)).await?; + } + [] if method == Method::DELETE => { + let disks = query.get("disks").map(String::as_str).unwrap_or("1"); + if !["0", "1"].contains(&disks) { + return Err(Error::new(400, "Invalid disks option.")); + } + vm_call("remove", Some(json!({"name":name,"disks":disks=="1"}))).await?; + } + [action] + if method == Method::POST + && ["start", "shutdown", "reboot", "destroy", "resume"] + .contains(action) => + { + vm_call("act", Some(json!({"name":name,"action":action}))).await?; + } + _ => return Err(Error::new(404, "Not Found")), + } + app.cache.invalidate("vms:domains"); + Value::Null + } + ["paper-clover"] if method == Method::GET => { + let mut value = paper(app, "stats", None).await?; + value["browse"] = std::env::var("STUDIO_PUBLISHED_ROOT") + .ok() + .map(|p| file_link(&p, false)) + .unwrap_or(Value::Null); + value + } + ["paper-clover", "activity"] if method == Method::GET => { + paper(app, "activity", None).await? + } + ["paper-clover", "scan"] if method == Method::POST => { + let path = &body["path"]; + if !path.is_null() + && (!path.is_string() + || !string(path).starts_with('/') + || string(path).split('/').any(|s| s == "..")) + { + return Err(Error::new( + 400, + "Paths can't contain \"..\". Write the full path, like /2026/friends.", + )); + } + paper( + app, + "scan", + Some(if path.is_null() { + json!({}) + } else { + json!({"path":path}) + }), + ) + .await?; + return Ok(StatusCode::ACCEPTED.into_response()); + } + _ => return Err(Error::new(404, "Not Found")), + }; + Ok(if value.is_null() { + StatusCode::NO_CONTENT.into_response() + } else { + Document::new(value).response() + }) +} diff --git a/dashboard/src/cache.rs b/dashboard/src/cache.rs new file mode 100644 index 0000000000000000000000000000000000000000..7fe428f7ae307c3d57b5b4e193b95e63e4d4570c --- /dev/null +++ b/dashboard/src/cache.rs @@ -0,0 +1,288 @@ +use crate::{Document, Error, Result}; +use std::{ + collections::HashMap, + future::Future, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tokio::sync::Mutex as AsyncMutex; + +#[derive(Default)] +pub struct Cache(Mutex>>); + +#[derive(Default)] +struct Entry { + state: Mutex, + loading: Arc>, +} + +struct Cached { + value: Option<(Instant, Arc)>, + failure: Option<(Instant, Error)>, + used: Instant, +} +impl Default for Cached { + fn default() -> Self { + Self { + value: None, + failure: None, + used: Instant::now(), + } + } +} + +impl Cache { + fn entry(&self, key: String) -> Result> { + let mut entries = self.0.lock().unwrap(); + if !entries.contains_key(&key) && entries.len() >= 256 { + let oldest = entries + .iter() + .filter(|(_, entry)| Arc::strong_count(entry) == 1) + .min_by_key(|(_, entry)| entry.state.lock().unwrap().used) + .map(|(key, _)| key.clone()); + if let Some(oldest) = oldest { + entries.remove(&oldest); + } else { + return Err(Error::new(503, "The dashboard is busy. Retry in a moment.")); + } + } + let entry = entries.entry(key).or_default().clone(); + entry.state.lock().unwrap().used = Instant::now(); + Ok(entry) + } + + pub async fn coalesce(&self, key: String, load: F) -> Result> + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + let started = Instant::now(); + let entry = self.entry(key)?; + let guard = entry.loading.clone().lock_owned().await; + { + let state = entry.state.lock().unwrap(); + if let Some((at, value)) = &state.value + && *at >= started + { + return Ok(value.clone()); + } + if let Some((at, error)) = &state.failure + && *at >= started + { + return Err(error.clone()); + } + } + tokio::spawn(async move { + let _guard = guard; + entry.store(load().await) + }) + .await? + } + + pub fn invalidate(&self, key: &str) { + self.0.lock().unwrap().remove(key); + } + + pub fn invalidate_prefix(&self, prefix: &str) { + self.0 + .lock() + .unwrap() + .retain(|key, _| !key.starts_with(prefix)); + } + + pub fn peek(&self, key: &str, ttl: Duration) -> Option> { + let entry = self.0.lock().unwrap().get(key).cloned()?; + let state = entry.state.lock().unwrap(); + state + .value + .as_ref() + .filter(|(at, _)| at.elapsed() <= ttl + Duration::from_secs(60)) + .map(|(_, value)| value.clone()) + } + + pub async fn get(&self, key: String, ttl: Duration, load: F) -> Result> + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + let entry = self.entry(key)?; + let stale = { + let state = entry.state.lock().unwrap(); + let value = state + .value + .as_ref() + .filter(|(at, _)| at.elapsed() <= ttl + Duration::from_secs(60)); + if let Some((at, value)) = value + && at.elapsed() < ttl + { + return Ok(value.clone()); + } + if let Some((at, error)) = &state.failure + && at.elapsed() < ttl + { + return value + .map(|(_, value)| value.clone()) + .ok_or_else(|| error.clone()); + } + value.map(|(_, value)| value.clone()) + }; + if let Some(stale) = stale { + if let Ok(guard) = entry.loading.clone().try_lock_owned() { + tokio::spawn(async move { + let _guard = guard; + let _ = entry.store(load().await); + }); + } + return Ok(stale); + } + let guard = entry.loading.clone().lock_owned().await; + { + let state = entry.state.lock().unwrap(); + if let Some((at, value)) = &state.value + && at.elapsed() < ttl + { + return Ok(value.clone()); + } + if let Some((at, error)) = &state.failure + && at.elapsed() < ttl + { + return Err(error.clone()); + } + } + tokio::spawn(async move { + let _guard = guard; + entry.store(load().await) + }) + .await? + } +} + +impl Entry { + fn store(&self, result: Result) -> Result> { + let mut state = self.state.lock().unwrap(); + match result { + Ok(value) => { + let document = Arc::new(Document::new(value)); + state.value = Some((Instant::now(), document.clone())); + state.failure = None; + Ok(document) + } + Err(error) => { + state.failure = Some((Instant::now(), error.clone())); + Err(error) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + #[tokio::test] + async fn coalesced_identity_reads_do_not_reuse_completed_or_failed_results() { + let cache = Arc::new(Cache::default()); + let calls = Arc::new(AtomicUsize::new(0)); + let mut readers = Vec::new(); + for _ in 0..100 { + let (cache, calls) = (cache.clone(), calls.clone()); + readers.push(tokio::spawn(async move { + cache + .coalesce("identity:owner".into(), move || async move { + calls.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(20)).await; + Ok(serde_json::json!({"enabled":true})) + }) + .await + .unwrap() + })); + } + for reader in readers { + assert_eq!(reader.await.unwrap().value["enabled"], true); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + let disabled = cache + .coalesce("identity:owner".into(), || async { + Ok(serde_json::json!({"enabled":false})) + }) + .await + .unwrap(); + assert_eq!(disabled.value["enabled"], false); + assert!( + cache + .coalesce("identity:owner".into(), || async { + Err(Error::new(502, "unavailable")) + }) + .await + .is_err() + ); + let recovered = cache + .coalesce("identity:owner".into(), || async { + Ok(serde_json::json!({"enabled":true})) + }) + .await + .unwrap(); + assert_eq!(recovered.value["enabled"], true); + } + #[tokio::test] + async fn disconnecting_reader_does_not_cancel_shared_load() { + let cache = Arc::new(Cache::default()); + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let (state, ready, finish) = (cache.clone(), started.clone(), release.clone()); + let first = tokio::spawn(async move { + state + .get( + "cancelled".into(), + Duration::from_secs(10), + move || async move { + ready.notify_one(); + finish.notified().await; + Ok(serde_json::json!({"ready":true})) + }, + ) + .await + }); + started.notified().await; + first.abort(); + release.notify_one(); + let value = cache + .get("cancelled".into(), Duration::from_secs(10), || async { + panic!("shared load was repeated"); + }) + .await + .unwrap(); + assert_eq!(value.value["ready"], true); + } + #[tokio::test] + async fn coalesces_readers_and_backs_off_failures() { + let cache = Arc::new(Cache::default()); + let calls = Arc::new(AtomicUsize::new(0)); + let mut readers = Vec::new(); + for _ in 0..100 { + let (cache, calls) = (cache.clone(), calls.clone()); + readers.push(tokio::spawn(async move { + cache + .get("same".into(), Duration::from_secs(10), move || async move { + calls.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(20)).await; + Err(Error::new(502, "unavailable")) + }) + .await + })); + } + for reader in readers { + assert!(reader.await.unwrap().is_err()); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + cache.invalidate("same"); + assert!( + cache + .get("same".into(), Duration::from_secs(10), || async { + Ok(serde_json::json!({"ok":true})) + }) + .await + .is_ok() + ); + } +} diff --git a/dashboard/src/core.rs b/dashboard/src/core.rs new file mode 100644 index 0000000000000000000000000000000000000000..638ca90811e9045a94cb3b9df718dfe5a668168f --- /dev/null +++ b/dashboard/src/core.rs @@ -0,0 +1,803 @@ +use crate::*; +use futures::{StreamExt, stream}; +use sha1::{Digest, Sha1}; + +pub async fn nomad(app: &App, path: &str) -> Result { + let value = nomad_raw(app, path).await?; + Ok(value + .map(|bytes| serde_json::from_slice(&bytes)) + .transpose()? + .unwrap_or(Value::Null)) +} +pub async fn nomad_raw(app: &App, path: &str) -> Result> { + let request = async { + let token = tokio::fs::read_to_string(env( + "STUDIO_NOMAD_TOKEN_FILE", + "/var/lib/studio/dashboard.token", + )) + .await + .map_err(|e| Error::new(501, format!("Nomad isn't connected: {e}")))?; + let _slot = app.nomad_slots.acquire().await?; + let base = match &app.internal { + Some((base, _)) => format!("{}nomad", base.as_str()), + None => env("NOMAD_ADDR", "http://127.0.0.1:4646"), + }; + let response = app + .request(Method::GET, &format!("{base}{path}"))? + .header("X-Nomad-Token", token.trim()) + .send() + .await + .map_err(|_| { + Error::new(502, "Service status is taking too long. Retry in a moment.") + })?; + if response.status() == 404 { + return Ok(None); + } + let status = response.status(); + let bytes = response.bytes().await?; + if !status.is_success() { + return Err(Error::new( + 502, + format!( + "Nomad refused {} ({status}): {}", + path.split('?').next().unwrap(), + String::from_utf8_lossy(&bytes).trim() + ), + )); + } + Ok(Some(bytes)) + }; + tokio::time::timeout(Duration::from_secs(15), request) + .await + .map_err(|_| Error::new(502, "Service status is taking too long. Retry in a moment."))? +} + +fn live_alloc(alloc: &Value) -> bool { + alloc["DesiredStatus"] == "run" + && matches!(string(&alloc["ClientStatus"]), "pending" | "running") +} +pub fn health(job: &Value, allocs: &[Value], checks: &[Value]) -> &'static str { + if job["Stop"] == true { + return "stopped"; + } + let running: Vec<_> = allocs.iter().filter(|a| live_alloc(a)).collect(); + if running.is_empty() { + return "down"; + } + if running + .iter() + .any(|a| a["JobVersion"] != running[0]["JobVersion"]) + { + return "deploying"; + } + if running.iter().any(|a| a["ClientStatus"] == "pending") { + return "starting"; + } + if running.iter().any(|a| { + a["TaskStates"] + .as_object() + .into_iter() + .flat_map(|s| s.values()) + .any(|s| s["State"] == "pending") + }) { + return "restarting"; + } + if checks.iter().any(|c| c["Status"] == "failure") + || running + .iter() + .any(|a| a["DeploymentStatus"]["Healthy"] == false) + { + return "degraded"; + } + "healthy" +} + +pub async fn scan(app: Arc) -> Result> { + let state = app.clone(); + app.cache + .get( + "nomad-scan".into(), + Duration::from_secs(10), + move || async move { + let (stubs, allocations, nodes) = tokio::try_join!( + nomad(&state, "/v1/jobs"), + nomad(&state, "/v1/allocations"), + nomad(&state, "/v1/nodes?resources=true") + )?; + let cpu = &nodes[0]["NodeResources"]["Cpu"]; + let mhz = number(&cpu["CpuShares"]) / number(&cpu["TotalCpuCores"]); + if !stubs.is_array() || !allocations.is_array() || !mhz.is_finite() || mhz <= 0.0 { + return Err(Error::new( + 502, + "Nomad answered without its jobs, allocations or node.", + )); + } + let jobs = stream::iter(array(&stubs).iter().cloned()) + .map(|stub| { + let state = state.clone(); + async move { + let id = string(&stub["ID"]).to_owned(); + let cached = state + .specs + .lock() + .unwrap() + .get(&id) + .filter(|job| job["JobModifyIndex"] == stub["JobModifyIndex"]) + .cloned(); + let job = match cached { + Some(job) => job, + None => nomad(&state, &format!("/v1/job/{}", encoded(&id))).await?, + }; + state.specs.lock().unwrap().insert(id.clone(), job.clone()); + Ok::<_, Error>((id, job)) + } + }) + .buffer_unordered(4) + .collect::>() + .await + .into_iter() + .collect::>>()?; + state + .specs + .lock() + .unwrap() + .retain(|id, _| jobs.contains_key(id)); + let mut allocs = array(&allocations).to_vec(); + allocs + .sort_by(|a, b| number(&b["CreateTime"]).total_cmp(&number(&a["CreateTime"]))); + let checked = stream::iter(allocs) + .map(|alloc| { + let state = state.clone(); + async move { + let checks = if live_alloc(&alloc) { + nomad( + &state, + &format!( + "/v1/client/allocation/{}/checks", + string(&alloc["ID"]) + ), + ) + .await? + .as_object() + .map(|v| v.values().cloned().collect::>()) + .unwrap_or_default() + } else { + Vec::new() + }; + let mut alloc = alloc; + alloc["home_checks"] = json!(checks); + Ok::<_, Error>(alloc) + } + }) + .buffered(4) + .collect::>() + .await + .into_iter() + .collect::>>()?; + let mut found = serde_json::Map::new(); + for (id, job) in jobs { + let mine: Vec<_> = checked.iter().filter(|a| a["JobID"] == id).collect(); + let allocs: Vec<_> = mine.iter().map(|a| (*a).clone()).collect(); + let checks: Vec<_> = mine + .iter() + .flat_map(|a| array(&a["home_checks"]).iter().cloned()) + .collect(); + let health = health(&job, &allocs, &checks); + found.insert( + id, + json!({"job":job,"allocs":allocs,"mhz":mhz,"health":health}), + ); + } + Ok(Value::Object(found)) + }, + ) + .await +} + +pub async fn managed() -> Result> { + Ok(host::call(json!({"operation":"deploy.managed"})) + .await? + .as_array() + .unwrap() + .iter() + .map(|id| string(id).to_owned()) + .collect()) +} +pub async fn read_json(file: &std::path::Path, missing: Value) -> Result { + match tokio::fs::read(file).await { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(missing), + Err(e) => Err(e.into()), + } +} +pub async fn write_json(file: &std::path::Path, value: &Value) -> Result<()> { + tokio::fs::create_dir_all(file.parent().unwrap()).await?; + let temporary = file.with_extension(format!("{}.tmp", rand::random::())); + tokio::fs::write(&temporary, serde_json::to_vec(value)?).await?; + tokio::fs::rename(&temporary, file).await?; + Ok(()) +} +pub async fn service_file(app: &App, id: &str) -> Result { + if !valid_id(id) { + return Err(Error::new(400, "Invalid service ID.")); + } + let direct = app.repo.join("service").join(id).join("service.pkl"); + if tokio::fs::try_exists(&direct).await? { + return Ok(direct); + } + let mut dirs = tokio::fs::read_dir(app.repo.join("service")).await?; + while let Some(dir) = dirs.next_entry().await? { + let file = dir.path().join(format!("{id}.pkl")); + if tokio::fs::try_exists(&file).await? { + return Ok(file); + } + } + Ok(direct) +} +pub fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.as_bytes()[0].is_ascii_lowercase() + && id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +async fn icons(app: Arc, id: String) -> Result> { + let state = app.clone(); + app.cache.get(format!("icon:{id}"),Duration::from_secs(30),move || async move { + let file = service_file(&state,&id).await?; + let dir = if file.file_name().unwrap() == "service.pkl" { file.parent().unwrap().to_path_buf() } else { file.parent().unwrap().join(&id) }; + let mut found = serde_json::Map::new(); + for file in ["icon.svg","icon-light.svg","icon-dark.svg"] { + let candidate = dir.join(file); + let fallback = PathBuf::from(env("STUDIO_ICON_DIR","server/icons")).join(&id).join(file); + let path = if tokio::fs::try_exists(&candidate).await? { candidate } else { fallback }; + if let Ok(bytes) = tokio::fs::read(&path).await { + found.insert(file.to_owned(),json!({"path":path,"hash":format!("{:x}",Sha1::digest(&bytes))[..12].to_owned()})); + } + } + Ok(Value::Object(found)) + }).await +} +async fn icon_of(app: Arc, id: &str) -> Value { + let Ok(found) = icons(app, id.into()).await else { + return Value::Null; + }; + let url = |mode| { + let file = if !found.value[mode].is_null() { + mode + } else { + "icon.svg" + }; + found.value[file]["hash"] + .as_str() + .map(|hash| format!("/api/icons/{id}/{file}?v={hash}")) + }; + match (url("icon-light.svg"), url("icon-dark.svg")) { + (Some(light), Some(dark)) => json!({"light":light,"dark":dark}), + _ => Value::Null, + } +} +pub async fn icon( + app: &Arc, + parts: &[&str], + query: &HashMap, +) -> Result { + if parts.len() != 3 + || !valid_id(parts[1]) + || !["icon.svg", "icon-light.svg", "icon-dark.svg"].contains(&parts[2]) + { + return Err(Error::new(404, "Not Found")); + } + let found = icons(app.clone(), parts[1].into()).await?; + let path = found.value[parts[2]]["path"] + .as_str() + .ok_or_else(|| Error::new(404, "Not Found"))?; + Ok(( + [ + ("content-type", "image/svg+xml"), + ( + "cache-control", + if query.contains_key("v") { + "public, max-age=31536000, immutable" + } else { + "no-cache" + }, + ), + ], + tokio::fs::read(path).await?, + ) + .into_response()) +} + +async fn summarize(app: Arc, id: &str, found: &Value) -> Value { + let meta = &found["job"]["Meta"]; + let tasks: Vec<_> = array(&found["job"]["TaskGroups"]) + .iter() + .flat_map(|g| array(&g["Tasks"])) + .collect(); + let usage = app + .usage + .lock() + .unwrap() + .get(id) + .cloned() + .unwrap_or(Value::Null); + let mhz = number(&found["mhz"]); + let cpu: f64 = tasks.iter().map(|t| number(&t["Resources"]["CPU"])).sum(); + let memory: f64 = tasks + .iter() + .map(|t| { + let max = number(&t["Resources"]["MemoryMaxMB"]); + if max > 0.0 { + max + } else { + number(&t["Resources"]["MemoryMB"]) + } + }) + .sum(); + 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}) +} +async fn summaries(app: Arc) -> Result> { + let state = app.clone(); + app.cache + .get( + "services".into(), + Duration::from_secs(2), + move || async move { + let (ids, jobs) = tokio::try_join!(managed(), scan(state.clone()))?; + let values = stream::iter(ids) + .map(|id| { + let state = state.clone(); + let jobs = jobs.clone(); + async move { summarize(state, &id, &jobs.value[&id]).await } + }) + .buffered(4) + .collect::>() + .await; + Ok(json!(values)) + }, + ) + .await +} + +pub fn seconds(value: &Value) -> Option { + chrono::DateTime::parse_from_rfc3339(value.as_str()?) + .ok() + .map(|t| t.timestamp() as f64 + t.timestamp_subsec_nanos() as f64 / 1e9) + .filter(|s| *s > 0.0) +} +pub fn last_restart(task: &Value) -> Value { + let Some(t) = seconds(&task["LastRestart"]) else { + return Value::Null; + }; + let events = array(&task["Events"]); + let end = events + .iter() + .rposition(|e| e["Type"] == "Restarting") + .map(|i| i + 1) + .unwrap_or(0); + let reason = events[..end] + .iter() + .rev() + .find(|e| { + ["Terminated", "Restart Signaled", "Driver Failure", "Killed"] + .contains(&string(&e["Type"])) + }) + .map(|e| string(&e["DisplayMessage"])) + .filter(|s| !s.is_empty()) + .unwrap_or("restarted"); + json!({"t":t,"reason":reason}) +} +pub fn checks_of(checks: &Value) -> Value { + json!(array(checks).iter().map(|c| json!({"name":c["Check"],"passing":c["Status"] != "failure","output":c["Output"]})).collect::>()) +} +fn containers(found: &Value) -> Value { + let allocs = array(&found["allocs"]); + let alloc = allocs + .iter() + .find(|a| live_alloc(a)) + .or_else(|| allocs.first()) + .unwrap_or(&Value::Null); + let tasks: HashMap<_, _> = array(&found["job"]["TaskGroups"]) + .iter() + .flat_map(|g| array(&g["Tasks"])) + .map(|t| (string(&t["Name"]), t)) + .collect(); + json!(alloc["TaskStates"].as_object().into_iter().flat_map(|s| s.iter()).map(|(name,state)| { + let spec = tasks.get(name.as_str()).copied().unwrap_or(&Value::Null); + 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"])}) + }).collect::>()) +} +async fn issues(app: Arc) -> Result { + let summaries = app + .cache + .peek("services", Duration::from_secs(2)) + .ok_or_else(|| Error::new(503, "Service status is unavailable."))?; + let jobs = app + .cache + .peek("nomad-scan", Duration::from_secs(10)) + .ok_or_else(|| Error::new(503, "Service status is unavailable."))?; + let ack = read_json(&app.data.join("restarts-acknowledged.json"), json!({})).await?; + let mut issues = Vec::new(); + for service in array(&summaries.value) { + let id = string(&service["id"]); + let health = string(&service["health"]); + let found = &jobs.value[id]; + let cs = containers(found); + let restart = array(&cs) + .iter() + .map(|c| &c["lastRestart"]) + .filter(|r| !r.is_null() && (ack[id].is_null() || number(&r["t"]) > number(&ack[id]))) + .max_by(|a, b| number(&a["t"]).total_cmp(&number(&b["t"]))); + let trouble = health == "down" || health == "degraded"; + if trouble || restart.is_some() { + 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})); + } + } + Ok(json!(issues)) +} + +async fn launcher(app: Arc) -> Result> { + let real = tokio::fs::canonicalize(&app.repo).await?; + let state = app.clone(); + app.cache.get(format!("launcher:{}",real.display()),Duration::from_secs(365*86400),move || async move { + let module = format!(r#"import* "file://{}/service/*/*.pkl" as services +output {{ renderer = new JsonRenderer {{ omitNullProperties = false }} +value = services.toMap().filter((_, s) -> s.enabled).mapValues((_, s) -> +let (route = (s.containers?.toMap()?.values ?? List()).add(s.container).filterNonNull().map((task) -> task.http).filterNonNull().findOrNull((http) -> http.hostname != null)) +new Dynamic {{ name = s.meta.name; tagline = s.meta.tagline; hostname = route?.hostname; access = route?.authRole }}) }}"#,real.display()); + let value: Value = serde_json::from_slice(&command("pkl",&["eval","-"],Some(module.as_bytes())).await?)?; + let mut apps = Vec::new(); + for (file, value) in value.as_object().into_iter().flat_map(|v| v.iter()) { + if let Some(host) = value["hostname"].as_str() { + let path = std::path::Path::new(file); + let id = if path.file_name().unwrap() == "service.pkl" { path.parent().unwrap().file_name().unwrap() } else { path.file_stem().unwrap() }.to_string_lossy(); + 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})); + } + } + Ok(json!(apps)) + }).await +} + +async fn service(app: Arc, id: &str) -> Result> { + let state = app.clone(); + let id = id.to_owned(); + app.cache.get(format!("service:{id}"), Duration::from_secs(2), move || async move { + let app = state; + let id = id.as_str(); + let ids = managed().await?; + let jobs = scan(app.clone()).await?; + let found = &jobs.value[id]; + if !ids.iter().any(|s| s == id) || found.is_null() { + return Err(Error::new( + 404, + format!("No service is named {id}. Pick one from the sidebar."), + )); + } + let all = summaries(app.clone()).await?; + let link = |other: &str, kind: &Value| { + array(&all.value) + .iter() + .find(|s| s["id"] == other) + .map(|s| json!({"id":other,"name":s["name"],"kind":kind,"health":s["health"]})) + }; + let needs = |other: &str| { + serde_json::from_str::(string(&jobs.value[other]["job"]["Meta"]["studio_requires"])) + .ok() + }; + let requirements = needs(id).map(|n| { + n.as_object() + .into_iter() + .flat_map(|v| v.iter()) + .filter_map(|(id, kind)| link(id, kind)) + .collect::>() + }); + let dependents = if ids.iter().all(|id| needs(id).is_some()) { + Some( + ids.iter() + .filter_map(|other| { + needs(other).and_then(|n| n.get(id).and_then(|kind| link(other, kind))) + }) + .collect::>(), + ) + } else { + None + }; + let mut summary = summarize(app.clone(), id, found).await; + let meta = &found["job"]["Meta"]; + let application_traces = if let Some(name) = meta["studio_trace_service"] + .as_str() + .filter(|s| !s.is_empty()) + { + telemetry::has_traces(app.clone(), name) + .await + .unwrap_or(false) + } else { + false + }; + let metrics = + if meta["studio_metrics_path"].as_str().is_some_and(|p| !p.is_empty()) || meta["studio_metrics_pushed"] == "true" { + telemetry::metric_names(app.clone(), id) + .await + .unwrap_or(json!([])) + } else { + json!([]) + }; + let release = host::call(json!({"operation":"deploy.current"})).await?; + let secrets = serde_json::from_str::(string(&meta["studio_secrets"])) + .ok() + .map(|v| { + array(&v) + .iter() + .map(|s| json!({"name":s["name"],"generated":s["generated"]})) + .collect::>() + }); + let ack = read_json(&app.data.join("restarts-acknowledged.json"), json!({})).await?; + let datasets = app.cache.get("zfs-datasets".into(),Duration::from_secs(30),move || async move { + let rows = host::call(json!({"operation":"storage.datasets"})).await?; + Ok(json!(array(&rows).iter().map(|d| json!({"name":d["name"],"mountpoint":d["mountpoint"],"used":d["usedbydataset"],"snapshots":d["usedbysnapshots"]})).collect::>())) + }).await?; + 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}\"}}")))})); + 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::>())),"requirements":requirements,"dependents":dependents,"secrets":secrets,"datasets":array(&datasets.value).iter().filter(|d| string(&d["name"]).ends_with(&format!("/prod/{id}"))).collect::>(),"restartsAcknowledged":ack[id],"logs":logs}); + summary + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + Ok(summary) + }).await +} + +async fn change(app: Arc, request: Value) -> Result<()> { + let run = host::call(request).await?; + app.cache.invalidate("deploys"); + let outcome = tokio::time::timeout(Duration::from_secs(120), async { + loop { + let status = host::call(json!({"operation":"deploy.run","id":run["id"]})).await?; + if let Some(code) = status["code"].as_i64() { + return if code == 0 { + Ok(()) + } else { + Err(Error::new( + 502, + "The change failed. Check its run in deploys before retrying.", + )) + }; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }) + .await + .map_err(|_| { + Error::new( + 504, + "The change is still running. Check its run in deploys before retrying.", + ) + }); + app.cache.invalidate("deploys"); + app.cache.invalidate("nomad-scan"); + app.cache.invalidate("services"); + app.cache + .invalidate(&format!("service:{}", string(&run["target"]))); + outcome??; + Ok(()) +} + +pub async fn route( + app: Arc, + method: &Method, + parts: &[&str], + query: &HashMap, + me: &Value, + body: Value, +) -> Result { + let empty = || StatusCode::NO_CONTENT.into_response(); + let value = match parts { + ["me"] if method == Method::GET => me.clone(), + ["host"] if method == Method::GET => { + let sample = host::sample(app).await?; + json!({"cores":sample.value["cores"],"memory":sample.value["memory"]["total"],"bootedAt":sample.value["bootedAt"],"outages":null}) + } + ["services"] if method == Method::GET => return Ok(summaries(app).await?.response()), + ["launcher"] if method == Method::GET => { + let found = launcher(app.clone()).await?; + let jobs = app.cache.peek("nomad-scan", Duration::from_secs(10)); + let groups: Vec<_> = array(&me["groups"]).iter().map(string).collect(); + let mut apps = Vec::new(); + for value in array(&found.value) + .iter() + .filter(|v| can_open(&groups, v["access"].as_str())) + { + let mut value = value.clone(); + value["health"] = jobs + .as_ref() + .map(|j| j.value[string(&value["id"])]["health"].clone()) + .unwrap_or(Value::Null); + value.as_object_mut().unwrap().remove("access"); + apps.push(value); + } + json!(apps) + } + ["status"] if method == Method::GET => { + let issues = issues(app.clone()).await.ok(); + let apps = launcher(app.clone()).await?; + let groups: Vec<_> = array(&me["groups"]).iter().map(string).collect(); + let admin = array(&me["sections"]).iter().any(|s| s == "admin"); + let issues = issues.map(|v| { + array(&v) + .iter() + .filter(|i| { + admin + || array(&apps.value).iter().any(|a| { + a["id"] == i["service"]["id"] + && can_open(&groups, a["access"].as_str()) + }) + }) + .cloned() + .collect::>() + }); + let sample = host::sample(app).await?; + json!({"load":(number(&sample.value["load"])/number(&sample.value["cores"])*100.0).min(100.0),"issues":issues}) + } + ["services", id] if method == Method::GET => return Ok(service(app, id).await?.response()), + ["services", id, "definition"] if method == Method::GET => definition(app, id).await?, + ["services", id, "restarts", "acknowledge"] if method == Method::POST => { + let file = app.data.join("restarts-acknowledged.json"); + let mut value = read_json(&file, json!({})).await?; + value[*id] = json!(now()); + write_json(&file, &value).await?; + app.cache.invalidate(&format!("service:{id}")); + return Ok(empty()); + } + ["services", id, action] if method == Method::POST => { + if !["start", "stop", "restart"].contains(action) { + return Err(Error::new(400, "Choose start, stop, or restart.")); + } + change( + app, + json!({"operation":"deploy.start","action":action,"target":id}), + ) + .await?; + return Ok(empty()); + } + ["services", id, "secrets", name] if method == Method::GET => { + json!({"value":host::call(json!({"operation":"deploy.secret.get","service":id,"key":name})).await?}) + } + ["services", id, "secrets", name] | ["services", id, "secrets", name, "rotate"] + if (parts.len() == 4 && method == Method::PUT) + || (parts.len() == 5 && method == Method::POST) => + { + let mut request = json!({"operation":if parts.len()==5 {"deploy.secret.rotate"} else {"deploy.secret.set"},"service":id,"key":name}); + if parts.len() == 4 { + request["value"] = body["value"].clone(); + } + change(app, request).await?; + return Ok(empty()); + } + ["metrics", metric] if method == Method::GET => { + return Ok(telemetry::metrics(app, metric, query, None, None) + .await? + .response()); + } + ["services", id, "metrics", name] if method == Method::GET => { + let detail = service(app.clone(), id).await?; + if !array(&detail.value["applicationMetrics"]) + .iter() + .any(|v| v == *name) + { + return Err(Error::new(404, "Metric unavailable for this service.")); + } + return Ok(telemetry::metrics(app, name, query, Some(id), None) + .await? + .response()); + } + ["services", id, "logs"] if method == Method::GET => { + telemetry::logs(app, id, query).await? + } + ["services", id, "traces"] if method == Method::GET => { + telemetry::traces(app, id, query, |_| true).await? + } + ["traces", id] if method == Method::GET => telemetry::trace(app, id).await?, + _ => return Err(Error::new(404, "Not Found")), + }; + Ok(Document::new(value).response()) +} + +pub fn start(app: Arc) { + let state = app.clone(); + tokio::spawn(async move { + loop { + if let Err(e) = summaries(state.clone()).await { + eprintln!("status: {}", e.message); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }); + tokio::spawn(async move { + let mut previous = (0.0, 0.0); + let mut interval = tokio::time::interval(Duration::from_secs(2)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let sample = async { + let sample = host::sample(app.clone()).await?; + let sample = &sample.value; + let total = number(&sample["cpu"]["total"]); let busy = number(&sample["cpu"]["busy"]); + 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); + let jobs = app.cache.peek("services",Duration::from_secs(2)); + 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(); + Ok::<_,Error>(Document::new(json!({"t":now(),"host":{"cpu":cpu,"memory":sample["memory"]["used"],"arc":sample["arc"],"temperature":sample["temperature"]},"services":services,"ups":null}))) + }.await; + match sample { + Ok(value) => { + app.live.send_replace(value.bytes); + } + Err(e) => eprintln!("host sample: {}", e.message), + } + } + }); +} + +async fn definition(app: Arc, id: &str) -> Result { + if !managed().await?.iter().any(|s| s == id) { + return Err(Error::new( + 404, + format!("No service is named {id}. Pick one from the sidebar."), + )); + } + let jobs = scan(app.clone()).await?; + let found = &jobs.value[id]; + if found.is_null() { + return Err(Error::new( + 404, + format!("No service is named {id}. Pick one from the sidebar."), + )); + } + let mhz = number(&found["mhz"]); + let groups=array(&found["job"]["TaskGroups"]).iter().map(|group| { + let tags=|service: &Value,key: &str| array(&service["Tags"]).iter().filter_map(|s| string(s).strip_prefix(&format!("{key}=")).map(str::to_owned)).collect::>(); + let services=array(&group["Services"]).iter().map(|service| { + let check=&service["Checks"][0];let restart=&check["CheckRestart"]; + 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}})}}) + }).collect::>(); + let tasks=array(&group["Tasks"]).iter().map(|task| { + 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));}}} + let config=&task["Config"];let mut env:Vec=task["Env"].as_object().into_iter().flat_map(|v| v.keys()).map(|name| json!({"name":name,"from":"job"})).collect(); + let assignments=regex::Regex::new(r"(?m)(?:^|\}\})([A-Za-z_]\w*)=").unwrap(); + 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"}}));}} + 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::>(),"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::>(),"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}) + }).collect::>(); + let restart=&group["RestartPolicy"]; + 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}) + }).collect::>(); + let source = tokio::fs::read_to_string(service_file(&app, id).await?) + .await + .ok(); + Ok(json!({"groups":groups,"source":source})) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn health_precedence() { + let job = json!({"Stop":false}); + let alloc = json!({"DesiredStatus":"run","ClientStatus":"running","JobVersion":1,"TaskStates":{"app":{"State":"running"}}}); + assert_eq!(health(&job, &[], &[]), "down"); + assert_eq!(health(&job, &[alloc.clone()], &[]), "healthy"); + assert_eq!( + health(&job, &[alloc.clone()], &[json!({"Status":"failure"})]), + "degraded" + ); + let mut pending = alloc.clone(); + pending["TaskStates"]["app"]["State"] = json!("pending"); + assert_eq!( + health(&job, &[pending], &[json!({"Status":"failure"})]), + "restarting" + ); + let mut newer = alloc.clone(); + newer["JobVersion"] = json!(2); + assert_eq!(health(&job, &[alloc, newer], &[]), "deploying"); + assert_eq!(health(&json!({"Stop":true}), &[], &[]), "stopped"); + } +} diff --git a/dashboard/src/deploys.rs b/dashboard/src/deploys.rs new file mode 100644 index 0000000000000000000000000000000000000000..7dc51da14bd02472132459a3c2612030b221b042 --- /dev/null +++ b/dashboard/src/deploys.rs @@ -0,0 +1,534 @@ +use crate::*; +use futures::stream; + +async fn history() -> Result { + host::call(json!({"operation":"deploy.history"})).await +} +async fn current() -> Result> { + Ok(serde_json::from_value( + host::call(json!({"operation":"deploy.current"})).await?, + )?) +} +async fn stages(app: Arc) -> Result { + let mut values = host::call(json!({"operation":"deploy.stages"})).await?; + let jobs = core::scan(app).await.ok(); + for stage in values.as_array_mut().unwrap() { + let job = jobs.as_ref().map(|jobs| &jobs.value[string(&stage["id"])]); + stage["hostname"] = json!( + job.and_then(|job| job["job"]["Meta"]["studio_hostname"].as_str()) + .filter(|hostname| !hostname.is_empty()) + ); + stage["health"] = json!(job.map(|job| job["health"].as_str().unwrap_or("down"))); + } + Ok(values) +} +async fn tree(app: Arc, id: &str) -> Result> { + let id = id.to_owned(); + app.cache + .get( + format!("release:{id}"), + Duration::from_secs(365 * 86400), + move || async move { + host::call(json!({"operation":"deploy.release","release":id})).await + }, + ) + .await +} +async fn changed( + app: Arc, + from: Option<&str>, + to: Option<&str>, +) -> Result>> { + let (Some(from), Some(to)) = (from, to) else { + return Ok(None); + }; + let (a, b) = tokio::try_join!(tree(app.clone(), from), tree(app.clone(), to))?; + let (Some(a), Some(b)) = (a.value.as_object(), b.value.as_object()) else { + return Ok(None); + }; + let ids: std::collections::BTreeSet<_> = a.keys().chain(b.keys()).collect(); + Ok(Some( + ids.into_iter() + .filter(|id| a.get(*id) != b.get(*id)) + .cloned() + .collect(), + )) +} +fn diff(before: &str, after: &str) -> Vec { + let a: Vec<_> = if before.is_empty() { + Vec::new() + } else { + before.split('\n').collect() + }; + let b: Vec<_> = if after.is_empty() { + Vec::new() + } else { + after.split('\n').collect() + }; + let mut common = vec![vec![0usize; b.len() + 1]; a.len() + 1]; + for i in (0..a.len()).rev() { + for j in (0..b.len()).rev() { + common[i][j] = if a[i] == b[j] { + common[i + 1][j + 1] + 1 + } else { + common[i + 1][j].max(common[i][j + 1]) + }; + } + } + let (mut i, mut j) = (0, 0); + let mut lines = Vec::new(); + while i < a.len() || j < b.len() { + if i < a.len() && j < b.len() && a[i] == b[j] { + lines.push(json!([" ", a[i]])); + i += 1; + j += 1; + } else if i < a.len() && (j == b.len() || common[i + 1][j] >= common[i][j + 1]) { + lines.push(json!(["-", a[i]])); + i += 1; + } else { + lines.push(json!(["+", b[j]])); + j += 1; + } + } + lines +} +async fn stage(app: Arc, id: &str) -> Result { + if !core::valid_id(id) { + return Err(Error::new( + 400, + "Stage IDs are lowercase letters, digits and dashes.", + )); + } + array(&stages(app).await?) + .iter() + .find(|s| s["id"] == id) + .cloned() + .ok_or_else(|| { + Error::new( + 404, + format!("No stage named {id}. It may have been destroyed; go back to deploys."), + ) + }) +} +fn entry(history: &Value, n: &str) -> Result<(usize, Value)> { + let n = n + .parse::() + .ok() + .filter(|n| *n > 0) + .ok_or_else(|| Error::new(400, "Invalid deploy number."))?; + array(history) + .get(n - 1) + .cloned() + .map(|v| (n, v)) + .ok_or_else(|| { + Error::new( + 404, + format!("No deploy number {n}. Go back to deploys to see the history."), + ) + }) +} +struct Scope { + jobs: Vec, + allocations: Vec, + from: f64, + to: Option, +} +async fn scope(app: Arc, kind: &str, target: &str) -> Result { + let mut after = f64::NEG_INFINITY; + let mut until = f64::INFINITY; + let mut to = None; + let jobs = if kind == "stages" { + vec![string(&stage(app.clone(), target).await?["id"]).to_owned()] + } else { + let history = history().await?; + let (n, entry) = entry(&history, target)?; + let previous = if n >= 2 { + &history[n - 2] + } else { + &Value::Null + }; + after = previous["time"].as_f64().unwrap_or(f64::NEG_INFINITY); + until = number(&entry["time"]); + to = history[n]["time"].as_f64(); + match changed( + app.clone(), + previous["release"].as_str(), + entry["release"].as_str(), + ) + .await? + { + Some(jobs) => jobs, + None => tree(app.clone(), string(&entry["release"])) + .await? + .value + .as_object() + .into_iter() + .flat_map(|t| t.keys().cloned()) + .collect(), + } + }; + let scanned = core::scan(app).await?; + let mut allocations = Vec::new(); + let mut created = Vec::new(); + for id in &jobs { + let found = &scanned.value[id]; + let mine=array(&found["allocs"]).iter().filter(|a| number(&a["CreateTime"])/1e9>after && number(&a["CreateTime"])/1e9<=until).map(|a| { + 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); + 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!([])}}) + }).collect::>(); + allocations.push(json!(mine)); + } + Ok(Scope { + jobs, + allocations, + from: created + .into_iter() + .min_by(f64::total_cmp) + .unwrap_or(after.max(0.0)), + to, + }) +} +async fn runtime(app: Arc, scope: Scope) -> Result { + let end = scope.to.unwrap_or((now() / 2.0).floor() * 2.0); + let mut jobs = Vec::new(); + for (i, id) in scope.jobs.iter().enumerate() { + let query = HashMap::from([("service".into(), id.clone())]); + let usage = tokio::try_join!( + telemetry::metrics( + app.clone(), + "service.cpu", + &query, + None, + Some((scope.from, end)) + ), + telemetry::metrics( + app.clone(), + "service.memory", + &query, + None, + Some((scope.from, end)) + ) + ); + let (cpu, memory) = match usage { + Ok((cpu, memory)) => (cpu.value[0].clone(), memory.value[0].clone()), + Err(e) if e.status == 501 => (Value::Null, Value::Null), + Err(e) => return Err(e), + }; + jobs.push(json!({"id":id,"allocations":scope.allocations[i],"cpu":cpu,"memory":memory})); + } + Ok(json!({"from":scope.from,"to":scope.to,"jobs":jobs})) +} +fn display_run(run: Value, history: &Value) -> Result { + if run.is_null() { + return Ok(run); + } + let target = string(&run["target"]); + let title = match string(&run["action"]) { + "secret-set" => format!("set {target}/{}", string(&run["key"])), + "secret-rotate" => format!("rotate {target}/{}", string(&run["key"])), + action @ ("start" | "stop" | "restart") => format!("{action} {target}"), + "deploy" => "deploy main".to_owned(), + "promote" => format!("promote {target}"), + "destroy" => format!("destroy {target}"), + "rollback" => { + let (_, entry) = entry(history, target)?; + format!( + "roll back to {}", + string(&entry["release"]).get(..8).unwrap_or_default() + ) + } + _ => { + return Err(Error::new( + 502, + "The deployment action couldn't be read. Reload deploys.", + )); + } + }; + Ok(json!({"id":run["id"],"title":title,"target":target,"code":run["code"]})) +} +async fn start(app: Arc, action: &str, target: &str) -> Result { + let run = + host::call(json!({"operation":"deploy.start","action":action,"target":target})).await?; + app.cache.invalidate("deploys"); + display_run(run, &history().await?) +} +pub async fn run_stream(app: Arc, id: &str) -> Result { + host::call(json!({"operation":"deploy.run","id":id})).await?; + let id = id.to_owned(); + let stream = stream::unfold( + (app, id, 0usize, false), + move |(app, id, mut sent, ended)| async move { + if ended { + return None; + } + let run = id.clone(); + let response = + app.cache + .get( + format!("run:{id}"), + Duration::from_secs(1), + move || async move { + host::call(json!({"operation":"deploy.run","id":run})).await + }, + ) + .await; + match response { + Ok(response) => { + let mut chunk = String::new(); + let lines = array(&response.value["lines"]); + while sent < lines.len() { + for line in string(&lines[sent]).lines() { + chunk.push_str(&format!("data: {line}\n")); + } + chunk.push('\n'); + sent += 1; + } + let ended = !response.value["code"].is_null(); + if ended { + chunk.push_str(&format!( + "event: exit\ndata: {}\n\n", + number(&response.value["code"]) + )); + } + if chunk.is_empty() { + chunk.push_str(": keepalive\n\n"); + } + tokio::time::sleep(Duration::from_secs(1)).await; + Some(( + Ok::<_, std::io::Error>(Bytes::from(chunk)), + (app, id, sent, ended), + )) + } + Err(error) => Some(( + Ok(Bytes::from(format!( + "event: exit\ndata: 1\n\n: {}\n\n", + error.message.replace('\n', " ") + ))), + (app, id, sent, true), + )), + } + }, + ); + Ok(( + [ + ("content-type", "text/event-stream"), + ("cache-control", "no-cache"), + ], + axum::body::Body::from_stream(stream), + ) + .into_response()) +} + +pub async fn route( + app: Arc, + method: &Method, + parts: &[&str], + query: &HashMap, +) -> Result { + let value = match parts { + [] if method == Method::GET => { + let state = app.clone(); + let document = app + .cache + .get( + "deploys".into(), + Duration::from_secs(2), + move || async move { + let history = history().await?; + let current = current().await?; + let main = host::call(json!({"operation":"deploy.main"})).await?; + let mut stages = stages(state.clone()).await?; + let mut entries = Vec::new(); + for (i, item) in array(&history).iter().enumerate() { + let mut item = item.clone(); + item["n"] = json!(i + 1); + item["changed"] = json!( + changed( + state.clone(), + i.checked_sub(1) + .and_then(|n| history[n]["release"].as_str()), + item["release"].as_str() + ) + .await? + ); + item["redeploys"] = json!( + changed(state.clone(), current.as_deref(), item["release"].as_str()).await? + ); + entries.push(item); + } + entries.reverse(); + for stage in stages.as_array_mut().unwrap() { + let base = array(&history).iter().rposition(|e| { + e["source"] == stage["id"] || number(&e["time"]) <= number(&stage["created"]) + }); + stage["files"] = if stage["clone"].is_null() { + Value::Null + } else { + apps::file_link(string(&stage["mount"]), false) + }; + stage["base"] = json!(base.map(|n| n + 1)); + stage["changed"] = json!( + changed(state.clone(), current.as_deref(), stage["release"].as_str()).await? + ); + stage["behind"] = json!( + changed( + state.clone(), + base.and_then(|n| history[n]["release"].as_str()), + current.as_deref() + ) + .await? + .unwrap_or_default() + ); + } + let run = display_run( + host::call(json!({"operation":"deploy.last"})).await?, + &history, + )?; + Ok(json!({ + "current": current, + "main": main, + "recorded": array(&history).last().and_then(|e| e["release"].as_str()) == current.as_deref(), + "history": entries, "stages": stages, "run": run + })) + }, + ) + .await?; + return Ok(document.response()); + } + ["changes"] if method == Method::GET => { + let to = query + .get("to") + .ok_or_else(|| Error::new(400, "Pick a release."))?; + let a = if let Some(from) = query.get("from") { + tree(app.clone(), from).await? + } else { + Arc::new(Document::new(json!({}))) + }; + let b = tree(app.clone(), to).await?; + let (Some(a), Some(b)) = (a.value.as_object(), b.value.as_object()) else { + return Err(Error::new( + 410, + "That release is no longer on the host, so its changes can't be shown.", + )); + }; + let ids: std::collections::BTreeSet<_> = a.keys().chain(b.keys()).collect(); + 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::>()) + } + [kind, target, "runtime"] + if method == Method::GET && ["stages", "history"].contains(kind) => + { + runtime(app.clone(), scope(app, kind, target).await?).await? + } + [kind, target, "logs"] if method == Method::GET && ["stages", "history"].contains(kind) => { + let scope = scope(app.clone(), kind, target).await?; + let job = query + .get("job") + .or(scope.jobs.first()) + .ok_or_else(|| Error::new(404, "No job ran here, so there are no logs to show."))?; + if !scope.jobs.contains(job) { + return Err(Error::new( + 404, + format!("{job} didn't run here, so there are no logs to show."), + )); + } + job_logs(app, job, query, scope.from, scope.to).await? + } + [kind, target, "output"] + if method == Method::GET && ["stages", "history"].contains(kind) => + { + json!({"lines":host::call(json!({"operation":"deploy.output","kind":kind,"target":target})).await?}) + } + ["main", release, "deploy"] if method == Method::POST => { + start(app, "deploy", release).await? + } + ["stages", id, "destroy"] if method == Method::POST => start(app, "destroy", id).await?, + ["history", n, "rollback"] if method == Method::POST => start(app, "rollback", n).await?, + _ => return Err(Error::new(404, "Not Found")), + }; + Ok(Document::new(value).response()) +} + +async fn job_logs( + app: Arc, + job: &str, + query: &HashMap, + from: f64, + to: Option, +) -> Result { + let jobs = core::scan(app.clone()).await?; + let found = &jobs.value[job]; + let limit = telemetry::query_number(query, "limit", 300.0, 1.0, 1000.0)? as usize; + let after = + telemetry::query_number(query, "after", from, f64::NEG_INFINITY, f64::INFINITY)?.max(from); + let before = telemetry::query_number( + query, + "before", + to.unwrap_or(f64::MAX), + f64::NEG_INFINITY, + f64::INFINITY, + )? + .min(to.unwrap_or(f64::MAX)); + let mut lines = Vec::new(); + let ansi = regex::Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]|\r").unwrap(); + let error = + regex::Regex::new(r"(?i)\b(ERROR|ERR|FATAL|CRITICAL|PANIC)\b|level=(error|fatal)").unwrap(); + let warn = regex::Regex::new(r"(?i)\b(WARN|WARNING|WRN)\b|level=warn").unwrap(); + for alloc in array(&found["allocs"]) { + for task in alloc["TaskStates"] + .as_object() + .into_iter() + .flat_map(|t| t.keys()) + { + let path = format!( + "/v1/client/fs/logs/{}?{}", + string(&alloc["ID"]), + params(&[ + ("task", task.clone()), + ("type", "stdout".into()), + ("origin", "end".into()), + ("offset", (-512 * 1024).to_string()), + ("plain", "true".into()) + ]) + ); + let Some(bytes) = core::nomad_raw(&app, &path).await? else { + continue; + }; + let text = String::from_utf8_lossy(&bytes); + let mut partial = String::new(); + for line in text.lines() { + let fields: Vec<_> = line.splitn(4, ' ').collect(); + if fields.len() != 4 + || !["stdout", "stderr"].contains(&fields[1]) + || !["F", "P"].contains(&fields[2]) + { + continue; + } + let Some(t) = core::seconds(&json!(fields[0])) else { + continue; + }; + partial.push_str(fields[3]); + if fields[2] == "P" { + continue; + } + let text = std::mem::take(&mut partial); + if t <= after || t >= before { + continue; + } + let plain = ansi.replace_all(&text, ""); + let level = if error.is_match(&plain) { + Some("error") + } else if warn.is_match(&plain) { + Some("warn") + } else { + None + }; + lines.push( + json!({"t":t,"container":task,"stream":fields[1],"level":level,"text":text}), + ); + } + } + } + lines.sort_by(|a, b| number(&b["t"]).total_cmp(&number(&a["t"]))); + lines.truncate(limit); + Ok(json!(lines)) +} diff --git a/dashboard/src/files.rs b/dashboard/src/files.rs new file mode 100644 index 0000000000000000000000000000000000000000..8401bc201fc76a7c99532e7decaa8a567893d48a --- /dev/null +++ b/dashboard/src/files.rs @@ -0,0 +1,1157 @@ +use crate::*; +use std::{ + collections::HashSet, + io::Read, + os::unix::fs::MetadataExt, + path::{Component, Path}, +}; + +pub fn relative(rel: &str, item: bool) -> Result { + if rel.len() > 4096 || rel.contains('\0') || Path::new(rel).is_absolute() { + return Err(Error::new( + 400, + "Paths start from the library, so they can't start with /.", + )); + } + let mut path = PathBuf::new(); + for component in Path::new(rel).components() { + match component { + Component::CurDir => (), + Component::ParentDir => { + if path.file_name().is_some_and(|s| s != "..") { + path.pop(); + } else { + path.push(".."); + } + } + _ => path.push(component.as_os_str()), + } + } + let value = path.to_string_lossy().into_owned(); + if item && value.is_empty() { + return Err(Error::new( + 400, + "Choose something inside the folder, not the folder itself.", + )); + } + Ok(value) +} +fn name(value: &Value) -> Result<&str> { + value + .as_str() + .filter(|s| { + !s.is_empty() && s.len() <= 255 && !s.contains(['/', '\0']) && *s != "." && *s != ".." + }) + .ok_or_else(|| Error::new(400, "Names can't contain / or be . or ..")) +} +#[derive(Clone)] +struct Explorer { + root: PathBuf, + base: PathBuf, + scope: PathBuf, +} +impl Explorer { + async fn new(root: PathBuf) -> Result { + let store = PathBuf::from(env("STUDIO_STORE_ROOT", "/srv")); + let scope = root + .strip_prefix(&store) + .map_err(|_| Error::new(500, "Library root is outside the store."))? + .to_path_buf(); + let real = tokio::fs::canonicalize(store).await?; + Ok(Self { + root, + base: real.join(&scope), + scope, + }) + } + async fn resolve(&self, rel: &str) -> Result { + let rel = relative(rel, false)?; + if rel == ".." || rel.starts_with("../") { + return Err(Error::new( + 403, + format!("That path is outside {}.", self.root.display()), + )); + } + let abs = self.base.join(&rel); + let mut ancestor = abs.clone(); + loop { + match tokio::fs::canonicalize(&ancestor).await { + Ok(real) => { + if !real.starts_with(&self.base) { + return Err(Error::new( + 403, + format!("That path is outside {}.", self.root.display()), + )); + } + return Ok(abs); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if !ancestor.pop() { + return Err(e.into()); + } + } + Err(e) => return Err(e.into()), + } + } + } + fn store(&self, rel: &str) -> String { + self.scope.join(rel).to_string_lossy().into_owned() + } + fn local(&self, rel: &str) -> Result { + Path::new(rel) + .strip_prefix(&self.scope) + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|_| { + Error::new( + 403, + format!("That change touched files outside {}.", self.root.display()), + ) + }) + } + async fn entries(&self, rel: &str, mounts: &Value) -> Result> { + let mut dir = tokio::fs::read_dir(self.resolve(rel).await?) + .await + .map_err(file_error)?; + let mut found = Vec::new(); + while let Some(entry) = dir.next_entry().await? { + let name = entry.file_name().to_string_lossy().into_owned(); + let child = Path::new(rel).join(&name).to_string_lossy().into_owned(); + let Ok(abs) = self.resolve(&child).await else { + continue; + }; + let Ok(info) = tokio::fs::metadata(&abs).await else { + continue; + }; + let is_dir = info.is_dir(); + let items = if is_dir { + tokio::time::timeout(Duration::from_millis(200), async { + let mut dir = tokio::fs::read_dir(&abs).await?; + let mut count = 0; + while dir.next_entry().await?.is_some() { + count += 1; + } + Ok::<_, std::io::Error>(count) + }) + .await + .ok() + .and_then(std::result::Result::ok) + } else { + None + }; + let dataset = if is_dir { + array(mounts) + .iter() + .find(|m| m["target"] == abs.to_string_lossy().as_ref()) + .map(|m| m["source"].clone()) + } else { + None + }; + 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)})); + } + Ok(found) + } + async fn not_dataset(&self, rels: &[String], mounts: &Value) -> Result<()> { + for rel in rels { + let abs = self.resolve(rel).await?; + if let Some(mount) = array(mounts) + .iter() + .find(|m| Path::new(string(&m["target"])).starts_with(&abs)) + { + return Err(Error::new( + 409, + if mount["target"] == abs.to_string_lossy().as_ref() { + format!( + "{rel} is the {} dataset. Rename or destroy it with zfs instead.", + string(&mount["source"]) + ) + } else { + format!( + "{rel} holds the {} dataset. Move that dataset out with zfs first.", + string(&mount["source"]) + ) + }, + )); + } + } + Ok(()) + } + async fn check_moves(&self, pairs: &[(String, String)], mounts: &Value) -> Result<()> { + self.not_dataset( + &pairs + .iter() + .map(|(from, _)| from.clone()) + .collect::>(), + mounts, + ) + .await?; + let mut sources = HashSet::new(); + let mut targets = HashSet::new(); + for (from, to) in pairs { + if to.starts_with(&format!("{from}/")) { + return Err(Error::new(400, format!("Can't move {from} into itself."))); + } + if !sources.insert(from) { + return Err(Error::new( + 400, + format!("{from} is listed twice. Pick one place for it."), + )); + } + if !targets.insert(to) { + return Err(Error::new( + 409, + format!("More than one item would land at {to}. Rename one first."), + )); + } + let a = self.resolve(from).await?; + let b = self.resolve(to).await?; + if !tokio::fs::try_exists(&a).await? { + return Err(Error::new( + 409, + format!("{from} isn't there anymore. Reload the folder."), + )); + } + if tokio::fs::try_exists(&b).await? { + return Err(Error::new( + 409, + format!("{to} already exists. Move or rename it first."), + )); + } + let source = holder(mounts, &a); + let target = holder(mounts, b.parent().unwrap()); + if source.map(|m| &m["source"]) != target.map(|m| &m["source"]) { + return Err(Error::new( + 409, + format!( + "{from} is on {} and {} is on {}. Moves can't cross datasets, so copy it from a shell instead.", + source.map(|m| string(&m["source"])).unwrap_or("no dataset"), + b.parent() + .and_then(Path::file_name) + .unwrap_or_default() + .to_string_lossy(), + target.map(|m| string(&m["source"])).unwrap_or("no dataset") + ), + )); + } + } + Ok(()) + } + async fn move_one(&self, from: &str, to: &str) -> Result> { + let target = self.resolve(to).await?; + let mut made = target.parent().unwrap().to_path_buf(); + let mut first = None; + while !tokio::fs::try_exists(&made).await? { + first = Some(made.clone()); + made.pop(); + } + tokio::fs::create_dir_all(target.parent().unwrap()).await?; + if let Err(error) = tokio::fs::rename(self.resolve(from).await?, &target).await { + if let Some(first) = &first { + remove_empty(first.clone()).await?; + } + return Err(file_error(error)); + } + Ok(first.map(|p| self.store(&p.strip_prefix(&self.base).unwrap().to_string_lossy()))) + } + async fn folder(&self, rel: &str) -> Result<()> { + let abs = self.resolve(rel).await?; + if !tokio::fs::metadata(abs).await.is_ok_and(|m| m.is_dir()) { + return Err(Error::new( + 400, + format!( + "{} isn't a folder. Create it first, or pick an existing folder.", + self.root.join(rel).display() + ), + )); + } + Ok(()) + } + async fn matches(&self, folder: &str, pattern: &str, app: Arc) -> Result> { + if pattern.is_empty() + || pattern.len() > 1024 + || pattern.starts_with('/') + || pattern.split('/').any(|s| s == "..") + { + return Err(Error::new(400, "Patterns can't start with / or contain ..")); + } + let base = self.resolve(folder).await?; + let pattern = pattern.to_owned(); + let _slot = app.heavy.acquire().await?; + let paths = tokio::task::spawn_blocking(move || { + let matcher = globset::GlobBuilder::new(&pattern) + .literal_separator(true) + .build() + .map_err(|e| Error::new(400, e.to_string()))? + .compile_matcher(); + Ok::<_, Error>( + walkdir::WalkDir::new(&base) + .min_depth(1) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + .filter_map(|e| { + let rel = e.path().strip_prefix(&base).ok()?; + matcher + .is_match(rel) + .then(|| rel.to_string_lossy().into_owned()) + }) + .collect::>(), + ) + }) + .await??; + let mut paths = paths + .into_iter() + .map(|p| Path::new(folder).join(p).to_string_lossy().into_owned()) + .collect::>(); + paths.sort(); + let mut valid = Vec::new(); + for path in paths { + if self.resolve(&path).await.is_ok() + && !valid + .iter() + .any(|p: &String| path.starts_with(&format!("{p}/"))) + { + valid.push(path); + } + } + Ok(valid) + } +} +fn file_error(error: std::io::Error) -> Error { + match error.kind() { + std::io::ErrorKind::NotFound => Error::new( + 404, + "That file or folder no longer exists. Reload the folder.", + ), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty => { + Error::new(409, "Something with that name already exists.") + } + _ if error.raw_os_error() == Some(18) => Error::new( + 409, + "Moves can't cross ZFS datasets. Copy it from a shell instead.", + ), + _ => error.into(), + } +} +async fn mounts() -> Result { + let value = host::call(json!({"operation":"storage.mounts"})).await?; + let mut values = array(&value["filesystems"]).to_vec(); + values.sort_by_key(|m| std::cmp::Reverse(string(&m["target"]).len())); + Ok(json!(values)) +} +fn holder<'a>(mounts: &'a Value, abs: &Path) -> Option<&'a Value> { + array(mounts) + .iter() + .find(|m| abs.starts_with(string(&m["target"]))) +} +fn selection(value: &Value) -> Result> { + if !value.is_array() || array(value).is_empty() || array(value).len() > 10000 { + return Err(Error::new(400, "Choose files or folders.")); + } + let paths = array(value) + .iter() + .map(|p| { + p.as_str() + .ok_or_else(|| Error::new(400, "Invalid path.")) + .and_then(|p| relative(p, true)) + }) + .collect::>>()?; + let mut seen = HashSet::new(); + Ok(paths + .iter() + .filter(|p| { + seen.insert((*p).clone()) + && !Path::new(p) + .ancestors() + .skip(1) + .any(|a| paths.iter().any(|p| p == a.to_string_lossy().as_ref())) + }) + .cloned() + .collect()) +} +fn item_count(count: usize) -> String { + format!("{count} {}", if count == 1 { "item" } else { "items" }) +} +async fn remove_empty(abs: PathBuf) -> Result { + tokio::task::spawn_blocking(move || { + fn remove(abs: &Path) -> std::io::Result { + if !std::fs::symlink_metadata(abs).is_ok_and(|m| m.is_dir()) { + return Ok(false); + } + let mut empty = true; + for entry in std::fs::read_dir(abs)? { + if !remove(&entry?.path())? { + empty = false; + } + } + if empty { + std::fs::remove_dir(abs)?; + } + Ok(empty) + } + Ok::<_, Error>(remove(&abs)?) + }) + .await? +} +fn walk_total(abs: &Path, budget: &mut usize) -> Option { + if *budget == 0 { + return None; + } + *budget -= 1; + let info = std::fs::symlink_metadata(abs).ok(); + let Some(info) = info else { + return Some(json!({"size":0,"alloc":0,"files":0})); + }; + if !info.is_dir() { + return Some(json!({"size":info.len(),"alloc":info.blocks()*512,"files":1})); + } + let mut total = json!({"size":0,"alloc":0,"files":0}); + if let Ok(dir) = std::fs::read_dir(abs) { + for entry in dir.flatten() { + let child = walk_total(&entry.path(), budget)?; + for key in ["size", "alloc", "files"] { + total[key] = json!(number(&total[key]) + number(&child[key])); + } + } + } + Some(total) +} +async fn tree(app: Arc, explorer: &Explorer, body: &Value, mounts: &Value) -> Result { + let top = relative(string(&body["path"]), false)?; + let mut folders = serde_json::Map::new(); + let first = explorer.entries(&top, mounts).await?; + let mut listed = first.len(); + folders.insert(top.clone(), json!(first)); + let mut complete = true; + if body["expanded"] == "all" { + let mut level = vec![top.clone()]; + while !level.is_empty() { + let next: Vec<_> = level + .iter() + .flat_map(|rel| { + array(&folders[rel]) + .iter() + .filter(|e| e["dir"] == true) + .map(|e| { + ( + Path::new(rel) + .join(string(&e["name"])) + .to_string_lossy() + .into_owned(), + e["items"].as_u64().map(|n| n as usize), + ) + }) + }) + .collect(); + let Some(total) = next + .iter() + .try_fold(listed, |total, (_, count)| count.map(|n| total + n)) + else { + complete = false; + break; + }; + listed = total; + if listed > 3000 { + complete = false; + break; + } + level.clear(); + for (rel, _) in next { + let entries = explorer.entries(&rel, mounts).await?; + folders.insert(rel.clone(), json!(entries)); + level.push(rel); + } + } + } else { + if !body["expanded"].is_array() || array(&body["expanded"]).len() > 3000 { + return Err(Error::new(400, "Choose expanded folders.")); + } + for rel in array(&body["expanded"]) { + let rel = relative(string(rel), true)?; + if (!top.is_empty() && !rel.starts_with(&format!("{top}/"))) + || folders.contains_key(&rel) + { + continue; + } + if let Ok(entries) = explorer.entries(&rel, mounts).await { + folders.insert(rel, json!(entries)); + } + } + } + let index = app.index.clone(); + let explorer = explorer.clone(); + let _slot = app.heavy.acquire().await?; + tokio::task::spawn_blocking(move || { + fn measure( + rel: &str, + explorer: &Explorer, + index: Option<&crate::index::Index>, + folders: &serde_json::Map, + sizes: &mut serde_json::Map, + budget: &mut usize, + from_index: &mut bool, + ) -> Result> { + if let Some(entries) = folders.get(rel) { + let mut total = json!({"size":0,"alloc":0,"files":0}); + let mut known = true; + for entry in array(entries) { + let child = if entry["dir"] == true { + let rel = Path::new(rel) + .join(string(&entry["name"])) + .to_string_lossy() + .into_owned(); + let value = + measure(&rel, explorer, index, folders, sizes, budget, from_index)?; + if let Some(value) = &value { + sizes.insert(rel, value.clone()); + } + value + } else { + Some(json!({"size":entry["size"],"alloc":entry["alloc"],"files":1})) + }; + if let Some(child) = child { + for key in ["size", "alloc", "files"] { + total[key] = json!(number(&total[key]) + number(&child[key])); + } + } else { + known = false; + } + } + return Ok(known.then_some(total)); + } + if let Some(index) = index + && index.updated()?.is_some() + && let Some(total) = + index.children(&explorer.scope.join(rel).to_string_lossy(), 0)? + { + *from_index = true; + return Ok(Some( + json!({"size":total["size"],"alloc":total["alloc"],"files":total["files"]}), + )); + } + Ok(walk_total(&explorer.base.join(rel), budget)) + } + let mut budget = 200000; + let mut sizes = serde_json::Map::new(); + let mut from_index = false; + if let Some(total) = measure( + &top, + &explorer, + index.as_deref(), + &folders, + &mut sizes, + &mut budget, + &mut from_index, + )? { + sizes.insert(top, total); + } + let updated = if from_index { + index.as_ref().map(|i| i.updated()).transpose()?.flatten() + } else { + None + }; + Ok(json!({"folders":folders,"sizes":sizes,"complete":complete,"updated":updated})) + }) + .await? +} + +async fn journal(app: &App) -> Result { + core::read_json(&app.data.join("file-ops.json"), json!([])).await +} +fn mine(explorer: &Explorer, op: &Value) -> bool { + array(&op["done"]).iter().all(|step| { + if step.is_array() { + array(step) + .iter() + .all(|p| explorer.local(string(p)).is_ok()) + } else { + explorer.local(string(step)).is_ok() + } + }) +} +async fn prune(ops: &Value) -> Result<()> { + let keep: HashSet<_> = array(ops) + .iter() + .filter(|op| op["kind"] == "delete" && op["undone"] == false) + .map(|op| string(&op["snapshot"])) + .collect(); + let mounts = mounts().await?; + let datasets: HashSet<_> = array(&mounts) + .iter() + .filter(|m| Path::new(string(&m["target"])).starts_with(env("STUDIO_STORE_ROOT", "/srv"))) + .map(|m| string(&m["source"])) + .collect(); + if datasets.is_empty() { + return Ok(()); + } + let listed = host::call(json!({"operation":"files.snapshots","datasets":datasets})).await?; + for snapshot in array(&listed).iter().map(string) { + if let Some((dataset, name)) = snapshot.split_once('@') + && !keep.contains(name) + { + host::call(json!({"operation":"files.discard","dataset":dataset,"snapshot":name})) + .await?; + } + } + Ok(()) +} + +pub async fn route( + app: Arc, + media: bool, + method: &Method, + parts: &[&str], + query: &HashMap, + body: Value, +) -> Result { + let root = if media { + env( + "STUDIO_MEDIA_ROOT", + &format!("{}/clover/Media", env("STUDIO_STORE_ROOT", "/srv")), + ) + } else { + env("STUDIO_STORE_ROOT", "/srv") + }; + let explorer = Explorer::new(root.into()).await?; + if parts == ["refresh"] && media && method == Method::POST { + return refresh(app).await; + } + let value = match parts { + ["list"] if method == Method::GET => { + let rel = relative( + query.get("path").map(String::as_str).unwrap_or_default(), + false, + )?; + let key = format!("files:{}", explorer.root.join(&rel).display()); + let listing = app + .cache + .get(key, Duration::from_secs(2), move || async move { + let served = explorer.root.join(&rel); + let entries = explorer.entries(&rel, &mounts().await?).await?; + Ok(json!({"host":served,"link":apps::file_link(&served.to_string_lossy(),false),"zip":apps::file_link(&served.to_string_lossy(),true),"entries":entries})) + }) + .await?; + return Ok(listing.response()); + } + ["tree"] if method == Method::POST => tree(app, &explorer, &body, &mounts().await?).await?, + ["peek"] if method == Method::GET => { + let rel = relative( + query.get("path").map(String::as_str).unwrap_or_default(), + true, + )?; + let abs = explorer.resolve(&rel).await?; + let _slot = app.heavy.acquire().await?; + tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(abs).map_err(file_error)?; + let size = file.metadata()?.len(); + let mut bytes = Vec::new(); + file.take(65536).read_to_end(&mut bytes)?; + if bytes.contains(&0) { + return Err(Error::new( + 415, + "This file isn't text. Open it in copyparty instead.", + )); + } + Ok::<_, Error>( + json!({"text":String::from_utf8_lossy(&bytes),"more":size>bytes.len() as u64}), + ) + }) + .await?? + } + ["match"] if method == Method::GET => { + let folder = relative( + query.get("path").map(String::as_str).unwrap_or_default(), + false, + )?; + let pattern = query + .get("pattern") + .ok_or_else(|| Error::new(400, "Enter a pattern."))?; + let found = explorer.matches(&folder, pattern, app.clone()).await?; + let paths: Vec<_> = found.iter().map(|p| explorer.base.join(p)).collect(); + let _slot = app.heavy.acquire().await?; + let size = tokio::task::spawn_blocking(move || { + let mut budget = 200000; + let mut size = Some(0.0); + for path in paths { + let total = walk_total(&path, &mut budget); + size = match (size, total) { + (Some(size), Some(total)) => Some(size + number(&total["size"])), + _ => None, + }; + } + size + }) + .await?; + let mut rows = serde_json::Map::new(); + for rel in &found { + let inside = Path::new(rel) + .strip_prefix(&folder) + .unwrap_or(Path::new(rel)); + rows.insert(inside.to_string_lossy().into_owned(), json!("self")); + for parent in inside + .ancestors() + .skip(1) + .filter(|p| !p.as_os_str().is_empty()) + { + let key = parent.to_string_lossy().into_owned(); + let count = rows.get(&key).map(number).unwrap_or(0.0) + 1.0; + rows.insert(key, json!(count)); + } + } + 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::>(),"rows":rows}) + } + ["ops"] if method == Method::GET => { + let ops = journal(&app).await?; + 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::>(),"count":array(&op["done"]).len()})).collect::>()) + } + _ if method == Method::POST => { + let result = change(app.clone(), &explorer, parts, body).await; + app.cache.invalidate_prefix("files:"); + return result; + } + _ => return Err(Error::new(404, "Not Found")), + }; + Ok(Document::new(value).response()) +} + +async fn change( + app: Arc, + explorer: &Explorer, + parts: &[&str], + body: Value, +) -> Result { + if env("STUDIO_FILES_WRITABLE", "") != "1" { + return Err(Error::new( + 501, + "The file browser can't change files yet. Use copyparty instead.", + )); + } + let _guard = app.file_changes.lock().await; + let mut ops = journal(&app).await?; + let mounts = mounts().await?; + if let ["ops", id, "undo"] = parts { + let position = array(&ops) + .iter() + .position(|op| op["id"] == *id && mine(explorer, op)) + .ok_or_else(|| Error::new(404, "That change is too old to undo."))?; + let op = ops[position].clone(); + if op["undone"] == true { + return Err(Error::new(409, "That change is already undone.")); + } + revert(explorer, &op, &mounts).await?; + ops[position]["undone"] = json!(true); + core::write_json(&app.data.join("file-ops.json"), &ops).await?; + if let Err(e) = prune(&ops).await { + eprintln!("snapshot prune: {}", e.message); + } + if let Some(index) = &app.index { + index.changed(); + } + return Ok(StatusCode::NO_CONTENT.into_response()); + } + let mut pairs = Vec::new(); + let mut paths = Vec::new(); + let mut folder = None; + let label; + match parts { + ["folder"] => { + let parent = relative(string(&body["path"]), false)?; + let name = name(&body["name"])?; + explorer.folder(&parent).await?; + folder = Some(Path::new(&parent).join(name).to_string_lossy().into_owned()); + label = format!("Created {name}"); + } + ["rename"] => { + if !body["renames"].is_array() + || array(&body["renames"]).is_empty() + || array(&body["renames"]).len() > 10000 + { + return Err(Error::new(400, "Choose names to change.")); + } + for rename in array(&body["renames"]) { + let from = relative(string(&rename["path"]), true)?; + let name = name(&rename["name"])?; + let to = Path::new(&from) + .parent() + .unwrap() + .join(name) + .to_string_lossy() + .into_owned(); + if from != to { + pairs.push((from, to)); + } + } + if pairs.is_empty() { + return Err(Error::new( + 400, + "Every name is unchanged. Edit a name, then rename.", + )); + } + label = if pairs.len() == 1 { + format!( + "Renamed {} to {}", + Path::new(&pairs[0].0) + .file_name() + .unwrap() + .to_string_lossy(), + Path::new(&pairs[0].1) + .file_name() + .unwrap() + .to_string_lossy() + ) + } else { + format!("Renamed {}", item_count(pairs.len())) + }; + } + ["move"] => { + let selected = selection(&body["paths"])?; + let to = relative(string(&body["to"]), false)?; + explorer.folder(&to).await?; + pairs = selected + .iter() + .map(|from| { + ( + from.clone(), + Path::new(&to) + .join(Path::new(from).file_name().unwrap()) + .to_string_lossy() + .into_owned(), + ) + }) + .collect(); + label = format!( + "Moved {} to {}", + item_count(selected.len()), + explorer + .root + .join(&to) + .file_name() + .unwrap_or_default() + .to_string_lossy() + ); + } + ["delete"] => { + paths = selection(&body["paths"])?; + label = if paths.len() == 1 { + format!( + "Deleted {}", + Path::new(&paths[0]).file_name().unwrap().to_string_lossy() + ) + } else { + format!("Deleted {}", item_count(paths.len())) + }; + } + ["bulk"] => { + let rel = relative(string(&body["path"]), false)?; + let pattern = string(&body["pattern"]); + let found = explorer.matches(&rel, pattern, app.clone()).await?; + let count = number(&body["count"]) as usize; + if count == 0 { + return Err(Error::new(400, "Choose at least one item.")); + } + if found.len() != count { + return Err(Error::new( + 409, + format!( + "The pattern matches {} now, not {count}. Go back and check the new matches.", + found.len() + ), + )); + } + if body["action"] == "delete" { + paths = found; + label = format!("Deleted {} matching {pattern}", item_count(count)); + } else if body["action"] == "move" { + let to = body["to"] + .as_str() + .ok_or_else(|| Error::new(400, "Choose a folder to move into."))?; + let to = relative(to, false)?; + explorer.folder(&to).await?; + pairs = found + .iter() + .map(|from| { + ( + from.clone(), + Path::new(&to) + .join(Path::new(from).strip_prefix(&rel).unwrap()) + .to_string_lossy() + .into_owned(), + ) + }) + .collect(); + label = format!( + "Moved {} matching {pattern} to {}", + item_count(count), + explorer + .root + .join(&to) + .file_name() + .unwrap_or_default() + .to_string_lossy() + ); + } else { + return Err(Error::new(400, "Choose move or delete.")); + } + } + _ => return Err(Error::new(404, "Not Found")), + } + if !pairs.is_empty() { + explorer.check_moves(&pairs, &mounts).await?; + } + let mut snapshot = None; + if !paths.is_empty() { + explorer.not_dataset(&paths, &mounts).await?; + let mut datasets = HashSet::new(); + for rel in &paths { + let abs = explorer.resolve(rel).await?; + tokio::fs::symlink_metadata(&abs) + .await + .map_err(file_error)?; + let source = holder(&mounts, &abs).ok_or_else(|| { + Error::new( + 409, + format!( + "{} isn't on a ZFS dataset, so deleting it couldn't be undone.", + abs.display() + ), + ) + })?; + if datasets.insert(string(&source["source"]).to_owned()) { + drop( + tokio::fs::read_dir(Path::new(string(&source["target"])).join(".zfs/snapshot")) + .await + .map_err(|_| { + Error::new( + 409, + format!( + "Can't delete {rel}. Its undo history isn't accessible here." + ), + ) + })?, + ); + } + } + let name = host::call(json!({"operation":"files.snapshot","datasets":datasets})).await?; + snapshot = Some(string(&name).to_owned()); + } + let id = uuid::Uuid::new_v4().to_string(); + let mut done = Vec::new(); + let mut created = Vec::new(); + let result = async { + if let Some(folder) = &folder { + tokio::fs::create_dir(explorer.resolve(folder).await?) + .await + .map_err(file_error)?; + done.push(json!(explorer.store(folder))); + } + for (from, to) in &pairs { + if let Some(made) = explorer.move_one(from, to).await? { + created.push(made); + } + done.push(json!([explorer.store(from), explorer.store(to)])); + } + for rel in &paths { + let abs = explorer.resolve(rel).await?; + let info = tokio::fs::symlink_metadata(&abs) + .await + .map_err(file_error)?; + if info.is_dir() { + tokio::fs::remove_dir_all(abs).await.map_err(file_error)?; + } else { + tokio::fs::remove_file(abs).await.map_err(file_error)?; + } + done.push(json!(explorer.store(rel))); + } + Ok::<_, Error>(()) + } + .await; + if !done.is_empty() { + 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"}}); + if let Some(snapshot) = snapshot { + op["snapshot"] = json!(snapshot); + } else if folder.is_none() { + op["created"] = json!(created); + } + ops.as_array_mut().unwrap().insert(0, op); + ops.as_array_mut().unwrap().truncate(20); + core::write_json(&app.data.join("file-ops.json"), &ops).await?; + } + if let Err(e) = prune(&ops).await { + eprintln!("snapshot prune: {}", e.message); + } + if let Some(index) = &app.index { + index.changed(); + } + result?; + Ok(Document::new(json!({"id":id,"label":label})).response()) +} +async fn revert(explorer: &Explorer, op: &Value, mounts: &Value) -> Result<()> { + let paths = array(&op["done"]); + match string(&op["kind"]) { + "folder" => { + let rel = explorer.local(string(&paths[0]))?; + let abs = explorer.resolve(&rel).await?; + let mut dir = tokio::fs::read_dir(&abs).await?; + if dir.next_entry().await?.is_some() { + return Err(Error::new( + 409, + format!("{rel} has something in it now. Empty it, then undo."), + )); + } + tokio::fs::remove_dir(abs).await?; + } + "delete" => { + let mut copies = Vec::new(); + for path in paths { + let rel = explorer.local(string(path))?; + let abs = explorer.resolve(&rel).await?; + if tokio::fs::try_exists(&abs).await? { + return Err(Error::new( + 409, + format!("{rel} exists again. Move or rename it, then undo."), + )); + } + let target = holder(mounts, &abs) + .map(|m| PathBuf::from(string(&m["target"]))) + .unwrap_or(abs.clone()); + let saved = target + .join(".zfs/snapshot") + .join(string(&op["snapshot"])) + .join(abs.strip_prefix(&target).unwrap()); + if !tokio::fs::try_exists(&saved).await? { + return Err(Error::new( + 409, + format!("Can't put {} back. Its snapshot is gone.", abs.display()), + )); + } + copies.push((abs, saved)); + } + for (abs, saved) in copies { + tokio::fs::create_dir_all(abs.parent().unwrap()).await?; + command( + "cp", + &[ + "-a", + "--reflink=auto", + &saved.to_string_lossy(), + &abs.to_string_lossy(), + ], + None, + ) + .await?; + } + } + "move" => { + let pairs = paths + .iter() + .rev() + .map(|p| { + Ok(( + explorer.local(string(&p[1]))?, + explorer.local(string(&p[0]))?, + )) + }) + .collect::>>()?; + explorer.check_moves(&pairs, mounts).await?; + for (from, to) in pairs { + explorer.move_one(&from, &to).await?; + } + for rel in array(&op["created"]).iter().rev() { + remove_empty(explorer.resolve(&explorer.local(string(rel))?).await?).await?; + } + } + _ => return Err(Error::new(409, "That change can't be undone.")), + } + Ok(()) +} +async fn refresh(app: Arc) -> Result { + let base = std::env::var("STUDIO_JELLYFIN_URL") + .map_err(|_| Error::new(501, "Jellyfin isn't connected to the dashboard."))?; + let password = host::call( + json!({"operation":"deploy.secret.get","service":"jellyfin","key":"admin_password"}), + ) + .await?; + let password = password + .as_str() + .ok_or_else(|| Error::new(409, "Jellyfin has no generated admin password."))?; + let response = app + .http + .post(format!("{base}/Users/AuthenticateByName")) + .header( + "Authorization", + "MediaBrowser Client=\"home server\", Device=\"server\", DeviceId=\"studio\", Version=\"1\"", + ) + .json(&json!({"Username":"snow","Pw":password})) + .send() + .await?; + if !response.status().is_success() { + return Err(Error::new( + 502, + format!("Jellyfin sign-in failed ({}).", response.status()), + )); + } + let value: Value = response.json().await?; + let token = value["AccessToken"] + .as_str() + .ok_or_else(|| Error::new(502, "Jellyfin didn't return an access token."))?; + let response = app + .http + .post(format!("{base}/Library/Refresh")) + .header("X-Emby-Token", token) + .send() + .await?; + if !response.status().is_success() { + return Err(Error::new( + 502, + format!( + "Jellyfin couldn't start a library scan ({}).", + response.status() + ), + )); + } + Ok(StatusCode::ACCEPTED.into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn selection_keeps_order_and_collapses_descendants() { + assert_eq!( + selection(&json!(["b/x", "a", "b", "a", "c/y"])).unwrap(), + vec!["a", "b", "c/y"] + ); + assert!(selection(&json!([""])).is_err()); + assert!(relative("/absolute", true).is_err()); + assert!(relative("a\0b", true).is_err()); + assert_eq!(relative("a/../b", true).unwrap(), "b"); + } + #[tokio::test] + async fn existing_and_missing_paths_cannot_escape_through_symlinks() { + let dir = std::env::temp_dir().join(format!("snowglobe-files-{}", uuid::Uuid::new_v4())); + let root = dir.join("root"); + let outside = dir.join("outside"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap(); + let root = std::fs::canonicalize(root).unwrap(); + let explorer = Explorer { + root: root.clone(), + base: root, + scope: PathBuf::new(), + }; + assert_eq!( + explorer.resolve("../outside").await.unwrap_err().status, + 403 + ); + assert_eq!( + explorer + .resolve("escape/new/file") + .await + .unwrap_err() + .status, + 403 + ); + assert!(explorer.resolve("new/file").await.is_ok()); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/dashboard/src/host.rs b/dashboard/src/host.rs new file mode 100644 index 0000000000000000000000000000000000000000..a3fc3bf5b0a308d965bafa8bef2feca6f7aa390b --- /dev/null +++ b/dashboard/src/host.rs @@ -0,0 +1,74 @@ +use crate::*; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +static SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); + +pub async fn sample(app: Arc) -> Result> { + app.cache + .get("host-sample".into(), Duration::from_secs(1), || async { + call(json!({"operation":"host.sample"})).await + }) + .await +} + +pub async fn call(request: Value) -> Result { + tokio::time::timeout(Duration::from_secs(70), async { + let _slot = SLOTS.acquire().await?; + let mut socket = tokio::net::UnixStream::connect(env( + "STUDIO_HOST_SOCKET", + "/run/studio-host/host.sock", + )) + .await?; + #[cfg(target_os = "linux")] + if socket.peer_cred()?.uid() != 0 { + return Err(Error::new( + 502, + "The host service identity couldn't be verified.", + )); + } + let mut message = serde_json::to_vec(&request)?; + message.push(b'\n'); + if message.len() > 65536 { + return Err(Error::new( + 400, + "The host request is too large. Narrow the selection.", + )); + } + socket.write_all(&message).await?; + let length = socket.read_u32().await? as usize; + if length > 16 * 1024 * 1024 { + return Err(Error::new( + 502, + "The host response is too large. Narrow the selection.", + )); + } + let mut bytes = vec![0; length]; + socket.read_exact(&mut bytes).await?; + let mut response: Value = serde_json::from_slice(&bytes)?; + if let Some(message) = response["error"].as_str() { + return Err(Error::new( + response["status"] + .as_u64() + .filter(|s| (400..=599).contains(s)) + .unwrap_or(502) as u16, + message, + )); + } + response + .as_object_mut() + .and_then(|v| v.remove("value")) + .ok_or_else(|| { + Error::new( + 502, + "The host response is incomplete. Check its logs, then retry.", + ) + }) + }) + .await + .map_err(|_| { + Error::new( + 504, + "The host operation is taking too long. Check its logs, then retry.", + ) + })? +} diff --git a/dashboard/src/index.rs b/dashboard/src/index.rs new file mode 100644 index 0000000000000000000000000000000000000000..d0d6b35602ee1c80a7e807f9158ed474de52a964 --- /dev/null +++ b/dashboard/src/index.rs @@ -0,0 +1,1026 @@ +use crate::*; +use rusqlite::{Connection, OptionalExtension, params}; +use sha1::{Digest, Sha1}; +use std::{ + collections::{BTreeSet, HashSet}, + os::unix::fs::MetadataExt, + sync::atomic::{AtomicBool, Ordering}, +}; + +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);"; +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;"; + +pub struct Index { + pool: String, + dir: PathBuf, + active: Mutex>, + scanning: AtomicBool, + wake: tokio::sync::Notify, +} +impl Index { + pub fn new(pool: String, dir: PathBuf) -> Self { + Self { + pool, + dir, + active: Mutex::new(BTreeSet::new()), + scanning: AtomicBool::new(false), + wake: tokio::sync::Notify::new(), + } + } + fn file(&self, dataset: &str) -> PathBuf { + self.dir.join(format!("{}.db", encoded(dataset))) + } + fn db(&self, dataset: &str) -> Result> { + if !self.active.lock().unwrap().contains(dataset) { + return Ok(None); + } + let file = self.file(dataset); + if !file.exists() { + return Ok(None); + } + let db = Connection::open_with_flags(file, rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE)?; + if meta(&db)?.is_none() { + return Ok(None); + } + Ok(Some(db)) + } + fn names(&self) -> BTreeSet { + let mut names = BTreeSet::new(); + for name in self.active.lock().unwrap().iter() { + let parts: Vec<_> = name.split('/').collect(); + for i in 1..=parts.len() { + names.insert(parts[..i].join("/")); + } + } + names + } + fn owner(&self, rel: &str) -> Option<(String, String)> { + let names = self.names(); + let full = if rel.is_empty() { + self.pool.clone() + } else { + format!("{}/{rel}", self.pool) + }; + let parts: Vec<_> = full.split('/').collect(); + for i in (1..=parts.len()).rev() { + let dataset = parts[..i].join("/"); + if names.contains(&dataset) { + return Some((dataset, parts[i..].join("/"))); + } + } + None + } + fn subs(&self, dataset: &str) -> Vec { + self.names() + .into_iter() + .filter_map(|name| { + let (parent, sub) = name.rsplit_once('/')?; + (parent == dataset).then(|| sub.to_owned()) + }) + .collect() + } + pub fn children(&self, rel: &str, limit: usize) -> Result> { + let Some((dataset, inner)) = self.owner(rel) else { + return Ok(None); + }; + let db = self.db(&dataset)?; + let subs = if inner.is_empty() { + self.subs(&dataset) + } else { + Vec::new() + }; + let own = match db.as_ref() { + Some(db) => children(db, &inner, limit + subs.len())?, + None if inner.is_empty() => { + Some(json!({"size":0,"alloc":0,"files":0,"mtime":0,"count":0,"entries":[]})) + } + _ => None, + }; + let Some(mut own) = own else { + return Ok(None); + }; + let mut entries: Vec<_> = array(&own["entries"]) + .iter() + .filter(|e| !subs.iter().any(|s| e["name"] == *s)) + .cloned() + .collect(); + for sub in subs { + let next = if rel.is_empty() { + sub.clone() + } else { + format!("{rel}/{sub}") + }; + let Some(total) = self.children(&next, 0)? else { + continue; + }; + let shadow = if let Some(db) = &db { + self::children(db, &sub, 0)?.unwrap_or(json!({})) + } else { + json!({}) + }; + for key in ["size", "alloc", "files"] { + own[key] = json!(number(&own[key]) - number(&shadow[key]) + number(&total[key])); + } + own["mtime"] = json!(number(&own["mtime"]).max(number(&total["mtime"]))); + own["count"] = json!( + number(&own["count"]) + 1.0 - if shadow["size"].is_null() { 0.0 } else { 1.0 } + ); + entries.push(json!({"name":sub,"dir":1,"size":total["size"],"alloc":total["alloc"],"files":total["files"],"mtime":total["mtime"]})); + } + entries.sort_by(|a, b| { + number(&b["size"]) + .total_cmp(&number(&a["size"])) + .then_with(|| string(&a["name"]).cmp(string(&b["name"]))) + }); + entries.truncate(limit); + own["entries"] = json!(entries); + Ok(Some(own)) + } + pub fn map(&self, rel: &str, min: f64) -> Result> { + let Some((dataset, inner)) = self.owner(rel) else { + return Ok(None); + }; + let db = self.db(&dataset)?; + let own = match db.as_ref() { + Some(db) => map(db, &inner, min)?, + None if inner.is_empty() => Some(json!(["", 0, 0, []])), + _ => None, + }; + let Some(mut own) = own else { + return Ok(None); + }; + if !inner.is_empty() { + return Ok(Some(own)); + } + let subs = self.subs(&dataset); + let mut children: Vec<_> = array(&own[3]) + .iter() + .filter(|n| !subs.iter().any(|s| n[0] == *s)) + .cloned() + .collect(); + for sub in subs { + let next = if rel.is_empty() { + sub.clone() + } else { + format!("{rel}/{sub}") + }; + let Some(tree) = self.map(&next, min)? else { + continue; + }; + let shadow = if let Some(db) = &db { + self::children(db, &sub, 0)?.unwrap_or(json!({})) + } else { + json!({}) + }; + own[1] = json!(number(&own[1]) + number(&tree[1]) - number(&shadow["size"])); + own[2] = json!(number(&own[2]) + number(&tree[2]) - number(&shadow["files"])); + if number(&tree[1]) >= min { + children.push(json!([sub, tree[1], tree[2], tree[3]])); + } + } + own[3] = json!(children); + Ok(Some(own)) + } + pub fn largest(&self, limit: usize) -> Result { + let mut values = Vec::new(); + let datasets = self.active.lock().unwrap().clone(); + for dataset in datasets { + let Some(db) = self.db(&dataset)? else { + continue; + }; + 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 ?")?; + 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)?})))?; + let prefix = dataset + .strip_prefix(&format!("{}/", self.pool)) + .map(|p| format!("{p}/")) + .unwrap_or_default(); + for row in rows { + let mut row = row?; + row["path"] = json!(format!("{prefix}{}", string(&row["path"]))); + values.push(row); + } + } + values.sort_by(|a, b| number(&b["size"]).total_cmp(&number(&a["size"]))); + values.truncate(limit); + Ok(json!(values)) + } + pub fn updated(&self) -> Result> { + let mut values = Vec::new(); + let datasets = self.active.lock().unwrap().clone(); + for dataset in datasets { + let Some(db) = self.db(&dataset)? else { + return Ok(None); + }; + let Some(meta) = meta(&db)? else { + return Ok(None); + }; + values.push(number(&meta["updated"])); + } + Ok(values.into_iter().min_by(f64::total_cmp)) + } + pub async fn start(self: Arc, app: Arc) { + let mut failures = HashMap::::new(); + loop { + let result = async { + tokio::fs::create_dir_all(&self.dir).await?; + let (listed, mounted) = tokio::try_join!( + crate::host::call(json!({"operation":"storage.datasets"})), + crate::host::call(json!({"operation":"storage.mounts"})) + )?; + let origins: HashMap<_, _> = array(&listed) + .iter() + .filter(|d| { + string(&d["name"]) == self.pool + || string(&d["name"]).starts_with(&format!("{}/", self.pool)) + }) + .map(|d| (string(&d["name"]), &d["origin"])) + .collect(); + let visible = + mounted_datasets(&tokio::fs::read_to_string("/proc/self/mountinfo").await?); + let mounts: Vec<_> = array(&mounted["filesystems"]) + .iter() + .filter(|m| { + origins + .get(string(&m["source"])) + .is_some_and(|v| v.is_null()) + && !string(&m["source"]).starts_with(&format!("{}/staging/", self.pool)) + && visible.contains(&( + string(&m["source"]).to_owned(), + string(&m["target"]).to_owned(), + )) + }) + .map(|m| { + ( + string(&m["source"]).to_owned(), + string(&m["target"]).to_owned(), + ) + }) + .collect(); + *self.active.lock().unwrap() = mounts.iter().map(|(d, _)| d.clone()).collect(); + self.scanning.store(true, Ordering::Relaxed); + for (dataset, mount) in mounts { + let result = self + .round( + app.clone(), + &dataset, + &mount, + *failures.get(&dataset).unwrap_or(&0), + ) + .await; + if let Err(e) = result { + eprintln!("index {dataset}: {}", e.message); + *failures.entry(dataset).or_default() += 1; + } else { + failures.remove(&dataset); + } + } + let mut files = tokio::fs::read_dir(&self.dir).await?; + while let Some(file) = files.next_entry().await? { + if let Some(name) = file + .file_name() + .to_str() + .and_then(|s| s.strip_suffix(".db")) + { + let name = url::form_urlencoded::parse(format!("x={name}").as_bytes()) + .next() + .map(|(_, v)| v.into_owned()) + .unwrap_or_default(); + if !origins.contains_key(name.as_str()) + || name.starts_with(&format!("{}/staging/", self.pool)) + { + tokio::fs::remove_file(file.path()).await?; + } + } + } + Ok::<_, Error>(()) + } + .await; + self.scanning.store(false, Ordering::Relaxed); + if let Err(e) = result { + eprintln!("file index: {}", e.message); + } + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(600)) => (), + _ = self.wake.notified() => (), + } + } + } + async fn round(&self, app: Arc, dataset: &str, mount: &str, failures: u32) -> Result<()> { + let snapshot: String = serde_json::from_value( + crate::host::call(json!({"operation":"index.snapshot","dataset":dataset})).await?, + )?; + let existing: Vec = serde_json::from_value( + crate::host::call(json!({"operation":"index.snapshots","dataset":dataset})).await?, + )?; + let file = self.file(dataset); + let old = if let Some(db) = self.db(dataset)? { + meta(&db)? + } else { + None + }; + let offset = u32::from_be_bytes(Sha1::digest(dataset.as_bytes())[..4].try_into().unwrap()) + as f64 + % (7.0 * 86400.0); + let incremental = old.as_ref().is_some_and(|m| { + m["snapshot"].is_string() + && existing.iter().any(|s| s == string(&m["snapshot"])) + && failures < 3 + && ((now() - offset) / (7.0 * 86400.0)).floor() + == ((number(&m["scanned"]) - offset) / (7.0 * 86400.0)).floor() + }); + let changes = if incremental { + let previous = string(&old.as_ref().unwrap()["snapshot"]); + let diff = crate::host::call(json!({"operation":"index.diff","dataset":dataset, + "from":previous.split_once('@').map(|(_, name)| name).unwrap_or_default(), + "to":snapshot.split_once('@').map(|(_, name)| name).unwrap_or_default()})) + .await?; + Some(parse_diff(string(&diff), mount)?) + } else { + None + }; + let _slot = app.heavy.acquire().await?; + let frozen = PathBuf::from(mount) + .join(".zfs/snapshot") + .join(snapshot.split_once('@').unwrap().1); + let current = snapshot.clone(); + tokio::task::spawn_blocking(move || { + if let Some(changes) = changes { + apply(&file, &frozen, &snapshot, &changes) + } else { + scan(&file, &frozen, Some(&snapshot)) + } + }) + .await??; + for previous in existing.iter().filter(|s| **s != current) { + let name = previous.split_once('@').unwrap().1; + if let Err(error) = crate::host::call( + json!({"operation":"index.discard","dataset":dataset,"snapshot":name}), + ) + .await + { + eprintln!("index snapshot {previous}: {}", error.message); + } + } + Ok(()) + } + pub fn changed(&self) { + self.wake.notify_one(); + } + pub fn is_scanning(&self) -> bool { + self.scanning.load(Ordering::Relaxed) + } +} +fn mounted_datasets(text: &str) -> HashSet<(String, String)> { + fn unescape(value: &str) -> String { + value + .replace("\\040", " ") + .replace("\\011", "\t") + .replace("\\012", "\n") + .replace("\\134", "\\") + } + text.lines() + .filter_map(|line| { + let (mount, filesystem) = line.split_once(" - ")?; + let mut fields = mount.split_whitespace(); + if fields.nth(3)? != "/" { + return None; + } + let target = fields.next()?; + let mut fields = filesystem.split_whitespace(); + if fields.next()? != "zfs" { + return None; + } + let source = fields.next()?; + (!source.contains('@')).then(|| (unescape(source), unescape(target))) + }) + .collect() +} +fn resolve(db: &Connection, rel: &str) -> Result>> { + let mut chain = vec![1]; + for name in rel.split('/').filter(|s| !s.is_empty()) { + let id = db + .query_row( + "SELECT id FROM file WHERE parent=? AND name=?", + params![chain.last().unwrap(), name], + |r| r.get::<_, i64>(0), + ) + .optional()?; + let Some(id) = id else { + return Ok(None); + }; + chain.push(id); + } + Ok(Some(chain)) +} +fn meta(db: &Connection) -> Result> { + Ok(db.query_row("SELECT snapshot,scanned,updated FROM meta",[],|r| Ok(json!({"snapshot":r.get::<_,Option>(0)?,"scanned":r.get::<_,i64>(1)?,"updated":r.get::<_,i64>(2)?}))).optional()?) +} +fn children(db: &Connection, rel: &str, limit: usize) -> Result> { + let Some(chain) = resolve(db, rel)? else { + return Ok(None); + }; + let id = chain.last().unwrap(); + 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)?})))?; + let mut query=db.prepare("SELECT name,dir,size,alloc,files,mtime FROM file WHERE parent=? ORDER BY size DESC,name LIMIT ?")?; + 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::,_>>()?; + value["entries"] = json!(rows); + Ok(Some(value)) +} +fn map(db: &Connection, rel: &str, min: f64) -> Result> { + let Some(chain) = resolve(db, rel)? else { + return Ok(None); + }; + let id = *chain.last().unwrap(); + 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")?; + let rows = query + .query_map(params![id, min], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, Option>(1)?, + r.get::<_, String>(2)?, + r.get::<_, bool>(3)?, + r.get::<_, i64>(4)?, + r.get::<_, i64>(5)?, + )) + })? + .collect::, _>>()?; + let nodes: HashMap<_, _> = rows + .iter() + .map(|(id, _, name, dir, size, files)| { + ( + *id, + if *dir { + json!([name, size, files, []]) + } else { + json!([name, size]) + }, + ) + }) + .collect(); + let mut children = HashMap::>::new(); + for (id, parent, ..) in rows { + if let Some(parent) = parent { + children.entry(parent).or_default().push(id); + } + } + fn assemble(id: i64, nodes: &HashMap, children: &HashMap>) -> Value { + let mut value = nodes[&id].clone(); + if value.as_array().unwrap().len() == 4 { + value[3] = json!( + children + .get(&id) + .into_iter() + .flatten() + .map(|id| assemble(*id, nodes, children)) + .collect::>() + ); + } + value + } + Ok(Some(assemble(id, &nodes, &children))) +} +fn scan(file: &std::path::Path, root: &std::path::Path, snapshot: Option<&str>) -> Result<()> { + let next = file.with_extension("db.new"); + let _ = std::fs::remove_file(&next); + let db = Connection::open(&next)?; + db.execute_batch(&format!( + "PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF;{TABLES} BEGIN;" + ))?; + let handle = std::fs::File::open(root)?; + let top = handle.metadata()?; + db.execute( + "INSERT INTO file VALUES(1,NULL,'',1,0,0,0,?)", + [top.mtime()], + )?; + fn walk( + db: &Connection, + id: i64, + root: &std::path::Path, + device: u64, + ) -> Result<(u64, u64, u64)> { + let mut sum = (0, 0, 0); + for entry in std::fs::read_dir(root)? { + let entry = entry?; + let info = std::fs::symlink_metadata(entry.path())?; + if info.is_dir() && info.dev() != device { + continue; + } + db.execute( + "INSERT INTO file(parent,name,dir,size,alloc,files,mtime) VALUES(?,?,?,?,?,?,?)", + params![ + id, + entry.file_name().to_string_lossy(), + info.is_dir(), + if info.is_dir() { 0 } else { info.len() }, + if info.is_dir() { + 0 + } else { + info.blocks() * 512 + }, + if info.is_dir() { 0 } else { 1 }, + info.mtime() + ], + )?; + let child = db.last_insert_rowid(); + let (size, alloc, files) = if info.is_dir() { + walk(db, child, &entry.path(), device)? + } else { + (info.len(), info.blocks() * 512, 1) + }; + if info.is_dir() { + db.execute( + "UPDATE file SET size=?,alloc=?,files=? WHERE id=?", + params![size, alloc, files, child], + )?; + } + sum.0 += size; + sum.1 += alloc; + sum.2 += files; + } + Ok(sum) + } + let (size, alloc, files) = walk(&db, 1, root, top.dev())?; + db.execute( + "UPDATE file SET size=?,alloc=?,files=? WHERE id=1", + params![size, alloc, files], + )?; + db.execute( + "INSERT INTO meta VALUES(?,?,?)", + params![snapshot, now() as u64, now() as u64], + )?; + db.execute_batch(&format!("{INDEXES} COMMIT;"))?; + db.close().map_err(|(_, e)| Error::from(e))?; + std::fs::rename(next, file)?; + Ok(()) +} +#[derive(Debug)] +struct Change { + kind: char, + path: String, + to: Option, +} +fn parse_diff(text: &str, mount: &str) -> Result> { + let mut changes = Vec::new(); + let mount = std::path::Path::new(mount); + for line in text.lines().filter(|s| !s.is_empty()) { + let fields: Vec<_> = line.split('\t').collect(); + if fields.len() < 3 || !["+", "-", "M", "R"].contains(&fields[0]) { + return Err(Error::new(500, format!("unexpected zfs diff line: {line}"))); + } + let relative = |s: &str| { + let path = crate::storage::unescape(s); + std::path::Path::new(&path) + .strip_prefix(mount) + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|_| { + Error::new( + 500, + format!("zfs diff path outside {}: {path}", mount.display()), + ) + }) + }; + let to = if fields[0] == "R" { + Some(relative( + fields + .get(3) + .ok_or_else(|| Error::new(500, "Invalid rename."))?, + )?) + } else { + None + }; + changes.push(Change { + kind: fields[0].chars().next().unwrap(), + path: relative(fields[2])?, + to, + }); + } + Ok(changes) +} +fn apply( + file: &std::path::Path, + root: &std::path::Path, + snapshot: &str, + changes: &[Change], +) -> Result<()> { + let db = Connection::open(file)?; + db.execute_batch("PRAGMA foreign_keys=ON;")?; + let targets: HashSet = changes + .iter() + .filter(|c| c.kind != '-') + .map(|c| c.to.as_ref().unwrap_or(&c.path).clone()) + .collect(); + let mut listings = HashMap::>>::new(); + let mut wanted = HashSet::new(); + for target in &targets { + let entries = match std::fs::read_dir(root.join(target)) { + Ok(dir) => Some(dir.collect::>>()?), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + None + } + Err(error) => return Err(error.into()), + }; + if let Some(entries) = &entries { + for entry in entries { + if !entry.file_type()?.is_dir() { + wanted.insert( + format!("{}/{}", target, entry.file_name().to_string_lossy()) + .trim_start_matches('/') + .to_owned(), + ); + } + } + } + listings.insert(target.clone(), entries); + let mut path = std::path::Path::new(target); + while !path.as_os_str().is_empty() { + wanted.insert(path.to_string_lossy().into_owned()); + path = path.parent().unwrap_or(std::path::Path::new("")); + } + } + let stats: HashMap<_, _> = wanted + .into_iter() + .map(|p| { + let info = match std::fs::symlink_metadata(root.join(&p)) { + Ok(info) => Some(info), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(Error::from(error)), + }; + Ok((p, info)) + }) + .collect::>()?; + let mut dirty = HashSet::::new(); + let moves: Vec<_> = changes + .iter() + .filter(|c| c.kind == 'R') + .map(|c| Ok((resolve(&db, &c.path)?, c.to.as_ref().unwrap().clone()))) + .collect::>()?; + let removed: Vec<_> = changes + .iter() + .filter(|c| c.kind == '-') + .map(|c| resolve(&db, &c.path)) + .collect::>()?; + let mut placed: Vec<_> = moves + .iter() + .map(|(chain, to)| (to.clone(), chain.as_ref().and_then(|c| c.last()).copied())) + .chain( + changes + .iter() + .filter(|c| c.kind == '+' || c.kind == 'M') + .map(|c| (c.path.clone(), None)), + ) + .collect(); + placed.sort_by_key(|(p, _)| p.split('/').count()); + 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"; + db.execute_batch("BEGIN;")?; + let result = || -> Result<()> { + for (chain, _) in &moves { + if let Some(chain) = chain { + dirty.extend(chain); + db.execute( + "UPDATE file SET parent=NULL WHERE id=?", + [chain.last().unwrap()], + )?; + } + } + for chain in removed.iter().flatten() { + dirty.extend(chain); + db.execute("DELETE FROM file WHERE id=?", [chain.last().unwrap()])?; + } + for (rel, id) in placed { + if rel.is_empty() { + continue; + } + let names: Vec<_> = rel.split('/').collect(); + let mut parent = 1i64; + for (i, name) in names.iter().enumerate() { + let Some(Some(info)) = stats.get(&names[..=i].join("/")) else { + break; + }; + if let Some(id) = id.filter(|_| i == names.len() - 1) { + db.execute( + "DELETE FROM file WHERE parent=? AND name=? AND id!=?", + params![parent, name, id], + )?; + db.execute( + "UPDATE file SET parent=?,name=? WHERE id=?", + params![parent, name, id], + )?; + } + dirty.insert(parent); + parent = db.query_row( + upsert, + params![ + parent, + name, + info.is_dir(), + if info.is_dir() { 0 } else { info.len() }, + if info.is_dir() { + 0 + } else { + info.blocks() * 512 + }, + if info.is_dir() { 0 } else { 1 }, + info.mtime() + ], + |r| r.get(0), + )?; + dirty.insert(parent); + } + } + for (rel, entries) in listings { + let Some(entries) = entries else { + continue; + }; + let Some(chain) = resolve(&db, &rel)? else { + continue; + }; + dirty.extend(&chain); + let parent = *chain.last().unwrap(); + let present: HashSet<_> = entries + .iter() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + let mut query = db.prepare("SELECT id,name FROM file WHERE parent=?")?; + let children = query + .query_map([parent], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + for (id, name) in children { + if !present.contains(&name) { + db.execute("DELETE FROM file WHERE id=?", [id])?; + } + } + for entry in entries { + let name = entry.file_name().to_string_lossy().into_owned(); + let path = if rel.is_empty() { + name.clone() + } else { + format!("{rel}/{name}") + }; + let Some(Some(info)) = stats.get(&path) else { + continue; + }; + if info.is_dir() { + continue; + } + let _: i64 = db.query_row( + upsert, + params![ + parent, + name, + false, + info.len(), + info.blocks() * 512, + 1, + info.mtime() + ], + |r| r.get(0), + )?; + } + } + db.execute("DELETE FROM file WHERE parent IS NULL AND id!=1", [])?; + let mut order = Vec::new(); + for id in dirty { + 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))?; + order.push((id, depth)); + } + order.sort_by_key(|(_, depth)| std::cmp::Reverse(*depth)); + for (id, _) in order { + 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])?; + } + db.execute( + "UPDATE meta SET snapshot=?,updated=?", + params![snapshot, now() as u64], + )?; + Ok(()) + }(); + if result.is_ok() { + db.execute_batch("COMMIT;")?; + } else { + db.execute_batch("ROLLBACK;")?; + } + result +} + +pub async fn route(app: Arc, kind: &str, query: &HashMap) -> Result { + let index = app + .index + .as_ref() + .ok_or_else(|| { + Error::new( + 501, + "The file index is off. Enable it on this home server, then retry.", + ) + })? + .clone(); + let _slot = app.heavy.acquire().await?; + let root = env("STUDIO_STORE_ROOT", "/srv"); + let rel = crate::files::relative( + query.get("path").map(String::as_str).unwrap_or_default(), + false, + )?; + let largest = kind == "largest"; + let (width, height) = if largest { + (1.0, 1.0) + } else { + ( + telemetry::query_number(query, "width", 0.0, 1.0, 8192.0)?, + telemetry::query_number(query, "height", 0.0, 1.0, 8192.0)?, + ) + }; + let value=tokio::task::spawn_blocking(move || { + 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);} + 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(); + Ok(json!({"root":root,"min":min,"scanning":index.is_scanning(),"tree":if total.is_some() {index.map(&rel,min)?} else {None}})) + }).await??; + Ok(Document::new(value).response()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn reads_recover_interrupted_updates() { + if let Ok(file) = std::env::var("STUDIO_INDEX_CRASH_TEST_DB") { + let db = Connection::open(file).unwrap(); + db.execute_batch("PRAGMA cache_size=1; BEGIN IMMEDIATE; UPDATE file SET size=99;") + .unwrap(); + std::process::exit(0); + } + let dir = std::env::temp_dir().join(format!("snowglobe-index-{}", uuid::Uuid::new_v4())); + let root = dir.join("tree"); + fs::create_dir_all(&root).unwrap(); + for n in 0..512 { + fs::write(root.join(format!("{n}-{}", "x".repeat(150))), [0]).unwrap(); + } + let index = Index::new("tank".into(), dir.clone()); + index.active.lock().unwrap().insert("tank".into()); + let file = index.file("tank"); + scan(&file, &root, Some("tank@1")).unwrap(); + let thread = std::thread::current(); + assert!( + std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", thread.name().unwrap()]) + .env("STUDIO_INDEX_CRASH_TEST_DB", &file) + .status() + .unwrap() + .success() + ); + let journal = fs::read(file.with_extension("db-journal")).unwrap(); + assert!(journal.len() > 512 && journal[..8].iter().any(|byte| *byte != 0)); + let tree = index.children("", 0).unwrap().unwrap(); + assert_eq!(tree["size"], 512); + assert_eq!(tree["files"], 512); + fs::remove_dir_all(dir).unwrap(); + } + + fn dump(file: &std::path::Path) -> Vec { + let db = Connection::open(file).unwrap(); + 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(); + query + .query_map([], |r| { + Ok(json!([ + r.get::<_, String>(0)?, + r.get::<_, bool>(1)?, + r.get::<_, i64>(2)?, + r.get::<_, i64>(3)?, + r.get::<_, i64>(4)?, + r.get::<_, i64>(5)? + ])) + }) + .unwrap() + .map(|r| r.unwrap()) + .collect() + } + #[test] + fn incremental_changes_match_fresh_scans_in_any_order() { + let cases: Value = serde_json::from_str(include_str!("../tests/index-cases.json")).unwrap(); + for case in array(&cases) { + for seed in 0..6u64 { + let dir = + std::env::temp_dir().join(format!("snowglobe-index-{}", uuid::Uuid::new_v4())); + let root = dir.join("tree"); + fs::create_dir_all(&root).unwrap(); + for (name, value) in case["before"].as_object().unwrap() { + let path = root.join(name); + fs::create_dir_all(if name.ends_with('/') { + path.as_path() + } else { + path.parent().unwrap() + }) + .unwrap(); + if let Some(link) = value.as_str() { + fs::hard_link(root.join(link), path).unwrap(); + } else if !name.ends_with('/') { + fs::write(path, vec![0; number(value) as usize]).unwrap(); + } + } + let old = dir.join("old.db"); + let fresh = dir.join("fresh.db"); + scan(&old, &root, Some("t@1")).unwrap(); + for op in array(&case["ops"]) { + let from = root.join(string(&op[1])); + match string(&op[0]) { + "renameSync" => fs::rename(from, root.join(string(&op[2]))).unwrap(), + "rmSync" => { + if from.is_dir() { + fs::remove_dir_all(from).unwrap() + } else { + fs::remove_file(from).unwrap() + } + } + "mkdirSync" => fs::create_dir_all(from).unwrap(), + "writeFileSync" => { + fs::write(from, vec![0; number(&op[2]) as usize]).unwrap() + } + "linkSync" => fs::hard_link(from, root.join(string(&op[2]))).unwrap(), + other => panic!("unknown fixture operation {other}"), + } + } + let mut lines = array(&case["diff"]).to_vec(); + let mut state = seed; + if seed > 0 { + for i in (1..lines.len()).rev() { + state = (state * 1103515245 + 12345) % 2147483648; + lines.swap(i, state as usize % (i + 1)); + } + } + let diff = lines.iter().map(string).collect::>().join("\n"); + apply(&old, &root, "t@2", &parse_diff(&diff, "/t").unwrap()).unwrap(); + scan(&fresh, &root, None).unwrap(); + assert_eq!( + dump(&old), + dump(&fresh), + "{} seed {seed}: {diff}", + string(&case["name"]) + ); + assert_eq!( + meta(&Connection::open(&old).unwrap()).unwrap().unwrap()["snapshot"], + "t@2" + ); + fs::remove_dir_all(dir).unwrap(); + } + } + } + #[test] + fn diff_unescapes_names_and_rejects_paths_outside_mount() { + 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(); + assert_eq!(changes[0].path, "café notes"); + assert_eq!(changes[1].path, "a\\b"); + assert_eq!(changes[1].to.as_deref(), Some("tab\tname")); + assert!(parse_diff("+\tF\t/other/x", "/tank/r").is_err()); + assert!(parse_diff("1789\t+\tF\t/tank/r/x", "/tank/r").is_err()); + } + #[test] + fn namespace_mounts_exclude_subdirectory_binds_and_snapshots() { + 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"; + assert_eq!( + mounted_datasets(text), + HashSet::from([ + ("tank/clover".into(), "/srv/clover".into()), + ("tank/media files".into(), "/srv/clover/Media Files".into()), + ]) + ); + } + #[test] + fn mounted_datasets_replace_shadowed_totals() { + let dir = std::env::temp_dir().join(format!("snowglobe-index-{}", uuid::Uuid::new_v4())); + let root = dir.join("tree"); + let nested = dir.join("nested"); + let dbs = dir.join("dbs"); + fs::create_dir_all(root.join("media")).unwrap(); + fs::create_dir_all(&nested).unwrap(); + fs::create_dir_all(&dbs).unwrap(); + fs::write(root.join("media/shadow"), [0; 100]).unwrap(); + fs::write(root.join("other"), [0; 10]).unwrap(); + fs::write(nested.join("film"), [0; 500]).unwrap(); + let index = Index::new("tank".into(), dbs); + index + .active + .lock() + .unwrap() + .extend(["tank".into(), "tank/media".into()]); + scan(&index.file("tank"), &root, None).unwrap(); + scan(&index.file("tank/media"), &nested, None).unwrap(); + let top = index.children("", 10).unwrap().unwrap(); + assert_eq!(number(&top["size"]), 510.0); + assert_eq!(number(&top["files"]), 2.0); + assert_eq!(number(&index.map("", 50.0).unwrap().unwrap()[1]), 510.0); + index.active.lock().unwrap().remove("tank"); + assert_eq!( + number(&index.children("", 10).unwrap().unwrap()["size"]), + 500.0 + ); + assert_eq!(number(&index.map("", 50.0).unwrap().unwrap()[1]), 500.0); + assert!(index.children("other", 10).unwrap().is_none()); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..8bdc8c1f5fb8fae3a52e44ae7b9a133681206bf7 --- /dev/null +++ b/dashboard/src/main.rs @@ -0,0 +1,539 @@ +mod apps; +mod cache; +mod core; +mod deploys; +mod files; +mod host; +mod index; +mod mcp; +mod observability; +mod relay; +mod shale; +mod storage; +mod telemetry; +mod users; +mod youtube; + +use axum::{ + Router, + body::Bytes, + extract::{Request, State}, + http::{HeaderMap, Method, StatusCode}, + response::{IntoResponse, Response, Sse, sse::Event}, + routing::any, +}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + path::PathBuf, + sync::{Arc, Mutex}, + time::Duration, +}; +use subtle::ConstantTimeEq; +use tokio::sync::{Semaphore, watch}; +use tokio_stream::{StreamExt, wrappers::WatchStream}; +use tower_http::services::{ServeDir, ServeFile}; + +type Result = std::result::Result; + +#[derive(Clone, Debug)] +struct Error { + status: u16, + message: String, +} +impl Error { + fn new(status: u16, message: impl Into) -> Self { + Self { + status, + message: message.into(), + } + } +} +impl From for Error { + fn from(error: E) -> Self { + Self::new(500, error.to_string()) + } +} +impl IntoResponse for Error { + fn into_response(self) -> Response { + if self.status >= 500 { + eprintln!("{}: {}", self.status, self.message); + } + ( + StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + self.message, + ) + .into_response() + } +} + +struct Document { + value: Value, + bytes: Bytes, +} +impl Document { + fn new(value: Value) -> Self { + let bytes = Bytes::from(serde_json::to_vec(&value).unwrap()); + Self { value, bytes } + } + fn response(&self) -> Response { + ([("content-type", "application/json")], self.bytes.clone()).into_response() + } +} + +struct App { + mcp: mcp::Store, + relay: relay::Broker, + shale: shale::Backend, + http: reqwest::Client, + internal: Option<(url::Url, reqwest::Client)>, + cache: cache::Cache, + nomad_slots: Semaphore, + specs: Mutex>, + repo: PathBuf, + data: PathBuf, + live: watch::Sender, + usage: Mutex>, + vm_usage: Mutex>, + seed_samples: Mutex>, + youtube: std::sync::OnceLock, + heavy: Semaphore, + file_changes: tokio::sync::Mutex<()>, + index: Option>, +} +impl App { + fn request(&self, method: Method, address: &str) -> Result { + let url = url::Url::parse(address)?; + let client = match &self.internal { + Some((base, client)) if url.origin() == base.origin() => client, + _ => &self.http, + }; + Ok(client.request(method, url)) + } +} + +fn env(name: &str, default: &str) -> String { + std::env::var(name).unwrap_or_else(|_| default.into()) +} +fn array(value: &Value) -> &[Value] { + value.as_array().map(Vec::as_slice).unwrap_or_default() +} +fn string(value: &Value) -> &str { + value.as_str().unwrap_or_default() +} +fn number(value: &Value) -> f64 { + value + .as_f64() + .or_else(|| value.as_str().and_then(|s| s.parse().ok())) + .unwrap_or(0.0) +} +fn now() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs_f64() +} +fn encoded(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} +fn params(items: &[(&str, String)]) -> String { + url::form_urlencoded::Serializer::new(String::new()) + .extend_pairs(items.iter().map(|(k, v)| (*k, v))) + .finish() +} + +fn user(headers: &HeaderMap) -> Result { + 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."))?; + let groups: Vec<&str> = headers + .get("User-Groups") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .split(|c: char| c == ',' || c.is_whitespace()) + .filter(|s| !s.is_empty()) + .map(|s| s.strip_prefix("role:").unwrap_or(s)) + .collect(); + let preview = headers + .get("cookie") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .split(';') + .find_map(|s| s.trim().strip_prefix("view-as=")); + let viewing = preview.is_some() && groups.contains(&"infra-admin"); + let groups = if viewing { + preview + .unwrap() + .split(',') + .filter(|s| !s.is_empty() && *s != "infra-admin") + .collect() + } else { + groups + }; + let sections: Vec<&str> = [ + ("launcher", None), + ("admin", Some("infra-admin")), + ("metrics", Some("metrics")), + ("media", Some("media-manage")), + ("vms", Some("vm")), + ] + .into_iter() + .filter(|(_, group)| can_open(&groups, *group)) + .map(|(section, _)| section) + .collect(); + Ok(json!({"name":name,"groups":groups,"sections":sections,"viewing":viewing})) +} +fn can_open(groups: &[&str], access: Option<&str>) -> bool { + access.is_none() || groups.contains(&"infra-admin") || groups.contains(&access.unwrap()) +} +fn need(user: &Value, section: &str) -> Result<()> { + if array(&user["sections"]).iter().any(|s| s == section) { + Ok(()) + } else { + Err(Error::new( + 403, + "Your account can't open this section. Ask an admin to add you to its group.", + )) + } +} + +async fn command(program: &str, args: &[&str], input: Option<&[u8]>) -> Result> { + use std::process::Stdio; + use tokio::io::AsyncWriteExt; + let mut child = tokio::process::Command::new(program) + .args(args) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + if let Some(input) = input { + child.stdin.take().unwrap().write_all(input).await?; + } + let output = tokio::time::timeout(Duration::from_secs(120), child.wait_with_output()) + .await + .map_err(|_| { + Error::new( + 504, + "The operation is taking too long. Check its logs, then retry.", + ) + })??; + if !output.status.success() { + return Err(Error::new( + 502, + String::from_utf8_lossy(&output.stderr).trim(), + )); + } + Ok(output.stdout) +} + +async fn api(State(app): State>, request: Request) -> Result { + let path = request + .uri() + .path() + .trim_start_matches("/api/") + .trim_end_matches('/') + .to_owned(); + let method = request.method().clone(); + let query: HashMap = + url::form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes()) + .into_owned() + .collect(); + let parts: Vec<&str> = path.split('/').collect(); + if parts.first() == Some(&"icons") && method == Method::GET { + return core::icon(&app, &parts, &query).await; + } + if parts.starts_with(&["account", "pictures"]) && method == Method::GET { + return users::picture(&app, &parts).await; + } + let me = user(request.headers())?; + let headers = request.headers().clone(); + let section = match parts.first().copied().unwrap_or_default() { + "host" | "metrics" | "live" => Some("metrics"), + "services" if parts.len() == 1 => Some("metrics"), + "storage" if parts.len() == 1 => Some("metrics"), + "services" | "traces" | "storage" | "users" | "deploys" | "paper-clover" => Some("admin"), + "media" | "seedbox" | "youtube" => Some("media"), + "vms" => Some("vms"), + _ => None, + }; + if let Some(section) = section { + need(&me, section)?; + } + if let ["deploys", "runs", id] = parts.as_slice() { + return deploys::run_stream(app, id).await; + } + if parts.first() == Some(&"account") { + return users::account(app, request, &parts[1..], &me).await; + } + if path == "live" && method == Method::GET { + let stream = WatchStream::new(app.live.subscribe()) + .filter(|data| !data.is_empty()) + .map(|data| { + Ok::<_, std::convert::Infallible>( + Event::default().data(String::from_utf8_lossy(&data)), + ) + }); + return Ok(Sse::new(stream).into_response()); + } + let body = axum::body::to_bytes(request.into_body(), 8 * 1024 * 1024).await?; + let value = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(&body) + .map_err(|_| Error::new(400, "The request didn't match what this route expects."))? + }; + match parts[0] { + "mcp" => mcp::manage(app, &method, &parts[1..], &me, value, &headers).await, + "users" => users::route(app, &method, &parts[1..], &me, value).await, + "vms" | "seedbox" | "paper-clover" => { + apps::route(app, &method, &parts, &query, value).await + } + "youtube" => youtube::route(app, &method, &parts[1..], value).await, + "media" => files::route(app, true, &method, &parts[1..], &query, value).await, + "storage" if parts.get(1) == Some(&"files") => { + if parts.len() == 3 && ["map", "largest"].contains(&parts[2]) { + index::route(app, parts[2], &query).await + } else { + files::route(app, false, &method, &parts[2..], &query, value).await + } + } + "storage" => storage::route(app, &method, &parts[1..], &query, value).await, + "deploys" => deploys::route(app, &method, &parts[1..], &query).await, + _ => core::route(app, &method, &parts, &query, &me, value).await, + } +} + +#[tokio::main(worker_threads = 4)] +async fn main() -> std::result::Result<(), Box> { + let address: std::net::IpAddr = env("STUDIO_LISTEN_ADDRESS", "127.0.0.1").parse()?; + let proof = match std::env::var_os("STUDIO_PROXY_TOKEN_FILE") { + Some(file) => { + let token = std::fs::read_to_string(file)?.trim().to_owned(); + if token.len() != 64 || !token.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(std::io::Error::other( + "The dashboard proxy token must contain 64 hexadecimal characters.", + ) + .into()); + } + Some(Arc::::from(token)) + } + None if address.is_loopback() => None, + None => { + return Err(std::io::Error::other( + "Set STUDIO_PROXY_TOKEN_FILE before listening on a non-loopback address.", + ) + .into()); + } + }; + let certificates = if let Ok(path) = + std::env::var("STUDIO_CA_BUNDLE").or_else(|_| std::env::var("NODE_EXTRA_CA_CERTS")) + { + reqwest::Certificate::from_pem_bundle(&std::fs::read(path)?)? + } else { + Vec::new() + }; + let client = || { + let mut http = reqwest::Client::builder().timeout(Duration::from_secs(15)); + for certificate in &certificates { + http = http.add_root_certificate(certificate.clone()); + } + http + }; + let internal = std::env::var("STUDIO_INTERNAL_URL") + .ok() + .map( + |address| -> std::result::Result<_, Box> { + let base = url::Url::parse(&address)?; + if base.scheme() != "https" + || base.host_str().is_none() + || !base.username().is_empty() + || base.password().is_some() + || base.path() != "/" + || base.query().is_some() + || base.fragment().is_some() + { + return Err(std::io::Error::other( + "STUDIO_INTERNAL_URL must be an HTTPS origin.", + ) + .into()); + } + let token = proof.as_ref().ok_or_else(|| { + std::io::Error::other("STUDIO_INTERNAL_URL requires STUDIO_PROXY_TOKEN_FILE.") + })?; + let mut value = axum::http::HeaderValue::from_str(token)?; + value.set_sensitive(true); + let mut headers = HeaderMap::new(); + headers.insert("Studio-Proxy-Token", value); + Ok(( + base, + client() + .default_headers(headers) + .redirect(reqwest::redirect::Policy::none()) + .build()?, + )) + }, + ) + .transpose()?; + let (live, _) = watch::channel(Bytes::new()); + let index = std::env::var("STUDIO_INDEX_POOL").ok().map(|pool| { + Arc::new(index::Index::new( + pool, + env("STUDIO_INDEX_DIR", ".cache/index").into(), + )) + }); + let origin = env( + "STUDIO_PUBLIC_ORIGIN", + &format!("https://globe.{}", env("STUDIO_DOMAIN", "studio.test")), + ); + let app = Arc::new(App { + mcp: mcp::Store::new(&PathBuf::from(env("STUDIO_DATA_DIR", "data")), &origin) + .map_err(|error| std::io::Error::other(error.message))?, + relay: relay::Broker::default(), + shale: shale::Backend::new( + &env( + "STUDIO_SHALE_URL", + &format!("https://shale.{}", env("STUDIO_DOMAIN", "studio.test")), + ), + client(), + ) + .map_err(|error| std::io::Error::other(error.message))?, + http: client().build()?, + internal, + cache: cache::Cache::default(), + nomad_slots: Semaphore::new(4), + specs: Mutex::new(HashMap::new()), + repo: env("STUDIO_REPO", "..").into(), + data: env("STUDIO_DATA_DIR", "data").into(), + live, + usage: Mutex::new(HashMap::new()), + vm_usage: Mutex::new(HashMap::new()), + seed_samples: Mutex::new(Vec::new()), + youtube: std::sync::OnceLock::new(), + heavy: Semaphore::new(4), + file_changes: tokio::sync::Mutex::new(()), + index, + }); + if let Some(index) = app.index.clone() { + let state = app.clone(); + tokio::spawn(async move { + index.start(state).await; + }); + } + if std::env::var("STUDIO_YT_STATE").is_ok() { + for parts in [&[][..], &["library"][..]] { + let state = app.clone(); + tokio::spawn(async move { + if let Err(error) = youtube::route(state, &Method::GET, parts, Value::Null).await { + eprintln!("youtube: {}", error.message); + } + }); + } + } + core::start(app.clone()); + telemetry::start(app.clone()); + let dist = env("STUDIO_WEB_DIR", "dist"); + let router = Router::new() + .route("/api/{*path}", any(api)) + .route("/oauth/{*path}", any(mcp::oauth)) + .route("/.well-known/{*path}", any(mcp::oauth)) + .nest_service("/assets", ServeDir::new(format!("{dist}/assets"))) + .with_state(app.clone()) + .merge(observability::router(app.clone())) + .merge(shale::router(app.clone())) + .merge(relay::router(app)) + .fallback_service( + ServeDir::new(&dist).fallback(ServeFile::new(format!("{dist}/index.html"))), + ) + .layer(axum::middleware::from_fn( + move |mut request: Request, next: axum::middleware::Next| { + let proof = proof.clone(); + async move { + if mcp::public(request.uri().path()) { + request.headers_mut().remove("Studio-Proxy-Token"); + request.headers_mut().remove("User-Name"); + request.headers_mut().remove("User-Groups"); + } else if let Some(proof) = proof { + let supplied = request + .headers() + .get("Studio-Proxy-Token") + .map(|value| value.as_bytes()) + .unwrap_or_default(); + if supplied.ct_eq(proof.as_bytes()).unwrap_u8() == 0 { + return Error::new(403, "Open the dashboard through its sign-in page.") + .into_response(); + } + request.headers_mut().remove("Studio-Proxy-Token"); + } + let asset = request.uri().path().starts_with("/assets/"); + let document = !asset && !request.uri().path().starts_with("/api/"); + if document { + request.headers_mut().remove("if-modified-since"); + request.headers_mut().remove("if-none-match"); + } + let mut response = next.run(request).await; + if asset && response.status().is_success() { + response.headers_mut().insert( + "cache-control", + "public, max-age=31536000, immutable".parse().unwrap(), + ); + } else if document { + response + .headers_mut() + .insert("cache-control", "no-store".parse().unwrap()); + } + response + } + }, + )); + let listener = tokio::net::TcpListener::bind(std::net::SocketAddr::new( + address, + env("PORT", "7070").parse()?, + )) + .await?; + println!("dashboard on {}", listener.local_addr()?); + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + let (shutdown, waiting) = tokio::sync::oneshot::channel::<()>(); + let mut server = Box::pin(std::future::IntoFuture::into_future( + axum::serve(listener, router).with_graceful_shutdown(async { + let _ = waiting.await; + }), + )); + tokio::select! { + result = &mut server => { result?; return Ok(()); }, + _ = terminate.recv() => {}, + _ = tokio::signal::ctrl_c() => {}, + } + let _ = shutdown.send(()); + if let Ok(result) = tokio::time::timeout(Duration::from_secs(5), server).await { + result?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_an_admin_can_preview_groups_and_preview_cannot_grant_admin() { + let mut headers = HeaderMap::new(); + assert_eq!(user(&headers).unwrap_err().status, 401); + headers.insert("User-Name", "snow".parse().unwrap()); + headers.insert("User-Groups", "role:metrics,media-manage".parse().unwrap()); + headers.insert("cookie", "view-as=infra-admin".parse().unwrap()); + let me = user(&headers).unwrap(); + assert_eq!(me["viewing"], false); + assert!(need(&me, "admin").is_err()); + assert!(need(&me, "metrics").is_ok()); + headers.insert("User-Groups", "infra-admin".parse().unwrap()); + let me = user(&headers).unwrap(); + assert_eq!(me["viewing"], true); + assert!(need(&me, "admin").is_err()); + headers.insert("cookie", "view-as=metrics".parse().unwrap()); + let me = user(&headers).unwrap(); + assert!(need(&me, "metrics").is_ok()); + assert!(need(&me, "media").is_err()); + } +} diff --git a/dashboard/src/mcp.rs b/dashboard/src/mcp.rs new file mode 100644 index 0000000000000000000000000000000000000000..703cb7624271ff0e719f92ee8b57ef9ec9a3ac7a --- /dev/null +++ b/dashboard/src/mcp.rs @@ -0,0 +1,1224 @@ +use crate::*; +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use sha2::{Digest, Sha256}; +use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + +const CATALOGS: &[(&str, &str, &[&str])] = &[ + ( + "observability", + "Logs and traces", + &["observability:read", "offline_access"], + ), + ( + "agents", + "Local agents", + &["sessions:read", "sessions:write", "offline_access"], + ), + ( + "shale", + "Shale", + &["shale:read", "shale:write", "offline_access"], + ), +]; + +pub struct Store { + pub(crate) db: Mutex, + pub origin: url::Url, +} +pub(crate) fn secret() -> String { + URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()) +} +pub(crate) fn hash(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) +} +pub(crate) fn get(db: &Connection, key: &str) -> Result { + let row: Option = db + .query_row( + "SELECT value FROM records WHERE key=? AND (expires=0 OR expires>?)", + rusqlite::params![key, now() as i64], + |r| r.get(0), + ) + .optional()?; + Ok(row + .map(|s| serde_json::from_str(&s)) + .transpose()? + .unwrap_or(Value::Null)) +} +pub(crate) fn put(db: &Connection, key: &str, value: &Value, ttl: i64) -> Result<()> { + db.execute( + "DELETE FROM records WHERE expires>0 AND expires<=?", + [now() as i64], + )?; + let count: i64 = db.query_row("SELECT count(*) FROM records", [], |r| r.get(0))?; + if count >= 16384 && get(db, key)?.is_null() { + return Err(Error::new( + 503, + "The connection store is full. Remove an unused connection and retry.", + )); + } + db.execute( + "INSERT OR REPLACE INTO records VALUES (?,?,?)", + rusqlite::params![ + key, + value.to_string(), + if ttl == 0 { 0 } else { now() as i64 + ttl } + ], + )?; + Ok(()) +} +pub(crate) fn delete(db: &Connection, key: &str) -> Result<()> { + db.execute("DELETE FROM records WHERE key=?", [key])?; + Ok(()) +} +pub(crate) fn revoke(db: &Connection, grant: &str) -> Result<()> { + db.execute( + "DELETE FROM records WHERE key=? OR json_extract(value,'$.grant')=?", + rusqlite::params![format!("grant:{grant}"), grant], + )?; + Ok(()) +} +pub(crate) fn list(db: &Connection, prefix: &str) -> Result> { + let mut query = db.prepare( + "SELECT value FROM records WHERE substr(key,1,?)=? AND (expires=0 OR expires>?)", + )?; + let rows = query.query_map(rusqlite::params![prefix.len(), prefix, now() as i64], |r| { + r.get::<_, String>(0) + })?; + rows.map(|r| Ok(serde_json::from_str(&r?)?)).collect() +} +fn fail(code: &str) -> Error { + Error::new(400, code) +} +fn redirect(value: &str) -> Result { + let url = url::Url::parse(value).map_err(|_| fail("invalid_redirect_uri"))?; + let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + if value.len() > 4096 + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || url.host_str().is_none() + || !(url.scheme() == "https" || url.scheme() == "http" && loopback) + { + return Err(fail("invalid_redirect_uri")); + } + Ok(url) +} +impl Store { + pub fn new(data: &std::path::Path, origin: &str) -> Result { + let origin = url::Url::parse(origin)?; + if origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + || !origin.username().is_empty() + || origin.password().is_some() + || origin.scheme() != "https" + && !(origin.scheme() == "http" + && matches!(origin.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))) + { + return Err(fail("Set an HTTPS origin for MCP connections.")); + } + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(data)?; + let path = data.join("connections.sqlite"); + let db = Connection::open_with_flags( + &path, + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NOFOLLOW, + )?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + 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);")?; + Ok(Self { + db: Mutex::new(db), + origin, + }) + } + pub fn resource(&self, catalog: &str) -> String { + self.origin + .join(&format!("mcp/{catalog}")) + .unwrap() + .to_string() + } + fn client( + &self, + db: &Connection, + input: &HashMap, + headers: &HeaderMap, + ) -> Result { + let basic = headers.get("authorization").and_then(|v| v.to_str().ok()); + let (id, credential) = if let Some(basic) = basic { + if input.contains_key("client_secret") { + return Err(fail("invalid_client")); + } + let bytes = STANDARD + .decode( + basic + .strip_prefix("Basic ") + .ok_or_else(|| fail("invalid_client"))?, + ) + .map_err(|_| fail("invalid_client"))?; + let value = String::from_utf8(bytes).map_err(|_| fail("invalid_client"))?; + let (id, credential) = value + .split_once(':') + .ok_or_else(|| fail("invalid_client"))?; + (id.to_owned(), Some(credential.to_owned())) + } else { + ( + input.get("client_id").cloned().unwrap_or_default(), + input.get("client_secret").cloned(), + ) + }; + if input.get("client_id").is_some_and(|v| v != &id) { + return Err(fail("invalid_client")); + } + let client = get(db, &format!("client:{id}"))?; + let method = string(&client["token_endpoint_auth_method"]); + let valid = match method { + "none" => basic.is_none() && credential.is_none(), + "client_secret_basic" => { + basic.is_some() + && credential.as_ref().is_some_and(|v| { + hash(v) + .as_bytes() + .ct_eq(string(&client["secret_hash"]).as_bytes()) + .unwrap_u8() + == 1 + }) + } + "client_secret_post" => { + basic.is_none() + && credential.as_ref().is_some_and(|v| { + hash(v) + .as_bytes() + .ct_eq(string(&client["secret_hash"]).as_bytes()) + .unwrap_u8() + == 1 + }) + } + _ => false, + }; + if valid { + Ok(client) + } else { + Err(fail("invalid_client")) + } + } + fn issue(&self, db: &Connection, grant: &Value) -> Result { + let access = secret(); + put( + db, + &format!("access:{}", hash(&access)), + &json!({"grant":grant["id"],"resource":grant["resource"]}), + 3600, + )?; + let mut response = json!({"access_token":access,"token_type":"Bearer","expires_in":3600,"scope":array(&grant["scopes"]).iter().map(string).collect::>().join(" ")}); + if array(&grant["scopes"]) + .iter() + .any(|s| s == "offline_access") + { + let refresh = secret(); + put( + db, + &format!("refresh:{}", hash(&refresh)), + &json!({"grant":grant["id"]}), + 30 * 86400, + )?; + response["refresh_token"] = json!(refresh); + } + Ok(response) + } + pub fn authenticate(&self, headers: &HeaderMap, resource: &str) -> Result { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .filter(|s| !s.is_empty() && s.len() <= 256) + .ok_or_else(|| Error::new(401, "invalid_token"))?; + let db = self.db.lock().unwrap(); + let access = get(&db, &format!("access:{}", hash(token)))?; + let grant = get(&db, &format!("grant:{}", string(&access["grant"])))?; + if grant.is_null() || access["resource"] != resource || grant["resource"] != resource { + return Err(Error::new(401, "invalid_token")); + } + Ok(grant) + } + fn exchange( + &self, + db: &Connection, + input: &HashMap, + headers: &HeaderMap, + ) -> Result<(String, Value)> { + let client = self.client(db, input, headers)?; + let (key, grant) = match input.get("grant_type").map(String::as_str) { + Some("authorization_code") => { + let key = format!( + "code:{}", + hash(input.get("code").map(String::as_str).unwrap_or_default()) + ); + let code = get(db, &key)?; + let verifier = input + .get("code_verifier") + .map(String::as_str) + .unwrap_or_default(); + if code.is_null() + || code["client"] != client["client_id"] + || input.get("redirect_uri").map(String::as_str) != code["redirect"].as_str() + || !(43..=128).contains(&verifier.len()) + || !verifier + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-._~".contains(&b)) + || URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) + != string(&code["challenge"]) + { + return Err(fail("invalid_grant")); + } + if input + .get("resource") + .is_some_and(|r| code["resource"] != r.as_str()) + { + return Err(fail("invalid_target")); + } + let grant = get(db, &format!("grant:{}", string(&code["grant"])))?; + if grant.is_null() { + return Err(fail("invalid_grant")); + } + (key, grant) + } + Some("refresh_token") => { + let fingerprint = hash( + input + .get("refresh_token") + .map(String::as_str) + .unwrap_or_default(), + ); + let key = format!("refresh:{fingerprint}"); + let token = get(db, &key)?; + let used = get(db, &format!("used:{fingerprint}"))?; + if !used.is_null() { + let grant = get(db, &format!("grant:{}", string(&used["grant"])))?; + if grant["client"] == client["client_id"] { + revoke(db, string(&used["grant"]))?; + } + return Err(fail("invalid_grant")); + } + let grant = get(db, &format!("grant:{}", string(&token["grant"])))?; + if grant.is_null() || grant["client"] != client["client_id"] { + return Err(fail("invalid_grant")); + } + if input + .get("resource") + .is_some_and(|r| grant["resource"] != r.as_str()) + { + return Err(fail("invalid_target")); + } + if input.get("scope").is_some_and(|s| { + s.split_whitespace() + .collect::>() + != array(&grant["scopes"]).iter().map(string).collect() + }) { + return Err(fail("invalid_scope")); + } + (key, grant) + } + _ => return Err(fail("unsupported_grant_type")), + }; + Ok((key, grant)) + } + fn oauth( + &self, + path: &str, + method: &Method, + input: &HashMap, + headers: &HeaderMap, + body: Value, + ) -> Result { + let mut db = self.db.lock().unwrap(); + let tx = db.transaction()?; + let value = match (path, method) { + ("register", &Method::POST) => { + let name = body["client_name"] + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 128) + .ok_or_else(|| fail("invalid_client_metadata"))?; + let uris = body["redirect_uris"] + .as_array() + .filter(|a| !a.is_empty() && a.len() <= 8) + .ok_or_else(|| fail("invalid_client_metadata"))?; + for uri in uris { + redirect(uri.as_str().ok_or_else(|| fail("invalid_redirect_uri"))?)?; + } + let auth = body["token_endpoint_auth_method"] + .as_str() + .unwrap_or("none"); + if !["none", "client_secret_post", "client_secret_basic"].contains(&auth) + || body.get("grant_types").is_some_and(|v| { + v.as_array().is_none_or(|types| { + types.is_empty() + || types + .iter() + .any(|s| s != "authorization_code" && s != "refresh_token") + }) + }) + || body + .get("response_types") + .is_some_and(|v| v != &json!(["code"])) + { + return Err(fail("invalid_client_metadata")); + } + if list(&tx, "client:")?.len() >= 4096 { + return Err(Error::new(429, "too_many_clients")); + } + let id = uuid::Uuid::new_v4().to_string(); + let credential = secret(); + 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}); + if auth != "none" { + client["secret_hash"] = json!(hash(&credential)); + } + put(&tx, &format!("client:{id}"), &client, 0)?; + client.as_object_mut().unwrap().remove("secret_hash"); + if auth != "none" { + client["client_secret"] = json!(credential); + client["client_secret_expires_at"] = json!(0); + } + tx.commit()?; + return Ok((StatusCode::CREATED, axum::Json(client)).into_response()); + } + ("authorize", &Method::GET) => { + let id = input + .get("client_id") + .map(String::as_str) + .unwrap_or_default(); + let client = get(&tx, &format!("client:{id}"))?; + let uri = input + .get("redirect_uri") + .map(String::as_str) + .unwrap_or_default(); + let challenge = input + .get("code_challenge") + .map(String::as_str) + .unwrap_or_default(); + let resource = input + .get("resource") + .map(String::as_str) + .unwrap_or_default(); + let scopes_allowed = CATALOGS + .iter() + .find(|(id, _, _)| resource == self.resource(id)) + .map(|(_, _, scopes)| *scopes) + .ok_or_else(|| fail("invalid_target"))?; + let scopes: Vec<_> = input + .get("scope") + .map(String::as_str) + .unwrap_or(scopes_allowed[0]) + .split_whitespace() + .collect(); + if client.is_null() || !array(&client["redirect_uris"]).iter().any(|v| v == uri) { + return Err(fail("invalid_redirect_uri")); + } + if input.get("response_type").map(String::as_str) != Some("code") + || input.get("code_challenge_method").map(String::as_str) != Some("S256") + || challenge.len() != 43 + || !challenge + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err(fail("invalid_request")); + } + if !scopes.contains(&scopes_allowed[0]) + || scopes.iter().any(|s| !scopes_allowed.contains(s)) + { + return Err(fail("invalid_scope")); + } + let pending = secret(); + put( + &tx, + &format!("pending:{}", hash(&pending)), + &json!({"client":id,"redirect":uri,"challenge":challenge,"resource":resource,"scopes":scopes,"state":input.get("state"),"owner":null}), + 600, + )?; + tx.commit()?; + return Ok(( + StatusCode::FOUND, + [("location", format!("/mcp?request={}", encoded(&pending)))], + ) + .into_response()); + } + ("token", &Method::POST) => { + let (key, grant) = match self.exchange(&tx, input, headers) { + Ok(exchange) => exchange, + Err(error) => { + tx.commit()?; + return Err(error); + } + }; + delete(&tx, &key)?; + if let Some(fingerprint) = key.strip_prefix("refresh:") { + put( + &tx, + &format!("used:{fingerprint}"), + &json!({"grant":grant["id"]}), + 30 * 86400, + )?; + } + self.issue(&tx, &grant)? + } + ("revoke", &Method::POST) => { + let client = self.client(&tx, input, headers)?; + let fingerprint = hash(input.get("token").map(String::as_str).unwrap_or_default()); + for kind in ["access", "refresh", "used"] { + let token = get(&tx, &format!("{kind}:{fingerprint}"))?; + let grant = get(&tx, &format!("grant:{}", string(&token["grant"])))?; + if !grant.is_null() && grant["client"] == client["client_id"] { + revoke(&tx, string(&grant["id"]))?; + } + } + tx.commit()?; + return Ok(StatusCode::OK.into_response()); + } + _ => return Err(Error::new(404, "No endpoint here.")), + }; + tx.commit()?; + Ok(axum::Json(value).into_response()) + } +} + +#[derive(Clone)] +pub(crate) struct Grant(pub Value); +pub fn router( + app: Arc, + catalog: &str, + handler: impl Fn() -> std::result::Result + Send + Sync + 'static, +) -> Router { + use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }; + let resource = app.mcp.resource(catalog); + let metadata = format!( + "{}.well-known/oauth-protected-resource/mcp/{catalog}", + app.mcp.origin.as_str() + ); + let mut config = StreamableHttpServerConfig::default(); + config.legacy_session_mode = false; + config.json_response = true; + config.allowed_hosts = + vec![app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned()]; + config.allowed_origins = vec![format!( + "{}://{}:{}", + app.mcp.origin.scheme(), + app.mcp.origin.host_str().unwrap(), + app.mcp.origin.port_or_known_default().unwrap() + )]; + let service = + StreamableHttpService::new(handler, Arc::new(LocalSessionManager::default()), config); + Router::new() + .nest_service(&format!("/mcp/{catalog}"), service) + .layer(axum::middleware::from_fn( + move |mut request: Request, next: axum::middleware::Next| { + let (app, resource, metadata) = (app.clone(), resource.clone(), metadata.clone()); + async move { + if request.headers().get("origin").is_some_and(|origin| { + origin.to_str().ok() + != Some(app.mcp.origin.origin().ascii_serialization().as_str()) + }) { + return Error::new(403, "This origin cannot use the connector.") + .into_response(); + } + match app.mcp.authenticate(request.headers(), &resource) { + Ok(grant) => { + request.extensions_mut().insert(Grant(grant)); + if let Some(value) = request.headers_mut().get_mut("authorization") { + value.set_sensitive(true); + } + next.run(request).await + } + Err(_) => ( + StatusCode::UNAUTHORIZED, + [( + "www-authenticate", + format!("Bearer resource_metadata=\"{metadata}\""), + )], + "This connection expired. Connect again.", + ) + .into_response(), + } + } + }, + )) +} + +pub fn public(path: &str) -> bool { + path.starts_with("/oauth/") + || path.starts_with("/mcp/") + || path.starts_with("/.well-known/oauth-") + || path == "/pairing" + || path == "/agent/connect" + || path.starts_with("/api/v1/") +} +pub async fn oauth(State(app): State>, request: Request) -> Response { + if request.uri().path().starts_with("/oauth/shale/") { + return shale::oauth(app, request).await; + } + let result = async { + let path = request.uri().path().to_owned(); + if path=="/.well-known/oauth-authorization-server" && request.method()==Method::GET { + let base = app.mcp.origin.as_str(); + let scopes: std::collections::BTreeSet<_> = CATALOGS.iter().flat_map(|(_, _, scopes)| scopes.iter()).collect(); + 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()); + } + if request.method() == Method::GET { + for (catalog, _, scopes) in CATALOGS { + if path == format!("/.well-known/oauth-protected-resource/mcp/{catalog}") { + 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()); + } + } + } + let method = request.method().clone(); + let headers = request.headers().clone(); + let query = request.uri().query().unwrap_or_default().to_owned(); + let bytes = axum::body::to_bytes(request.into_body(),65536).await.map_err(|_| fail("invalid_request"))?; + let registration = path=="/oauth/register"; + let encoded = if method==Method::GET {query.as_bytes()} else {&bytes}; + 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")); } + let mut input = HashMap::new(); + 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"));} } } + let body = if registration {serde_json::from_slice(&bytes).map_err(|_| fail("invalid_client_metadata"))?} else {Value::Null}; + if path == "/oauth/token" && method == Method::POST { + let (_, grant) = app.mcp.exchange(&app.mcp.db.lock().unwrap(), &input, &headers)?; + let id = string(&grant["user"]); + let (profile, roles) = match tokio::try_join!( + host::call(json!({"operation":"iam.request","path":format!("/users/{id}"),"method":"GET","body":null})), + host::call(json!({"operation":"iam.request","path":format!("/users/{id}/role-mappings/realm"),"method":"GET","body":null})) + ) { + Ok(identity) => identity, + Err(error) if error.status == 404 => { + revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?; + return Err(fail("invalid_grant")); + } + Err(error) => return Err(error), + }; + if profile["body"]["enabled"] != true || grant["resource"] == app.mcp.resource("observability") && !array(&roles["body"]).iter().any(|role| role["name"] == "infra-admin") { + revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?; + return Err(fail("invalid_grant")); + } + } + app.mcp.oauth(path.trim_start_matches("/oauth/"),&method,&input,&headers,body) + }.await; + let mut response = match result { + Ok(response) => response, + Err(error) => ( + StatusCode::from_u16(if error.message == "invalid_client" { + 401 + } else { + error.status + }) + .unwrap_or(StatusCode::BAD_REQUEST), + axum::Json( + json!({"error":if error.status >= 500 {"server_error"} else {&error.message}}), + ), + ) + .into_response(), + }; + response + .headers_mut() + .insert("cache-control", "no-store".parse().unwrap()); + response + .headers_mut() + .insert("pragma", "no-cache".parse().unwrap()); + response +} + +pub async fn manage( + app: Arc, + method: &Method, + parts: &[&str], + me: &Value, + body: Value, + headers: &HeaderMap, +) -> Result { + if method != Method::GET + && (me["viewing"] == true + || headers.get("origin").and_then(|h| h.to_str().ok()) + != Some(app.mcp.origin.origin().ascii_serialization().as_str())) + { + return Err(Error::new( + 403, + "Open MCP settings from your own signed-in account.", + )); + } + let owner = users::self_user(&app, me).await?; + if owner["enabled"] != true { + return Err(Error::new(403, "This account is disabled.")); + } + let owner_id = string(&owner["id"]); + if parts == ["shale"] { + return shale::manage(app, method, &owner, &body).await; + } + if parts == ["relay", "live"] && method == Method::GET { + let owner = owner_id.to_owned(); + let stream = WatchStream::new(app.relay.changes.subscribe()).map(move |_| { + let machines = relay::machines(&app.mcp.db.lock().unwrap(), &owner) + .map(|machines| app.relay.view(machines, None)); + machines + .map(|machines| Event::default().json_data(machines).unwrap()) + .map_err(|error| std::io::Error::other(error.message)) + }); + return Ok(Sse::new(stream) + .keep_alive(axum::response::sse::KeepAlive::default()) + .into_response()); + } + let consent_resource = if let ["consent", id] = parts { + get( + &app.mcp.db.lock().unwrap(), + &format!("pending:{}", hash(id)), + )?["resource"] + .as_str() + .unwrap_or_default() + .to_owned() + } else { + String::new() + }; + let agent_consent = consent_resource == app.mcp.resource("agents"); + let shale_consent = consent_resource == app.mcp.resource("shale"); + let mut linked = true; + let resources: Vec = if agent_consent { + relay::machines(&app.mcp.db.lock().unwrap(), owner_id)? + .into_iter() + .map(|m| json!({"id":m["id"],"name":m["name"]})) + .collect() + } else if shale_consent && body["deny"] != true { + match shale::repositories(&app, owner_id).await { + Ok(repositories) => repositories, + Err(error) if error.status == 401 => { + linked = false; + Vec::new() + } + Err(error) => return Err(error), + } + } else if consent_resource == app.mcp.resource("observability") + && array(&me["sections"]).iter().any(|s| s == "admin") + && array(&owner["groups"]) + .iter() + .any(|g| g["name"] == "infra-admin") + { + core::scan(app.clone()) + .await? + .value + .as_object() + .unwrap() + .keys() + .map(|id| json!({"id":id,"name":id})) + .collect() + } else { + Vec::new() + }; + let mut db = app.mcp.db.lock().unwrap(); + let tx = db.transaction()?; + let value = match parts { + [] if method == Method::GET => { + let mut grants = list(&tx, "grant:")?; + grants.retain(|g| g["user"] == owner_id); + let machines = relay::machines(&tx, owner_id)?; + let connections = grants.iter().map(|grant| { + let name = if grant["client"].is_null() {grant["name"].clone()} else {get(&tx, &format!("client:{}", string(&grant["client"])))?["client_name"].clone()}; + 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(); + Ok(json!({"id":grant["id"],"name":name,"resources":resources,"scopes":grant["scopes"],"createdAt":grant["createdAt"]})) + }).collect::>>()?; + let shale = get(&tx, &format!("shale-session:{owner_id}"))?; + let catalogs: Vec<_> = CATALOGS + .iter() + .map(|(id, name, _)| json!({"name":name,"endpoint":app.mcp.resource(id)})) + .collect(); + 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"]})}}) + } + ["consent", id] if method == Method::GET || method == Method::POST => { + let key = format!("pending:{}", hash(id)); + let mut pending = get(&tx, &key)?; + if pending.is_null() { + return Err(Error::new( + 404, + "This connection request expired. Start it again.", + )); + } + if !pending["owner"].is_null() && pending["owner"] != owner_id { + return Err(Error::new( + 403, + "This connection request belongs to another account.", + )); + } + pending["owner"] = json!(owner_id); + if method == Method::POST && body["deny"] == true { + delete(&tx, &key)?; + let mut target = redirect(string(&pending["redirect"]))?; + target + .query_pairs_mut() + .append_pair("error", "access_denied") + .append_pair("iss", app.mcp.origin.as_str()); + if let Some(state) = pending["state"].as_str() { + target.query_pairs_mut().append_pair("state", state); + } + tx.commit()?; + return Ok(axum::Json(json!({"redirect":target.as_str()})).into_response()); + } + if method == Method::GET { + tx.execute( + "UPDATE records SET value=? WHERE key=?", + rusqlite::params![pending.to_string(), key], + )?; + json!({"client":get(&tx,&format!("client:{}",string(&pending["client"])))?["client_name"],"scopes":pending["scopes"],"resources":resources,"linked":linked}) + } else { + if shale_consent + && get(&tx, &format!("shale-session:{owner_id}"))?["origin"] + != app.shale.origin.as_str() + { + return Err(Error::new( + 401, + "Link your Shale account before allowing repository access.", + )); + } + let chosen = body["resources"] + .as_array() + .filter(|a| !a.is_empty() && a.len() <= resources.len()) + .ok_or_else(|| Error::new(400, "Choose each available resource once."))?; + if chosen.iter().enumerate().any(|(index, r)| { + !resources.iter().any(|id| r == &id["id"]) || chosen[..index].contains(r) + }) { + return Err(Error::new( + 403, + "Choose resources available to your account.", + )); + } + if list(&tx, "grant:")? + .iter() + .filter(|g| g["user"] == owner_id) + .count() + >= 256 + { + return Err(Error::new( + 409, + "Remove an unused connection before adding another.", + )); + } + let grant_id = uuid::Uuid::new_v4().to_string(); + let mut grant = json!({"id":grant_id,"user":owner_id,"client":pending["client"],"resource":pending["resource"],"scopes":pending["scopes"],"resources":chosen,"createdAt":now()}); + if agent_consent { + grant["targets"] = json!(chosen); + } + put(&tx, &format!("grant:{grant_id}"), &grant, 0)?; + let code = secret(); + pending["grant"] = json!(grant_id); + put(&tx, &format!("code:{}", hash(&code)), &pending, 300)?; + delete(&tx, &key)?; + let mut target = redirect(string(&pending["redirect"]))?; + target + .query_pairs_mut() + .append_pair("code", &code) + .append_pair("iss", app.mcp.origin.as_str()); + if let Some(state) = pending["state"].as_str() { + target.query_pairs_mut().append_pair("state", state); + } + json!({"redirect":target.as_str()}) + } + } + ["relay", rest @ ..] => relay::manage(&app, &tx, rest, method, owner_id, &body)?, + ["connections", id] if method == Method::DELETE => { + let grant = get(&tx, &format!("grant:{id}"))?; + if grant["user"] != owner_id { + return Err(Error::new(404, "No connection with that ID.")); + } + revoke(&tx, id)?; + Value::Null + } + _ => return Err(Error::new(404, "No endpoint here.")), + }; + tx.commit()?; + if matches!(parts, ["relay", "pair"] | ["relay", "machines", _]) { + app.relay.changes.send_replace(()); + } + Ok(if value.is_null() { + StatusCode::NO_CONTENT.into_response() + } else { + axum::Json(value).into_response() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + struct Fixture { + store: Arc, + path: std::path::PathBuf, + } + impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir() + .canonicalize() + .unwrap() + .join(format!("studio-mcp-test-{}", uuid::Uuid::new_v4())); + let store = Arc::new(Store::new(&path, "https://globe.studio.test").unwrap()); + Self { store, path } + } + fn code(&self, client: &str, code: &str) -> HashMap { + let grant = uuid::Uuid::new_v4().to_string(); + let db = self.store.db.lock().unwrap(); + 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(); + 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(); + let verifier = "v".repeat(43); + 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(); + fields( + 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")}), + ) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.path).unwrap(); + } + } + fn fields(value: Value) -> HashMap { + value + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), string(v).to_owned())) + .collect() + } + async fn value(response: Response) -> Value { + serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), 65536) + .await + .unwrap(), + ) + .unwrap() + } + fn bearer(token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("authorization", format!("Bearer {token}").parse().unwrap()); + headers + } + #[test] + fn revocation_removes_durable_keys_and_family_records() { + let fixture = Fixture::new(); + let db = fixture.store.db.lock().unwrap(); + put(&db, "grant:revoked", &json!({"id":"revoked"}), 0).unwrap(); + for kind in ["access", "refresh", "used", "code"] { + put(&db, &format!("{kind}:one"), &json!({"grant":"revoked"}), 0).unwrap(); + put( + &db, + &format!("{kind}:other"), + &json!({"grant":"retained"}), + 0, + ) + .unwrap(); + } + revoke(&db, "revoked").unwrap(); + assert!(get(&db, "grant:revoked").unwrap().is_null()); + for kind in ["access", "refresh", "used", "code"] { + assert!(get(&db, &format!("{kind}:one")).unwrap().is_null()); + assert!(!get(&db, &format!("{kind}:other")).unwrap().is_null()); + } + } + #[tokio::test] + async fn code_binds_pkce_client_redirect_and_audience_without_consuming_on_failure() { + let fixture = Fixture::new(); + let input = fixture.code("one", "owned-code"); + fixture.code("two", "other-code"); + for (key, wrong) in [ + ("client_id", "two"), + ("code_verifier", "wrong"), + ("redirect_uri", "http://127.0.0.1:20002/callback"), + ("resource", "https://other.invalid/mcp/observability"), + ] { + let mut attempt = input.clone(); + attempt.insert(key.into(), wrong.into()); + assert!( + fixture + .store + .oauth( + "token", + &Method::POST, + &attempt, + &HeaderMap::new(), + Value::Null + ) + .is_err() + ); + } + let tokens = value( + fixture + .store + .oauth( + "token", + &Method::POST, + &input, + &HeaderMap::new(), + Value::Null, + ) + .unwrap(), + ) + .await; + assert!( + fixture + .store + .oauth( + "token", + &Method::POST, + &input, + &HeaderMap::new(), + Value::Null + ) + .is_err() + ); + assert!( + fixture + .store + .authenticate( + &bearer(string(&tokens["access_token"])), + &fixture.store.resource("observability") + ) + .is_ok() + ); + assert!( + fixture + .store + .authenticate( + &bearer(string(&tokens["access_token"])), + "https://other.invalid/mcp/observability" + ) + .is_err() + ); + assert_eq!( + std::fs::metadata(fixture.path.join("connections.sqlite")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + let rows = fixture + .store + .db + .lock() + .unwrap() + .prepare("SELECT key,value FROM records") + .unwrap() + .query_map([], |row| { + Ok(format!( + "{} {}", + row.get::<_, String>(0)?, + row.get::<_, String>(1)? + )) + }) + .unwrap() + .collect::, _>>() + .unwrap() + .join("\n"); + for token in [ + "owned-code", + string(&tokens["access_token"]), + string(&tokens["refresh_token"]), + ] { + assert!(!rows.contains(token)); + } + } + #[tokio::test] + async fn refresh_replay_revokes_family_but_another_client_cannot_revoke_it() { + let fixture = Fixture::new(); + let input = fixture.code("one", "owned-code"); + fixture.code("two", "other-code"); + let tokens = value( + fixture + .store + .oauth( + "token", + &Method::POST, + &input, + &HeaderMap::new(), + Value::Null, + ) + .unwrap(), + ) + .await; + let refresh = fields( + json!({"grant_type":"refresh_token","client_id":"one","refresh_token":tokens["refresh_token"]}), + ); + let mut wrong = refresh.clone(); + wrong.insert("client_id".into(), "two".into()); + assert!( + fixture + .store + .oauth( + "token", + &Method::POST, + &wrong, + &HeaderMap::new(), + Value::Null + ) + .is_err() + ); + fixture + .store + .oauth( + "revoke", + &Method::POST, + &fields(json!({"client_id":"two","token":tokens["access_token"]})), + &HeaderMap::new(), + Value::Null, + ) + .unwrap(); + let access = bearer(string(&tokens["access_token"])); + assert!( + fixture + .store + .authenticate(&access, &fixture.store.resource("observability")) + .is_ok() + ); + let rotated = value( + fixture + .store + .oauth( + "token", + &Method::POST, + &refresh, + &HeaderMap::new(), + Value::Null, + ) + .unwrap(), + ) + .await; + assert_ne!(rotated["refresh_token"], tokens["refresh_token"]); + assert!( + fixture + .store + .oauth( + "token", + &Method::POST, + &wrong, + &HeaderMap::new(), + Value::Null + ) + .is_err() + ); + assert!( + fixture + .store + .authenticate(&access, &fixture.store.resource("observability")) + .is_ok() + ); + assert!( + fixture + .store + .oauth( + "token", + &Method::POST, + &refresh, + &HeaderMap::new(), + Value::Null + ) + .is_err() + ); + assert!( + fixture + .store + .authenticate(&access, &fixture.store.resource("observability")) + .is_err() + ); + assert!( + fixture + .store + .authenticate( + &bearer(string(&rotated["access_token"])), + &fixture.store.resource("observability") + ) + .is_err() + ); + 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()); + } + #[test] + fn concurrent_code_exchange_has_one_winner() { + let fixture = Fixture::new(); + let input = fixture.code("one", "concurrent-code"); + let start = Arc::new(std::sync::Barrier::new(9)); + let workers = (0..8) + .map(|_| { + let store = fixture.store.clone(); + let input = input.clone(); + let start = start.clone(); + std::thread::spawn(move || { + start.wait(); + store + .oauth( + "token", + &Method::POST, + &input, + &HeaderMap::new(), + Value::Null, + ) + .is_ok() + }) + }) + .collect::>(); + start.wait(); + assert_eq!( + workers + .into_iter() + .map(|w| w.join().unwrap() as u32) + .sum::(), + 1 + ); + } + #[tokio::test] + async fn confidential_client_secret_is_required_and_hashed() { + let fixture = Fixture::new(); + 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; + let id = string(&client["client_id"]); + let stored = get(&fixture.store.db.lock().unwrap(), &format!("client:{id}")).unwrap(); + assert!(stored.get("client_secret").is_none()); + assert_ne!(stored["secret_hash"], client["client_secret"]); + let input = fields(json!({"client_id":id,"token":"unknown"})); + assert!( + fixture + .store + .oauth( + "revoke", + &Method::POST, + &input, + &HeaderMap::new(), + Value::Null + ) + .is_err() + ); + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + format!( + "Basic {}", + STANDARD.encode(format!("{id}:{}", string(&client["client_secret"]))) + ) + .parse() + .unwrap(), + ); + assert!( + fixture + .store + .oauth("revoke", &Method::POST, &input, &headers, Value::Null) + .is_ok() + ); + let mut duplicate = input.clone(); + duplicate.insert( + "client_secret".into(), + string(&client["client_secret"]).into(), + ); + assert!( + fixture + .store + .oauth("revoke", &Method::POST, &duplicate, &headers, Value::Null) + .is_err() + ); + } +} diff --git a/dashboard/src/observability.rs b/dashboard/src/observability.rs new file mode 100644 index 0000000000000000000000000000000000000000..8d3849a311c886ed7c67a9cf84cd0ffb608c64f6 --- /dev/null +++ b/dashboard/src/observability.rs @@ -0,0 +1,190 @@ +use crate::*; +use rmcp::{ + ErrorData, RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, ToolAnnotations, + }, + service::RequestContext, +}; + +#[derive(Clone)] +struct Observability(Arc); +impl ServerHandler for Observability { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + } + async fn list_tools( + &self, + _: Option, + _: RequestContext, + ) -> std::result::Result { + Ok(ListToolsResult { tools: [ + ("get_logs", "Retrieve logs from one granted service."), + ("get_traces", "Find traces from one granted service."), + ("get_trace", "Retrieve a trace with spans limited to granted services."), + ].into_iter().map(|(name, description)| { + let mut properties = json!({"service":{"type":"string"},"q":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":500}}); + let required = if name=="get_trace" { properties=json!({"service":{"type":"string"},"trace_id":{"type":"string"}}); json!(["service","trace_id"]) } else {json!(["service"])}; + Tool::new(name, description, json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}).as_object().unwrap().clone()).with_annotations(ToolAnnotations::new().read_only(true)) + }).collect(), ..Default::default() }) + } + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> std::result::Result { + let result: Result = async { + let grant = &context + .extensions + .get::() + .and_then(|p| p.extensions.get::()) + .ok_or_else(|| Error::new(401, "This connection expired. Connect again."))? + .0; + let arguments = request.arguments.unwrap_or_default(); + let service = arguments + .get("service") + .and_then(Value::as_str) + .ok_or_else(|| Error::new(400, "Choose a granted service."))?; + if !array(&grant["resources"]).iter().any(|id| id == service) { + return Err(Error::new( + 403, + "This service is outside the connection's access.", + )); + } + let mut query = HashMap::new(); + for (key, value) in &arguments { + match key.as_str() { + "service" => {} + "q" if request.name != "get_trace" => { + query.insert( + key.clone(), + value + .as_str() + .filter(|s| s.len() <= 4096) + .ok_or_else(|| Error::new(400, "Narrow the search."))? + .to_owned(), + ); + } + "limit" if request.name != "get_trace" => { + query.insert( + key.clone(), + value + .as_u64() + .filter(|n| (1..=500).contains(n)) + .ok_or_else(|| Error::new(400, "Choose a limit from 1 to 500."))? + .to_string(), + ); + } + "trace_id" if request.name == "get_trace" && value.as_str().is_some() => {} + _ => return Err(Error::new(400, "Use the fields listed for this tool.")), + } + } + match request.name.as_ref() { + "get_logs" => { + Ok(json!({"logs":telemetry::logs(self.0.clone(), service, &query).await?})) + } + "get_traces" => { + let jobs = core::scan(self.0.clone()).await?; + let traces = telemetry::traces(self.0.clone(), service, &query, |span| { + visible_span(span, &jobs.value, &grant["resources"]) + }) + .await?; + Ok(json!({"traces":traces})) + } + "get_trace" => { + let id = arguments + .get("trace_id") + .and_then(Value::as_str) + .filter(|s| !s.is_empty() && s.len() <= 128) + .ok_or_else(|| Error::new(400, "Choose a trace ID."))?; + let mut trace = telemetry::trace(self.0.clone(), id).await?; + let jobs = core::scan(self.0.clone()).await?; + if !array(&trace["spans"]) + .iter() + .any(|span| visible_span(span, &jobs.value, &json!([service]))) + { + return Err(Error::new(404, "No trace from that service has this ID.")); + } + trace["spans"] + .as_array_mut() + .unwrap() + .retain(|span| visible_span(span, &jobs.value, &grant["resources"])); + Ok(json!({"trace":trace})) + } + _ => Err(Error::new(404, "No tool with that name.")), + } + } + .await; + Ok(match result { + Ok(value) => CallToolResult::structured(value), + Err(error) => CallToolResult::error(vec![ContentBlock::text(if error.status >= 500 { + "The service couldn't answer. Check its dashboard and retry.".to_owned() + } else { + error.message + })]), + } + .into()) + } +} +fn visible_span(span: &Value, jobs: &Value, resources: &Value) -> bool { + if let Some(id) = span["attributes"]["studio.service"].as_str() { + return array(resources).iter().any(|r| r == id); + } + let owners: Vec<_> = jobs + .as_object() + .into_iter() + .flat_map(|jobs| jobs.iter()) + .filter(|(_, job)| job["job"]["Meta"]["studio_trace_service"] == span["service"]) + .map(|(id, _)| id) + .collect(); + owners.len() == 1 && array(resources).iter().any(|r| r == owners[0]) +} +pub fn router(app: Arc) -> Router { + let state = app.clone(); + mcp::router(app, "observability", move || { + Ok(Observability(state.clone())) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn trace_grants_reject_foreign_and_ambiguous_spans() { + let jobs = json!({"allowed":{"job":{"Meta":{"studio_trace_service":"backend"}}},"private":{"job":{"Meta":{"studio_trace_service":"private-api"}}}}); + let resources = json!(["allowed"]); + assert!(visible_span( + &json!({"service":"backend","attributes":{}}), + &jobs, + &resources + )); + assert!(!visible_span( + &json!({"service":"private-api","attributes":{}}), + &jobs, + &resources + )); + assert!(!visible_span( + &json!({"service":"backend","attributes":{"studio.service":"private"}}), + &jobs, + &resources + )); + assert!(visible_span( + &json!({"service":"allowed","attributes":{"studio.service":"allowed"}}), + &jobs, + &resources + )); + let mut ambiguous = jobs.clone(); + ambiguous["private"]["job"]["Meta"]["studio_trace_service"] = json!("backend"); + assert!(!visible_span( + &json!({"service":"backend","attributes":{}}), + &ambiguous, + &resources + )); + assert!(!visible_span( + &json!({"service":"unknown","attributes":{}}), + &jobs, + &resources + )); + } +} diff --git a/dashboard/src/relay.rs b/dashboard/src/relay.rs new file mode 100644 index 0000000000000000000000000000000000000000..8c02fbef820cce11a20ad4a7b1c5ce57597a57ea --- /dev/null +++ b/dashboard/src/relay.rs @@ -0,0 +1,1065 @@ +use crate::*; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use futures::SinkExt; +use mcp::{delete, get, hash, list, put, secret}; +use rmcp::{ + ErrorData, RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, ToolAnnotations, + }, + service::RequestContext, +}; +use tokio::sync::{mpsc, oneshot}; + +pub struct Broker { + connections: Mutex>>, + pending_slots: Semaphore, + pub(crate) changes: watch::Sender<()>, +} +impl Default for Broker { + fn default() -> Self { + Self { + connections: Mutex::new(HashMap::new()), + pending_slots: Semaphore::new(128), + changes: watch::channel(()).0, + } + } +} +struct Connection { + messages: mpsc::Sender, + pending: Mutex>>>, + closed: watch::Sender, +} +struct Pending { + connection: Arc, + id: String, +} +impl Drop for Pending { + fn drop(&mut self) { + self.connection.pending.lock().unwrap().remove(&self.id); + } +} +fn unknown() -> Error { + Error::new( + 409, + "Command outcome is unknown. Read the thread before retrying.", + ) +} +impl Broker { + pub fn disconnect(&self, id: &str) { + if let Some(connection) = self.connections.lock().unwrap().remove(id) { + self.changes.send_replace(()); + connection.closed.send_replace(true); + for (_, response) in connection.pending.lock().unwrap().drain() { + let _ = response.send(Err(unknown())); + } + } + } + fn remove(&self, id: &str, connection: &Arc) { + let mut connections = self.connections.lock().unwrap(); + if connections + .get(id) + .is_some_and(|current| Arc::ptr_eq(current, connection)) + { + connections.remove(id); + self.changes.send_replace(()); + } + connection.closed.send_replace(true); + for (_, response) in connection.pending.lock().unwrap().drain() { + let _ = response.send(Err(unknown())); + } + } + pub fn view(&self, machines: Vec, grant: Option<&Value>) -> Vec { + let connections = self.connections.lock().unwrap(); + machines + .into_iter() + .filter(|machine| grant.is_none_or(|g| array(&g["resources"]).contains(&machine["id"]))) + .map(|mut machine| { + machine.as_object_mut().unwrap().remove("tokenHash"); + machine.as_object_mut().unwrap().remove("user"); + machine["online"] = json!(connections.contains_key(string(&machine["id"]))); + if let Some(grant) = grant { + machine["selected"] = json!(array(&grant["targets"]).contains(&machine["id"])); + } + machine + }) + .collect() + } + async fn dispatch( + &self, + store: &mcp::Store, + grant_id: &str, + machine_id: &str, + method: &str, + params: Value, + deadline: Duration, + ) -> Result { + let params = command(method, params)?; + let (pending, receive, slot) = { + let db = store.db.lock().unwrap(); + let grant = get(&db, &format!("grant:{grant_id}"))?; + let machine = get(&db, &format!("machine:{machine_id}"))?; + if grant.is_null() + || grant["resource"] != store.resource("agents") + || machine.is_null() + || machine["user"] != grant["user"] + || !array(&grant["resources"]).iter().any(|id| id == machine_id) + { + return Err(Error::new( + 403, + "Choose a machine granted to this connection.", + )); + } + let scope = if matches!(method, "send_message" | "interrupt_thread" | "start_thread") { + "sessions:write" + } else { + "sessions:read" + }; + if !array(&grant["scopes"]).iter().any(|s| s == scope) { + return Err(Error::new( + 403, + "This connection has read access only. Connect again to request control.", + )); + } + let slot = self.pending_slots.try_acquire().map_err(|_| { + Error::new( + 429, + "The relay has too many pending commands. Try again shortly.", + ) + })?; + let connection = self + .connections + .lock() + .unwrap() + .get(machine_id) + .cloned() + .ok_or_else(|| Error::new(409, "Machine is offline. Start its local agent."))?; + let (send, receive) = oneshot::channel(); + let id = uuid::Uuid::new_v4().to_string(); + { + let mut pending = connection.pending.lock().unwrap(); + if *connection.closed.borrow() { + return Err(Error::new( + 409, + "Machine is offline. Start its local agent.", + )); + } + if pending.len() >= 8 { + return Err(Error::new( + 429, + "Machine has eight pending commands. Wait for one to finish.", + )); + } + pending.insert(id.clone(), send); + } + let pending = Pending { + connection: connection.clone(), + id: id.clone(), + }; + let frame = json!({"id":id,"method":method,"params":params}).to_string(); + if frame.len() > 256 * 1024 { + return Err(Error::new( + 413, + "Send a shorter message. Local agent commands are limited to 256 KB.", + )); + } + let frame = Message::Text(frame.into()); + if connection.messages.try_send(frame).is_err() { + return Err(unknown()); + } + (pending, receive, slot) + }; + let result = tokio::time::timeout(deadline, receive) + .await + .map_err(|_| unknown())? + .map_err(|_| unknown())?; + drop(pending); + drop(slot); + result + } +} +pub(crate) fn machines(db: &rusqlite::Connection, user: &str) -> Result> { + Ok(list(db, "machine:")? + .into_iter() + .filter(|m| m["user"] == user) + .collect()) +} +fn device(db: &rusqlite::Connection, headers: &HeaderMap) -> Result { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .filter(|s| !s.is_empty() && s.len() <= 256) + .ok_or_else(|| Error::new(401, "Pair this machine again."))?; + let fingerprint = hash(token); + list(db, "machine:")? + .into_iter() + .find(|machine| { + string(&machine["tokenHash"]) + .as_bytes() + .ct_eq(fingerprint.as_bytes()) + .unwrap_u8() + == 1 + }) + .ok_or_else(|| Error::new(401, "Pair this machine again.")) +} +fn name(value: &Value) -> Result<&str> { + value + .as_str() + .filter(|s| !s.trim().is_empty() && s.encode_utf16().count() <= 100) + .ok_or_else(|| Error::new(400, "Enter a name up to 100 characters.")) +} +pub(crate) fn manage( + app: &App, + db: &rusqlite::Connection, + parts: &[&str], + method: &Method, + owner: &str, + body: &Value, +) -> Result { + match parts { + ["pair"] if method == Method::POST => { + let code = body["code"] + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 40) + .ok_or_else(|| Error::new(400, "Enter the code from your local agent."))?; + let code: String = code + .chars() + .filter(|c| *c != '-' && !c.is_whitespace()) + .flat_map(char::to_uppercase) + .collect(); + let key = format!("pair:{}", hash(&code)); + let pairing = get(db, &key)?; + if pairing.is_null() { + return Err(Error::new( + 410, + "This code expired or was used. Start pairing again.", + )); + } + if machines(db, owner)?.len() >= 128 { + return Err(Error::new( + 409, + "Unlink an unused machine before adding another.", + )); + } + let id = uuid::Uuid::new_v4().to_string(); + let machine = json!({"id":id,"user":owner,"name":pairing["name"],"platform":pairing["platform"],"tokenHash":pairing["tokenHash"]}); + put(db, &format!("machine:{id}"), &machine, 0)?; + delete(db, &key)?; + Ok(json!(app.relay.view(vec![machine], None).remove(0))) + } + ["machines", id] if method == Method::DELETE || method == Method::PATCH => { + let mut machine = get(db, &format!("machine:{id}"))?; + if machine["user"] != owner { + return Err(Error::new(404, "No linked machine with that ID.")); + } + if method == Method::DELETE { + delete(db, &format!("machine:{id}"))?; + app.relay.disconnect(id); + Ok(Value::Null) + } else { + machine["name"] = json!(name(&body["name"])?); + put(db, &format!("machine:{id}"), &machine, 0)?; + Ok(json!(app.relay.view(vec![machine], None).remove(0))) + } + } + ["keys"] if method == Method::POST => { + let available = machines(db, owner)?; + let resources = selection( + &body["resources"], + &available + .iter() + .map(|m| m["id"].clone()) + .collect::>(), + )?; + let name = name(&body["name"])?; + let write = body + .get("write") + .map(|v| { + v.as_bool() + .ok_or_else(|| Error::new(400, "Choose read access or control.")) + }) + .transpose()? + .unwrap_or(false); + if list(db, "grant:")? + .iter() + .filter(|g| g["user"] == owner) + .count() + >= 256 + { + return Err(Error::new( + 409, + "Revoke an unused connection before adding another.", + )); + } + let id = uuid::Uuid::new_v4().to_string(); + 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()}); + let key = format!("ar_{}", secret()); + put(db, &format!("grant:{id}"), &grant, 0)?; + put( + db, + &format!("access:{}", hash(&key)), + &json!({"grant":id,"resource":grant["resource"]}), + 0, + )?; + Ok(json!({"key":key,"id":id})) + } + _ => Err(Error::new(404, "No endpoint here.")), + } +} +fn selection(value: &Value, allowed: &[Value]) -> Result> { + let ids = value + .as_array() + .filter(|ids| !ids.is_empty() && ids.len() <= 128) + .ok_or_else(|| Error::new(400, "Choose at least one linked machine."))?; + if ids + .iter() + .enumerate() + .any(|(i, id)| !allowed.contains(id) || ids[..i].contains(id)) + { + return Err(Error::new( + 403, + "Choose machines granted to this connection once each.", + )); + } + Ok(ids.clone()) +} +fn view(app: &App, grant_id: &str, targets: Option<&Value>) -> Result> { + let mut db = app.mcp.db.lock().unwrap(); + let tx = db.transaction()?; + let mut grant = get(&tx, &format!("grant:{grant_id}"))?; + if grant.is_null() || grant["resource"] != app.mcp.resource("agents") { + return Err(Error::new( + 401, + "This connection was revoked. Connect again.", + )); + } + let machines = machines(&tx, string(&grant["user"]))?; + if let Some(targets) = targets { + let allowed: Vec<_> = machines + .iter() + .filter(|m| array(&grant["resources"]).contains(&m["id"])) + .map(|m| m["id"].clone()) + .collect(); + grant["targets"] = json!(selection(targets, &allowed)?); + put(&tx, &format!("grant:{grant_id}"), &grant, 0)?; + } + let result = app.relay.view(machines, Some(&grant)); + tx.commit()?; + Ok(result) +} +fn schema(method: &str) -> Option { + let provider = json!({"type":"string","enum":["codex","claude"]}); + let id = json!({"type":"string","format":"uuid"}); + let message = json!({"type":"string","minLength":1,"maxLength":100000}); + let (properties, required) = match method { + "list_threads" => ( + json!({"provider":provider,"limit":{"type":"integer","minimum":1,"maximum":100,"default":30}}), + json!([]), + ), + "read_thread" => ( + json!({"provider":provider,"thread_id":id,"limit":{"type":"integer","minimum":1,"maximum":100,"default":20}}), + json!(["provider", "thread_id"]), + ), + "send_message" => ( + json!({"provider":provider,"thread_id":id,"message":message,"expected_turn_id":id}), + json!(["provider", "thread_id", "message"]), + ), + "interrupt_thread" => ( + json!({"provider":provider,"thread_id":id,"expected_turn_id":id}), + json!(["provider", "thread_id"]), + ), + "start_thread" => ( + json!({"provider":provider,"cwd":{"type":"string","minLength":1,"maxLength":4096},"message":message}), + json!(["provider", "cwd", "message"]), + ), + _ => return None, + }; + Some( + json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}), + ) +} +fn command(method: &str, value: Value) -> Result { + let schema = schema(method) + .ok_or_else(|| Error::new(404, "Choose a session tool listed by this connector."))?; + let mut params = value + .as_object() + .cloned() + .ok_or_else(|| Error::new(400, "Use the fields listed for this tool."))?; + let properties = schema["properties"].as_object().unwrap(); + if params.keys().any(|key| !properties.contains_key(key)) + || array(&schema["required"]) + .iter() + .any(|key| !params.contains_key(string(key))) + { + return Err(Error::new(400, "Use the fields listed for this tool.")); + } + for (key, rule) in properties { + if !params.contains_key(key) && rule.get("default").is_some() { + params.insert(key.clone(), rule["default"].clone()); + } + let Some(value) = params.get(key) else { + continue; + }; + let valid = match string(&rule["type"]) { + "integer" => value.as_i64().is_some_and(|n| { + n >= rule["minimum"].as_i64().unwrap() && n <= rule["maximum"].as_i64().unwrap() + }), + "string" => value.as_str().is_some_and(|s| { + let length = s.encode_utf16().count() as u64; + rule["minLength"].as_u64().is_none_or(|n| length >= n) + && rule["maxLength"].as_u64().is_none_or(|n| length <= n) + && rule + .get("enum") + .is_none_or(|values| array(values).contains(value)) + && (rule["format"] != "uuid" + || uuid::Uuid::parse_str(s) + .is_ok_and(|id| id.to_string().eq_ignore_ascii_case(s))) + }), + _ => false, + }; + if !valid { + return Err(Error::new( + 400, + format!("Check {key} against the tool's fields."), + )); + } + } + Ok(json!(params)) +} +async fn pairing(State(app): State>, request: Request) -> Result { + let method = request.method().clone(); + let headers = request.headers().clone(); + let bytes = axum::body::to_bytes(request.into_body(), 4096) + .await + .map_err(|_| Error::new(400, "Enter a shorter machine name."))?; + let mut db = app.mcp.db.lock().unwrap(); + let tx = db.transaction()?; + let response = if method == Method::POST { + if list(&tx, "pair:")?.len() >= 256 { + return Err(Error::new( + 429, + "Too many pairing requests. Try again in ten minutes.", + )); + } + let body: Value = serde_json::from_slice(&bytes) + .map_err(|_| Error::new(400, "Enter a machine name and platform."))?; + let name = name(&body["name"])?; + let platform = body["platform"] + .as_str() + .filter(|s| s.encode_utf16().count() <= 100) + .ok_or_else(|| Error::new(400, "Enter a platform up to 100 characters."))?; + let token = secret(); + let code: String = rand::random::<[u8; 5]>() + .iter() + .map(|b| format!("{b:02X}")) + .collect(); + put( + &tx, + &format!("pair:{}", hash(&code)), + &json!({"name":name,"platform":platform,"tokenHash":hash(&token)}), + 600, + )?; + (StatusCode::CREATED, axum::Json(json!({"code":format!("{}-{}", &code[..5], &code[5..]),"token":token,"expires_in":600}))).into_response() + } else if method == Method::GET { + match device(&tx, &headers) { + Ok(machine) => axum::Json(json!({"machine_id":machine["id"]})).into_response(), + Err(_) => { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .filter(|s| !s.is_empty() && s.len() <= 256) + .ok_or_else(|| Error::new(401, "Start pairing from your local agent."))?; + if !list(&tx, "pair:")? + .iter() + .any(|p| p["tokenHash"] == hash(token)) + { + return Err(Error::new( + 410, + "This pairing expired. Start pairing again.", + )); + } + (StatusCode::ACCEPTED, axum::Json(json!({"pending":true}))).into_response() + } + } + } else { + return Err(Error::new(405, "Use GET or POST for pairing.")); + }; + tx.commit()?; + Ok(response) +} +async fn connect( + State(app): State>, + headers: HeaderMap, + ws: WebSocketUpgrade, +) -> Result { + if headers.contains_key("origin") { + return Err(Error::new(401, "Connect from your local agent.")); + } + let machine = device(&app.mcp.db.lock().unwrap(), &headers)?; + let id = string(&machine["id"]).to_owned(); + let (send, receive) = mpsc::channel(16); + let (closed, _) = watch::channel(false); + let connection = Arc::new(Connection { + messages: send, + pending: Mutex::new(HashMap::new()), + closed, + }); + { + let mut connections = app.relay.connections.lock().unwrap(); + if connections.contains_key(&id) { + return Err(Error::new( + 409, + "Another agent is connected. Stop it before starting a second copy.", + )); + } + if connections.len() >= 256 { + return Err(Error::new( + 503, + "Too many agents are connected. Try again later.", + )); + } + connections.insert(id.clone(), connection.clone()); + app.relay.changes.send_replace(()); + } + let (failure_app, failure_id, failure_connection) = + (app.clone(), id.clone(), connection.clone()); + Ok(ws + .max_frame_size(4 * 1024 * 1024) + .max_message_size(4 * 1024 * 1024) + .read_buffer_size(16 * 1024) + .write_buffer_size(0) + .max_write_buffer_size(256 * 1024) + .on_failed_upgrade(move |_| failure_app.relay.remove(&failure_id, &failure_connection)) + .on_upgrade(move |socket| session(app, id, connection, receive, socket))) +} +async fn session( + app: Arc, + id: String, + connection: Arc, + mut messages: mpsc::Receiver, + mut socket: WebSocket, +) { + let mut closed = connection.closed.subscribe(); + let connected = Message::Text( + json!({"type":"connected","machine_id":id}) + .to_string() + .into(), + ); + if tokio::time::timeout(Duration::from_secs(5), socket.send(connected)) + .await + .is_ok_and(|r| r.is_ok()) + { + let mut heartbeat = tokio::time::interval_at( + tokio::time::Instant::now() + Duration::from_secs(20), + Duration::from_secs(20), + ); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut alive = true; + loop { + if *closed.borrow() + || !get(&app.mcp.db.lock().unwrap(), &format!("machine:{id}")) + .is_ok_and(|m| !m.is_null()) + { + break; + } + let outgoing = tokio::select! { + _ = closed.changed() => break, + outgoing = messages.recv() => match outgoing {Some(frame) => frame, None => break}, + _ = heartbeat.tick() => { + if !alive {break;} + alive = false; + Message::Ping(Bytes::new()) + }, + incoming = socket.recv() => { + match incoming { + Some(Ok(Message::Pong(_))) => alive = true, + Some(Ok(Message::Ping(bytes))) => { + if !tokio::time::timeout(Duration::from_secs(5), socket.send(Message::Pong(bytes))).await.is_ok_and(|r| r.is_ok()) {break;} + }, + Some(Ok(Message::Text(text))) => { + let reply = serde_json::from_str::(&text); + let Ok(reply) = reply else {break}; + let valid = reply.as_object().is_some_and(|fields| fields.keys().all(|k| ["id","result","error"].contains(&k.as_str()))) + && reply["id"].as_str().is_some_and(|s| uuid::Uuid::parse_str(s).is_ok()) + && (reply.get("result").is_some() != reply.get("error").is_some()) + && reply.get("error").is_none_or(|error| error.as_str().is_some_and(|s| s.encode_utf16().count() <= 2000)); + if !valid {break;} + if let Some(send) = connection.pending.lock().unwrap().remove(string(&reply["id"])) { + let result = if let Some(error) = reply["error"].as_str() {Err(Error::new(400, error))} else {Ok(reply["result"].clone())}; + let _ = send.send(result); + } + }, + _ => break, + } + continue; + } + }; + if !tokio::time::timeout(Duration::from_secs(5), socket.send(outgoing)) + .await + .is_ok_and(|r| r.is_ok()) + { + break; + } + } + } + app.relay.remove(&id, &connection); + let _ = tokio::time::timeout(Duration::from_secs(1), socket.close()).await; +} +async fn rest(State(app): State>, request: Request) -> Response { + let result: Result = async { + if request.headers().get("origin").is_some_and(|v| { + v.to_str().ok() != Some(app.mcp.origin.origin().ascii_serialization().as_str()) + }) { + return Err(Error::new(403, "Use the relay from its own origin.")); + } + let grant = app + .mcp + .authenticate(request.headers(), &app.mcp.resource("agents"))?; + let id = string(&grant["id"]); + let method = request.method().clone(); + let path = request + .uri() + .path() + .trim_start_matches("/api/v1/") + .to_owned(); + let bytes = axum::body::to_bytes(request.into_body(), 256 * 1024) + .await + .map_err(|_| Error::new(400, "Send a shorter command."))?; + let body: Value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).map_err(|_| Error::new(400, "Use JSON command fields."))? + }; + let value = match path.split('/').collect::>().as_slice() { + ["machines"] if method == Method::GET => json!(view(&app, id, None)?), + ["targets"] if method == Method::PUT => json!(view(&app, id, Some(&body["machine_ids"]))?), + ["machines", machine, "commands"] if method == Method::POST => { + let method = body["method"] + .as_str() + .ok_or_else(|| Error::new(400, "Choose a session command."))?; + json!({"result":app.relay.dispatch(&app.mcp, id, machine, method, body["params"].clone(), Duration::from_secs(30)).await?}) + } + _ => return Err(Error::new(404, "No relay endpoint here.")), + }; + Ok(axum::Json(value).into_response()) + }.await; + match result { + Ok(response) => response, + 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(), + } +} +#[derive(Clone)] +struct Agents(Arc); +impl ServerHandler for Agents { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + } + async fn list_tools( + &self, + _: Option, + _: RequestContext, + ) -> std::result::Result { + let tools = [ + ("list_machines", "List granted machines and their connection state."), + ("set_target_machines", "Select default machines for this connection."), + ("list_threads", "List recent Codex and Claude Code threads on selected machines."), + ("read_thread", "Read a thread and its current status."), + ("send_message", "Submit a message. Submission acknowledges delivery, not task completion. Desktop control requires local opt-in."), + ("interrupt_thread", "Interrupt an agent-owned session or an opted-in Codex desktop turn."), + ("start_thread", "Start an agent-owned session under a locally allowed directory."), + ].into_iter().map(|(name, description)| { + 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})}); + if !["list_machines","set_target_machines"].contains(&name) {schema["properties"]["machine_id"] = json!({"type":"string","format":"uuid"});} + let read = ["list_machines","list_threads","read_thread"].contains(&name); + Tool::new(name, description, schema.as_object().unwrap().clone()).with_annotations(ToolAnnotations::new().read_only(read).destructive(name == "interrupt_thread").idempotent(read)) + }).collect(); + Ok(ListToolsResult { + tools, + ..Default::default() + }) + } + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> std::result::Result { + let result: Result = async { + let grant = &context + .extensions + .get::() + .and_then(|parts| parts.extensions.get::()) + .ok_or_else(|| Error::new(401, "This connection expired. Connect again."))? + .0; + let mut arguments = request.arguments.unwrap_or_default(); + let id = string(&grant["id"]); + match request.name.as_ref() { + "list_machines" if arguments.is_empty() => Ok(CallToolResult::structured( + json!({"machines":view(&self.0, id, None)?}), + )), + "set_target_machines" + if arguments.len() == 1 && arguments.contains_key("machine_ids") => + { + Ok(CallToolResult::structured( + json!({"machines":view(&self.0, id, arguments.get("machine_ids"))?}), + )) + } + method => { + let explicit = arguments.remove("machine_id"); + let machines = view(&self.0, id, None)?; + let targets: Vec<_> = if let Some(explicit) = explicit { + let explicit = explicit + .as_str() + .ok_or_else(|| Error::new(400, "Choose a machine ID."))?; + if !machines.iter().any(|m| m["id"] == explicit) { + return Err(Error::new(403, "Choose a granted machine.")); + } + vec![explicit.to_owned()] + } else { + machines + .iter() + .filter(|m| m["selected"] == true) + .map(|m| string(&m["id"]).to_owned()) + .collect() + }; + if targets.is_empty() || method != "list_threads" && targets.len() != 1 { + return Err(Error::new( + 400, + "Select one machine or supply machine_id for this command.", + )); + } + let params = command(method, json!(arguments))?; + let mut replies: futures::stream::FuturesUnordered<_> = targets.into_iter().map(|machine| { + let params = params.clone(); + async move { + match self + .0 + .relay + .dispatch( + &self.0.mcp, + id, + &machine, + method, + params, + Duration::from_secs(30), + ) + .await + { + Ok(result) => json!({"machine_id":machine,"result":result}), + Err(error) => json!({"machine_id":machine,"error":error.message}), + } + } + }).collect(); + let mut results = Vec::new(); + let mut bytes = 0; + while let Some(reply) = futures::StreamExt::next(&mut replies).await { + bytes += reply.to_string().len(); + if bytes > 16*1024*1024 {return Err(Error::new(413, "Machine replies exceed 16 MB. Select fewer machines or request fewer messages."));} + results.push(reply); + } + let errors = results.iter().any(|r| r.get("error").is_some()); + let mut result = CallToolResult::structured(json!({"results":results})); + result.is_error = Some(errors); + Ok(result) + } + } + } + .await; + Ok(match result { + Ok(result) => result, + Err(error) => CallToolResult::error(vec![ContentBlock::text(if error.status >= 500 { + "The relay couldn't answer. Check its dashboard and retry.".to_owned() + } else { + error.message + })]), + } + .into()) + } +} +pub fn router(app: Arc) -> Router { + let state = app.clone(); + let expected_host = + app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned(); + Router::new() + .route("/pairing", any(pairing)) + .route("/agent/connect", any(connect)) + .route("/api/v1/{*path}", any(rest)) + .with_state(app.clone()) + .merge(mcp::router(app, "agents", move || { + Ok(Agents(state.clone())) + })) + .layer(axum::middleware::from_fn( + move |request: Request, next: axum::middleware::Next| { + let host = expected_host.clone(); + async move { + if request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&host) { + return StatusCode::MISDIRECTED_REQUEST.into_response(); + } + next.run(request).await + } + }, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + struct Fixture { + store: Arc, + broker: Arc, + path: PathBuf, + machine: String, + grant: String, + } + impl Fixture { + fn new(write: bool) -> Self { + let path = std::env::temp_dir() + .canonicalize() + .unwrap() + .join(format!("studio-relay-test-{}", uuid::Uuid::new_v4())); + let store = Arc::new(mcp::Store::new(&path, "https://globe.studio.test").unwrap()); + let machine = uuid::Uuid::new_v4().to_string(); + let grant = uuid::Uuid::new_v4().to_string(); + { + let db = store.db.lock().unwrap(); + put(&db, &format!("machine:{machine}"), &json!({"id":machine,"user":"owner","name":"Fixture","tokenHash":hash("device-secret")}), 0).unwrap(); + 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(); + } + Self { + store, + broker: Arc::new(Broker::default()), + path, + machine, + grant, + } + } + fn connect(&self) -> (Arc, mpsc::Receiver) { + let (messages, receive) = mpsc::channel(16); + let (closed, _) = watch::channel(false); + let connection = Arc::new(Connection { + messages, + closed, + pending: Mutex::new(HashMap::new()), + }); + self.broker + .connections + .lock() + .unwrap() + .insert(self.machine.clone(), connection.clone()); + (connection, receive) + } + fn dispatch(&self, duration: Duration) -> tokio::task::JoinHandle> { + let (store, broker, machine, grant) = ( + self.store.clone(), + self.broker.clone(), + self.machine.clone(), + self.grant.clone(), + ); + tokio::spawn(async move { + broker + .dispatch( + &store, + &grant, + &machine, + "list_threads", + json!({}), + duration, + ) + .await + }) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.path).unwrap(); + } + } + #[test] + fn command_fields_defaults_and_provider_boundaries() { + assert_eq!( + command("list_threads", json!({})).unwrap(), + json!({"limit":30}) + ); + let id = uuid::Uuid::new_v4().to_string(); + assert_eq!( + command("read_thread", json!({"provider":"claude","thread_id":id})).unwrap()["limit"], + 20 + ); + for (method, params) in [ + ( + "read_thread", + json!({"provider":"codex","thread_id":"not-a-uuid"}), + ), + ( + "read_thread", + json!({"provider":"native-chat","thread_id":id}), + ), + ("list_threads", json!({"limit":101})), + ("list_threads", json!({"limit":1.5})), + ("list_threads", json!({"approval_response":true})), + ( + "send_message", + json!({"provider":"codex","thread_id":id,"message":""}), + ), + ("start_thread", json!({"provider":"claude","cwd":"/tmp"})), + ( + "interrupt_thread", + json!({"provider":"codex","thread_id":id,"expected_turn_id":1}), + ), + ("approve_tool", json!({})), + ] { + assert!(command(method, params).is_err(), "{method}"); + } + } + #[tokio::test] + async fn dispatch_uses_current_owner_grant_and_control_scope() { + let fixture = Fixture::new(false); + let (connection, mut receive) = fixture.connect(); + let task = fixture.dispatch(Duration::from_secs(2)); + let Message::Text(frame) = receive.recv().await.unwrap() else { + panic!() + }; + let frame: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(frame["params"], json!({"limit":30})); + connection + .pending + .lock() + .unwrap() + .remove(string(&frame["id"])) + .unwrap() + .send(Ok(json!({"threads":[]}))) + .unwrap(); + assert_eq!(task.await.unwrap().unwrap(), json!({"threads":[]})); + let refused = fixture + .broker + .dispatch( + &fixture.store, + &fixture.grant, + &fixture.machine, + "start_thread", + json!({"provider":"codex","cwd":"/tmp","message":"owned fixture"}), + Duration::from_secs(2), + ) + .await + .unwrap_err(); + assert_eq!(refused.status, 403); + assert!(receive.try_recv().is_err()); + let foreign = uuid::Uuid::new_v4().to_string(); + { + let db = fixture.store.db.lock().unwrap(); + let mut grant = get(&db, &format!("grant:{}", fixture.grant)).unwrap(); + grant["resources"] = json!([foreign]); + put(&db, &format!("grant:{}", fixture.grant), &grant, 0).unwrap(); + put( + &db, + &format!("machine:{foreign}"), + &json!({"id":foreign,"user":"someone-else"}), + 0, + ) + .unwrap(); + } + assert_eq!( + fixture + .broker + .dispatch( + &fixture.store, + &fixture.grant, + &foreign, + "list_threads", + json!({}), + Duration::from_secs(2) + ) + .await + .unwrap_err() + .status, + 403 + ); + delete( + &fixture.store.db.lock().unwrap(), + &format!("grant:{}", fixture.grant), + ) + .unwrap(); + assert_eq!( + fixture + .dispatch(Duration::from_secs(2)) + .await + .unwrap() + .unwrap_err() + .status, + 403 + ); + } + #[tokio::test] + async fn unicode_message_cannot_exceed_local_agent_frame_budget() { + let fixture = Fixture::new(true); + let (connection, mut receive) = fixture.connect(); + 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(); + assert_eq!(error.status, 413); + assert!(receive.try_recv().is_err()); + assert!(connection.pending.lock().unwrap().is_empty()); + assert_eq!(fixture.broker.pending_slots.available_permits(), 128); + } + #[tokio::test] + async fn eight_pending_limit_and_cancellation_release_capacity() { + let fixture = Fixture::new(true); + let (connection, mut receive) = fixture.connect(); + let mut tasks = Vec::new(); + for _ in 0..8 { + tasks.push(fixture.dispatch(Duration::from_secs(2))); + receive.recv().await.unwrap(); + } + assert_eq!( + fixture + .dispatch(Duration::from_secs(2)) + .await + .unwrap() + .unwrap_err() + .status, + 429 + ); + tasks.remove(0).abort(); + tokio::task::yield_now().await; + assert_eq!(connection.pending.lock().unwrap().len(), 7); + tasks.push(fixture.dispatch(Duration::from_secs(2))); + receive.recv().await.unwrap(); + assert_eq!(connection.pending.lock().unwrap().len(), 8); + fixture.broker.disconnect(&fixture.machine); + for task in tasks { + assert!(task.await.unwrap().unwrap_err().message.contains("unknown")); + } + assert!(connection.pending.lock().unwrap().is_empty()); + } + #[tokio::test] + async fn timeout_disconnect_and_reconnect_do_not_replay() { + let fixture = Fixture::new(true); + let (old, mut receive) = fixture.connect(); + let task = fixture.dispatch(Duration::from_millis(5)); + receive.recv().await.unwrap(); + assert!(task.await.unwrap().unwrap_err().message.contains("unknown")); + assert!(old.pending.lock().unwrap().is_empty()); + assert!(receive.try_recv().is_err()); + let task = fixture.dispatch(Duration::from_secs(2)); + receive.recv().await.unwrap(); + fixture.broker.disconnect(&fixture.machine); + assert!(task.await.unwrap().unwrap_err().message.contains("unknown")); + let (new, mut receive) = fixture.connect(); + fixture.broker.remove(&fixture.machine, &old); + assert!(Arc::ptr_eq( + fixture + .broker + .connections + .lock() + .unwrap() + .get(&fixture.machine) + .unwrap(), + &new + )); + assert!(receive.try_recv().is_err()); + let task = fixture.dispatch(Duration::from_secs(2)); + receive.recv().await.unwrap(); + fixture.broker.disconnect(&fixture.machine); + assert!(task.await.unwrap().unwrap_err().message.contains("unknown")); + } +} diff --git a/dashboard/src/shale.rs b/dashboard/src/shale.rs new file mode 100644 index 0000000000000000000000000000000000000000..a1bf9f8638f5daa9570bccd528b4b9b18f40f702 --- /dev/null +++ b/dashboard/src/shale.rs @@ -0,0 +1,847 @@ +use crate::*; +use rmcp::{ + ErrorData, RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, ToolAnnotations, + }, + service::RequestContext, +}; +use scraper::{Html, Selector}; + +pub struct Backend { + pub(crate) origin: url::Url, + http: reqwest::Client, + slots: Semaphore, +} +impl Backend { + pub fn new(address: &str, http: reqwest::ClientBuilder) -> Result { + let origin = url::Url::parse(address)?; + if origin.scheme() != "https" + || origin.host_str().is_none() + || !origin.username().is_empty() + || origin.password().is_some() + || origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + { + return Err(Error::new(500, "Set an HTTPS origin for Shale.")); + } + Ok(Self { + origin, + http: http + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .build()?, + slots: Semaphore::new(8), + }) + } + async fn get( + &self, + path: &str, + session: Option<&str>, + ) -> Result<(StatusCode, HeaderMap, String)> { + self.request(Method::GET, self.origin.join(path)?, session, None) + .await + } + async fn request( + &self, + method: Method, + target: url::Url, + session: Option<&str>, + form: Option<&HashMap>, + ) -> Result<(StatusCode, HeaderMap, String)> { + if target.origin() != self.origin.origin() + || !target.username().is_empty() + || target.password().is_some() + || target.fragment().is_some() + { + return Err(Error::new(400, "Open a page on this Shale instance.")); + } + let _slot = tokio::time::timeout(Duration::from_secs(5), self.slots.acquire()) + .await + .map_err(|_| Error::new(429, "Shale is busy. Try again in a moment."))??; + let mut request = self.http.request(method, target.clone()); + if let Some(session) = session { + let mut cookie = axum::http::HeaderValue::from_str(&format!("SessionID={session}"))?; + cookie.set_sensitive(true); + request = request.header("cookie", cookie); + } + if let Some(form) = form { + request = request + .header("origin", self.origin.origin().ascii_serialization()) + .header("referer", target.as_str()) + .form(form); + } + let mut response = request + .send() + .await + .map_err(|_| Error::new(502, "Shale couldn't answer. Open it and check its status."))?; + let status = response.status(); + let headers = response.headers().clone(); + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| { + Error::new( + 502, + "The Shale response was interrupted. Open it to check the result.", + ) + })? { + if body.len() + chunk.len() > 4 * 1024 * 1024 { + return Err(Error::new( + 502, + "The Shale page is too large. Narrow the selection.", + )); + } + body.extend_from_slice(&chunk); + } + Ok((status, headers, String::from_utf8(body)?)) + } + async fn page(&self, target: &url::Url, session: &str) -> Result { + let (status, _, body) = self + .request(Method::GET, target.clone(), Some(session), None) + .await?; + match status { + StatusCode::OK => Ok(body), + StatusCode::BAD_REQUEST => Err(Error::new( + 400, + "Shale rejected this request. Check the fields or use Shale's issue filter syntax.", + )), + StatusCode::FORBIDDEN | StatusCode::NOT_FOUND => Err(Error::new( + 403, + "Shale doesn't allow access to this repository. Check your account's permissions.", + )), + status if status.is_redirection() || status == StatusCode::UNAUTHORIZED => { + Err(Error::new(401, "The Shale session expired. Link it again.")) + } + _ => Err(Error::new( + 502, + "Shale couldn't open this page. Check it in Shale.", + )), + } + } +} + +fn document(html: &str, page: &str, repository: Option<&str>) -> Result { + let document = Html::parse_document(html); + if document + .select(&Selector::parse("body").unwrap()) + .next() + .and_then(|body| body.attr("id")) + != Some(page) + || repository.is_some_and(|name| { + document + .select(&Selector::parse("meta[name='astheno.shale.repo.name']").unwrap()) + .next() + .and_then(|meta| meta.attr("content")) + != Some(name) + }) + { + return Err(Error::new( + 502, + "Shale's page changed. Open it to check the result.", + )); + } + Ok(document) +} +fn text(element: scraper::ElementRef<'_>) -> String { + element.text().collect::().trim().to_owned() +} +fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Result { + if repository.is_empty() + || repository.len() > 255 + || matches!(repository, "." | ".." | "-") + || repository + .chars() + .any(|c| c.is_control() || c.is_whitespace() || "/\\%?#".contains(c)) + { + return Err(Error::new( + 400, + "Choose a repository from this connection's access.", + )); + } + let mut target = origin.clone(); + target + .path_segments_mut() + .unwrap() + .clear() + .push(repository) + .extend(suffix.iter().copied()); + Ok(target) +} +fn session(app: &App, owner: &str) -> Result { + let credential = mcp::get( + &app.mcp.db.lock().unwrap(), + &format!("shale-session:{owner}"), + )?; + if credential["origin"] != app.shale.origin.as_str() { + return Err(Error::new( + 401, + "Link your Shale account from the dashboard's MCP tab.", + )); + } + credential["session"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| Error::new(401, "Link your Shale account from the dashboard's MCP tab.")) +} +pub async fn repositories(app: &App, owner: &str) -> Result> { + let session = session(app, owner)?; + username( + &app.shale + .page(&app.shale.origin.join("/-/settings")?, &session) + .await?, + )?; + let body = app.shale.page(&app.shale.origin, &session).await?; + let document = document(&body, "page-index", None)?; + let mut repositories = Vec::new(); + for row in document.select(&Selector::parse(".grid-container table tbody tr").unwrap()) { + let cells: Vec<_> = row.select(&Selector::parse("td").unwrap()).collect(); + if cells.len() != 3 { + return Err(Error::new( + 502, + "Shale's repository list changed. Open Shale to browse it.", + )); + } + let link = cells[0] + .select(&Selector::parse("a").unwrap()) + .next() + .ok_or_else(|| { + Error::new( + 502, + "Shale's repository list changed. Open Shale to browse it.", + ) + })?; + let name = text(link); + let target = app + .shale + .origin + .join(link.attr("href").unwrap_or_default())?; + if target != repository_path(&app.shale.origin, &name, &[""])? + || repositories.iter().any(|r: &Value| r["id"] == name) + { + return Err(Error::new( + 502, + "Shale's repository list changed. Open Shale to browse it.", + )); + } + repositories.push(json!({"id":name,"name":name,"description":text(cells[1])})); + } + if repositories.len() > 1024 { + return Err(Error::new( + 502, + "The repository list is too large. Open Shale to narrow it.", + )); + } + Ok(repositories) +} +fn issue(html: &str, repository: &str, id: Option) -> Result { + let document = document(html, "page-issue", Some(repository))?; + let spans: Vec<_> = document + .select(&Selector::parse("h1 > span").unwrap()) + .collect(); + let issue_id = spans.first().and_then(|s| { + text(*s) + .strip_prefix('#') + .and_then(|s| s.parse::().ok()) + }); + let status = document + .select(&Selector::parse("select[name=status] option[selected]").unwrap()) + .next() + .and_then(|e| e.attr("value")); + if spans.len() != 2 + || issue_id.is_none_or(|n| n == 0 || id.is_some_and(|id| n != id)) + || status.is_none() + { + return Err(Error::new( + 502, + "Shale's issue page changed. Open the issue to check it.", + )); + } + let mut comments = Vec::new(); + for comment in document.select(&Selector::parse("li.comment").unwrap()) { + let content = comment + .select(&Selector::parse(".markdown").unwrap()) + .next() + .ok_or_else(|| { + Error::new( + 502, + "Shale's comments changed. Open the issue to read them.", + ) + })?; + let author = comment + .select(&Selector::parse(".n-card__header a[href^='/~']").unwrap()) + .next() + .map(text); + let time = comment + .select(&Selector::parse(".n-card__header span[title]").unwrap()) + .next() + .and_then(|e| e.attr("title")); + comments.push( + json!({"id":comment.attr("id"),"author":author,"createdAt":time,"text":text(content)}), + ); + } + let labels: Vec<_> = document + .select(&Selector::parse("dd.sidebar-labels a").unwrap()) + .map(text) + .collect(); + Ok( + json!({"repository":repository,"id":issue_id,"title":text(spans[1]),"status":status,"labels":labels,"comments":comments}), + ) +} +fn current(app: &App, grant: &Value, credential: &str) -> Result<()> { + let db = app.mcp.db.lock().unwrap(); + if mcp::get(&db, &format!("grant:{}", string(&grant["id"])))? != *grant + || mcp::get(&db, &format!("shale-session:{}", string(&grant["user"])))?["session"] + != credential + { + return Err(Error::new( + 401, + "This connection changed or was revoked. Connect again.", + )); + } + Ok(()) +} + +#[derive(Clone)] +struct Shale(Arc); +impl ServerHandler for Shale { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + } + async fn list_tools( + &self, + _: Option, + _: RequestContext, + ) -> std::result::Result { + let tools = [ + ("list_repositories", "List repositories granted to this connection.", json!({}), json!([]), true), + ("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), + ("get_issue", "Read an issue with rendered comment text and labels.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1}}), json!(["repository","id"]), true), + ("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), + ("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), + ("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), + ("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), + ].into_iter().map(|(name, description, properties, required, read)| { + Tool::new(name, description, json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}).as_object().unwrap().clone()) + .with_annotations(ToolAnnotations::new().read_only(read).idempotent(read)) + }).collect(); + Ok(ListToolsResult { + tools, + ..Default::default() + }) + } + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> std::result::Result { + let result: Result = async { + let app = &self.0; + let grant = &context.extensions.get::() + .and_then(|p| p.extensions.get::()) + .ok_or_else(|| Error::new(401, "This connection expired. Connect again."))?.0; + let name = request.name.as_ref(); + let arguments = request.arguments.unwrap_or_default(); + let allowed: &[&str] = match name { + "list_repositories" => &[], "list_issues" => &["repository", "q"], "get_issue" => &["repository", "id"], + "create_issue" => &["repository", "title", "description"], "comment_issue" => &["repository", "id", "comment"], + "set_issue_status" => &["repository", "id", "status"], "set_issue_title" => &["repository", "id", "title"], + _ => return Err(Error::new(404, "No tool with that name.")), + }; + if arguments.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err(Error::new(400, "Use the fields listed for this tool.")); + } + let write = !matches!(name, "list_repositories" | "list_issues" | "get_issue"); + if !array(&grant["scopes"]).iter().any(|s| s == if write {"shale:write"} else {"shale:read"}) { + return Err(Error::new(403, "This connection allows reads only. Connect again to request issue editing.")); + } + let credential = session(app, string(&grant["user"]))?; + current(app, grant, &credential)?; + if name == "list_repositories" { + let mut repositories = repositories(app, string(&grant["user"])).await?; + repositories.retain(|r| array(&grant["resources"]).contains(&r["id"])); + current(app, grant, &credential)?; + return Ok(json!({"repositories":repositories})); + } + let repository = arguments.get("repository").and_then(Value::as_str) + .filter(|r| array(&grant["resources"]).iter().any(|id| id == *r)) + .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?; + let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?; + let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else { + Some(arguments.get("id").and_then(Value::as_u64).filter(|n| *n > 0) + .ok_or_else(|| Error::new(400, "Choose a positive issue ID."))?) + }; + if let Some(id) = issue_id { target = repository_path(&app.shale.origin, repository, &["issues", &id.to_string()])?; } + if name == "create_issue" { target = repository_path(&app.shale.origin, repository, &["issues", "new"])?; } + if let Some(q) = arguments.get("q") { + let q = q.as_str().filter(|q| q.len() <= 4096).ok_or_else(|| Error::new(400, "Narrow the issue search."))?; + target.query_pairs_mut().append_pair("q", q); + } + let mut fields = HashMap::new(); + for (key, max) in [("title", 4096), ("description", 262144), ("comment", 262144), ("status", 64)] { + if allowed.contains(&key) { + let value = arguments.get(key).and_then(Value::as_str) + .filter(|v| v.len() <= max && (key == "description" || !v.trim().is_empty())) + .or_else(|| (key == "description" && !arguments.contains_key(key)).then_some("")) + .ok_or_else(|| Error::new(400, format!("Enter {key} within the tool's size limit.")))?; + fields.insert(key.to_owned(), value.to_owned()); + } + } + username(&app.shale.page(&app.shale.origin.join("/-/settings")?, &credential).await?)?; + let body = app.shale.page(&target, &credential).await?; + if name == "list_issues" { + let document = document(&body, "page-issues", Some(repository))?; + let mut issues = Vec::new(); + for row in document.select(&Selector::parse(".grid-container table tbody tr").unwrap()) { + let cells: Vec<_> = row.select(&Selector::parse("td").unwrap()).collect(); + if cells.len() != 6 { return Err(Error::new(502, "Shale's issue list changed. Open it in Shale.")); } + let id = text(cells[0]).strip_prefix('#').and_then(|s| s.parse::().ok()).filter(|n| *n > 0) + .ok_or_else(|| Error::new(502, "Shale's issue list changed. Open it in Shale."))?; + let status = cells[1].select(&Selector::parse("span[class]").unwrap()) + .flat_map(|span| span.value().classes()).find_map(|class| class.strip_prefix("issuestatus-")) + .filter(|status| !status.is_empty()) + .ok_or_else(|| Error::new(502, "Shale's issue list changed. Open it in Shale."))?; + issues.push(json!({"id":id,"title":text(cells[2]),"status":status,"author":text(cells[3]), + "modifiedAt":cells[4].select(&Selector::parse("span[title]").unwrap()).next().and_then(|e| e.attr("title")), + "createdAt":cells[5].select(&Selector::parse("span[title]").unwrap()).next().and_then(|e| e.attr("title"))})); + } + current(app, grant, &credential)?; + return Ok(json!({"repository":repository,"issues":issues})); + } + if !write { + let issue = issue(&body, repository, issue_id)?; + current(app, grant, &credential)?; + return Ok(json!({"issue":issue})); + } + let form_body = if name == "set_issue_title" { + issue(&body, repository, issue_id)?; + let mut edit = target.clone(); edit.query_pairs_mut().append_pair("edit", "title"); + app.shale.page(&edit, &credential).await? + } else { body }; + let post_target = { + let document = if name == "set_issue_title" { Html::parse_fragment(&form_body) } else { + document(&form_body, if name == "create_issue" {"page-new-issue"} else {"page-issue"}, Some(repository))? + }; + let forms: Vec<_> = document.select(&Selector::parse("form[method=post]").unwrap()) + .filter(|form| if name == "create_issue" { form.select(&Selector::parse("input[name=title]").unwrap()).next().is_some() } + else { form.select(&Selector::parse("input[name=t]").unwrap()).any(|input| input.attr("value") == Some(match name { + "comment_issue" => "comment", "set_issue_status" => "status", _ => "title", + })) }).collect(); + if forms.len() != 1 { return Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")); } + let form = forms[0]; + let post_target = target.join(form.attr("action").unwrap_or_default())?; + if post_target != target || form.attr("enctype").is_some_and(|s| s != "application/x-www-form-urlencoded") { + return Err(Error::new(502, "Shale's form destination changed. Open the issue to edit it.")); + } + if name == "set_issue_status" && !form.select(&Selector::parse("select[name=status] option").unwrap()) + .any(|option| option.attr("value") == fields.get("status").map(String::as_str)) { + return Err(Error::new(400, "Choose a status available on this issue in Shale.")); + } + for field in form.select(&Selector::parse("input[type=hidden], input[hidden]").unwrap()) { + if let Some(name) = field.attr("name") { + if fields.insert(name.to_owned(), field.attr("value").unwrap_or_default().to_owned()).is_some() { + return Err(Error::new(502, "Shale's form fields changed. Open the issue to edit it.")); + } + } + } + fields.insert("timezone".to_owned(), "UTC".to_owned()); + fields.insert("tzoffset".to_owned(), "+00:00".to_owned()); + post_target + }; + current(app, grant, &credential)?; + let outcome: Result = async { + let (status, headers, body) = app.shale.request(Method::POST, post_target.clone(), Some(&credential), Some(&fields)).await?; + let body = if matches!(status, StatusCode::SEE_OTHER | StatusCode::FOUND) { + let target = headers.get("location").and_then(|v| v.to_str().ok()) + .and_then(|location| post_target.join(location).ok()) + .ok_or_else(|| Error::new(502, "Shale didn't return an issue destination."))?; + let prefix = repository_path(&app.shale.origin, repository, &["issues", ""])?; + if target.origin() != app.shale.origin.origin() || !target.path().starts_with(prefix.path()) + || target.query().is_some() || target.fragment().is_some() + || target.path()[prefix.path().len()..].parse::().ok().is_none_or(|n| n == 0) + || !target.username().is_empty() || target.password().is_some() + { return Err(Error::new(502, "Shale returned a different destination.")); } + app.shale.page(&target, &credential).await? + } else if status == StatusCode::OK { body } else { + return Err(Error::new(502, "Shale didn't confirm the issue change.")); + }; + let result = issue(&body, repository, issue_id)?; + current(app, grant, &credential)?; + Ok(json!({"issue":result})) + }.await; + outcome.map_err(|_| Error::new(502, "The write outcome is unknown. Check the issue in Shale before retrying.")) + }.await; + Ok(match result { + Ok(value) => CallToolResult::structured(value), + Err(error) => CallToolResult::error(vec![ContentBlock::text(error.message)]), + } + .into()) + } +} +pub fn router(app: Arc) -> Router { + let state = app.clone(); + mcp::router(app, "shale", move || Ok(Shale(state.clone()))) +} + +fn session_cookie(headers: &HeaderMap) -> Result { + let sessions: Vec<_> = headers + .get_all("set-cookie") + .iter() + .filter_map(|value| value.to_str().ok()) + .filter_map(|value| value.split(';').next()?.strip_prefix("SessionID=")) + .collect(); + if sessions.len() != 1 + || sessions[0].is_empty() + || sessions[0].len() > 4096 + || !sessions[0] + .bytes() + .all(|b| matches!(b, 0x21 | 0x23..=0x2b | 0x2d..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e)) + { + return Err(Error::new( + 502, + "Shale didn't return a session. Link it again.", + )); + } + Ok(sessions[0].to_owned()) +} +fn fields(query: &str) -> Result> { + if query.len() > 16384 { + return Err(Error::new( + 400, + "This sign-in response is too large. Link Shale again.", + )); + } + let mut fields = HashMap::new(); + for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { + if fields + .insert(key.into_owned(), value.into_owned()) + .is_some() + { + return Err(Error::new( + 400, + "This sign-in response has repeated fields. Link Shale again.", + )); + } + } + Ok(fields) +} +fn username(page: &str) -> Result { + let document = Html::parse_document(page); + if document + .select(&Selector::parse("body#page-user-settings").unwrap()) + .next() + .is_none() + { + return Err(Error::new(401, "The Shale session expired. Link it again.")); + } + let names: Vec<_> = document + .select(&Selector::parse("kbd").unwrap()) + .map(|element| element.text().collect::()) + .collect(); + if names.len() != 1 || names[0].is_empty() { + return Err(Error::new( + 502, + "Shale's account page changed. Open Shale to check your account.", + )); + } + Ok(names.into_iter().next().unwrap()) +} + +pub async fn manage( + app: Arc, + method: &Method, + owner: &Value, + body: &Value, +) -> Result { + let owner_id = string(&owner["id"]); + let key = format!("shale-session:{owner_id}"); + match *method { + Method::POST => { + let pending = if let Some(id) = body["request"].as_str() { + let db = app.mcp.db.lock().unwrap(); + let key = format!("pending:{}", mcp::hash(id)); + let pending = mcp::get(&db, &key)?; + if pending["owner"] != owner_id || pending["resource"] != app.mcp.resource("shale") + { + return Err(Error::new( + 403, + "Open your Shale connection request before linking.", + )); + } + Some(id.to_owned()) + } else { + None + }; + let (status, headers, _) = app.shale.get("/-/login", None).await?; + if status != StatusCode::FOUND { + return Err(Error::new( + 502, + "Shale couldn't start sign-in. Open Shale and try again.", + )); + } + let authorization = url::Url::parse( + headers + .get("location") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(), + )?; + let issuer = url::Url::parse(&env( + "STUDIO_KEYCLOAK_URL", + &format!("https://keycloak.{}", env("STUDIO_DOMAIN", "studio.test")), + ))?; + let parameters = fields(authorization.query().unwrap_or_default())?; + if authorization.origin() != issuer.origin() + || authorization.path() != "/realms/master/protocol/openid-connect/auth" + || !authorization.username().is_empty() + || authorization.password().is_some() + || authorization.fragment().is_some() + || parameters.get("redirect_uri") + != Some(&app.shale.origin.join("/-/callback")?.to_string()) + || parameters.get("response_type").map(String::as_str) != Some("code") + || parameters + .get("state") + .is_none_or(|s| s.is_empty() || s.len() > 1024) + { + return Err(Error::new( + 502, + "Shale's sign-in destination doesn't match this instance. Check its OIDC settings.", + )); + } + let nonce = mcp::secret(); + let mut db = app.mcp.db.lock().unwrap(); + let tx = db.transaction()?; + tx.execute("DELETE FROM records WHERE substr(key,1,11)='shale-link:' AND json_extract(value,'$.owner')=?", [owner_id])?; + mcp::put( + &tx, + &format!("shale-link:{}", mcp::hash(&nonce)), + &json!({"owner":owner_id,"authorization":authorization.as_str(),"phase":"prepared","request":pending}), + 600, + )?; + tx.commit()?; + Ok(axum::Json(json!({"redirect":app.shale.origin.join(&format!("/-/studio-mcp/{nonce}"))?.as_str()})).into_response()) + } + Method::DELETE => { + let session = { + let mut db = app.mcp.db.lock().unwrap(); + let tx = db.transaction()?; + let session = mcp::get(&tx, &key)?; + mcp::delete(&tx, &key)?; + for grant in mcp::list(&tx, "grant:")? { + if grant["user"] == owner_id && grant["resource"] == app.mcp.resource("shale") { + mcp::revoke(&tx, string(&grant["id"]))?; + } + } + tx.execute("DELETE FROM records WHERE substr(key,1,11)='shale-link:' AND json_extract(value,'$.owner')=?", [owner_id])?; + tx.commit()?; + session + }; + if session["origin"] == app.shale.origin.as_str() { + app.shale + .get("/-/logout", session["session"].as_str()) + .await?; + } + Ok(StatusCode::NO_CONTENT.into_response()) + } + _ => Err(Error::new(405, "Link or unlink Shale from MCP settings.")), + } +} + +pub async fn oauth(app: Arc, request: Request) -> Response { + let callback = request.uri().path() == "/oauth/shale/callback"; + let result: Result = async move { + if request.method() != Method::GET + || request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&app.shale.origin[url::Position::BeforeHost..url::Position::AfterPort]) + || request.headers().get("origin").is_some_and(|v| v.to_str().ok() != Some(app.shale.origin.origin().ascii_serialization().as_str())) + { + return Err(Error::new(403, "Start Shale linking from your dashboard's MCP tab.")); + } + if let Some(nonce) = request.uri().path().strip_prefix("/oauth/shale/link/") { + if nonce.len() != 43 || !nonce.bytes().all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)) || request.uri().query().is_some() { + return Err(Error::new(400, "This Shale link is incomplete. Start linking again.")); + } + let mut db = app.mcp.db.lock().unwrap(); + let tx = db.transaction()?; + let key = format!("shale-link:{}", mcp::hash(nonce)); + let mut link = mcp::get(&tx, &key)?; + if link.is_null() || link["phase"] != "prepared" { + return Err(Error::new(410, "This Shale link expired or was used. Start linking again.")); + } + link["phase"] = json!("claimed"); + tx.execute("UPDATE records SET value=? WHERE key=?", rusqlite::params![link.to_string(), key])?; + tx.commit()?; + return Ok((StatusCode::FOUND, [ + ("location", string(&link["authorization"]).to_owned()), + ("set-cookie", format!("studio_mcp_shale_link={nonce}; Path=/-/callback; Secure; HttpOnly; SameSite=Lax; Max-Age=600")), + ]).into_response()); + } + if !callback { + return Err(Error::new(404, "No Shale sign-in endpoint here.")); + } + let cookies: Vec<_> = request.headers().get_all("cookie").iter() + .filter_map(|v| v.to_str().ok()).flat_map(|v| v.split(';')) + .filter_map(|part| part.trim().strip_prefix("studio_mcp_shale_link=")).collect(); + if cookies.len() != 1 || cookies[0].len() != 43 { + return Err(Error::new(400, "This Shale link cookie is missing. Start linking again.")); + } + let query = request.uri().query().unwrap_or_default().to_owned(); + let parameters = fields(&query)?; + let pending_key = format!("shale-link:{}", mcp::hash(cookies[0])); + let link = { + let mut db = app.mcp.db.lock().unwrap(); + let tx = db.transaction()?; + let mut link = mcp::get(&tx, &pending_key)?; + if link.is_null() || link["phase"] != "claimed" { + return Err(Error::new(410, "This Shale link expired or was used. Start linking again.")); + } + let authorization = url::Url::parse(string(&link["authorization"]))?; + let expected = fields(authorization.query().unwrap_or_default())?; + if parameters.get("state") != expected.get("state") { + return Err(Error::new(403, "This response belongs to another Shale sign-in. Start linking again.")); + } + link["phase"] = json!("processing"); + tx.execute("UPDATE records SET value=? WHERE key=?", rusqlite::params![link.to_string(), pending_key])?; + tx.commit()?; + link + }; + if parameters.get("code").is_none_or(|code| code.is_empty() || code.len() > 4096) || parameters.contains_key("error") { + return Err(Error::new(400, "Shale sign-in was declined or incomplete. Start linking again.")); + } + let identity = host::call(json!({"operation":"iam.request", "path":format!("/users/{}", string(&link["owner"])), "method":"GET", "body":null})).await?; + if identity["body"]["enabled"] != true { + return Err(Error::new(403, "This dashboard account is disabled. Contact its administrator.")); + } + let (status, headers, _) = app.shale.get(&format!("/-/callback?{query}"), None).await?; + if !status.is_redirection() { + return Err(Error::new(502, "Shale couldn't finish sign-in. Link it again.")); + } + let session = session_cookie(&headers)?; + let verified: Result<()> = async { + let (status, _, body) = app.shale.get("/-/settings", Some(&session)).await?; + if status != StatusCode::OK || username(&body)? != identity["body"]["username"] { + return Err(Error::new(403, "Sign in to Shale with the same account as your dashboard, then link it again.")); + } + let owner = string(&link["owner"]); + let session_key = format!("shale-session:{owner}"); + let previous = mcp::get(&app.mcp.db.lock().unwrap(), &session_key)?; + if previous["origin"] == app.shale.origin.as_str() && previous["session"] != session { + app.shale.get("/-/logout", previous["session"].as_str()).await?; + } + let mut db = app.mcp.db.lock().unwrap(); + let tx = db.transaction()?; + if mcp::get(&tx, &pending_key)?["phase"] != "processing" { + return Err(Error::new(410, "This Shale link was cancelled. Start linking again.")); + } + mcp::put(&tx, &session_key, &json!({"origin":app.shale.origin.as_str(),"session":session,"linkedAt":now()}), 0)?; + mcp::delete(&tx, &pending_key)?; + tx.commit()?; + Ok(()) + }.await; + if let Err(error) = verified { + let _ = app.shale.get("/-/logout", Some(&session)).await; + return Err(error); + } + let mut target = app.mcp.origin.join("mcp")?; + if let Some(request) = link["request"].as_str() { + target.query_pairs_mut().append_pair("request", request); + } + Ok((StatusCode::SEE_OTHER, [("location", target.to_string())]).into_response()) + }.await; + let mut response = match result { + Ok(response) => response, + Err(error) => error.into_response(), + }; + response + .headers_mut() + .insert("cache-control", "no-store".parse().unwrap()); + response + .headers_mut() + .insert("referrer-policy", "no-referrer".parse().unwrap()); + if callback { + response.headers_mut().append( + "set-cookie", + "studio_mcp_shale_link=; Path=/-/callback; Secure; HttpOnly; SameSite=Lax; Max-Age=0" + .parse() + .unwrap(), + ); + } + response +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn repository_names_cannot_change_origin_or_path_segments() { + let origin = url::Url::parse("https://shale.studio.test").unwrap(); + for name in [ + "", + ".", + "..", + "-", + "../other", + "one/two", + "one\\two", + "%2e%2e", + "one?x", + "one#x", + "one\n", + "//foreign.test", + ] { + assert!( + repository_path(&origin, name, &["issues", "1"]).is_err(), + "{name:?}" + ); + } + let path = repository_path(&origin, "雪☃", &["issues", "1"]).unwrap(); + assert_eq!(path.origin(), origin.origin()); + assert_eq!(path.path(), "/%E9%9B%AA%E2%98%83/issues/1"); + } + #[test] + fn issue_pages_must_match_repository_identity_and_issue_number() { + let page = "

#3Snow & ☃

"; + assert_eq!(issue(page, "owned", Some(3)).unwrap()["title"], "Snow & ☃"); + assert!(issue(page, "other", Some(3)).is_err()); + assert!(issue(page, "owned", Some(4)).is_err()); + assert!(issue(&page.replace("page-issue", "page-login"), "owned", Some(3)).is_err()); + assert!(issue(&page.replace("selected", ""), "owned", Some(3)).is_err()); + } + #[test] + fn account_identity_uses_html_text_and_rejects_login_or_changed_markup() { + assert_eq!( + username("snow&flake☃").unwrap(), + "snow&flake☃" + ); + for html in [ + "snow", + "", + "snowother", + ] { + assert!(username(html).is_err()); + } + } + #[test] + fn callback_fields_and_session_cookie_refuse_ambiguity_and_header_injection() { + assert!(fields("state=one&state=two").is_err()); + assert!(fields(&"s".repeat(16385)).is_err()); + let mut headers = HeaderMap::new(); + headers.append( + "set-cookie", + "SessionID=owned:signature; Path=/; Secure; HttpOnly" + .parse() + .unwrap(), + ); + assert_eq!(session_cookie(&headers).unwrap(), "owned:signature"); + headers.append("set-cookie", "SessionID=other; Path=/".parse().unwrap()); + assert!(session_cookie(&headers).is_err()); + for value in [ + "SessionID=", + "SessionID=with space", + "SessionID=bad,other", + "SessionID=\"quoted\"", + ] { + let mut headers = HeaderMap::new(); + headers.insert("set-cookie", value.parse().unwrap()); + assert!(session_cookie(&headers).is_err()); + } + } +} diff --git a/dashboard/src/storage.rs b/dashboard/src/storage.rs new file mode 100644 index 0000000000000000000000000000000000000000..0a0d8f06c9c0c3bdca3d82b5b899e1020d6d41c5 --- /dev/null +++ b/dashboard/src/storage.rs @@ -0,0 +1,255 @@ +use crate::*; + +async fn datasets() -> Result { + host::call(json!({"operation":"storage.datasets"})).await +} +fn leaves(vdev: &Value, out: &mut Vec) { + if let Some(children) = vdev["vdevs"].as_object() { + for child in children.values() { + leaves(child, out); + } + } else { + let errors = number(&vdev["read_errors"]) + + number(&vdev["write_errors"]) + + number(&vdev["checksum_errors"]); + let mut issues = Vec::new(); + if vdev["state"] != "ONLINE" { + issues.push(string(&vdev["state"]).to_lowercase()); + } + if errors > 0.0 { + issues.push(format!( + "{errors} error{}", + if errors == 1.0 { "" } else { "s" } + )); + } + 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(", "))}})); + } +} +async fn pool() -> Result { + let value = host::call(json!({"operation":"storage.pool"})).await?; + let name = string(&value["name"]); + let pool = &value["status"]["pools"][name]; + let mut output = value["summary"].clone(); + output["name"] = json!(name); + output["state"] = pool["state"].clone(); + output["scan"] = Value::Null; + let mut vdevs = Vec::new(); + let mut striped = Vec::new(); + for vdev in pool["vdevs"][name]["vdevs"] + .as_object() + .into_iter() + .flat_map(|v| v.values()) + { + if vdev["vdevs"].is_object() { + let mut disks = Vec::new(); + leaves(vdev, &mut disks); + vdevs.push(json!({"name":vdev["name"],"state":vdev["state"],"disks":disks})); + } else { + leaves(vdev, &mut striped); + } + } + if !striped.is_empty() { + vdevs.insert( + 0, + json!({"name":name,"state":pool["state"],"disks":striped}), + ); + } + for kind in ["logs", "l2cache", "special", "dedup", "spares"] { + let children: Vec<_> = pool[kind] + .as_object() + .into_iter() + .flat_map(|v| v.values()) + .collect(); + if children.is_empty() { + continue; + } + let worst = children + .iter() + .find(|v| v["state"] != "ONLINE" && v["state"] != "AVAIL") + .unwrap_or(&children[0]); + let mut disks = Vec::new(); + for child in &children { + leaves(child, &mut disks); + } + vdevs.push(json!({"name":if kind=="l2cache" {"cache"} else {kind},"state":worst["state"],"disks":disks})); + } + output["vdevs"] = json!(vdevs); + let scan = &pool["scan_stats"]; + if ["SCANNING", "FINISHED", "CANCELED"].contains(&string(&scan["state"])) { + 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"]}); + } + output["errors"] = json!(if number(&pool["error_count"]) > 0.0 { + format!("{} data errors", number(&pool["error_count"])) + } else { + "No known data errors".into() + }); + Ok(output) +} +pub async fn snapshots(dataset: &str) -> Result { + validate(dataset, false)?; + host::call(json!({"operation":"storage.snapshots","dataset":dataset})).await +} +pub fn validate(name: &str, snapshot: bool) -> Result<()> { + let regex = if snapshot { + r"^[\w.: ][\w.: -]*$" + } else { + r"^[\w.: ][\w.: -]*(/[\w.: -]+)*$" + }; + if regex::Regex::new(regex).unwrap().is_match(name) { + Ok(()) + } else { + Err(Error::new( + 400, + "That isn't a ZFS name. Pick one from the list.", + )) + } +} +async fn existing(dataset: &str, from: &str, to: &str) -> Result<()> { + validate(from, true)?; + validate(to, true)?; + let values = snapshots(dataset).await?; + for name in [from, to] { + if !array(&values).iter().any(|s| s["name"] == name) { + return Err(Error::new(404, format!("No snapshot {dataset}@{name}"))); + } + } + Ok(()) +} +pub fn unescape(name: &str) -> String { + let bytes = name.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\\' + && i + 4 < bytes.len() + && bytes[i + 1..i + 5] + .iter() + .all(|b| (b'0'..=b'7').contains(b)) + { + let octal = std::str::from_utf8(&bytes[i + 1..i + 5]).unwrap(); + out.push(u16::from_str_radix(octal, 8).unwrap() as u8); + i += 5; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} +pub async fn route( + app: Arc, + method: &Method, + parts: &[&str], + query: &HashMap, + body: Value, +) -> Result { + let value = match parts { + [] if method == Method::GET => { + let found=app.cache.get("storage".into(),Duration::from_secs(30),move || async move { + 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"]}); + 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})) + }).await?; + return Ok(found.response()); + } + ["snapshots"] if method == Method::GET => { + let dataset = query + .get("dataset") + .ok_or_else(|| Error::new(400, "Pick a dataset."))?; + json!( + array(&snapshots(dataset).await?) + .iter() + .filter(|s| !string(&s["name"]).starts_with("index-")) + .cloned() + .collect::>() + ) + } + ["reclaim"] if method == Method::GET => { + let dataset = query + .get("dataset") + .ok_or_else(|| Error::new(400, "Pick a dataset."))?; + let from = query + .get("from") + .ok_or_else(|| Error::new(400, "Pick a snapshot."))?; + let to = query + .get("to") + .ok_or_else(|| Error::new(400, "Pick a snapshot."))?; + existing(dataset, from, to).await?; + let value = host::call( + json!({"operation":"storage.reclaim","dataset":dataset,"from":from,"to":to}), + ) + .await?; + let bytes = string(&value) + .lines() + .find_map(|line| line.strip_prefix("reclaim\t")) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0); + json!({"bytes":bytes}) + } + ["destroy"] if method == Method::POST => { + let (dataset, from, to) = ( + string(&body["dataset"]), + string(&body["from"]), + string(&body["to"]), + ); + existing(dataset, from, to).await?; + host::call( + json!({"operation":"storage.destroy","dataset":dataset,"from":from,"to":to}), + ) + .await?; + app.cache.invalidate("storage"); + Value::Null + } + ["removed"] if method == Method::GET => { + let dataset = query + .get("dataset") + .ok_or_else(|| Error::new(400, "Pick a dataset."))?; + let snapshot = query + .get("snapshot") + .ok_or_else(|| Error::new(400, "Pick a snapshot."))?; + validate(dataset, false)?; + validate(snapshot, true)?; + let values = datasets().await?; + let mount = array(&values) + .iter() + .find(|d| d["name"] == *dataset) + .and_then(|d| d["mountpoint"].as_str()) + .ok_or_else(|| { + Error::new( + 409, + format!("{dataset} isn't mounted, so its deleted files can't be listed"), + ) + })?; + let value = host::call( + json!({"operation":"storage.removed","dataset":dataset,"snapshot":snapshot}), + ) + .await?; + let text = string(&value); + let mut files = Vec::new(); + for line in text.lines() { + if let Some(path) = line.strip_prefix("-\t") { + let path = unescape(path); + let Ok(relative) = std::path::Path::new(&path).strip_prefix(mount) else { + continue; + }; + let saved = std::path::Path::new(mount) + .join(".zfs/snapshot") + .join(snapshot) + .join(relative); + if let Ok(info) = tokio::fs::symlink_metadata(saved).await + && info.is_file() + { + files.push(json!({"path":relative,"size":info.len()})); + } + } + } + files.sort_by(|a, b| number(&b["size"]).total_cmp(&number(&a["size"]))); + json!({"count":files.len(),"largest":files.into_iter().take(4).collect::>()}) + } + _ => return Err(Error::new(404, "Not Found")), + }; + Ok(if value.is_null() { + StatusCode::NO_CONTENT.into_response() + } else { + Document::new(value).response() + }) +} diff --git a/dashboard/src/telemetry.rs b/dashboard/src/telemetry.rs new file mode 100644 index 0000000000000000000000000000000000000000..3149a14b43ee1e12184d5c43849d4023b15f7157 --- /dev/null +++ b/dashboard/src/telemetry.rs @@ -0,0 +1,929 @@ +use crate::*; +use futures::{StreamExt, stream}; + +pub async fn endpoint(app: Arc, service: &str) -> Result { + if let Some((base, _)) = &app.internal { + return Ok(format!("{}services/{}", base.as_str(), encoded(service))); + } + let id = service.to_owned(); + let state = app.clone(); + let found = app + .cache + .get( + format!("endpoint:{service}"), + Duration::from_secs(10), + move || async move { + let values = core::nomad(&state, &format!("/v1/service/{}", encoded(&id))).await?; + let instance = array(&values) + .iter() + .find(|v| v["Address"] == "127.0.0.1") + .or_else(|| array(&values).first()) + .ok_or_else(|| { + Error::new(501, format!("{id} isn't running on this host yet.")) + })?; + let address = string(&instance["Address"]); + Ok(json!(format!( + "http://{}:{}", + if address.contains(':') { + format!("[{address}]") + } else { + address.into() + }, + number(&instance["Port"]) as u16 + ))) + }, + ) + .await?; + Ok(string(&found.value).to_owned()) +} + +pub async fn read( + app: Arc, + url: String, + form: Option>, +) -> Result> { + let state = app.clone(); + let key = format!("read:{url}:{}", serde_json::to_string(&form)?); + app.cache + .get(key, Duration::from_secs(2), move || async move { + let request = if let Some(form) = &form { + state.request(Method::POST, &url)?.form(form) + } else { + state.request(Method::GET, &url)? + }; + let response = request + .timeout(Duration::from_secs(8)) + .send() + .await + .map_err(|_| Error::new(502, "The telemetry store isn't answering."))?; + if !response.status().is_success() { + return Err(Error::new( + 502, + format!("The telemetry store answered {}.", response.status()), + )); + } + if form.is_some() { + Ok(json!(response.text().await?)) + } else { + Ok(response.json().await?) + } + }) + .await +} + +const METRICS: &[(&str, &str)] = &[ + ("host.cpu", "studio_host_cpu_percent"), + ("host.memory", "studio_host_memory_bytes"), + ("host.temperature", "studio_host_temperature_celsius"), + ("host.power", "studio_host_power_watts"), + ("host.gpu", "studio_host_gpu_percent"), + ("host.network", "studio_host_network_bytes_per_second"), + ("service.cpu", "studio_service_cpu_cores"), + ("service.memory", "studio_service_memory_bytes"), + ("vm.cpu", "studio_vm_cpu_percent"), + ("vm.memory", "studio_vm_memory_bytes"), +]; + +fn series(result: &Value, application: bool) -> Value { + json!( + array(&result["data"]["result"]) + .iter() + .take(if application { 12 } else { usize::MAX }) + .map(|row| { + let metric = &row["metric"]; + let name = if application { + let labels = metric + .as_object() + .into_iter() + .flat_map(|v| v.iter()) + .filter(|(k, _)| { + !["__name__", "service", "service_name"].contains(&k.as_str()) + }) + .map(|(k, v)| format!("{k}={}", string(v))) + .collect::>() + .join(" · "); + if labels.is_empty() { + "total".into() + } else { + labels + } + } else { + metric["service"] + .as_str() + .or(metric["vm"].as_str()) + .or(metric["direction"].as_str()) + .unwrap_or("total") + .into() + }; + let t: Vec<_> = array(&row["values"]).iter().map(|v| v[0].clone()).collect(); + let v: Vec<_> = array(&row["values"]) + .iter() + .map(|v| { + let n = string(&v[1]).parse::().unwrap_or(f64::NAN); + if n.is_finite() { json!(n) } else { Value::Null } + }) + .collect(); + json!({"name":name,"t":t,"v":v}) + }) + .collect::>() + ) +} + +pub fn query_number( + query: &HashMap, + key: &str, + default: f64, + min: f64, + max: f64, +) -> Result { + let value = query + .get(key) + .map(|v| v.parse::()) + .transpose() + .map_err(|_| Error::new(400, format!("Invalid {key}.")))? + .unwrap_or(default); + if !value.is_finite() || value < min || value > max { + return Err(Error::new(400, format!("Invalid {key}."))); + } + Ok(value) +} +pub async fn metrics( + app: Arc, + name: &str, + query: &HashMap, + application: Option<&str>, + window: Option<(f64, f64)>, +) -> Result> { + let range = query_number(query, "range", 3600.0, 60.0, 30.0 * 86400.0)?; + let to = window + .map(|(_, to)| to) + .unwrap_or_else(|| (now() / 2.0).floor() * 2.0); + let from = window.map(|(from, _)| from).unwrap_or(to - range); + let service = query.get("service"); + let quote = |s: &str| serde_json::to_string(s).unwrap(); + let selector = if let Some(id) = application { + format!( + "{name}{{service={}}} or {name}{{service_name={}}}", + quote(id), + quote(id) + ) + } else { + let metric = METRICS + .iter() + .find(|(key, _)| *key == name) + .ok_or_else(|| Error::new(400, "Invalid metric."))? + .1; + format!( + "{metric}{}", + service + .map(|id| format!( + "{{{}={}}}", + if name.starts_with("vm.") { + "vm" + } else { + "service" + }, + quote(id) + )) + .unwrap_or_default() + ) + }; + let key = if window.is_some() { + format!("series:{selector}:fixed:{from}:{to}") + } else { + format!("series:{selector}:live:{range}") + }; + let state = app.clone(); + let network = name == "host.network"; + let application = application.is_some(); + app.cache + .get(key, Duration::from_secs(2), move || async move { + let base = endpoint(state.clone(), "victoria-metrics").await?; + let query = params(&[ + ("query", selector), + ("start", from.to_string()), + ("end", to.to_string()), + ("step", ((to - from) / 300.0).round().max(2.0).to_string()), + ]); + let response = read(state, format!("{base}/api/v1/query_range?{query}"), None).await?; + if response.value["status"] != "success" { + return Err(Error::new(502, "The metrics query failed.")); + } + let mut values = series(&response.value, application); + if network { + values + .as_array_mut() + .unwrap() + .sort_by_key(|s| if s["name"] == "down" { 0 } else { 1 }); + } + Ok(values) + }) + .await +} +pub async fn metric_names(app: Arc, id: &str) -> Result { + let query = params(&["service", "service_name"].map(|label| { + ( + "match[]", + format!( + "{{{label}={},__name__!~\"studio_.*\"}}", + serde_json::to_string(id).unwrap() + ), + ) + })); + let url = format!( + "{}/api/v1/label/__name__/values?{query}", + endpoint(app.clone(), "victoria-metrics").await? + ); + let response = read(app, url, None).await?; + let valid = regex::Regex::new(r"^[a-zA-Z_:][a-zA-Z0-9_:]*$").unwrap(); + let mut values: Vec<_> = array(&response.value["data"]) + .iter() + .filter(|v| valid.is_match(string(v))) + .cloned() + .collect(); + values.sort_by(|a, b| string(a).cmp(string(b))); + Ok(json!(values)) +} + +#[derive(Debug, PartialEq)] +struct Term { + field: Option, + op: String, + value: String, + exclude: bool, +} +fn terms(query: &str, trace: bool) -> Result> { + let tokens = + regex::Regex::new(r#"(-?)(?:([\w.]+):(>=|<=|>|<)?)?(?:"([^"]*)"?|(\S+))"#).unwrap(); + let wildcard = regex::Regex::new(r"(?i)^(\d+)x+$").unwrap(); + let mut found = Vec::new(); + for captures in tokens.captures_iter(query) { + let field = captures.get(2).map(|m| m.as_str()); + let recognized = field.filter(|s| { + if trace { + ["status", "method", "path", "host", "client", "duration"].contains(s) + || s.contains('.') + } else { + ["container", "stream"].contains(s) + } + }); + let value = if field.is_some() && recognized.is_none() { + captures[0].trim_start_matches('-').to_owned() + } else { + captures + .get(4) + .or(captures.get(5)) + .map(|m| m.as_str()) + .unwrap_or_default() + .to_owned() + }; + let value = wildcard.replace(&value, "$1").into_owned(); + if value.is_empty() { + continue; + } + found.push(Term { + field: recognized.map(str::to_owned), + op: if recognized.is_some() { + captures.get(3).map(|m| m.as_str()).unwrap_or(":") + } else { + ":" + } + .into(), + value, + exclude: &captures[1] == "-", + }); + } + Ok(found) +} +pub async fn logs(app: Arc, id: &str, query: &HashMap) -> Result { + let limit = query_number(query, "limit", 200.0, 1.0, 5000.0)?; + let mut filters = vec![ + "source:=nomad".to_owned(), + format!("job:={}", serde_json::to_string(id)?), + ]; + if let Some(level) = query.get("level") { + filters.push( + match level.as_str() { + "error" => "level:=error", + "warn" => "(level:=warn OR level:=error)", + _ => return Err(Error::new(400, "Invalid log level.")), + } + .into(), + ); + } + for term in terms( + query.get("q").map(String::as_str).unwrap_or_default(), + false, + )? { + let field = match term.field.as_deref() { + Some("container") => "task", + Some("stream") => "stream", + _ => "_msg", + }; + filters.push(format!( + "{}{field}:~{}", + if term.exclude { "!" } else { "" }, + serde_json::to_string(&format!( + "(?i){}{}", + if term.field.is_some() { "^" } else { "" }, + regex::escape(&term.value) + ))? + )); + } + let mut form = vec![ + ("query".into(), filters.join(" ")), + ("limit".into(), limit.to_string()), + ]; + for (key, field, add) in [("after", "start", 0.000001), ("before", "end", 0.0)] { + if query.contains_key(key) { + form.push(( + field.into(), + (query_number(query, key, 0.0, f64::NEG_INFINITY, f64::INFINITY)? + add) + .to_string(), + )); + } + } + let url = format!( + "{}/select/logsql/query", + endpoint(app.clone(), "victoria-logs").await? + ); + let response = read(app, url, Some(form)).await?; + let mut rows = string(&response.value).lines().filter(|l| !l.trim().is_empty()).map(|line| { + let row: Value = serde_json::from_str(line)?; + 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()})) + }).collect::>>()?; + rows.sort_by(|a, b| number(&b["t"]).total_cmp(&number(&a["t"]))); + Ok(json!(rows)) +} + +fn span(row: &Value) -> Value { + let mut attributes: serde_json::Map = row + .as_object() + .into_iter() + .flat_map(|v| v.iter()) + .filter_map(|(key, value)| { + key.strip_prefix("span_attr:") + .map(|key| (key.into(), value.clone())) + }) + .collect(); + for (from, to) in [ + ("http.request.method", "http.request.method"), + ("http.response.status_code", "http.response.status_code"), + ("http.route", "url.path"), + ] { + if let Some(value) = row.get(format!("resource_attr:{from}")) { + attributes + .entry(to.to_owned()) + .or_insert_with(|| value.clone()); + } + } + let status = attributes + .get("http.response.status_code") + .or(attributes.get("http.status_code")) + .cloned() + .unwrap_or(Value::Null); + let service = attributes + .get("studio.service") + .cloned() + .unwrap_or_else(|| { + row["resource_attr:service.name"] + .as_str() + .map(|s| json!(s)) + .unwrap_or(json!("unknown")) + }); + let error = if row["status_code"] == "2" || number(&status) >= 500.0 { + if !status.is_null() { + status + } else { + json!( + row["status_message"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or("request failed") + ) + } + } else { + Value::Null + }; + let name = if attributes.get("studio.kind").is_some_and(|v| v == "edge") { + format!("edge: {}", row["name"].as_str().unwrap_or("request")) + } else { + string(&row["name"]).into() + }; + 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}) +} +async fn spans(app: Arc, query: String) -> Result> { + let url = format!( + "{}/select/logsql/query", + endpoint(app.clone(), "victoria-traces").await? + ); + let rows = read(app, url, Some(vec![("query".into(), query)])).await?; + let mut traces: HashMap> = HashMap::new(); + for line in string(&rows.value).lines().filter(|l| !l.trim().is_empty()) { + let row: Value = serde_json::from_str(line)?; + if string(&row["trace_id"]).is_empty() || string(&row["span_id"]).is_empty() { + continue; + } + traces + .entry(string(&row["trace_id"]).to_owned()) + .or_default() + .push(span(&row)); + } + Ok(traces + .into_iter() + .map(|(id, mut spans)| { + let parents: HashMap<_, _> = spans + .iter() + .map(|s| (string(&s["id"]).to_owned(), string(&s["parent"]).to_owned())) + .collect(); + let depth = |span: &Value| { + let mut parent = string(&span["parent"]); + let mut seen = std::collections::HashSet::new(); + let mut n = 0; + while let Some(next) = parents.get(parent) { + if !seen.insert(parent) { + break; + } + n += 1; + parent = next; + } + n + }; + spans.sort_by(|a, b| { + depth(a) + .cmp(&depth(b)) + .then_with(|| number(&a["start"]).total_cmp(&number(&b["start"]))) + }); + json!({"id":id,"spans":spans}) + }) + .collect()) +} +pub async fn trace(app: Arc, id: &str) -> Result { + spans(app, format!("trace_id:={}", serde_json::to_string(id)?)) + .await? + .into_iter() + .next() + .ok_or_else(|| { + Error::new( + 404, + "That trace has aged out of the trace store. Pick a newer one.", + ) + }) +} +pub async fn has_traces(app: Arc, service: &str) -> Result { + let url = format!( + "{}/select/logsql/query", + endpoint(app.clone(), "victoria-traces").await? + ); + Ok(!string( + &read( + app, + url, + Some(vec![( + "query".into(), + format!( + "_time:7d \"resource_attr:service.name\":={} | limit 1", + serde_json::to_string(service)? + ), + )]), + ) + .await? + .value, + ) + .trim() + .is_empty()) +} +pub async fn traces( + app: Arc, + id: &str, + query: &HashMap, + visible: impl Fn(&Value) -> bool, +) -> Result { + let jobs = core::scan(app.clone()).await?; + let Some(service) = jobs.value[id]["job"]["Meta"]["studio_trace_service"] + .as_str() + .filter(|s| !s.is_empty()) + else { + return Ok(json!([])); + }; + let sort = query.get("sort").map(String::as_str).unwrap_or("newest"); + let (key, descending) = match sort { + "newest" => ("_time", true), + "oldest" => ("_time", false), + "slowest" => ("duration", true), + "fastest" => ("duration", false), + _ => return Err(Error::new(400, "Invalid trace order.")), + }; + let limit = query_number(query, "limit", 50.0, 1.0, 500.0)?; + let quote = |s: &str| serde_json::to_string(s).unwrap(); + let like = |field: &str, pattern: &str| { + format!("{}:~{}", quote(field), quote(&format!("(?i){pattern}"))) + }; + let mut filters = vec![ + "_time:7d".into(), + format!("\"resource_attr:service.name\":={}", quote(service)), + ]; + if query.get("errors").is_some_and(|v| v == "1") { + filters.push("(status_code:=2 OR \"span_attr:http.response.status_code\":>=500)".into()); + } + let duration = regex::Regex::new(r"^(\d+(?:\.\d+)?)(us|µs|ms|s|m)?$").unwrap(); + for term in terms(query.get("q").map(String::as_str).unwrap_or_default(), true)? { + let escaped = regex::escape(&term.value); + let filter = match term.field.as_deref() { + Some("duration") => { + let captures = duration.captures(&term.value).ok_or_else(|| { + Error::new( + 400, + format!("Write a duration like 500ms or 2s, not {}.", term.value), + ) + })?; + let nanos = match captures.get(2).map(|m| m.as_str()).unwrap_or("ms") { + "us" | "µs" => 1e3, + "s" => 1e9, + "m" => 60e9, + _ => 1e6, + }; + format!( + "duration:{}{}", + if term.op == ":" { ">=" } else { &term.op }, + (captures[1].parse::().unwrap() * nanos).round() + ) + } + None => format!( + "({})", + [ + "name", + "span_attr:url.path", + "span_attr:http.request.method", + "span_attr:http.response.status_code", + "span_attr:server.address" + ] + .map(|f| like(f, &escaped)) + .join(" OR ") + ), + Some(field) => { + let alias = match field { + "status" => "http.response.status_code", + "method" => "http.request.method", + "path" => "url.path", + "host" => "server.address", + "client" => "client.address", + _ => field, + }; + if term.op == ":" { + like(&format!("span_attr:{alias}"), &format!("^{escaped}")) + } else { + let value = term + .value + .parse::() + .ok() + .filter(|n| n.is_finite()) + .ok_or_else(|| { + Error::new(400, format!("Put a number after {field}:{}.", term.op)) + })?; + format!( + "{}:{}{value}", + quote(&format!("span_attr:{alias}")), + term.op + ) + } + } + }; + filters.push(if term.exclude { + format!("!({filter})") + } else { + filter + }); + } + let mut found = spans( + app, + format!( + "trace_id:in({} | sort by ({key}{}) | limit {limit} | fields trace_id)", + filters.join(" "), + if descending { " desc" } else { "" } + ), + ) + .await?; + for trace in &mut found { + trace["spans"].as_array_mut().unwrap().retain(&visible); + } + found.retain(|trace| !array(&trace["spans"]).is_empty()); + let value = |trace: &Value| { + array(&trace["spans"]) + .iter() + .find(|s| s["service"] == service && s["attributes"]["studio.kind"] != "edge") + .map(|s| number(&s[if key == "_time" { "start" } else { "duration" }])) + .unwrap_or(0.0) + }; + found.sort_by(|a, b| { + if descending { + value(b).total_cmp(&value(a)) + } else { + value(a).total_cmp(&value(b)) + } + }); + 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::>())) +} + +#[derive(Default)] +struct Recorder { + signature: Value, + cpu: HashMap, + vms: HashMap, + network: Option<(f64, f64, f64)>, +} +impl Recorder { + async fn collect(&mut self, app: Arc) -> Result> { + let jobs = core::scan(app.clone()).await?; + let mut signature = Vec::new(); + let mut owners = HashMap::new(); + for (id, found) in jobs.value.as_object().unwrap() { + for alloc in array(&found["allocs"]) { + let alloc_id = string(&alloc["ID"]).to_owned(); + owners.insert(alloc_id.clone(), id.clone()); + for (name, task) in alloc["TaskStates"] + .as_object() + .into_iter() + .flat_map(|v| v.iter()) + { + signature.push((alloc_id.clone(), name.clone(), task["StartedAt"].clone())); + } + } + } + signature.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1))); + let signature = json!(signature); + let counters = + host::call(json!({"operation":"host.usage","refresh":signature != self.signature})) + .await?; + self.signature = signature; + let at = number(&counters["at"]); + let timestamp = (now() * 1000.0) as i64; + let mut next = HashMap::new(); + let mut usage: HashMap = HashMap::new(); + for container in array(&counters["containers"]) { + let name = string(&container["name"]); + let alloc = name + .get(name.len().saturating_sub(36)..) + .unwrap_or_default(); + let Some(service) = owners.get(alloc) else { + continue; + }; + let usec = number(&container["cpu"]); + let memory = number(&container["memory"]); + let id = string(&container["id"]); + let cpu = self + .cpu + .get(id) + .filter(|(_, t)| at > *t) + .map(|(old, t)| ((usec - old) / ((at - t) * 1e6)).max(0.0)) + .unwrap_or(0.0); + next.insert(id.to_owned(), (usec, at)); + let item = usage.entry(service.clone()).or_default(); + item.0 += cpu; + item.1 += memory; + } + self.cpu = next; + *app.usage.lock().unwrap() = usage + .iter() + .map(|(id, (cpu, memory))| (id.clone(), json!({"cpu":cpu,"memory":memory}))) + .collect(); + let bytes = app.live.borrow().clone(); + let live: Value = if bytes.is_empty() { + json!({}) + } else { + serde_json::from_slice(&bytes)? + }; + let mut lines = vec![ + format!( + "studio_host_cpu_percent {} {timestamp}", + number(&live["host"]["cpu"]) + ), + format!( + "studio_host_memory_bytes {} {timestamp}", + number(&live["host"]["memory"]) + ), + ]; + for (id, (cpu, memory)) in usage { + let id = serde_json::to_string(&id)?; + lines.push(format!( + "studio_service_cpu_cores{{service={id}}} {cpu} {timestamp}" + )); + lines.push(format!( + "studio_service_memory_bytes{{service={id}}} {memory} {timestamp}" + )); + } + let sample = host::sample(app.clone()).await?; + let sample = &sample.value; + let rx = number(&sample["network"]["rx"]); + let tx = number(&sample["network"]["tx"]); + let network_at = number(&sample["at"]); + if let Some((old_rx, old_tx, t)) = self.network.filter(|(_, _, t)| network_at > *t) { + lines.push(format!( + "studio_host_network_bytes_per_second{{direction=\"down\"}} {} {timestamp}", + ((rx - old_rx) / (network_at - t)).max(0.0) + )); + lines.push(format!( + "studio_host_network_bytes_per_second{{direction=\"up\"}} {} {timestamp}", + ((tx - old_tx) / (network_at - t)).max(0.0) + )); + } + self.network = Some((rx, tx, network_at)); + if let Some(value) = live["host"]["temperature"].as_f64() { + lines.push(format!( + "studio_host_temperature_celsius {value} {timestamp}" + )); + } + if let Some(value) = sample["gpu"].as_f64() { + lines.push(format!("studio_host_gpu_percent {value} {timestamp}")); + } + if let Ok(Ok(stats)) = tokio::time::timeout( + Duration::from_secs(8), + host::call(json!({"operation":"vm.stats"})), + ) + .await + { + let mut next = HashMap::new(); + let mut usage = HashMap::new(); + for (id, stat) in stats.as_object().into_iter().flat_map(|v| v.iter()) { + let cpu = self + .vms + .get(id) + .filter(|(_, t)| at > *t) + .map(|(old, t)| { + ((number(&stat["cpu"]) - old) / (at - t) / number(&stat["vcpus"]) * 100.0) + .max(0.0) + }) + .unwrap_or(0.0); + usage.insert(id.clone(), json!({"cpu":cpu,"memory":stat["memory"]})); + next.insert(id.clone(), (number(&stat["cpu"]), at)); + lines.push(format!( + "studio_vm_cpu_percent{{vm={}}} {cpu} {timestamp}", + serde_json::to_string(id)? + )); + lines.push(format!( + "studio_vm_memory_bytes{{vm={}}} {} {timestamp}", + serde_json::to_string(id)?, + number(&stat["memory"]) + )); + } + self.vms = next; + *app.vm_usage.lock().unwrap() = usage; + } + Ok(lines) + } +} +pub fn start(app: Arc) { + tokio::spawn(async move { + let mut recorder = Recorder::default(); + loop { + let result = async { + let base = endpoint(app.clone(), "victoria-metrics").await?; + if env("STUDIO_METRICS", "") == "follow" { + let names: Vec<_> = METRICS + .iter() + .filter(|(k, _)| k.starts_with("service.") || k.starts_with("vm.")) + .map(|(_, v)| *v) + .collect(); + let url = format!( + "{base}/api/v1/query?{}", + params(&[("query", format!("{{__name__=~\"{}\"}}", names.join("|")))]) + ); + let result = read(app.clone(), url, None).await?; + let mut services = HashMap::::new(); + let mut vms = HashMap::::new(); + for row in array(&result.value["data"]["result"]) { + let metric = &row["metric"]; + let (map, id) = if metric["service"].is_string() { + (&mut services, string(&metric["service"])) + } else { + (&mut vms, string(&metric["vm"])) + }; + let value = map + .entry(id.into()) + .or_insert_with(|| json!({"cpu":0,"memory":0})); + value[if string(&metric["__name__"]).contains("_cpu_") { + "cpu" + } else { + "memory" + }] = json!(number(&row["value"][1])); + } + *app.usage.lock().unwrap() = services; + *app.vm_usage.lock().unwrap() = vms; + } else { + let lines = recorder.collect(app.clone()).await?; + app.request(Method::POST, &format!("{base}/api/v1/import/prometheus"))? + .body(lines.join("\n") + "\n") + .send() + .await? + .error_for_status()?; + let jobs = core::scan(app.clone()).await?; + let scrapes: Vec<_> = jobs + .value + .as_object() + .unwrap() + .iter() + .filter_map(|(id, job)| { + job["job"]["Meta"]["studio_metrics_path"] + .as_str() + .filter(|p| !p.is_empty()) + .map(|p| (id.clone(), p.to_owned())) + }) + .collect(); + stream::iter(scrapes) + .for_each_concurrent(4, |(id, path)| { + let app = app.clone(); + let base = base.clone(); + async move { + let result = async { + let response = app + .request( + Method::GET, + &format!("{}{path}", endpoint(app.clone(), &id).await?), + )? + .timeout(Duration::from_secs(5)) + .send() + .await? + .error_for_status()?; + app.request( + Method::POST, + &format!( + "{base}/api/v1/import/prometheus?{}", + params(&[("extra_label", format!("service={id}"))]) + ), + )? + .body(response.text().await?) + .send() + .await? + .error_for_status()?; + Ok::<_, Error>(()) + } + .await; + if let Err(e) = result { + eprintln!("metrics {id}: {}", e.message); + } + } + }) + .await; + } + Ok::<_, Error>(()) + } + .await; + if let Err(e) = result + && e.status != 501 + { + eprintln!("metrics: {}", e.message); + } + tokio::time::sleep(Duration::from_secs(15)).await; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn search_fields_comparisons_exclusions_and_unknown_fields() { + let parsed = terms( + r#"status:5xx -path:/health duration:>1s "GET /x" user:42 -noise"#, + true, + ) + .unwrap(); + assert_eq!( + parsed + .iter() + .map(|t| ( + t.field.as_deref(), + t.op.as_str(), + t.value.as_str(), + t.exclude + )) + .collect::>(), + vec![ + (Some("status"), ":", "5", false), + (Some("path"), ":", "/health", true), + (Some("duration"), ">", "1s", false), + (None, ":", "GET /x", false), + (None, ":", "user:42", false), + (None, ":", "noise", true) + ] + ); + assert_eq!(terms(r#" "" - "#, false).unwrap().len(), 1); + } + #[test] + fn non_finite_points_are_null() { + assert_eq!( + series( + &json!({"data":{"result":[{"metric":{"service":"a"},"values":[[1,"2"],[2,"NaN"],[3,"+Inf"]]}]}}), + false + ), + json!([{"name":"a","t":[1,2,3],"v":[2.0,null,null]}]) + ); + } + #[test] + fn span_error_status_is_preserved() { + let s = span( + &json!({"span_id":"a","parent_span_id":"a","start_time_unix_nano":"1000000000","duration":"20","status_code":"2","status_message":"broken"}), + ); + assert_eq!(s["error"], "broken"); + } +} diff --git a/dashboard/src/users.rs b/dashboard/src/users.rs new file mode 100644 index 0000000000000000000000000000000000000000..742e326ae641043555b2ab4928fa7d140201683b --- /dev/null +++ b/dashboard/src/users.rs @@ -0,0 +1,588 @@ +use crate::*; +use axum::extract::{FromRequest, Multipart}; +use futures::{StreamExt, stream}; + +async fn call(path: &str, method: Method, body: Option) -> Result { + host::call(json!({"operation":"iam.request", "path":path, + "method":method.as_str(), "body":body})) + .await +} +async fn get(path: &str) -> Result { + Ok(call(path, Method::GET, None).await?["body"].take()) +} +async fn list() -> Result { + let list = get("/users?max=1000").await?; + let found = stream::iter(array(&list).iter().cloned()) + .map(|mut user| async move { + user["groups"] = get(&format!( + "/users/{}/role-mappings/realm", + encoded(string(&user["id"])) + )) + .await?; + for key in ["email", "firstName", "lastName"] { + if user.get(key).is_none() { + user[key] = Value::Null; + } + } + Ok::<_, Error>(user) + }) + .buffered(4) + .collect::>() + .await + .into_iter() + .collect::>>()?; + Ok(json!(found)) +} +async fn directory(app: Arc) -> Result> { + app.cache + .get( + "users".into(), + Duration::from_secs(300), + move || async move { + let (list, groups) = tokio::try_join!(list(), get("/roles"))?; + let users = stream::iter(array(&list).iter().cloned()) + .map(|mut user| async move { + user["sessions"] = + get(&format!("/users/{}/sessions", encoded(string(&user["id"])))) + .await?; + Ok::<_, Error>(user) + }) + .buffered(4) + .collect::>() + .await + .into_iter() + .collect::>>()?; + Ok(json!({"users":users,"groups":groups})) + }, + ) + .await +} +async fn found(id: &str) -> Result { + array(&list().await?) + .iter() + .find(|u| u["id"] == id) + .cloned() + .ok_or_else(|| Error::new(404, "No user with that id")) +} +async fn spare(me: &Value, id: &str, remove_role: Option<&str>) -> Result<()> { + let user = found(id).await?; + if user["username"] == me["name"] { + let keeps_admin = remove_role.is_some() + && array(&user["groups"]) + .iter() + .any(|g| g["name"] == "infra-admin" && g["name"] != remove_role.unwrap()); + if !keeps_admin { + return Err(Error::new( + 400, + "That would lock you out of this page. Sign in as another admin to change it.", + )); + } + } + Ok(()) +} +fn uuid(id: &str) -> Result<()> { + if regex::Regex::new( + 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}$", + ) + .unwrap() + .is_match(id) + { + Ok(()) + } else { + Err(Error::new(400, "No user with that id")) + } +} +fn profile(body: &Value, full: bool) -> Result { + let mut profile = serde_json::Map::new(); + let username_pattern = regex::Regex::new(r"^[a-z0-9][a-z0-9._@-]*$").unwrap(); + for key in [ + "username", + "email", + "firstName", + "lastName", + "enabled", + "emailVerified", + "requiredActions", + ] { + let Some(value) = body.get(key) else { + if full && ["username", "email", "firstName", "lastName"].contains(&key) { + return Err(Error::new(400, "Enter a user profile.")); + } + continue; + }; + let value = match key { + "username" => { + let v = string(value).trim().to_lowercase(); + if !username_pattern.is_match(&v) { + return Err(Error::new( + 400, + "Usernames use lowercase letters, digits, dots, dashes, and @", + )); + } + json!(v) + } + "email" | "firstName" | "lastName" => { + let v = value + .as_str() + .ok_or_else(|| Error::new(400, "Enter a name or email address."))? + .trim(); + if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) { + return Err(Error::new(400, "Enter a full email address")); + } + if v.is_empty() { Value::Null } else { json!(v) } + } + "enabled" | "emailVerified" => { + if !value.is_boolean() { + return Err(Error::new(400, "Invalid profile.")); + } + value.clone() + } + _ => { + if !value.is_array() || array(value).iter().any(|v| !v.is_string()) { + return Err(Error::new(400, "Invalid required actions.")); + } + value.clone() + } + }; + profile.insert(key.into(), value); + } + Ok(Value::Object(profile)) +} +fn password(value: &Value) -> Result<&str> { + value + .as_str() + .filter(|s| s.chars().count() >= 8) + .ok_or_else(|| Error::new(400, "Use at least 8 characters")) +} + +pub async fn route( + app: Arc, + method: &Method, + parts: &[&str], + me: &Value, + body: Value, +) -> Result { + if parts.is_empty() && method == Method::GET { + return Ok(directory(app).await?.response()); + } + if let Some(id) = parts.first() { + uuid(id)?; + } + let value = match parts { + [] if method == Method::POST => { + let mut profile = profile(&body["profile"], true)?; + let setup = string(&body["setup"]["kind"]); + if setup == "email" && profile["email"].is_null() { + return Err(Error::new(400, "Add an email address to send a setup link")); + } + if setup != "email" && setup != "password" { + return Err(Error::new(400, "Choose how this user signs in.")); + } + if setup == "password" { + password(&body["setup"]["password"])?; + } + if !body["groups"].is_array() { + return Err(Error::new(400, "Choose groups.")); + } + for group in array(&body["groups"]) { + uuid(string(group))?; + } + let actions = if setup == "email" { + json!(["UPDATE_PASSWORD", "VERIFY_EMAIL"]) + } else { + json!([]) + }; + profile["enabled"] = json!(true); + profile["emailVerified"] = json!(false); + profile["requiredActions"] = actions.clone(); + let response = call("/users", Method::POST, Some(profile)).await?; + let id = response["id"] + .as_str() + .ok_or_else(|| { + Error::new( + 502, + "Keycloak created the user but did not return its ID. Reload the page.", + ) + })? + .to_owned(); + for group in array(&body["groups"]) { + change_role(&id, string(group), Method::POST).await?; + } + if setup == "email" { + call( + &format!("/users/{id}/execute-actions-email"), + Method::PUT, + Some(actions), + ) + .await?; + } else { + call(&format!("/users/{id}/reset-password"),Method::PUT,Some(json!({"type":"password","value":body["setup"]["password"],"temporary":true}))).await?; + } + app.cache.invalidate("users"); + return Ok((StatusCode::CREATED, axum::Json(json!({"id":id}))).into_response()); + } + [id] if method == Method::PATCH => { + let profile = profile(&body, false)?; + if profile["enabled"] == false { + spare(me, id, None).await?; + } + call(&format!("/users/{id}"), Method::PUT, Some(profile)).await?; + Value::Null + } + [id] if method == Method::DELETE => { + spare(me, id, None).await?; + call(&format!("/users/{id}"), Method::DELETE, None).await?; + Value::Null + } + [id, "groups", group] if method == Method::PUT || method == Method::DELETE => { + uuid(group)?; + if method == Method::DELETE { + let groups = get("/roles").await?; + let name = array(&groups) + .iter() + .find(|g| g["id"] == *group) + .map(|g| string(&g["name"])); + spare(me, id, name).await?; + } + change_role( + id, + group, + if method == Method::PUT { + Method::POST + } else { + Method::DELETE + }, + ) + .await?; + Value::Null + } + [id, "credentials"] if method == Method::GET => { + get(&format!("/users/{id}/credentials")).await? + } + [id, "logout"] if method == Method::POST => { + call(&format!("/users/{id}/logout"), Method::POST, None).await?; + Value::Null + } + [id, "actions-email"] if method == Method::POST => { + let user = found(id).await?; + if user["email"].is_null() { + return Err(Error::new(400, "Add an email address first")); + } + if array(&user["requiredActions"]).is_empty() { + return Err(Error::new(400, "Pick at least one required action first")); + } + call( + &format!("/users/{id}/execute-actions-email"), + Method::PUT, + Some(user["requiredActions"].clone()), + ) + .await?; + Value::Null + } + [id, "password"] if method == Method::PUT => { + let password = password(&body["password"])?; + if !body["temporary"].is_boolean() { + return Err(Error::new( + 400, + "Choose whether this password is temporary.", + )); + } + call( + &format!("/users/{id}/reset-password"), + Method::PUT, + Some(json!({"type":"password","value":password,"temporary":body["temporary"]})), + ) + .await?; + Value::Null + } + _ => return Err(Error::new(404, "Not Found")), + }; + if method != Method::GET { + app.cache.invalidate("users"); + } + Ok(if value.is_null() { + StatusCode::NO_CONTENT.into_response() + } else { + Document::new(value).response() + }) +} +async fn change_role(id: &str, group: &str, method: Method) -> Result<()> { + let roles = get("/roles").await?; + let role = array(&roles) + .iter() + .find(|g| g["id"] == group) + .ok_or_else(|| Error::new(404, "That role is no longer available. Reload the page."))?; + call( + &format!("/users/{id}/role-mappings/realm"), + method, + Some(json!([role])), + ) + .await?; + Ok(()) +} +pub async fn self_user(app: &App, me: &Value) -> Result { + let name = string(&me["name"]).to_owned(); + let value = app + .cache + .coalesce(format!("identity:{name}"), move || async move { + let found = get(&format!("/users?username={}&exact=true", encoded(&name))).await?; + let mut user = array(&found) + .iter() + .find(|u| u["username"] == name) + .cloned() + .ok_or_else(|| { + Error::new( + 404, + format!( + "Keycloak has no user named {}. Sign out, then sign in again.", + name + ), + ) + })?; + user["groups"] = get(&format!( + "/users/{}/role-mappings/realm", + encoded(string(&user["id"])) + )) + .await?; + for key in ["email", "firstName", "lastName"] { + if user.get(key).is_none() { + user[key] = Value::Null; + } + } + Ok(user) + }) + .await?; + Ok(value.value.clone()) +} +fn image_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + Some("image/webp") + } else if bytes.get(1..4) == Some(b"PNG") { + Some("image/png") + } else { + None + } +} +pub async fn picture(app: &App, parts: &[&str]) -> Result { + let ["account", "pictures", id] = parts else { + return Err(Error::new(404, "No picture here")); + }; + if id.is_empty() + || !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err(Error::new(404, "No picture here")); + } + let bytes = tokio::fs::read(app.data.join("pictures").join(id)) + .await + .map_err(|_| Error::new(404, "No picture here"))?; + Ok(( + [ + ( + "content-type", + image_type(&bytes).unwrap_or("application/octet-stream"), + ), + ("cache-control", "max-age=86400"), + ], + bytes, + ) + .into_response()) +} +pub async fn account( + app: Arc, + request: Request, + parts: &[&str], + me: &Value, +) -> Result { + let method = request.method().clone(); + let headers = request.headers(); + let origin = format!( + "{}://{}", + headers + .get("X-Forwarded-Proto") + .and_then(|h| h.to_str().ok()) + .unwrap_or("http"), + headers + .get("X-Forwarded-Host") + .or(headers.get("Host")) + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost") + ); + let realm = || { + std::env::var("STUDIO_KEYCLOAK_URL") + .map(|s| format!("{s}/realms/master")) + .map_err(|_| { + Error::new( + 501, + "Keycloak isn't connected to this home server. Connect it, then retry.", + ) + }) + }; + if parts == ["sign-out"] && method == Method::GET { + let logout = format!( + "{}/protocol/openid-connect/logout?{}", + realm()?, + params(&[ + ("client_id", "forward-auth".into()), + ("post_logout_redirect_uri", format!("{origin}/")) + ]) + ); + return Ok(( + StatusCode::FOUND, + [( + "location", + format!("/snow.oauth2/sign_out?{}", params(&[("rd", logout)])), + )], + ) + .into_response()); + } + if let ["actions", action] = parts { + if method != Method::GET + || (![ + "webauthn-register-passwordless", + "UPDATE_PASSWORD", + "UPDATE_EMAIL", + ] + .contains(action) + && !regex::Regex::new(r"^delete_credential:[\w-]+$") + .unwrap() + .is_match(action)) + { + return Err(Error::new( + 400, + "Keycloak can't start that action from here", + )); + } + return Ok(( + StatusCode::FOUND, + [( + "location", + format!( + "{}/protocol/openid-connect/auth?{}", + realm()?, + params(&[ + ("client_id", "forward-auth".into()), + ("redirect_uri", format!("{origin}/account")), + ("response_type", "code".into()), + ("scope", "openid".into()), + ("kc_action", action.to_string()) + ]) + ), + )], + ) + .into_response()); + } + let mut user = self_user(&app, me).await?; + let id = string(&user["id"]).to_owned(); + let value = match parts { + [] if method == Method::GET => { + let attributes = user + .as_object_mut() + .unwrap() + .remove("attributes") + .unwrap_or(Value::Null); + user["picture"] = attributes["picture"][0].clone(); + user["credentials"] = get(&format!("/users/{id}/credentials")).await?; + user["console"] = json!(format!("{}/account", realm()?)); + user + } + [] if method == Method::PATCH => { + let body: Value = serde_json::from_slice( + &axum::body::to_bytes(request.into_body(), 1024 * 1024).await?, + ) + .map_err(|_| Error::new(400, "Invalid profile."))?; + let mut value = serde_json::Map::new(); + for key in ["firstName", "lastName"] { + if let Some(v) = body.get(key) { + let v = v + .as_str() + .ok_or_else(|| Error::new(400, "Enter a name."))? + .trim(); + value.insert( + key.into(), + if v.is_empty() { Value::Null } else { json!(v) }, + ); + } + } + call( + &format!("/users/{id}"), + Method::PUT, + Some(Value::Object(value)), + ) + .await?; + Value::Null + } + ["verify-email"] if method == Method::POST => { + call( + &format!("/users/{id}/execute-actions-email"), + Method::PUT, + Some(json!(["VERIFY_EMAIL"])), + ) + .await?; + Value::Null + } + ["picture"] if method == Method::PUT => { + let (parts, body) = request.into_parts(); + let bytes = axum::body::to_bytes(body, 512 * 1024) + .await + .map_err(|_| Error::new(413, "That picture is over 512 KB. Pick a smaller one."))?; + let request = Request::from_parts(parts, axum::body::Body::from(bytes)); + let mut multipart = Multipart::from_request(request, &()) + .await + .map_err(|_| Error::new(400, "Pick a picture to upload"))?; + let mut picture = None; + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| Error::new(400, "Pick a picture to upload"))? + { + if field.name() == Some("picture") && field.file_name().is_some() { + picture = Some( + field + .bytes() + .await + .map_err(|_| Error::new(400, "Pick a picture to upload"))?, + ); + break; + } + } + let bytes = picture.ok_or_else(|| Error::new(400, "Pick a picture to upload"))?; + if image_type(&bytes).is_none() { + return Err(Error::new(415, "Upload a WebP or PNG picture")); + } + tokio::fs::create_dir_all(app.data.join("pictures")).await?; + tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?; + let picture = format!( + "{origin}/api/account/pictures/{id}?v={}", + (now() * 1000.0) as u64 + ); + call( + &format!("/users/{id}"), + Method::PUT, + Some(json!({"attributes":{"picture":[picture]}})), + ) + .await?; + json!({"picture":picture}) + } + ["picture"] if method == Method::DELETE => { + call( + &format!("/users/{id}"), + Method::PUT, + Some(json!({"attributes":{"picture":null}})), + ) + .await?; + let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await; + Value::Null + } + _ => return Err(Error::new(404, "Not Found")), + }; + if method != Method::GET { + app.cache.invalidate("users"); + } + Ok(if value.is_null() { + StatusCode::NO_CONTENT.into_response() + } else { + Document::new(value).response() + }) +} diff --git a/dashboard/src/youtube.rs b/dashboard/src/youtube.rs new file mode 100644 index 0000000000000000000000000000000000000000..65ec0c633ed3c090d50dbeec1e998a5f569187a0 --- /dev/null +++ b/dashboard/src/youtube.rs @@ -0,0 +1,344 @@ +use crate::*; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + sync::{mpsc, oneshot}, +}; + +pub struct Worker(mpsc::Sender); +struct Call { + method: String, + args: Value, + reply: oneshot::Sender>, +} + +impl Worker { + pub fn new() -> Self { + let (send, mut receive) = mpsc::channel::(64); + tokio::spawn(async move { + let mut child: Option<( + tokio::process::Child, + tokio::process::ChildStdin, + tokio::io::Lines>, + )> = None; + let mut id = 0u64; + while let Some(call) = receive.recv().await { + if call.reply.is_closed() { + continue; + } + let response = tokio::time::timeout(Duration::from_secs(25), async { + if child.is_none() { + if std::env::var("STUDIO_YT_STATE").is_err() { + return Err(Error::new( + 501, + "YouTube isn't connected to this home server. Configure its archive state, then retry.", + )); + } + let python = env("STUDIO_YT_PYTHON", "python3"); + let script = env("STUDIO_YT_WORKER", "server/youtube-worker.py"); + let mut command = tokio::process::Command::new(python); + for (key, name) in [ + ("SMTP_HOST", "smtp_host"), + ("SMTP_USER", "smtp_user"), + ("SMTP_PASS", "smtp_pass"), + ] { + if let Ok(secret) = host::call( + json!({"operation":"deploy.secret.get","service":"yt-feed","key":name}), + ).await { + if let Some(value) = secret.as_str() { + command.env(key, value); + } + } + } + let mut process = command + .arg(script) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true) + .spawn()?; + let input = process.stdin.take().unwrap(); + let output = BufReader::new(process.stdout.take().unwrap()).lines(); + child = Some((process, input, output)); + } + id += 1; + let (_, input, output) = child.as_mut().unwrap(); + input + .write_all( + format!( + "{}\n", + json!({"id":id,"method":call.method,"args":call.args}) + ) + .as_bytes(), + ) + .await?; + input.flush().await?; + let line = output.next_line().await?.ok_or_else(|| { + Error::new(502, "YouTube processing stopped. Retry shortly.") + })?; + let response: Value = serde_json::from_str(&line)?; + if response["id"] != id { + return Err(Error::new( + 502, + "YouTube processing stopped. Retry shortly.", + )); + } + if let Some(error) = response["error"].as_str() { + return Err(Error::new( + if error.contains("read-only") || error.contains("changed since") { + 409 + } else { + 502 + }, + error, + )); + } + Ok(response["result"].clone()) + }) + .await + .unwrap_or_else(|_| { + Err(Error::new( + 504, + "YouTube is taking too long. Retry shortly.", + )) + }); + if response.as_ref().is_err_and(|e| { + e.status == 504 + || e.status == 500 + || (e.status == 502 && e.message.contains("stopped")) + }) { + child = None; + } + let _ = call.reply.send(response); + } + }); + Self(send) + } + pub async fn call(&self, method: &str, args: Value) -> Result { + let (reply, result) = oneshot::channel(); + tokio::time::timeout(Duration::from_secs(30), async { + self.0 + .send(Call { + method: method.into(), + args, + reply, + }) + .await + .map_err(|_| Error::new(502, "YouTube processing stopped. Retry shortly."))?; + result + .await + .map_err(|_| Error::new(502, "YouTube processing stopped. Retry shortly."))? + }) + .await + .map_err(|_| Error::new(504, "YouTube is taking too long. Retry shortly."))? + } +} + +pub async fn route( + app: Arc, + method: &Method, + parts: &[&str], + body: Value, +) -> Result { + if method == Method::GET { + let call = match parts { + [] => "snapshot", + ["channels"] => "channels", + ["configs"] => "configs", + ["library"] => "library", + _ => return Err(Error::new(404, "Not Found")), + }; + let state = app.clone(); + let document = app + .cache + .get( + format!("youtube:{call}"), + Duration::from_secs(if call == "library" { 30 } else { 2 }), + move || async move { + if call == "library" { + let _slot = state.heavy.acquire().await?; + let python = env("STUDIO_YT_PYTHON", "python3"); + let script = env("STUDIO_YT_WORKER", "server/youtube-worker.py"); + return Ok(serde_json::from_slice( + &command(&python, &[&script, "--library"], None).await?, + )?); + } + let worker = state.youtube.get_or_init(Worker::new); + let mut value = worker.call(call, json!({})).await?; + if call == "snapshot" { + let archive = core::read_json( + std::path::Path::new(&env( + "STUDIO_YT_ARCHIVE_STATUS", + "/srv/prod/ytdl-sub/config/archive-status.json", + )), + Value::Null, + ) + .await + .unwrap_or(Value::Null); + if !archive.is_null() { + value["archive"] = archive; + } + for job in value["jobs"].as_array_mut().into_iter().flatten() { + job["folder"] = apps::file_link(string(&job["folder"]), false); + } + } + Ok(value) + }, + ) + .await?; + return Ok(document.response()); + } + let worker = app.youtube.get_or_init(Worker::new); + let value = match parts { + ["configs", name] if method == Method::PUT => { + if !body["original"].is_string() || !body["body"].is_string() { + return Err(Error::new(400, "Invalid config.")); + } + worker + .call( + "saveConfig", + json!({"name":name,"original":body["original"],"body":body["body"]}), + ) + .await?; + Value::Null + } + ["library", "rename"] if method == Method::POST => { + if string(&body["path"]).is_empty() || string(&body["title"]).trim().is_empty() { + return Err(Error::new(400, "Enter a title.")); + } + for key in ["season", "episode"] { + if !body[key].is_null() + && (number(&body[key]) < 1.0 || number(&body[key]).fract() != 0.0) + { + return Err(Error::new(400, "Invalid episode number.")); + } + } + worker.call("rename", body).await?; + Value::Null + } + ["channels"] if method == Method::PUT => { + validate_channels(&body)?; + worker.call("setChannels", body).await?; + Value::Null + } + ["pending"] if method == Method::POST => { + let video = regex::Regex::new( + r"^https?://((www|m|music)\.)?(youtube\.com/(watch\?|shorts/|live/)|youtu\.be/)", + ) + .unwrap(); + if !body["urls"].is_array() || array(&body["urls"]).is_empty() { + return Err(Error::new(400, "Paste a YouTube video link.")); + } + for url in array(&body["urls"]) { + if !video.is_match(string(url)) { + return Err(Error::new( + 400, + format!("{} isn't a YouTube video link.", string(url)), + )); + } + } + worker.call("add", body).await?; + Value::Null + } + ["pending", key, "ingest"] if method == Method::POST => { + let dest = string(&body["dest"]); + if !["independent", "music", "indie"].contains(&dest) { + return Err(Error::new(400, "Choose where this video goes.")); + } + if dest == "indie" { + if string(&body["show"]).trim().is_empty() || !body["title"].is_string() { + return Err(Error::new(400, "Enter a show name.")); + } + for key in ["season", "episode"] { + let n = number(&body[key]); + if n < 1.0 || n.fract() != 0.0 { + return Err(Error::new(400, "Invalid episode number.")); + } + } + } + worker + .call("ingest", json!({"key":key,"choice":body})) + .await?; + Value::Null + } + ["pending", key, "skip"] if method == Method::POST => { + worker.call("skip", json!({"key":key})).await?; + Value::Null + } + ["jobs", id, "retry"] if method == Method::POST => { + worker.call("retry", json!({"id":id})).await?; + Value::Null + } + ["upscaler"] if method == Method::PUT => { + if !body["enabled"].is_boolean() { + return Err(Error::new(400, "Choose whether upscaling is enabled.")); + } + worker.call("setUpscaler", body).await?; + Value::Null + } + _ => return Err(Error::new(404, "Not Found")), + }; + for call in ["snapshot", "channels", "configs", "library"] { + app.cache.invalidate(&format!("youtube:{call}")); + } + Ok(if value.is_null() { + StatusCode::NO_CONTENT.into_response() + } else { + Document::new(value).response() + }) +} +fn validate_channels(body: &Value) -> Result<()> { + let channel = regex::Regex::new( + r"^https?://(www\.|m\.)?youtube\.com/(@[\w.-]+|channel/[\w-]+|c/[^/?#]+|user/[^/?#]+)/?$", + ) + .unwrap(); + let date_pattern = regex::Regex::new(r"^\d{8}$").unwrap(); + for key in ["notify", "archive"] { + if !body[key].is_array() { + return Err(Error::new(400, "Enter channel lists.")); + } + let mut names = std::collections::HashSet::new(); + for item in array(&body[key]) { + let name = string(&item["name"]).trim(); + if name.is_empty() { + return Err(Error::new(400, "Enter a name for the channel.")); + } + if !names.insert(name) { + return Err(Error::new( + 400, + format!("Two channels are named {name}. Rename one."), + )); + } + if !channel.is_match(string(&item["url"]).trim()) { + return Err(Error::new( + 400, + "That isn't a YouTube channel link. Use one like https://www.youtube.com/@handle.", + )); + } + if key == "archive" { + let rules = &item["rules"]; + if !rules.is_object() { + return Err(Error::new(400, "Enter channel rules.")); + } + if let Some(date) = rules.get("download_after") + && !date_pattern.is_match(string(date)) + { + return Err(Error::new(400, "Pick a date for the backlog.")); + } + for rule in [ + "title_include_keywords", + "title_exclude_keywords", + "description_include_keywords", + "description_exclude_keywords", + ] { + if let Some(words) = rules.get(rule) + && (!words.is_array() + || array(words).iter().any(|w| string(w).trim().is_empty())) + { + return Err(Error::new(400, "Enter keywords.")); + } + } + } + } + } + Ok(()) +} diff --git a/dashboard/tests/index-cases.json b/dashboard/tests/index-cases.json new file mode 100644 index 0000000000000000000000000000000000000000..b476d83c35fd75e74b5ea734e5ac87de98a79162 --- /dev/null +++ b/dashboard/tests/index-cases.json @@ -0,0 +1,403 @@ +[ + { + "name": "move out of a removed directory", + "before": { + "trash/keep": 5, + "trash/x": 7, + "o": 1 + }, + "ops": [ + [ + "renameSync", + "trash/keep", + "keep" + ], + [ + "rmSync", + "trash", + { + "recursive": true + } + ] + ], + "diff": [ + "R\tF\t/t/trash/keep\t/t/keep", + "-\tF\t/t/trash/x", + "-\t/\t/t/trash", + "M\t/\t/t" + ] + }, + { + "name": "move into a new directory", + "before": { + "a": 5, + "o": 1 + }, + "ops": [ + [ + "mkdirSync", + "n" + ], + [ + "renameSync", + "a", + "n/a" + ] + ], + "diff": [ + "+\t/\t/t/n", + "R\tF\t/t/a\t/t/n/a", + "M\t/\t/t" + ] + }, + { + "name": "rename a directory and a file inside it", + "before": { + "d/x": 5, + "d/z": 3 + }, + "ops": [ + [ + "renameSync", + "d", + "e" + ], + [ + "renameSync", + "e/x", + "e/y" + ] + ], + "diff": [ + "R\t/\t/t/d\t/t/e", + "R\tF\t/t/d/x\t/t/e/y", + "M\t/\t/t", + "M\t/\t/t/e" + ] + }, + { + "name": "swap directories", + "before": { + "A/1": 5, + "B/2": 3 + }, + "ops": [ + [ + "renameSync", + "A", + "T" + ], + [ + "renameSync", + "B", + "A" + ], + [ + "renameSync", + "T", + "B" + ] + ], + "diff": [ + "R\t/\t/t/A\t/t/B", + "R\t/\t/t/B\t/t/A", + "M\t/\t/t" + ] + }, + { + "name": "invert nesting", + "before": { + "a/b/f": 5, + "a/g": 3 + }, + "ops": [ + [ + "renameSync", + "a/b", + "b" + ], + [ + "renameSync", + "a", + "b/a" + ] + ], + "diff": [ + "R\t/\t/t/a/b\t/t/b", + "R\t/\t/t/a\t/t/b/a", + "M\t/\t/t", + "M\t/\t/t/b" + ] + }, + { + "name": "reuse a renamed directory's name", + "before": { + "d/x": 5 + }, + "ops": [ + [ + "renameSync", + "d", + "e" + ], + [ + "mkdirSync", + "d" + ], + [ + "writeFileSync", + "d/f", + 9 + ], + [ + "writeFileSync", + "e/g", + 4 + ] + ], + "diff": [ + "R\t/\t/t/d\t/t/e", + "+\t/\t/t/d", + "+\tF\t/t/d/f", + "+\tF\t/t/e/g", + "M\t/\t/t", + "M\t/\t/t/e" + ] + }, + { + "name": "replace a file with a directory", + "before": { + "x": 5 + }, + "ops": [ + [ + "rmSync", + "x" + ], + [ + "mkdirSync", + "x" + ], + [ + "writeFileSync", + "x/f", + 9 + ] + ], + "diff": [ + "-\tF\t/t/x", + "+\t/\t/t/x", + "+\tF\t/t/x/f", + "M\t/\t/t" + ] + }, + { + "name": "rename over an empty directory", + "before": { + "x/f": 5, + "e/": 0 + }, + "ops": [ + [ + "rmSync", + "e", + { + "recursive": true + } + ], + [ + "renameSync", + "x", + "e" + ] + ], + "diff": [ + "-\t/\t/t/e", + "R\t/\t/t/x\t/t/e", + "M\t/\t/t" + ] + }, + { + "name": "rename a directory onto a removed file's name", + "before": { + "x/f": 5, + "e": 3 + }, + "ops": [ + [ + "rmSync", + "e" + ], + [ + "renameSync", + "x", + "e" + ] + ], + "diff": [ + "-\tF\t/t/e", + "R\t/\t/t/x\t/t/e", + "M\t/\t/t" + ] + }, + { + "name": "move a directory, then modify inside it", + "before": { + "m/a/f": 5 + }, + "ops": [ + [ + "renameSync", + "m/a", + "b" + ], + [ + "writeFileSync", + "b/f", + 50 + ] + ], + "diff": [ + "R\t/\t/t/m/a\t/t/b", + "M\tF\t/t/b/f", + "M\t/\t/t", + "M\t/\t/t/m" + ] + }, + { + "name": "chain renames", + "before": { + "a": 1, + "b": 2, + "c": 3 + }, + "ops": [ + [ + "rmSync", + "c" + ], + [ + "renameSync", + "b", + "c" + ], + [ + "renameSync", + "a", + "b" + ] + ], + "diff": [ + "-\tF\t/t/c", + "R\tF\t/t/b\t/t/c", + "R\tF\t/t/a\t/t/b", + "M\t/\t/t" + ] + }, + { + "name": "remove a deep subtree", + "before": { + "a/b/c/d": 5, + "a/z": 1 + }, + "ops": [ + [ + "rmSync", + "a/b", + { + "recursive": true + } + ] + ], + "diff": [ + "-\tF\t/t/a/b/c/d", + "-\t/\t/t/a/b/c", + "-\t/\t/t/a/b", + "M\t/\t/t/a" + ] + }, + { + "name": "add a hard link", + "before": { + "a": 5, + "d/": 0 + }, + "ops": [ + [ + "linkSync", + "a", + "d/b" + ] + ], + "diff": [ + "M\tF\t/t/a\t(+1)", + "M\t/\t/t/d" + ] + }, + { + "name": "remove a hard link", + "before": { + "a": 5, + "d/b": "a" + }, + "ops": [ + [ + "rmSync", + "d/b" + ] + ], + "diff": [ + "M\tF\t/t/a\t(-1)", + "M\t/\t/t/d" + ] + }, + { + "name": "rename a file and link it", + "before": { + "a": 5 + }, + "ops": [ + [ + "renameSync", + "a", + "b" + ], + [ + "linkSync", + "b", + "c" + ] + ], + "diff": [ + "M\tF\t/t/b\t(+1)", + "M\t/\t/t" + ] + }, + { + "name": "link a new file into a new directory", + "before": { + "o": 1 + }, + "ops": [ + [ + "writeFileSync", + "n", + 7 + ], + [ + "mkdirSync", + "d" + ], + [ + "linkSync", + "n", + "d/m" + ] + ], + "diff": [ + "+\tF\t/t/n", + "+\t/\t/t/d", + "M\t/\t/t" + ] + } +] diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..07d631d645b1afd4917092da6d00864e4d662fdc --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": [ + "node", + "vite/client" + ], + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true + }, + "include": [ + "web", + "dev.ts", + "vite.config.ts" + ] +} diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..c975b431b54e6019ec4615d5356897591dfc777e --- /dev/null +++ b/dashboard/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import solid from "vite-plugin-solid"; + +export default defineConfig({ + root: "web", + plugins: [solid()], + build: { outDir: "../dist", emptyOutDir: true }, + server: { + host: true, + allowedHosts: [".ts.net", "sandwich"], + // Stands in for forward auth. + proxy: { + "/api/": { + target: "http://127.0.0.1:7070", + xfwd: true, + headers: { "User-Name": process.env.STUDIO_DEV_USER ?? "snow", "User-Groups": process.env.STUDIO_DEV_GROUPS ?? "infra-admin" }, + }, + }, + }, +}); diff --git a/dashboard/web/api.contract.ts b/dashboard/web/api.contract.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2448d7b7715d532d18e7d16bf8af85532246a09 --- /dev/null +++ b/dashboard/web/api.contract.ts @@ -0,0 +1,305 @@ +import type { Connections, Consent } from "./types/mcp.ts"; +import type { Pool, Vdev, Disk, Dataset, Snapshot } from "./types/storage.ts"; +import type { Torrent, TorrentFile, ServerState } from "./types/seedbox.ts"; +import type { Video, Show, Job, Wall, Archive, Upscaler, Channels, ConfigFile, LibraryEntry, Ingest } from "./types/youtube.ts"; +import type { User, Group, Session, Credential } from "./types/users.ts"; +import type { Hono } from "hono"; +import type { Health } from "./types/model.ts"; +import type { Domain, Image, History, NewDomain } from "./types/vms.ts"; +import type { Me, ServiceSummary, ServiceDetail, ServiceDefinition, HostInfo, Issue, Series, LogLine, TraceSummary, Trace } from "./types/model.ts"; +import type { MapNode } from "./types/storage.index.ts"; +import type { ProgressNode, PaperCloverStats } from "./types/paperClover.ts"; + +type Endpoint = { + input: Input; output: Output; status: Status; outputFormat: Format; +}; + +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; }; + +type Explorer = { + "/list": { + $get: Endpoint<{ host: string; link: string | null; zip: string | null; entries: FileEntry[]; }, { query: { path?: string | undefined; }; }>; + }; + "/tree": { + $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"; }; }>; + }; + "/match": { + $get: Endpoint<{ count: number; size: number | null; sample: string[]; rows: { [x: string]: number | "self"; }; }, { query: { path: string; pattern: string; }; }>; + }; + "/peek": { + $get: Endpoint<{ text: string; more: boolean; }, { query: { path: string; }; }>; + }; + "/ops": { + $get: Endpoint<{ id: string; label: string; at: number; undone: boolean; sample: string[]; count: number; }[]>; + }; + "/folder": { + $post: Endpoint<{ id: `${string}-${string}-${string}-${string}-${string}`; label: string; }, { json: { path: string; name: string; }; }>; + }; + "/rename": { + $post: Endpoint<{ id: `${string}-${string}-${string}-${string}-${string}`; label: string; }, { json: { renames: { path: string; name: string; }[]; }; }>; + }; + "/move": { + $post: Endpoint<{ id: `${string}-${string}-${string}-${string}-${string}`; label: string; }, { json: { paths: string[]; to: string; }; }>; + }; + "/delete": { + $post: Endpoint<{ id: `${string}-${string}-${string}-${string}-${string}`; label: string; }, { json: { paths: string[]; }; }>; + }; + "/bulk": { + $post: Endpoint<{ id: `${string}-${string}-${string}-${string}-${string}`; label: string; }, { json: { path: string; pattern: string; count: number; action: "move" | "delete"; to?: string | undefined; }; }>; + }; + "/ops/:id/undo": { + $post: Endpoint; + }; +}; +type ExplorerRoutes = { [P in keyof Explorer as `${Prefix}${P}`]: Explorer[P] }; + +export type Api = Hono<{}, { + "/mcp": { $get: Endpoint; }; + "/mcp/shale": { $post: Endpoint<{ redirect: string }, { json: { request?: string } }>; $delete: Endpoint; }; + "/mcp/consent/:id": { + $get: Endpoint; + $post: Endpoint<{ redirect: string }, { param: { id: string }; json: { resources: string[] } | { deny: true } }>; + }; + "/mcp/relay/pair": { $post: Endpoint; }; + "/mcp/relay/machines/:id": { + $patch: Endpoint; + $delete: Endpoint; + }; + "/mcp/relay/keys": { $post: Endpoint<{ key: string; id: string }, { json: { name: string; resources: string[]; write: boolean } }>; }; + "/mcp/connections/:id": { $delete: Endpoint; }; + + "/me": { + $get: Endpoint; + }; + "/launcher": { + $get: Endpoint<{ id: string; name: string; tagline: string | null; url: string; icon: { light: string; dark: string; } | null; health: Health | null; }[]>; + }; + "/status": { + $get: Endpoint<{ load: number; issues: Issue[] | null }>; + }; + "/host": { + $get: Endpoint; + }; + "/services": { + $get: Endpoint; + }; + "/services/:id": { + $get: Endpoint; + }; + "/services/:id/definition": { + $get: Endpoint; + }; + "/services/:id/:action": { + $post: Endpoint; + }; + "/services/:id/restarts/acknowledge": { + $post: Endpoint; + }; + "/services/:id/secrets/:name": { + $get: Endpoint<{ value: string; }, { param: { name: string; } & { id: string; }; }>; + $put: Endpoint; + }; + "/services/:id/secrets/:name/rotate": { + $post: Endpoint; + }; + "/services/:id/logs": { + $get: Endpoint; + }; + "/services/:id/metrics/:name": { + $get: Endpoint; + }; + "/services/:id/traces": { + $get: Endpoint; + }; + "/traces/:id": { + $get: Endpoint; + }; + "/metrics/:metric": { + $get: Endpoint; + }; + "/live": { + $get: Endpoint<{}, {}, 200, string>; + }; + "/icons/:id/:file": { + $get: Endpoint; + }; + "/storage": { + $get: Endpoint<{ pool: Omit & { vdevs: (Omit & { disks: (Disk & { issue: string | null })[] })[] }; datasets: (Dataset & { link: string | null; media: string | null })[]; space: { live: number; held: number; free: number } }>; + }; + "/storage/snapshots": { + $get: Endpoint; + }; + "/storage/reclaim": { + $get: Endpoint<{ bytes: number; }, { query: { dataset: string; from: string; to: string; }; }>; + }; + "/storage/removed": { + $get: Endpoint<{ count: number; largest: { path: string; size: number; }[]; }, { query: { dataset: string; snapshot: string; }; }>; + }; + "/storage/destroy": { + $post: Endpoint; + }; + "/storage/files/map": { + $get: Endpoint<{ root: string; min: number; scanning: boolean; tree: MapNode | null }, { query: { width: string | string[]; height: string | string[]; path?: string | undefined; }; }>; + }; + "/storage/files/largest": { + $get: Endpoint<{ link: string | null; path: string; size: number; alloc: number; mtime: number; }[]>; + }; + "/media/refresh": { + $post: Endpoint; + }; + "/seedbox": { + $get: Endpoint<{state: ServerState; downloads: string | null; torrents: (Torrent & {folder: string | null; zip: string | null})[]}>; + }; + "/seedbox/history": { + $get: Endpoint; + }; + "/seedbox/torrents": { + $post: Endpoint; + }; + "/seedbox/torrents/:hash/files": { + $get: Endpoint<(TorrentFile & {link: string | null})[], { param: { hash: string; }; }>; + }; + "/seedbox/torrents/:hash/:action": { + $post: Endpoint; + }; + "/seedbox/torrents/:hash": { + $delete: Endpoint; + }; + "/youtube": { + $get: Endpoint<{pending:Video[]; shows:Show[]; jobs:Job[]; wall:Wall; archive:Archive; upscaler:Upscaler}>; + }; + "/youtube/channels": { + $get: Endpoint; + $put: Endpoint; + }; + "/youtube/configs": { + $get: Endpoint; + }; + "/youtube/configs/:name": { + $put: Endpoint; + }; + "/youtube/library": { + $get: Endpoint; + }; + "/youtube/library/rename": { + $post: Endpoint; + }; + "/youtube/pending": { + $post: Endpoint; + }; + "/youtube/pending/:key/ingest": { + $post: Endpoint; + }; + "/youtube/pending/:key/skip": { + $post: Endpoint; + }; + "/youtube/jobs/:id/retry": { + $post: Endpoint; + }; + "/youtube/upscaler": { + $put: Endpoint; + }; + "/paper-clover": { + $get: Endpoint; + }; + "/paper-clover/activity": { + $get: Endpoint; + }; + "/paper-clover/scan": { + $post: Endpoint; + }; + "/users": { + $get: Endpoint<{users:(User & {sessions:Session[]})[]; groups:Group[]}>; + $post: Endpoint<{ id: string; }, { json: { profile: { email: string; firstName: string; lastName: string; username: string; }; groups: string[]; setup: { kind: "email"; } | { kind: "password"; password: string; }; }; }, 201>; + }; + "/users/:id": { + $patch: Endpoint; + $delete: Endpoint; + }; + "/users/:id/groups/:group": { + $put: Endpoint; + $delete: Endpoint; + }; + "/users/:id/credentials": { + $get: Endpoint; + }; + "/users/:id/logout": { + $post: Endpoint; + }; + "/users/:id/actions-email": { + $post: Endpoint; + }; + "/users/:id/password": { + $put: Endpoint; + }; + "/deploys": { + $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; }>; + }; + "/deploys/changes": { + $get: Endpoint<{ id: string; lines: [op: " " | "-" | "+", text: string][]; }[], { query: { to: string; from?: string | undefined; }; }>; + }; + "/deploys/stages/:id/runtime": { + $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; }; }>; + }; + "/deploys/history/:n/runtime": { + $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; }; }>; + }; + "/deploys/stages/:id/logs": { + $get: Endpoint; + }; + "/deploys/history/:n/logs": { + $get: Endpoint; + }; + "/deploys/stages/:id/output": { + $get: Endpoint<{ lines: string[] | null; }, { param: { id: string; }; }>; + }; + "/deploys/history/:n/output": { + $get: Endpoint<{ lines: string[] | null; }, { param: { n: string; }; }>; + }; + "/deploys/main/:release/deploy": { + $post: Endpoint<{ id: string; title: string; target: string; }, { param: { release: string; }; }>; + }; + "/deploys/stages/:id/destroy": { + $post: Endpoint<{ id: string; title: string; target: string; }, { param: { id: string; }; }>; + }; + "/deploys/history/:n/rollback": { + $post: Endpoint<{ id: string; title: string; target: string; }, { param: { n: string; }; }>; + }; + "/deploys/runs/:id": { + $get: Endpoint<{}, { param: { id: string; }; }, 200, string>; + }; + "/vms": { + $get: Endpoint<{ node: { cpus: number; memory: number }; domains: Domain[]; images: Image[] }>; + $post: Endpoint; + }; + "/vms/history": { + $get: Endpoint; + }; + "/vms/:name": { + $patch: Endpoint; + $delete: Endpoint; + }; + "/vms/:name/:action": { + $post: Endpoint; + }; + "/account": { + $get: Endpoint; + $patch: Endpoint; + }; + "/account/verify-email": { + $post: Endpoint; + }; + "/account/picture": { + $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>; + $delete: Endpoint; + }; + "/account/pictures/:id": { + $get: Endpoint; + }; + "/account/actions/:action": { + $get: Endpoint; + }; + "/account/sign-out": { + $get: Endpoint; + }; +} & ExplorerRoutes<"/media"> & ExplorerRoutes<"/storage/files">>; diff --git a/dashboard/web/api.ts b/dashboard/web/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3c99c3f76df045bfb6996ba1f04911aa3b9a32e --- /dev/null +++ b/dashboard/web/api.ts @@ -0,0 +1,89 @@ +import { DetailedError, hc, parseResponse } from "hono/client"; +import { createResource, untrack } from "solid-js"; +import type { Api } from "./api.contract.ts"; +import type { Metric } from "./types/model.ts"; + +export const api = hc("/api", { + fetch: (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method !== "GET") return fetch(input, init); + const timeout = AbortSignal.timeout(15_000); + return fetch(input, { ...init, signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout }); + }, +}); + +/** + * The server has no source for this yet (HTTP 501); its message names what's missing. It stays missing until the + * server restarts, so polling it is pointless. + */ +export const unconnected = (error: unknown) => error instanceof DetailedError && error.statusCode === 501; + +/** A sentence for a failed request: the server's own text or JSON message when it sent one. */ +export function reason(error: unknown): string { + if (!(error instanceof DetailedError)) return "The dashboard didn't answer. Check your connection, then retry."; + const data = error.detail?.data; + const text = typeof data === "string" ? data.trim() : data?.message; + if (typeof text === "string" && text && !text.startsWith("<")) return text; + return error.statusCode >= 502 + ? "The dashboard server isn't answering. It may be restarting, so retry in a moment." + : `The dashboard answered ${error.message}. Check its logs, then retry.`; +} + +/** The last answer to each query, so a page opened again renders at once while it refetches. */ +const answers = new Map(); +/** The one request under way per query, which every load joins, including a page opening after its link's hover. */ +const loading = new Map>(); + +/** + * A GET that every view showing it shares. `use` is `createResource` that renders the last answer at once and refetches + * it, and whose loads join one already under way; `preload` starts a first load early, for a route's link hover. + */ +export function query(name: string, fetch: (arg: A) => Promise) { + const keyOf = (arg: A) => JSON.stringify([name, arg ?? null]); + function request(key: string, arg: A) { + const answer = fetch(arg); + loading.set(key, answer); + answer.then((value) => answers.set(key, value), () => {}).finally(() => loading.delete(key)); + return answer; + } + return { + preload(arg: A) { + const key = keyOf(arg); + if (!answers.has(key) && !loading.has(key)) request(key, arg).catch(() => {}); + }, + /** `arg` is reactive; while it's false nothing loads. */ + use(arg: () => A | false = () => undefined as A) { + const key = () => { + const value = arg(); + return value !== false && keyOf(value); + }; + const [resource, actions] = createResource(key, (key, { value, refetching }) => { + if (!refetching && answers.has(key)) { + queueMicrotask(() => actions.refetch()); + return answers.get(key) as T; + } + // A new key with no answer yet shows its skeleton, since Solid keeps the previous key's value as `latest`. + if (!refetching && value !== undefined) actions.mutate(undefined); + // Solid drops an answer once a newer load starts, so a poll that restarted a slow request would never land. + return (loading.get(key) as Promise | undefined) ?? request(key, untrack(arg) as A); + }); + return [resource, actions] as const; + }, + }; +} + +/** Queries that several pages share or a route preloads. */ +export const queries = { + services: query("services", () => parseResponse(api.services.$get())), + service: query("service", (id: string) => parseResponse(api.services[":id"].$get({ param: { id } }))), + launcher: query("launcher", () => parseResponse(api.launcher.$get())), + host: query("host", () => parseResponse(api.host.$get())), + storage: query("storage", () => parseResponse(api.storage.$get())), + deploys: query("deploys", () => parseResponse(api.deploys.$get())), + vms: query("vms", () => parseResponse(api.vms.$get())), + metric: query("metric", ({ metric, range, service }: { metric: Metric; range: number; service?: string }) => + parseResponse(api.metrics[":metric"].$get({ param: { metric }, query: { range: String(range), ...(service ? { service } : {}) } }))), + seedbox: query("seedbox", () => parseResponse(api.seedbox.$get())), + seedboxHistory: query("seedbox history", () => parseResponse(api.seedbox.history.$get())), + paperClover: query("paper clover", () => parseResponse(api["paper-clover"].$get())), + paperCloverActivity: query("paper clover activity", () => parseResponse(api["paper-clover"].activity.$get())), +}; diff --git a/dashboard/web/components/Ago.tsx b/dashboard/web/components/Ago.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb8863a2eb046899c8fc7bc6d7d0cbe9541b6d9e --- /dev/null +++ b/dashboard/web/components/Ago.tsx @@ -0,0 +1,6 @@ +import { ago, datetime } from "../format.ts"; + +/** A relative time with the full date and time on hover. `t` is unix seconds. */ +export const Ago = (props: { t: number }) => ( + +); diff --git a/dashboard/web/components/AppIcon.tsx b/dashboard/web/components/AppIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8fbdf46f905bef4ca4b87b6f30ff41fe0b8ff7fd --- /dev/null +++ b/dashboard/web/components/AppIcon.tsx @@ -0,0 +1,26 @@ +import { Show } from "solid-js"; +import type { ServiceSummary } from "../types/model.ts"; + +function slot(id: string) { + let seed = 0; + for (const char of id) seed = (seed * 31 + char.charCodeAt(0)) % 997; + return (seed % 8) + 1; +} + +/** The service's in-tree icon, or a lettered tile when it has none. */ +export function AppIcon(props: { id: string; name: string; icon: ServiceSummary["icon"] }) { + return ( + + ); +} diff --git a/dashboard/web/components/Checkbox.tsx b/dashboard/web/components/Checkbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a59256dbf6d91976839fd4a9b64bfb8f4d2364b --- /dev/null +++ b/dashboard/web/components/Checkbox.tsx @@ -0,0 +1,37 @@ +import Check from "lucide-solid/icons/check"; +import Minus from "lucide-solid/icons/minus"; +import { createEffect, type JSX } from "solid-js"; + +/** + * A checkbox drawn in the page's style around a real input, so Space, label clicks and forms behave natively. A string + * `disabled` is the reason, shown as the tooltip; `checked` without `onChange` is just the initial state in a form. The + * box flips at once, and once `onChange`'s promise settles it shows `checked` again, so a failed save flips it back. + */ +export function Checkbox(props: { + checked?: boolean; + indeterminate?: boolean; + disabled?: boolean | string; + name?: string; + value?: string; + onChange?: (checked: boolean) => unknown; + children: JSX.Element; +}) { + const reason = () => (typeof props.disabled === "string" ? props.disabled : undefined); + let input!: HTMLInputElement; + createEffect(() => (input.indeterminate = !!props.indeterminate)); + return ( + + ); +} diff --git a/dashboard/web/components/Copy.tsx b/dashboard/web/components/Copy.tsx new file mode 100644 index 0000000000000000000000000000000000000000..06db34cd813557c7d19e40341cc539a6c75750fa --- /dev/null +++ b/dashboard/web/components/Copy.tsx @@ -0,0 +1,22 @@ +import { createSignal, type JSX } from "solid-js"; + +/** + * Text that copies `value` on click; the tooltip turns into the result. Shows `value` unless given children, and + * `label` names what's copied when that isn't what's shown. + */ +export function Copy(props: { value: string; label?: string; children?: JSX.Element }) { + const [result, setResult] = createSignal(""); + const copy = () => + Promise.resolve() + .then(() => navigator.clipboard.writeText(props.value)) + .then(() => setResult("copied"), () => setResult("Couldn't copy. Select the text instead")); + return ( + <> + + {result()} + + ); +} diff --git a/dashboard/web/components/Dialog.tsx b/dashboard/web/components/Dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d15a527075c454b8095670ecc6c46ae6d9f320a5 --- /dev/null +++ b/dashboard/web/components/Dialog.tsx @@ -0,0 +1,131 @@ +import { createSignal, type JSX, onMount, Show } from "solid-js"; +import { render } from "solid-js/web"; +import { reason } from "../api.ts"; + +type Content = JSX.Element | (() => JSX.Element); +const build = (content: Content) => (typeof content === "function" ? content() : content); + +interface DialogOptions { + title: string; + /** + * The consequence, read out with the title. Pass a function when it reads signals or uses control flow, so it's + * built inside the dialog rather than in the click handler, which has no owner. + */ + description?: Content; + /** Form fields and options under the description (and text field), built inside the dialog like `description`. */ + body?: Content; + /** Names the action, like "restart"; never "ok". */ + confirmLabel: string; + destructive?: boolean; + /** Takes focus once the dialog closes, instead of the element that opened it. */ + returnFocus?: HTMLElement; + /** + * Gets the body's named fields. The dialog stays open with a spinner until this settles; a rejection shakes it + * and shows the reason. + */ + onConfirm: (form: FormData) => unknown; +} + +interface TextDialogOptions extends Omit { + /** Visible label for the field. */ + label: string; + placeholder?: string; + initialValue?: string; + /** Gates the confirm button; defaults to non-empty. */ + validateInput?: (value: string) => boolean; + onConfirm: (value: string, form: FormData) => unknown; +} + +const SHAKE = [0, -8, 8, -8, 8, -8, 8, 0].map((x) => ({ transform: `translateX(${x}px)` })); +const reduced = matchMedia("(prefers-reduced-motion: reduce)"); +let dialogs = 0; + +/** + * Enter confirms and Esc, the backdrop, and "back" cancel. Confirm starts focused unless the body has an + * `autofocus` field; the browser checks the body's constraints (`required`, `pattern`…) before `onConfirm` runs. + */ +export const showConfirmDialog = (options: DialogOptions) => open(options); + +/** Like showConfirmDialog with a text field; Enter submits once `validateInput` passes. */ +export const showTextDialog = (options: TextDialogOptions) => + open({ ...options, onConfirm: (form) => options.onConfirm(String(form.get("value")), form) }, options); + +function open(options: DialogOptions, field?: TextDialogOptions) { + const host = document.body.appendChild(document.createElement("div")); + const id = `dialog-${++dialogs}`; + const dispose = render(() => { + const [value, setValue] = createSignal(field?.initialValue ?? ""); + const [pending, setPending] = createSignal(false); + const [error, setError] = createSignal(""); + const valid = () => !field || (field.validateInput ?? Boolean)(value()); + let dialog!: HTMLDialogElement; + let pressedOutside = false; + + const close = () => { + if (pending()) return; + dialog.classList.add("closing"); + setTimeout(() => dialog.close(), reduced.matches ? 0 : 140); + }; + + const submit = async (event: SubmitEvent) => { + event.preventDefault(); + if (pending() || !valid()) return; + setPending(true); + setError(""); + try { + await options.onConfirm(new FormData(event.currentTarget as HTMLFormElement)); + setPending(false); + close(); + } catch (failure) { + setPending(false); + setError(reason(failure)); + if (!reduced.matches) dialog.animate(SHAKE, { duration: 380, easing: "cubic-bezier(0.36, 0.07, 0.19, 0.97)" }); + } + }; + + onMount(() => dialog.showModal()); + + return ( + { + event.preventDefault(); + close(); + }} + onClose={() => { + dispose(); + host.remove(); + options.returnFocus?.focus(); + }} + onPointerDown={(event) => (pressedOutside = event.target === dialog)} + onClick={(event) => pressedOutside && event.target === dialog && close()}> +
+

{options.title}

+ +
{build(options.description)}
+
+ + {(field) => ( + + )} + + {build(options.body)} + +
+ + {/* aria-disabled rather than disabled while pending, so focus stays put for a retry. */} + +
+
+
+ ); + }, host); +} diff --git a/dashboard/web/components/Donut.tsx b/dashboard/web/components/Donut.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7637dd1381961d6e7de1803f21d7dc0e0db0e48a --- /dev/null +++ b/dashboard/web/components/Donut.tsx @@ -0,0 +1,49 @@ +import { For } from "solid-js"; + +/** Part-to-whole ring; slices are separated by a surface-colored gap rather than a stroke. Colors may be `var(--…)`. */ +export function Donut(props: { + slices: { label: string; value: number; color: string }[]; + format: (value: number) => string; + center: string; + caption: string; +}) { + const R = 52; + const C = 2 * Math.PI * R; + const arcs = () => { + const total = props.slices.reduce((sum, slice) => sum + slice.value, 0) || 1; + let offset = 0; + return props.slices.map((slice) => { + const length = (slice.value / total) * C; + const arc = { ...slice, dash: `${Math.max(0, length - 2)} ${C}`, offset: -offset }; + offset += length; + return arc; + }); + }; + return ( +
+ + + + {(arc) => ( + + )} + + + {props.center} + {props.caption} + +
+ + {(slice) => ( +
+ + {slice.label} + {props.format(slice.value)} +
+ )} +
+
+
+ ); +} diff --git a/dashboard/web/components/Explorer.css b/dashboard/web/components/Explorer.css new file mode 100644 index 0000000000000000000000000000000000000000..d5a6c68df2adc4ad0510d21788e19b8c02c88aa7 --- /dev/null +++ b/dashboard/web/components/Explorer.css @@ -0,0 +1,215 @@ +.explorer { display: flex; flex-direction: column; } +.explorer > * { flex: none; } + +/* The chrome is spaced, not ruled: the pinned table header draws the one line between it and the list. */ +.explorer .bar { display: flex; align-items: center; gap: 6px; padding: 0 var(--gutter) 6px; min-height: 36px; } +.crumbs { display: flex; align-items: center; flex-wrap: wrap; min-width: 0; margin-left: -9px; color: var(--muted); font-size: 14px; } +.crumbs a { + min-width: 0; + height: 30px; + padding: 0 9px; + overflow: hidden; + border-radius: var(--radius); + color: var(--text-2); + line-height: 30px; + white-space: nowrap; + text-overflow: ellipsis; + transition: background-color 150ms; +} +.crumbs svg { flex: none; } +.crumbs a:hover { background: var(--hover); color: var(--text); } +.crumbs a[aria-current] { color: var(--text); font-weight: 600; } +.explorer .bar .total { color: var(--text-2); font-size: 13px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.explorer .bar .count { margin-right: 4px; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.explorer .bar .button.icon-only[aria-pressed="true"] { color: var(--accent); } + +.explorer .history { + inset: auto; + top: anchor(bottom); + right: anchor(right); + width: min(460px, calc(100vw - 32px)); + max-height: min(360px, 60vh); + margin: 6px 0 0; + padding: 4px 0; + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + color: var(--text); + box-shadow: 0 8px 28px #0005; + font-size: 13px; +} +.explorer .history:popover-open { animation: menu-in 200ms var(--ease-out); } +.explorer .history .none { margin: 0; padding: 10px 14px; color: var(--muted); } +.explorer .history .empty.failed { padding: 8px 14px; } +.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; } +.explorer .history .op:hover { background: var(--hover); } +.explorer .history .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.explorer .history .when { color: var(--muted); font-size: 12px; white-space: nowrap; } +.explorer .history .undone { color: var(--muted); font-size: 12px; text-align: center; } +.explorer .history .button { justify-self: stretch; } + +.explorer .map-slot { padding: 0 var(--gutter) 8px; } +.explorer .folder-map { position: relative; height: clamp(120px, 22vh, 220px); } +.explorer .folder-map.empty { display: grid; place-items: center; color: var(--muted); font-size: 13px; border-radius: var(--radius); background: var(--well); } +.explorer .folder-map .cell { + position: absolute; + overflow: hidden; + display: flex; + flex-direction: column; + padding: 4px 6px; + border: 1px solid var(--page); + border-radius: var(--radius); + background: color-mix(in srgb, var(--kind) 42%, var(--page)); + font-size: 12px; + line-height: 1.3; + transition: background-color 150ms; +} +.explorer .folder-map .cell.dir { cursor: pointer; } +.explorer .folder-map .cell.rest { background: repeating-linear-gradient(135deg, var(--raised) 0 4px, var(--page) 4px 8px); } +.explorer .folder-map .cell.hover:not(.rest) { background: color-mix(in srgb, var(--kind) 62%, var(--page)); } +.explorer .folder-map .cell.cursor { box-shadow: inset 0 0 0 2px var(--text); } +.explorer .folder-map .cell > span { flex: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.explorer .folder-map .name { color: var(--text); font-weight: 550; } +.explorer .folder-map .size { color: var(--text-2); font-variant-numeric: tabular-nums; } + +.explorer .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 var(--gutter) 8px; font-size: 13px; white-space: nowrap; } +.explorer .toolbar .clip { display: flex; align-items: center; gap: 4px; } +.explorer .toolbar .matched { font-variant-numeric: tabular-nums; margin-right: 4px; } +.explorer .toolbar .error { overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.explorer .toolbar .pattern { flex: 0 1 240px; min-width: 132px; gap: 6px; } +.explorer .toolbar .pattern input { font: 12px var(--mono); } +.explorer .toolbar .pattern.active { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); background: var(--accent-wash); } +.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; } +.explorer .toolbar .pattern .clear:hover { background: var(--hover); color: var(--text); } + +.explorer .files-scroll { flex: 1 1 0; min-height: 0; overflow: auto; } +.explorer .files-skeleton { display: grid; gap: 14px; padding: 36px var(--gutter) 12px calc(var(--gutter) + 24px); } + +.explorer table.files { user-select: none; outline: none; } +.explorer table.files th { + position: sticky; + top: 0; + z-index: 1; + height: 28px; + padding: 0 8px; + border-bottom: 0; + box-shadow: inset 0 -1px var(--line); + background: var(--page); +} +.explorer table.files .num { width: 1%; } +.explorer table.files tr > :first-child { padding-left: var(--gutter); } +.explorer table.files tr > :last-child { padding-right: var(--gutter); } +.explorer table.files td { padding: 0 8px; height: 26px; border-bottom: 0; color: var(--text-2); } +/* Lets the name column shrink below its text, so long names ellipsize instead of widening the table. */ +.explorer table.files td:first-child { max-width: 0; } +.explorer table.files .name { + display: flex; + align-items: center; + gap: 6px; + padding-left: calc(var(--depth, 0) * 20px); + color: var(--text); + min-width: 0; +} +.explorer table.files .twisty { + display: grid; + place-items: center; + flex: none; + width: 18px; + height: 22px; + margin-left: -4px; + padding: 0; + border: 0; + border-radius: 4px; + background: none; + color: var(--muted); + cursor: pointer; +} +.explorer table.files .twisty:hover { background: var(--hover); color: var(--text); } +.explorer table.files .twisty svg { transition: transform 150ms; } +.explorer table.files .twisty.open svg { transform: rotate(90deg); } +.explorer table.files .twisty[aria-busy="true"] svg { opacity: 0.4; } +.explorer table.files tr.up td { color: var(--muted); cursor: pointer; } +.explorer table.files tr.up .name { color: var(--text-2); } +.explorer table.files tr.up:hover .name { color: var(--text); } +.explorer table.files tr.empty-row td { height: 64px; color: var(--muted); text-align: center; } +.explorer table.files tr.empty-row:hover { background: none; } +.explorer table.files tr.cut .name > :not(.twisty) { opacity: 0.45; } +.explorer table.files .name .text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.explorer table.files .icon { width: 15px; height: 15px; flex: none; } +.explorer table.files tbody tr { cursor: default; } +.explorer table.files tbody tr:hover { background: var(--hover); } +.explorer table.files tbody tr.selected { background: var(--accent-wash); } +.explorer table.files tbody tr.cursor td:first-child { box-shadow: inset 2px 0 var(--accent); } +.explorer table.files:focus-visible tbody tr.cursor { outline: 2px solid var(--focus); outline-offset: -2px; } +.explorer table.files .rename { flex: 1; height: 22px; padding: 0 6px; } +.explorer table.files.lens tbody tr:not(.hit, .up) { opacity: 0.4; } +.explorer table.files .badge { + flex: none; + padding: 0 6px; + border-radius: 5px; + background: var(--accent-wash); + color: var(--accent); + font-size: 11.5px; + line-height: 18px; + font-variant-numeric: tabular-nums; +} + +.explorer table.files tbody tr.hover { background: var(--hover); } +.explorer table.files .kind { display: grid; flex: none; } + +.explorer .legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + padding: 8px var(--gutter); + color: var(--muted); + font-size: 12px; +} +.explorer .legend span { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; } +.explorer .legend kbd { margin-right: 2px; } + +.dialog .match-list { + list-style: none; + margin: 0 0 8px; + padding: 6px 8px; + max-height: 180px; + overflow: auto; + background: var(--well); + border: 1px solid var(--line); + border-radius: var(--radius); + font-size: 12px; + line-height: 1.6; +} +.dialog .match-list li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dialog .match-list .more { color: var(--muted); font-family: var(--sans); } +.dialog .description:has(.match-list) { min-width: 0; } +.dialog .description:has(.match-list) p { margin: 0; } + +.dialog:has(.bulk-rename) { width: min(640px, calc(100% - 32px)); } +.bulk-rename { display: grid; gap: 12px; } +.bulk-rename .segmented { justify-self: start; } +.bulk-rename .row.format { grid-auto-flow: row; grid-template-columns: 1fr 96px; row-gap: 6px; } +.bulk-rename .row.format .hint { grid-column: 1 / -1; color: var(--muted); font-size: 12px; } +.bulk-rename .preview { max-height: 240px; overflow: auto; background: var(--well); border: 1px solid var(--line); border-radius: var(--radius); } +.bulk-rename .preview table { font-size: 12px; } +.bulk-rename .preview td { height: 24px; padding: 0 8px; border-bottom: 0; white-space: nowrap; max-width: 260px; overflow: hidden; text-overflow: ellipsis; } +.bulk-rename .preview td:first-child { color: var(--text-2); } +.bulk-rename .preview td.arrow { width: 1%; padding: 0; color: var(--muted); } +.bulk-rename .preview tr.same td:last-child { color: var(--muted); font-family: var(--sans); } +.bulk-rename .preview tr.clash td:last-child { color: var(--critical); } + +.dialog:has(.peek) { width: min(720px, calc(100% - 32px)); } +.dialog .peek { + max-height: 55vh; + margin: 0 0 6px; + padding: 8px 10px; + overflow: auto; + background: var(--well); + border: 1px solid var(--line); + border-radius: var(--radius); + color: var(--text); + font: 12px/1.5 var(--mono); + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/dashboard/web/components/Explorer.tsx b/dashboard/web/components/Explorer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bdb0411521da1518bebeef6d363471f8095e755b --- /dev/null +++ b/dashboard/web/components/Explorer.tsx @@ -0,0 +1,1073 @@ +import { useLocation, useSearchParams } from "@solidjs/router"; +import { Checkbox } from "./Checkbox.tsx"; +import { type InferResponseType, parseResponse } from "hono/client"; +import ChevronRight from "lucide-solid/icons/chevron-right"; +import CornerLeftUp from "lucide-solid/icons/corner-left-up"; +import Database from "lucide-solid/icons/database"; +import FileIcon from "lucide-solid/icons/file"; +import FileCode from "lucide-solid/icons/file-code"; +import FileImage from "lucide-solid/icons/file-image"; +import FileMusic from "lucide-solid/icons/file-music"; +import FilePlay from "lucide-solid/icons/file-play"; +import FileText from "lucide-solid/icons/file-text"; +import Folder from "lucide-solid/icons/folder"; +import History from "lucide-solid/icons/rotate-ccw-clock"; +import LayoutGrid from "lucide-solid/icons/layout-grid"; +import TextSearch from "lucide-solid/icons/text-search"; +import X from "lucide-solid/icons/x"; +import { + createEffect, createMemo, createResource, createSignal, For, type JSX, on, onCleanup, onMount, Show, +} from "solid-js"; +import { api, queries, query, reason } from "../api.ts"; +import { ago, bytes, count, percent, plural } from "../format.ts"; +import { Ago } from "./Ago.tsx"; +import { showConfirmDialog, showTextDialog } from "./Dialog.tsx"; +import { lastGood, Loaded } from "./Loaded.tsx"; +import { OpenApp } from "./OpenApp.tsx"; +import { Reveal } from "./Reveal.tsx"; +import { type Sort, SortHeader } from "./SortHeader.tsx"; +import { TabBar } from "./TabBar.tsx"; +import { toast } from "./Toast.tsx"; +import "./Explorer.css"; + +/** A file browser's routes: the library's, or the whole store's under storage. */ +type Client = typeof api.media | typeof api.storage.files; +type Entry = InferResponseType["entries"][number]; +type Tree = InferResponseType; +type Key = "name" | "size" | "modified"; +type Op = { id: string; label: string }; +/** A visible line of the file tree; `path` is root-relative. */ +type Row = { path: string; parent: string; depth: number; entry: Entry }; + +/** The pool's main areas by mountpoint; a folder takes the color of the last that holds it, so Media leaves clover. */ +export const REGIONS = [ + { root: "/srv/prod", label: "prod", series: 2 }, + { root: "/srv/clover", label: "clover", series: 1 }, + { root: "/srv/clover/Media", label: "media", series: 5 }, + { root: "/srv/staging", label: "staging", series: 3 }, + { root: "/srv/vm", label: "vm", series: 7 }, +] as const; + +/** Squarified treemap (Bruls et al.) of items already sorted by size, largest first; cells are relative to the box. */ +export function squarify(items: T[], width: number, height: number) { + const total = items.reduce((sum, item) => sum + item.size, 0); + const cells: { item: T; x: number; y: number; w: number; h: number }[] = []; + let [x, y, w, h] = [0, 0, width, height]; + let rest = items.map((item) => ({ item, area: (item.size / total) * width * height })); + while (rest.length) { + const side = Math.min(w, h); + const worst = (sum: number, smallest: number) => + Math.max((side * side * rest[0]!.area) / (sum * sum), (sum * sum) / (side * side * smallest)); + let sum = rest[0]!.area; + let taken = 1; + while (taken < rest.length && worst(sum + rest[taken]!.area, rest[taken]!.area) <= worst(sum, rest[taken - 1]!.area)) { + sum += rest[taken]!.area; + taken++; + } + const thickness = sum / side; + let offset = 0; + for (const { item, area } of rest.slice(0, taken)) { + const length = area / thickness; + cells.push(w >= h + ? { item, x, y: y + offset, w: thickness, h: length } + : { item, x: x + offset, y, w: length, h: thickness }); + offset += length; + } + if (w >= h) [x, w] = [x + thickness, w - thickness]; + else [y, h] = [y + thickness, h - thickness]; + rest = rest.slice(taken); + } + return cells; +} + +/** Folder links for `path` under a root labelled `root`, keeping the page's other parameters. */ +export function Crumbs(props: { root: string; path: string; tip?: string }) { + const location = useLocation(); + const names = () => (props.path ? props.path.split("/") : []); + const href = (path: string) => { + const query = new URLSearchParams(location.search); + if (path) query.set("path", path); + else query.delete("path"); + return `${location.pathname}${query.size ? `?${query}` : ""}`; + }; + return ( + + ); +} + +const KINDS: [RegExp, (props: { class: string }) => JSX.Element, string][] = [ + [/\.(mkv|mp4|m4v|avi|webm|mov|ts)$/i, FilePlay, "var(--series-1)"], + [/\.(flac|mp3|m4a|opus|ogg|wav|aac)$/i, FileMusic, "var(--series-2)"], + [/\.(jpe?g|png|webp|gif|bmp|tbn|heic)$/i, FileImage, "var(--series-3)"], + [/\.(nfo|xml|json)$/i, FileCode, "var(--series-4)"], + [/\.(srt|ass|ssa|vtt|sub|idx|txt)$/i, FileText, "var(--series-5)"], +]; +const TEXT = /\.(nfo|xml|json|srt|ass|ssa|vtt|txt|md|log|cue|m3u8?|html?|ya?ml|ini|conf)$/i; + +/** Finder's keys on Apple platforms, Explorer's everywhere else. */ +const MAC = /^(Mac|iP)/.test(navigator.platform); + +/** Rows are keyed by entry object, so reading props once is safe. */ +function EntryIcon(props: { entry: Entry; color?: string }) { + if (props.entry.dataset) return ; + if (props.entry.dir) return ; + const [, Icon, color] = KINDS.find(([pattern]) => pattern.test(props.entry.name)) ?? [null, FileIcon, "var(--muted)"]; + return ; +} + +const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name); +const parentOf = (rel: string) => rel.split("/").slice(0, -1).join("/"); +/** Whether `rel` is strictly inside `dir`. */ +const within = (dir: string, rel: string) => rel !== dir && (!dir || rel.startsWith(dir + "/")); +const under = (dir: string, abs: string) => abs === dir || abs.startsWith(dir + "/"); + +/** Entries under this share of the folder merge into one cell, drawn at least `REST` large so they stay findable. */ +const TINY = 0.004; +const REST = 0.04; + +/** The folder's entries as blocks sized by bytes, each colored as its row's icon. */ +function FolderMap(props: { + items: { name: string; size: number; dir: boolean; files: number; dataset: string | null }[]; + total: number; + hover: string | undefined; + cursor: string | undefined; + color: (name: string) => string; + onHover: (name: string | undefined) => void; + onPick: (name: string, open: boolean) => void; +}) { + let box!: HTMLDivElement; + const [size, setSize] = createSignal({ width: 0, height: 0 }); + onMount(() => { + const resizes = new ResizeObserver(([entry]) => setSize({ width: entry!.contentRect.width, height: entry!.contentRect.height })); + resizes.observe(box); + onCleanup(() => resizes.disconnect()); + }); + const layout = createMemo(() => { + const sized = props.items.filter((item) => item.size > 0).sort((a, b) => b.size - a.size); + const total = sized.reduce((sum, item) => sum + item.size, 0); + const shown = sized.filter((item) => item.size >= total * TINY); + const tiny = sized.slice(shown.length); + const rest = { name: "", size: Math.max(total * REST, tiny.reduce((sum, item) => sum + item.size, 0)), dir: false, files: 0, dataset: null }; + const { width, height } = size(); + return { cells: width ? squarify(tiny.length ? [...shown, rest] : shown, width, height) : [], tiny }; + }); + return ( + + ); +} + +/** + * A file browser over a root the server picked: a tree of the folder in `path` with Finder or Explorer keys, a map of + * what takes the space, and undoable renames, moves and deletes. `id` keys what it remembers per page. + */ +export function Explorer(props: { id: string; client: Client; root: string }) { + const [params, setParams] = useSearchParams<{ path?: string; pattern?: string }>(); + const here = () => params.path ?? ""; + const pattern = () => params.pattern ?? ""; + const folderName = () => here().split("/").at(-1) || props.root; + const open = (path: string) => setParams({ path: path || undefined }); + + const [list, { refetch }] = query(`${props.id} folder`, (path: string) => + parseResponse(props.client.list.$get({ query: { path } }))).use(here); + /** `latest` rethrows a failed load. */ + const listing = () => (list.state === "errored" ? undefined : list.latest); + const apps = lastGood(queries.launcher.use()[0]); + const [ops, { refetch: refetchOps }] = createResource(() => parseResponse(props.client.ops.$get())); + const lastOps = lastGood(ops); + const [matches, { refetch: refetchMatches }] = createResource( + () => (pattern() ? { path: here(), pattern: pattern() } : false), + (query) => parseResponse(props.client.match.$get({ query })), + ); + + /** Listings of expanded folders and totals of every folder shown, both root-relative. */ + const [folders, setFolders] = createSignal({}); + const [sizes, setSizes] = createSignal({}); + /** The folder whose totals have arrived, so the map knows missing ones are unmeasurable, not pending. */ + const [measured, setMeasured] = createSignal(); + /** Why the folder shown couldn't be measured, until it changes. */ + const [unmeasured, setUnmeasured] = createSignal(); + const [updated, setUpdated] = createSignal(null); + const [expanded, setExpanded] = createSignal(new Set()); + const [expanding, setExpanding] = createSignal(); + const [sort, setSort] = createSignal>({ key: "name", desc: false }); + const [selected, setSelected] = createSignal(new Set()); + const [cursor, setCursor] = createSignal(); + const [hover, setHover] = createSignal(); + const [editing, setEditing] = createSignal<{ rename: string } | "folder">(); + const renaming = () => { + const edit = editing(); + return typeof edit === "object" ? edit.rename : undefined; + }; + const [clipboard, setClipboard] = createSignal(); + const [undoing, setUndoing] = createSignal(); + const mapKey = `explorer.${props.id}.map`; + const [mapShown, setMapShown] = createSignal(localStorage.getItem(mapKey) !== "hidden"); + let anchor: string | undefined; + let table: HTMLTableElement | undefined; + let patternInput!: HTMLInputElement; + + const known = new WeakMap(); + const rows = createMemo(() => { + const { key, desc } = sort(); + const value = (row: Row) => + key === "size" ? (row.entry.size ?? sizes()[row.path]?.size ?? 0) : key === "modified" ? row.entry.modified : 0; + const out: Row[] = []; + const walk = (dir: string, entries: Entry[], depth: number) => { + const level = entries.map((entry) => { + let row = known.get(entry); + if (!row) known.set(entry, row = { path: join(dir, entry.name), parent: dir, depth, entry }); + return row; + }).sort((a, b) => + Number(b.entry.dir) - Number(a.entry.dir) + || (desc ? -1 : 1) * (value(a) - value(b) || a.entry.name.localeCompare(b.entry.name, undefined, { numeric: true }))); + for (const row of level) { + out.push(row); + const inner = expanded().has(row.path) && folders()[row.path]; + if (inner) walk(row.path, inner, depth + 1); + } + }; + walk(here(), listing()?.entries ?? [], 0); + return out; + }); + const current = () => rows().find((row) => row.path === cursor()); + /** The selection in view, without anything inside a selected folder. */ + const chosen = () => rows().filter((row) => selected().has(row.path) && ![...selected()].some((rel) => within(rel, row.path))); + const only = () => (chosen().length === 1 ? chosen()[0] : undefined); + /** A dataset in the selection, which only zfs renames, moves or deletes. */ + const dataset = () => chosen().find((row) => row.entry.dataset); + const found = () => (pattern() && matches.state !== "errored" ? matches.latest : undefined); + const expandable = (row: Row) => row.entry.dir && row.entry.items !== 0; + const sizeOf = (row: Row) => row.entry.size ?? sizes()[row.path]?.size; + const total = (chosen: Row[]) => chosen.reduce((sum, row) => sum + (sizeOf(row) ?? 0), 0); + + /** + * A top folder's color: its region's, else the first free one from a hash of its identity, which renames and growth + * leave alone. Folders claim colors in inode order, so a new one rarely takes an existing one's. A mounted dataset's + * root inode repeats across datasets, so datasets go by name. + */ + const colors = createMemo(() => { + const host = listing()?.host ?? ""; + const dirs = (listing()?.entries ?? []).filter((entry) => entry.dir) + .map((entry) => ({ name: entry.name, identity: entry.dataset ?? String(entry.inode) })) + .sort((a, b) => a.identity.localeCompare(b.identity, undefined, { numeric: true })); + const picked = new Map(dirs.flatMap(({ name }) => { + const region = REGIONS.find((region) => region.root === `${host}/${name}`); + return region ? [[name, region.series as number] as const] : []; + })); + const inRegion = REGIONS.findLast((region) => under(region.root, host)); + for (const { name, identity } of dirs.filter(({ name }) => !picked.has(name))) { + const hash = [...identity].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 7); + const free = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => ((hash + i) % 8) + 1) + .find((n) => ![...picked.values()].includes(n) && n !== inRegion?.series); + if (free) picked.set(name, free); + } + return picked; + }); + const colorOf = (name: string) => (colors().has(name) ? `--series-${colors().get(name)}` : "--other"); + const mapItems = () => measured() === here() ? rows().filter((row) => !row.depth && sizeOf(row) !== undefined).map((row) => ({ + name: row.entry.name, + size: sizeOf(row)!, + dir: row.entry.dir, + files: row.entry.dir ? sizes()[row.path]?.files ?? 0 : 1, + dataset: row.entry.dataset, + })) : undefined; + + /** Folds `tree` in, newest listing winning; drops expansions it was asked for but no longer finds. */ + const merge = (tree: Tree, asked: Set = new Set()) => { + setFolders((known) => ({ ...known, ...tree.folders })); + setSizes((known) => ({ ...known, ...tree.sizes })); + setExpanded((open) => new Set([...open].filter((rel) => !asked.has(rel) || rel in tree.folders))); + }; + const refreshTree = async () => { + const path = here(); + const asked = new Set(expanded()); + const tree = await parseResponse(props.client.tree.$post({ json: { path, expanded: [...asked] } })).catch((failure) => { + if (here() === path) setUnmeasured(reason(failure)); + return null; + }); + if (tree && here() === path) { + merge(tree, asked); + setMeasured(path); + setUpdated(tree.updated); + } + }; + + createEffect(on(here, (now, before) => { + setFolders({}); + setSizes({}); + setMeasured(); + setUnmeasured(); + setExpanded(new Set()); + setEditing(); + /** Coming up out of a folder lands on it, like Finder. */ + const from = before !== undefined && within(now, before) + ? join(now, before.slice(now ? now.length + 1 : 0).split("/")[0]!) + : undefined; + setSelected(new Set(from ? [from] : [])); + setCursor(from); + anchor = from; + refreshTree(); + })); + + createEffect(on([cursor, rows], ([path, all]) => { + const index = all.findIndex((row) => row.path === path); + if (index >= 0) queueMicrotask(() => table?.querySelector(`[data-row="${index}"]`)?.scrollIntoView({ block: "nearest" })); + })); + + const select = (row: Row, how: "only" | "toggle" | "range") => { + setCursor(row.path); + setEditing(); + if (how === "range") { + const all = rows(); + const at = all.indexOf(row); + const from = all.findIndex((other) => other.path === anchor); + const [start, end] = from < 0 ? [at, at] : [Math.min(from, at), Math.max(from, at)]; + setSelected(new Set(all.slice(start, end + 1).map((other) => other.path))); + return; + } + anchor = row.path; + const next = new Set(how === "toggle" ? selected() : []); + if (how === "toggle" && next.has(row.path)) next.delete(row.path); + else next.add(row.path); + setSelected(next); + }; + + const step = (delta: number, extend: boolean) => { + const all = rows(); + const at = all.findIndex((row) => row.path === cursor()); + const next = all[Math.max(0, Math.min(all.length - 1, at < 0 && delta < 0 ? 0 : at + delta))]; + if (next) select(next, extend ? "range" : "only"); + }; + + const expandAll = async (path: string) => { + setExpanding(path); + try { + const tree = await parseResponse(props.client.tree.$post({ json: { path, expanded: "all" } })); + if (path !== here() && !within(here(), path)) return; + merge(tree); + setExpanded((open) => new Set([...open, ...Object.keys(tree.folders).filter((rel) => rel !== here())])); + if (!tree.complete) { + if (Object.values(tree.folders).some((entries) => entries.some((entry) => entry.dir && entry.items === null))) { + toast("Some folder counts are unavailable. Expand folders one at a time."); + return; + } + const depth = (rel: string) => (rel ? rel.split("/").length : 0); + const levels = Math.max(...Object.keys(tree.folders).map(depth)) - depth(path); + toast(levels + ? `Expanded ${plural(levels, "level")}. Deeper folders hold too many items to open at once.` + : "These folders hold too many items to open at once. Expand them one at a time."); + } + } catch (failure) { + toast(reason(failure)); + } finally { + setExpanding(); + } + }; + + const expand = async (row: Row) => { + if (!expandable(row)) return; + setExpanded((open) => new Set(open).add(row.path)); + if (!folders()[row.path]) { + const found = await parseResponse(props.client.list.$get({ query: { path: row.path } })).catch((failure) => { + toast(reason(failure)); + collapse(row); + }); + if (found && within(here(), row.path)) setFolders((known) => ({ ...known, [row.path]: found.entries })); + } + const tree = await parseResponse(props.client.tree.$post({ json: { path: row.path, expanded: [] } })).catch(() => null); + if (tree && within(here(), row.path)) merge(tree); + }; + + /** Keeps what's open inside, so expanding again shows it the same way, unless `deep`. */ + const collapse = (row: Row, deep = false) => { + setExpanded((open) => new Set([...open].filter((rel) => rel !== row.path && !(deep && within(row.path, rel))))); + setSelected((picked) => new Set([...picked].filter((rel) => !within(row.path, rel)))); + if (within(row.path, cursor() ?? "")) setCursor(row.path); + }; + + const activate = (row: Row) => { + if (row.entry.dir) open(row.path); + else if (row.entry.download) window.open(row.entry.download, "_blank", "noreferrer"); + }; + const up = () => here() && open(parentOf(here())); + + const reload = () => { + refetch(); + refetchOps(); + refetchMatches(); + refreshTree(); + }; + const undo = (op: Op) => parseResponse(props.client.ops[":id"].undo.$post({ param: { id: op.id } })).finally(reload); + + const done = (op: Op) => { + toast(op.label, { label: "undo", run: () => undo(op) }); + setSelected(new Set()); + setEditing(); + reload(); + }; + + /** For changes that can fail partway, so the folder reloads either way; the failure reaches the dialog. */ + const act = (change: Promise) => change.then(done, (failure) => { + reload(); + throw failure; + }); + + const paths = () => chosen().map((row) => row.path); + const what = () => (only() ? only()!.entry.name : plural(chosen().length, "item")); + const refuseDataset = () => { + const row = dataset(); + if (row) toast(`${row.entry.name} is the ${row.entry.dataset} dataset. Rename, move or destroy it with zfs instead.`); + return !!row; + }; + + const remove = () => { + const selection = paths(); + if (!selection.length || refuseDataset()) return; + showConfirmDialog({ + title: `Delete ${what()}?`, + description: () => ( + <> + 1}> +
    + {(rel) =>
  • {rel.slice(here() ? here().length + 1 : 0)}
  • }
    +
+
+

You can undo this from recent changes.

+ + ), + confirmLabel: "delete", + destructive: true, + returnFocus: table, + onConfirm: () => act(parseResponse(props.client.delete.$post({ json: { paths: selection } }))), + }); + }; + + const moveTo = (title: string, description: () => JSX.Element, run: (to: string) => Promise) => showTextDialog({ + title, + description, + returnFocus: table, + label: "Destination folder", + initialValue: here() ? here() + "/" : "", + validateInput: () => true, + confirmLabel: "move", + onConfirm: (to) => act(run(to.trim().replace(/^\/+|\/+$/g, ""))), + }); + + const move = () => { + const selection = paths(); + if (!selection.length || refuseDataset()) return; + moveTo(`Move ${what()}`, () => `Folder paths start from ${props.root}.`, + (to) => parseResponse(props.client.move.$post({ json: { paths: selection, to } }))); + }; + + /** Where ⌘V lands: inside the folder under the cursor when it's open, else beside the cursor. */ + const pasteTarget = () => { + const row = current(); + if (!row) return here(); + return row.entry.dir && expanded().has(row.path) ? row.path : row.parent; + }; + const nameOf = (rel: string) => rel.split("/").at(-1) || props.root; + + const paste = () => { + const to = pasteTarget(); + const moving = (clipboard() ?? []).filter((rel) => parentOf(rel) !== to); + if (!clipboard()) return; + if (!moving.length) return toast(`Already in ${nameOf(to)}`); + showConfirmDialog({ + title: `Move ${moving.length === 1 ? nameOf(moving[0]!) : plural(moving.length, "item")} to ${nameOf(to)}?`, + description: () => { + const from = new Set(moving.map(parentOf)); + return ( + <> +
    {(rel) =>
  • {nameOf(rel)}
  • }
+

+ {from.size === 1 ? `From ${[...from][0] || props.root} into` : "Into"} {to || props.root}. + You can undo this from recent changes. +

+ + ); + }, + confirmLabel: "move", + returnFocus: table, + onConfirm: () => act(parseResponse(props.client.move.$post({ json: { paths: moving, to } }))).then(() => setClipboard()), + }); + }; + + const bulk = (action: "delete" | "move") => { + const shown = found(); + if (!shown?.count) return; + const query = { path: here(), pattern: pattern(), count: shown.count }; + const list = () => ( +
    + {(path) =>
  • {path}
  • }
    + shown.sample.length}> +
  • and {count(shown.count - shown.sample.length)} more
  • +
    +
+ ); + if (action === "move") { + return moveTo(`Move ${plural(shown.count, "match", "matches")}`, () => <>{list()}

Each keeps its subfolders.

, + (to) => parseResponse(props.client.bulk.$post({ json: { ...query, action, to } }))); + } + showTextDialog({ + title: `Delete ${plural(shown.count, "match", "matches")}?`, + description: () => ( + <>{list()}

{shown.size === null ? "" : `${bytes(shown.size)} in ${folderName()}. `}You can undo this from recent changes.

+ ), + returnFocus: table, + label: `Type ${shown.count} to confirm`, + validateInput: (value) => value.trim() === String(shown.count), + confirmLabel: "delete", + destructive: true, + onConfirm: () => act(parseResponse(props.client.bulk.$post({ json: { ...query, action } }))), + }); + }; + + const bulkRename = () => { + const targets = chosen(); + const [mode, setMode] = createSignal<"replace" | "format">("replace"); + const [find, setFind] = createSignal(""); + const [replacement, setReplacement] = createSignal(""); + const [regex, setRegex] = createSignal(false); + const [format, setFormat] = createSignal("{name}"); + const [start, setStart] = createSignal(1); + const rename = (name: string, index: number, dir: boolean) => { + if (mode() === "format") { + const dot = dir ? -1 : name.lastIndexOf("."); + const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""]; + return format().replace(/\{(n+)\}/g, (_, digits: string) => String(start() + index).padStart(digits.length, "0")) + .replaceAll("{name}", stem) + ext; + } + if (!find()) return name; + return regex() ? name.replace(new RegExp(find(), "g"), replacement()) : name.replaceAll(find(), replacement()); + }; + const preview = () => { + let failure = ""; + const renamed = targets.map((row, index) => { + try { + return { row, name: rename(row.entry.name, index, row.entry.dir) }; + } catch (error) { + failure = `Check the regex: ${(error as Error).message}.`; + return { row, name: row.entry.name }; + } + }); + const taken = (row: Row) => new Set(((row.parent === here() ? listing()?.entries : folders()[row.parent]) ?? []) + .map((entry) => entry.name).filter((name) => name !== row.entry.name)); + const clashes = renamed.map(({ row, name }) => { + if (!name.trim() || name.includes("/") || name === "." || name === "..") return "names can't be empty or contain /"; + if (name === row.entry.name) return undefined; + if (taken(row).has(name)) return `${name} already exists`; + const twin = renamed.some((other) => other.row !== row && other.row.parent === row.parent && other.name === name); + if (twin) return "two items get this name"; + }); + const problems = clashes.filter(Boolean).length; + return { + renamed: renamed.map((item, index) => ({ ...item, clash: clashes[index] })), + failure: failure + || (problems ? `${plural(problems, "name")} can't be used. Change the pattern so each name is new and unique.` : ""), + }; + }; + let field: HTMLInputElement | undefined; + const input = (props: { label: string; value: () => string; set: (value: string) => void; autofocus?: boolean }) => ( + + ); + showConfirmDialog({ + title: `Rename ${plural(targets.length, "item")}`, + returnFocus: table, + confirmLabel: "rename", + body: () => { + createEffect(() => field?.setCustomValidity(preview().failure)); + return ( +
+ + + + + + {input({ label: "Name", value: format, set: setFormat, autofocus: true })} + + {"{name}"} is the old name and {"{n}"} counts up; {"{nn}"} pads it to 2 digits +
+ }> +
+ {input({ label: "Find", value: find, set: setFind, autofocus: true })} + {input({ label: "Replace with", value: replacement, set: setReplacement })} +
+ regex, with $1 for groups + +
+ + + + {(item) => ( + + + + + + )} + + +
{item.row.entry.name}→{item.name === item.row.entry.name ? "unchanged" : item.name}
+
+ + ); + }, + onConfirm: () => { + const renames = preview().renamed.filter((item) => item.name !== item.row.entry.name) + .map((item) => ({ path: item.row.path, name: item.name })); + return renames.length && act(parseResponse(props.client.rename.$post({ json: { renames } }))); + }, + }); + }; + + const rename = () => { + if (refuseDataset()) return; + if (chosen().length > 1) bulkRename(); + else if (only()) setEditing({ rename: only()!.path }); + }; + + const preview = (row: Row | undefined) => { + if (!row || row.entry.dir) return; + if (!TEXT.test(row.entry.name)) return toast(`No preview for ${row.entry.name}. Open it with ${MAC ? "⌘O" : "enter"}.`); + showConfirmDialog({ + title: row.entry.name, + description: () => { + const [peek, { refetch }] = createResource(() => parseResponse(props.client.peek.$get({ query: { path: row.path } }))); + return ( + + {(file) => ( + <> +
{file().text || "This file is empty."}
+

The first 64 KiB of {bytes(row.entry.size!)}.

+ + )} +
+ ); + }, + confirmLabel: "open", + returnFocus: table, + onConfirm: () => row.entry.download && window.open(row.entry.download, "_blank", "noreferrer"), + }); + }; + + const onKey = (event: KeyboardEvent) => { + if (event.target !== table && event.target !== document.body) return; + const mod = MAC ? event.metaKey : event.ctrlKey; + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + const row = current(); + if ((key === "ArrowDown" || key === "ArrowUp") && !mod && !event.altKey) step(key === "ArrowDown" ? 1 : -1, event.shiftKey); + else if (key === "Home" || key === "End") step(key === "Home" ? -Infinity : Infinity, event.shiftKey); + else if (MAC ? mod && key === "ArrowUp" : (event.altKey && key === "ArrowUp") || (key === "Backspace" && !mod)) up(); + else if (MAC ? mod && (key === "o" || key === "ArrowDown") : key === "Enter") row && activate(row); + else if (MAC ? key === "Enter" : key === "F2") rename(); + else if (MAC ? mod && key === "Backspace" : key === "Delete") { + if (chosen().length) remove(); + else bulk("delete"); + } else if (key === "ArrowRight" && row?.entry.dir) { + if (event.altKey) expandAll(row.path); + else if (!expanded().has(row.path)) expand(row); + else if (rows()[rows().indexOf(row) + 1]?.parent === row.path) step(1, false); + } else if (key === "ArrowLeft" && row) { + if (expanded().has(row.path)) collapse(row, event.altKey); + else if (row.depth) select(rows().find((other) => other.path === row.parent)!, "only"); + } else if (mod && key === "a") setSelected(new Set(rows().map((row) => row.path))); + else if (mod && key === "x") setClipboard(chosen().length ? paths() : undefined); + else if (mod && key === "v") paste(); + else if (mod && key === "z") { + const last = lastOps()?.find((op) => !op.undone); + if (!last) toast("Nothing to undo here"); + else undo(last).then(() => toast(`Undone: ${last.label}`), (failure) => toast(reason(failure))); + } + else if (key === " ") preview(row); + else if (key === "Escape") { + if (selected().size) setSelected(new Set()); + else if (clipboard()) setClipboard(); + else clearPattern(); + } else if (key === "/") patternInput.focus(); + else return; + event.preventDefault(); + }; + document.addEventListener("keydown", onKey); + onCleanup(() => document.removeEventListener("keydown", onKey)); + + let debounce: ReturnType | undefined; + onCleanup(() => clearTimeout(debounce)); + const usePattern = (value: string) => { + clearTimeout(debounce); + setParams({ pattern: value.trim() ? value : undefined }, { replace: true }); + }; + const clearPattern = () => { + patternInput.value = ""; + usePattern(""); + }; + + /** Enter on an unchanged rename just closes the field; a new name lands selected in `dir`. */ + const nameInput = (dir: string, initial: string, submit: (value: string) => Promise) => { + let saving = false; + return ( + queueMicrotask(() => { + input.focus(); + input.setSelectionRange(0, initial.lastIndexOf(".") > 0 ? initial.lastIndexOf(".") : initial.length); + })} onKeyDown={(event) => { + const value = event.currentTarget.value.trim(); + if (event.key === "Enter" && value === initial && renaming()) { + setEditing(); + table?.focus(); + } else if (event.key === "Enter" && value && !saving) { + saving = true; + submit(value).then((op) => { + done(op); + setSelected(new Set([join(dir, value)])); + setCursor(join(dir, value)); + anchor = join(dir, value); + table?.focus(); + }, (failure) => toast(reason(failure))).finally(() => (saving = false)); + } else if (event.key === "Escape") table?.focus(); + }} onBlur={() => !saving && setEditing()} /> + ); + }; + + const selectionTip = (verb: string) => + chosen().length ? dataset() && `${dataset()!.entry.name} is a dataset; zfs manages it` : `select items to ${verb}`; + const collapsedFolders = () => rows().some((row) => expandable(row) && !expanded().has(row.path)); + const keys: [JSX.Element, string][] = MAC + ? [[<>←→, "fold"], [⏎, "rename"], + [⌘O, "open"], [⌘↑, "up"], [⌘⌫, "delete"], [<>⌘X⌘V, "cut, paste"], [⌘Z, "undo"], + [space, "preview"], [/, "pattern"]] + : [[<>←→, "fold"], [enter, "open"], + [F2, "rename"], [⌫, "up"], [del, "delete"], [<>ctrl Xctrl V, "cut, paste"], [ctrl Z, "undo"], + [space, "preview"], [/, "pattern"]]; + const historyId = `${props.id}-history`; + const folderTotal = () => sizes()[here()]; + + return ( +
+
+ + + {(total) => ( + total().alloc * 1.02 && `${(total().size / total().alloc).toFixed(2)}× compressed`, + updated() && `indexed ${ago(updated()!)}`, + ].filter(Boolean).join(", ")}>{bytes(total().size)} + )} + + + + + {chosen().length ? `${count(chosen().length)} selected` : plural(listing()!.entries.length, "item")} + + + +
(event as ToggleEvent).newState === "open" && refetchOps()}> + + {(recent) => ( + No changes yet. Renames, moves and deletes show here to undo.

}> + + {(op) => ( +
+ 1 + ? `${op.sample.join(", ")}${op.count > op.sample.length ? ` and ${count(op.count - op.sample.length)} more` : ""}` + : op.sample[0]}>{op.label} + + undone}> + + +
+ )} +
+
+ )} +
+
+ + + {(link) => app.id === "copyparty") ?? { id: "copyparty", name: "Copyparty", icon: null }} + href={link()} />} + +
+ + +
+ +

Couldn't measure this folder. {unmeasured()}

+
+ :
}> + {(items) => ( + {measured() === here() && !folderTotal() && listing()?.entries.length + ? "Too many files to measure here without the file index." + : "Nothing here takes space"}
+ }> + { + const row = rows().find((row) => !row.depth && row.entry.name === name); + if (dir) open(join(here(), name)); + else if (row) { + select(row, "only"); + table?.focus(); + } + }} /> + + )} + +
+ + +
+ + + + + download + }> + {(href) => ( + + {only()?.entry.dir === false ? "download" : "zip"} + + )} + + + + + + + }> + {reason(matches.error)}}> + + {!found() ? "matching…" : found()!.count ? plural(found()!.count, "match", "matches") : "no matches"} + ({bytes(found()!.size!)}) + + + + + + + + {(cut) => ( + + + + + )} + + +
+ +
+ + + {(width) =>
} + +
+ }> + {() => ( + + + + + + + + + + + + + + + + + + + + + + + }> + {(row, index) => { + const hit = () => found()?.rows[row.path.slice(here() ? here().length + 1 : 0)]; + const isOpen = () => expanded().has(row.path); + const total = () => sizes()[row.path]; + return ( + rel === row.path || within(rel, row.path)), + }} + onMouseEnter={() => !row.depth && setHover(row.entry.name)} onMouseLeave={() => setHover()} + onClick={(event) => + select(row, event.shiftKey ? "range" : (MAC ? event.metaKey : event.ctrlKey) ? "toggle" : "only")} + onDblClick={() => activate(row)}> + + = 1024 && `${count(row.entry.size!)} bytes`, + row.entry.alloc! < row.entry.size! * 0.98 && `${bytes(row.entry.alloc!)} on disk`, + ].filter(Boolean).join(", ") || undefined}> + {bytes(row.entry.size!)} + + }> + {row.entry.items === null ? "–" : plural(row.entry.items, "item")}}> + {(total) => ( + + )} + + + + + ); + }} + + +
+ + + (up to {nameOf(parentOf(here()))}) +
{nameInput(here(), "New folder", (name) => + parseResponse(props.client.folder.$post({ json: { path: here(), name } })))}
+
This folder is empty
+ }> + + + + + + {row.entry.name}}> + {nameInput(row.parent, row.entry.name, (name) => + parseResponse(props.client.rename.$post({ json: { renames: [{ path: row.path, name }] } })))} + + + {(hit) => hit === "self" + ? match + : {count(hit)}} + +
+ {bytes(total().size)} +
+ )} +
+
+ +
+ {([key, label]) => {key} {label}} +
+ + ); +} diff --git a/dashboard/web/components/ListPage.tsx b/dashboard/web/components/ListPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2a733692f36e2d0116cbb9c482d0a00bc8e9071c --- /dev/null +++ b/dashboard/web/components/ListPage.tsx @@ -0,0 +1,39 @@ +import ChevronUp from "lucide-solid/icons/chevron-up"; +import { children, createSignal, type JSX, Show } from "solid-js"; + +/** + * A page whose head and summary stay put while the body scrolls under them; tables in the body keep their header + * row pinned. The summary folds away behind a handle, remembered per `id`. A body child with class `fill` takes the + * leftover height and scrolls on its own. + */ +export function ListPage(props: { + id: string; + class?: string; + head: JSX.Element; + summary?: JSX.Element; + /** Body children with class `fill` or `edge`, and tables, run edge to edge; the rest keep the page gutter. */ + flush?: boolean; + children: JSX.Element; +}) { + const key = `list-page.${props.id}.collapsed`; + const [collapsed, setCollapsed] = createSignal(localStorage.getItem(key) === "true"); + const summary = children(() => props.summary); + return ( +
+ {props.head} + +
+
{summary()}
+
+ +
+
{props.children}
+
+ ); +} diff --git a/dashboard/web/components/Loaded.tsx b/dashboard/web/components/Loaded.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d7d7f32eb9da25cbdf0717446301ab030bce64a9 --- /dev/null +++ b/dashboard/web/components/Loaded.tsx @@ -0,0 +1,70 @@ +import { type Accessor, createMemo, For, type JSX, type Resource, Show } from "solid-js"; +import Unplug from "lucide-solid/icons/unplug"; +import { reason, unconnected } from "../api.ts"; + +/** The latest value that loaded, kept through later failures; reading an errored resource directly throws. */ +export function lastGood(resource: Resource): Accessor { + return createMemo((previous) => (resource.state === "errored" ? previous : resource.latest)); +} + +/** Placeholder rows of staggered widths, for a list or table. */ +export function SkeletonRows(props: { count: number }) { + return {(_, i) =>
}; +} + +/** + * A skeleton, then `children` with the latest value, which stays up through a refetch and, with a note and a retry + * above it, through a failed one. Only a failure before anything loaded replaces the content with the error, and a + * source that isn't connected yet replaces it with the server's note instead, since retrying can't help. + */ +export function Loaded(props: { + data: Resource; + /** What failed to load, e.g. "pool status", read as "Couldn't load pool status." */ + what: string; + retry: () => void; + /** Placeholder shaped like the content, built from `.skeleton` blocks; defaults to one block. */ + skeleton?: JSX.Element; + children: (value: Accessor>) => JSX.Element; +}) { + const value = lastGood(props.data); + /** The last failure, kept through a refetch until one succeeds, so polling doesn't flash the skeleton. */ + const error = createMemo((previous) => + props.data.state === "errored" ? props.data.error : props.data.state === "ready" ? undefined : previous); + const failure = () => error() !== undefined && reason(error()); + return ( + + Loading {props.what}… + {props.skeleton ??
} + + }> + {(text) => unconnected(error()) ? ( +
+
+ ) : ( + + )} + + }> + {(latest) => ( + <> + + {(text) => ( +

+ Couldn't refresh {props.what}. {text()} + +

+ )} +
+ {props.children(latest)} + + )} + + ); +} diff --git a/dashboard/web/components/LogView.css b/dashboard/web/components/LogView.css new file mode 100644 index 0000000000000000000000000000000000000000..c55c47adaaf8525817fdb0118971c9c8978bec44 --- /dev/null +++ b/dashboard/web/components/LogView.css @@ -0,0 +1,31 @@ +.log-view { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; } +.log-view .logs { + flex: 1; + min-height: 0; + max-height: none; + margin: 0; + padding: 0 0 8px; + border: 0; + border-top: 1px solid var(--line); + border-radius: 0; + box-shadow: none; + background: var(--well); + /* Terminal colors lean toward the text color, more so on light backgrounds they were never picked for. */ + --ansi-mix: 85%; +} +@media (prefers-color-scheme: light) { .log-view .logs { --ansi-mix: 55%; } } +.log-view .logs:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; } +.log-view .log { grid-template-columns: 62px 1fr; padding: 0 var(--gutter); } +.log-view .log.warn:hover { background: color-mix(in srgb, var(--warning) 16%, transparent); } +.log-view .log.error:hover { background: color-mix(in srgb, var(--critical) 20%, transparent); } +.log-view .ansi { + color: color-mix(in srgb, var(--fg, currentColor) var(--ansi-mix), var(--text)); + background: color-mix(in srgb, var(--bg, transparent) 45%, transparent); +} +.log-edge { padding: 6px var(--gutter); color: var(--muted); font: 12px var(--sans); } +.log-edge.day { padding-block: 8px 2px; color: var(--text-2); font-weight: 600; } +.log-edge.at { padding-block: 2px; border-block: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); color: var(--accent); } +.log-view .latest { position: absolute; bottom: 16px; left: 50%; translate: -50%; background: var(--raised); box-shadow: var(--shadow); } +.log-view .container { margin-right: 8px; padding: 0; border: 0; background: none; color: var(--muted); font: inherit; } +.log-view button.container { cursor: pointer; } +.log-view button.container:hover { color: var(--text); text-decoration: underline dotted; text-underline-offset: 3px; } diff --git a/dashboard/web/components/LogView.tsx b/dashboard/web/components/LogView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1af7e4395e3ec3d4db8413f836f47eaeddac3035 --- /dev/null +++ b/dashboard/web/components/LogView.tsx @@ -0,0 +1,160 @@ +import ArrowDown from "lucide-solid/icons/arrow-down"; +import { createMemo, type JSX, Show } from "solid-js"; +import type { LogLine } from "../types/model.ts"; +import { clock, count, date, datetime } from "../format.ts"; +import { VirtualList } from "./VirtualList.tsx"; +import "./LogView.css"; + +/** SGR sequences capture their parameters; every other escape and control character is dropped. */ +const ESCAPE = /\x1b\[([0-9;]*)m|\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]?|[\x00-\x08\x0b-\x1f\x7f]/g; +/** The eight base colors in theme terms; the bright eight reuse them. */ +const ANSI = ["var(--muted)", "var(--critical)", "var(--good)", "var(--warning)", "var(--series-1)", "var(--series-5)", + "var(--series-3)", "var(--text)"]; + +function xterm(n: number) { + if (n < 16) return ANSI[n % 8]!; + if (n >= 232) return `rgb(${Array(3).fill(8 + (n - 232) * 10).join(" ")})`; + return `rgb(${[36, 6, 1].map((step) => Math.floor((n - 16) / step) % 6).map((c) => (c ? 55 + c * 40 : 0)).join(" ")})`; +} + +function sgr(style: JSX.CSSProperties, params: string): JSX.CSSProperties { + const codes = (params || "0").split(";").map(Number); + const next = { ...style }; + const color = (key: "--fg" | "--bg", value: string | undefined) => (value ? (next[key] = value) : delete next[key]); + for (let i = 0; i < codes.length; i++) { + const code = codes[i]!; + if (code === 0) for (const key in next) delete next[key as keyof JSX.CSSProperties]; + else if (code === 1) next["font-weight"] = 600; + else if (code === 2) next.opacity = 0.65; + else if (code === 3) next["font-style"] = "italic"; + else if (code === 4) next["text-decoration"] = "underline"; + else if (code === 22) { + delete next["font-weight"]; + delete next.opacity; + } + else if (code === 23) delete next["font-style"]; + else if (code === 24) delete next["text-decoration"]; + else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) color("--fg", ANSI[code % 10]); + else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) color("--bg", ANSI[code % 10]); + else if (code === 39 || code === 49) color(code === 39 ? "--fg" : "--bg", undefined); + else if (code === 38 || code === 48) { + const rgb = codes[i + 1] === 5 ? xterm(codes[i + 2]!) : `rgb(${codes.slice(i + 2, i + 5).join(" ")})`; + color(code === 38 ? "--fg" : "--bg", rgb); + i += codes[i + 1] === 5 ? 2 : 4; + } + } + return next; +} + +/** A log line as a terminal shows it: colored, each line from its last carriage return; each of `needles` is marked. */ +function ansi(raw: string, needles: string[]) { + const text = raw.replace(/[^\n]*\r(?=[^\n])/g, ""); + if (!needles.length && !/[\x00-\x08\x0b-\x1f\x7f]/.test(text)) return text; + const segments: { text: string; style: JSX.CSSProperties }[] = []; + let style: JSX.CSSProperties = {}; + let last = 0; + for (const match of text.matchAll(ESCAPE)) { + if (match.index > last) segments.push({ text: text.slice(last, match.index), style }); + if (match[1] !== undefined) style = sgr(style, match[1]); + last = match.index + match[0].length; + } + segments.push({ text: text.slice(last), style }); + const plain = segments.map((segment) => segment.text).join("").toLowerCase(); + const found: [number, number][] = []; + for (const needle of needles.map((needle) => needle.toLowerCase())) { + for (let at = plain.indexOf(needle); at !== -1; at = plain.indexOf(needle, at + needle.length)) found.push([at, at + needle.length]); + } + const marks: [number, number][] = []; + for (const [from, to] of found.sort((a, b) => a[0] - b[0])) { + const last = marks.at(-1); + if (last && from <= last[1]) last[1] = Math.max(last[1], to); + else marks.push([from, to]); + } + let end = 0; + return segments.map((segment) => { + const start = end; + end += segment.text.length; + const parts: JSX.Element[] = []; + let cut = start; + for (const [from, to] of marks) { + if (to <= cut || from >= end) continue; + if (from > cut) parts.push(segment.text.slice(cut - start, from - start)); + cut = Math.min(to, end); + parts.push({segment.text.slice(Math.max(from, start) - start, cut - start)}); + } + if (cut < end) parts.push(segment.text.slice(cut - start)); + return Object.keys(segment.style).length ? {parts} : parts; + }); +} + +/** Line height and character width of `.logs` text, for estimating how a line wraps before it renders. */ +const LINE = 18.6; +const CHAR = 7.2; +/** Everything in a log row beside the text: the gutters, the time column, and the gap after it. */ +const ROW_CHROME = 2 * 28 + 62 + 12; + +function estimate(line: LogLine, width: number) { + const columns = Math.max(16, Math.floor((width - ROW_CHROME) / CHAR)); + let rows = 0; + for (const part of line.text.replace(ESCAPE, "").split("\n")) rows += Math.max(1, Math.ceil(part.length / columns)); + return rows * LINE; +} + +/** + * Log lines, newest at the bottom, as a terminal shows them: colored, soft-wrapped, split by day, `marks` marked, and + * named by container when several are mixed. Follows new lines while scrolled to the end; away from it, a button leads back. + */ +export function LogView(props: { + lines: LogLine[]; + follow: boolean; + onFollow: (atEnd: boolean) => void; + marks?: string[]; + /** Makes a line's container name a button, when several containers are mixed. */ + onContainer?: (container: string) => void; + /** Called while the view is near the oldest line. */ + onStart?: () => void; + /** Lines that arrived while away from the end, counted on the button back to it. */ + unseen?: number; + /** What the button back to the end does, if more than following again. */ + onLatest?: () => void; + /** Rows above a line, after its day divider. */ + before?: (line: LogLine, index: () => number) => JSX.Element; +}) { + const mixed = createMemo(() => new Set(props.lines.map((line) => line.container)).size > 1); + return ( +
+ + {(line, index) => ( + <> + {/* Rows can outlive a swapped-in list for a tick, so the previous line may not exist yet. */} + 0 && props.lines[index() - 1] && date(props.lines[index() - 1]!.t) !== date(line.t)}> +
{date(line.t)}
+
+ {props.before?.(line, index)} +
+ + + + {line.container}}> + {(filter) => ( + + )} + + + {ansi(line.text, props.marks ?? [])} + +
+ + )} +
+ + + +
+ ); +} diff --git a/dashboard/web/components/Meter.tsx b/dashboard/web/components/Meter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f5d54066d6ef0b7d92d6df6fe1fa724c4a935511 --- /dev/null +++ b/dashboard/web/components/Meter.tsx @@ -0,0 +1,12 @@ +import { Show } from "solid-js"; + +/** A thin progress bar; `failed` stacks a critical segment after the done one. */ +export function Meter(props: { value: number; max?: number; failed?: number }) { + const width = (part: number) => `${(part / (props.max || 1)) * 100}%`; + return ( +
+ + {(failed) => } +
+ ); +} diff --git a/dashboard/web/components/Metric.tsx b/dashboard/web/components/Metric.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7856cc19c5d64e570e871ac87246247a4ed077a8 --- /dev/null +++ b/dashboard/web/components/Metric.tsx @@ -0,0 +1,95 @@ +import { createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js"; +import type { Metric, Series } from "../types/model.ts"; +import { queries } from "../api.ts"; +import { lastGood, Loaded } from "./Loaded.tsx"; +import { TabBar } from "./TabBar.tsx"; +import { TimeChart, type ChartProps } from "./TimeChart.tsx"; + +const RANGES = [["1h", 3600], ["6h", 21600], ["24h", 86400], ["7d", 604800]] as const; + +export function RangePicker(props: { value: number; onChange: (value: number) => void }) { + return ( + + + {([label, seconds]) => ( + + )} + + + ); +} + +/** Keeps the heaviest series and folds the rest into "other" so no slot color repeats. */ +function fold(series: Series[], keep = 5) { + const mean = (item: Series) => item.v.reduce((sum, v) => sum + (v ?? 0), 0) / (item.v.length || 1); + const ranked = [...series].sort((a, b) => mean(b) - mean(a)); + const top = new Set(ranked.slice(0, keep)); + const kept = series.filter((item) => top.has(item)); + const rest = series.filter((item) => !top.has(item)); + const colors = kept.map((_, i) => `var(--series-${i + 1})`); + if (!rest.length) return { series: kept, colors }; + const other: Series = { + name: "other", + t: rest[0]!.t, + v: rest[0]!.t.map((_, j) => rest.reduce((sum, item) => sum + (item.v[j] ?? 0), 0)), + }; + return { series: [...kept, other], colors: [...colors, "var(--other)"] }; +} + +export interface MetricQuery { + metric: Metric; + range: number; + service?: string; + /** Series per service, named by `names`, the heaviest kept and the rest folded into "other". */ + split?: boolean; + names?: Record; +} + +/** A metric over the range, refreshed every 30 seconds; `shape` names and folds the loaded series for a chart. */ +export function useMetric(props: MetricQuery) { + const [data, { refetch }] = queries.metric.use(() => ({ metric: props.metric, range: props.range, service: props.service })); + const timer = setInterval(refetch, 30_000); + onCleanup(() => clearInterval(timer)); + const shape = (loaded: Series[]) => { + const series = loaded.map((item) => ({ ...item, name: props.names?.[item.name] ?? item.name })); + return props.split ? fold(series) : { series, colors: undefined }; + }; + return { data, refetch, shape }; +} + +interface MetricCardProps extends Omit, MetricQuery { + title: string; + sub?: string; + actions?: JSX.Element; +} + +export function MetricCard(props: MetricCardProps) { + const { data, refetch, shape } = useMetric(props); + const [hover, setHover] = createSignal(null); + const latest = lastGood(data); + // A single series has no legend, so its value, latest or hovered, sits in the title. + const reading = () => { + const series = latest(); + if (series?.length !== 1) return; + const values = series[0]!.v; + const value = hover() === null ? values.findLast((v) => v !== null) : values[hover()!]; + return value == null ? undefined : props.format(value); + }; + return ( +
+
+ {props.title} + {(value) => {value()}} + {props.sub} + + {props.actions} +
+ }> + {(latest) => { + const shaped = createMemo(() => shape(latest())); + return ; + }} + +
+ ); +} diff --git a/dashboard/web/components/OpenApp.tsx b/dashboard/web/components/OpenApp.tsx new file mode 100644 index 0000000000000000000000000000000000000000..74a2c0a5bd337fb1811a659f0969bc4e1f8aa5c0 --- /dev/null +++ b/dashboard/web/components/OpenApp.tsx @@ -0,0 +1,27 @@ +import ArrowUpRight from "lucide-solid/icons/arrow-up-right"; +import type { JSX } from "solid-js"; +import type { ServiceSummary } from "../types/model.ts"; +import { AppIcon } from "./AppIcon.tsx"; + +/** + * A chip out to the real app a page wraps, placed after the page title: the app's icon, then `children` if given. + * The tooltip says where it lands; with `icon={false}` the chip shows that address instead, for pages that already + * show the app's icon. + */ +export function OpenApp(props: { + app: Pick; + href: string; + icon?: false; + children?: JSX.Element; +}) { + // A query is noise in a tooltip, but a hash route like #/master/users says where it lands. + const host = () => props.href.replace(/^https?:\/\//, "").replace(/#?\/?\?.*$/, "").replace(/\/$/, ""); + return ( + + {props.icon === false ? host() : } + {props.children} + + ); +} diff --git a/dashboard/web/components/PaperCloverMark.tsx b/dashboard/web/components/PaperCloverMark.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d35a5440e47e85f3642f11c1fc133ee876c95d34 --- /dev/null +++ b/dashboard/web/components/PaperCloverMark.tsx @@ -0,0 +1,11 @@ +/** The paperclover.net clover, recolored to follow the nav text color. */ +export function PaperCloverMark(props: { class: string }) { + return ( + + ); +} diff --git a/dashboard/web/components/PirateFlag.tsx b/dashboard/web/components/PirateFlag.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4fc411bb3f49c13c098fc9c9934db677d6eef809 --- /dev/null +++ b/dashboard/web/components/PirateFlag.tsx @@ -0,0 +1,19 @@ +import { createUniqueId } from "solid-js"; + +/** Lucide's flag, filled, with a skull and crossbones knocked out of it. */ +export function PirateFlag(props: { class: string }) { + const mask = createUniqueId(); + return ( + + ); +} diff --git a/dashboard/web/components/Reveal.tsx b/dashboard/web/components/Reveal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5585c3d5901ef3bd21bb60e4d0d7d9080b75e182 --- /dev/null +++ b/dashboard/web/components/Reveal.tsx @@ -0,0 +1,14 @@ +import type { JSX } from "solid-js"; + +/** + * Grows `children` in from nothing along `axis` (default "y") while `when` holds, and folds them away after. + * Hidden children stay mounted but inert. The wrapper still takes a flex or grid gap, so space it from inside. + */ +export function Reveal(props: { when: boolean; axis?: "x" | "y"; children: JSX.Element }) { + return ( +
+
{props.children}
+
+ ); +} diff --git a/dashboard/web/components/Sidebar.tsx b/dashboard/web/components/Sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1dc3b93c9029f4f8b92a1190b1d61e583349a90c --- /dev/null +++ b/dashboard/web/components/Sidebar.tsx @@ -0,0 +1,288 @@ +import { A, useLocation, useMatch } from "@solidjs/router"; +import ArrowUpRight from "lucide-solid/icons/arrow-up-right"; +import Boxes from "lucide-solid/icons/boxes"; +import ChevronRight from "lucide-solid/icons/chevron-right"; +import ChevronsUpDown from "lucide-solid/icons/chevrons-up-down"; +import Clapperboard from "lucide-solid/icons/clapperboard"; +import Eye from "lucide-solid/icons/eye"; +import HardDrive from "lucide-solid/icons/hard-drive"; +import House from "lucide-solid/icons/house"; +import LogOut from "lucide-solid/icons/log-out"; +import Monitor from "lucide-solid/icons/monitor"; +import Rocket from "lucide-solid/icons/rocket"; +import SquarePlay from "lucide-solid/icons/square-play"; +import UserRound from "lucide-solid/icons/user-round"; +import Plug from "lucide-solid/icons/plug"; +import Users from "lucide-solid/icons/users"; +import { createSignal, For, type JSX, Show } from "solid-js"; +import { type Health, type Me, type Section, trouble, VIEW_AS } from "../types/model.ts"; +import { queries } from "../api.ts"; +import snowflake from "../snowflake.svg"; +import { bytes, cores, plural } from "../format.ts"; +import { account, Avatar, displayName } from "../pages/Account.tsx"; +import { status } from "../pages/Overview.tsx"; +import { TABS as STORAGE_TABS } from "../pages/Storage.tsx"; +import { TABS as YOUTUBE_TABS } from "../pages/YouTube.tsx"; +import { stream } from "../live.ts"; +import { AppIcon } from "./AppIcon.tsx"; +import { lastGood, Loaded } from "./Loaded.tsx"; +import { PaperCloverMark } from "./PaperCloverMark.tsx"; +import { PirateFlag } from "./PirateFlag.tsx"; +import { Spark } from "./Spark.tsx"; +import { Status, StatusLabel } from "./Status.tsx"; + +type Icon = (props: { class: string }) => JSX.Element; + +interface Page { + href: string; + label: string; + icon: Icon; + section: Section; + /** `?tab=` values, labels and icons, the page's default first. */ + tabs?: readonly (readonly [string, string, Icon])[]; +} + +const BEFORE: Page[] = [{ href: "/", label: "overview", icon: House, section: "launcher" }]; + +const AFTER: Page[] = [ + { href: "/mcp", label: "mcp", icon: Plug, section: "launcher" }, + { + href: "/storage", label: "storage", icon: HardDrive, section: "admin", + tabs: STORAGE_TABS, + }, + { href: "/media", label: "media", icon: Clapperboard, section: "media" }, + { href: "/seedbox", label: "seedbox", icon: PirateFlag, section: "media" }, + { + href: "/youtube", label: "youtube", icon: SquarePlay, section: "media", + tabs: YOUTUBE_TABS, + }, + { href: "/paper-clover", label: "paper clover", icon: PaperCloverMark, section: "admin" }, + { href: "/users", label: "users", icon: Users, section: "admin" }, + { href: "/deploys", label: "deploys", icon: Rocket, section: "admin" }, + { href: "/vms", label: "vms", icon: Monitor, section: "vms" }, +]; + +/** Every page and the section that opens it; admins also get the Services group between the two halves. */ +export const PAGES = [...BEFORE, ...AFTER]; + +/** Groups an admin can preview the dashboard as. */ +const PREVIEW_GROUPS = ["media", "media-manage", "metrics", "vm"]; + +/** How many apps need a look, on the overview's link, so it shows from every page. */ +function IssueCount() { + const issues = () => status.latest()?.issues ?? []; + return ( + + + + `${issue.service.name} ${trouble(issue.health) ? issue.health : "restarted"}`).join(", ")}> + issue.health === "down") ? "down" : "degraded"}>{issues().length} + need a look + + + + ); +} + +/** A group's open state, remembered across visits. */ +function remembered(key: string, initial: boolean) { + const [open, setOpen] = createSignal((localStorage.getItem(key) ?? (initial ? "open" : "closed")) === "open"); + const toggle = () => { + setOpen(!open()); + localStorage.setItem(key, open() ? "open" : "closed"); + }; + return [open, toggle] as const; +} + +function NavLink(props: { page: Page; children?: JSX.Element }) { + const link = ( + + + {props.page.label} + {props.children} + + ); + return ( + + {(tabs) => { + const [open, toggle] = remembered(`sidebar.${props.page.label}`, false); + const location = useLocation(); + const current = (tab: string) => location.pathname === props.page.href + && (new URLSearchParams(location.search).get("tab") ?? tabs()[0]![0]) === tab; + return ( + <> + + + + ); + }} + + ); +} + +/** Health states counted together in the collapsed group's summary, in order. */ +const TALLY: [Health, Health[], string][] = [ + ["healthy", ["healthy"], "up"], + ["down", ["down"], "down"], + ["degraded", ["degraded"], "degraded"], + ["deploying", ["deploying", "restarting", "starting"], "changing"], + ["stopped", ["stopped"], "stopped"], +]; + +function Services() { + const [open, toggle] = remembered("sidebar.services", true); + const [services, { refetch }] = queries.services.use(); + stream.start(); + + const health = (id: string, fallback: Health) => stream.latest()?.services[id]?.health ?? fallback; + const loaded = lastGood(services); + const tally = () => TALLY.map(([shape, states, word]) => { + const names = (loaded() ?? []).filter((service) => states.includes(health(service.id, service.health))).map((s) => s.name); + return { shape, word, names }; + }).filter((group) => group.names.length); + + return ( + <> + + + + ); +} + +/** Previews the dashboard as someone in fewer groups; the server only honors it for admins. */ +export function viewAs(groups: string[] | null) { + document.cookie = groups ? `${VIEW_AS}=${encodeURIComponent(groups.join(","))}; path=/; samesite=strict` : `${VIEW_AS}=; path=/; max-age=0`; + location.reload(); +} + +/** The signed-in user's corner; it falls back to the forwarded name while Keycloak is out of reach. */ +function Whoami(props: { me: Me }) { + const user = lastGood(account); + const name = () => { + const known = user(); + return known ? displayName(known) : props.me.name; + }; + const here = useMatch(() => "/account"); + const [picked, setPicked] = createSignal(props.me.viewing ? props.me.groups : []); + let menu!: HTMLDivElement; + return ( +
+ + +
+ ); +} + +export function Sidebar(props: { me: Me }) { + const allowed = (page: Page) => props.me.sections.includes(page.section); + return ( + + ); +} diff --git a/dashboard/web/components/SortHeader.tsx b/dashboard/web/components/SortHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c7c1d67b20400b132742dc9cd02b7fcc124cdf70 --- /dev/null +++ b/dashboard/web/components/SortHeader.tsx @@ -0,0 +1,29 @@ +import ArrowDown from "lucide-solid/icons/arrow-down"; +import ArrowUp from "lucide-solid/icons/arrow-up"; +import { Show } from "solid-js"; + +export interface Sort { + key: K; + desc: boolean; +} + +/** A sortable column head; `num` columns right-align and sort largest first on the first click. */ +export function SortHeader(props: { + column: K; + label: string; + sort: Sort; + onSort: (sort: Sort) => void; + num?: boolean; + class?: string; +}) { + const active = () => props.sort.key === props.column; + return ( + + + + ); +} diff --git a/dashboard/web/components/Spark.tsx b/dashboard/web/components/Spark.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2c5b18296730aaf3ffc060bff0225ba378ca6130 --- /dev/null +++ b/dashboard/web/components/Spark.tsx @@ -0,0 +1,61 @@ +import { createEffect, createMemo, on } from "solid-js"; +import { LIVE_INTERVAL } from "../types/model.ts"; + +const W = 44; +const H = 14; +/** Samples averaged into each point, aligned to the stream's sample count so a finished point never moves. */ +const PER_POINT = 7; +const GAP = 2; +const reduced = matchMedia("(prefers-reduced-motion: reduce)"); + +/** Rounds up to 1, 2, or 5 times a power of ten, so the scale only changes on real swings. */ +function nice(value: number) { + const power = 10 ** Math.floor(Math.log10(value)); + return [1, 2, 5, 10].map((m) => m * power).find((step) => step >= value)!; +} + +/** + * A live trail that scrolls continuously between ticks. `values` are the latest samples and `count` is how many the + * stream has produced; the scale is at least `max`. + */ +export function Spark(props: { values: number[]; count: number; max: number; color: string; label: string }) { + let track!: SVGGElement; + const shape = createMemo(() => { + const { values, count } = props; + const first = count - values.length; + const base = Math.floor(first / PER_POINT); + const means: number[] = []; + for (let bucket = base; values.length && bucket * PER_POINT < count; bucket++) { + const slice = values.slice(Math.max(0, bucket * PER_POINT - first), (bucket + 1) * PER_POINT - first); + means.push(slice.reduce((sum, v) => sum + v, 0) / slice.length); + } + const peak = Math.max(0, ...means); + const top = peak > props.max ? nice(peak) : props.max; + const points = means.map((v, i) => [(i + 0.5) * GAP, H - 1 - Math.min(1, v / top) * (H - 2)] as const); + let line = points.length ? `M${points[0]}` : ""; + for (let i = 1; i < points.length - 1; i++) { + const [x, y] = points[i]!; + const [nx, ny] = points[i + 1]!; + line += `Q${x},${y} ${(x + nx) / 2},${(y + ny) / 2}`; + } + if (points.length > 1) line += `L${points.at(-1)}`; + const area = points.length > 1 ? `${line}L${points.at(-1)![0]},${H}L${points[0]![0]},${H}Z` : ""; + // Where the view's left edge sits for a given sample count, in this path's coordinates. + const offset = (samples: number) => `translateX(${W - (samples / PER_POINT - base) * GAP}px)`; + return { line, area, from: offset(count - 1), to: offset(count) }; + }); + + createEffect(on(shape, ({ from, to }) => { + if (reduced.matches) track.style.transform = to; + else track.animate([{ transform: from }, { transform: to }], { duration: LIVE_INTERVAL * 1000, fill: "forwards" }); + })); + + return ( + + + + + + + ); +} diff --git a/dashboard/web/components/Status.tsx b/dashboard/web/components/Status.tsx new file mode 100644 index 0000000000000000000000000000000000000000..843a2e480224ab920e3ac46923065415aaa6ae8e --- /dev/null +++ b/dashboard/web/components/Status.tsx @@ -0,0 +1,36 @@ +import type { JSX } from "solid-js"; +import type { Health } from "../types/model.ts"; + +/** A service's health, or routine background work (a scan, a download) that isn't a service changing state. */ +export type StatusKind = Health | "working"; + +/** Each state has its own shape so it survives without color; the in-between states share a spinner. */ +function shape(health: StatusKind) { + switch (health) { + case "healthy": return ; + case "degraded": return ; + case "down": return ; + case "stopped": return ; + case "deploying": case "restarting": case "starting": case "working": + return ; + } +} + +export function Status(props: { health: StatusKind; label?: string }) { + const label = () => props.label ?? props.health; + return ( + + {shape(props.health)} + + ); +} + +/** The shape beside visible text, which defaults to the state's name. */ +export function StatusLabel(props: { health: StatusKind; children?: JSX.Element }) { + return ( + + + {props.children ?? props.health} + + ); +} diff --git a/dashboard/web/components/Strip.tsx b/dashboard/web/components/Strip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b1ee413d828786937f75d864f1a87f96fe2e17dd --- /dev/null +++ b/dashboard/web/components/Strip.tsx @@ -0,0 +1,59 @@ +import { createMemo, createSignal, type JSX, Show } from "solid-js"; +import type { Series } from "../types/model.ts"; +import { lastGood, Loaded } from "./Loaded.tsx"; +import { type MetricQuery, useMetric } from "./Metric.tsx"; +import { type ChartProps, TimeChart } from "./TimeChart.tsx"; + +/** A chart's current value for its corner; `tip` is the next thing worth knowing about it. */ +export type Reading = { value: JSX.Element; tip?: string; stale?: boolean }; + +/** A chart stacked with others on one time axis and crosshair, which only the `last` one labels. */ +export function Strip(props: Omit & MetricQuery & { + title: string; + height: number; + /** Multiplies every value, e.g. to turn cores into percent of the machine. */ + scale?: number; + tabs?: JSX.Element; + now: (latest: Series[]) => Reading | undefined; + last?: boolean; +}) { + const { data, refetch, shape } = useMetric(props); + const latest = lastGood(data); + const [hovered, setHovered] = createSignal(false); + return ( +
+
+

{props.title}

+ {props.tabs} + + + {(now) => ( + + {now().value} + + )} + +
+ }> + {(loaded) => { + const shaped = createMemo(() => { + const scale = props.scale ?? 1; + const shaped = shape(loaded()); + // A lone line goes by the chart's name, so its hovered value reads "51% cpu". + const series = shaped.series.map((item) => ({ + ...item, name: shaped.series.length === 1 ? props.title : item.name, v: item.v.map((v) => v && v * scale), + })); + return { ...shaped, series }; + }); + return ( + setHovered(index !== null)} /> + ); + }} + +
+ ); +} diff --git a/dashboard/web/components/TabBar.tsx b/dashboard/web/components/TabBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fca401f118da2f9f9ad9468fa146c25ddc239281 --- /dev/null +++ b/dashboard/web/components/TabBar.tsx @@ -0,0 +1,40 @@ +import { type JSX, onCleanup, onMount } from "solid-js"; + +/** + * A `.segmented` group of `aria-pressed` buttons, or links marked `aria-current="page"`, whose highlight slides to + * the chosen one. A copy of the labels in the highlight's text color is clipped to the highlight as it moves, so + * each letter takes its color from whatever is under it at that moment. + */ +export function TabBar(props: { label: string; children: JSX.Element }) { + let root!: HTMLDivElement; + let pill!: HTMLSpanElement; + let ink!: HTMLDivElement; + const place = () => { + ink.replaceChildren(...[...root.children].filter((el) => el !== pill && el !== ink).map((el) => el.cloneNode(true))); + const chosen = root.querySelector(':scope > :is([aria-pressed="true"], [aria-current="page"])'); + pill.hidden = ink.hidden = !chosen; + if (!chosen) return; + pill.style.left = `${chosen.offsetLeft}px`; + pill.style.width = `${chosen.offsetWidth}px`; + const right = root.clientWidth - chosen.offsetLeft - chosen.offsetWidth; + ink.style.clipPath = `inset(2px ${right}px 2px ${chosen.offsetLeft}px round 7px)`; + }; + onMount(() => { + place(); + const changes = new MutationObserver((records) => records.some((record) => !ink.contains(record.target)) && place()); + changes.observe(root, { subtree: true, childList: true, characterData: true, attributeFilter: ["aria-pressed", "aria-current"] }); + const resizes = new ResizeObserver(place); + resizes.observe(root); + onCleanup(() => { + changes.disconnect(); + resizes.disconnect(); + }); + }); + return ( +
+ }> + {(src) => } + + ); +} + +const PICTURE_SIZE = 256; +const FIELDS = ["firstName", "lastName"] as const; + +const DONE: Record = { + "webauthn-register-passwordless": "Added a passkey", + UPDATE_PASSWORD: "Changed your password", + UPDATE_EMAIL: "Sent a link to confirm your new email", + delete_credential: "Removed the passkey", +}; + +/** 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. */ +async function square(file: File) { + const image = await createImageBitmap(file).catch(() => null); + if (!image) return null; + const side = Math.min(image.width, image.height); + const canvas = Object.assign(document.createElement("canvas"), { width: PICTURE_SIZE, height: PICTURE_SIZE }); + canvas.getContext("2d")!.drawImage(image, (image.width - side) / 2, (image.height - side) / 2, side, side, + 0, 0, PICTURE_SIZE, PICTURE_SIZE); + image.close(); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/webp", 0.85)); + return blob && new File([blob], "picture", { type: blob.type }); +} + +export function Account() { + const [params] = useSearchParams<{ kc_action?: string; kc_action_status?: string }>(); + const navigate = useNavigate(); + const apps = lastGood(queries.launcher.use()[0]); + onMount(() => { + const { kc_action: action, kc_action_status: status } = params; + if (!status) return; + if (status === "success" && action) toast(DONE[action] ?? "Done"); + if (status === "error") toast("Keycloak couldn't finish that. Try again."); + navigate("/account", { replace: true }); + }); + + return ( + }> + {(result) => ( + {props.empty}

}> + {(lines) => ( +
+ {(line) =>
{line}
}
+
+ )} +
+ )} + + ); +} + +type Runtime = InferResponseType<(typeof api.deploys.stages)[":id"]["runtime"]["$get"], 200>; +type Allocation = Runtime["jobs"][number]["allocations"][number]; +type LogQuery = { after?: string; before?: string; limit?: string }; + +/** Allocations and usage of a stage or deploy, polled while its jobs still run. */ +function useRuntime(name: string, key: () => string, fetch: (key: string) => Promise) { + const [runtime, { refetch }] = query(name, fetch).use(key); + const latest = lastGood(runtime); + const timer = setInterval(() => latest()?.to === null && refetch(), 10000); + onCleanup(() => clearInterval(timer)); + return { runtime, latest, refetch }; +} + +/** Every task restart across the jobs, newest first. */ +const restarts = (runtime: Runtime | undefined) => (runtime?.jobs ?? []) + .flatMap((job) => job.allocations.flatMap((allocation) => allocation.tasks)) + .flatMap((task) => (task.lastRestart ? [{ ...task.lastRestart, count: task.restarts }] : [])) + .sort((a, b) => b.t - a.t); + +function RestartStat(props: { runtime: Runtime | undefined; href: string }) { + return ( + + {(last) => ( + + {plural(restarts(props.runtime).reduce((sum, restart) => sum + restart.count, 0), "restart")} + + )} + + ); +} + +/** Cpu over memory on one time axis, stacked by job. */ +function Usage(props: { runtime: Runtime; name: (job: string) => string }) { + const STRIPS = [["cpu", cores], ["memory", bytes]] as const; + /** Usage is null until a metrics store records it. */ + const jobs = () => props.runtime.jobs.flatMap(({ id, cpu, memory }) => (cpu && memory ? [{ id, cpu, memory }] : [])); + const empty = () => jobs().length < props.runtime.jobs.length ? "No usage history on this host yet: it needs a metrics store." + : jobs().every((job) => job.memory.v.every((v) => v === null)) + ? props.runtime.jobs.length ? "No usage. The job never started." : "No service changed here." + : null; + return ( + {empty()}

}> +
+ + {([key, format], i) => { + const series = () => jobs().map((job) => ({ ...job[key], name: jobs().length === 1 ? key : props.name(job.id) })); + const totals = () => series()[0]!.t.map((_, index) => + series().reduce((sum, item) => (item.v[index] == null ? sum : (sum ?? 0) + item.v[index]!), null)); + const peak = () => format(Math.max(...totals().map((v) => v ?? 0))); + return ( +
+
+

{key}

+ + peak {peak()}}> + + {format(totals().findLast((v) => v !== null) ?? 0)} + + +
+ 1} /> +
+ ); + }} +
+
+
+ ); +} + +const ALLOCATION: Record = { + pending: ["starting", "pending"], running: ["healthy", "running"], complete: ["stopped", "replaced"], + failed: ["down", "failed"], lost: ["down", "lost"], unknown: ["degraded", "unknown"], +}; + +/** The jobs' allocations, newest first, with a service column when there's more than one job. */ +function Allocations(props: { runtime: Runtime; name: (job: string) => string }) { + const rows = () => props.runtime.jobs.flatMap((job) => job.allocations.map((allocation) => ({ job: job.id, ...allocation }))) + .sort((a, b) => b.created - a.created); + const many = () => props.runtime.jobs.length > 1; + return ( + No allocations. Nomad forgets old ones after a while, so older deploys may have none left.

+ }> + + + + + + + + + + {(row) => { + const [health, label] = row.state === "running" && row.healthy !== true + ? row.healthy === null ? ["starting", "starting"] as const : ["degraded", "unhealthy"] as const + : row.state === "complete" && row.healthy === false ? ["degraded", "replaced"] as const + : ALLOCATION[row.state]; + const last = row.tasks.map((task) => task.lastRestart).filter((restart) => restart !== null).sort((a, b) => b.t - a.t)[0]; + return ( + + + + + + + + + ); + }} + + +
serviceallocationstatechecksrestartsstarted
{props.name(row.job)}{row.id.slice(0, 8)} + {label} + + –}> + {(check) => ( + + {check.name} + + )} + + + {count(row.tasks.reduce((sum, task) => sum + task.restarts, 0))} +
+
+ ); +} + +/** + * A job's lines in the page's window, newest at the bottom; older ones load near the top, and newer ones arrive + * while the job still runs. The last restart of each task is marked. + */ +function JobLogs(props: { + fetch: (query: LogQuery) => Promise; + runtime: Runtime | undefined; + empty: JSX.Element; +}) { + const PAGE = 500; + const [lines, setLines] = createSignal([]); + const [start, setStart] = createSignal(false); + const [follow, setFollow] = createSignal(true); + const [first, { refetch }] = createResource(() => props.fetch({ limit: String(PAGE) }).then((got) => { + setLines(got.reverse()); + setStart(got.length < PAGE); + setFollow(true); + return true; + })); + let loading = false; + const older = async () => { + const oldest = lines()[0]; + if (loading || start() || !oldest) return; + loading = true; + const got = await props.fetch({ before: String(oldest.t), limit: String(PAGE) }).catch(() => null); + loading = false; + if (!got || oldest !== lines()[0]) return; + setStart(got.length < PAGE); + setLines([...got.reverse(), ...lines()]); + }; + const timer = setInterval(async () => { + const last = lines().at(-1); + if (first.state !== "ready" || props.runtime?.to !== null) return; + const got = await props.fetch(last ? { after: String(last.t) } : {}).catch(() => []); + if (!got.length || last !== lines().at(-1)) return; + setLines([...lines(), ...got.reverse()].slice(-5000)); + }, 3000); + onCleanup(() => clearInterval(timer)); + const marked = createMemo(() => new Map(restarts(props.runtime).flatMap((restart) => { + const line = lines().find((item) => item.t >= restart.t); + return line ? [[line, restart] as const] : []; + }))); + return ( +
+ }> + {() => ( + {props.empty}

}> + ( + + {(restart) =>
restarted at {clock(restart().t)}: {restart().reason}
} +
+ )} /> +
+ )} +
+
+ ); +} + +export function Deploy() { + const params = useParams<{ id: string; tab?: string }>(); + const navigate = useNavigate(); + const { state, refetch, service, name } = useDeploys(); + const deploys = lastGood(state); + const app = (id: string) => service(id) ?? { id, name: id, icon: null }; + const icon = (id: string) => ; + + const [run, setRun] = createSignal(null); + let events: EventSource | undefined; + onCleanup(() => events?.close()); + const tail = (id: string, title: string, done?: () => void) => { + events?.close(); + setRun({ id, title, lines: [], code: null, done }); + events = followRun(id, { + open: () => setRun((r) => r && { ...r, lines: [] }), + line: (text) => setRun((r) => r && { ...r, lines: [...r.lines, text] }), + lost: () => setRun((r) => r && { ...r, code: "lost" }), + exit: async (code) => { + await refetch(); + const finished = untrack(run); + if (code !== 0) return setRun((r) => r && { ...r, code }); + setRun(null); + finished?.done?.(); + }, + }); + }; + // Picks up this page's run when it was started elsewhere or before the page opened, unless it succeeded or was dismissed. + createEffect(() => { + const active = deploys()?.run; + if (!active || (params.id === "main" ? active.title !== "deploy main" : active.target !== params.id) || active.code === 0 || dismissed().includes(active.id)) return; + if (untrack(run)?.id !== active.id) tail(active.id, active.title); + }); + const shown = () => (run() && !dismissed().includes(run()!.id) ? run() : null); + const act = ({ id, title }: Pick, done: () => void) => { + tail(id, title, done); + refetch(); + }; + const wait = () => (deploys()?.run?.code === null ? "Wait for the current run to finish" : ""); + + const redeploys = (ids: string[] | null, release: string) => + !ids ? `Production switches to release ${release.slice(0, 8)}. Services that differ from it redeploy.` + : !ids.length ? `Production switches to release ${release.slice(0, 8)} without redeploying any service.` + : `${new Intl.ListFormat("en").format(ids.map(name))} ${ids.length === 1 ? "redeploys" : "redeploy"} ` + + `on release ${release.slice(0, 8)}. Everything else keeps running.`; + + /** The job's lines in the logs app, bounded to the window once its jobs stopped. */ + const logsUrl = (job: string, runtime: Runtime | undefined) => { + const url = service("victoria-logs")?.url; + const iso = (t: number) => new Date(t * 1000).toISOString(); + const window = runtime?.to ? ` _time:[${iso(runtime.from)}, ${iso(runtime.to)}]` : ""; + return url && `${url}/select/vmui/#/?query=${encodeURIComponent(`{job="${job}"}${window}`)}`; + }; + const OpenLogs = (props: { job: string; runtime: Runtime | undefined }) => ( + + {(href) => open in logs} + + ); + const usage = (runtime: Resource, retry: () => void) => ( + }> + {(loaded) => } + + ); + const status = (runtime: Resource, retry: () => void) => ( + }> + {(loaded) => } + + ); + + const Tabs = (props: { tabs: [string, string][] }) => ( + + {([tab, label]) => {label}} + + ); + + const MainPage = () => { + const candidate = () => deploys()?.main; + const blocked = () => !candidate() ? "Upload main with python3 tools/deploy.py publish" + : candidate()!.release === deploys()?.current && deploys()?.recorded ? "Already deployed" + : wait(); + const deploy = () => { + const main = candidate()!; + return showConfirmDialog({ + title: "Deploy main?", + description: `Commit ${main.commit.slice(0, 12)} deploys the full repository configuration to production. Preview data stays in staging.`, + confirmLabel: "deploy main", + onConfirm: async () => act(await parseResponse(api.deploys.main[":release"].deploy.$post({ param: { release: main.release } })), () => toast("Deployed main")), + }); + }; + return ( + +
+ deploys

main

+ {(main) => {main().commit.slice(0, 12)}} + + +
+ {(r) => } + }> +

Upload main: python3 tools/deploy.py publish

+ No main commit uploaded yet.

}> + {(main) => <> +
{main().description}
+ + } +
+
+ ); + }; + + const StagePage = (props: { stage: StageInfo }) => { + const stage = () => props.stage; + const data = () => deploys()!; + const hex = () => stage().id.slice(stage().service.length + 1); + /** The deploy this was staged on, while production has moved past it. */ + const base = () => (stage().base === data().history[0]?.n ? undefined : data().history.find((entry) => entry.n === stage().base)); + const behind = () => stage().behind.filter((id) => stage().changed?.includes(id)); + const { runtime, latest, refetch: retry } = useRuntime("stage runtime", () => stage().id, + (id) => parseResponse(api.deploys.stages[":id"].runtime.$get({ param: { id } }))); + const destroy = () => { + const { id } = stage(); + return confirmDestroyStage(stage(), (run) => act(run, () => { + navigate("/deploys"); + toast(`Destroyed ${id}`); + })); + }; + return ( + +
+ deploys +

{name(stage().service)}

+ {hex()} + preview –}> + {(hostname) => preview} + + {(href) => data} +
+ health –}> + {(health) => ( + + {health() === "healthy" ? "running" : undefined} + + )} + + not ready + + staged + + {(entry) => ( + + behind production + + )} + + + unsaved settings + + + deploy didn't finish}> + in production + + +
+ + +
+ {(r) => } + + } summary={usage(runtime, retry)}> +

Update this preview: python3 tools/deploy.py stage {stage().service}

+
+ + +
+ + + + + {status(runtime, retry)} + + parseResponse(api.deploys.stages[":id"].logs.$get({ param: { id: stage().id }, query }))} + empty={ + + Nothing from this stage's job. Its staging output shows why it didn't start. + + } /> + + + parseResponse(api.deploys.stages[":id"].output.$get({ param: { id: stage().id } }))} + empty="The host kept no output from staging this." /> + + +
+ ); + }; + + const DeployPage = (props: { entry: Deployed }) => { + const entry = () => props.entry; + const data = () => deploys()!; + const previous = () => data().history.find((item) => item.n === entry().n - 1); + const next = () => data().history.find((item) => item.n === entry().n + 1); + const live = () => entry().n === data().history[0]?.n && data().recorded; + const stage = () => data().stages.find((item) => item.id === entry().source); + const { runtime, latest, refetch: retry } = useRuntime("deploy runtime", () => String(entry().n), + (n) => parseResponse(api.deploys.history[":n"].runtime.$get({ param: { n } }))); + const [search, setSearch] = useSearchParams<{ job?: string }>(); + const jobs = () => latest()?.jobs.map((job) => job.id) ?? []; + const job = () => (search.job && jobs().includes(search.job) ? search.job : jobs()[0]); + const rollback = () => { + const { n, release, redeploys: ids } = entry(); + return showConfirmDialog({ + title: `Roll back to ${release.slice(0, 8)}?`, + description: redeploys(ids, release), + confirmLabel: "roll back", + onConfirm: async () => act(await parseResponse(api.deploys.history[":n"].rollback.$post({ param: { n: String(n) } })), + () => toast(`Rolled back to ${release.slice(0, 8)}`)), + }); + }; + return ( + +
+ deploys +

{EVENT[entry().source] ?? name(entry().source.replace(/-preview-[0-9a-f]+$/, ""))}

+ {entry().release.slice(0, 8)} +
+ + {(after) => ( + + live {duration(after().time - entry().time)} + + )} + + }> + production + + deployed + + from {entry().source}}> + from {entry().source} + + + legacy + +
+ + + + +
+ {(r) => } + + } summary={usage(runtime, retry)}> +
+ + + {(id) => ( + <> + 1}> + + + + + )} + +
+ + + + + {status(runtime, retry)} + + {(id) => ( + parseResponse(api.deploys.history[":n"].logs.$get({ + param: { n: String(entry().n) }, query: { ...query, job: id }, + }))} + empty={`No lines from ${name(id)} while this deploy was live.`} /> + )} + + + parseResponse(api.deploys.history[":n"].output.$get({ param: { n: String(entry().n) } }))} + empty="The host kept no output for this deploy." /> + + +
+ ); + }; + + // Remounts per stage or deploy, so switching never shows the last one's usage, logs or output. + return ( + + +
+ + {(width) => } +
+
+
+ }> + {(data) => ( + +
deploys

{params.id}

+

+ {/^\d+$/.test(params.id) ? `No deploy number ${params.id} yet.` : `No stage named ${params.id}. It may have been destroyed.`} +

+
+ }> + + stage.id === params.id)}> + {(stage) => } + + String(entry.n) === params.id)}> + {(entry) => } + + + )} + + + ); +} diff --git a/dashboard/web/pages/Deploys.css b/dashboard/web/pages/Deploys.css new file mode 100644 index 0000000000000000000000000000000000000000..4188553a8dd645850c4d0bea72c38c1850dc019f --- /dev/null +++ b/dashboard/web/pages/Deploys.css @@ -0,0 +1,63 @@ +.deploys .graph { --lane: 16px; --y: 18px; --row-bg: var(--page); display: flex; flex-direction: column; padding-bottom: 24px; } +.deploys .hint { margin: 0; padding: 8px var(--gutter); color: var(--muted); font-size: 13px; } + +.deploys .row { + position: relative; + display: grid; + grid-template-columns: calc(var(--lanes) * var(--lane) + 22px) minmax(0, 17rem) minmax(0, 1fr) 7rem; + align-items: start; + column-gap: 12px; + padding: 0 var(--gutter); + background: var(--row-bg); + transition: background-color 150ms; +} +.deploys .row.link:hover { --row-bg: var(--hover); } +.deploys .row:has(a.title:focus-visible) { outline: 2px solid var(--focus); outline-offset: -2px; } +.deploys .row > :not(.rail) { padding: 8px 0; line-height: 20px; } +.deploys .row > .skeleton { height: 16px; margin: 10px 0; } + +.deploys .rail { position: relative; align-self: stretch; } +.deploys .rail i { position: absolute; left: calc(6px + var(--x, 0) * var(--lane)); width: 2px; background: var(--axis); } +.deploys .rail i.trunk { background: var(--accent); } +.deploys .pending .rail i.trunk { background: repeating-linear-gradient(var(--accent) 0 3px, transparent 3px 7px); } +.deploys .rail .start { top: var(--y); bottom: 0; } +.deploys .rail .pass { top: 0; bottom: 0; } +.deploys .rail .end { top: 0; height: var(--y); } +.deploys .rail .lane.merge { top: 0; height: calc(var(--y) - 8px); } +.deploys .rail i.curve { + left: 7px; + top: calc(var(--y) - 8px); + width: calc(var(--x) * var(--lane) + 1px); + height: 9px; + border: solid var(--axis); + border-width: 0 2px 2px 0; + border-bottom-right-radius: 8px; + background: none; +} +.deploys .node { + position: absolute; + left: calc(7px + var(--x, 0) * var(--lane)); + top: var(--y); + translate: -50% -50%; + display: grid; + place-items: center; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--row-bg); + transition: background-color 150ms; +} +.deploys .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); } +.deploys .dot.rollback { background: var(--row-bg); box-shadow: inset 0 0 0 2px var(--accent); } +.deploys .dot.head { width: 12px; height: 12px; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); } + +.deploys .what { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; min-width: 0; } +.deploys .title { display: inline-flex; align-items: baseline; gap: 8px; min-width: 0; } +.deploys .title b { font-weight: 600; white-space: nowrap; } +.deploys .row.link a.title::after { content: ""; position: absolute; inset: 0; } +.deploys a.title:focus-visible { outline: none; } +.deploys .row :is(.chips a, time, .chip[data-tip]) { position: relative; z-index: 1; } +.deploys .when { text-align: right; color: var(--text-2); white-space: nowrap; } + +.chip.production { background: var(--accent-wash); color: var(--accent); } +.chip.failed { background: color-mix(in srgb, var(--critical) 16%, transparent); color: color-mix(in srgb, var(--critical) 70%, var(--text)); } diff --git a/dashboard/web/pages/Deploys.tsx b/dashboard/web/pages/Deploys.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7701583c6a4b51588fc0e9b0b3c7999daece119f --- /dev/null +++ b/dashboard/web/pages/Deploys.tsx @@ -0,0 +1,221 @@ +import { A } from "@solidjs/router"; +import { type InferResponseType, parseResponse } from "hono/client"; +import { createMemo, createResource, For, type JSX, onCleanup, Show } from "solid-js"; +import { api, queries } from "../api.ts"; +import { Ago } from "../components/Ago.tsx"; +import { showConfirmDialog } from "../components/Dialog.tsx"; +import { ListPage } from "../components/ListPage.tsx"; +import { lastGood, Loaded } from "../components/Loaded.tsx"; +import { Status } from "../components/Status.tsx"; +import "./Deploys.css"; + +type Overview = InferResponseType; +export type StageInfo = Overview["stages"][number]; +export type Deployed = Overview["history"][number]; + +export function followRun(id: string, events: { + open?: () => void; + line?: (text: string) => void; + exit: (code: number) => void; + lost: () => void; +}) { + const source = new EventSource(`/api/deploys/runs/${id}`); + source.onopen = () => events.open?.(); + source.onmessage = (event) => events.line?.(event.data); + source.onerror = () => source.readyState === EventSource.CLOSED && events.lost(); + source.addEventListener("exit", (event) => { + source.close(); + events.exit(Number(event.data)); + }); + return source; +} + +export function confirmDestroyStage(stage: StageInfo, + started: (run: InferResponseType) => unknown, + returnFocus?: HTMLElement, +) { + const { id, hostname, clone, mount } = stage; + return showConfirmDialog({ + title: `Destroy ${id}?`, + description: () => <> + The preview{hostname ? <> at {hostname} : ""} goes offline + {clone ? <> and {mount} is deleted with everything in it : ""}. + Production isn't touched. + , + confirmLabel: "destroy", + destructive: true, + returnFocus, + onConfirm: async () => started(await parseResponse(api.deploys.stages[":id"].destroy.$post({ param: { id } }))), + }); +} + +export const EVENT: Record = { main: "main", bootstrap: "first release", rollback: "rolled back", previous: "adopted" }; + +/** Deploy state, polled, and service names for the ids in it; both pages read the same shape. */ +export function useDeploys() { + const [state, { refetch }] = queries.deploys.use(); + // Only for names, icons and links; ids stand in when it fails. + const services = lastGood(queries.services.use()[0]); + const timer = setInterval(refetch, 5000); + onCleanup(() => clearInterval(timer)); + const service = (id: string) => services()?.find((item) => item.id === id); + return { state, refetch, service, name: (id: string) => service(id)?.name ?? id }; +} + +export function Changed(props: { ids: string[] | null; name: (id: string) => string; warn?: string[] }) { + return ( + –}> + {(ids) => ( +
+ props.name(a).localeCompare(props.name(b)))} fallback={nothing}> + {(id) => ( + + {props.name(id)} + + )} + +
+ )} +
+ ); +} + +/** What a lane draws in one row: a node that starts it, a line through, or the curve into production. */ +type Mark = "start" | "pass" | "merge"; + +interface Row { + key: string; + href?: string; + /** Production's line through this row. */ + trunk?: "start" | "pass" | "end" | "only"; + node: JSX.Element; + marks: [lane: number, mark: Mark][]; + title: JSX.Element; + flags?: JSX.Element; + changes?: JSX.Element; + time?: number; + class?: string; +} + +export function Deploys() { + const { state, refetch, name } = useDeploys(); + return ( +

deploys

deploy main
}> + + {() =>
}
+
+ }> + {(data) => { + // Polls mostly return what's already shown; rebuilding rows then would drop hover and focus. + const same = createMemo(data, undefined, { equals: (a, b) => JSON.stringify(a) === JSON.stringify(b) }); + const rows = createMemo(() => { + const { stages, history, run, current, recorded } = same(); + const stageRows = [...stages].sort((a, b) => b.created - a.created); + // Branches that rejoin production higher up sit nearer its line, so no curve crosses another branch. + const lanes = new Map([...stages].sort((a, b) => (b.base ?? 0) - (a.base ?? 0)).map((stage, i) => [stage.id, i + 1])); + const top: Row[] = []; + if (run?.code === null) { + top.push({ + key: run.id, href: `/deploys/${run.title === "deploy main" ? "main" : run.target}`, node: , marks: [], + title: {run.title}…, class: "pending", + }); + } else if (current && !recorded) { + const staged = stages.find((stage) => stage.release === current); + top.push({ + key: current, href: staged ? `/deploys/${staged.id}` : run?.code ? `/deploys/${run.target}` : undefined, marks: [], + node: , + title: <>{staged ? name(staged.service) : "deploy"}{current.slice(0, 8)}, + flags: didn't finish, + }); + } + const trunk: Row[] = [...top, ...history.map((entry, i): Row => ({ + key: String(entry.n), + href: `/deploys/${entry.n}`, + node: , + marks: [], + title: ( + <> + {EVENT[entry.source] ?? name(entry.source.replace(/-preview-[0-9a-f]+$/, ""))} + {entry.release.slice(0, 8)} + + ), + flags: <> + production + legacy + , + changes: , + time: entry.time, + }))]; + trunk.forEach((row, i) => (row.trunk = trunk.length === 1 ? "only" : i === 0 ? "start" : i === trunk.length - 1 ? "end" : "pass")); + const all: Row[] = [...stageRows.map((stage): Row => ({ + key: stage.id, + href: `/deploys/${stage.id}`, + node: stage.health + ? + : , + marks: [], + title: <>{name(stage.service)}{stage.id.slice(stage.service.length + 1)}, + flags: <> + not ready}> + staging failed + + + unsaved settings + + in production + , + changes: , + time: stage.created, + })), ...trunk]; + for (const stage of stages) { + const lane = lanes.get(stage.id)!; + const from = all.findIndex((row) => row.key === stage.id); + const to = stage.base ? all.findIndex((row) => row.key === String(stage.base)) : all.length; + all[from]!.marks.push([lane, "start"]); + for (let i = from + 1; i < Math.min(to, all.length); i++) all[i]!.marks.push([lane, "pass"]); + if (to < all.length) all[to]!.marks.push([lane, "merge"]); + } + return { all, lanes: stages.length, empty: !stages.length }; + }); + return ( +
+ +

No stages. Run deploy.py stage <service> to preview a change here.

+
+ + {(row) => ( +
+ +
+ {row.title}}> + {(href) => {row.title}} + + {row.flags} +
+
{row.changes}
+
{(t) => }
+
+ )} +
+
+ ); + }} + + + ); +} diff --git a/dashboard/web/pages/MCP.css b/dashboard/web/pages/MCP.css new file mode 100644 index 0000000000000000000000000000000000000000..abdb97ddce4fd4681cacd9f09e06ce6488526ba6 --- /dev/null +++ b/dashboard/web/pages/MCP.css @@ -0,0 +1,12 @@ +.mcp-page h2 { margin-top: 2rem; } +.mcp-connector, .mcp-consent { padding: 1.5rem; border: 1px solid var(--line); border-radius: 8px; margin: 1rem 0; } +.mcp-connector h3, .mcp-consent h2 { margin-top: 0; } +.mcp-resources { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; margin: 1.5rem 0; } +.mcp-actions { display: flex; gap: .75rem; } +.mcp-page table { width: 100%; text-align: left; border-collapse: collapse; } +.mcp-page th, .mcp-page td { padding: .75rem; border-bottom: 1px solid var(--line); } + +.mcp-page form { margin: 1rem 0; } +.mcp-page form label { display: flex; align-items: center; gap: .75rem; } +.mcp-page input { padding: .5rem; } +.mcp-page form > button { margin-top: .75rem; } diff --git a/dashboard/web/pages/MCP.tsx b/dashboard/web/pages/MCP.tsx new file mode 100644 index 0000000000000000000000000000000000000000..27318a3a06f5bc759e0a8af31ca59be62ef7331e --- /dev/null +++ b/dashboard/web/pages/MCP.tsx @@ -0,0 +1,133 @@ +import { useLocation } from "@solidjs/router"; +import { parseResponse } from "hono/client"; +import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; +import { api, reason } from "../api.ts"; +import { Ago } from "../components/Ago.tsx"; +import { Checkbox } from "../components/Checkbox.tsx"; +import { Copy } from "../components/Copy.tsx"; +import { Loaded } from "../components/Loaded.tsx"; +import { toast } from "../components/Toast.tsx"; +import "./MCP.css"; + +export function MCP() { + const location = useLocation(); + const request = () => new URLSearchParams(location.search).get("request"); + const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get())); + const [consent, { refetch: retryConsent }] = createResource(() => request() || false, + (id) => parseResponse(api.mcp.consent[":id"].$get({ param: { id } }))); + const [picked, setPicked] = createSignal([]); + const [busy, setBusy] = createSignal(false); + const [code, setCode] = createSignal(""); + const [keyName, setKeyName] = createSignal(""); + const [keyMachines, setKeyMachines] = createSignal([]); + const [control, setControl] = createSignal(false); + const [key, setKey] = createSignal(""); + createEffect(() => { request(); setPicked([]); setBusy(false); }); + let events: EventSource | undefined; + createEffect(() => { + if (!overview() || events) return; + events = new EventSource("/api/mcp/relay/live"); + events.onmessage = (event) => { + const machines: NonNullable>["machines"] = JSON.parse(event.data); + mutate((previous) => previous && { ...previous, machines }); + }; + }); + onCleanup(() => events?.close()); + const answer = async (deny: boolean) => { + setBusy(true); + try { + const result = await parseResponse(api.mcp.consent[":id"].$post({ param: { id: request()! }, json: deny ? { deny: true } : { resources: picked() } })); + window.location.assign(result.redirect); + } catch (error) { toast(reason(error)); setBusy(false); } + }; + return
+ + + + {(details) => } + + + + {(data) => <> +

Connectors

+ {(catalog) =>
+

{catalog.name}

Add this endpoint to your AI client. Access is granted when you connect.

+ +
}
+

Shale account

+ Link your Shale account to grant clients access to its repositories.

}> + {(shale) =>

Account linked

} +
+ + +
+

Local machines

+

Run the Agent Relay local agent with this dashboard's origin, then enter its pairing code.

+ +
{ + event.preventDefault(); setBusy(true); + try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); } + catch (error) { toast(reason(error)); } + finally { setBusy(false); } + }}> +
+ No machines linked yet. Pair a local agent to connect it.

}> + + {(machine) => } +
MachinePlatformConnection
{machine.name}{machine.platform}{machine.online ? "Online" : "Offline"} + +
+

API key

+
{ + event.preventDefault(); setBusy(true); + 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(); } + catch (error) { toast(reason(error)); } + finally { setBusy(false); } + }}> + +
{(machine) => setKeyMachines(checked ? [...keyMachines(), machine.id] : keyMachines().filter((id) => id !== machine.id))}>{machine.name}}
+ Allow session control + +
+
+

New API key

Copy this key now. It won't be shown again.

+

Connections

+ No clients connected yet. Add a connector endpoint to your AI client to get started.

}> + + {(connection) => } +
ClientAccessAdded
{connection.name}{connection.resources.join(", ")} · Session control · Issue editing + +
+
+ } +
+
; +} diff --git a/dashboard/web/pages/Media.tsx b/dashboard/web/pages/Media.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b6f392a7f9e47ca4da68547c78e103cd2ccecc6e --- /dev/null +++ b/dashboard/web/pages/Media.tsx @@ -0,0 +1,53 @@ +import { useSearchParams } from "@solidjs/router"; +import { parseResponse } from "hono/client"; +import { createSignal, Show } from "solid-js"; +import { api, queries, reason } from "../api.ts"; +import { Explorer } from "../components/Explorer.tsx"; +import { ListPage } from "../components/ListPage.tsx"; +import { lastGood } from "../components/Loaded.tsx"; +import { OpenApp } from "../components/OpenApp.tsx"; +import { toast } from "../components/Toast.tsx"; + +export function Media() { + const [params] = useSearchParams<{ path?: string }>(); + const here = () => params.path ?? ""; + const apps = lastGood(queries.launcher.use()[0]); + /** Jellyfin has no page per folder, so a show, movie or artist folder opens a search for its title. */ + const jellyfin = () => { + const found = apps()?.find((app) => app.id === "jellyfin"); + const [top, , title] = here().split("/"); + if (!found) return null; + return { + ...found, + href: top === "jellyfin" && title + ? `${found.url}/web/#/search?query=${encodeURIComponent(title.replace(/ \(\d{4}\)$/, ""))}` + : `${found.url}/web/`, + }; + }; + const [refreshing, setRefreshing] = createSignal(false); + const refresh = async () => { + setRefreshing(true); + try { + await parseResponse(api.media.refresh.$post()); + toast("Jellyfin started scanning all libraries."); + } catch (failure) { + toast(reason(failure)); + } finally { + setRefreshing(false); + } + }; + + return ( + +

media

+ {(link) => } + + + + }> + +
+ ); +} diff --git a/dashboard/web/pages/Overview.css b/dashboard/web/pages/Overview.css new file mode 100644 index 0000000000000000000000000000000000000000..26d2787b52b548ac2fb62ac9f3e72eb44cdedccd --- /dev/null +++ b/dashboard/web/pages/Overview.css @@ -0,0 +1,99 @@ +/* home: the launcher everyone who isn't an admin lands on ------------------ */ + +.home { display: grid; justify-items: center; align-content: start; max-width: 760px; padding-top: max(40px, 9vh); } +.home .globe { width: 52px; height: 52px; } +.page h1.greeting { margin: 0; font-size: 28px; font-weight: 250; letter-spacing: -0.01em; line-height: 1.2; } +.page.home h1.greeting { margin: 14px 0 30px; text-align: center; text-wrap: balance; } +.home .empty p { margin: 0 0 4px; } +.home .load { display: flex; align-items: center; gap: 8px; margin: -18px 0 26px; color: var(--text-2); font-size: 13px; } +.home .load .meter { width: 48px; min-width: 0; } +.home .issues { width: 100%; max-width: 600px; margin-top: 16px; } +.home .empty p:first-child { color: var(--text-2); } + +/* An app is a card that opens it; admins and friends see the same ones. */ +.apps { display: grid; grid-template-columns: repeat(auto-fill, minmax(136px, 1fr)); gap: 4px; align-content: start; } +.apps > .skeleton { height: 40px; border-radius: var(--radius); } +.app { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + height: 40px; + padding: 0 10px 0 8px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + font-size: 13px; + transition: background-color 150ms, border-color 150ms; +} +.app:hover { background: var(--hover); border-color: var(--axis); } +.app :is(img, .monogram) { flex: none; width: 24px; height: 24px; font-size: 12px; transition: transform 200ms var(--ease-out); } +.app:hover :is(img, .monogram) { transform: scale(1.08); } +.app:active :is(img, .monogram) { transform: scale(0.94); transition-duration: 100ms; } +.app .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.app .status { flex: none; } +.app .status-label { flex: none; color: var(--text-2); font-size: 12px; } +.app.out :is(img, .monogram) { filter: grayscale(1); opacity: 0.45; } +.app.out .name { color: var(--text-2); } +.app.down { border-color: color-mix(in srgb, var(--critical) 55%, var(--line)); } +.app.degraded { border-color: color-mix(in srgb, var(--warning) 55%, var(--line)); } +.app > .skeleton.icon { width: 24px; height: 24px; border-radius: 22%; } + +.home .apps { width: 100%; max-width: 600px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; } +.home .app { height: 52px; padding: 0 14px 0 12px; gap: 12px; border-radius: var(--radius-lg); font-size: 14px; } +.home .app :is(img, .monogram) { width: 30px; height: 30px; font-size: 14px; } +.home .app > .skeleton.icon { width: 30px; height: 30px; } + +/* admin overview ---------------------------------------------------------- */ + +.overview .page-head { margin-bottom: 14px; } + +.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)); } +.staging-heading { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; } +.staging-heading h2 { margin: 0; color: color-mix(in srgb, var(--warning) 65%, var(--text)); } +.staging-heading span { color: var(--text-2); font-size: 12px; } +.staging-list { display: flex; flex-wrap: wrap; gap: 6px; } +.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; } +.staging-list a:hover { border-color: var(--warning); } +.staging-list a .mono { font-size: 11px; } +.staging-list a[aria-busy="true"] { opacity: 0.6; } +.stage-menu { + position: fixed; inset: auto; margin: 0; padding: 4px; min-width: 140px; + border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); + box-shadow: 0 8px 24px #0002; +} +.stage-menu button { + display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; + border: 0; border-radius: 4px; background: none; color: var(--critical); text-align: left; +} +.stage-menu button:hover, .stage-menu button:focus-visible { background: var(--hover); } +.stage-menu button:disabled { opacity: 0.5; } + +.glance { display: grid; grid-template-columns: minmax(290px, 2fr) minmax(0, 3fr); gap: 24px; align-items: start; } +.changes { margin: 0; padding: 0; list-style: none; font-size: 13px; } +.changes li { display: grid; grid-template-columns: 14px 1fr auto; gap: 10px; align-items: center; min-height: 32px; border-bottom: 1px solid var(--grid); } +.changes li:last-child { border-bottom: 0; } +.changes li > svg { color: var(--muted); } +.changes li.warn > svg:not(.status) { color: var(--warning); } +.changes .what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.changes a { border-radius: 3px; transition: color 150ms; } +.changes .muted a { color: var(--text-2); text-decoration: underline dotted var(--axis); text-underline-offset: 3px; } +.changes a[href]:hover { color: var(--accent); text-decoration-color: currentColor; } +.changes.issues { border-bottom: 1px solid var(--grid); } +.issues li:has(> button) { grid-template-columns: 14px 1fr auto auto; } +.issues li > .status { justify-self: center; } +.issues .button.icon-only { --control: 22px; width: 22px; } +.changes time { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } + +.section-head { display: flex; align-items: center; gap: 12px; margin: 22px 0 6px; } +.section-head h2 { margin: 0; } +.section-head > .segmented { margin-left: auto; } +.section-head h2 a[href]:hover { text-decoration: underline; text-underline-offset: 3px; } +.changes li > .skeleton { grid-column: 1 / -1; } + +.pool { margin-bottom: 4px; } +.pool .donut-row svg { width: 120px; height: 120px; } +.pool .changes .scanning { display: flex; align-items: center; gap: 8px; } +.pool .changes .meter { flex: 1; max-width: 160px; } +.pool .changes li > .muted { font-size: 12px; } diff --git a/dashboard/web/pages/Overview.tsx b/dashboard/web/pages/Overview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cf6358931a31c59e7ebe73b33c3f21a1f98d7c67 --- /dev/null +++ b/dashboard/web/pages/Overview.tsx @@ -0,0 +1,527 @@ +import { type InferResponseType, parseResponse } from "hono/client"; +import ArrowDown from "lucide-solid/icons/arrow-down"; +import ArrowUp from "lucide-solid/icons/arrow-up"; +import BatteryWarning from "lucide-solid/icons/battery-warning"; +import HardDrive from "lucide-solid/icons/hard-drive"; +import Power from "lucide-solid/icons/power"; +import Rocket from "lucide-solid/icons/rocket"; +import RotateCcw from "lucide-solid/icons/rotate-ccw"; +import ShieldCheck from "lucide-solid/icons/shield-check"; +import Trash from "lucide-solid/icons/trash"; +import Undo2 from "lucide-solid/icons/undo-2"; +import X from "lucide-solid/icons/x"; +import { createMemo, createResource, createRoot, createSignal, For, type JSX, onCleanup, Show } from "solid-js"; +import { type Health, type HostInfo, type Live, type Me, type Series, type ServiceSummary, trouble } from "../types/model.ts"; +import { api, queries, reason } from "../api.ts"; +import { Ago } from "../components/Ago.tsx"; +import { AppIcon } from "../components/AppIcon.tsx"; +import { Donut } from "../components/Donut.tsx"; +import { lastGood, Loaded } from "../components/Loaded.tsx"; +import { Meter } from "../components/Meter.tsx"; +import { OpenApp } from "../components/OpenApp.tsx"; +import { RangePicker } from "../components/Metric.tsx"; +import { Status, StatusLabel } from "../components/Status.tsx"; +import { type Reading, Strip } from "../components/Strip.tsx"; +import { TabBar } from "../components/TabBar.tsx"; +import { toast } from "../components/Toast.tsx"; +import { ago, bytes, celsius, cores, duration, percent, plural, rate, watts } from "../format.ts"; +import { stream } from "../live.ts"; +import snowflake from "../snowflake.svg"; +import { confirmDestroyStage, followRun, type StageInfo } from "./Deploys.tsx"; +import "./Overview.css"; + +/** How busy the machine is and what needs a look, polled for the overview and the sidebar's badge. */ +export const status = createRoot(() => { + const [data, { refetch }] = createResource(() => parseResponse(api.status.$get())); + setInterval(refetch, 30_000); + return { latest: lastGood(data), refetch }; +}); + +/** Opens an app; its tagline is the tooltip, for people who don't know the app by name. */ +function AppCard(props: { + app: Pick; + /** Null while it's unknown. */ + health: Health | null; + children?: JSX.Element; +}) { + return ( + + + {props.app.name} + {props.children} + + ); +} + +/** Snow gets her own line, except while previewing the dashboard as someone else. */ +function Greeting(props: { me: Me }) { + return ( +

+ {props.me.name === "snow" && !props.me.viewing + ? "hi snow. keep it up." + : `hi ${props.me.name}… welcome to my cozy little snow globe…`} +

+ ); +} + +/** Apps that are down or degraded or restarted unacknowledged; admins reach each service and clear its restart. */ +function Issues(props: { admin: boolean }) { + const clear = (id: string) => parseResponse(api.services[":id"].restarts.acknowledge.$post({ param: { id } })) + .then(status.refetch, (failure) => toast(`Couldn't clear the restart. ${reason(failure)}`)); + return ( + +
    + + {(issue) => { + const [clearing, setClearing] = createSignal(false); + const name = () => props.admin ? {issue.service.name} : issue.service.name; + return ( + <> + +
  • + + {name()} {issue.health} {issue.failing} +
  • +
    + + {(restart) => ( +
  • +
  • + )} +
    + + ); + }} +
    +
+
+ ); +} + +/** The front door for everyone without machine metrics: their apps, a word when one is out, and how busy it all is. */ +function Home(props: { me: Me }) { + const [apps, { refetch }] = queries.launcher.use(); + const timer = setInterval(refetch, 30_000); + onCleanup(() => clearInterval(timer)); + return ( +
+ + + + {(now) => ( +

+ + the snow globe is {now().load < 25 ? "quiet" : now().load < 60 ? "busy" : "very busy"} +

+ )} +
+ + + {() =>
} +
+
+ }> + {(list) => ( +

No apps shared with you yet.

They show up here once Clover gives you access.

+ }> + +
+ )} + + + + ); +} + +type Storage = InferResponseType; + +const latestOf = (series: Series | undefined) => series?.v.findLast((v) => v != null) ?? undefined; + +/** The machine at a glance, for admins and the metrics group: apps, what changed, storage, and the machine's charts. */ +function Dashboard(props: { me: Me }) { + const admin = props.me.sections.includes("admin"); + stream.start(); + const [launcher, { refetch: refetchLauncher }] = queries.launcher.use(); + const [host, { refetch: refetchHost }] = queries.host.use(); + const [storage, { refetch: refetchStorage }] = queries.storage.use(); + const [services, { refetch: refetchServices }] = queries.services.use(); + const [deploys, { refetch: refetchDeploys }] = queries.deploys.use(() => (admin ? undefined : false)); + const timer = setInterval(() => [refetchLauncher, refetchHost, refetchStorage, refetchServices, refetchDeploys] + .forEach((refetch) => refetch()), 60_000); + onCleanup(() => clearInterval(timer)); + const info = lastGood(host); + const pool = lastGood(storage); + const apps = lastGood(launcher); + const history = lastGood(deploys); + const stages = () => (history()?.stages ?? []).filter((stage) => + !stage.ready || (stage.health !== "down" && stage.health !== "stopped")); + const known = lastGood(services); + const names = () => Object.fromEntries((known() ?? []).map((service) => [service.id, service.name])); + const name = (id: string) => names()[id] ?? id; + const health = (service: { id: string; health: Health | null }) => + stream.latest()?.services[service.id]?.health ?? service.health; + const metrics = () => apps()?.find((app) => app.id === "victoria-metrics"); + + const [menuId, setMenuId] = createSignal(); + const selectedStage = () => history()?.stages.find((stage) => stage.id === menuId()); + const [destroying, setDestroying] = createSignal(); + let stageMenu!: HTMLDivElement; + let stageAnchor: HTMLAnchorElement; + let destroyRun: EventSource | undefined; + onCleanup(() => destroyRun?.close()); + const showStageMenu = (stage: StageInfo, event: (MouseEvent | KeyboardEvent) & { currentTarget: HTMLAnchorElement }) => { + event.preventDefault(); + stageAnchor = event.currentTarget; + stageAnchor.focus(); + setMenuId(stage.id); + stageMenu.showPopover(); + const rect = stageAnchor.getBoundingClientRect(); + const x = event instanceof MouseEvent ? event.clientX : rect.left; + const y = event instanceof MouseEvent ? event.clientY : rect.bottom; + stageMenu.style.left = `${Math.max(8, Math.min(x, innerWidth - stageMenu.offsetWidth - 8))}px`; + stageMenu.style.top = `${Math.max(8, Math.min(y, innerHeight - stageMenu.offsetHeight - 8))}px`; + stageMenu.querySelector("button")?.focus(); + }; + const destroyStage = (stage: StageInfo) => { + stageMenu.hidePopover(); + confirmDestroyStage(stage, (run) => { + setDestroying(stage.id); + destroyRun = followRun(run.id, { + exit: async (code) => { + setDestroying(); + await refetchDeploys(); + if (code !== 0) toast("Couldn't destroy the preview. Open its output, then retry.", { + label: "output", run: async () => location.assign(`/deploys/${stage.id}/output`), + }); + }, + lost: () => { + setDestroying(); + toast("Lost destroy progress. Open its output to check the result.", { + label: "output", run: async () => location.assign(`/deploys/${stage.id}/output`), + }); + }, + }); + refetchDeploys(); + }, stageAnchor); + }; + + const [range, setRange] = createSignal(3600); + const [splitCpu, setSplitCpu] = createSignal(false); + const [splitMemory, setSplitMemory] = createSignal(false); + const groupBy = (value: () => boolean, set: (value: boolean) => void) => ( + + + + + ); + const live = (read: (tick: Live, host: HostInfo) => Reading) => () => { + const tick = stream.latest(); + const host = info(); + return tick && host ? { ...read(tick, host), stale: !stream.live() } : undefined; + }; + + type Change = { t: number; icon: typeof Rocket; what: JSX.Element; detail?: JSX.Element; warn?: boolean }; + const changes = createMemo(() => { + const list: Change[] = []; + const host = info(); + for (const outage of host?.outages ?? []) { + list.push({ + t: outage.start, icon: BatteryWarning, what: "on battery", warn: outage.end === null, + detail: outage.end === null ? "now" : `for ${duration(outage.end - outage.start)}`, + }); + } + for (const entry of history()?.history ?? []) { + const rollback = entry.source === "rollback"; + if (!rollback && entry.changed?.length === 0) continue; + list.push({ + t: entry.time, icon: rollback ? Undo2 : Rocket, + what: {rollback ? "rolled back" : "updated"}, + detail: ( + + {(id, i) => <>{i() ? ", " : ""}{name(id)}} + + ), + }); + } + const recent = list.filter((change) => !host || change.t > host.bootedAt).sort((a, b) => b.t - a.t).slice(0, 7); + // Everything older than the boot is history; the boot itself always closes the list. + return host + ? [...recent, { t: host.bootedAt, icon: Power, what: "booted", detail: `up ${duration(Date.now() / 1000 - host.bootedAt)}` }] + : recent; + }); + + return ( +
+
+ + +
+

staging

{plural(stages().length, "preview")}
+ +
+
+ + + +
+ {() => }
+ }> + {(list) => ( + No apps shared with you yet.

}> + +
+ )} + +
+ + {() =>
  • }
    + }> + {() => ( +
      + + {(change) => ( +
    • +
    • + )} +
      +
    + )} +
    +
    +
    + + +
    + }> + {(data) => } + + +
    +

    machine

    + {(app) => } + +
    +
    + { + const [top] = Object.entries(tick.services).flatMap(([id, { cpu }]) => (cpu === null ? [] : [[id, cpu] as const])) + .sort((a, b) => b[1] - a[1]); + return { + value: percent(tick.host.cpu), + tip: `${cores((tick.host.cpu / 100) * host.cores)} / ${host.cores} cores` + + (top ? ` · top: ${name(top[0])} ${cores(top[1])}` : ""), + }; + })} /> + ({ + value: bytes(tick.host.memory), + tip: `${bytes(tick.host.memory)} / ${bytes(host.memory)} (${percent((tick.host.memory / host.memory) * 100)})` + + (tick.host.arc === null ? "" : ` · ${bytes(tick.host.arc)} ZFS cache`), + }))} /> + { + const value = latestOf(series[0]); + return value === undefined ? undefined : { value: percent(value) }; + }} /> + { + if (!ups) return { value: "–", tip: "No UPS is connected to this host yet." }; + if (ups.status === "battery") { + return { + value: on battery, {duration(ups.runtime)} left, + tip: `${watts(ups.load)} · ${percent(ups.charge)} charged`, + }; + } + const outage = host.outages?.at(-1); + return { + value: watts(ups.load), + tip: [ + `${duration(ups.runtime)} on battery`, + ups.charge < 100 && `${percent(ups.charge)} charged`, + outage && `last outage ${ago(outage.start)}, ${duration((outage.end ?? Date.now() / 1000) - outage.start)}`, + ].filter(Boolean).join(" · "), + }; + })} /> + { + const hottest = pool()?.pool.vdevs.flatMap((vdev) => vdev.disks).flatMap((disk) => disk.smart ?? []) + .sort((a, b) => b.temperature - a.temperature)[0]; + return { + value: tick.host.temperature === null ? "–" : celsius(tick.host.temperature), + tip: `${tick.host.temperature === null ? "no cpu sensor read yet" : "cpu"}` + + `${hottest ? ` · hottest drive ${celsius(hottest.temperature)}, ${hottest.model}` : ""}`, + }; + })} /> + { + const [down, up] = series.map(latestOf); + return down === undefined || up === undefined ? undefined : { + value: ( + <>{rate(down)}{rate(up)} + ), + }; + }} /> +
    +
    + ); +} + +/** How full the pool is, which disks need a look and why, and the latest scrub; `disks` links to the disk list. */ +function Pool(props: { data: Storage; disks?: string }) { + const space = () => props.data.space; + const used = () => space().live + space().held; + const disks = () => props.data.pool.vdevs.flatMap((vdev) => vdev.disks); + const ailing = () => disks().filter((disk) => disk.issue); + const scan = () => props.data.pool.scan; + return ( +
    + +
      + + + +
    +
    + ); +} + +export function Overview(props: { me: Me }) { + return ( + }> + + + ); +} diff --git a/dashboard/web/pages/PaperClover.css b/dashboard/web/pages/PaperClover.css new file mode 100644 index 0000000000000000000000000000000000000000..044b246e87060655a25bd99aa4dd3414f246ef98 --- /dev/null +++ b/dashboard/web/pages/PaperClover.css @@ -0,0 +1,152 @@ +.paper-clover > :is(.grid, .card) + :is(.grid, .card) { margin-top: 8px; } +.paper-clover ul { list-style: none; margin: 0; padding: 0; } + +.paper-clover .page-head h1 { white-space: nowrap; } +.paper-clover .page-head h1 a { border-radius: 4px; transition: color 150ms; } +.paper-clover .page-head h1 a:hover { color: var(--accent); } + +.paper-clover .file-name { display: grid; min-width: 0; line-height: 1.3; } +.paper-clover .file-name > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.paper-clover .file-name a { justify-self: start; max-width: 100%; border-radius: 3px; transition: color 150ms; } +.paper-clover .file-name a:hover { color: var(--accent); } +.paper-clover .file-name .folder { color: var(--muted); font-size: 12px; } + +.paper-clover :is(.issues ul, .files) > li { border-top: 1px solid var(--grid); } +.paper-clover :is(.issues ul, .files) > li:first-child { border-top: 0; } + +/* Rows share their parent's columns so the error and time line up down the card. */ +.paper-clover .issues ul { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) auto auto; column-gap: 12px; } +.paper-clover .issues li { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + align-items: center; + padding: 5px 0; + min-height: 44px; +} +.paper-clover .issues li > .button { align-self: stretch; height: auto; } +.paper-clover .issues li > time { color: var(--muted); font-size: 12px; white-space: nowrap; } +.paper-clover .issues .what { font-weight: 500; } +.paper-clover .issues .why { display: grid; min-width: 0; font-size: 12.5px; color: var(--text-2); } +.paper-clover .issues button.why { + padding: 2px 6px; + margin: -2px -6px; + border: 0; + border-radius: 6px; + background: none; + text-align: left; + cursor: pointer; + transition: background-color 150ms; +} +.paper-clover .issues button.why:hover:not(:disabled) { background: var(--hover); } +.paper-clover .issues button.why > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.paper-clover .issues .detail { grid-column: 1 / -1; display: grid; gap: 6px; margin-top: 6px; font-size: 12.5px; } +.paper-clover .issues .detail p { margin: 0; color: var(--text-2); } +.paper-clover .issues .trace { + margin: 0; + padding: 8px 10px; + max-height: 240px; + overflow: auto; + border-radius: var(--radius); + background: var(--well); + font: 12px/1.45 var(--mono); + color: var(--text-2); +} + +.paper-clover .files li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 5px 0; } +.paper-clover .files .num { font-size: 12.5px; line-height: 1.3; } + + +/* The progress tree, drawn like @clo/lib/progress draws it in a terminal. */ +.paper-clover .running { padding: 10px 16px; font: 12.5px/20px var(--mono); } +.paper-clover .running .empty { padding: 12px 0; font-family: var(--sans); } +.paper-clover .running li { position: relative; } +.paper-clover .running ul ul > li { padding-left: 3ch; } +.paper-clover .running ul ul > li::before, +.paper-clover .running ul ul > li:not(:last-child)::after { + content: ""; + position: absolute; + top: 0; + left: 0.5ch; + border-left: 1px solid var(--axis); +} +.paper-clover .running ul ul > li::before { width: 1.6ch; height: 10px; border-bottom: 1px solid var(--axis); } +.paper-clover .running ul ul > li:not(:last-child)::after { bottom: 0; } +.paper-clover .running .line { display: flex; gap: 1ch; align-items: center; height: 20px; min-width: 0; white-space: nowrap; } +.paper-clover .running .line > * { flex: none; } +.paper-clover .running .dim { color: var(--muted); } +.paper-clover .running .step { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; flex-shrink: 1; } +.paper-clover .running a { border-radius: 3px; transition: color 150ms; } +.paper-clover .running a:hover { color: var(--accent); } +.paper-clover .running .path { display: flex; min-width: 0; flex-shrink: 1; } +.paper-clover .running .path > .dim { overflow: hidden; text-overflow: ellipsis; } +.paper-clover .running .path > :last-child { flex: none; } +.paper-clover .running .log { padding-left: 1ch; } +.paper-clover .running .log > :last-child { overflow: hidden; text-overflow: ellipsis; flex-shrink: 1; color: var(--text-2); } +.paper-clover .running .warn { color: color-mix(in srgb, var(--warning) 75%, var(--text)); } +.paper-clover .running .error { color: color-mix(in srgb, var(--critical) 75%, var(--text)); } +.paper-clover .running .more { padding: 0; border: 0; background: none; color: var(--muted); font: inherit; cursor: pointer; } +.paper-clover .running .more:hover { color: var(--text); } + +.paper-clover .spinner { width: 1ch; color: var(--series-1); text-align: center; } +.paper-clover .spinner::before { content: "⠋"; animation: paper-clover-spinner 800ms infinite; } +@keyframes paper-clover-spinner { + 0% { content: "⠋"; } 10% { content: "⠙"; } 20% { content: "⠹"; } 30% { content: "⠸"; } 40% { content: "⠼"; } + 50% { content: "⠴"; } 60% { content: "⠦"; } 70% { content: "⠧"; } 80% { content: "⠇"; } 90% { content: "⠏"; } +} +@media (prefers-reduced-motion: reduce) { .paper-clover .spinner::before { animation: none; } } + +.paper-clover .running .bar { width: 12ch; height: 12px; background: var(--raised); } +.paper-clover .running .bar > span { display: block; height: 100%; background: var(--series-1); transition: width 300ms var(--ease-out); } + +.paper-clover .space { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 14px; row-gap: 8px; align-items: end; } +.paper-clover .space .total { justify-self: end; margin-bottom: -3px; color: var(--muted); font-size: 12.5px; white-space: nowrap; } +.paper-clover .space .total b { color: var(--text); font-weight: 600; } +.paper-clover .space a.total[href] { border-radius: 4px; transition: color 150ms; } +.paper-clover .space a.total[href]:hover { color: var(--accent); } +.paper-clover .segments { display: flex; gap: 2px; } +.paper-clover .segment { flex: 0 1 0; min-width: 1px; display: grid; gap: 3px; container-type: inline-size; border-radius: 3px; } +/* A size that doesn't fit beside its name wraps onto a clipped second line. */ +.paper-clover .segment .label { + display: flex; + flex-wrap: wrap; + column-gap: 0.5ch; + height: 1lh; + overflow: hidden; + font-size: 11.5px; + line-height: 15px; + white-space: nowrap; + color: var(--text-2); +} +.paper-clover .segment .label > :first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.paper-clover .segment .mark { height: 10px; border-radius: 3px; transition: filter 150ms; } +.paper-clover .segment:is(:hover, :focus-visible) .mark { filter: brightness(1.2); } +@container (width < 30px) { .paper-clover .segment .label { visibility: hidden; } } + +.paper-clover .folders { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(52px, 1fr); gap: 4px; margin-top: 14px; overflow-x: auto; } +.paper-clover .folders .folder { + display: grid; + justify-items: center; + min-width: 0; + padding: 4px 4px 2px; + border-radius: 6px; + font-size: 12px; + line-height: 1.35; + font-variant-numeric: tabular-nums; + transition: background-color 150ms; +} +.paper-clover .folders :is(.name, .muted) { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.paper-clover .folders .folder[href]:hover { background: var(--hover); } +.paper-clover .folders .column { display: flex; align-items: flex-end; width: 100%; height: 48px; margin-bottom: 4px; border-bottom: 1px solid var(--axis); } +.paper-clover .folders .column > span { width: 100%; min-height: 2px; border-radius: 3px 3px 0 0; background: var(--other); transition: background-color 150ms; } +.paper-clover .folders .folder[href]:hover .column > span { background: var(--accent); } + +.paper-clover .processors table.data { font-size: 12.5px; } +.paper-clover .processors table.data :is(th, td) { padding: 3px 6px; } +.paper-clover .processors table.data :is(th, td):first-child { padding-left: 0; } +.paper-clover .processors table.data :is(th, td):last-child { padding-right: 0; } +.paper-clover .processors tbody tr:last-child td { border-bottom: 0; } +.paper-clover .processors td:first-child { white-space: nowrap; } +.paper-clover .processors .swatch { display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 2px; } +.paper-clover .processors .failed { color: color-mix(in srgb, var(--critical) 75%, var(--text)); } +.paper-clover .processors .status-label { justify-content: flex-end; gap: 4px; white-space: nowrap; } diff --git a/dashboard/web/pages/PaperClover.tsx b/dashboard/web/pages/PaperClover.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2a851d2df37ed115ddc3544c20284e27c54c6892 --- /dev/null +++ b/dashboard/web/pages/PaperClover.tsx @@ -0,0 +1,523 @@ +import RotateCw from "lucide-solid/icons/rotate-cw"; +import { createSignal, For, Index, onCleanup, type Resource, Show } from "solid-js"; +import { A } from "@solidjs/router"; +import { parseResponse } from "hono/client"; +import type { PaperCloverStats, ProgressNode } from "../types/paperClover.ts"; +import { api, queries, unconnected } from "../api.ts"; +import { Ago } from "../components/Ago.tsx"; +import { showTextDialog } from "../components/Dialog.tsx"; +import { lastGood, Loaded } from "../components/Loaded.tsx"; +import { type StatusKind, StatusLabel } from "../components/Status.tsx"; +import { TabBar } from "../components/TabBar.tsx"; +import { toast } from "../components/Toast.tsx"; +import { bytes, clock, count, datetime, duration, percent, plural, until } from "../format.ts"; +import "./PaperClover.css"; + +const SITE = "https://paperclover.net/file"; +/** The runtime side of the source of truth: its logs, restarts and resources. */ +const SERVICE = "/services/clover-source-of-truth"; +/** Lines a progress node lists before folding the rest into "[N more]". */ +const SHOWN = 6; + +const scan = (path: string | null) => parseResponse(api["paper-clover"].scan.$post({ json: { path } })); +const url = (base: string, path: string) => base + path.split("/").map(encodeURIComponent).join("/"); + +/** + * The name opens the file on paperclover.net; the folder opens in copyparty when `browse`, its page for the Published + * folder, is known. + */ +function FileName(props: { path: string; browse: string | null; tip?: string }) { + const cut = () => props.path.lastIndexOf("/"); + const folder = () => props.path.slice(0, cut()) || "/"; + return ( +
    + {props.path.slice(cut() + 1)} + {folder()}}> + {(browse) => {folder()}} + +
    + ); +} + +function health(s: PaperCloverStats, failing: number, blocked: number): [StatusKind, string] { + if (!s.indexer.enabled) return ["stopped", "indexer off"]; + if (s.lastSweep?.error) return ["down", "full scan failed"]; + if (failing) return ["degraded", `${plural(failing, "file")} failing`]; + if (blocked) return ["degraded", `${plural(blocked, "processor")} can't run`]; + if (!s.indexer.watching) return ["degraded", "not watching for changes"]; + if (s.lastSweep && !s.lastSweep.endedAt) return ["working", "scanning everything"]; + return ["healthy", "healthy"]; +} + +function Issues(props: { + stats: PaperCloverStats; + browse: string | null; + blocked: PaperCloverStats["processors"]; + busy: (path: string) => boolean; + rescan: (path: string | null) => Promise; +}) { + const title = (name: string) => props.stats.processors.find((p) => p.name === name)?.title ?? name; + const next = () => props.stats.indexer.nextSweepAt; + const [retryingAll, setRetryingAll] = createSignal(false); + const retryAll = async () => { + setRetryingAll(true); + try { + await Promise.all(props.stats.failures.map((file) => props.rescan(file.path))); + } finally { + setRetryingAll(false); + } + }; + + return ( +
    +
    + needs attention + + {(t) => failed files retry on their own {until(t())}} + + + 1}> + + +
    +
      + + {(error) => ( +
    • +
      full scan
      +
      {error().replace(/^Error: /, "")}
      + + props.rescan(null)} /> +
    • + )} +
      + +
    • +
      not watching for changes
      +
      New files wait for the next full scan{next() ? `, ${until(next()!)}` : ""}.
      +
    • +
      + + {(p) => ( +
    • +
      {p().title}
      +
      + Can't run on this machine. {plural(p().applicable - p().done, "file")} waiting. +
      +
    • + )} +
      + file.path)}> + {(path) => { + const [open, setOpen] = createSignal(false); + const file = () => props.stats.failures.find((f) => f.path === path); + const failures = () => file()?.errors ?? []; + return ( +
    • + + + f.at))} /> + props.rescan(path)} /> + +
      +

      made {file()?.made.map(title).join(", ") || "nothing yet"}

      +
      {failures().map((f) => `${title(f.processor)}: ${f.error}`).join("\n\n")}
      +
      +
      +
    • + ); + }} +
      +
    +
    + ); +} + +/** Spins while the request is out, then for as long as `busy` says the work is still going. */ +function RetryButton(props: { busy: boolean; run: () => Promise }) { + const [sending, setSending] = createSignal(false); + const click = async () => { + setSending(true); + await props.run().finally(() => setSending(false)); + }; + return ( + + ); +} + +/** A port of @clo/lib/progress's terminal renderer (`formatAnsi`): guides, bars, dim counts and "[N more]". */ +function Tree(props: { nodes: ProgressNode[]; browse: string | null; top?: boolean }) { + const [all, setAll] = createSignal(false); + const folded = () => (all() || props.nodes.length <= SHOWN + 1 ? 0 : props.nodes.length - SHOWN); + return ( +
      + + {(node) => { + const text = () => /^(.*?)( \(.*\))?$/.exec(node().text)!; + const age = () => Date.now() / 1000 - node().since; + const cut = () => node().path!.lastIndexOf("/"); + return ( +
    • +
      + + + + + + + [{percent(node().progress! * 100)}{node().eta ? `, ${duration(node().eta! - Date.now() / 1000)}` : ""}] + + + + [{node().count}] + + + {(path) => ( + + {path().slice(1, cut() + 1)}}> + {(browse) => ( + + {path().slice(1, cut() + 1)} + + )} + + {path().slice(cut() + 1)} + + )} + + {text()[1]} + {text()[2]} + = 60}> + {duration(age())} + +
      + + + {(log) => ( +
      + > + {log().level}: + {log().text} +
      + )} +
      +
    • + ); + }} +
      + +
    • +
      +
    + ); +} + +function Running(props: { activity: Resource; browse: string | null; retry: () => void }) { + return ( +
    + }> + {(nodes) => ( + Nothing running. Files show up here while they're indexed or processed.
    }> + + + )} +
    + + ); +} + +/** A processor's family is its name up to the first dash: "av1" for "av1-au". */ +const family = (name: string) => name.split("-")[0]!; + +/** + * Series colors for the families with output, hashed from the family name so they stay put as processors come and + * go; a family whose slot is taken probes to the next free one, in name order. + */ +function familyColors(processors: PaperCloverStats["processors"]) { + const slots = new Map(); + for (const name of [...new Set(processors.filter((p) => p.bytes).map((p) => family(p.name)))].sort()) { + let slot = [...name].reduce((hash, c) => Math.imul(hash ^ c.charCodeAt(0), 16777619), 2166136261) >>> 29; + while (slots.size < 8 && [...slots.values()].includes(slot)) slot = (slot + 1) % 8; + slots.set(name, slot); + } + return (processor: string) => { + const slot = slots.get(family(processor)); + return slot === undefined ? undefined : `var(--series-${slot + 1})`; + }; +} + +/** Published by type and derived by processor on one scale, then published by top-level folder. */ +function Archive(props: { stats: PaperCloverStats; browse: string | null }) { + const scale = () => Math.max(props.stats.bytes, props.stats.derived.bytes); + const types = () => { + const sorted = props.stats.types.toSorted((a, b) => b.bytes - a.bytes); + const rest = sorted.slice(6); + const other = props.stats.bytes - sorted.slice(0, 6).reduce((sum, type) => sum + type.bytes, 0); + return [ + ...sorted.slice(0, 6).map((type, i) => ({ + label: type.ext || "no extension", + bytes: type.bytes, + tip: `${bytes(type.bytes)} in ${plural(type.files, "file")} (${percent((type.bytes / props.stats.bytes) * 100)})`, + color: `var(--series-${i + 1})`, + })), + ...(other > 0 + ? [{ + label: "other", + bytes: other, + tip: `${bytes(other)}: ` + rest.slice(0, 4).map((type) => `${type.ext} ${bytes(type.bytes)}`).join(", ") + + (rest.length > 4 ? `, ${rest.length - 4} more` : ""), + color: "var(--other)", + }] + : []), + ]; + }; + const derived = () => { + const color = familyColors(props.stats.processors); + const made = props.stats.processors.flatMap((p) => p.bytes !== null && p.outputs !== null && p.bytes > 0 + ? [{ ...p, bytes: p.bytes, outputs: p.outputs }] : []); + const total = (name: string) => made.reduce((sum, p) => sum + (family(p.name) === name ? p.bytes : 0), 0); + return made.toSorted((a, b) => total(family(b.name)) - total(family(a.name)) || b.bytes - a.bytes).map((p) => ({ + label: p.title.replace(/^encode /, ""), + bytes: p.bytes, + tip: `${p.title}: ${bytes(p.bytes)} in ${plural(p.outputs, "file")} (${percent((p.bytes / props.stats.derived.bytes) * 100)})`, + color: color(p.name)!, + })); + }; + const folders = () => props.stats.folders.toSorted((a, b) => a.name.localeCompare(b.name)); + const tallest = () => Math.max(...props.stats.folders.map((folder) => folder.bytes)); + + const Row = (row: { label: string; total: number; href?: string | null; tip: string; segments: ReturnType }) => ( + <> + + {bytes(row.total)} {row.label} + +
    + + {(segment) => ( +
    + {segment().label}{bytes(segment().bytes)} + +
    + )} +
    +
    + + ); + + return ( +
    +
    + + +
    + +
    + ); +} + +function Processors(props: { stats: PaperCloverStats }) { + const left = () => props.stats.processors.reduce((sum, p) => sum + p.applicable - p.done, 0); + const work = () => { + let total = 0; + for (const p of props.stats.processors) { + if (p.seconds === null) return null; + total += (p.applicable - p.done) * p.seconds; + } + return total; + }; + const color = () => familyColors(props.stats.processors); + return ( +
    +
    + processors + + {left() ? `${plural(left(), "job")} left` : "all caught up"} + +
    + + + + + {(p) => ( + + + + + + + )} + + +
    per fileoutput
    {p().title}{p().seconds === null ? "" : (p().seconds ?? 0) < 10 ? `${(p().seconds ?? 0).toFixed(1)}s` : duration(p().seconds ?? 0)}{p().bytes !== null && (p().bytes ?? 0) > 0 ? bytes(p().bytes ?? 0) : ""} + can't run}> + {p().done < p().applicable ? `${count(p().applicable - p().done)} left` : ""}}> + {count(p().failed)} failed + + +
    +
    + ); +} + +function Files(props: { stats: PaperCloverStats; browse: string | null }) { + const [largest, setLargest] = createSignal(false); + const files = (): { path: string; size: number; at?: number; duration?: number; dimensions?: string }[] => + largest() ? props.stats.largest : props.stats.recent; + return ( +
    +
    + + + + +
    + No files indexed yet.
    }> +
      + + {(file) => ( +
    • + +
      +
      {bytes(file().size)}
      +
      + {file().at !== undefined ? : file().duration ? duration(file().duration!) : ""} +
      +
      +
    • + )} +
      +
    + + + ); +} + +export function PaperClover() { + const [stats, { refetch }] = queries.paperClover.use(); + const [activity, activityControl] = queries.paperCloverActivity.use(); + const timers = [ + setInterval(() => unconnected(stats.error) || refetch(), 5000), + setInterval(() => unconnected(activity.error) || activityControl.refetch(), 2000), + ]; + onCleanup(() => timers.forEach(clearInterval)); + + const current = lastGood(stats); + const now = lastGood(activity); + const blocked = () => current()?.processors.filter((p) => !p.runnable && p.done < p.applicable) ?? []; + const busy = (target: string) => { + const covers = (node: ProgressNode): boolean => + Boolean(node.path && (target === node.path || target.startsWith(node.path + "/"))) || node.children.some(covers); + return now()?.some(covers) ?? false; + }; + + const rescan = async (target: string | null) => { + await scan(target); + activityControl.refetch(); + refetch(); + }; + + const rescanDialog = () => showTextDialog({ + title: "Rescan", + description: "Leave it empty to rescan everything.", + label: "folder or file", + placeholder: "/2026/friends…", + validateInput: () => true, + confirmLabel: "rescan", + onConfirm: async (value) => { + const path = value.trim().replace(/^\/+|\/+$/g, ""); + await rescan(path ? `/${path}` : null); + toast(path ? `Rescanning /${path}` : "Rescanning everything"); + }, + }); + + const off = () => current()?.indexer.enabled === false; + + return ( +
    +
    +

    paper clover

    +
    + {(width) => }}> + {(s) => { + const state = () => health(s(), s().failures.length, blocked().length); + const sweep = () => s().lastSweep; + return ( + <> + {state()[1]} + + + {(last) => (last().endedAt ? <>scanned + : <>scanning since )} + + + + + ); + }} + +
    +
    + +
    +
    +
    +
    + }> + {(s) => ( + <> + + + + + +
    + + +
    + + )} + +
    + ); +} diff --git a/dashboard/web/pages/Seedbox.css b/dashboard/web/pages/Seedbox.css new file mode 100644 index 0000000000000000000000000000000000000000..954c634662bc5b8f0df36a07ee6bf83ed31dc4bf --- /dev/null +++ b/dashboard/web/pages/Seedbox.css @@ -0,0 +1,80 @@ +.quick-add { position: relative; flex: 1; display: flex; justify-content: flex-end; align-items: center; } +.quick-add .search { flex: 0 1 280px; min-width: 110px; } +.quick-add .reveal button { margin-left: 6px; } +/* Floats below the field so an error doesn't push the page down. */ +.quick-add .error { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 2; + pointer-events: none; + width: max-content; + max-width: 320px; + padding: 3px 8px; + border-radius: 6px; + background: var(--raised); + font-size: 12px; +} +.quick-add .search[aria-invalid="true"] { border-color: color-mix(in srgb, var(--critical) 60%, var(--line)); } +.stat:is(.down, .up) { color: var(--text); font-weight: 600; } +.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)); } +.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)); } +.stat.down svg { color: var(--series-1); } +.stat.up svg { color: var(--series-2); } + +.torrent-filters { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; } +.torrent-filters .search { max-width: 220px; margin-left: auto; } + +.torrents.fill { overflow: auto; } +.torrents > table.data { table-layout: fixed; } +.torrents th.progress { width: 148px; } +.torrents th.size { width: 72px; } +.torrents th.speed { width: calc(92px + var(--gutter)); } +.torrents tr.torrent > td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.torrents td.name button { + display: block; + width: 100%; + border: 0; + padding: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} +.torrents tr.open td.name button { white-space: normal; overflow-wrap: anywhere; } +.torrents tr.torrent { cursor: pointer; } +.torrents tr.torrent:hover td { background: var(--hover); } +.torrents tr.torrent.open td { background: var(--accent-wash); border-bottom-color: transparent; } +.torrents tr.paused td.name { color: var(--text-2); } +.torrents tr.outside > td { opacity: 0.55; } +.torrents td.progress > div { display: grid; grid-template-columns: 40px 1fr; gap: 8px; align-items: center; font-size: 12px; } +.torrents td.progress .meter { min-width: 0; height: 5px; } +.torrents td.progress .status-label { grid-column: 2; min-width: 0; } +.torrents td.speed span { display: inline-flex; gap: 3px; align-items: center; } +.torrents td.speed .down svg { color: var(--series-1); } +.torrents td.speed .up svg { color: var(--series-2); } + +.torrents tr.expanded > td { background: var(--panel); padding: 8px 10px 10px; white-space: normal; } +.torrent-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.torrent-actions .queue { display: flex; gap: 4px; align-items: center; margin-left: 10px; } +.torrent-actions .queue span { color: var(--text-2); font-size: 12px; margin-right: 4px; } +.torrent-detail .facts { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 8px 0 4px; font-size: 12px; } +.torrent-detail .facts div { display: flex; gap: 6px; } +.torrent-detail .facts dt { color: var(--muted); } +.torrent-detail .facts dd { margin: 0; font-variant-numeric: tabular-nums; } +.torrent-detail .empty { padding: 12px 0; margin: 0; font-size: 12px; } +.torrent-detail table.files { table-layout: fixed; } +.torrent-detail table.files td { border-bottom-color: var(--line); padding: 4px 6px; } +.torrent-detail table.files td.name { overflow-wrap: anywhere; padding-left: 0; } +.torrent-detail table.files td:nth-child(2) { width: 72px; } +.torrent-detail table.files td.get { width: 48px; } +.torrent-detail .download { display: inline-grid; place-items: center; width: 26px; height: 18px; border-radius: 5px; color: var(--accent); } +.torrent-detail .download:hover { background: var(--accent-wash); } +.torrent-detail .file-skeleton { height: 22px; margin-top: 6px; } +.torrents .row-skeleton { height: 16px; margin: 11px var(--gutter); } + +.torrent-name { overflow-wrap: anywhere; } +.torrent-actions a.button { gap: 5px; } diff --git a/dashboard/web/pages/Seedbox.tsx b/dashboard/web/pages/Seedbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60170fe77ad24ab930fe894bbe222ccd1ee88b6b --- /dev/null +++ b/dashboard/web/pages/Seedbox.tsx @@ -0,0 +1,423 @@ +import { useSearchParams } from "@solidjs/router"; +import { Checkbox } from "../components/Checkbox.tsx"; +import { type InferResponseType, parseResponse } from "hono/client"; +import ArrowDown from "lucide-solid/icons/arrow-down"; +import ArrowDownToLine from "lucide-solid/icons/arrow-down-to-line"; +import ArrowUp from "lucide-solid/icons/arrow-up"; +import ArrowUpToLine from "lucide-solid/icons/arrow-up-to-line"; +import ChevronDown from "lucide-solid/icons/chevron-down"; +import ChevronUp from "lucide-solid/icons/chevron-up"; +import Download from "lucide-solid/icons/download"; +import ArrowUpRight from "lucide-solid/icons/arrow-up-right"; +import HardDrive from "lucide-solid/icons/hard-drive"; +import Scale from "lucide-solid/icons/scale"; +import { createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js"; +import { UNKNOWN_ETA } from "../types/model.ts"; +import type { Torrent, TorrentState, TORRENT_ACTIONS } from "../types/seedbox.ts"; +import { api, queries, reason, unconnected } from "../api.ts"; +import { Ago } from "../components/Ago.tsx"; +import { Copy } from "../components/Copy.tsx"; +import { showConfirmDialog } from "../components/Dialog.tsx"; +import { ListPage } from "../components/ListPage.tsx"; +import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; +import { Meter } from "../components/Meter.tsx"; +import { OpenApp } from "../components/OpenApp.tsx"; +import { Reveal } from "../components/Reveal.tsx"; +import { type Sort, SortHeader } from "../components/SortHeader.tsx"; +import { type StatusKind, StatusLabel } from "../components/Status.tsx"; +import { TabBar } from "../components/TabBar.tsx"; +import { TimeChart } from "../components/TimeChart.tsx"; +import { toast } from "../components/Toast.tsx"; +import { bytes, count, duration, percent, rate } from "../format.ts"; +import "./Seedbox.css"; + +type Listed = InferResponseType["torrents"][number]; +type Group = "downloading" | "seeding" | "paused" | "error"; + +const STATES: Record = { + downloading: ["working", "downloading", "downloading"], + forcedDL: ["working", "downloading", "downloading"], + metaDL: ["working", "starting", "downloading"], + forcedMetaDL: ["working", "starting", "downloading"], + allocating: ["working", "starting", "downloading"], + checkingDL: ["working", "checking", "downloading"], + checkingResumeData: ["working", "checking", "downloading"], + moving: ["working", "moving", "downloading"], + stalledDL: ["degraded", "stalled", "downloading"], + queuedDL: ["stopped", "queued", "downloading"], + uploading: ["healthy", "seeding", "seeding"], + forcedUP: ["healthy", "seeding", "seeding"], + stalledUP: ["healthy", "seeding", "seeding"], + queuedUP: ["stopped", "queued", "seeding"], + checkingUP: ["working", "checking", "seeding"], + stoppedDL: ["stopped", "paused", "paused"], + stoppedUP: ["stopped", "paused", "paused"], + error: ["down", "error", "error"], + missingFiles: ["down", "missing files", "error"], + unknown: ["down", "unknown", "error"], +}; + +const GROUPS: [Group | "", string][] = [ + ["", "all"], ["downloading", "downloading"], ["seeding", "seeding"], ["paused", "paused"], ["error", "errors"], +]; + +const COLUMNS = { + name: ["name", (t) => t.name.toLowerCase()], + progress: ["progress", (t) => t.progress], + size: ["size", (t) => t.size, true], + speed: ["speed", (t) => t.dlspeed + t.upspeed, true], +} satisfies Record string | number, true?]>; + +type Action = (typeof TORRENT_ACTIONS)[number]; + +const PRIORITY: [Action, string, typeof ChevronUp][] = [ + ["topPrio", "move to top", ArrowUpToLine], ["increasePrio", "move up", ChevronUp], + ["decreasePrio", "move down", ChevronDown], ["bottomPrio", "move to bottom", ArrowDownToLine], +]; + +function Details(props: { torrent: Listed; onChange: () => void }) { + const hash = () => props.torrent.hash; + const [files, { refetch }] = createResource(hash, (hash) => + parseResponse(api.seedbox.torrents[":hash"].files.$get({ param: { hash } }))); + const timer = setInterval(refetch, 5000); + onCleanup(() => clearInterval(timer)); + const [pending, setPending] = createSignal(null); + const paused = () => STATES[props.torrent.state][2] === "paused"; + const act = async (action: Action) => { + if (pending()) return; + setPending(action); + try { + await parseResponse(api.seedbox.torrents[":hash"][":action"].$post({ param: { hash: hash(), action } })); + props.onChange(); + } catch (failure) { + toast(`Couldn't ${action === "stop" ? "pause" : action === "start" ? "resume" : "reorder"} the torrent. ${reason(failure)}`); + } finally { + setPending(null); + } + }; + const remove = () => { + const [files, setFiles] = createSignal(false); + showConfirmDialog({ + title: "Remove torrent", + description: () => ( + <>{props.torrent.name} stops seeding and leaves the list.{" "} + {files() ? "Its files are deleted from disk." : "Its files stay on disk."} + ), + body: () => ( + delete files too + ), + confirmLabel: "remove", + destructive: true, + onConfirm: (form) => parseResponse(api.seedbox.torrents[":hash"].$delete({ + param: { hash: hash() }, query: { files: form.has("files") ? "1" : "0" }, + })).then(props.onChange), + }); + }; + return ( +
    +
    + + 0}> +
    + #{props.torrent.priority} in queue + + {([action, label, Icon]) => ( + + )} + +
    +
    + + + {(href) => open folder} + + + {(href) => download zip} + + +
    +
    +
    added
    +
    category
    {props.torrent.category}
    +
    ratio
    {props.torrent.ratio.toFixed(2)}
    +
    seeders
    {count(props.torrent.num_seeds)} of {count(props.torrent.num_complete)}
    +
    leechers
    {count(props.torrent.num_leechs)} of {count(props.torrent.num_incomplete)}
    +
    up
    {rate(props.torrent.upspeed)}
    +
    magnet
    {hash().slice(0, 8)}
    +
    + {() =>
    }}> + {(files) => ( + No files yet. They appear once the first peer connects.

    }> + + + + {(file) => ( + + + + + + )} + + +
    {file.name.replace(`${props.torrent.name}/`, "")}{bytes(file.size)} + + {percent(file.progress * 100)} + + }> + + {(href) => ( + + )} + + +
    +
    + )} + +
    + ); +} + +function QuickAdd(props: { onAdded: () => void }) { + const [url, setUrl] = createSignal(""); + const [error, setError] = createSignal(""); + const [busy, setBusy] = createSignal(false); + return ( +
    { + event.preventDefault(); + if (busy() || !url().trim()) return; + setBusy(true); + setError(""); + try { + await parseResponse(api.seedbox.torrents.$post({ json: { url: url() } })); + setUrl(""); + props.onAdded(); + } catch (failure) { + setError(reason(failure)); + } finally { + setBusy(false); + } + }}> + { + setUrl(event.currentTarget.value); + setError(""); + }} /> + + {error()} +
    + ); +} + +export function Seedbox() { + const [data, { refetch }] = queries.seedbox.use(); + const seedbox = lastGood(data); + const [history, { refetch: refetchHistory }] = queries.seedboxHistory.use(); + const lastHistory = lastGood(history); + const apps = lastGood(queries.launcher.use()[0]); + const timers = [ + setInterval(() => unconnected(data.error) || refetch(), 3000), + setInterval(() => unconnected(history.error) || refetchHistory(), 5000), + ]; + onCleanup(() => timers.forEach(clearInterval)); + + const [params, setParams] = useSearchParams<{ state?: string; open?: string }>(); + const group = () => GROUPS.find(([value]) => value && value === params.state)?.[0] ?? ""; + const [query, setQuery] = createSignal(""); + const [sort, setSort] = createSignal>({ key: "progress", desc: false }); + /** After a filter change, brings the open torrent back into view wherever it sorted to. */ + const refilter = (change: () => void) => { + change(); + requestAnimationFrame(() => document.querySelector(".torrents tr.torrent.open")?.scrollIntoView({ block: "nearest" })); + }; + + const [hover, setHover] = createSignal(null); + /** The rate at the hovered point of the chart, or `now` when the pointer is elsewhere. */ + const at = (series: number, now: number) => { + const index = hover(); + return index === null ? now : lastHistory()?.[series]?.v[index] ?? now; + }; + + return ( + +

    seedbox

    + app.id === "qbittorrent")}>{(app) => } +
    + {(width) => }}> + {(latest) => { + const state = () => latest().state; + const vpn = (): [StatusKind, string, string] => { + const via = state().current_network_interface || "any interface"; + if (state().connection_status === "disconnected") return ["down", "vpn offline", "no connection"]; + if (state().connection_status === "firewalled") { + return ["degraded", "vpn firewalled", `peers can't reach port ${state().listen_port} on ${via}`]; + } + return ["healthy", "vpn", `connected via ${via}`]; + }; + return ( + <> + + {rate(at(0, state().dl_info_speed))} + + + {rate(at(1, state().up_info_speed))} + + + {state().global_ratio} + + + {state().free_space_on_disk < 0 ? "–" : bytes(state().free_space_on_disk)} + + {vpn()[1]} + + ); + }} + +
    + +
    + } + summary={ + // Both come from qBittorrent, so the list alone reports it missing. + + }> + {(series) => { + onCleanup(() => setHover(null)); + return
    ; + }} +
    +
    + }> + +
    +
    + +
    + + }> + {(latest) => { + const torrents = () => latest().torrents; + const byHash = createMemo(() => new Map(torrents().map((t) => [t.hash, t]))); + const tally = (group: Group | "") => torrents().filter((t) => !group || STATES[t.state][2] === group).length; + const matches = (t: Listed) => + (!group() || STATES[t.state][2] === group()) && t.name.toLowerCase().includes(query().toLowerCase()); + const shown = createMemo(() => { + const key = COLUMNS[sort().key][1]; + const sign = sort().desc ? -1 : 1; + return torrents() + // The open torrent stays, dimmed, when a filter or an action leaves it out. + .filter((t) => t.hash === params.open || matches(t)) + .sort((a, b) => (key(a) < key(b) ? -sign : key(a) > key(b) ? sign : b.added_on - a.added_on)) + .map((t) => t.hash); + }); + return ( + <> +
    + + + {([value, label]) => ( + + )} + + + refilter(() => setQuery(event.currentTarget.value))} /> +
    +
    + + {torrents().length ? "No torrents match." : "No torrents yet. Paste a magnet link above to add one."} +
    + }> + + + + + {(key) => ( + + )} + + + + + + {(hash) => ( + + {(torrent) => { + const info = () => STATES[torrent().state]; + return ( + <> + setParams({ open: params.open === hash ? undefined : hash }, { replace: true })}> + + + + + + + + + + + + ); + }} + + )} + + +
    + + +
    + + + {info()[2] === "downloading" && torrent().eta < UNKNOWN_ETA ? `${duration(torrent().eta)} left` : info()[1]} + +
    +
    {bytes(torrent().size)} + + {rate(torrent().upspeed)} + + }> + {rate(torrent().dlspeed)} + +
    +
    +
    + +
    + + ); + }} +
    + + ); +} diff --git a/dashboard/web/pages/Service.css b/dashboard/web/pages/Service.css new file mode 100644 index 0000000000000000000000000000000000000000..2be991f5ded5cbe2ba7200864be91a51cca81fa1 --- /dev/null +++ b/dashboard/web/pages/Service.css @@ -0,0 +1,215 @@ +.service-page .page-head { margin-bottom: 10px; } +.service-page .page-head { flex-wrap: wrap; row-gap: 8px; } +.service-page .page-head h1 { white-space: nowrap; } +.stat.image { padding: 0 3px; } +.stat.image .copy { display: inline-flex; align-items: center; gap: 5px; height: 20px; margin: 0; padding: 0 7px 0 5px; border-radius: 99px; } +.stat.image .version { max-width: 12ch; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); font-size: 11px; } + +.problem { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + margin-bottom: 10px; + padding: 6px 12px; + border-radius: var(--radius); + background: color-mix(in srgb, var(--warning) 10%, transparent); + font-size: 13px; +} +.problem .status-label { color: var(--text); font-weight: 600; align-self: center; } +.problem .output { flex: 1; min-width: 0; color: var(--text-2); } +.problem a { color: var(--accent); text-decoration: underline dotted; text-underline-offset: 3px; } + +.service-strips { padding-bottom: 2px; } +.service-strips .strip-head .now { font-size: 15px; } + +.tab-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; } +.tab-row .search-box { position: relative; flex: 1 1 140px; min-width: 0; max-width: 240px; margin-left: auto; } +.search-hints { + position: absolute; + top: calc(100% + 6px); + right: -2px; + z-index: 5; + display: grid; + min-width: 300px; + padding: 4px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: 0 8px 28px #0005; + animation: menu-in 200ms var(--ease-out); +} +.search-hints[hidden] { display: none; } +.search-hints button { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 5px 8px; + border: 0; + border-radius: calc(var(--radius) - 2px); + background: none; + color: var(--text); + font: 12px var(--mono); + text-align: left; + cursor: pointer; +} +.search-hints button:hover { background: var(--hover); } +.search-hints span { color: var(--muted); font-family: var(--sans); } +.liveness { flex: none; font-size: 12px; color: var(--text-2); } +.liveness.live .status { animation: breathe 2.4s ease-in-out infinite; } +@keyframes breathe { 50% { opacity: 0.35; } } +@media (prefers-reduced-motion: reduce) { .liveness.live .status { animation: none; } } + +.logs-tab { display: flex; flex-direction: column; } +.logs-tab .empty { font-family: var(--sans); } +.log-view .log .skeleton { margin: 4px 0; } + +.empty p { margin: 0 0 4px; } +.empty strong { color: var(--text-2); font-weight: 600; } + +.traces.fill { display: flex; flex-direction: column; min-height: 320px; } +.trace-list { flex: none; max-height: 185px; overflow: auto; border-block: 1px solid var(--line); } +.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); } +.trace-list table.data { table-layout: fixed; } +.trace-list table.data td { padding-block: 3px; } +.trace-list tr:last-child td { border-bottom: 0; } +.trace-list :is(th, td):first-child { padding-left: var(--gutter); width: calc(76px + var(--gutter)); white-space: nowrap; } +.trace-list td.when { color: var(--text-2); } +.trace-list .code { width: 72px; } +.trace-list .spans { width: 56px; color: var(--text-2); } +.trace-list :is(th, td):last-child { padding-right: var(--gutter); width: calc(128px + var(--gutter)); } +.trace-list td.code { color: var(--text-2); font: 12px var(--mono); } +.trace-list td.code span { display: inline-flex; align-items: center; gap: 6px; } +.trace-list td.code.client { color: color-mix(in srgb, var(--warning) 75%, var(--text)); } +.trace-list .log-edge { border-top: 1px solid var(--line); } +.trace-list tr { cursor: pointer; } +.trace-list tr:hover td { background: var(--hover); } +.trace-list tr.selected td { background: var(--accent-wash); } +.trace-list td.request button { + display: block; + width: 100%; + border: 0; + padding: 0; + background: none; + color: inherit; + font: 12px var(--mono); + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} +.trace-list div.duration { display: flex; justify-content: flex-end; align-items: center; gap: 8px; } +.trace-list .track { flex: none; width: 56px; height: 6px; border-radius: 3px; background: var(--raised); overflow: hidden; } +.trace-list .track .bar { display: block; height: 100%; background: var(--series-1); } +.traces .row-skeleton { height: 14px; margin: 9px var(--gutter); } +.traces .stale-note { margin-inline: var(--gutter); } +.flame-skeleton { flex: 1; margin: 36px var(--gutter) 12px; } + +.flame { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.flame-bar { flex: none; display: flex; align-items: center; gap: 12px; padding: 6px var(--gutter) 4px; font-size: 12px; } +.flame-path { flex: 1; min-width: 0; display: flex; align-items: center; gap: 2px; color: var(--muted); } +.flame-path button { + min-width: 0; + max-width: max-content; + flex: 0 1 auto; + padding: 2px 5px; + border: 0; + border-radius: 4px; + background: none; + color: var(--text-2); + font: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} +.flame-path button:hover { background: var(--hover); color: var(--text); } +.flame-path button[aria-current] { flex-shrink: 0; max-width: 50%; color: var(--text); font-weight: 600; } +.flame-bar .legend { display: flex; gap: 10px; } +.flame-bar .legend a { display: inline-flex; align-items: center; gap: 5px; color: var(--text-2); } +.flame-bar .legend a::before { content: ""; width: 8px; height: 8px; border-radius: 2px; background: var(--color); } +.flame-bar .legend a:hover { color: var(--text); } +.flame-bar .hint { flex: none; color: var(--muted); } +.flame-bar kbd { margin-right: 3px; } +.flame-canvas { position: relative; flex: 1; min-height: 120px; margin-inline: var(--gutter); } +.flame-canvas canvas { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; } +.flame-canvas canvas:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; } +.flame-hover { + position: absolute; + z-index: 2; + max-width: 340px; + padding: 6px 10px; + translate: 14px 16px; + border-radius: 6px; + background: var(--text); + color: var(--page); + font-size: 12px; + line-height: 1.4; + pointer-events: none; + box-shadow: var(--shadow); +} +.flame-hover.left { translate: calc(-100% - 14px) 16px; } +.flame-hover.up { translate: 14px calc(-100% - 10px); } +.flame-hover.left.up { translate: calc(-100% - 14px) calc(-100% - 10px); } +.flame-hover strong { display: block; overflow-wrap: anywhere; } +.flame-hover dl { display: grid; grid-template-columns: max-content auto; gap: 0 10px; margin: 2px 0 0; } +.flame-hover dt { opacity: 0.65; } +.flame-hover dd { margin: 0; font-variant-numeric: tabular-nums; } +.flame-hover .error { margin: 2px 0 0; color: color-mix(in srgb, var(--critical) 70%, var(--page)); } +.span-facts { + flex: none; + display: flex; + flex-wrap: wrap; + gap: 2px 18px; + max-height: 72px; + overflow: auto; + margin: 0; + padding: 8px var(--gutter) 10px; + border-top: 1px solid var(--line); + font-size: 12px; +} +.span-facts > div { display: flex; gap: 6px; min-width: 0; } +.span-facts dt { color: var(--muted); } +.span-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +.span-facts a { color: var(--accent); text-decoration: underline dotted; text-underline-offset: 3px; } + +.stat.restart { color: var(--text-2); } +.stat.restart a { display: inline-flex; align-items: center; gap: 5px; } +.stat.restart a:hover { color: var(--text); } +.stat.restart .button { margin-left: 2px; background: none; color: var(--muted); } +.stat.restart .button:hover { color: var(--text); } + +.definition { padding-bottom: 8px; font-size: 13px; } +.definition dl { margin: 0; } +.definition h2 { margin: 28px 0 10px; } +.definition h2 .muted { font-weight: 400; font-size: 12px; } +.definition .kv { max-width: 760px; } +.definition .kv a.mono, .definition .kv > dd > div > a { color: var(--accent); text-decoration: underline dotted; text-underline-offset: 3px; } +.definition .chips { align-items: center; } +.definition .chip .status-label { gap: 5px; color: inherit; } +.definition .chip.mono { display: inline-flex; align-items: center; gap: 4px; font: 11px/20px var(--mono); } +.definition .chip svg { color: var(--muted); } +.definition .tasks { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 440px), 1fr)); gap: 12px; align-items: start; } +.definition .task { display: grid; gap: 10px; } +.definition .task-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; } +.definition .task-head h3 { margin: 0; font-size: 13px; font-weight: 650; } +.definition .task-head .chip.main { background: var(--accent-wash); color: var(--accent); } +.definition .task .state { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 12px; } +.definition .task .about { margin: -4px 0 0; color: var(--text-2); } +.definition .task .kv { gap: 6px 14px; } +.definition .task .copy { overflow-wrap: anywhere; } +.definition ul.plain { margin: 0; padding: 0; list-style: none; } +.definition table.mounts { border-collapse: collapse; } +.definition table.mounts td { padding: 1px 12px 1px 0; vertical-align: baseline; overflow-wrap: anywhere; } +.definition table.mounts td:last-child { padding-right: 0; white-space: nowrap; } +.definition pre.source { + margin: 0; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + font: 12px/1.55 var(--mono); + overflow-x: auto; + tab-size: 2; +} diff --git a/dashboard/web/pages/Service.definition.tsx b/dashboard/web/pages/Service.definition.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d9ea158d87696bbd435032e7e69a8824c445c8b3 --- /dev/null +++ b/dashboard/web/pages/Service.definition.tsx @@ -0,0 +1,305 @@ +import { A } from "@solidjs/router"; +import { parseResponse } from "hono/client"; +import LockKeyhole from "lucide-solid/icons/lock-keyhole"; +import { For, Show } from "solid-js"; +import type { Container, ServiceDetail, ServiceLink, TaskDefinition } from "../types/model.ts"; +import { api, queries, query } from "../api.ts"; +import { Ago } from "../components/Ago.tsx"; +import { Copy } from "../components/Copy.tsx"; +import { Loaded } from "../components/Loaded.tsx"; +import { StatusLabel } from "../components/Status.tsx"; +import { ago, bytes, cores, duration, plural } from "../format.ts"; +import { stream } from "../live.ts"; + +/** Null links come from a source the host doesn't expose yet. */ +function Links(props: { label: string; links: ServiceLink[] | null }) { + return ( + <> +
    {props.label}
    + –}> + {(links) => ( +
    + nothing}> + {(link) => { + const health = () => stream.latest()?.services[link.id]?.health ?? link.health; + return ( + + {link.name} + + ); + }} + +
    + )} +
    + + ); +} + +/** When a task runs, as a chip label and a sentence. */ +function phase(task: TaskDefinition, service: ServiceDetail) { + if (!task.hook) return { label: "main", about: null }; + if (task.sidecar) { + return { + label: "sidecar", + about: `Starts ${task.hook === "prestart" ? "before" : "after"} the main tasks and keeps running beside them.`, + }; + } + if (task.hook === "prestart") { + // A wait task is named for what it waits on: wait--. + const waited = service.requirements?.find((link) => task.name.startsWith(`wait-${link.id}-`)); + return { + label: "runs first", + about: waited + ? `Waits for ${waited.name} to answer, then exits. The main tasks start once it has.` + : "Runs once and exits before the main tasks start.", + }; + } + return task.hook === "poststart" + ? { label: "after start", about: "Runs once after the main tasks start." } + : { label: "on stop", about: "Runs once after the main tasks stop." }; +} + +function State(props: { container: Container | undefined; hook: boolean }) { + return ( + not in the current allocation}> + {(container) => ( + + {container().state === "running" + ? running + : container().state === "pending" + ? starting + : {props.hook ? "finished" : "stopped"}} + {(t) => started } + + {(n) => {plural(n(), "restart")}} + + + )} + + ); +} + +function Task(props: { task: TaskDefinition; service: ServiceDetail }) { + const task = () => props.task; + const about = () => phase(task(), props.service); + const container = () => props.service.containers.find((item) => item.name === task().name); + const list = (label: string, items: string[]) => ( + +
    {label}
    +
    {items.join(", ")}
    +
    + ); + return ( +
    +
    +

    {task().name}

    + {about().label} + + +
    + {(text) =>

    {text()}

    }
    +
    +
    image
    +
    + –}>{(image) => } +
    +
    runs as
    +
    {task().user ?? the image's user}
    +
    reserves
    +
    + {plural(Number(cores(task().cpu)), "core")}, {bytes(task().memory)} + {(max) => (up to {bytes(max())})} +
    + +
    ports
    +
    + +
      + + {(port) => ( +
    • + {port.container ?? "–"} as {port.label} + + , host port {port.host ?? "picked at start"}{port.network === "loopback" ? ", this machine only" : ""} + +
    • + )} +
      +
    +
    +
    +
    + +
    mounts
    +
    + + + + {(mount) => ( + + + + + + )} + + +
    {mount.target}{mount.source}{mount.readOnly ? "read-only" : ""}
    +
    +
    + {list("tmpfs", task().tmpfs)} + {list("devices", task().devices)} + {list("capabilities", task().capabilities)} + {list("extra hosts", task().extraHosts)} + +
    env
    +
    + + {(env) => ( + + + {env.name} + + )} + +
    +
    +
    +
    + ); +} + +const definitions = query("definition", (id: string) => parseResponse(api.services[":id"].definition.$get({ param: { id } }))); + +/** What the service runs right now, read from its Nomad job and the release's service file. */ +export function Definition(props: { service: ServiceDetail }) { + const [definition, { refetch }] = definitions.use(() => props.service.id); + const [deploys] = queries.deploys.use(); + /** The newest deploy that changed this service, else the first, which deployed everything. */ + const deploy = () => { + const history = deploys()?.history; + return history?.find((entry) => entry.changed?.includes(props.service.id)) ?? history?.at(-1); + }; + const release = () => (props.service.release ?? deploy()?.release)?.slice(0, 8); + return ( + +
    +
    +
    + }> + {(loaded) => { + const groups = () => loaded().groups; + const services = () => groups().flatMap((group) => group.services); + const routed = () => services().filter((service) => service.hostnames.length); + const checked = () => services().flatMap((service) => service.check ?? []); + return ( +
    +
    +
    address
    +
    + none, only other services reach it}> + {(service) => ( +
    + + {(host, i) => <>{i() ? ", " : ""}{host}} + + + {" "}{service.authRole ? `sign-in required, ${service.authRole} group` : "no sign-in gate"} + +
    + )} +
    +
    +
    release
    +
    + –}> + {release()} + + + {" "}deployed {ago(props.service.deployedAt)},{" "} + {props.service.rollout === "simple" ? "updates with a brief outage" : "updates without downtime"} + +
    + + + +
    data
    +
    + + {(dataset) => ( +
    + {dataset.mountpoint} + {bytes(dataset.used)}, {bytes(dataset.snapshots)} in snapshots +
    + )} +
    +
    +
    + +
    health check
    +
    + + {(check) => ( +
    + {check.type === "http" ? <>GET {check.path} : check.type.toUpperCase()} + + {check.task ? ` on ${check.task}` : ""} every {duration(check.interval)}, gives up after {duration(check.timeout)} + + + {(after) => ( +
    + {plural(after().failures, "failure")} in a row restart it, once it's been up {duration(after().grace)} +
    + )} +
    +
    + )} +
    +
    +
    + + {(group) => ( + <> +
    {groups().length > 1 ? `${group.name} restarts` : "restarts"}
    +
    + up to {group.restart.attempts} every {duration(group.restart.interval)}, {duration(group.restart.delay)} apart + , then {group.restart.fail ? "gives up" : "waits and tries again"} +
    + + )} +
    +
    + + + {(group) => ( + <> +

    + tasks + 1 || group.count > 1}> + {group.name}, {plural(group.count, "copy", "copies")} + +

    +
    + Number(!!a.hook) - Number(!!b.hook))}> + {(task) => } + +
    + + )} +
    + +

    + service file + service/{props.service.id}/service.pkl{release() ? ` in release ${release()}` : ""} +

    + This release has no service file for {props.service.name}.

    }> + {(source) =>
    {source()}
    } +
    +
    + ); + }} + + ); +} diff --git a/dashboard/web/pages/Service.flame.tsx b/dashboard/web/pages/Service.flame.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b649519e8dfce9166dba9ea2c0f1edf75551ad1d --- /dev/null +++ b/dashboard/web/pages/Service.flame.tsx @@ -0,0 +1,481 @@ +import { A } from "@solidjs/router"; +import ChevronRight from "lucide-solid/icons/chevron-right"; +import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from "solid-js"; +import type { Span, Trace } from "../types/model.ts"; +import { Copy } from "../components/Copy.tsx"; +import { percent } from "../format.ts"; + +/** Milliseconds under a second, else seconds; `seconds` may be a span's length or an offset into a trace. */ +export const ms = (seconds: number) => { + const value = seconds * 1000; + if (value >= 1000) return `${seconds.toFixed(seconds < 10 ? 2 : 1)} s`; + if (value >= 10 || value === 0) return `${Math.round(value)} ms`; + return `${value.toFixed(value < 1 ? 2 : 1)} ms`; +}; + +const ROW = 20; +const AXIS = 20; +/** Narrower bars go unlabelled. */ +const LABEL = 28; +/** How much of a service's color each shade mixes into the page. */ +const SHADES = [0.36, 0.48, 0.6, 0.72]; + +interface Bar { + span: Span; + /** Seconds from the trace's start. */ + from: number; + to: number; + /** Time not covered by any child. */ + self: number; + row: number; + service: number; + /** Spans sharing a name's first word ("index", "tokenize") share a shade of their service's color. */ + shade: number; + parent: Bar | undefined; + /** By start. */ + children: Bar[]; +} + +/** + * Bars in rows below their parents. Siblings that overlap in time, like concurrent calls, take separate lanes, and + * each keeps its whole subtree as one block under it so no bar ever sits beside a stranger's children. + */ +function layout(trace: Trace) { + const t0 = trace.spans[0]!.start; + const byId = new Map(); + const services: string[] = []; + const bars = trace.spans.map((span) => { + const parent = span.parent === null ? undefined : byId.get(span.parent); + if (!services.includes(span.service)) services.push(span.service); + const from = span.start - t0; + const word = span.name.split(" ")[0]!; + let shade = 0; + for (const char of word) shade = (shade * 31 + char.charCodeAt(0)) % SHADES.length; + const bar: Bar = { + span, from, to: from + span.duration, self: span.duration, row: 0, service: services.indexOf(span.service), shade, parent, + children: [], + }; + byId.set(span.id, bar); + parent?.children.push(bar); + return bar; + }); + const stack = (group: Bar[]) => { + group.sort((a, b) => a.from - b.from); + const lanes: [number, number][][] = []; + let height = 0; + let end = 0; + for (const bar of group) { + const below = stack(bar.children); + const rows = 1 + below.height; + const until = Math.max(bar.to, below.end); + let lane = 0; + while (lanes.slice(lane, lane + rows).some((row) => row.some(([a, b]) => a < until && bar.from < b))) lane++; + for (let i = lane; i < lane + rows; i++) (lanes[i] ??= []).push([bar.from, until]); + bar.row = lane; + height = Math.max(height, lane + rows); + end = Math.max(end, until); + } + return { height, end }; + }; + const { height, end } = stack(bars.filter((bar) => !bar.parent)); + const rows: Bar[][] = Array.from({ length: height }, () => []); + for (const bar of bars) { + if (bar.parent) bar.row += bar.parent.row + 1; + rows[bar.row]!.push(bar); + let reach = bar.from; + for (const child of bar.children) { + const to = Math.min(child.to, bar.to); + bar.self -= Math.max(0, to - Math.max(child.from, reach)); + reach = Math.max(reach, to); + } + } + for (const row of rows) row.sort((a, b) => a.from - b.from); + return { bars, rows, end, services }; +} + +/** A step of 1, 2 or 5 times a power of ten, at least `min`. */ +function niceStep(min: number) { + const power = 10 ** Math.floor(Math.log10(min)); + return [1, 2, 5, 10].map((k) => k * power).find((step) => step >= min)!; +} + +const token = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +/** `a` over `b` at `amount`, both `#rrggbb`. */ +function mix(a: string, b: string, amount: number) { + const channel = (hex: string, i: number) => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16); + return `rgb(${[0, 1, 2].map((i) => Math.round(channel(a, i) * amount + channel(b, i) * (1 - amount))).join(" ")})`; +} + +function theme() { + const page = token("--page"); + const fills = (color: string) => + ({ shades: SHADES.map((amount) => mix(color, page, amount)), hot: mix(color, page, 0.9), faint: mix(color, page, 0.2) }); + return { + font: token("--sans"), + text: token("--text"), + muted: token("--muted"), + line: token("--line"), + series: Array.from({ length: 8 }, (_, i) => fills(token(`--series-${i + 1}`))), + failed: fills(token("--critical")), + }; +} + +/** + * A flame chart of one trace: time runs left to right and each span sits under its parent. Clicking a span zooms to + * it and shows its attributes; Esc zooms back out. Drag or scroll pans, pinch or ⌘/ctrl scroll zooms at the pointer. + * The arrow keys walk the tree. Spans holding all of `needles` stay bright. + */ +export function Flame(props: { trace: Trace; needles: string[]; job: string }) { + const job = (service: string) => service === props.trace.spans[0]?.service ? props.job : service; + let wrap!: HTMLDivElement; + let canvas!: HTMLCanvasElement; + const graph = createMemo(() => layout(props.trace)); + const [focus, setFocus] = createSignal(); + const [hover, setHover] = createSignal<{ bar: Bar; x: number; y: number }>(); + let view = { from: 0, to: 1 }; + let scroll = 0; + let width = 0; + let height = 0; + let colors = theme(); + const widths = new Map(); + let frame = 0; + let animation = 0; + const reduced = matchMedia("(prefers-reduced-motion: reduce)"); + const mac = /Mac|iPhone|iPad/.test(navigator.platform); + + const total = () => graph().end || 1e-6; + const scale = () => width / (view.to - view.from); + const maxScroll = () => Math.max(0, graph().rows.length * ROW - (height - AXIS)); + const clampView = (from: number, to: number) => { + const span = Math.min(total(), Math.max(to - from, total() * 1e-5)); + const start = Math.min(Math.max(0, from), total() - span); + return { from: start, to: start + span }; + }; + /** Bars matching the search, or null when there is none. */ + const matching = createMemo(() => { + const needles = props.needles.map((needle) => needle.toLowerCase()); + return needles.length ? new Set(graph().bars.filter(({ span }) => { + const values = [span.name, ...Object.values(span.attributes)].map((value) => String(value).toLowerCase()); + return needles.every((needle) => values.some((value) => value.includes(needle))); + })) : null; + }); + + const measure = (text: string, ctx: CanvasRenderingContext2D) => { + let known = widths.get(text); + if (known === undefined) widths.set(text, (known = ctx.measureText(text).width)); + return known; + }; + const fit = (text: string, space: number, ctx: CanvasRenderingContext2D) => { + const full = measure(text, ctx); + if (full <= space) return text; + const chars = Math.floor((text.length * space) / full) - 1; + return chars < 2 ? "" : text.slice(0, chars) + "…"; + }; + + const draw = () => { + frame = 0; + const ctx = canvas.getContext("2d"); + if (!ctx || !width) return; + const dpr = devicePixelRatio; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.font = `11px ${colors.font}`; + ctx.textBaseline = "middle"; + const k = scale(); + const x = (t: number) => (t - view.from) * k; + const step = niceStep(90 / k); + const seconds = step >= 1; + const digits = Math.max(0, -Math.floor(Math.log10(seconds ? step : step * 1000) + 1e-9)); + for (let t = Math.ceil(view.from / step) * step; t <= view.to; t += step) { + const at = Math.round(x(t)); + ctx.fillStyle = colors.line; + ctx.fillRect(at, AXIS - 4, 1, height); + const label = seconds ? `${t.toFixed(digits)} s` : `${(t * 1000).toFixed(digits)} ms`; + if (at + 4 + measure(label, ctx) > width) continue; + ctx.fillStyle = colors.muted; + ctx.fillText(label, at + 4, AXIS / 2 - 1); + } + + ctx.save(); + ctx.beginPath(); + ctx.rect(0, AXIS, width, height - AXIS); + ctx.clip(); + const { rows } = graph(); + const hovered = hover()?.bar; + const lit = matching(); + const focused = focus(); + const last = Math.min(rows.length, Math.ceil((scroll + height - AXIS) / ROW)); + for (let r = Math.floor(scroll / ROW); r < last; r++) { + const y = AXIS + r * ROW - scroll; + for (const bar of rows[r]!) { + if (bar.to < view.from || bar.from > view.to) continue; + const left = Math.max(-1, x(bar.from)); + const w = Math.max(1, Math.min(width + 1, x(bar.to)) - left); + const fills = bar.span.error ? colors.failed : colors.series[bar.service % 8]!; + const bright = !lit || lit.has(bar); + ctx.fillStyle = !bright ? fills.faint : bar === hovered ? fills.hot : fills.shades[bar.shade]!; + ctx.fillRect(left, y, w > 2 ? w - 1 : w, ROW - 1); + if (bar === focused) { + ctx.strokeStyle = colors.text; + ctx.lineWidth = 1.5; + ctx.strokeRect(left + 0.75, y + 0.75, Math.max(0, w - 2.5), ROW - 2.5); + } + if (w < LABEL) continue; + const text = Math.max(0, left) + 5; + const name = fit(bar.span.name, w - 10, ctx); + ctx.fillStyle = bright ? colors.text : colors.muted; + ctx.fillText(name, text, y + ROW / 2); + if (name !== bar.span.name) continue; + const time = ms(bar.span.duration); + const after = measure(name, ctx) + 6; + if (after + measure(time, ctx) + 10 > w) continue; + ctx.globalAlpha = 0.6; + ctx.fillText(time, text + after, y + ROW / 2); + ctx.globalAlpha = 1; + } + } + ctx.restore(); + }; + const redraw = () => (frame ||= requestAnimationFrame(draw)); + + /** Eases to a view and a scroll, width geometrically so deep zooms feel even. */ + const animate = (target: { from: number; to: number }, targetScroll = scroll) => { + cancelAnimationFrame(animation); + const start = { ...view, scroll }; + const began = performance.now(); + const duration = reduced.matches ? 0 : 220; + const tick = (now: number) => { + const p = duration ? Math.min(1, (now - began) / duration) : 1; + const e = 1 - (1 - p) ** 3; + const [a, b] = [start.to - start.from, target.to - target.from]; + const span = a * (b / a) ** e; + const center = (start.from + start.to) / 2 + (((target.from + target.to) / 2) - (start.from + start.to) / 2) * e; + view = p === 1 ? target : { from: center - span / 2, to: center + span / 2 }; + scroll = start.scroll + (targetScroll - start.scroll) * e; + draw(); + if (p < 1) animation = requestAnimationFrame(tick); + }; + animation = requestAnimationFrame(tick); + }; + + const zoomTo = (bar: Bar) => { + setFocus(bar); + const pad = (bar.to - bar.from) * 0.03; + const top = bar.row * ROW; + const room = height - AXIS; + const targetScroll = top < scroll ? top : top + ROW > scroll + room ? top + ROW - room : scroll; + animate(clampView(bar.from - pad, bar.to + pad), Math.min(maxScroll(), Math.max(0, targetScroll))); + }; + + const zoomAt = (px: number, factor: number) => { + const t = view.from + px / scale(); + const span = (view.to - view.from) * factor; + cancelAnimationFrame(animation); + view = clampView(t - (px / width) * span, t - (px / width) * span + span); + redraw(); + }; + + const hit = (px: number, py: number) => { + if (py < AXIS) return undefined; + const row = graph().rows[Math.floor((py - AXIS + scroll) / ROW)] ?? []; + const t = view.from + px / scale(); + const slop = 3 / scale(); + let found: Bar | undefined; + for (const bar of row) { + if (bar.from - slop > t) break; + if (t <= bar.to + slop && (!found || (bar.from <= t && t <= bar.to))) found = bar; + } + return found; + }; + + let drag: { x: number; y: number; view: typeof view; scroll: number; moved: boolean } | undefined; + const point = (event: MouseEvent) => { + const box = canvas.getBoundingClientRect(); + return [event.clientX - box.left, event.clientY - box.top] as const; + }; + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + canvas.setPointerCapture(event.pointerId); + drag = { x: event.clientX, y: event.clientY, view, scroll, moved: false }; + }; + const onPointerMove = (event: PointerEvent) => { + const [px, py] = point(event); + if (drag) { + const dx = event.clientX - drag.x; + const dy = event.clientY - drag.y; + if (!drag.moved && Math.hypot(dx, dy) < 4) return; + if (!drag.moved) cancelAnimationFrame(animation); + drag.moved = true; + canvas.style.cursor = "grabbing"; + setHover(undefined); + const shift = dx / scale(); + view = clampView(drag.view.from - shift, drag.view.to - shift); + scroll = Math.min(maxScroll(), Math.max(0, drag.scroll - dy)); + redraw(); + return; + } + const bar = hit(px, py); + canvas.style.cursor = bar ? "pointer" : ""; + setHover(bar && { bar, x: px, y: py }); + }; + const onPointerUp = (event: PointerEvent) => { + if (drag && !drag.moved) { + const bar = hit(...point(event)); + if (bar) zoomTo(bar); + } + drag = undefined; + canvas.style.cursor = ""; + }; + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + const [px] = point(event); + const lines = event.deltaMode === 1 ? 16 : 1; + if (event.ctrlKey || event.metaKey) return zoomAt(px, Math.exp(event.deltaY * lines * 0.01)); + const dx = (event.shiftKey ? event.deltaY : event.deltaX) * lines; + const dy = event.shiftKey ? 0 : event.deltaY * lines; + cancelAnimationFrame(animation); + view = clampView(view.from + dx / scale(), view.to + dx / scale()); + scroll = Math.min(maxScroll(), Math.max(0, scroll + dy)); + setHover(undefined); + redraw(); + }; + const onKeyDown = (event: KeyboardEvent) => { + const bar = focus(); + if (!bar) return; + const siblings = bar.parent?.children ?? graph().bars.filter((other) => !other.parent); + const index = siblings.indexOf(bar); + const next = ({ + Escape: bar.parent ?? graph().bars[0], + ArrowUp: bar.parent, + ArrowDown: bar.children[0], + ArrowLeft: siblings[index - 1], + ArrowRight: siblings[index + 1], + Home: graph().bars[0], + } as Record)[event.key]; + if (event.key === "+" || event.key === "=" || event.key === "-") { + event.preventDefault(); + return zoomAt(width / 2, event.key === "-" ? 1.5 : 1 / 1.5); + } + if (!next) return; + event.preventDefault(); + zoomTo(next); + }; + + onMount(() => { + const resize = new ResizeObserver(() => { + width = canvas.clientWidth; + height = canvas.clientHeight; + canvas.width = Math.round(width * devicePixelRatio); + canvas.height = Math.round(height * devicePixelRatio); + scroll = Math.min(scroll, maxScroll()); + draw(); + }); + resize.observe(canvas); + const scheme = matchMedia("(prefers-color-scheme: dark)"); + const retheme = () => { + colors = theme(); + widths.clear(); + redraw(); + }; + scheme.addEventListener("change", retheme); + document.fonts.addEventListener("loadingdone", retheme); + canvas.addEventListener("wheel", onWheel, { passive: false }); + onCleanup(() => { + resize.disconnect(); + scheme.removeEventListener("change", retheme); + document.fonts.removeEventListener("loadingdone", retheme); + cancelAnimationFrame(frame); + cancelAnimationFrame(animation); + }); + }); + createEffect(on(graph, (next) => { + cancelAnimationFrame(animation); + setFocus(next.bars[0]); + setHover(undefined); + view = { from: 0, to: total() }; + scroll = 0; + redraw(); + })); + createEffect(on([focus, hover, matching], redraw, { defer: true })); + + const path = () => { + const chain: Bar[] = []; + for (let bar = focus(); bar; bar = bar.parent) chain.unshift(bar); + return chain; + }; + const root = () => graph().bars[0]!; + const facts = (bar: Bar) => [ + ["duration", `${ms(bar.span.duration)} (${percent((bar.span.duration / (root().span.duration || 1)) * 100)})`], + ...(bar.children.length ? [["self", ms(bar.self)]] : []), + ["start", `+${ms(bar.from)}`], + ...(bar.span.service !== root().span.service ? [["service", bar.span.service]] : []), + ]; + + return ( +
    +
    + + 1}> + + + {(service, i) => ( + + {service} + + )} + + + + {mac ? "⌘" : "ctrl"} scroll to zoom +
    +
    + (drag = undefined)} onPointerLeave={() => !drag && setHover(undefined)} onKeyDown={onKeyDown} /> + + {(at) => ( +
    wrap.clientWidth / 2, up: at().y > wrap.clientHeight / 2 }} + style={{ left: `${at().x}px`, top: `${at().y}px` }}> + {at().bar.span.name} +
    + {([key, value]) => <>
    {key}
    {value}
    }
    +
    + {(error) =>

    {error()}

    }
    +
    + )} +
    +
    + + {(bar) => ( +
    + {([key, value]) =>
    {key}
    {value}
    }
    + {(error) =>
    error
    {error()}
    }
    + + {([key, value]) =>
    {key}
    } +
    +
    span
    + +
    + )} +
    +
    + ); +} diff --git a/dashboard/web/pages/Service.tsx b/dashboard/web/pages/Service.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5c8aca5815dbcae6aab65e7a48ea7ecb53019a9e --- /dev/null +++ b/dashboard/web/pages/Service.tsx @@ -0,0 +1,745 @@ +import { A, useParams, useSearchParams } from "@solidjs/router"; +import { parseResponse } from "hono/client"; +import Eye from "lucide-solid/icons/eye"; +import EyeOff from "lucide-solid/icons/eye-off"; +import HardDrive from "lucide-solid/icons/hard-drive"; +import Package from "lucide-solid/icons/package"; +import RotateCcw from "lucide-solid/icons/rotate-ccw"; +import Search from "lucide-solid/icons/search"; +import X from "lucide-solid/icons/x"; +import { + createEffect, createMemo, createResource, createSignal, createUniqueId, For, type JSX, Match, onCleanup, Show, Switch, +} from "solid-js"; +import { Portal } from "solid-js/web"; +import { + type Action, type LogLine, type Series, type ServiceDetail, type TraceSummary, unacknowledgedRestart, +} from "../types/model.ts"; +import { logField, needles, parseSearch, traceField, type TraceSort } from "../types/search.ts"; +import { api, queries, query, reason } from "../api.ts"; +import { Ago } from "../components/Ago.tsx"; +import { AppIcon } from "../components/AppIcon.tsx"; +import { Copy } from "../components/Copy.tsx"; +import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx"; +import { ListPage } from "../components/ListPage.tsx"; +import { LogView } from "../components/LogView.tsx"; +import { OpenApp } from "../components/OpenApp.tsx"; +import { lastGood, Loaded } from "../components/Loaded.tsx"; +import { RangePicker } from "../components/Metric.tsx"; +import { TimeChart } from "../components/TimeChart.tsx"; +import { type Sort, SortHeader } from "../components/SortHeader.tsx"; +import { Status, StatusLabel } from "../components/Status.tsx"; +import { Strip } from "../components/Strip.tsx"; +import { TabBar } from "../components/TabBar.tsx"; +import { toast } from "../components/Toast.tsx"; +import { ago, bytes, clock, cores, datetime, duration, plural } from "../format.ts"; +import { stream } from "../live.ts"; +import { Definition } from "./Service.definition.tsx"; +import { Flame, ms } from "./Service.flame.tsx"; +import "./Service.css"; + +const TABS = [["", "logs"], ["traces", "traces"], ["metrics", "metrics"], ["secrets", "secrets"], ["definition", "definition"]] as const; +const LEVELS = [[undefined, "all"], ["warn", "warn"], ["error", "error"]] as const; + +/** URL state of the logs and traces tabs; `at` opens the logs at a moment instead of following. */ +type Query = { q?: string; level?: "warn" | "error"; at?: string; from?: string; to?: string; errors?: "1"; sort?: TraceSort; trace?: string }; + +/** Each service's latest unfiltered lines, so returning to a service draws its logs at once. */ +const recent = new Map(); + +/** Newest at the bottom; follows new lines while scrolled to the end, and loads older ones near the top. */ +function Logs(props: { service: ServiceDetail; tools: HTMLElement }) { + const [params, setParams] = useSearchParams(); + const at = () => (params.at ? Number(params.at) : null); + const bounded = () => at() !== null || !!params.to; + const unfiltered = () => !params.q && !params.level && !params.from && !params.to && at() === null; + const dateValue = (value?: string) => { + if (!value) return ""; + const date = new Date(Number(value) * 1000); + return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16); + }; + const [older, setOlder] = createSignal([]); + const [newer, setNewer] = createSignal([]); + const [exhausted, setExhausted] = createSignal(false); + const [loadingOlder, setLoadingOlder] = createSignal(false); + const [follow, setFollow] = createSignal(true); + const [unseen, setUnseen] = createSignal(0); + /** Why the last poll failed, until one succeeds. */ + const [stalled, setStalled] = createSignal(); + const fetchLines = (limit: number, extra: { before?: string; after?: string }) => + parseResponse(api.services[":id"].logs.$get({ + param: { id: props.service.id }, + query: { q: params.q ?? "", ...(params.level ? { level: params.level } : {}), limit: String(limit), + ...(params.from ? { after: params.from } : {}), ...(params.to ? { before: params.to } : {}), ...extra }, + })).then((lines) => lines.reverse()); + const [first, { refetch }] = createResource( + // A string key, since a fresh array from an unrelated rerun would count as a change and refetch. + () => JSON.stringify([props.service.id, params.q, params.level, params.at, params.from, params.to]), + (_, { refetching }) => { + setOlder([]); + setNewer([]); + setExhausted(false); + setFollow(!bounded()); + setUnseen(0); + setStalled(); + const cached = !refetching && unfiltered() && recent.get(props.service.id); + if (cached) { + queueMicrotask(() => poll()); + return cached; + } + return fetchLines(1000, at() === null ? {} : { before: String(at()! + 30) }); + }, + ); + const loaded = lastGood(first); + const all = createMemo(() => [...older(), ...(loaded() ?? []), ...newer()]); + createEffect(() => first.state === "ready" && unfiltered() && recent.set(props.service.id, all().slice(-1000))); + + const loadOlder = async () => { + const oldest = all()[0]; + if (!oldest || loadingOlder() || exhausted()) return; + if (params.from && oldest.t <= Number(params.from)) return setExhausted(true); + setLoadingOlder(true); + try { + const lines = await fetchLines(5000, { before: String(oldest.t) }); + if (!lines.length) setExhausted(true); + setOlder([...lines, ...older()]); + } catch (failure) { + toast(`Couldn't load older lines. ${reason(failure)}`); + } finally { + setLoadingOlder(false); + } + }; + + const poll = async () => { + const last = all().at(-1); + if (bounded() || !last || first.loading) return; + let lines: LogLine[]; + try { + lines = await fetchLines(1000, { after: String(last.t) }); + setStalled(); + } catch (failure) { + setStalled(reason(failure)); + return; + } + // A search changed while this was under way, so the lines belong to the old one. + if (!lines.length || first.loading || last !== all().at(-1)) return; + setNewer([...newer(), ...lines]); + if (!follow()) setUnseen(unseen() + lines.length); + }; + const timer = setInterval(poll, 2000); + onCleanup(() => clearInterval(timer)); + const marked = createMemo(() => (at() === null ? undefined : all().find((line) => line.t > at()!))); + const hints = () => [ + ...props.service.containers.filter((container) => !container.hook && props.service.containers.length > 1).slice(0, 1) + .map((container) => [`container:${container.name}`, "one container's lines"] as const), + ["stream:stderr", "error output only"], + ["-health", "hide lines with a word"], + ['"connection refused"', "an exact phrase"], + ] as const; + + return ( +
    + (el.style.display = "contents")}> + + + + {([level, label]) => ( + + )} + + + + + {(link) => {link().app.name}} + + + + +
    + + {(_, i) => ( +
    + )} +
    +
    +
    + }> + {() => ( + {params.q || params.level ? "No lines match." : "No recent logs."}
    + }> + setParams({ q: [params.q, `container:${name}`].filter(Boolean).join(" ") }, { replace: true })} + onFollow={(end) => { + setFollow(end && at() === null); + if (end) setUnseen(0); + }} + onLatest={() => (at() === null ? setFollow(true) : setParams({ at: undefined }))} + before={(line, index) => ( + <> + +
    + + + +
    +
    +
    {clock(at()!)}
    + + )} /> + + )} +
    +
    + ); +} + +const traceList = query("traces", ({ id, ...query }: { id: string; q: string; errors?: "1"; sort?: TraceSort; limit: string }) => + parseResponse(api.services[":id"].traces.$get({ param: { id }, query }))); +const oneTrace = query("trace", (id: string) => parseResponse(api.traces[":id"].$get({ param: { id } }))); +const PAGE = 50; + +function AppMetrics(props: { service: ServiceDetail; range: number }) { + const [name, setName] = createSignal(props.service.applicationMetrics[0]!); + const [data, { refetch }] = createResource( + () => [props.service.id, name(), props.range] as const, + ([id, metric, range]) => parseResponse(api.services[":id"].metrics[":name"].$get({ + param: { id, name: metric }, query: { range: String(range) }, + })), + ); + const timer = setInterval(refetch, 30_000); + onCleanup(() => clearInterval(timer)); + return ( +
    +
    + application metrics + + +
    + }> + {(series) => No samples in this range.
    }> + new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} /> + } + +
    + ); +} + +function Traces(props: { service: ServiceDetail; tools: HTMLElement }) { + const [params, setParams] = useSearchParams(); + const [limit, setLimit] = createSignal(PAGE); + const [traces, { refetch }] = traceList.use(() => + ({ id: props.service.id, q: params.q ?? "", errors: params.errors, sort: params.sort, limit: String(limit()) })); + const timer = setInterval(refetch, 10_000); + onCleanup(() => clearInterval(timer)); + const shown = lastGood(traces); + /** Rows keep an unchanged trace's summary, so a refresh doesn't rebuild them and take focus or scroll with it. */ + const rows = createMemo((previous) => { + const known = new Map(previous.map((summary) => [summary.id, summary])); + return (shown() ?? []).map((summary) => { + const old = known.get(summary.id); + return old && old.spans === summary.spans && old.error === summary.error ? old : summary; + }); + }, []); + const failing = createMemo((previous) => + traces.state === "errored" ? reason(traces.error) : traces.state === "ready" ? undefined : previous); + // Pinned once, so newer traces arriving above it don't swap the one being read; never from a search's old answer. + createEffect(() => { + const first = rows()[0]; + if (!params.trace && first && traces.state === "ready") setParams({ trace: first.id }, { replace: true }); + }); + const [trace, { refetch: refetchTrace }] = oneTrace.use(() => params.trace ?? false); + const slowest = () => Math.max(...rows().map((summary) => summary.root.duration)); + const sort = (): Sort<"when" | "duration"> => ({ + key: params.sort === "slowest" || params.sort === "fastest" ? "duration" : "when", + desc: params.sort !== "oldest" && params.sort !== "fastest", + }); + // A column's first click shows its most telling end: the newest, or the slowest. + const onSort = ({ key, desc }: Sort<"when" | "duration">) => setParams({ + sort: key === "when" ? (desc || key !== sort().key ? undefined : "oldest") : desc || key !== sort().key ? "slowest" : "fastest", + trace: undefined, + }, { replace: true }); + /** Up and down move the selection, keeping focus on the chosen row. */ + const step = (event: KeyboardEvent) => { + const offset = ({ ArrowDown: 1, ArrowUp: -1 } as Record)[event.key]; + const next = offset && rows()[rows().findIndex((summary) => summary.id === params.trace) + offset]; + if (!next) return; + event.preventDefault(); + setParams({ trace: next.id }, { replace: true }); + (event.currentTarget as HTMLElement).querySelector(`[data-trace="${next.id}"]`)?.focus(); + }; + const hints = [ + ["status:5xx", "by status code"], + ["path:/api", "paths that start with /api"], + ["duration:>1s", "slower than a second"], + ["method:post", "by method"], + ["-path:/health", "hide matching requests"], + ] as const; + return ( + <> + (el.style.display = "contents")}> + + + + {(errors) => ( + + )} + + + + + + + + {(_, i) =>
    } + +
    + }> + {() => ( + No traces match in the last week.
    }> +
    +

    No traces from {props.service.name} yet.

    +
    + + }> +
    +
    + + + + + + + + + + + + + {(summary) => { + const root = summary.root; + const selected = () => params.trace === summary.id; + const status = root.attributes["http.response.status_code"]; + return ( + createEffect(() => selected() && row.scrollIntoView({ block: "nearest" }))} + classList={{ selected: selected() }} onClick={() => setParams({ trace: summary.id }, { replace: true })}> + + + + + + + ); + }} + + +
    requeststatusspans
    + + + + {(error) => } + {status} + + 1 ? summary.services.join(", ") : undefined}> + {summary.spans} + +
    + + + + {ms(root.duration)} +
    +
    + +
    + +
    +
    +
    + }> + {(loaded) => } + +
    + + )} + + + ); +} + +/** Whether a tab still takes in new data; `paused` and `failure` say why it doesn't. */ +function Liveness(props: { every: string; paused?: string; failure?: string }) { + const health = () => (props.failure ? "degraded" : props.paused ? "stopped" : "healthy"); + return ( + + {props.failure ? "reconnecting" : props.paused ? "paused" : "live"} + + ); +} + +function Secrets(props: { service: ServiceDetail; onChange: () => unknown }) { + const [shown, setShown] = createSignal>({}); + const [revealing, setRevealing] = createSignal(); + const param = (name: string) => ({ param: { id: props.service.id, name } }); + const hide = (name: string) => setShown(({ [name]: _, ...rest }) => rest); + const reveal = async (name: string) => { + setRevealing(name); + try { + const { value } = await parseResponse(api.services[":id"].secrets[":name"].$get(param(name))); + setShown({ ...shown(), [name]: value }); + } catch (failure) { + toast(`Couldn't reveal ${name}. ${reason(failure)}`); + } finally { + setRevealing(); + } + }; + const rotate = (name: string) => showConfirmDialog({ + title: `Rotate ${name}?`, + description: `${props.service.name} gets a new random ${name} and restarts to use it. ` + + "Anything still using the old value stops working until you update it.", + confirmLabel: "rotate", + destructive: true, + onConfirm: async () => { + await parseResponse(api.services[":id"].secrets[":name"].rotate.$post(param(name))); + hide(name); + await props.onChange(); + }, + }); + const set = (name: string) => showTextDialog({ + title: `Set ${name}`, + description: `${props.service.name} restarts to use the new value.`, + label: "New value", + confirmLabel: "set", + onConfirm: async (value) => { + await parseResponse(api.services[":id"].secrets[":name"].$put({ ...param(name), json: { value } })); + hide(name); + await props.onChange(); + }, + }); + return ( + Secrets aren't wired up to the dashboard yet.}> + {(secrets) => ( + +

    {props.service.name} has no secrets.

    +

    Secrets its service file declares appear here.

    + + }> + + + + + {(secret) => ( + + + + + + )} + + +
    secretvalue
    {secret.name} + reveal(secret.name)}> + reveal + + }> + {(value) => ( + + + + + )} + + +
    + set(secret.name)}>set}> + + +
    +
    +
    + )} +
    + ); +} + +/** + * The search box and filters that share the tab row with the tabs, kept in the URL. Focusing the empty box lists + * `hints`, examples of the search syntax that fill the box when picked. + */ +function Filters(props: { placeholder: string; hints: readonly (readonly [string, string])[]; children: JSX.Element }) { + const [params, setParams] = useSearchParams(); + const [open, setOpen] = createSignal(false); + const hintsId = createUniqueId(); + let input!: HTMLInputElement; + let debounce: ReturnType | undefined; + const search = (value: string) => { + clearTimeout(debounce); + setParams({ q: value || undefined, trace: undefined }, { replace: true }); + }; + const key = (event: KeyboardEvent) => { + if (event.key !== "/" || (event.target as Element).closest("input, textarea, [contenteditable]")) return; + event.preventDefault(); + input.focus(); + }; + document.addEventListener("keydown", key); + onCleanup(() => { + document.removeEventListener("keydown", key); + clearTimeout(debounce); + }); + return ( + <> + + {props.children} + + ); +} + +/** Remounts per service, so switching shows the new one's cached state or a skeleton, never the last one's. */ +export function Service() { + const params = useParams<{ id: string }>(); + return ; +} + +function ServicePage() { + const params = useParams<{ id: string; tab?: string }>(); + const [service, { refetch }] = queries.service.use(() => params.id); + const timer = setInterval(refetch, 10_000); + onCleanup(() => clearInterval(timer)); + // After an action the refetched detail is fresher than the stream until the stream's next tick. + const [since, setSince] = createSignal(0); + stream.start(); + const refresh = async () => { + setSince(Date.now() / 1000); + await refetch(); + }; + const act = async (action: Action) => { + await parseResponse(api.services[":id"][":action"].$post({ param: { id: params.id, action } })); + await refresh(); + }; + + const [range, setRange] = createSignal(3600); + + return ( + +
    + + + {(width) => } +
    +
    +
    + }> + {(data) => { + const tick = () => { + const latest = stream.latest(); + return latest && latest.t > since() ? latest.services[data().id] : undefined; + }; + const health = () => tick()?.health ?? data().health; + /** Nomad leaves usage unmeasured when the task driver reports none; the tip adds the reservation and the charted peak. */ + const reading = (now: number | null, reserved: string, format: (value: number) => string) => (latest: Series[]) => { + const values = latest[0]?.v ?? []; + return { + value: now === null ? "–" : format(now), + tip: reserved + (values.length ? `, ${duration(range())} peak ${format(Math.max(...values.map((v) => v ?? 0)))}` : ""), + stale: !stream.live(), + }; + }; + const failing = () => + (health() === "degraded" || health() === "down" ? data().checks.find((check) => !check.passing) : undefined); + const unacknowledged = () => unacknowledgedRestart(data()); + const restarts = () => data().containers.reduce((sum, container) => sum + container.restarts, 0); + const main = () => data().containers.filter((container) => !container.hook); + const started = () => (health() === "healthy" ? main()[0]?.startedAt : undefined); + /** One pill per image the main tasks run, however many tasks share it. */ + const images = () => [...new Set(main().flatMap((container) => container.image ?? []))].map((image) => ({ + image, tasks: main().filter((container) => container.image === image).map((container) => container.name), + })); + const [clearing, setClearing] = createSignal(false); + const acknowledge = () => { + setClearing(true); + parseResponse(api.services[":id"].restarts.acknowledge.$post({ param: { id: params.id } })) + .then(refresh, (failure) => toast(`Couldn't clear the restart. ${reason(failure)}`)) + .finally(() => setClearing(false)); + }; + const also = (verb: string) => data().dependents?.length + ? `, ${verb} ${new Intl.ListFormat("en").format(data().dependents!.map((link) => link.name))}` : ""; + const restart = () => showConfirmDialog({ + title: `Restart ${data().name}?`, + description: `${data().name} goes offline for a moment. Anyone using it is interrupted${also("as are")}.`, + confirmLabel: "restart", + onConfirm: () => act("restart"), + }); + const stop = () => showConfirmDialog({ + title: `Stop ${data().name}?`, + description: `${data().name} stays offline until you start it again. Anyone using it loses access${also("as do")}.`, + confirmLabel: "stop", + destructive: true, + onConfirm: () => act("stop"), + }); + const start = () => showConfirmDialog({ + title: `Start ${data().name}?`, + description: `${data().name} comes back online${data().release ? ` on release ${data().release!.slice(0, 8)}` : ""}.`, + confirmLabel: "start", + onConfirm: () => act("start"), + }); + const tabHref = (tab: string) => `/services/${data().id}${tab ? "/" + tab : ""}`; + let tools!: HTMLDivElement; + return ( + +
    + +

    {data().name}

    + {(url) => } +
    + }> + {(t) => ( + `${check.name}: ${check.output}`)].join("; ")}> + up {duration(Date.now() / 1000 - t())} + + )} + + + {({ image, tasks }) => { + const [ref, digest] = image.split("@") as [string, string?]; + const [name, tag] = ref.slice(ref.lastIndexOf("/") + 1).split(":") as [string, string?]; + return ( + + 1 ? `${tasks.join(" and ")} image` : "image"}> + + + ); + }} + + + {(dataset) => ( + + {bytes(dataset.used)} + + )} + + + {(last) => ( + + + + + + )} + +
    + + + stop}> + + +
    + + {(check) => ( +
    + {check().name} + {check().output} + error logs +
    + )} +
    + + } summary={ +
    + } + now={reading(tick()?.cpu ?? data().cpu, `${cores(data().cpuLimit)} cores reserved`, cores)} /> + +
    + }> +
    + + tab !== "traces" || data().applicationTraces).filter(([tab]) => tab !== "metrics" || data().applicationMetrics.length > 0)}> + {([tab, label]) => {label}} + + +
    + + + + 0}> + + + +
    + ); + }} +
    + ); +} diff --git a/dashboard/web/pages/Storage.css b/dashboard/web/pages/Storage.css new file mode 100644 index 0000000000000000000000000000000000000000..948ff2f15a00670de682fd01e6feffff0b79e0b3 --- /dev/null +++ b/dashboard/web/pages/Storage.css @@ -0,0 +1,133 @@ +.storage .fill:not(.explorer) { overflow: auto; } +.storage .list-body > .segmented { margin-bottom: 8px; } +.storage table.data th { white-space: nowrap; } +.storage .stats .mono { font-weight: 500; } +.storage td .chip { margin-left: 8px; } +.storage tr.pickable { cursor: pointer; } +.storage .snapshots tr.pickable { user-select: none; } +.storage tr.pickable:hover, .storage tr.hover { background: var(--hover); } +.storage tr.selected, .storage tr.selected:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); } + +.storage tr.trouble > td { background: color-mix(in srgb, var(--critical) 13%, transparent); } + +.storage tr.vdev td { color: var(--text-2); background: var(--panel); padding-block: 4px; } +.storage td.disk .muted { font-size: 12px; } + +.storage .datasets td .name { display: flex; align-items: center; min-width: 0; } +.storage .expander { + display: inline-flex; + align-items: center; + gap: 4px; + border: 0; + background: none; + padding: 0; + cursor: pointer; +} +.storage .expander .chevron { color: var(--muted); } +.storage td.action { width: 1%; padding-block: 0; } +.storage td.action div { display: flex; justify-content: flex-end; gap: 6px; } +.storage tr:is(.open, .hover) .row-icon { opacity: 1; } +.storage .datasets tr.expansion > td { padding-block: 0 10px; background: var(--panel); } +.storage .expansion .facts { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 6px 0 8px 16px; font-size: 12px; } +.storage .expansion .facts div { display: flex; gap: 6px; align-items: baseline; } +.storage .expansion .facts dt { color: var(--muted); } +.storage .expansion .facts dd { margin: 0; } +.storage td.disk .capacity { margin-left: 6px; } +.storage .snapshots { border-left: 2px solid var(--line); margin-left: 14px; padding-left: 10px; } +.storage .snapshots .empty { padding: 12px 0; text-align: left; } +.storage .selection { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + gap: 12px; + padding: 6px 8px; + min-height: 42px; + background: var(--panel); + font-size: 13px; +} + +.dialog .description .target { overflow-wrap: anywhere; margin-bottom: 6px; color: var(--text); } + +.files-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 12px; } + +.storage .treemap { position: relative; margin: 0 var(--gutter) 6px; } +.storage .tm-cell { + position: absolute; + overflow: hidden; + border: 1px solid var(--page); + border-radius: var(--radius); + background: color-mix(in srgb, var(--kind) 42%, var(--page)); + padding: 4px 6px; + font-size: 12px; + line-height: 1.3; + display: flex; + flex-direction: column; + transition: background-color 150ms; +} +.storage .tm-cell.dir { cursor: pointer; } +.storage .tm-cell.rest { background: repeating-linear-gradient(135deg, var(--raised) 0 4px, var(--page) 4px 8px); } +.storage .tm-cell.hover:not(.rest) { background: color-mix(in srgb, var(--kind) 62%, var(--page)); } +.storage .tm-cell > span { flex: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.storage .tm-cell .name { color: var(--text); font-weight: 550; } +.storage .tm-cell .size { color: var(--text-2); font-variant-numeric: tabular-nums; } + + +.storage td.path { overflow-wrap: anywhere; } + +.storage .map { display: flex; flex-direction: column; padding: 0 var(--gutter); } +/* A fixed share, so hovering a long path can't reflow the crumbs and resize the map under the pointer. */ +.storage .map .map-status { display: flex; flex: none; justify-content: flex-end; gap: 8px; width: 40%; } +.storage .map-status .path { overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; } +.storage .map-status b { color: var(--text); font-weight: 600; } +.storage .map-body { display: grid; grid-template-columns: 1fr 184px; gap: 12px; flex: 1; min-height: 0; padding-bottom: 12px; } +.storage .map-frame { position: relative; overflow: hidden; border-radius: var(--radius); outline-offset: 2px; } +.storage .map-frame.zoomable { cursor: zoom-in; } +.storage .map-frame canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.storage .map-frame .skeleton { position: absolute; inset: 0; } +.storage .map-frame .empty { position: absolute; inset: 0; display: grid; place-items: center; } +.storage .map-frame .outline { position: absolute; pointer-events: none; box-sizing: border-box; } +.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); } +.storage .map-frame .outline.leaf { border: 2px solid white; box-shadow: 0 0 0 1px rgb(0 0 0 / 0.6); } +.storage .map-legend { list-style: none; margin: 0; padding: 0; overflow: auto; font-size: 12px; } +.storage .map-legend li { + display: grid; + grid-template-columns: 10px 1fr auto; + align-items: center; + gap: 8px; + padding: 3px 6px; + border-radius: var(--radius); +} +.storage .map-legend li[tabindex] { cursor: default; } +.storage .map-legend li[tabindex]:is(:hover, :focus-visible) { background: var(--hover); } +.storage .map-legend .swatch { width: 10px; height: 10px; border-radius: 3px; background: var(--other); } +.storage .map-legend .swatch.small { background: color-mix(in srgb, var(--other) 50%, var(--page)); } +.storage .map-legend .v { color: var(--text-2); font-variant-numeric: tabular-nums; } +.storage td.num > span[data-tip] { padding-block: 8px; margin-block: -8px; } + +.storage .regions { display: flex; flex-wrap: wrap; gap: 2px; list-style: none; margin: 0 0 0 -8px; padding: 0; } +.storage .regions li { display: flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: var(--radius); color: var(--text-2); } +.storage .regions li:is(:hover, :focus-visible) { background: var(--hover); } +.storage .regions b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; } +.storage :is(.regions, .held-key) .swatch { flex: none; width: 10px; height: 10px; border-radius: 3px; } +.storage .regions .swatch { background: color-mix(in srgb, var(--kind) 70%, var(--page)); } +.storage .held-key { display: flex; align-items: center; gap: 6px; color: var(--text-2); } +.storage .held-key .swatch { --kind: var(--other); } +.storage .regions-map { flex: none; } +.storage .region { + position: absolute; + border-radius: calc(var(--radius) + 2px); + background: color-mix(in srgb, var(--kind) 16%, var(--page)); + transition: opacity 150ms; +} +.storage .region.dim { opacity: 0.3; } +.storage :is(.regions-map .tm-cell.dir, .held-key .swatch) { + --fill: color-mix(in srgb, var(--kind) 42%, var(--page)); + background: + linear-gradient(to top, transparent var(--held, 100%), var(--fill) var(--held, 100%)), + repeating-linear-gradient(135deg, var(--fill) 0 2px, color-mix(in srgb, var(--kind) 20%, var(--page)) 2px 5px); +} +.storage .regions-map .tm-cell.hover { --fill: color-mix(in srgb, var(--kind) 62%, var(--page)); } +.storage .regions-map .tm-cell.open { box-shadow: inset 0 0 0 2px var(--text); } +.storage .datasets tr { scroll-margin-top: 34px; } +.storage .regions-map .tm-cell.rest { background: color-mix(in srgb, var(--kind) 26%, var(--page)); } diff --git a/dashboard/web/pages/Storage.map.tsx b/dashboard/web/pages/Storage.map.tsx new file mode 100644 index 0000000000000000000000000000000000000000..428235a0f1130dc3c78bd6ccf637736d212b691f --- /dev/null +++ b/dashboard/web/pages/Storage.map.tsx @@ -0,0 +1,299 @@ +import { useSearchParams } from "@solidjs/router"; +import { parseResponse } from "hono/client"; +import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; +import type { ServiceSummary } from "../types/model.ts"; +import type { MapNode } from "../types/storage.index.ts"; +import { api, reason } from "../api.ts"; +import { Crumbs, squarify } from "../components/Explorer.tsx"; +import { lastGood } from "../components/Loaded.tsx"; +import { OpenApp } from "../components/OpenApp.tsx"; +import { bytes, percent, plural } from "../format.ts"; + +/** Extensions by type, in categorical color order: video, audio, image, archive, document, code, data, font. */ +const KINDS = [ + /^(mkv|mp4|m4v|mov|webm|avi|mts|wmv)$/, + /^(flac|wav|aiff?|mp3|m4a|ogg|opus|als|alac)$/, + /^(jpe?g|png|gif|webp|heic|tiff?|dng|psd|raw|cr2|arw|svg|avif)$/, + /^(zip|tar|gz|tgz|xz|zst|7z|rar|dmg|iso|img|pack|part)$/, + /^(pdf|docx?|pages|key|txt|md|rtf|epub|srt|ass|xlsx?|numbers)$/, + /^(js|mjs|cjs|ts|tsx|jsx|py|rs|go|c|h|cpp|css|html?|json|ya?ml|toml|sh|lua|map)$/, + /^(db|sqlite3?|wal|shm|sql|parquet|csv|log|bin)$/, + /^(otf|ttf|woff2?|ttc)$/, +]; + +const extension = (name: string) => (name.lastIndexOf(".") > 0 ? name.slice(name.lastIndexOf(".") + 1).toLowerCase() : ""); +const colorOf = (ext: string) => { + const kind = KINDS.findIndex((pattern) => pattern.test(ext)); + return kind === -1 ? "--other" : `--series-${kind + 1}`; +}; + +/** WinDirStat's cushion constants: ridge height, its falloff per level, ambient light, and a light at (-1, -1, 10). */ +const HEIGHT = 0.38; +const FALLOFF = 0.91; +const AMBIENT = 0.13; +const LIGHT_XY = -1 / Math.hypot(1, 1, 10); +const LIGHT_Z = 10 / Math.hypot(1, 1, 10); +/** Flat ground shows a type's color a little brighter, as WinDirStat does, so the shaded rims stay readable. */ +const FLAT = (AMBIENT + (1 - AMBIENT) * LIGHT_Z) / 1.45; +/** How much of the rims' darkening light mode keeps, so they don't read as black outlines on a pale page. */ +const LIGHT_RIMS = 0.5; + +/** A laid-out entry; `ext` is null for folders drawn whole and for the space a folder's small files take together. */ +interface Box { + name: string; + size: number; + files: number; + dir: boolean; + ext: string | null; + x: number; + y: number; + w: number; + h: number; + /** The cushion's surface coefficients: x², y², x and y. */ + surface: [number, number, number, number]; + children: Box[]; +} + +function layout(node: MapNode, x: number, y: number, w: number, h: number, parent: Box["surface"], height: number): Box { + const [name, size, files, children] = node; + const surface: Box["surface"] = [ + parent[0] - (4 * height) / w, parent[1] - (4 * height) / h, + parent[2] + ((4 * height) / w) * (2 * x + w), parent[3] + ((4 * height) / h) * (2 * y + h), + ]; + const box: Box = { name, size, files: files ?? 1, dir: !!children, ext: children ? null : extension(name), x, y, w, h, surface, children: [] }; + if (!children?.length) return box; + const shown = children.reduce((sum, child) => sum + child[1], 0); + const shownFiles = children.reduce((sum, child) => sum + (child[2] ?? 1), 0); + const items = [...children, ...(size > shown ? [["", size - shown, files! - shownFiles, []] satisfies MapNode] : [])] + .filter((item) => item[1] > 0).map((item) => ({ item, size: item[1] })).sort((a, b) => b.size - a.size); + box.children = squarify(items, w, h) + .map((cell) => layout(cell.item.item, x + cell.x, y + cell.y, cell.w, cell.h, surface, height * FALLOFF)); + return box; +} + +const leaves = (box: Box): Box[] => (box.children.length ? box.children.flatMap(leaves) : [box]); + +/** The chain of boxes from `box` down to the leaf under the point. */ +function hit(box: Box, px: number, py: number): Box[] { + const inside = box.children.find((child) => px >= child.x && px < child.x + child.w && py >= child.y && py < child.y + child.h); + return inside ? [box, ...hit(inside, px, py)] : [box]; +} + +function rgb(canvas: HTMLCanvasElement, variable: string) { + const probe = document.createElement("canvas").getContext("2d")!; + probe.fillStyle = getComputedStyle(canvas).getPropertyValue(variable).trim(); + const hex = probe.fillStyle; + return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); +} + +/** Every file in the pool as a cushion-shaded treemap, WinDirStat style, colored by type. */ +export function StorageMap(props: { copyparty: { href: string; app?: Pick } | null | undefined }) { + const [params, setParams] = useSearchParams<{ path?: string }>(); + const current = () => params.path ?? ""; + const go = (path: string) => setParams({ path: path || undefined }); + let frame!: HTMLDivElement; + let canvas!: HTMLCanvasElement; + const [size, setSize] = createSignal({ width: 0, height: 0 }); + const [scheme, setScheme] = createSignal(0); + onMount(() => { + const resizes = new ResizeObserver(([entry]) => setSize({ width: entry!.contentRect.width, height: entry!.contentRect.height })); + resizes.observe(frame); + const dark = matchMedia("(prefers-color-scheme: dark)"); + const repaint = () => setScheme(scheme() + 1); + dark.addEventListener("change", repaint); + onCleanup(() => { + resizes.disconnect(); + dark.removeEventListener("change", repaint); + }); + }); + // Buckets keep a window resize from refetching on every pixel. + const query = () => size().width ? { + path: current(), + width: String(Math.ceil(size().width / 128) * 128), + height: String(Math.ceil(size().height / 128) * 128), + } : null; + const [map, { refetch }] = createResource(query, (query) => + parseResponse(api.storage.files.map.$get({ query })).then((map) => ({ ...map, path: query.path }))); + const loaded = lastGood(map); + /** A resize redraws the map it has, but another folder's would zoom to the wrong paths. */ + const data = () => (loaded()?.path === current() ? loaded() : undefined); + createEffect(() => { + if (!data()?.scanning || data()?.tree) return; + const timer = setTimeout(refetch, 2000); + onCleanup(() => clearTimeout(timer)); + }); + + const root = createMemo(() => { + const tree = data()?.tree; + const { width, height } = size(); + return tree && width && tree[1] > 0 ? layout(tree, 0, 0, width, height, [0, 0, 0, 0], HEIGHT) : null; + }); + const legend = createMemo(() => { + const sizes = new Map(); + let small = 0; + for (const leaf of root() ? leaves(root()!) : []) { + if (leaf.ext === null) small += leaf.size; + else { + const total = sizes.get(leaf.ext) ?? { size: 0, files: 0 }; + sizes.set(leaf.ext, { size: total.size + leaf.size, files: total.files + 1 }); + } + } + const ranked = [...sizes].sort((a, b) => b[1].size - a[1].size); + return { top: ranked.slice(0, 14), rest: ranked.slice(14).reduce((sum, [, total]) => sum + total.size, 0), small }; + }); + const [focus, setFocus] = createSignal(undefined); + const [pointer, setPointer] = createSignal(null); + const [picked, setPicked] = createSignal(null); + /** The chain under the pointer, or the child of the view picked with the arrow keys. */ + const chain = () => { + const child = picked() !== null && root()?.children[picked()!]; + return pointer() ?? (child ? [root()!, child] : null); + }; + /** The folder a click opens; the unnamed box is a folder's small files together. */ + const target = () => { + const box = chain()?.[1]; + return box?.dir && box.name ? box : null; + }; + + createEffect(() => { + const box = root(); + const highlight = focus(); + scheme(); + const ratio = devicePixelRatio; + canvas.width = Math.round(size().width * ratio); + canvas.height = Math.round(size().height * ratio); + if (!box) return; + const context = canvas.getContext("2d")!; + const image = context.createImageData(canvas.width, canvas.height); + const colors = new Map(); + const page = rgb(canvas, "--page"); + const rims = matchMedia("(prefers-color-scheme: dark)").matches ? 1 : LIGHT_RIMS; + for (const leaf of leaves(box)) { + const variable = leaf.ext === null ? "--other" : colorOf(leaf.ext); + let color = colors.get(variable) ?? rgb(canvas, variable); + colors.set(variable, color); + // Space under the cutoff reads as a quieter "other". + if (leaf.ext === null) color = color.map((c, i) => (c + page[i]!) / 2); + if (highlight !== undefined && leaf.ext !== highlight) color = color.map((c, i) => c * 0.25 + page[i]! * 0.75); + const [red = 0, green = 0, blue = 0] = color; + const [s0, s1, s2, s3] = leaf.surface; + const [x0, x1, y0, y1] = [leaf.x, leaf.x + leaf.w, leaf.y, leaf.y + leaf.h].map((v) => Math.round(v * ratio)); + for (let py = y0!; py < y1!; py++) { + const y = (py + 0.5) / ratio; + const ny = -(2 * s1 * y + s3); + for (let px = x0!; px < x1!; px++) { + const x = (px + 0.5) / ratio; + const nx = -(2 * s0 * x + s2); + const light = AMBIENT + (1 - AMBIENT) * Math.max(0, ((nx + ny) * LIGHT_XY + LIGHT_Z) / Math.sqrt(nx * nx + ny * ny + 1)); + const shade = light < FLAT ? 1 - (1 - light / FLAT) * rims : light / FLAT; + const at = (py * canvas.width + px) * 4; + image.data[at] = red * shade; + image.data[at + 1] = green * shade; + image.data[at + 2] = blue * shade; + image.data[at + 3] = 255; + } + } + } + context.putImageData(image, 0, 0); + }); + + const outline = (box: Box | null | undefined) => + box ? { left: `${box.x}px`, top: `${box.y}px`, width: `${box.w}px`, height: `${box.h}px` } : { display: "none" }; + const zoom = () => { + const box = target(); + if (!box) return; + setPointer(null); + setPicked(null); + go(current() ? `${current()}/${box.name}` : box.name); + }; + const up = () => current() && go(current().split("/").slice(0, -1).join("/")); + + return ( +
    +
    + + + + + {(boxes) => { + const leaf = () => boxes().at(-1)!; + return ( + <> + + {[current(), ...boxes().slice(1).map((box) => box.name || "smaller files")].filter(Boolean).join("/")} + + {bytes(leaf().size)} + {plural(leaf().files, "file")} + + ); + }} + + + + {(link) => } + +
    +
    + + ); +} + +const W = 1000; +const H = 300; +type Dataset = Overview["datasets"][number]; +/** What a dataset takes in the pool itself: its files and snapshots, without child datasets. */ +const within = (path: string, root: string) => path === root || path.startsWith(root + "/"); +const own = (dataset: Dataset) => dataset.usedbydataset + dataset.usedbysnapshots; +/** Room between regions, and the area under which datasets merge into one block, in treemap units. */ +const GAP = 3; +const CELL = 400; +/** The smallest share a region is drawn at, so prod and staging keep room to point at. */ +const FLOOR = 0.1; + +function Datasets(props: { data: Overview; onDestroy: () => void }) { + const [params, setParams] = useSearchParams<{ dataset?: string }>(); + const open = (dataset: string | undefined) => setParams({ dataset }, { replace: true }); + /** A dataset picked on the treemap, whose row scrolls into view once it opens. */ + const [reveal, setReveal] = createSignal(params.dataset); + const [hover, setHover] = createSignal(null); + const [region, setRegion] = createSignal<(typeof REGIONS)[number] | null>(null); + const rel = (name: string) => name.slice(props.data.pool.name.length + 1); + const groups = createMemo(() => REGIONS.map((region) => { + const members = props.data.datasets + .filter((dataset) => own(dataset) > 0 && REGIONS.findLast((r) => within(dataset.mountpoint ?? "", r.root)) === region) + .sort((a, b) => own(b) - own(a)); + const size = members.reduce((sum, dataset) => sum + own(dataset), 0); + return { region, members, size, held: members.reduce((sum, dataset) => sum + dataset.usedbysnapshots, 0) }; + }).filter((group) => group.size > 0)); + const blocks = createMemo(() => { + const total = groups().reduce((sum, group) => sum + group.size, 0); + const areas = groups().map((group) => ({ group, size: Math.max(group.size, total * FLOOR) })).sort((a, b) => b.size - a.size); + return squarify(areas, W, H).map(({ item: { group }, x, y, w, h }) => { + const scale = ((w - 2 * GAP) * (h - 2 * GAP)) / group.size; + const shown = group.members.filter((dataset) => own(dataset) * scale >= CELL); + const tiny = group.members.slice(shown.length); + const rest = tiny.reduce((sum, dataset) => sum + own(dataset), 0); + const items = [ + ...shown.map((dataset) => ({ dataset, size: own(dataset) })), + ...(tiny.length ? [{ dataset: null, size: Math.max(rest, CELL / scale) }] : []), + ]; + return { group, x, y, w, h, tiny, rest, cells: squarify(items, w - 2 * GAP, h - 2 * GAP) }; + }); + }); + + return ( + <> +
    +
      + + {(group) => { + return ( +
    • setRegion(group.region)} onPointerLeave={() => setRegion(null)} + onFocus={() => setRegion(group.region)} onBlur={() => setRegion(null)} + data-tip={`${group.region.root}${group.region.label === "clover" ? " without Media" : ""}: ` + + `${bytes(group.size - group.held)} used, ${bytes(group.held)} snapshots, ` + + `${percent((group.size / (props.data.space.live + props.data.space.held + props.data.space.free)) * 100)} of the pool`}> + {group.region.label}{bytes(group.size)} +
    • + ); + }} +
      +
    + + snapshots +
    + +
    + + + + + + + + + + + {(dataset) => { + /** Listed ancestors; staging is a plain folder, so a stage sits under srv as staging/. */ + const above = props.data.datasets.filter((other) => dataset.name.startsWith(other.name + "/")); + const depth = above.length; + const expanded = () => params.dataset === dataset.name; + let row!: HTMLTableRowElement; + createEffect(() => { + if (!expanded() || reveal() !== dataset.name) return; + row.scrollIntoView({ block: "start", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); + setReveal(undefined); + }); + return ( + <> + setHover(dataset.name)} onMouseLeave={() => setHover(null)}> + + + + + + + + + + + + + + + ); + }} + + +
    datasettotallivesnapshotscompressionquota +
    +
    + + + {(origin) => clone} + +
    +
    0 + ? `${bytes(dataset.usedbydataset + dataset.usedbysnapshots)} own, ` + + `${bytes(dataset.used - dataset.usedbydataset - dataset.usedbysnapshots)} in child datasets` + : undefined}> + {bytes(dataset.used)} + {bytes(dataset.usedbydataset)}{dataset.usedbysnapshots ? bytes(dataset.usedbysnapshots) : "–"}= 1.01 + ? `${dataset.compression}, ${bytes(dataset.logicalused)} before` : dataset.compression}> + {dataset.compressratio >= 1.01 ? `${dataset.compressratio.toFixed(2)}×` : "–"} + + {dataset.quota ? bytes(dataset.quota) : "–"} + +
    + + + + + + + + {(link) => } + +
    +
    +
    +
    +
    mounted at
    +
    {(mount) => }
    +
    +
    records
    {bytes(dataset.recordsize)}
    +
    compression
    {dataset.compression}
    + + {(origin) =>
    clone of
    {origin()}
    } +
    +
    + +
    +
    + + ); +} + +function Largest() { + const [, setParams] = useSearchParams(); + const [list, { refetch }] = createResource(() => parseResponse(api.storage.files.largest.$get())); + return ( +
    }> + {(list) => ( +
    + No files indexed yet. The largest show up here after the first scan.
    }> + + + + + {(file) => { + const cut = file.path.lastIndexOf("/"); + const open = () => setParams({ tab: "files", path: cut === -1 ? undefined : file.path.slice(0, cut) }); + return ( + + + + + + + ); + }} + + +
    filesizemodified
    {file.path.slice(0, cut + 1)}{file.path.slice(cut + 1)}{bytes(file.size)} +
    + + {(link) => } + +
    +
    + +
    + )} + + ); +} + +export function Storage() { + const [params] = useSearchParams<{ tab?: Tab; path?: string }>(); + const tab = () => params.tab ?? "datasets"; + const [data, { refetch }] = queries.storage.use(); + const overview = lastGood(data); + const apps = lastGood(queries.launcher.use()[0]); + /** Copyparty at the folder the map shows. */ + const files = () => { + const root = overview()?.datasets.find((dataset) => dataset.name === overview()!.pool.name)?.link; + return root && root + (params.path ?? "").split("/").map(encodeURIComponent).join("/"); + }; + return ( + +

    storage

    +
    + {(data) => } +
    + + }> + + + {([id, label, Icon]) => ( + {label} + )} + + + + + +
    + +
    + }> + {(data) => } +
    +
    + + + + + app.id === "copyparty") } : null} /> + + + + }> + {(data) => } + + +
    +
    + ); +} diff --git a/dashboard/web/pages/Users.css b/dashboard/web/pages/Users.css new file mode 100644 index 0000000000000000000000000000000000000000..01a3df8427c9805478a1cf3431e8bad5724af4ed --- /dev/null +++ b/dashboard/web/pages/Users.css @@ -0,0 +1,96 @@ +/* list ------------------------------------------------------------------- */ + +.card.access { display: grid; padding: 4px; margin-bottom: 2px; } + +.access-row { + display: grid; + grid-template-columns: 112px 36px 1fr; + align-items: center; + gap: 12px; + min-height: 30px; + padding: 3px 10px; + border: 0; + border-radius: var(--radius); + background: none; + text-align: left; + cursor: pointer; + transition: background-color 150ms; +} + +.access-row:hover { background: var(--hover); } +.access-row[aria-pressed="true"] { background: var(--accent-wash); } +.access-row .who { font-weight: 600; } +.access-row[aria-pressed="true"] .who { color: var(--accent); } +.access-row .num { color: var(--muted); } + +.grants { display: flex; flex-wrap: wrap; gap: 2px 14px; font-size: 12.5px; color: var(--text-2); } +.grant { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } +.grant :is(img, .monogram, .icon) { flex: none; width: 15px; height: 15px; font-size: 9px; border-radius: 22%; } +.grant.dash .icon { color: var(--muted); border-radius: 0; } + +.users-toolbar { display: flex; align-items: center; gap: 8px; margin: 6px 0 8px; } +.users-toolbar .count { margin-left: 6px; opacity: 0.7; font-variant-numeric: tabular-nums; } + +.users-toolbar .search-box { width: 260px; } + +.chip.filter { display: inline-flex; align-items: center; gap: 4px; background: var(--accent-wash); color: var(--accent); } +.chip.filter:hover { background: var(--raised); color: var(--text); } + +.users-table { overflow: auto; } +.users-table table.data td { padding-block: 5px; white-space: nowrap; } +.users-table tbody tr { cursor: pointer; } +.users-table tbody tr:hover { background: var(--hover); } +.users-table tr.disabled td { color: var(--muted); } +.users-table td.username a { font-weight: 600; border-radius: 3px; } +.users-table tr.disabled td.username a { font-weight: 500; } +.users-table td.groups { width: 1%; } +.users-table td.groups .chips { flex-wrap: nowrap; } + +/* user page -------------------------------------------------------------- */ + +.user-page .page-head { margin-bottom: 12px; } +.user-grid { display: flex; flex-wrap: wrap; gap: 8px; align-items: start; } +.user-grid .column { flex: 1 1 320px; display: grid; gap: 8px; min-width: 0; } +.user-grid .card { padding: 10px 14px 12px; } +.user-grid h2.card-title { margin: 0 0 8px; color: var(--text); align-items: center; min-height: 26px; } +.user-grid h2.card-title.opens { margin-top: 14px; } +.user-grid p { margin: 0; font-size: 13px; } +.user-grid table.data td { padding: 4px 6px; } +.user-grid table.data tr:last-child td { border-bottom: 0; } + +.toggles { display: flex; flex-wrap: wrap; gap: 6px; } + +/* A group or step that's on or off: a button that acts at once, or a checkbox in a form. */ +.chip.toggle { position: relative; padding: 0 10px; border: 1px dashed var(--axis); background: none; color: var(--text-2); cursor: pointer; } +.chip.toggle:hover:not(:disabled) { border-color: var(--accent); background: none; color: var(--text); } +.chip.toggle:is([aria-pressed="true"], :has(:checked)) { border: 1px solid var(--accent); background: var(--accent-wash); color: var(--text); } +.chip.toggle:disabled { opacity: 0.6; cursor: progress; } +.chip.toggle input { position: absolute; opacity: 0; pointer-events: none; } +.chip.toggle:has(:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; } + +.fields { display: grid; grid-template-columns: max-content 1fr; align-items: center; gap: 4px 14px; font-size: 13px; } +.fields .label { color: var(--muted); } +.fields .field { display: grid; gap: 2px; min-width: 0; } +.field-error { color: color-mix(in srgb, var(--critical) 80%, var(--text)); font-size: 12px; } + +input.inline { + width: 100%; + height: 28px; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--well); + color: var(--text); + font: inherit; + transition: border-color 150ms, box-shadow 150ms; +} + +input.inline:hover { border-color: var(--axis); } +input.inline:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-wash); } +input.inline[aria-invalid="true"] { border-color: var(--critical); } +input.inline[aria-busy="true"] { cursor: progress; opacity: 0.7; } +input.inline::placeholder { color: var(--muted); } + +a.grant { border-radius: 3px; transition: color 150ms; } +a.grant:hover { color: var(--accent); } +.passkeys span + span::before { content: ", "; } diff --git a/dashboard/web/pages/Users.tsx b/dashboard/web/pages/Users.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f86a82ea901604e4f667ff6e8f9368304cb6acf0 --- /dev/null +++ b/dashboard/web/pages/Users.tsx @@ -0,0 +1,697 @@ +import { A, useNavigate, useParams, useSearchParams } from "@solidjs/router"; +import ArrowLeft from "lucide-solid/icons/arrow-left"; +import Search from "lucide-solid/icons/search"; +import UserPlus from "lucide-solid/icons/user-plus"; +import X from "lucide-solid/icons/x"; +import { type Accessor, createSignal, For, type Resource, Show } from "solid-js"; +import { Dynamic } from "solid-js/web"; +import { parseResponse } from "hono/client"; +import { canOpen, sectionsOf, type ServiceSummary } from "../types/model.ts"; +import type { Group } from "../types/users.ts"; +import { api, query, reason } from "../api.ts"; +import { Ago } from "../components/Ago.tsx"; +import { Checkbox } from "../components/Checkbox.tsx"; +import { AppIcon } from "../components/AppIcon.tsx"; +import { Copy } from "../components/Copy.tsx"; +import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx"; +import { ListPage } from "../components/ListPage.tsx"; +import { OpenApp } from "../components/OpenApp.tsx"; +import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; +import { Reveal } from "../components/Reveal.tsx"; +import { PAGES } from "../components/Sidebar.tsx"; +import { TabBar } from "../components/TabBar.tsx"; +import { toast } from "../components/Toast.tsx"; +import { ago, count, date, datetime, plural } from "../format.ts"; +import "./Users.css"; + +const STEPS: [string, string][] = [ + ["VERIFY_EMAIL", "verify email"], + ["UPDATE_PASSWORD", "new password"], + ["UPDATE_PROFILE", "check profile"], + ["CONFIGURE_TOTP", "add authenticator"], + ["webauthn-register-passwordless", "add passkey"], +]; + +const STATES = { all: "all", disabled: "disabled", pending: "setup pending" } as const; + +type Params = { q?: string; group?: string; state?: keyof typeof STATES }; + +const load = () => Promise.all([parseResponse(api.users.$get()), parseResponse(api.services.$get())]) + .then(([{ users, groups }, services]) => ({ users, groups, services })); +type Data = Awaited>; +export const directory = query("users", load); +const credentialsOf = query("credentials", (id: string) => parseResponse(api.users[":id"].credentials.$get({ param: { id } }))); +type User = Data["users"][number]; + +const fullName = (user: User) => [user.firstName, user.lastName].filter(Boolean).join(" "); +const names = (user: User) => user.groups.map((group) => group.name); +const inState = (user: User, state: keyof typeof STATES) => + state === "all" || (state === "disabled" ? !user.enabled : user.enabled && user.requiredActions.length > 0); + +/** The apps a set of groups opens, and dashboard pages; `everything` when nothing is out of reach. */ +function reach(groups: string[], services: ServiceSummary[]) { + const apps = services.filter((app) => app.url); + const open = { + apps: apps.filter((app) => canOpen(groups, app.access)), + pages: PAGES.filter((page) => sectionsOf(groups).includes(page.section)), + }; + return { ...open, everything: open.apps.length === apps.length && open.pages.length === PAGES.length }; +} + +/** What joining `group` opens beyond what every signed-in user already can. */ +function adds(group: string, services: ServiceSummary[]) { + const base = reach([], services); + const own = reach([group], services); + return { + everything: own.everything, + apps: own.apps.filter((app) => !base.apps.includes(app)), + pages: own.pages.filter((page) => !base.pages.includes(page)), + }; +} + +/** "30 members · opens Jellyfin, Navidrome" */ +function groupTip(group: string, d: Data) { + const more = adds(group, d.services); + const members = d.users.filter((user) => names(user).includes(group)).length; + const metrics = sectionsOf([group]).includes("metrics") ? ["machine metrics"] : []; + const opens = more.everything ? "everything" + : [...more.apps.map((app) => app.name), ...more.pages.map((page) => page.label), ...metrics].join(", "); + return `${plural(members, "member")} · opens ${opens}`; +} + +/** The app behind a Keycloak `clientId`, which is its service id. */ +const appName = (clientId: string, services: ServiceSummary[]) => + services.find((service) => service.id === clientId)?.name ?? clientId; + +/** "active 3h ago in Jellyfin, Shale", from their open sessions. */ +function seen(user: User, services: ServiceSummary[]) { + if (!user.sessions.length) return "not signed in"; + const last = Math.max(...user.sessions.map((session) => session.lastAccess)); + const used = [...new Set(user.sessions.flatMap((session) => Object.values(session.clients)))].map((id) => appName(id, services)); + return `active ${ago(last / 1000)} in ${used.join(", ")}`; +} + +/** Tailscale hands out 100.64.0.0/10; the rest of the private ranges are the home network. */ +function network(ip: string) { + const [a, b] = ip.split(".").map(Number) as [number, number]; + if (a === 100 && b >= 64 && b < 128) return "via Tailscale"; + if (a === 10 || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168)) return "local network"; +} + +/** Keycloak's admin console at `path` inside the realm. */ +function KeycloakLink(props: { services: ServiceSummary[] | undefined; path: string }) { + return ( + service.id === "keycloak" && service.url)}> + {(keycloak) => } + + ); +} + +/** Where the list was scrolled when a user page opened, so going back lands in the same place. */ +let listScroll = 0; +/** Whether the open user page was reached from the list, so "back" can return to it with its filters. */ +let fromList = false; + +export function Users() { + const params = useParams<{ name?: string }>(); + const [data, { refetch }] = directory.use(); + const ready = lastGood(data); + return ( + }> + {(name) => } + + ); +} + +function StateTag(props: { user: User }) { + return ( + disabled
    }> + + STEPS.find(([step]) => step === action)?.[1] ?? action).join(", ")}> + setup pending + + + + ); +} + +/** Apps and dashboard pages; `used` links each to its page and tips an app with when it was last used. */ +function Grants(props: { apps: ServiceSummary[]; pages: typeof PAGES; used?: Map }) { + const tag = () => (props.used ? A : "span"); + return ( + + + {(app) => ( + + {app.name} + + )} + + + {(page) => ( + + {page.label} + + )} + + + ); +} + +function List(props: { data: Resource; ready: Accessor; refetch: () => void }) { + const [params, setParams] = useSearchParams(); + const navigate = useNavigate(); + const state = () => params.state ?? "all"; + const ready = props.ready; + fromList = false; + const pick = (group?: string) => setParams({ group: group === params.group ? undefined : group }); + const href = (user: User) => `/users/${user.username}`; + const open = (user: User) => { + fromList = true; + navigate(href(user)); + }; + const shown = (users: User[]) => { + const q = params.q?.trim().toLowerCase() ?? ""; + return users + .filter((user) => !q || [user.username, fullName(user), user.email, ...names(user)].some((v) => v?.toLowerCase().includes(q))) + .filter((user) => !params.group || names(user).includes(params.group)) + .filter((user) => inState(user, state())) + .toSorted((a, b) => a.username.localeCompare(b.username)); + }; + const create = (groups: Group[]) => showConfirmDialog({ + title: "New user", + confirmLabel: "create user", + body: () => { + const [invite, setInvite] = createSignal(true); + return ( + <> + + +
    + + +
    +
    + groups + + + {(g) => } + + +
    +
    + first sign-in + + + + +
    + + + + + ); + }, + onConfirm: async (form) => { + const text = (name: string) => String(form.get(name) ?? ""); + const { id } = await parseResponse(api.users.$post({ + json: { + profile: { username: text("username"), email: text("email"), firstName: text("firstName"), lastName: text("lastName") }, + groups: form.getAll("groups").map(String), + setup: form.has("password") ? { kind: "password", password: text("password") } : { kind: "email" }, + }, + })); + await props.refetch(); + const user = ready()?.users.find((user) => user.id === id); + if (user) open(user); + }, + }); + + return ( + +

    users

    + + + + + } summary={ + + + {(_, i) => ( +
    + + + +
    + )} +
    + + )}> + {(d) => { + const everyone = () => reach([], d().services); + const rows = () => d().groups.map((group) => ({ + group, ...adds(group.name, d().services), + members: d().users.filter((user) => names(user).includes(group.name)).map((user) => user.username).sort(), + })); + const preview = (members: string[]) => + members.length > 6 ? `${members.slice(0, 5).join(", ")} +${members.length - 5} more` : members.join(", "); + return ( +
    + + + {(row) => ( + + )} + +
    + ); + }} +
    + }> + +
    +
    + + }> + {(d) => ( + <> +
    + + + + + + + + {([key, label]) => ( + + )} + + +
    +
    requestAnimationFrame(() => (el.scrollTop = listScroll))} + onScroll={(event) => (listScroll = event.currentTarget.scrollTop)}> + + No users match.{" "} + +
    + }> + + + + + {(user) => ( + !(event.target as Element).closest("a, button") && open(user)}> + + + + + + )} + + +
    usernamenameemailgroups
    + + (fromList = true)} data-tip={seen(user, d().services)}> + {user.username} + + + + {fullName(user) || "–"} +
    + + {(g) => ( + + )} + +
    +
    + + + + )} +
    +
    + ); +} + +/** Back to the list, through history when it's the page behind this one, so its filters and scroll come back. */ +function useBack() { + const navigate = useNavigate(); + return () => (fromList ? history.back() : navigate("/users")); +} + +function Person(props: { name: string; data: Resource; refetch: () => unknown }) { + const back = useBack(); + return ( + +
    +
    + + {() => ( +
    +
    +
    +
    + )} + +
    +
    + }> + {(d) => ( + user.username === props.name)} fallback={ +
    +
    + No user named {props.name}.{" "} + +
    +
    + }> + {(user) => } +
    + )} + + ); +} + +function Profile(props: { user: User; data: Data; refetch: () => unknown }) { + const navigate = useNavigate(); + const back = useBack(); + const param = () => ({ id: props.user.id }); + const [credentials, credentialActions] = credentialsOf.use(() => props.user.id); + const [busy, setBusy] = createSignal(); + + const patch = async (json: Parameters[0]["json"]) => { + await parseResponse(api.users[":id"].$patch({ param: param(), json })); + await props.refetch(); + }; + /** Runs a quick change, marking `key` busy and toasting the reason if it fails. */ + const run = async (key: string, change: () => Promise) => { + setBusy(key); + try { + await change(); + } catch (failure) { + toast(reason(failure)); + } finally { + setBusy(); + } + }; + const member = (group: Group) => props.user.groups.some((g) => g.id === group.id); + const toggleGroup = (group: Group) => run(group.id, async () => { + const route = api.users[":id"].groups[":group"]; + const args = { param: { ...param(), group: group.id } }; + await parseResponse(member(group) ? route.$delete(args) : route.$put(args)); + await props.refetch(); + }); + const setEnabled = (enabled: boolean) => run("enabled", async () => { + await patch({ enabled }); + if (!enabled) toast(`${props.user.username} can't sign in now`, { label: "undo", run: () => patch({ enabled: true }) }); + }); + const toggleStep = (step: string) => run(step, () => patch({ + requiredActions: props.user.requiredActions.includes(step) + ? props.user.requiredActions.filter((a) => a !== step) + : [...props.user.requiredActions, step], + })); + const emailSteps = () => run("email", async () => { + await parseResponse(api.users[":id"]["actions-email"].$post({ param: param() })); + toast(`Emailed ${props.user.email} a link`); + }); + + const setPassword = () => showTextDialog({ + title: `Set ${props.user.username}'s password`, + label: "new password, at least 8 characters", + body: ask for a new one at next sign-in, + confirmLabel: "set password", + validateInput: (value) => value.length >= 8, + onConfirm: async (password, form) => { + await parseResponse(api.users[":id"].password.$put({ param: param(), json: { password, temporary: form.has("temporary") } })); + await Promise.all([props.refetch(), credentialActions.refetch()]); + }, + }); + const signOut = (sessions: number) => showConfirmDialog({ + title: `Sign ${props.user.username} out?`, + description: `${sessions === 1 ? "Ends their one session" : `Ends all ${sessions} sessions`}. They can sign in again.`, + confirmLabel: "sign out", + onConfirm: async () => { + await parseResponse(api.users[":id"].logout.$post({ param: param() })); + await props.refetch(); + }, + }); + const remove = () => { + const name = props.user.username; + showTextDialog({ + title: `Delete ${name}?`, + description: `${name} is signed out and loses access to everything. This can't be undone.`, + label: `type ${name} to confirm`, + validateInput: (value) => value === name, + confirmLabel: "delete user", + destructive: true, + onConfirm: async () => { + await parseResponse(api.users[":id"].$delete({ param: param() })); + back(); + await props.refetch(); + toast(`Deleted ${name}`); + }, + }); + }; + + const access = () => reach(props.user.enabled ? names(props.user) : [], props.data.services); + /** Last use of each app, by the Keycloak `clientId` its sessions went through. */ + const used = () => { + const last = new Map(); + for (const session of props.user.sessions) { + for (const id of Object.values(session.clients)) last.set(id, Math.max(last.get(id) ?? 0, session.lastAccess)); + } + return last; + }; + + return ( +
    +
    + +

    {props.user.username}

    + {fullName(props.user)} + + + + +
    + +
    +
    +
    +

    groups

    +
    + + {(group) => ( + + )} + +
    +

    + can open +

    + nothing while disabled

    }> + everything

    }> + +
    +
    +
    +
    +

    + sign-in + + +

    + }> + {(list) => { + const password = () => list().find((c) => c.type === "password"); + const passkeys = () => list().filter((c) => c.type.startsWith("webauthn")); + const otp = () => list().find((c) => c.type === "otp"); + return ( +
    +
    password
    +
    {password() ? `set ${date(password()!.createdDate / 1000)}` : "none"}
    +
    passkeys
    +
    + + {(c) => {c.userLabel ?? "unnamed"}} + +
    +
    authenticator
    +
    {otp() ? `added ${date(otp()!.createdDate / 1000)}` : "none"}
    +
    + ); + }} +
    +

    + next sign-in + + +

    +
    + + {([step, label]) => ( + + )} + +
    +
    +
    + +
    +
    +

    profile

    +
    + { + await parseResponse(api.users[":id"].$patch({ param: param(), json: { username } })); + await props.refetch(); + navigate(`/users/${username.trim().toLowerCase()}`, { replace: true }); + }} /> + patch({ email })} /> + + run("verified", () => patch({ emailVerified }))}> + verified + + patch({ firstName })} /> + patch({ lastName })} /> + sign-in + allowed + created + +
    +
    + +
    +

    + sessions + + +

    + Not signed in anywhere

    }> + + + + {(session) => ( + + + + + + )} + + +
    {session.ipAddress}{Object.values(session.clients).map((id) => appName(id, props.data.services)).join(", ")} + +
    +
    +
    +
    +
    +
    + ); +} + +/** A profile value that is always an input: saves on Enter or blur, Esc puts the old value back. */ +function Field(props: { label: string; value: string | null; type?: string; onSave: (value: string) => Promise }) { + const [error, setError] = createSignal(""); + const [pending, setPending] = createSignal(false); + const id = `field-${props.label.replace(/\W+/g, "-")}`; + return ( + <> + + + { + const input = event.currentTarget; + if (input.value === (props.value ?? "")) return setError(""); + setPending(true); + try { + await props.onSave(input.value); + setError(""); + } catch (failure) { + setError(reason(failure)); + } finally { + setPending(false); + } + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = props.value ?? ""; + setError(""); + event.currentTarget.blur(); + } + }} /> + {error()} + + + ); +} diff --git a/dashboard/web/pages/VMs.css b/dashboard/web/pages/VMs.css new file mode 100644 index 0000000000000000000000000000000000000000..9533a2c9934f5a5ecdc54a38b284af92e044bfbe --- /dev/null +++ b/dashboard/web/pages/VMs.css @@ -0,0 +1,79 @@ +.vms-list th:not(:first-child), .vms-list tr.top > td:not(.title) { width: 1%; white-space: nowrap; } +.vms-list .row-skeleton { height: 36px; margin: 14px var(--gutter); } + +/* Each VM is a tbody of two lines; the actions cell spans both. */ +.vms-list tbody.vm tr:not(.expanded) { cursor: pointer; } +.vms-list tbody.vm tr:not(.expanded) > td { transition: background-color 150ms; } +.vms-list tbody.vm:has(tr:not(.expanded):hover) tr:not(.expanded) > td { background: var(--hover); } +.vms-list tbody.vm.open tr:not(.expanded) > td { background: var(--accent-wash); } +.vms-list tr.top > td { padding-top: 8px; padding-bottom: 0; } +.vms-list tr.bottom > td { padding-top: 2px; padding-bottom: 8px; font-size: 12px; color: var(--muted); } +.vms-list tbody.off tr.bottom > td.num { color: var(--text-2); } +.vms .vms-list table.data tbody.vm tr.bottom > td:last-child { padding-right: 8px; } +/* A row-spanning cell loses its collapsed bottom border to the next row, so each VM's rule is drawn as a shadow. */ +.vms-list tbody.vm td { border-bottom: 0; } +.vms-list tbody.vm:not(.open) :is(tr.bottom > td, td.actions) { box-shadow: inset 0 -1px var(--grid); } +.vms-list td:focus-visible { outline-offset: -2px; } + +.vms-list td.title { max-width: 0; } +.vms-list td.title button { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + white-space: nowrap; + cursor: pointer; +} +.vms-list td.title button:focus-visible { outline-offset: 2px; } +.vm-name { font-weight: 600; } +.vms-list tbody.off .vm-name { color: var(--text-2); } +.vms-list .for { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--text-2); } +.vms-list td.specs { max-width: 0; } +.vms-list td.specs > div { display: flex; flex-wrap: wrap; gap: 2px 12px; cursor: default; } +.vms-list td.specs span { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; } +.vms-list td.specs svg { flex: none; } +.vms-list td.specs .mono { color: var(--text-2); } + + +.vms-list tr.expanded > td { background: var(--panel); padding-block: 10px 12px; } +.vm-detail { display: grid; gap: 10px; } +.vm-detail .off-hour { margin: 0; color: var(--muted); font-size: 12px; } +.vm-charts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.vm-charts .skeleton { height: 104px; } +.vm-detail .facts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 16px 28px; } +.vm-detail .kv { align-content: start; } +.vm-detail .settings { display: grid; gap: 12px; align-content: start; } +.vm-detail .describe { + margin: 0 -4px; + padding: 0 4px; + border: 0; + border-radius: 4px; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: text; + transition: background-color 150ms; +} +.vm-detail .describe:hover { background: var(--hover); } +.vm-detail .describe.empty { color: var(--accent); cursor: pointer; } +.vm-detail .lines { display: grid; gap: 4px 10px; align-items: center; } +.vm-detail .lines > div { display: contents; } +.vm-detail .lines.disks { grid-template-columns: auto minmax(0, 1fr) 72px auto; } +.vm-detail .lines.disks .meter { min-width: 0; height: 5px; } +.vm-detail .lines.nics { grid-template-columns: auto auto minmax(0, 1fr); } +.vm-detail .lines.devices { grid-template-columns: auto minmax(0, 1fr); } +.vm-detail .lines .source { overflow-wrap: anywhere; } +.vm-actions { display: flex; gap: 6px; } + +.vms-empty { display: grid; justify-items: center; gap: 8px; } +.vms-empty p { margin: 0; } + +.dialog .vm-sizes { align-items: start; } +.dialog .vm-checks { display: flex; gap: 18px; } diff --git a/dashboard/web/pages/VMs.tsx b/dashboard/web/pages/VMs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..617d1422ba9555d2266458970409fdebe9787116 --- /dev/null +++ b/dashboard/web/pages/VMs.tsx @@ -0,0 +1,535 @@ +import { useSearchParams } from "@solidjs/router"; +import CircuitBoard from "lucide-solid/icons/circuit-board"; +import HardDrive from "lucide-solid/icons/hard-drive"; +import Network from "lucide-solid/icons/network"; +import Plus from "lucide-solid/icons/plus"; +import Power from "lucide-solid/icons/power"; +import Usb from "lucide-solid/icons/usb"; +import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; +import { LIVE_INTERVAL } from "../types/model.ts"; +import type { Domain, DOMAIN_ACTIONS, Hostdev, Image } from "../types/vms.ts"; +import { parseResponse } from "hono/client"; +import { api, queries, reason, unconnected } from "../api.ts"; +import { Checkbox } from "../components/Checkbox.tsx"; +import { Copy } from "../components/Copy.tsx"; +import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx"; +import { ListPage } from "../components/ListPage.tsx"; +import { lastGood, Loaded } from "../components/Loaded.tsx"; +import { Meter } from "../components/Meter.tsx"; +import { type StatusKind, StatusLabel } from "../components/Status.tsx"; +import { TimeChart } from "../components/TimeChart.tsx"; +import { toast } from "../components/Toast.tsx"; +import { bytes, datetime, duration, percent, plural } from "../format.ts"; +import "./VMs.css"; + +const GiB = 2 ** 30; +/** A guest whose agent hasn't answered this long after power-on counts as up anyway; many never install one. */ +const STARTING = 180; + +const REASONS: Record = { + user: "paused manually", + ioerror: "paused after a disk error, check the vms pool's free space", + watchdog: "paused by its watchdog", + panicked: "the guest kernel panicked", +}; + +const isOff = (vm: Domain) => vm.state === "shutoff" || vm.state === "crashed"; +const running = (vm: Domain) => vm.state === "running" || vm.state === "blocked"; + +function status(vm: Domain): [StatusKind, string, tip?: string] { + if (running(vm)) { + if (vm.startedAt === null) return ["healthy", "up"]; + const up = Date.now() / 1000 - vm.startedAt!; + const started = `started ${datetime(vm.startedAt!)}`; + if (vm.agent !== "disconnected") return ["healthy", `up ${duration(up)}`, started]; + if (up < STARTING) return ["starting", "starting", started]; + return ["healthy", `up ${duration(up)}`, `${started}, guest agent not answering`]; + } + const tip = vm.reason ? REASONS[vm.reason] ?? vm.reason : undefined; + switch (vm.state) { + case "paused": return ["stopped", "paused", tip]; + case "pmsuspended": return ["stopped", "asleep", tip]; + case "shutdown": return ["restarting", "stopping"]; + case "crashed": return ["down", "crashed", tip]; + default: return ["stopped", "off"]; + } +} + +/** "/dev/disk/by-id/nvme-Samsung_SSD_980_PRO_1TB_S5GX…" reads as "Samsung SSD 980 PRO 1TB": no bus, no serial. */ +const drive = (path: string) => path.split("/").at(-1)!.replace(/^[a-z]+-/, "").replace(/_[^_]+$/, "").replaceAll("_", " "); + +/** [8, 9, 10, 12] reads "8–10, 12". */ +const threads = (list: number[]) => list + .reduce<[number, number][]>((runs, n) => { + const last = runs.at(-1); + if (last && n === last[1] + 1) last[1] = n; + else runs.push([n, n]); + return runs; + }, []) + .map(([from, to]) => (from === to ? `${from}` : `${from}–${to}`)) + .join(", "); + +/** Current and max memory, like "16 GiB", or "8–12 GiB" while the balloon holds some back. */ +const allocated = (vm: Domain) => (vm.balloon < vm.memory ? `${+(vm.balloon / GiB).toFixed(1)}–${bytes(vm.memory)}` : bytes(vm.memory)); + +/** Remote desktop for Windows, the web UI for Home Assistant, ssh for the rest. */ +function remote(os: string, address: string): [href: string, label: string] { + const host = address.includes(":") ? `[${address}]` : address; + if (os.startsWith("Windows")) return [`rdp://full%20address=s:${host}:3389`, "rdp"]; + if (os.startsWith("Home Assistant")) return [`http://${host}:8123`, "open"]; + return [`ssh://${host}`, "ssh"]; +} + +/** Devices sharing an IOMMU group pass through together, so a GPU and its audio function read as one. */ +function groups(devices: Hostdev[]) { + const byGroup = new Map(); + for (const dev of devices) byGroup.set(dev.iommuGroup ?? dev, [...(byGroup.get(dev.iommuGroup ?? dev) ?? []), dev]); + return [...byGroup.values()]; +} + +async function act(vm: Domain, action: (typeof DOMAIN_ACTIONS)[number], refetch: () => unknown) { + await parseResponse(api.vms[":name"][":action"].$post({ param: { name: vm.name, action } })); + await refetch(); +} + +const stop = (vm: Domain, refetch: () => unknown) => showConfirmDialog({ + title: `Stop ${vm.name}?`, + description: `${vm.name} shuts down as if its power button were pressed. If it hangs, force it off.`, + confirmLabel: "stop", + destructive: true, + onConfirm: () => act(vm, "shutdown", refetch), +}); + +const restart = (vm: Domain, refetch: () => unknown) => showConfirmDialog({ + title: `Restart ${vm.name}?`, + description: `${vm.name} shuts down and boots again. Anyone using it is interrupted.`, + confirmLabel: "restart", + onConfirm: () => act(vm, "reboot", refetch), +}); + +const forceOff = (vm: Domain, refetch: () => unknown) => showConfirmDialog({ + title: `Force off ${vm.name}?`, + description: `${vm.name} loses power without shutting down. Unsaved work in it is lost.`, + confirmLabel: "force off", + destructive: true, + onConfirm: () => act(vm, "destroy", refetch), +}); + +const describe = (vm: Domain, refetch: () => unknown) => showTextDialog({ + title: `Describe ${vm.name}`, + label: "what it's for", + placeholder: "game streaming to the TV…", + initialValue: vm.description, + validateInput: () => true, + confirmLabel: "save", + onConfirm: async (description) => { + await parseResponse(api.vms[":name"].$patch({ param: { name: vm.name }, json: { description } })); + await refetch(); + }, +}); + +function remove(vm: Domain, refetch: () => unknown) { + const volumes = vm.disks.filter((disk) => disk.pool); + const size = bytes(volumes.reduce((sum, disk) => sum + disk.capacity, 0)); + showTextDialog({ + title: `Delete ${vm.name}?`, + description: () => ( + <> +

    {isOff(vm) ? "" : `${vm.name} is forced off first. `}This can't be undone.

    + volumes.length}>

    Passed-through drives are left as they are.

    + + ), + label: `type ${vm.name} to confirm`, + body: volumes.length ? () => ( + + {volumes.length === 1 ? `delete its disk too, ${size}` : `delete its ${volumes.length} disks too, ${size}`} + + ) : undefined, + validateInput: (value) => value === vm.name, + confirmLabel: "delete", + destructive: true, + onConfirm: async (_, form) => { + await parseResponse(api.vms[":name"].$delete({ param: { name: vm.name }, query: { disks: form.has("disks") ? "1" : "0" } })); + await refetch(); + }, + }); +} + +function create(node: { cpus: number; memory: number }, images: Image[], domains: Domain[], refetch: () => unknown) { + const free = node.memory - domains.filter((vm) => !isOff(vm)).reduce((sum, vm) => sum + vm.balloon, 0); + showConfirmDialog({ + title: "New virtual machine", + confirmLabel: "create", + body: () => { + const [name, setName] = createSignal(""); + const [image, setImage] = createSignal(images[0]); + const problem = () => (domains.some((vm) => vm.name === name()) ? `${name()} already exists. Pick another name` : ""); + return ( + <> + + + + + {(image) => ( +
    + + + +
    + )} +
    +
    + start now + start with the host +
    + + ); + }, + onConfirm: async (form) => { + await parseResponse(api.vms.$post({ + json: { + name: String(form.get("name")), + description: String(form.get("description")), + image: String(form.get("image")), + vcpus: Number(form.get("vcpus")), + memory: Math.round(Number(form.get("memory")) * GiB), + disk: Math.round(Number(form.get("disk")) * GiB), + autostart: form.has("autostart"), + start: form.has("start"), + }, + })); + await refetch(); + }, + }); +} + +function Detail(props: { vm: Domain; refetch: () => unknown }) { + const [history, { refetch }] = createResource(() => props.vm.name, (name) => + parseResponse(api.vms.history.$get({ query: { range: "3600", name } }))); + const timer = setInterval(refetch, 30_000); + onCleanup(() => clearInterval(timer)); + const [saving, setSaving] = createSignal(false); + const setAutostart = async (autostart: boolean) => { + setSaving(true); + try { + await parseResponse(api.vms[":name"].$patch({ param: { name: props.vm.name }, json: { autostart } })); + await props.refetch(); + } catch (failure) { + toast(`Couldn't change autostart. ${reason(failure)}`); + } finally { + setSaving(false); + } + }; + return ( +
    +
    + }> + {(latest) => { + const samples = () => latest().domains[props.vm.name]; + return ( + v !== null)} fallback={

    Off for the last hour

    }> +
    + + +
    +
    + ); + }} + +
    +
    +
    +
    for
    +
    + +
    +
    autostart
    +
    + start with the host +
    +
    cpu
    +
    {props.vm.pinned ? `pinned to threads ${threads(props.vm.pinned)}` : "floats over all threads"}
    +
    +
    + + + + + + + +
    +
    +
    +
    disks
    +
    + + {(disk) => ( +
    + {disk.target} + + + {disk.pool ? disk.source : drive(disk.source)} + + + whole drive}> + + + + + {bytes(disk.capacity)} +
    + )} +
    +
    +
    network
    +
    + + {(nic) => ( +
    + {nic.source} + + {running(props.vm) ? "no address" : ""}}> + {(address) => } + + + +
    + )} +
    +
    + +
    passthrough
    +
    + + {(dev) => ( +
    + {dev.address.replace(/^0000:/, "")} + {dev.name} +
    + )} +
    +
    +
    +
    +
    +
    + ); +} + +function Row(props: { + vm: Domain; + hostCpus: number; + open: boolean; + onToggle: () => void; + refetch: () => unknown; +}) { + const [pending, setPending] = createSignal(false); + const state = () => status(props.vm); + const run = async (action: "start" | "resume") => { + setPending(true); + try { + await act(props.vm, action, props.refetch); + } catch (failure) { + toast(`Couldn't ${action} ${props.vm.name}. ${reason(failure)}`); + } finally { + setPending(false); + } + }; + const pinned = () => props.vm.pinned && `pinned to threads ${threads(props.vm.pinned)}`; + const cpuTip = () => { + if (!props.vm.usage) return pinned() || undefined; + const busy = (props.vm.usage.cpu / 100) * props.vm.vcpus; + return [`${busy.toFixed(1)} of ${props.vm.vcpus} vcpus busy, ${percent((busy / props.hostCpus) * 100)} of the host`, pinned()] + .filter(Boolean).join(", "); + }; + const memoryTip = () => { + const used = props.vm.usage?.memory; + const grows = props.vm.balloon < props.vm.memory && `can grow to ${bytes(props.vm.memory)}`; + if (used === undefined) return grows ? `boots with ${bytes(props.vm.balloon)}, ${grows}` : undefined; + return [`${bytes(used)} / ${bytes(props.vm.balloon)} (${percent((used / props.vm.balloon) * 100)})`, grows] + .filter(Boolean).join(", "); + }; + return ( + + + + + + + {state()[1]} + + {props.vm.usage && percent(props.vm.usage.cpu)} + {props.vm.usage && bytes(props.vm.usage.memory)} + event.stopPropagation()}> +
    + nic.addresses)[0]}> + {(value) => { + const target = () => remote(props.vm.os, value()); + return {target()[1]}; + }} + + + + + + + + + + + + + +
    + + + + +
    event.stopPropagation()}> + {props.vm.os} + + {(nic) => ( + + + {(value) => } + + )} + + + {(group) => ( + `with ${dev.address.replace(/^0000:/, "")} ${dev.name}`), + ].filter(Boolean).join(", ")}> + {group[0]!.iommuGroup === null ? : } + {group[0]!.name.match(/\[(.+)\]/)?.[1] ?? group[0]!.name} + + )} + + + {(disk) => ( + + {bytes(disk.capacity)} + + )} + + +
    + + {plural(props.vm.vcpus, "vcpu")} + {allocated(props.vm)} + + + event.stopPropagation()}> + + + + + ); +} + +export function VMs() { + const [data, { refetch }] = queries.vms.use(); + const timer = setInterval(() => data.loading || unconnected(data.error) || refetch(), LIVE_INTERVAL * 1000); + onCleanup(() => clearInterval(timer)); + const [params, setParams] = useSearchParams<{ vm?: string }>(); + const loaded = lastGood(data); + const newVm = () => { + const latest = loaded(); + if (latest) create(latest.node, latest.images, latest.domains, refetch); + }; + const newButton = (label: string) => ( + + ); + + return ( + +

    virtual machines

    + + {newButton("new vm")} +
    + }> + + {() =>
    } +
    + }> + {(latest) => ( + +

    No virtual machines yet.

    + {newButton("create vm")} +
    + }> +
    + + + + + + + + + + + vm.name)}> + {(name) => ( + vm.name === name)}> + {(vm) => ( + setParams({ vm: params.vm === name ? undefined : name }, { replace: true })} + refetch={refetch} /> + )} + + )} + +
    namestatecpumemoryactions
    +
    + + )} +
    + + ); +} diff --git a/dashboard/web/pages/YouTube.css b/dashboard/web/pages/YouTube.css new file mode 100644 index 0000000000000000000000000000000000000000..ffb08b9db34a9d508f44b97e232b80caeebf4f7e --- /dev/null +++ b/dashboard/web/pages/YouTube.css @@ -0,0 +1,128 @@ +.yt-meta { color: var(--muted); font-size: 12px; } + +.yt-banner { + margin-bottom: 8px; + padding: 8px 12px; + border-left: 3px solid var(--warning); + border-radius: var(--radius); + background: color-mix(in srgb, var(--warning) 10%, transparent); + font-size: 13px; +} + +.youtube .list-body > .segmented { margin-bottom: 8px; } + +.yt-config { padding: 16px; } +.yt-config-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; } +.yt-config-head label { display: flex; align-items: center; gap: 8px; } +.yt-config textarea { box-sizing: border-box; display: block; width: 100%; min-height: 60vh; padding: 12px; font: 12px/1.5 var(--mono); resize: vertical; } + +.yt-queue { border-block: 1px solid var(--line); } +.yt-caught-up { display: grid; gap: 4px; padding: 40px 0; } +.yt-caught-up strong { color: var(--text); font-size: 15px; } + +.yt-item { + display: grid; + grid-template-columns: 128px 1fr; + gap: 12px; + padding-inline: var(--gutter); + border-bottom: 1px solid var(--grid); + transition: background-color 150ms, box-shadow 150ms; +} +.yt-item.yt-item:focus-visible { outline: none; } +.yt-item:last-child { border-bottom: 0; } +.yt-item:focus-within { background: var(--hover); box-shadow: inset 3px 0 var(--accent); } +.yt-item.gone { display: none; } + +.yt-thumb { + position: relative; + display: grid; + place-items: center; + width: 128px; + aspect-ratio: 16 / 9; + align-self: start; + margin: 8px 0; + border-radius: 6px; + color: #fff9; + background: radial-gradient(circle at 25% 20%, hsl(var(--hue) 55% 45% / 0.9), transparent 60%), + linear-gradient(135deg, hsl(calc(var(--hue) + 40) 40% 28%), hsl(calc(var(--hue) + 90) 35% 14%)); + background-size: cover; + background-position: center; +} + +.yt-duration { + position: absolute; + right: 4px; + bottom: 4px; + padding: 0 4px; + border-radius: 3px; + background: #000b; + color: #fff; + font: 10px/16px var(--mono); +} + +.yt-body { display: grid; gap: 2px; align-content: start; padding: 8px 0; min-width: 0; } +.yt-title { font-weight: 600; line-height: 20px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.yt-title.mono { font-weight: 400; overflow-wrap: anywhere; } +input.yt-title { + height: 22px; + width: 100%; + margin-left: -6px; + padding: 0 5px; + border: 1px dashed var(--axis); + border-radius: 5px; + background: none; + color: inherit; + font: inherit; + font-weight: 600; + transition: border-color 150ms, background-color 150ms; +} +input.yt-title:hover { border-style: solid; } +input.yt-title:focus-visible { outline: none; border: 1px solid var(--accent); background: var(--well); } +.yt-body .error { font-size: 12px; margin-top: 4px; } + +.yt-fields { --control: 26px; display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } +.yt-fields .search { font-size: 13px; padding: 0 8px; } +.yt-fields .yt-dest { width: 150px; } +.yt-fields .yt-season { width: 124px; } +.yt-fields .yt-show { width: 140px; } +.yt-episode { display: flex; align-items: center; gap: 4px; color: var(--muted); font-size: 12px; } +.yt-episode .search { width: 52px; } + +.yt-actions { display: flex; gap: 6px; margin-left: auto; transition: opacity 150ms; } +@media (hover: hover) { + .yt-item:not(:hover, :focus-within) .yt-actions, + .yt-channels tr:not(:hover, :focus-within) .actions > div { opacity: 0; } +} + +.yt-jobs { border-top: 1px solid var(--line); } +.yt-job-state { width: 1%; white-space: nowrap; } +.yt-job-state > * { vertical-align: middle; } +.yt-job-state .meter { display: inline-flex; width: 56px; min-width: 0; margin: 0 8px; } +.yt-job-title { overflow-wrap: anywhere; } +.yt-list-head { display: flex; align-items: baseline; gap: 8px; margin: 20px 0 8px; font-size: 12px; } +.yt-list-head:first-child { margin-top: 4px; } +.yt-list-head h2 { margin: 0; } +.yt-list-head .button { margin-left: auto; align-self: center; } +.yt-channels { border-top: 1px solid var(--line); } +.yt-channel { font-weight: 550; white-space: nowrap; } +.yt-handle { width: 100%; font-size: 12px; } +.yt-rules { text-align: right; white-space: nowrap; } +.yt-rules .chip { margin-left: 4px; } +.yt-channels .actions > div { transition: opacity 150ms; } + +.yt-backlog { display: flex; gap: 8px; } +.yt-backlog > * { flex: 1; } +.yt-keywords { display: grid; grid-template-columns: auto 1fr 1fr; gap: 6px 8px; align-items: center; color: var(--text-2); font-size: 12px; } +.yt-keywords .search { min-width: 0; } +.yt-keywords .hint { grid-column: span 2; margin-top: -2px; color: var(--muted); } + +:is(.yt-meta, .yt-job-title) a { color: inherit; text-underline-offset: 3px; border-radius: 3px; } +:is(.yt-meta, .yt-job-title) a:not(:hover) { text-decoration: none; } +.yt-meta a:hover { color: var(--text); } + +.yt-library-search { margin: 12px; max-width: calc(100% - 24px); width: 320px; } +.yt-library { width: 100%; } +.yt-library-edit { display: flex; gap: 6px; align-items: center; } +.yt-library-edit input { min-width: 0; } +.yt-library-edit input:first-child { flex: 1; } +.yt-library-edit input[type="number"] { width: 58px; } diff --git a/dashboard/web/pages/YouTube.tsx b/dashboard/web/pages/YouTube.tsx new file mode 100644 index 0000000000000000000000000000000000000000..40064809d3313fb5c7f0b929f8bd89a80c9eed4c --- /dev/null +++ b/dashboard/web/pages/YouTube.tsx @@ -0,0 +1,705 @@ +import { A, useSearchParams } from "@solidjs/router"; +import Clock from "lucide-solid/icons/clock"; +import ListVideo from "lucide-solid/icons/list-video"; +import Tv from "lucide-solid/icons/tv"; +import Library from "lucide-solid/icons/library"; +import Code from "lucide-solid/icons/code"; +import Pause from "lucide-solid/icons/pause"; +import Play from "lucide-solid/icons/play"; +import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"; +import { parseResponse } from "hono/client"; +import type { Channels, ConfigFile, Ingest, JobStatus, LibraryEntry, Rules, Show as IndieShow, Video } from "../types/youtube.ts"; +import { api, reason, unconnected } from "../api.ts"; +import { Ago } from "../components/Ago.tsx"; +import { showConfirmDialog } from "../components/Dialog.tsx"; +import { ListPage } from "../components/ListPage.tsx"; +import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; +import { Meter } from "../components/Meter.tsx"; +import { type StatusKind, StatusLabel } from "../components/Status.tsx"; +import { TabBar } from "../components/TabBar.tsx"; +import { toast } from "../components/Toast.tsx"; +import { ago, count, date, datetime, duration, plural, until } from "../format.ts"; +import "./YouTube.css"; + +const JOB: Record = { + queued: ["stopped", "queued"], + resolving: ["working", "looking up"], + downloading: ["working", "downloading"], + retrying: ["working", "retrying"], + waiting: ["degraded", "waiting"], + done: ["healthy", "done"], + error: ["down", "failed"], +}; + +export const TABS = [["", "queue", ListVideo], ["history", "history", Clock], ["channels", "channels", Tv], ["library", "library", Library], ["config", "YAML", Code]] as const; + +function RawConfigs(props: { files: ConfigFile[]; reload: () => unknown }) { + const [selected, setSelected] = createSignal(props.files[0]?.name ?? ""); + const [draft, setDraft] = createSignal(null); + const [saving, setSaving] = createSignal(false); + const file = () => props.files.find((item) => item.name === selected()); + const save = async (event: SubmitEvent) => { + event.preventDefault(); + const current = file(); + if (!current || draft() === null) return; + setSaving(true); + try { + await parseResponse(api.youtube.configs[":name"].$put({ param: { name: current.name }, + json: { original: current.body, body: draft()! } })); + await props.reload(); + setDraft(null); + toast(`${current.name} saved`); + } catch (failure) { + toast(`Couldn't save ${current.name}. ${reason(failure)}`); + } finally { + setSaving(false); + } + }; + return
    +
    + + +
    + No YAML files in the YouTube config folder.}> + {(current) =>