diff --git a/.github/Dockerfile.native b/.github/Dockerfile.native index ac7564be..b71e4e18 100644 --- a/.github/Dockerfile.native +++ b/.github/Dockerfile.native @@ -19,9 +19,9 @@ RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ fi && \ rm -rf /tmp/bins -# Copy frontend assets (same for all platforms) -COPY --chown=tamanu:tamanu frontend/target/site /home/tamanu/target/site -COPY --chown=tamanu:tamanu frontend/static /home/tamanu/static +# Public-server's Tera templates link to /static/public.css and /static/images/... +# (private-server has its React bundle embedded in the binary). +COPY --chown=tamanu:tamanu static /home/tamanu/static USER tamanu ENV HOME=/home/tamanu diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 90bec20a..36c06aa6 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -16,9 +16,6 @@ permissions: env: CARGO_TERM_COLOR: always CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse - LEPTOS_OUTPUT_NAME: private-server - SERVER_FN_MOD_PATH: "true" - DISABLE_SERVER_FN_HASH: "true" jobs: build-binaries: @@ -37,8 +34,6 @@ jobs: - name: Free up space run: sudo rm -rf /opt/hostedtoolcache /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup || true - uses: actions/checkout@v6 - with: - submodules: recursive - name: Configure toolchain run: | @@ -54,6 +49,8 @@ jobs: with: key: ${{ matrix.arch }}-release + # The runner image ships with Node + npm; private-server's build.rs + # uses them to produce the embedded private-web/dist/ bundle. - name: Build binaries run: just build-servers-release ${{ matrix.target }} env: @@ -70,55 +67,15 @@ jobs: path: artifacts/${{ matrix.arch }}/ retention-days: 1 - build-frontend: - name: Build Leptos frontend - runs-on: ubuntu-24.04 - steps: - - name: Free up space - run: sudo rm -rf /opt/hostedtoolcache /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup || true - - uses: actions/checkout@v6 - with: - submodules: recursive - - - name: Configure toolchain - run: | - rustup toolchain install --profile minimal --no-self-update stable - rustup default stable - rustup target add wasm32-unknown-unknown - - - name: Install jq - run: sudo apt-get update && sudo apt-get install -y jq - - - uses: taiki-e/install-action@v2 - with: - tool: just,cargo-leptos - - - uses: Swatinem/rust-cache@v2 - with: - key: frontend-release - - - name: Build frontend - run: just build-frontend-release - - - uses: actions/upload-artifact@v5 - with: - name: frontend - path: | - target/site/ - static/ - retention-days: 1 - build-images: name: Build container images runs-on: ubuntu-24.04 - needs: [build-binaries, build-frontend] + needs: [build-binaries] steps: - name: Login to ghcr.io run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin - uses: actions/checkout@v6 - with: - submodules: recursive - name: Extract version id: version @@ -136,22 +93,13 @@ jobs: name: binaries-arm64 path: .github/build-context/arm64/ - - name: Download frontend - uses: actions/download-artifact@v6 - with: - name: frontend - path: .github/build-context/frontend/ + # Stage the public-server's static assets into the build context. + - name: Stage static assets + run: cp -r static .github/build-context/static - name: Debug build context structure run: | - echo "=== Build context structure ===" ls -laR .github/build-context/ - echo "=== AMD64 binaries ===" - ls -lh .github/build-context/amd64/ - echo "=== ARM64 binaries ===" - ls -lh .github/build-context/arm64/ - echo "=== Frontend assets ===" - ls -lh .github/build-context/frontend/ - name: Setup buildkit uses: docker/setup-buildx-action@v3 @@ -159,16 +107,16 @@ jobs: - uses: docker/metadata-action@v5 id: meta with: - images: ghcr.io/beyondessential/tamanu-meta + images: ghcr.io/beyondessential/canopy tags: | type=semver,value=v${{ steps.version.outputs.version }},pattern={{version}} type=semver,value=v${{ steps.version.outputs.version }},pattern={{major}}.{{minor}} type=semver,value=v${{ steps.version.outputs.version }},pattern={{major}} labels: | org.opencontainers.image.vendor=BES - org.opencontainers.image.title=Tamanu Meta Server + org.opencontainers.image.title=Canopy org.opencontainers.image.url=https://www.bes.au/products/tamanu/ - org.opencontainers.image.source=https://github.com/beyondessential/tamanu-meta-server/ + org.opencontainers.image.source=https://github.com/beyondessential/canopy/ org.opencontainers.image.version=${{ steps.version.outputs.version }} org.opencontainers.image.licenses=GPL-3.0-or-later diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f0b8e96..5a3b13b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,6 @@ concurrency: env: CARGO_TERM_COLOR: always CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse - LEPTOS_OUTPUT_NAME: private-server - SERVER_FN_MOD_PATH: "true" - DISABLE_SERVER_FN_HASH: "true" jobs: test: @@ -26,8 +23,6 @@ jobs: - name: Free up space run: sudo rm -rf /opt/hostedtoolcache /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup || true - uses: actions/checkout@v6 - with: - submodules: recursive - name: Configure toolchain run: | rustup toolchain install --profile minimal --no-self-update stable @@ -37,12 +32,12 @@ jobs: tool: cargo-nextest - run: | sudo systemctl start postgresql.service - sudo -u postgres psql --command="CREATE ROLE meta SUPERUSER LOGIN PASSWORD 'meta'" - sudo -u postgres createdb --owner=meta meta + sudo -u postgres psql --command="CREATE ROLE canopy SUPERUSER LOGIN PASSWORD 'canopy'" + sudo -u postgres createdb --owner=canopy canopy - uses: Swatinem/rust-cache@v2 - run: cargo nextest run env: - DATABASE_URL: postgresql://meta:meta@localhost:5432/meta + DATABASE_URL: postgresql://canopy:canopy@localhost:5432/canopy clippy: name: Clippy @@ -51,8 +46,6 @@ jobs: - name: Free up space run: sudo rm -rf /opt/hostedtoolcache /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup || true - uses: actions/checkout@v6 - with: - submodules: recursive - name: Configure toolchain run: | rustup toolchain install --profile minimal --no-self-update stable @@ -63,3 +56,66 @@ jobs: tool: just - uses: Swatinem/rust-cache@v2 - run: just lint + + e2e: + name: Playwright + runs-on: ubuntu-latest + # The fixture (private-web/e2e/fixture.ts) spawns the private-server + # binary plus a Vite dev server itself, so this job builds the rust + # binaries the fixture expects, installs the frontend deps + chromium, + # and runs `npm run test:e2e`. + steps: + - name: Free up space + run: sudo rm -rf /opt/hostedtoolcache /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup || true + - uses: actions/checkout@v6 + - name: Configure toolchain + run: | + rustup toolchain install --profile minimal --no-self-update stable + rustup default stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: npm + cache-dependency-path: private-web/package-lock.json + - name: Start Postgres + # Mirrors the canopy/canopy role + admin DB used by the rust test job. + run: | + sudo systemctl start postgresql.service + sudo -u postgres psql --command="CREATE ROLE canopy SUPERUSER LOGIN PASSWORD 'canopy'" + sudo -u postgres createdb --owner=canopy canopy + - name: Build private-server + migrate + # SKIP_FRONTEND_BUILD avoids private-server/build.rs running an npm + # install + vite build to embed dist/ — the e2e fixture uses Vite + # at runtime so the embedded bundle isn't needed here. + env: + SKIP_FRONTEND_BUILD: "1" + run: cargo build --bin private-server --bin migrate --locked + - name: npm ci + run: npm ci + working-directory: private-web + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('private-web/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + working-directory: private-web + - name: Playwright tests + run: npm run test:e2e + working-directory: private-web + env: + CANOPY_E2E_ADMIN_DATABASE_URL: postgresql://canopy:canopy@localhost:5432/postgres + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + private-web/playwright-report + private-web/test-results + retention-days: 7 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 4153bc9f..bb1d4724 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,8 @@ Rocket.toml *.crt *.key identity.*.pem -/static/bulma +TODO.txt +/private-web/node_modules +/private-web/dist +/private-web/test-results +/private-web/playwright-report diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 7405d901..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule ".sub/bulma"] - path = .sub/bulma - url = https://github.com/jgthms/bulma diff --git a/AGENTS.md b/AGENTS.md index d7e8927d..a9835bbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,11 @@ -# Agent Rules for tamanu-meta-server +# Agent Rules for canopy Avoid writing large summaries of actions taken when done. ## Project Structure Overview - **Database crate**: Models, migrations, and database logic - **Public server**: Internet-exposed API endpoints for device registration and updates -- **Private server**: Admin web interface using Leptos (SSR + hydration) +- **Private server**: Admin HTTP/JSON API in axum, plus an embedded React SPA (in `private-web/`) served at the root in production - **Commons**: Shared utilities, authentication, error handling ## Database Connection @@ -23,31 +23,27 @@ Avoid writing large summaries of actions taken when done. - Use `commons_tests::db::TestDb::run()` for database-only tests - Use `commons_tests::server::run()` for HTTP endpoint tests - Use `commons_tests::server::run_with_device_auth()` for authenticated device tests in the public server -- Admin pages: Restrict frontend with `is_current_user_admin()` resource and use `admin_guard()` in server functions +- Admin endpoints take a `TailscaleAdmin` axum extractor; the React UI gates with `commons.is_current_user_admin` -## Leptos Private Server Architecture -- **Frontend/Backend Separation**: Code must build in both SSR and hydrate modes -- **SSR Pattern**: Put database/backend imports in `#[cfg(feature = "ssr")]` sections, following `fns/statuses.rs` structure -- **Server Functions**: Use `#[server]` functions that delegate to `ssr::` module functions -- **Data Structures**: Create separate serializable structs for frontend (no direct database types) -- **Admin Guard**: Use `crate::fns::commons::admin_guard()` for admin authentication -- **Page Structure**: Follow pattern in `app/statuses.rs` and `app/admins.rs` +## Private server architecture +- **Server fns** under `crates/private-server/src/fns/.rs` are bare axum handlers with `(State, [auth extractor], Json) -> Result>` signatures. +- Each module exposes `pub fn routes() -> Router` and is mounted under `/api/` by `crate::fns::routes()`. +- The SPA fallback (`crate::spa::handler`) serves the embedded React bundle from `private-web/dist/` for any path the API doesn't claim. +- `build.rs` runs `npm install --frozen-lockfile && npm run build` in `private-web/` before embedding. Set `SKIP_FRONTEND_BUILD=1` to skip (`just`'s recipes already do this for dev workflows). -Example Leptos server function pattern: +Example axum handler pattern: ```rust -#[server] -pub async fn my_function() -> Result { - ssr::my_function().await -} - -#[cfg(feature = "ssr")] -mod ssr { - use database::MyModel; // SSR-only imports here +#[derive(Deserialize)] +pub struct AddArgs { pub email: String } - pub async fn my_function() -> Result { - let db = crate::fns::commons::admin_guard().await?; - // Implementation here - } +pub async fn add( + State(state): State, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json, +) -> Result> { + let mut conn = state.db.get().await?; + database::admins::Admin::add(&mut conn, &args.email).await?; + Ok(Json(())) } ``` @@ -62,27 +58,32 @@ mod ssr { - For database tests, use direct model functions instead of HTTP endpoints - Always include `use database::ModelName;` imports in test files - Do not include `_test` suffix or prefix in test filenames in `tests/` directory -- Calling Leptos Server Functions in private-server tests: - - Server functions are exposed at `/api/private_server/fns//` (e.g., `/api/private_server/fns/statuses/server_ids`) - - Use `.form(&[("param_name", "param_value")])` for parameters (not `.json()`) - - Use `.post()` without body for functions with no parameters +- Calling private-server endpoints in tests: + - Endpoints are at `/api//` (e.g. `/api/statuses/server_grouped_ids`) + - Pass parameters via `.json(&serde_json::json!({"param_name": value}))` + - For functions with no parameters, still send an empty body: `.json(&serde_json::json!({}))` + +## React frontend (`private-web/`) +The React + MUI + Vite frontend lives at `/private-web/` and is embedded into the private-server binary at build time via `rust-embed`. -## Frontend Development -- Pages go in `crates/private-server/src/app/` -- Server functions go in `crates/private-server/src/fns/` -- Common components go in `crates/private-server/src/components/` -- CSS files go in `static/private/` -- Add routes in `crates/private-server/src/app.rs` -- Use Leptos signals for state management -- Handle async operations with `Resource` and `Action` +Local dev workflow (two terminals): +- `just watch-private-api` runs the private-server binary on `127.0.0.1:8081`. (We bind to IPv4 because Node's vite-proxy can't resolve `[::1]` literals.) `SKIP_FRONTEND_BUILD=1` is set so `cargo run` doesn't reinvoke `npm run build` on every iteration. +- `just watch-private-web` runs Vite at `:8090`, proxying `/api` to the API. + +Open `http://localhost:8090/`. The Vite proxy makes the React app same-origin with the API, so no CORS plumbing is needed. + +End-to-end tests use Playwright. Run with `npm run test:e2e` from `/private-web/`. The fixture (`e2e/fixture.ts`) spawns its own private-server + Vite pair against a freshly-migrated `canopy_e2e_` Postgres database per worker, so the operator does not need to keep `just watch-private-api` running. Build the binaries first with `cargo build --bin private-server --bin migrate`. Override the admin connection used to create/drop the throwaway DB with `CANOPY_E2E_ADMIN_DATABASE_URL` (default `postgres://localhost/postgres`); set `CANOPY_E2E_VERBOSE=1` to stream backend/frontend logs. The first run on a fresh checkout needs `npx playwright install chromium`. ## Development Workflow - Always check: `just check` for basic compilation -- Always check: `just build-frontend` for frontend compatibility - Run full test suite: `just test` - Run specific tests: `just test-name ` - Verify no compilation warnings in tests and main code +## Version Control +- If the working copy is a jujutsu repo (a `.jj` directory exists at the repo root), prefer `jj` commands over `git` for VCS operations (status, diff, log, commit/describe, etc.). The repo may be colocated with git, but `jj` is the source of truth for local work. +- If there is no `.jj` directory, use `git` as normal. + ## Troubleshooting and common mistakes - Always use just for tests, never use `cargo test`. - Unless you've done wide-ranging changes, prefer to test specific packages with `just test-package ` instead of the full test suite. diff --git a/COPYRIGHT b/COPYRIGHT index 67bcf47e..9663d2cd 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,6 +1,6 @@ Short version for non-lawyers: -Tamanu Meta Server is licensed under GPL 3.0 and later. +Canopy is licensed under GPL 3.0 and later. Commercial licensing is available via BES. diff --git a/Cargo.lock b/Cargo.lock index 088aa468..45081959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,18 +118,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "any_spawner" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" -dependencies = [ - "futures", - "thiserror 2.0.18", - "tokio", - "wasm-bindgen-futures", -] - [[package]] name = "anyhow" version = "1.0.102" @@ -196,23 +184,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-once-cell" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" - [[package]] name = "async-trait" version = "0.1.89" @@ -230,36 +201,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "attribute-derive" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" -dependencies = [ - "attribute-derive-macro", - "derive-where", - "manyhow", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "attribute-derive-macro" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" -dependencies = [ - "collection_literals", - "interpolator", - "manyhow", - "proc-macro-utils", - "proc-macro2", - "quote", - "quote-use", - "syn", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -290,13 +231,12 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", - "base64", "bytes", "form_urlencoded", "futures-util", @@ -309,17 +249,14 @@ dependencies = [ "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", @@ -358,9 +295,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", @@ -380,9 +317,9 @@ dependencies = [ [[package]] name = "axum-test" -version = "18.7.0" +version = "20.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce2a8627e8d8851f894696b39f2b67807d6375c177361d376173ace306a21e2" +checksum = "3a86bfe2ef15bee102ac34912f7f4542b0bb37dc464fa55461763999c4d625e7" dependencies = [ "anyhow", "axum", @@ -401,7 +338,6 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "smallvec", "tokio", "tower", "url", @@ -431,12 +367,6 @@ dependencies = [ "backtrace", ] -[[package]] -name = "base16" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" - [[package]] name = "base64" version = "0.22.1" @@ -562,12 +492,6 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" -[[package]] -name = "camino" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" - [[package]] name = "cc" version = "1.2.57" @@ -592,6 +516,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "charset" version = "0.1.5" @@ -653,9 +588,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -675,9 +610,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -710,23 +645,6 @@ dependencies = [ "cc", ] -[[package]] -name = "codee" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" -dependencies = [ - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "collection_literals" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" - [[package]] name = "colorchoice" version = "1.0.5" @@ -742,7 +660,6 @@ dependencies = [ "diesel", "diesel-async", "http", - "leptos", "miette", "mobc", "node-semver", @@ -849,103 +766,18 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "config" -version = "0.15.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" -dependencies = [ - "convert_case 0.6.0", - "pathdiff", - "serde_core", - "toml 1.0.7+spec-1.1.0", - "winnow 1.0.0", -] - -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-str" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" - -[[package]] -name = "const_format" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - [[package]] name = "const_str_slice_concat" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "convert_case" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "convert_case_extras" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb" -dependencies = [ - "convert_case 0.11.0", -] - [[package]] name = "cookie" version = "0.18.1" @@ -981,6 +813,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1153,17 +994,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "derive-where" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "deunicode" version = "1.6.2" @@ -1172,9 +1002,9 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "diesel" -version = "2.3.7" +version = "2.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ae09a41a4b89f94ec1e053623da8340d996bc32c6517d325a9daad9b239358" +checksum = "9940fb8467a0a06312218ed384185cb8536aa10d8ec017d0ce7fad2c1bd882d5" dependencies = [ "bitflags", "byteorder", @@ -1189,9 +1019,9 @@ dependencies = [ [[package]] name = "diesel-async" -version = "0.7.4" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13096fb8dae53f2d411c4b523bec85f45552ed3044a2ab4d85fb2092d9cb4f34" +checksum = "9c20ddcc6737cecdaef3dfecb2796bdfe3002456521189d30be8e4c5a1bc821d" dependencies = [ "async-trait", "diesel", @@ -1199,7 +1029,7 @@ dependencies = [ "futures-core", "futures-util", "mobc", - "scoped-futures", + "pin-project-lite", "tokio", "tokio-postgres", ] @@ -1231,9 +1061,9 @@ dependencies = [ [[package]] name = "diesel_migrations" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "745fd255645f0f1135f9ec55c7b00e0882192af9683ab4731e4bba3da82b8f9c" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" dependencies = [ "diesel", "migrations_internals", @@ -1266,6 +1096,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1317,9 +1168,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "either_of" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14f7f86eef3a7e4b9c2107583dbbbe3d9535c4b800796faf1774b82ba22033da" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" dependencies = [ "paste", "pin-project-lite", @@ -1385,27 +1236,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - [[package]] name = "expect-json" version = "1.10.1" @@ -1660,12 +1490,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", - "wasm-bindgen", ] [[package]] @@ -1698,46 +1527,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "gloo-net" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" -dependencies = [ - "futures-channel", - "futures-core", - "futures-sink", - "gloo-utils", - "http", - "js-sys", - "pin-project", - "serde", - "serde_json", - "thiserror 1.0.69", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "gloo-utils" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" -dependencies = [ - "js-sys", - "serde", - "serde_json", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "guardian" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" - [[package]] name = "hashbrown" version = "0.15.5" @@ -1872,22 +1661,6 @@ dependencies = [ "libm", ] -[[package]] -name = "hydration_context" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8714ae4adeaa846d838f380fbd72f049197de629948f91bf045329e0cf0a283" -dependencies = [ - "futures", - "js-sys", - "once_cell", - "or_poisoned", - "pin-project-lite", - "serde", - "throw_error", - "wasm-bindgen", -] - [[package]] name = "hyper" version = "1.8.1" @@ -2128,12 +1901,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "interpolator" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" - [[package]] name = "inventory" version = "0.3.22" @@ -2191,9 +1958,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" dependencies = [ "jiff-static", "jiff-tzdb-platform", @@ -2216,9 +1983,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" dependencies = [ "proc-macro2", "quote", @@ -2292,233 +2059,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "leptos" -version = "0.8.17" +name = "libc" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b540ac2868724738f0f5d00f00ec4640e587223774219c1baddc46bad46fb8e" -dependencies = [ - "any_spawner", - "base64", - "cfg-if", - "either_of", - "futures", - "getrandom 0.4.2", - "hydration_context", - "leptos_config", - "leptos_dom", - "leptos_hot_reload", - "leptos_macro", - "leptos_server", - "oco_ref", - "or_poisoned", - "paste", - "rand 0.9.2", - "reactive_graph", - "rustc-hash", - "rustc_version", - "send_wrapper", - "serde", - "serde_json", - "serde_qs", - "server_fn", - "slotmap", - "tachys", - "thiserror 2.0.18", - "throw_error", - "typed-builder", - "typed-builder-macro", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm_split_helpers", - "web-sys", -] - -[[package]] -name = "leptos_axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "196de3f5cde6a4c4cd254bb16dc6abd2efbf46cc3ae1b6c7da0731f77b4bdf61" -dependencies = [ - "any_spawner", - "axum", - "futures", - "hydration_context", - "leptos", - "leptos_integration_utils", - "leptos_macro", - "leptos_meta", - "leptos_router", - "or_poisoned", - "server_fn", - "tachys", - "tokio", - "tower", - "tower-http", -] - -[[package]] -name = "leptos_config" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a2ac32008dda0d657f2147cc33336f4e743e091597db10f7a99d668e92a46d" -dependencies = [ - "config", - "regex", - "serde", - "thiserror 2.0.18", - "typed-builder", -] - -[[package]] -name = "leptos_dom" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884" -dependencies = [ - "js-sys", - "or_poisoned", - "reactive_graph", - "send_wrapper", - "tachys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "leptos_hot_reload" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1" -dependencies = [ - "anyhow", - "camino", - "indexmap", - "or_poisoned", - "proc-macro2", - "quote", - "rstml", - "serde", - "syn", - "walkdir", -] - -[[package]] -name = "leptos_integration_utils" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c097f89cd9aa606297672f56fa5bdda09f01609a9f4eefaccdbb5ab5afea4279" -dependencies = [ - "futures", - "hydration_context", - "leptos", - "leptos_config", - "leptos_meta", - "leptos_router", - "reactive_graph", -] - -[[package]] -name = "leptos_macro" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712325a77f1d050bf2897061ccaf2b075930aab36954980d658f04452686c474" -dependencies = [ - "attribute-derive", - "cfg-if", - "convert_case 0.11.0", - "convert_case_extras", - "html-escape", - "itertools", - "leptos_hot_reload", - "prettyplease", - "proc-macro-error2", - "proc-macro2", - "quote", - "rstml", - "rustc_version", - "server_fn_macro", - "syn", - "uuid", -] - -[[package]] -name = "leptos_meta" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c3efe657b4c55ed2e078922786ffe20acfb71767c3dd913767b09a35c75c890" -dependencies = [ - "futures", - "indexmap", - "leptos", - "or_poisoned", - "send_wrapper", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "leptos_router" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35058d4096407b8369843b5b5d227588dfd57ecc9e9bda0567523f084dce69e8" -dependencies = [ - "any_spawner", - "either_of", - "futures", - "gloo-net", - "js-sys", - "leptos", - "leptos_router_macro", - "or_poisoned", - "percent-encoding", - "reactive_graph", - "rustc_version", - "send_wrapper", - "tachys", - "thiserror 2.0.18", - "tracing", - "url", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "leptos_router_macro" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "409c0bd99f986c3cfa1a4db2443c835bc602ded1a12784e22ecb28c3ed5a2ae2" -dependencies = [ - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "leptos_server" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" -dependencies = [ - "any_spawner", - "base64", - "codee", - "futures", - "hydration_context", - "or_poisoned", - "reactive_graph", - "send_wrapper", - "serde", - "serde_json", - "server_fn", - "tachys", -] - -[[package]] -name = "libc" -version = "0.2.183" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libm" @@ -2583,29 +2127,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "manyhow" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" -dependencies = [ - "manyhow-macros", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "manyhow-macros" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" -dependencies = [ - "proc-macro-utils", - "proc-macro2", - "quote", -] - [[package]] name = "matchers" version = "0.2.0" @@ -2684,7 +2205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" dependencies = [ "serde", - "toml 0.9.12+spec-1.1.0", + "toml", ] [[package]] @@ -2732,9 +2253,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -2781,23 +2302,6 @@ dependencies = [ "pxfm", ] -[[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 = "next_tuple" version = "0.1.0" @@ -2948,16 +2452,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "oco_ref" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" -dependencies = [ - "serde", - "thiserror 2.0.18", -] - [[package]] name = "oid-registry" version = "0.8.1" @@ -2985,6 +2479,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "or_poisoned" version = "0.1.0" @@ -2997,12 +2497,6 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -3041,12 +2535,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pem" version = "3.0.6" @@ -3163,26 +2651,6 @@ dependencies = [ "siphasher", ] -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3236,9 +2704,9 @@ dependencies = [ [[package]] name = "postgres-types" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" +checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" dependencies = [ "bytes", "fallible-iterator", @@ -3307,22 +2775,16 @@ dependencies = [ "commons-servers", "commons-tests", "commons-types", - "console_error_panic_hook", "database", "futures", "hex", "itertools", "jiff", - "js-sys", - "leptos", - "leptos_axum", - "leptos_meta", - "leptos_router", "lloggs", "miette", + "mime_guess", "public-server", - "pulldown-cmark", - "reqwest", + "rust-embed", "serde", "serde_json", "tokio", @@ -3330,8 +2792,6 @@ dependencies = [ "tower-http", "tracing", "uuid", - "wasm-bindgen", - "web-sys", ] [[package]] @@ -3347,39 +2807,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "proc-macro-utils" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" -dependencies = [ - "proc-macro2", - "quote", - "smallvec", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3389,19 +2816,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "version_check", - "yansi", -] - [[package]] name = "psm" version = "0.1.30" @@ -3449,9 +2863,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.1" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ "bitflags", "getopts", @@ -3545,28 +2959,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "quote-use" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" -dependencies = [ - "quote", - "quote-use-macros", -] - -[[package]] -name = "quote-use-macros" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" -dependencies = [ - "proc-macro-utils", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "quoted_printable" version = "0.5.1" @@ -3606,6 +2998,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3644,6 +3047,12 @@ 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 = "rcgen" version = "0.14.7" @@ -3659,66 +3068,23 @@ dependencies = [ ] [[package]] -name = "reactive_graph" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35774620b3da884a07341e9e36612e1509b1eb0553ef3bb76f1547dd1b797417" -dependencies = [ - "any_spawner", - "async-lock", - "futures", - "guardian", - "hydration_context", - "indexmap", - "or_poisoned", - "paste", - "pin-project-lite", - "rustc-hash", - "rustc_version", - "send_wrapper", - "serde", - "slotmap", - "thiserror 2.0.18", - "web-sys", -] - -[[package]] -name = "reactive_stores" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e114642d342893571ff40b4e1da8ccdea907be44c649041eb7d8413b3fd95e8" -dependencies = [ - "guardian", - "indexmap", - "itertools", - "or_poisoned", - "paste", - "reactive_graph", - "reactive_stores_macro", - "rustc-hash", - "send_wrapper", -] - -[[package]] -name = "reactive_stores_macro" -version = "0.4.1" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b024812c47a6867b6cb32767a46182203f94e59eb88c69b032fd9caffa304ce" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "convert_case 0.11.0", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", + "bitflags", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "redox_users" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "bitflags", + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", ] [[package]] @@ -3803,7 +3169,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.2", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -3857,42 +3223,63 @@ dependencies = [ ] [[package]] -name = "rstml" -version = "0.12.1" +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" dependencies = [ - "derive-where", "proc-macro2", - "proc-macro2-diagnostics", "quote", + "rust-embed-utils", + "shellexpand", "syn", - "syn_derive", - "thiserror 2.0.18", + "walkdir", ] [[package]] -name = "rtoolbox" -version = "0.0.3" +name = "rust-embed-utils" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "libc", - "windows-sys 0.52.0", + "mime_guess", + "sha2", + "walkdir", ] [[package]] name = "rust-multipart-rfc7578_2" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +checksum = "00bdaa068902270ca7fa8619775e1838e23a63620abac0947ce0f715819b8cec" dependencies = [ "bytes", "futures-core", "futures-util", "http", "mime", - "rand 0.9.2", + "rand 0.10.1", "thiserror 2.0.18", ] @@ -4019,15 +3406,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scoped-futures" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b24aae2d0636530f359e9d5ef0c04669d11c5e756699b27a6a6d845d8329091" -dependencies = [ - "pin-project-lite", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -4068,9 +3446,6 @@ name = "send_wrapper" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" -dependencies = [ - "futures-core", -] [[package]] name = "serde" @@ -4126,17 +3501,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_qs" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" -dependencies = [ - "percent-encoding", - "serde", - "thiserror 2.0.18", -] - [[package]] name = "serde_spanned" version = "1.0.4" @@ -4158,82 +3522,6 @@ dependencies = [ "serde", ] -[[package]] -name = "server_fn" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c799cec4e8e210dfb2f203aa97f0e82232c619e385ef4d011b17a58d6397c7b" -dependencies = [ - "axum", - "base64", - "bytes", - "const-str", - "const_format", - "futures", - "gloo-net", - "http", - "http-body-util", - "hyper", - "inventory", - "js-sys", - "or_poisoned", - "pin-project-lite", - "rustc_version", - "rustversion", - "send_wrapper", - "serde", - "serde_json", - "serde_qs", - "server_fn_macro_default", - "thiserror 2.0.18", - "throw_error", - "tokio", - "tower", - "tower-layer", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.5.0", - "web-sys", - "xxhash-rust", -] - -[[package]] -name = "server_fn_macro" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1295b54815397d30d986b63f93cfd515fa86d5e528e0bb589ce9d530502f9e0f" -dependencies = [ - "const_format", - "convert_case 0.11.0", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "xxhash-rust", -] - -[[package]] -name = "server_fn_macro_default" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" -dependencies = [ - "server_fn_macro", - "syn", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sha2" version = "0.10.9" @@ -4241,7 +3529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4254,6 +3542,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" @@ -4323,12 +3620,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - [[package]] name = "spki" version = "0.7.3" @@ -4413,18 +3704,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "syn_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" -dependencies = [ - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4447,11 +3726,10 @@ dependencies = [ [[package]] name = "tachys" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f768750b0d5514f487772187d4b20c66f56faff4541b1faa5aad4975f5aee085" +checksum = "2989c94c59db8497727875aa561d4d0daa3cc79b5774d5ced48263f7091beff1" dependencies = [ - "any_spawner", "async-trait", "const_str_slice_concat", "drain_filter_polyfill", @@ -4463,11 +3741,8 @@ dependencies = [ "itertools", "js-sys", "next_tuple", - "oco_ref", "or_poisoned", "paste", - "reactive_graph", - "reactive_stores", "rustc-hash", "rustc_version", "send_wrapper", @@ -4668,9 +3943,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -4684,9 +3959,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -4695,9 +3970,9 @@ dependencies = [ [[package]] name = "tokio-postgres" -version = "0.7.16" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42" +checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" dependencies = [ "async-trait", "byteorder", @@ -4712,7 +3987,7 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.2", + "rand 0.10.1", "socket2", "tokio", "tokio-util", @@ -4744,18 +4019,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -4777,24 +4040,11 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "serde_core", "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime", "toml_parser", "winnow 0.7.15", ] -[[package]] -name = "toml" -version = "1.0.7+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" -dependencies = [ - "serde_core", - "serde_spanned", - "toml_datetime 1.0.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.0", -] - [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -4804,15 +4054,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_datetime" -version = "1.0.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_parser" version = "1.0.10+spec-1.1.0" @@ -4972,43 +4213,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.2", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - -[[package]] -name = "typed-builder" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" -dependencies = [ - "typed-builder-macro", -] - -[[package]] -name = "typed-builder-macro" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "typeid" version = "1.0.3" @@ -5133,12 +4337,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8-width" version = "0.1.8" @@ -5159,9 +4357,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -5336,41 +4534,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasm_split_helpers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a114b3073258dd5de3d812cdd048cca6842342755e828a14dbf15f843f2d1b84" -dependencies = [ - "async-once-cell", - "wasm_split_macros", -] - -[[package]] -name = "wasm_split_macros" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56481f8ed1a9f9ae97ea7b08a5e2b12e8adf9a7818a6ba952b918e09c7be8bf0" -dependencies = [ - "base16", - "quote", - "sha2", - "syn", -] - [[package]] name = "wasmparser" version = "0.244.0" @@ -5669,9 +4832,6 @@ name = "winnow" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" -dependencies = [ - "memchr", -] [[package]] name = "wit-bindgen" @@ -5797,12 +4957,6 @@ dependencies = [ "time", ] -[[package]] -name = "xxhash-rust" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" - [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 17c49047..33db9418 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,26 +13,25 @@ members = [ ] [workspace.dependencies] -axum = "0.8.4" -axum-test = "18.3.0" -clap = "4.5.47" -diesel = "2.3.3" -diesel-async = "0.7.4" +axum = "0.8.9" +axum-test = "20.0.0" +clap = "4.6.1" +diesel = "2.3.9" +diesel-async = "0.9.0" futures = "0.3.31" http = "1.3.1" -jiff = { version = "0.2.15", features = ["serde"] } -leptos = "0.8.0" +jiff = { version = "0.2.24", features = ["serde"] } lloggs = "1.1.0" miette = "7.6.0" mobc = "0.9.0" node-semver = "2.2.0" problem_details = "0.9.0" -pulldown-cmark = { version = "0.13.0", features = ["html"] } +pulldown-cmark = { version = "0.13.3", features = ["html"] } reqwest = { version = "0.12.23", default-features = false, features = ["json", "rustls-tls", "stream"] } serde = "1.0.219" tera = "1.20.0" timesimp = "1.0.0" -tokio = "1.47.1" +tokio = "1.52.1" tracing = "0.1.41" [profile.release] @@ -40,11 +39,6 @@ lto = true codegen-units = 1 strip = "symbols" -[profile.wasm-release] -inherits = "release" -opt-level = "z" -panic = "abort" - [profile.dev.build-override] opt-level = 0 codegen-units = 1024 @@ -60,18 +54,3 @@ debug = false debug-assertions = false overflow-checks = false incremental = false - -[[workspace.metadata.leptos]] -name = "private-server" -output-name = "private-server" -server-fn-mod-path = true -disable-server-fn-hash = true -site-root = "target/site" -assets-dir = "static" -site-addr = "[::1]:8081" -reload-port = 8082 -bin-package = "private-server" -bin-features = ["ssr"] -lib-package = "private-server" -lib-features = ["hydrate"] -lib-profile-release = "wasm-release" diff --git a/README.md b/README.md index bf6f9cef..d94a9141 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Tamanu Meta Server +# Canopy [Tamanu](https://www.bes.au/products/tamanu/) is an open-source patient-level electronic health records system for mobile and desktop. -The Meta service provides: +Canopy provides: - a server discovery service for the Tamanu mobile app - a server list and health check page - a range of active versions @@ -13,7 +13,7 @@ The Meta service provides: We have a container image for linux/amd64 and linux/arm64: ``` -ghcr.io/beyondessential/tamanu-meta:5.12.1 +ghcr.io/beyondessential/canopy:5.12.1 ``` ## Develop @@ -23,7 +23,7 @@ ghcr.io/beyondessential/tamanu-meta:5.12.1 - Clone the repo via git: ```console -$ git clone git@github.com:beyondessential/tamanu-meta-server.git +$ git clone git@github.com:beyondessential/canopy.git ``` - Install development dependencies: @@ -32,7 +32,7 @@ $ git clone git@github.com:beyondessential/tamanu-meta-server.git $ just install-deps ``` -This will install [cargo-nextest](https://nextest.rs), [cargo-leptos](https://leptos.dev), +This will install [cargo-nextest](https://nextest.rs), [diesel CLI](https://diesel.rs/guides/getting-started.html#installing-diesel-cli), [cargo-release](https://github.com/crate-ci/cargo-release), [git-cliff](https://git-cliff.org), and [watchexec](https://github.com/watchexec/watchexec). @@ -40,10 +40,10 @@ and [watchexec](https://github.com/watchexec/watchexec). ### Quick Start - Create a new blank postgres database. -- Optionally set the `DATABASE_URL` environment variable (if your database isn't named the default `tamanu_meta`): +- Optionally set the `DATABASE_URL` environment variable (if your database isn't named the default `canopy`): ```console -$ export DATABASE_URL=postgres://localhost/tamanu_meta_dev +$ export DATABASE_URL=postgres://localhost/canopy_dev ``` - Run migrations: @@ -129,7 +129,7 @@ You'll need to have `kubectl` installed and authorised. ```console # just download-db {database name} {kubernetes namespace} [dump file] -$ just download-db tamanu_meta tamanu-meta-prod +$ just download-db canopy canopy-prod ``` ### Releasing diff --git a/cliff.toml b/cliff.toml index 4bf8016f..aa7ac77e 100644 --- a/cliff.toml +++ b/cliff.toml @@ -41,7 +41,7 @@ body = """ {% endfor %}\n """ postprocessors = [ - { pattern = '\$REPO', replace = "https://github.com/beyondessential/tamanu-meta-server" }, + { pattern = '\$REPO', replace = "https://github.com/beyondessential/canopy" }, ] [git] @@ -54,7 +54,7 @@ tag_pattern = "v[0-9].*" sort_commits = "oldest" link_parsers = [ - { pattern = "#(\\d+)", href = "https://github.com/beyondessential/tamanu-meta-server/issues/$1"}, + { pattern = "#(\\d+)", href = "https://github.com/beyondessential/canopy/issues/$1"}, { pattern = "RFC(\\d+)", text = "ietf-rfc$1", href = "https://datatracker.ietf.org/doc/html/rfc$1"}, { pattern = '(\w{2,5}-[1-9][0-9]*)', href = "https://linear.app/bes/issue/$1" }, # linear ] diff --git a/crates/commons-errors/Cargo.toml b/crates/commons-errors/Cargo.toml index 4936241d..46bcc24e 100644 --- a/crates/commons-errors/Cargo.toml +++ b/crates/commons-errors/Cargo.toml @@ -16,7 +16,6 @@ commons-macros.path = "../commons-macros" diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } http.workspace = true -leptos.workspace = true miette.workspace = true mobc = { workspace = true, optional = true } node-semver.workspace = true diff --git a/crates/commons-errors/src/lib.rs b/crates/commons-errors/src/lib.rs index e2fe8a22..46b64579 100644 --- a/crates/commons-errors/src/lib.rs +++ b/crates/commons-errors/src/lib.rs @@ -5,10 +5,6 @@ use axum::response::{IntoResponse, Response}; #[cfg(feature = "ssr")] use diesel_async::pooled_connection::PoolError; use http::{StatusCode, Uri}; -use leptos::{ - prelude::{FromServerFnError, ServerFnErrorErr}, - server_fn::codec::JsonEncoding, -}; use problem_details::ProblemDetails; use serde::{Deserialize, Serialize}; @@ -76,9 +72,6 @@ pub enum AppError { #[error("authentication failed: {reason}")] AuthFailed { reason: String }, - - #[error("server error: {0}")] - ServerFn(#[from] ServerFnErrorErr), } impl AppError { @@ -105,13 +98,6 @@ impl From for AppError { } } -impl FromServerFnError for AppError { - type Encoder = JsonEncoding; - fn from_server_fn_error(value: ServerFnErrorErr) -> Self { - AppError::ServerFn(value) - } -} - #[cfg(feature = "ssr")] impl IntoResponse for AppError { fn into_response(self) -> Response { @@ -185,7 +171,6 @@ impl AppError { Self::AuthCertificateNotFound => "auth-certificate-not-found", Self::AuthInsufficientPermissions { .. } => "auth-insufficient-permissions", Self::AuthFailed { .. } => "auth-failed", - Self::ServerFn(_) => "server-fn", Self::Problem(_) => unreachable!(), } )) diff --git a/crates/commons-macros/Cargo.toml b/crates/commons-macros/Cargo.toml index 57129ea4..89be3e2c 100644 --- a/crates/commons-macros/Cargo.toml +++ b/crates/commons-macros/Cargo.toml @@ -11,6 +11,6 @@ authors = [ ] [dependencies] -tachys = "0.2.11" +tachys = "0.2.15" wasm-bindgen = { version = "0.2.106", default-features = false, features = [] } web-sys = { version = "0.3.83", default-features = false, features = [] } diff --git a/crates/commons-tests/Cargo.toml b/crates/commons-tests/Cargo.toml index 50e64b93..f4034659 100644 --- a/crates/commons-tests/Cargo.toml +++ b/crates/commons-tests/Cargo.toml @@ -19,10 +19,10 @@ commons-servers = { path = "../commons-servers" } database = { path = "../database" } diesel.workspace = true diesel-async.workspace = true -diesel_migrations = "2.2.0" +diesel_migrations = "2.3.2" miette.workspace = true percent-encoding = "2.3.2" -private-server = { path = "../private-server", features = ["ssr"] } +private-server = { path = "../private-server" } public-server = { path = "../public-server" } rcgen = "0.14.3" time = "0.3.43" @@ -30,5 +30,5 @@ tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread"] } tracing.workspace = true tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } url = { version = "2.5.7", features = ["serde"] } -uuid = { version = "1.18.1", features = ["serde", "v4"] } +uuid = { version = "1.23.1", features = ["serde", "v4"] } x509-parser = "0.18.0" diff --git a/crates/commons-tests/src/db.rs b/crates/commons-tests/src/db.rs index bae3edee..032ac1e6 100644 --- a/crates/commons-tests/src/db.rs +++ b/crates/commons-tests/src/db.rs @@ -32,7 +32,7 @@ impl TestDb { let mut admin_url = base.clone(); admin_url.set_path("postgres"); - let name = format!("tamanu_meta_test_{}", Uuid::new_v4().simple()); + let name = format!("canopy_test_{}", Uuid::new_v4().simple()); tracing::info!("in temporary database {name}"); let mut url = base.clone(); diff --git a/crates/commons-types/Cargo.toml b/crates/commons-types/Cargo.toml index 3d735500..89ed8620 100644 --- a/crates/commons-types/Cargo.toml +++ b/crates/commons-types/Cargo.toml @@ -20,7 +20,7 @@ node-semver.workspace = true serde = { workspace = true, features = ["derive"] } serde_json = "1.0.145" thiserror = "2.0.17" -uuid = { version = "1.18.1", features = ["serde"] } +uuid = { version = "1.23.1", features = ["serde"] } [features] default = ["ssr"] diff --git a/crates/commons-types/src/server.rs b/crates/commons-types/src/server.rs index a35d83ef..544a189e 100644 --- a/crates/commons-types/src/server.rs +++ b/crates/commons-types/src/server.rs @@ -3,4 +3,4 @@ pub mod kind; pub mod rank; pub mod ticket; -pub use ticket::MetaTicket; +pub use ticket::CanopyTicket; diff --git a/crates/commons-types/src/server/kind.rs b/crates/commons-types/src/server/kind.rs index 99fd97cf..34ba880d 100644 --- a/crates/commons-types/src/server/kind.rs +++ b/crates/commons-types/src/server/kind.rs @@ -18,7 +18,7 @@ pub enum ServerKind { #[default] Central, Facility, - Meta, + Canopy, } impl Display for ServerKind { @@ -26,7 +26,7 @@ impl Display for ServerKind { match self { ServerKind::Central => write!(f, "central"), ServerKind::Facility => write!(f, "facility"), - ServerKind::Meta => write!(f, "meta"), + ServerKind::Canopy => write!(f, "canopy"), } } } @@ -50,7 +50,7 @@ impl FromStr for ServerKind { match value.to_ascii_lowercase().as_ref() { "tamanu sync server" | "central" => Ok(Self::Central), "tamanu lan server" | "facility" => Ok(Self::Facility), - "meta" => Ok(Self::Meta), + "canopy" => Ok(Self::Canopy), s => Err(ServerKindFromStringError(s.into())), } } diff --git a/crates/commons-types/src/server/ticket.rs b/crates/commons-types/src/server/ticket.rs index 3bfaadd7..ac358483 100644 --- a/crates/commons-types/src/server/ticket.rs +++ b/crates/commons-types/src/server/ticket.rs @@ -6,7 +6,7 @@ use super::{kind::ServerKind, rank::ServerRank}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct MetaTicket { +pub struct CanopyTicket { /// Ticket format version, must be "ticket-1". pub v: String, /// The server's own UUID. @@ -31,7 +31,7 @@ pub struct MetaTicket { pub central_public_key: Option, } -impl MetaTicket { +impl CanopyTicket { /// Decode a base64-encoded ticket string. pub fn from_base64(input: &str) -> Result { use base64::Engine as _; diff --git a/crates/database/Cargo.toml b/crates/database/Cargo.toml index dbe0765d..291c64cd 100644 --- a/crates/database/Cargo.toml +++ b/crates/database/Cargo.toml @@ -23,7 +23,7 @@ diesel = { workspace = true, features = [ ] } diesel-async = { workspace = true, features = ["mobc", "postgres", "migrations", "async-connection-wrapper"] } diesel-derive-enum = { version = "2.1.0", features = ["postgres"] } -diesel_migrations = "2.3.0" +diesel_migrations = "2.3.2" futures = "0.3.31" hex = "0.4.3" ipnet = { version = "2.11.0", features = ["serde"] } @@ -39,7 +39,7 @@ thiserror = "2.0.17" tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread"] } tracing.workspace = true url = { version = "2.5.7", features = ["serde"] } -uuid = { version = "1.18.1", features = ["serde", "v4"] } +uuid = { version = "1.23.1", features = ["serde", "v4"] } bestool-postgres = "1.0.3" diff --git a/crates/database/src/servers.rs b/crates/database/src/servers.rs index c2173a50..e474c4d9 100644 --- a/crates/database/src/servers.rs +++ b/crates/database/src/servers.rs @@ -1,7 +1,7 @@ use commons_errors::{AppError, Result}; use commons_types::{ geo::GeoPoint, - server::{kind::ServerKind, rank::ServerRank, ticket::MetaTicket}, + server::{kind::ServerKind, rank::ServerRank, ticket::CanopyTicket}, }; use diesel::prelude::*; use diesel_async::{AsyncPgConnection, RunQueryDsl}; @@ -149,7 +149,7 @@ impl Server { /// but a different ID than `ticket.server_id`. pub async fn upsert_from_ticket( db: &mut AsyncPgConnection, - ticket: &MetaTicket, + ticket: &CanopyTicket, kind: ServerKind, rank: Option, ) -> Result { @@ -159,7 +159,7 @@ impl Server { // Look up parent server by its public key if provided. let parent_server_id = if let Some(ref pem) = ticket.central_public_key { - let central_key_der = MetaTicket::pem_to_der(pem)?; + let central_key_der = CanopyTicket::pem_to_der(pem)?; if let Some(central_device) = crate::devices::Device::from_key(db, ¢ral_key_der).await? { diff --git a/crates/private-server/Cargo.toml b/crates/private-server/Cargo.toml index 6b33bb07..febe8a0f 100644 --- a/crates/private-server/Cargo.toml +++ b/crates/private-server/Cargo.toml @@ -10,77 +10,33 @@ authors = [ "BES Developers ", ] -[lib] -crate-type = ["cdylib", "rlib"] - [dependencies] -axum = { workspace = true, optional = true, features = ["json", "macros"] } -axum-client-ip = { version = "1.1.3", optional = true, features = ["forwarded-header"] } -axum-server-timing = { version = "3.0.0", optional = true } +axum = { workspace = true, features = ["json", "macros"] } +axum-client-ip = { version = "1.1.3", features = ["forwarded-header"] } +axum-server-timing = "3.0.0" base64 = "0.22.1" -bestool-postgres = { version = "1.0.3", optional = true } -clap = { workspace = true, optional = true, features = ["derive", "env"] } -commons-errors = { path = "../commons-errors", default-features = false } -commons-servers = { path = "../commons-servers", optional = true } -commons-types = { path = "../commons-types", default-features = false } -console_error_panic_hook = { version = "0.1.0", optional = true } -database = { path = "../database", optional = true } +bestool-postgres = "1.0.3" +clap = { workspace = true, features = ["derive", "env"] } +commons-errors = { path = "../commons-errors", features = ["ssr"] } +commons-servers = { path = "../commons-servers" } +commons-types = { path = "../commons-types", features = ["ssr"] } +database = { path = "../database" } futures.workspace = true -hex = "0.4.3" itertools = "0.14.0" -jiff = { version = "0.2.15", features = ["serde"] } -js-sys = { version = "0.3.81", optional = true } -leptos.workspace = true -leptos_axum = { version = "0.8.0", optional = true } -leptos_meta = "0.8.0" -leptos_router = { version = "0.8.8", features = ["tracing"] } -lloggs = { workspace = true, optional = true, features = ["miette-7"] } -miette = { workspace = true, optional = true, features = ["fancy"] } -public-server = { path = "../public-server", optional = true, default-features = false } -pulldown-cmark.workspace = true -reqwest = { workspace = true, optional = true } -serde = { workspace = true, features = ["derive"] } +jiff = { version = "0.2.24", features = ["serde"] } +lloggs = { workspace = true, features = ["miette-7"] } +mime_guess = "2.0.5" +miette = { workspace = true, features = ["fancy"] } +public-server = { path = "../public-server", default-features = false } +rust-embed = { version = "8.5", features = ["mime-guess", "interpolate-folder-path"] } +serde = { workspace = true, features = ["derive", "rc"] } serde_json = "1.0.145" -tokio = { workspace = true, optional = true, features = ["macros", "net", "rt-multi-thread", "signal", "time"] } -tokio-postgres = { version = "0.7.15", optional = true, features = ["with-jiff-0_2", "with-serde_json-1", "with-uuid-1"] } -tower-http = { version = "0.6.6", optional = true, features = ["fs"] } +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal", "time"] } +tokio-postgres = { version = "0.7.17", features = ["with-jiff-0_2", "with-serde_json-1", "with-uuid-1"] } +tower-http = { version = "0.6.6", features = ["fs"] } tracing.workspace = true -uuid = { version = "1.18.1", features = ["serde"] } -wasm-bindgen = { version = "0.2.104", optional = true } -web-sys = { version = "0.3.81", features = ["Clipboard", "Document", "Navigator", "Window"] } +uuid = { version = "1.23.1", features = ["serde", "v4"] } [dev-dependencies] commons-tests = { path = "../commons-tests" } - -[features] -default = ["ssr"] -hydrate = [ - "dep:console_error_panic_hook", - "dep:js-sys", - "dep:wasm-bindgen", - "leptos/hydrate", - "uuid/js", -] -ssr = [ - "dep:axum", - "dep:axum-client-ip", - "dep:axum-server-timing", - "dep:bestool-postgres", - "dep:clap", - "dep:commons-servers", - "dep:database", - "dep:leptos_axum", - "dep:lloggs", - "dep:miette", - "dep:public-server", - "dep:reqwest", - "dep:tokio", - "dep:tokio-postgres", - "dep:tower-http", - "commons-errors/ssr", - "commons-types/ssr", - "leptos/ssr", - "leptos_meta/ssr", - "leptos_router/ssr", - "uuid/v4", -] +hex = "0.4.3" diff --git a/crates/private-server/build.rs b/crates/private-server/build.rs new file mode 100644 index 00000000..d395714f --- /dev/null +++ b/crates/private-server/build.rs @@ -0,0 +1,67 @@ +use std::path::Path; +use std::process::Command; +use std::{env, fs}; + +fn main() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let frontend = Path::new(&manifest_dir).join("../../private-web"); + let dist = frontend.join("dist"); + + println!("cargo:rerun-if-changed=../../private-web/src"); + println!("cargo:rerun-if-changed=../../private-web/public"); + println!("cargo:rerun-if-changed=../../private-web/package.json"); + println!("cargo:rerun-if-changed=../../private-web/package-lock.json"); + println!("cargo:rerun-if-changed=../../private-web/vite.config.ts"); + println!("cargo:rerun-if-changed=../../private-web/index.html"); + println!("cargo:rerun-if-changed=../../private-web/tsconfig.app.json"); + println!("cargo:rerun-if-env-changed=SKIP_FRONTEND_BUILD"); + + // Make sure dist/ always exists so rust-embed has something to point at, + // even when we skip the build below. + fs::create_dir_all(&dist).expect("failed to create private-web/dist"); + + if env::var("SKIP_FRONTEND_BUILD").is_ok_and(|v| !v.is_empty()) { + // Dev path: trust whatever is on disk. The vite dev server is the + // real source of UI; the rust binary's embedded assets are only + // hit in prod. An empty value (e.g. `SKIP_FRONTEND_BUILD=`) + // re-enables the build, which is how the release recipe opts back + // in. + return; + } + + let Some(npm) = which_npm() else { + // No npm available — assume someone built the frontend out of band + // (e.g. CI) and dropped the dist into place. + println!( + "cargo:warning=npm not found; using whatever is in private-web/dist (set SKIP_FRONTEND_BUILD=1 to silence this)" + ); + return; + }; + + let status = Command::new(&npm) + .args(["install", "--frozen-lockfile"]) + .current_dir(&frontend) + .status() + .expect("failed to run npm install"); + assert!(status.success(), "npm install failed"); + + let status = Command::new(&npm) + .args(["run", "build"]) + .current_dir(&frontend) + .status() + .expect("failed to run npm run build"); + assert!(status.success(), "npm run build failed"); +} + +fn which_npm() -> Option { + for candidate in ["npm", "npm.cmd"] { + if Command::new(candidate) + .arg("--version") + .output() + .is_ok_and(|o| o.status.success()) + { + return Some(candidate.to_owned()); + } + } + None +} diff --git a/crates/private-server/src/app.rs b/crates/private-server/src/app.rs deleted file mode 100644 index c84591a7..00000000 --- a/crates/private-server/src/app.rs +++ /dev/null @@ -1,241 +0,0 @@ -use leptos::prelude::*; -use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; -use leptos_router::{ - components::{A, ParentRoute, Route, Router, Routes}, - path, -}; - -use crate::components::{Toast, ToggleSignal as _}; - -mod admins; -mod bestool; -mod devices; -mod servers; -mod sql; -mod status; -mod versions; - -#[component] -fn SqlNavItem() -> impl IntoView { - let sql_available = Resource::new( - || (), - |_| async { crate::fns::sql::is_sql_available().await }, - ); - - view! { - - {move || { - sql_available - .and_then(|available| { - available - .then(|| { - view! { - - "SQL" - - } - }) - }) - }} - - } -} - -pub fn shell(options: LeptosOptions) -> impl IntoView { - // TODO: dark mode - view! { - - - - - - - - - - - </head> - <body> - <App /> - </body> - // There's a bug in leptos where the stylesheets are not being - // replaced correctly when client-side navigation occurs. - // Putting the shared stylesheets at the bottom works around - // this by ensuring that the dynamic stylesheets (from the page) - // are swapped, but the main stylesheet is not. - <Stylesheet id="css-main" href="/static/bulma/bulma.min.css" /> - <Stylesheet id="css-main" href="/static/main.css" /> - </html> - } -} - -#[component] -pub fn App() -> impl IntoView { - provide_meta_context(); - view! { - <div id="root"> - <Router> - <GlobalNav /> - <Toast> - <main class="container"> - <Routes fallback=|| view! { <></> }> - <Route path=path!("status") view=status::Page /> - <ParentRoute path=path!("servers") view=servers::Page> - <Route path=path!("") view=servers::list::Centrals /> - <Route path=path!("facilities") view=servers::list::Facilities /> - <Route path=path!(":id/edit") view=servers::Edit /> - <Route path=path!(":id") view=servers::Detail /> - </ParentRoute> - <ParentRoute path=path!("bestool") view=bestool::Page> - <Route path=path!("snippets") view=bestool::snippets::List /> - <Route - path=path!("snippets/:id") - view=bestool::snippets::detail::Detail - /> - </ParentRoute> - <Route path=path!("admins") view=admins::Page /> - <Route path=path!("sql") view=sql::Page /> - <Route path=path!("versions") view=versions::Page /> - <Route path=path!("versions/:version") view=versions::Detail /> - - <ParentRoute path=path!("devices") view=devices::Page> - <Route path=path!("") view=devices::Search /> - <Route path=path!("untrusted") view=devices::list::Untrusted /> - <Route path=path!("trusted") view=devices::list::Trusted /> - <Route path=path!(":id") view=devices::Detail /> - </ParentRoute> - </Routes> - </main> - </Toast> - </Router> - </div> - } -} - -#[component] -pub fn GlobalNav() -> impl IntoView { - let is_admin = Resource::new( - || (), - |_| async { crate::fns::commons::is_current_user_admin().await }, - ); - - let public_url = Resource::new(|| (), |_| async { crate::fns::commons::public_url().await }); - - let server_versions_url = Resource::new( - || (), - |_| async { crate::fns::commons::server_versions_url().await }, - ); - - let (burgered, set_burgered) = signal(false); - - view! { - <nav id="global-nav" class="navbar" role="navigation" aria-label="main navigation"> - <div class="navbar-brand"> - <A - href="/status" - {..} - class="navbar-item" - title=format!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) - > - <img src="/static/images/tamanu_logo.svg" alt="Tamanu Logo" class="logo" /> - </A> - <a - class="navbar-burger" - role="button" - aria-label="menu" - aria-expanded=move || burgered.get().to_string() - class:is-active=move || burgered.get() - on:click=move |_| set_burgered.toggle() - > - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - </a> - </div> - <div class="navbar-menu" class:is-active=move || burgered.get()> - <div class="navbar-start"> - <A href="/status" {..} class="navbar-item"> - "Status" - </A> - <A href="/servers" {..} class="navbar-item"> - "Servers" - </A> - <A href="/versions" {..} class="navbar-item"> - "Versions" - </A> - <A href="/bestool" {..} class="navbar-item"> - "Bestool" - </A> - <Suspense> - {move || { - is_admin - .get() - .and_then(|result| { - if result.unwrap_or(false) { - Some( - view! { - <A href="/admins" {..} class="navbar-item"> - "Admins" - </A> - <A href="/devices" {..} class="navbar-item"> - "Devices" - </A> - }, - ) - } else { - None - } - }) - }} - </Suspense> - <SqlNavItem /> - </div> - <div class="navbar-end"> - <Suspense> - {move || { - public_url - .get() - .and_then(|result| { - if let Ok(Some(url)) = result { - Some( - view! { - <a class="navbar-item" href=url target="_blank"> - "Public" - </a> - }, - ) - } else { - None - } - }) - }} - </Suspense> - <Suspense> - {move || { - server_versions_url - .get() - .and_then(|result| { - if let Ok(Some(url)) = result { - Some( - view! { - <a - class="navbar-item" - href=url - target="_blank" - > - "Server Versions" - </a> - }, - ) - } else { - None - } - }) - }} - </Suspense> - </div> - </div> - </nav> - } -} diff --git a/crates/private-server/src/app/admins.rs b/crates/private-server/src/app/admins.rs deleted file mode 100644 index 62b657c7..00000000 --- a/crates/private-server/src/app/admins.rs +++ /dev/null @@ -1,161 +0,0 @@ -use leptos::prelude::*; -use leptos_meta::{Stylesheet, provide_meta_context}; - -use crate::components::ToastCtx; - -#[component] -pub fn Page() -> impl IntoView { - provide_meta_context(); - let list = LocalResource::new(async || crate::fns::admins::list().await); - - view! { - <Stylesheet id="css-admin" href="/static/admin.css" /> - <section class="section"> - <div class="columns"> - <div class="column"> - <AddAdmin after_add=move || list.refetch() /> - </div> - <div class="column"> - <Suspense fallback=|| view! { <progress class="progress is-small is-primary" max="100">"Loading..."</progress> }> - {move || list.get().map(|data| match data { - Ok(admins) => { - if admins.is_empty() { - view! { - <div class="has-info-text">"No admins configured"</div> - }.into_any() - } else { - view! { - <ListAdmins admins after_del=move || list.refetch() /> - }.into_any() - } - } - Err(err) => { - view! { - <div class="has-danger-text">{format!("Error loading admins: {err}")}</div> - }.into_any() - } - })} - </Suspense> - </div> - </div> - </section> - } -} - -#[component] -fn AddAdmin(after_add: impl Fn() + Send + Copy + 'static) -> impl IntoView { - let ToastCtx(set_message) = use_context().unwrap(); - let (email, set_email) = signal(String::new()); - - let add_admin = Action::new(move |email: &String| { - let email = email.clone(); - async move { crate::fns::admins::add(email).await } - }); - - let on_submit = move |ev: web_sys::SubmitEvent| { - ev.prevent_default(); - let email_value = email.get().trim().to_string(); - - if email_value.is_empty() { - set_message.set(Some("Email cannot be empty".to_string())); - return; - } - - if !email_value.contains('@') { - set_message.set(Some("Please enter a valid email address".to_string())); - return; - } - - add_admin.dispatch(email_value); - set_email.set(String::new()); - }; - - Effect::new(move |_| { - if let Some(result) = add_admin.value().get() { - match result { - Ok(_) => { - set_message.set(Some("Admin added successfully".to_string())); - after_add(); - - set_timeout( - move || set_message.set(None), - std::time::Duration::from_millis(3000), - ); - } - Err(e) => { - set_message.set(Some(format!("Error adding admin: {}", e))); - } - } - } - }); - - view! { - <div class="box"> - <form on:submit=on_submit> - <div class="field has-addons"> - <div class="control is-expanded"> - <input - type="email" - class="input" - name="email" - prop:value=move || email.get() - on:input=move |ev| set_email.set(event_target_value(&ev)) - placeholder="admin@example.com" - /> - </div> - <div class="control"> - <button - type="submit" - class="button is-primary" - disabled=move || add_admin.pending().get() - > - {move || if add_admin.pending().get() { "Adding..." } else { "Add Admin" }} - </button> - </div> - </div> - </form> - </div> - } -} - -#[component] -fn ListAdmins(admins: Vec<String>, after_del: impl Fn() + Send + Copy + 'static) -> impl IntoView { - let ToastCtx(set_message) = use_context().unwrap(); - - let delete_admin = Action::new(move |email: &String| { - let email = email.clone(); - async move { crate::fns::admins::delete(email).await } - }); - - Effect::new(move |_| { - if let Some(result) = delete_admin.value().get() { - match result { - Ok(_) => { - after_del(); - } - Err(e) => { - set_message.set(Some(format!("Error deleting admin: {}", e))); - } - } - } - }); - - view! { - <For each=move || admins.clone() key=|a| a.clone() let:admin> - <div class="box level"> - <div class="level-left"> - <span class="level-item monospace">{admin.clone()}</span> - </div> - <div class="level-right"> - <button - class="level-item button is-danger" - on:click=move |_| drop(delete_admin.dispatch(admin.clone())) - disabled=move || delete_admin.pending().get() - > - {move || if delete_admin.pending().get() { "Deleting..." } else { "Delete" }} - </button> - </div> - </div> - </For> - } -} diff --git a/crates/private-server/src/app/bestool.rs b/crates/private-server/src/app/bestool.rs deleted file mode 100644 index 8dcadd5c..00000000 --- a/crates/private-server/src/app/bestool.rs +++ /dev/null @@ -1,56 +0,0 @@ -use leptos::prelude::*; -use leptos_meta::Stylesheet; -use leptos_router::components::A; -use leptos_router::hooks::use_params_map; -use uuid::Uuid; - -use crate::components::{EndTabs, SubTabs}; -use crate::fns::bestool::get_snippet; - -pub mod snippets; - -#[component] -fn SnippetBreadcrumb() -> impl IntoView { - let params = use_params_map(); - let snippet_id = move || { - params - .read() - .get("id") - .and_then(|id| Uuid::parse_str(&id).ok()) - }; - - let snippet_name = Resource::new(snippet_id, |id| async move { - if let Some(id) = id { - get_snippet(id).await.ok().map(|detail| (id, detail.name)) - } else { - None - } - }); - - view! { - <Transition>{move || { - snippet_name.get().flatten().map(|(id, name)| { - view! { - <A href=format!("/bestool/snippets/{id}")>{name}</A> - } - }) - }}</Transition> - } -} - -#[component] -pub fn Page() -> impl IntoView { - leptos_meta::provide_meta_context(); - - view! { - <Stylesheet id="css-bestool" href="/static/bestool.css" /> - <SubTabs> - <A href="/bestool/snippets" exact=true>Snippets</A> - <span>""</span> - - <EndTabs slot> - <SnippetBreadcrumb /> - </EndTabs> - </SubTabs> - } -} diff --git a/crates/private-server/src/app/bestool/snippets.rs b/crates/private-server/src/app/bestool/snippets.rs deleted file mode 100644 index 16a17666..00000000 --- a/crates/private-server/src/app/bestool/snippets.rs +++ /dev/null @@ -1,199 +0,0 @@ -use std::sync::Arc; - -use leptos::prelude::*; - -use crate::{ - components::{ErrorHandler, LoadingBar, Nothing, PaginatedList}, - fns::bestool::{count_snippets, create_snippet, list_snippets}, -}; - -pub mod detail; - -const PAGE_SIZE: u64 = 10; - -#[component] -pub fn List() -> impl IntoView { - let count = Resource::new( - || (), - |_| async { count_snippets().await.unwrap_or_default() }, - ); - - let fetcher = move |p| async move { - let offset = p * PAGE_SIZE; - list_snippets(offset, Some(PAGE_SIZE)).await - }; - - let (page, set_page) = signal(0u64); - let snippets = Resource::new(move || page.get(), fetcher); - - let (show_create, set_show_create) = signal(false); - - let refresh = move || { - snippets.refetch(); - count.refetch(); - }; - - view! { - <header class="level mt-4"> - <div class="level-left"> - <h1 class="level-item is-size-3">PSQL Snippets</h1> - </div> - <div class="level-right"> - <button - class="button" - class:is-info={move || !show_create.get()} - class:is-danger={move || show_create.get()} - on:click=move |_| set_show_create.set(!show_create.get()) - > - {move || if show_create.get() { "Cancel" } else { "Add" }} - </button> - </div> - </header> - {move || show_create.get().then(|| { - view! { <CreateSnippetForm on_created=move || { - refresh(); - set_show_create.set(false); - } /> } - })} - <Transition fallback=|| view! { <LoadingBar /> }> - <ErrorHandler> - {move || { - match snippets.get() { - Some(Ok(snippets_list)) => { - if snippets_list.is_empty() { - view! { - <Nothing thing="snippets" /> - }.into_any() - } else { - view! { - <PaginatedList - page=page - set_page=set_page - total_count=Signal::derive(move || count.get().unwrap_or(0)) - page_size=PAGE_SIZE - > - <SnippetList snippets=snippets_list /> - </PaginatedList> - }.into_any() - } - } - _ => { - let _: () = view! {}; - ().into_any() - }, - } - }} - </ErrorHandler> - </Transition> - } -} - -#[component] -fn CreateSnippetForm<F>(on_created: F) -> impl IntoView -where - F: Fn() + 'static + Send + Sync, -{ - let on_created = Arc::new(on_created); - let (name, set_name) = signal(String::new()); - let (description, set_description) = signal(String::new()); - let (sql, set_sql) = signal(String::new()); - - let create_action = Action::new(move |_: &()| { - let name_val = name.get(); - let desc_val = if description.get().is_empty() { - None - } else { - Some(description.get()) - }; - let sql_val = sql.get(); - let on_created = Arc::clone(&on_created); - async move { - match create_snippet(name_val, desc_val, sql_val).await { - Ok(_) => { - set_name.set(String::new()); - set_description.set(String::new()); - set_sql.set(String::new()); - on_created(); - Ok(()) - } - Err(e) => Err(e), - } - } - }); - - view! { - <div class="box"> - <form class="field" on:submit=move |ev| { - ev.prevent_default(); - create_action.dispatch(()); - }> - <div class="field"> - <label class="label">"Name (will become the filename/snipname)"</label> - <div class="control"> - <input - class="input" - type="text" - required - placeholder="example_snippet_name" - disabled=move || create_action.pending().get() - prop:value=move || name.get() - on:input=move |ev| set_name.set(event_target_value(&ev)) - /> - </div> - </div> - - <div class="field"> - <label class="label">"Description (optional)"</label> - <div class="control"> - <textarea - class="textarea" - placeholder="Sentence about what it does and ${variables} required" - disabled=move || create_action.pending().get() - prop:value=move || description.get() - on:input=move |ev| set_description.set(event_target_value(&ev)) - ></textarea> - </div> - </div> - - <div class="field"> - <label class="label">"SQL (no sensitive info! everything here may be read by anyone with bestool)"</label> - <div class="control"> - <textarea - class="textarea monospace" - required - placeholder="SELECT ..." - disabled=move || create_action.pending().get() - prop:value=move || sql.get() - on:input=move |ev| set_sql.set(event_target_value(&ev)) - ></textarea> - </div> - </div> - - <div class="field is-grouped"> - <div class="control"> - <button - type="submit" - class="button is-primary" - disabled=move || create_action.pending().get() - class:is-loading=move || create_action.pending().get() - > - "Save" - </button> - </div> - </div> - </form> - </div> - } -} - -#[component] -fn SnippetList(snippets: Vec<Arc<crate::fns::bestool::BestoolSnippetInfo>>) -> impl IntoView { - view! { - <For each=move || snippets.clone() key=|snippet| snippet.id let:snippet> - <a href=format!("/bestool/snippets/{}", snippet.id) class="box"> - <p class="monospace">{snippet.name.clone()}</p> - <p class="snippet-description">{snippet.description.clone()}</p> - </a> - </For> - } -} diff --git a/crates/private-server/src/app/bestool/snippets/detail.rs b/crates/private-server/src/app/bestool/snippets/detail.rs deleted file mode 100644 index a43e55da..00000000 --- a/crates/private-server/src/app/bestool/snippets/detail.rs +++ /dev/null @@ -1,273 +0,0 @@ -use leptos::prelude::*; -use leptos_router::NavigateOptions; -use leptos_router::hooks::{use_navigate, use_params_map}; -use uuid::Uuid; - -use crate::{ - components::{ErrorHandler, LoadingBar}, - fns::bestool::{ - BestoolSnippetDetail, delete_snippet, get_latest_snippet_id, get_snippet, update_snippet, - }, -}; - -#[component] -pub fn Detail() -> impl IntoView { - let params = use_params_map(); - let snippet_id = move || { - params - .read() - .get("id") - .and_then(|id| Uuid::parse_str(&id).ok()) - }; - - let snippet = Resource::new(snippet_id, |id| async move { - if let Some(id) = id { - get_snippet(id).await.ok() - } else { - None - } - }); - - // Check if this snippet is superseded and redirect to latest if needed - let navigate = use_navigate(); - let _ = LocalResource::new(move || { - let navigate = navigate.clone(); - async move { - if let Some(Some(detail)) = snippet.get() { - let current_id = detail.id; - if let Ok(latest_id) = get_latest_snippet_id(current_id).await - && latest_id != current_id - { - let mut opts = NavigateOptions::default(); - opts.replace = true; - navigate(&format!("/bestool/snippets/{}", latest_id), opts); - } - } - } - }); - - view! { - <Transition fallback=|| view! { <LoadingBar /> }> - <ErrorHandler> - {move || { - snippet.get().flatten().map(|detail| { - view! { <SnippetDetailView detail=detail.clone() /> } - }) - }} - </ErrorHandler> - </Transition> - } -} - -#[component] -fn SnippetDetailView(detail: BestoolSnippetDetail) -> impl IntoView { - let (is_editing, set_is_editing) = signal(false); - let (show_delete_confirm, set_show_delete_confirm) = signal(false); - let (name, set_name) = signal(detail.name.clone()); - let (description, set_description) = signal(detail.description.clone().unwrap_or_default()); - let (sql, set_sql) = signal(detail.sql.clone()); - - let navigate = use_navigate(); - let navigate_update = navigate.clone(); - - let update_action = Action::new(move |_: &()| { - let id = detail.id; - let name_val = name.get(); - let desc_val = if description.get().is_empty() { - None - } else { - Some(description.get()) - }; - let sql_val = sql.get(); - let nav_fn = navigate_update.clone(); - async move { - match update_snippet(id, name_val, desc_val, sql_val).await { - Ok(new_detail) => { - set_is_editing.set(false); - // Navigate to the new version with replace - let mut opts = NavigateOptions::default(); - opts.replace = true; - nav_fn(&format!("/bestool/snippets/{}", new_detail.id), opts); - Ok(()) - } - Err(e) => Err(e), - } - } - }); - - let delete_action = Action::new(move |_: &()| { - let id = detail.id; - let nav_fn = navigate.clone(); - async move { - match delete_snippet(id).await { - Ok(_) => { - // Navigate back to list - nav_fn("/bestool/snippets", NavigateOptions::default()); - Ok(()) - } - Err(e) => Err(e), - } - } - }); - - view! { - <section class="section"> - {move || { - if is_editing.get() { - view! { - <div class="level"> - <h1 class="level-item is-size-3">Edit</h1> - </div> - <div class="box"> - <form class="field" on:submit=move |ev| { - ev.prevent_default(); - update_action.dispatch(()); - }> - <div class="field"> - <label class="label">"Name"</label> - <div class="control"> - <input - class="input" - type="text" - required - placeholder="example_snippet_name" - disabled=move || update_action.pending().get() - prop:value=move || name.get() - on:input=move |ev| set_name.set(event_target_value(&ev)) - /> - </div> - </div> - - <div class="field"> - <label class="label">"Description"</label> - <div class="control"> - <textarea - class="textarea" - placeholder="Optional description" - disabled=move || update_action.pending().get() - prop:value=move || description.get() - on:input=move |ev| set_description.set(event_target_value(&ev)) - ></textarea> - </div> - </div> - - <div class="field"> - <label class="label">"SQL"</label> - <div class="control"> - <textarea - class="textarea monospace" - required - placeholder="SELECT ..." - disabled=move || update_action.pending().get() - prop:value=move || sql.get() - on:input=move |ev| set_sql.set(event_target_value(&ev)) - ></textarea> - </div> - </div> - - <div class="field is-grouped"> - <div class="control"> - <button - type="submit" - class="button is-primary" - disabled=move || update_action.pending().get() - class:is-loading=move || update_action.pending().get() - > - "Save" - </button> - </div> - <div class="control"> - <button - type="button" - class="button is-danger is-light" - on:click=move |_| set_is_editing.set(false) - > - "Cancel" - </button> - </div> - </div> - </form> - </div> - }.into_any() - } else { - view! { - <div class="level"> - <div class="level-left"> - <h1 class="level-item is-size-3 monospace">{detail.name.clone()}</h1> - </div> - <div class="level-right"> - <div class="level-item"> - "Last edit by " - {detail.editor.clone()} - </div> - <div class="level-item"> - <button - class="button is-primary" - on:click=move |_| set_is_editing.set(true) - > - "Edit" - </button> - </div> - <div class="level-item"> - <button - class="button is-danger is-light" - on:click=move |_| set_show_delete_confirm.set(true) - > - "Delete" - </button> - </div> - </div> - </div> - <div class="box"> - {detail.description.clone().map(|desc| { - view! { <p class="block">{desc}</p> } - })} - <pre class="block" style="white-space: break-spaces"> - <code>{detail.sql.clone()}</code> - </pre> - </div> - {move || { - if show_delete_confirm.get() { - view! { - <div class="modal is-active"> - <div class="modal-background" on:click=move |_| set_show_delete_confirm.set(false)></div> - <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">"Delete Snippet"</p> - <button class="delete" on:click=move |_| set_show_delete_confirm.set(false)></button> - </header> - <section class="modal-card-body"> - <p>"Are you sure you want to delete this snippet? This action cannot be undone."</p> - </section> - <footer class="modal-card-foot"> - <div class="buttons"> - <button - class="button" - on:click=move |_| set_show_delete_confirm.set(false) - > - "Cancel" - </button> - <button - class="button is-danger" - disabled=move || delete_action.pending().get() - class:is-loading=move || delete_action.pending().get() - on:click=move |_| { delete_action.dispatch(()); } - > - "Delete" - </button> - </div> - </footer> - </div> - </div> - }.into_any() - } else { - let _: () = view! { }; - ().into_any() - } - }} - }.into_any() - } - }} - </section> - } -} diff --git a/crates/private-server/src/app/devices.rs b/crates/private-server/src/app/devices.rs deleted file mode 100644 index 81270011..00000000 --- a/crates/private-server/src/app/devices.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::str::FromStr as _; - -use leptos::prelude::*; -use leptos_meta::{Stylesheet, provide_meta_context}; -use leptos_router::{components::A, hooks::use_params_map}; -use uuid::Uuid; - -use crate::{ - components::{EndTabs, SubTabs}, - fns::devices::get_device_name_by_id, -}; - -mod detail; -mod history; -pub mod list; -mod search; - -pub use detail::Detail; -pub use search::Search; - -#[component] -fn DeviceBreadcrumb() -> impl IntoView { - let params = use_params_map(); - let device_id = move || { - params - .read() - .get("id") - .and_then(|id| Uuid::from_str(&id).ok()) - }; - let device_name = Resource::new(device_id, async move |id| { - if let Some(id) = id { - get_device_name_by_id(id).await.ok().map(|name| (id, name)) - } else { - None - } - }); - - view! { - <Transition>{move || { - device_name.get().flatten().map(|(id, name)| { - view! { - <A href=format!("/devices/{id}")>{name}</A> - } - }) - }}</Transition> - } -} - -#[component] -pub fn Page() -> impl IntoView { - provide_meta_context(); - - view! { - <Stylesheet id="css-devices" href="/static/devices.css" /> - <SubTabs> - <A href="" exact=true>Search</A> - <A href="untrusted">Untrusted Devices</A> - <A href="trusted">Trusted Devices</A> - - <EndTabs slot> - <DeviceBreadcrumb /> - </EndTabs> - </SubTabs> - } -} diff --git a/crates/private-server/src/app/devices/detail.rs b/crates/private-server/src/app/devices/detail.rs deleted file mode 100644 index 8ce8b3b5..00000000 --- a/crates/private-server/src/app/devices/detail.rs +++ /dev/null @@ -1,558 +0,0 @@ -use commons_types::{Uuid, device::DeviceRole}; -use leptos::prelude::*; -use leptos_meta::Title; -use leptos_router::hooks::use_params_map; - -use super::history::ConnectionHistory; -use crate::{ - components::{ServerShorty, TimeAgo, ToastCtx}, - fns::devices::DeviceInfo, -}; - -#[component] -pub fn Detail() -> impl IntoView { - let params = use_params_map(); - let device_id = move || params.read().get("id").and_then(|s| s.parse::<Uuid>().ok()); - - let (refresh_trigger, set_refresh_trigger) = signal(0); - let device_resource = Resource::new( - move || (device_id(), refresh_trigger.get()), - async |(id, _)| { - if let Some(id) = id { - crate::fns::devices::get_device_by_id(id).await - } else { - Err(commons_errors::AppError::custom("No device ID provided")) - } - }, - ); - - view! { - <div class="section device-detail"> - <Suspense fallback=|| view! { <div class="loading">"Loading device..."</div> }> - {move || { - device_resource.get().map(|result| { - match result { - Ok(device_info) => { - view! { - <DeviceDetail device_info set_refresh_trigger /> - }.into_any() - } - Err(err) => { - view! { - <div class="error"> - <h2>"Error Loading Device"</h2> - <p>{format!("{err}")}</p> - <a href="/devices" class="back-link">"← Back to Devices"</a> - </div> - }.into_any() - } - } - }) - }} - </Suspense> - </div> - } -} - -#[component] -fn DeviceDetail(device_info: DeviceInfo, set_refresh_trigger: WriteSignal<i32>) -> impl IntoView { - let device_id = device_info.device.id; - let device_role = device_info.device.role; - let ToastCtx(set_message) = use_context().unwrap(); - - let update_role_action = Action::new(move |(device_id, role): &(Uuid, DeviceRole)| { - let device_id = *device_id; - let role = *role; - async move { crate::fns::devices::update_role(device_id, role).await } - }); - - Effect::new(move |_| { - if let Some(result) = update_role_action.value().get() { - match result { - Ok(_) => { - set_message.set(Some("Device role updated successfully".to_string())); - set_refresh_trigger.update(|n| *n += 1); - set_timeout( - move || set_message.set(None), - std::time::Duration::from_millis(3000), - ); - } - Err(e) => { - set_message.set(Some(format!("Error updating device role: {}", e))); - } - } - } - }); - - let untrust_action = Action::new(move |device_id: &Uuid| { - let device_id = *device_id; - async move { crate::fns::devices::untrust(device_id).await } - }); - - Effect::new(move |_| { - if let Some(result) = untrust_action.value().get() { - match result { - Ok(_) => { - set_message.set(Some("Device untrusted successfully".to_string())); - set_refresh_trigger.update(|n| *n += 1); - set_timeout( - move || set_message.set(None), - std::time::Duration::from_millis(3000), - ); - } - Err(e) => { - set_message.set(Some(format!("Error untrusting device: {}", e))); - } - } - } - }); - - let trust_action = Action::new(move |(device_id, role): &(Uuid, DeviceRole)| { - let device_id = *device_id; - let role = *role; - async move { crate::fns::devices::trust(device_id, role).await } - }); - - Effect::new(move |_| { - if let Some(result) = trust_action.value().get() { - match result { - Ok(_) => { - set_message.set(Some("Device trusted successfully".to_string())); - set_refresh_trigger.update(|n| *n += 1); - set_timeout( - move || set_message.set(None), - std::time::Duration::from_millis(3000), - ); - } - Err(e) => { - set_message.set(Some(format!("Error trusting device: {}", e))); - } - } - } - }); - - view! { - <Title text={ let name = device_info.name(); move || format!("Tamanu Meta Device {name}") } /> - <h1 class="is-size-3">"Device "{device_info.name()}</h1> - <div class="box"> - <div class="info-grid"> - {device_info.latest_connection.as_ref().map(|conn| { - view! { - <div class="info-item"> - <span class="info-label">"Address"</span> - <span class="info-value monospace">{conn.ip.clone()}</span> - </div> - } - })} - <div class="info-item"> - <span class="info-label">"First seen"</span> - <TimeAgo timestamp={device_info.device.created_at} {..} class:info-value /> - </div> - {device_info.latest_connection.as_ref().map(|conn| { - view! { - <div class="info-item"> - <span class="info-label">"Last seen"</span> - <TimeAgo timestamp={conn.created_at} {..} class:info-value /> - </div> - } - })} - <div class="info-item"> - <span class="info-label">"Last updated"</span> - <TimeAgo timestamp={device_info.device.updated_at} {..} class:info-value /> - </div> - {device_info.latest_connection.as_ref().and_then(|conn| conn.user_agent.as_ref()).map(|ua| { - view! { - <div class="info-item"> - <span class="info-label">"User-agent"</span> - <span class="info-value">{ua.clone()}</span> - </div> - } - })} - </div> - </div> - - <div class="box"> - <h3 class="is-size-4 mb-3">"Public Keys " <span class="amount is-size-5">{format!("({})", device_info.keys.len())}</span></h3> - <For each=move || device_info.keys.clone() key=|key| key.id let:key> - <KeyItem - key_id=key.id - name=key.name.clone() - pem_data=key.pem_data.clone() - on_update=move || set_refresh_trigger.update(|n| *n += 1) - /> - </For> - </div> - - <div class="box level"> - {if device_role != DeviceRole::Untrusted { - view! { - <div class="level-left"> - <div class="level-item"> - <label class="label" for="role">"Change role:"</label> - </div> - <div class="level-item"> - <RoleChange - role=device_role - action=move |role| drop(trust_action.dispatch((device_id, role))) - pending=trust_action.pending() /> - </div> - </div> - <div class="level-right"> - <div class="level-item"> - <RoleUntrust - action=move || drop(untrust_action.dispatch(device_id)) - pending=untrust_action.pending() /> - </div> - </div> - }.into_any() - } else { - view! { - <div class="level-left"> - <div class="level-item"> - <label class="label" for="role">"Trust this device as:"</label> - </div> - <div class="level-item"> - <RoleTrust - action=move |role| drop(trust_action.dispatch((device_id, role))) - pending=trust_action.pending() /> - </div> - </div> - }.into_any() - }} - </div> - - {move || { - (device_role != DeviceRole::Untrusted).then(|| view! { - <AssociatedServers device_id /> - }) - }} - - <PastServerAssociations device_id /> - - <ConnectionHistory device_id /> - } -} - -#[component] -fn RoleChange( - role: DeviceRole, - action: impl Fn(DeviceRole) + Copy + Send + 'static, - pending: Memo<bool>, -) -> impl IntoView { - let (selected_role, set_selected_role) = signal(role); - - view! { - <div class="field has-addons"> - <div class="control"> - <div class="select"> - <select - name="role" - disabled=move || pending.get() - prop:value=move || selected_role.get() - on:change=move |ev| set_selected_role.set(event_target_value(&ev).parse().unwrap_or_default()) - > - <option value={DeviceRole::Server}>{DeviceRole::Server}</option> - <option value={DeviceRole::Releaser}>{DeviceRole::Releaser}</option> - <option value={DeviceRole::Admin}>{DeviceRole::Admin}</option> - </select> - </div> - </div> - <div class="control"> - <button - class="button is-primary" - disabled=move || pending.get() - on:click=move |_| action(selected_role.get()) - > - {move || if pending.get() { "Saving..." } else { "Save" }} - </button> - </div> - </div> - } -} - -#[component] -fn RoleTrust(action: impl Fn(DeviceRole) + 'static, pending: Memo<bool>) -> impl IntoView { - let (selected_role, set_selected_role) = signal(DeviceRole::Server); - - view! { - <div class="field has-addons"> - <div class="control"> - <div class="select"> - <select - disabled=move || pending.get() - prop:value=move || selected_role.get() - on:change=move |ev| set_selected_role.set(event_target_value(&ev).parse().unwrap_or_default()) - > - <option value={DeviceRole::Server}>{DeviceRole::Server}</option> - <option value={DeviceRole::Releaser}>{DeviceRole::Releaser}</option> - <option value={DeviceRole::Admin}>{DeviceRole::Admin}</option> - </select> - </div> - </div> - <div class="control"> - <button - class="button is-primary" - on:click=move |_| action(selected_role.get()) - disabled=move || pending.get() - > - {move || if pending.get() { "Trusting..." } else { "Trust" }} - </button> - </div> - </div> - } -} - -#[component] -fn RoleUntrust(action: impl Fn() + Send + Copy + 'static, pending: Memo<bool>) -> impl IntoView { - let (show_untrust_confirm, set_show_untrust_confirm) = signal(false); - - view! { - <div class="field is-grouped"> - {move || if show_untrust_confirm.get() { - view! { - <p class="control"> - <button - class="button is-danger" - disabled=move || pending.get() - on:click=move |_| { - action(); - set_show_untrust_confirm.set(false); - } - > - {move || if pending.get() { "Untrusting..." } else { "Confirm" }} - </button> - </p> - <p class="control"> - <button - class="button" - disabled=move || pending.get() - on:click=move |_| set_show_untrust_confirm.set(false) - > - "Cancel" - </button> - </p> - } - .into_any() - } else { - view! { - <p class="control"> - <button - class="button is-danger" - on:click=move |_| set_show_untrust_confirm.set(true) - > - "Untrust" - </button> - </p> - } - .into_any() - }} - </div> - } -} - -#[component] -fn KeyItem( - key_id: Uuid, - name: Option<String>, - pem_data: String, - on_update: impl Fn() + 'static + Copy, -) -> impl IntoView { - let ToastCtx(set_message) = use_context().unwrap(); - let (editing, set_editing) = signal(false); - let (new_name, set_new_name) = signal(name.clone().unwrap_or_default()); - - let update_key_name_action = Action::new(move |(key_id, name): &(Uuid, Option<String>)| { - let key_id = *key_id; - let name = name.clone(); - async move { crate::fns::devices::update_key_name(key_id, name).await } - }); - - Effect::new(move |_| { - if let Some(result) = update_key_name_action.value().get() { - match result { - Ok(_) => { - set_message.set(Some("Key name updated successfully".to_string())); - set_editing.set(false); - on_update(); - set_timeout( - move || set_message.set(None), - std::time::Duration::from_millis(3000), - ); - } - Err(e) => { - set_message.set(Some(format!("Error updating key name: {}", e))); - } - } - } - }); - - let original_name = name.clone(); - - view! { - <div class="mt-3"> - {move || { - let editing_val = editing.get(); - let name_display = name.clone(); - let original_name_for_cancel = original_name.clone(); - - if editing_val { - view! { - <div class="field is-grouped"> - <p class="control is-expanded"> - <input - type="text" - class="input" - prop:value=move || new_name.get() - on:input=move |ev| set_new_name.set(event_target_value(&ev)) - placeholder="Key name (optional)" - /> - </p> - <p class="control"> - <button - class="button is-primary" - on:click=move |_| { - let name_value = new_name.get().trim().to_string(); - let name_to_save = if name_value.is_empty() { - None - } else { - Some(name_value) - }; - update_key_name_action.dispatch((key_id, name_to_save)); - } - disabled=move || update_key_name_action.pending().get() - > - {move || if update_key_name_action.pending().get() { "Saving..." } else { "Save" }} - </button> - </p> - <p class="control"> - <button - class="button is-danger" - on:click=move |_| { - set_new_name.set(original_name_for_cancel.clone().unwrap_or_default()); - set_editing.set(false); - } - > - "Cancel" - </button> - </p> - </div> - }.into_any() - } else { - view! { - <div class="level mb-2"> - <div class="level-left"> - <h4 class="level-item is-size-5">{name_display.as_deref().unwrap_or("Unnamed key")}</h4> - </div> - <div class="level-right"> - <button - class="level-item button" - on:click=move |_| set_editing.set(true) - title="Edit key name" - > - "✏️" - </button> - </div> - </div> - }.into_any() - } - }} - <pre class="key-data">{pem_data.clone()}</pre> - </div> - } -} - -#[component] -fn AssociatedServers(device_id: Uuid) -> impl IntoView { - let servers_resource = Resource::new( - move || device_id, - async |id| crate::fns::devices::get_servers_for_device(id).await, - ); - - view! { - <div class="box"> - <div class="level"> - <div class="level-left"> - <h3 class="is-size-5 level-item">"Associated Servers"</h3> - </div> - <div class="level-right"> - <button class="button level-item" on:click=move |_| servers_resource.refetch()> - "Refresh" - </button> - </div> - </div> - <Transition fallback=|| view! { <progress class="progress is-small is-primary" max="100">"Loading..."</progress> }> - {move || { - servers_resource.get().map(|result| { - match result { - Ok(servers) if servers.is_empty() => { - view! { - <div class="block has-text-info">"No servers are associated with this device"</div> - }.into_any() - } - Ok(servers) => { - view! { - <For each=move || servers.clone() key=|server| server.id let:server> - <ServerShorty server=server.into() /> - </For> - }.into_any() - } - Err(err) => { - view! { - <div class="block has-text-danger">{format!("Error loading servers: {err}")}</div> - }.into_any() - } - } - }) - }} - </Transition> - </div> - } -} - -#[component] -fn PastServerAssociations(device_id: Uuid) -> impl IntoView { - let past_servers_resource = Resource::new( - move || device_id, - async |id| crate::fns::devices::get_past_server_associations(id).await, - ); - - view! { - <div class="box"> - <div class="level"> - <div class="level-left"> - <h3 class="is-size-5 level-item">"Past Server Associations"</h3> - </div> - <div class="level-right"> - <button class="button level-item" on:click=move |_| past_servers_resource.refetch()> - "Refresh" - </button> - </div> - </div> - <Transition fallback=|| view! { <progress class="progress is-small is-primary" max="100">"Loading..."</progress> }> - {move || { - past_servers_resource.get().map(|result| { - match result { - Ok(servers) if servers.is_empty() => { - view! { - <div class="block has-text-info">"No past server associations found"</div> - }.into_any() - } - Ok(servers) => { - view! { - <For each=move || servers.clone() key=|server| server.id let:server> - <ServerShorty server=server.into() /> - </For> - }.into_any() - } - Err(err) => { - view! { - <div class="block has-text-danger">{format!("Error loading past associations: {err}")}</div> - }.into_any() - } - } - }) - }} - </Transition> - </div> - } -} diff --git a/crates/private-server/src/app/devices/history.rs b/crates/private-server/src/app/devices/history.rs deleted file mode 100644 index ae303348..00000000 --- a/crates/private-server/src/app/devices/history.rs +++ /dev/null @@ -1,306 +0,0 @@ -use std::collections::HashMap; - -use commons_types::Uuid; -use jiff::{RoundMode, SignedDurationRound, Unit}; -use leptos::prelude::*; - -use crate::{components::TimeAgo, fns::devices::DeviceConnectionData}; - -pub fn connection_count(device_id: Uuid) -> LocalResource<u64> { - LocalResource::new(move || async move { - crate::fns::devices::connection_count(device_id) - .await - .unwrap_or_default() - }) -} - -#[component] -pub fn ConnectionHistory(device_id: Uuid) -> impl IntoView { - let (show_history, set_show_history) = signal(false); - - let count = connection_count(device_id); - - view! { - <div class="level"> - <button - class="level-item button" - on:click=move |_| set_show_history.update(|show| *show = !*show) - > - {move || { - if show_history.get() { - "Hide connection history" - } else { - "Show connection history" - } - }} - " " - <Transition> - {move || count.get().map(|n| format!("({n})"))} - </Transition> - </button> - </div> - {move || { - show_history.get().then(|| { - view! { - <DeviceConnectionHistory device_id /> - } - }) - }} - } -} - -const BATCH: usize = 1000; - -#[component] -fn DeviceConnectionHistory(device_id: Uuid) -> impl IntoView { - let (history_offset, set_history_offset) = signal(0u64); - let (all_connections, set_all_connections) = - signal(HashMap::<Uuid, DeviceConnectionData>::new()); - let (has_more, set_has_more) = signal(false); - - let load_more_action = { - Action::new(move |offset: &u64| { - let device_id = device_id; - let offset = *offset; - async move { - crate::fns::devices::connection_history(device_id, Some(BATCH as _), Some(offset)) - .await - } - }) - }; - - Effect::new(move |_| { - if let Some(result) = load_more_action.value().get() { - match result { - Ok(new_connections) => { - let has_more_data = new_connections.len() == BATCH; - set_has_more.set(has_more_data); - - set_all_connections.update(|existing| { - for conn in new_connections { - existing.insert(conn.id, conn); - } - }); - } - Err(_) => { - set_has_more.set(false); - } - } - } - }); - - Effect::new(move |_| { - if history_offset.get() == 0 && all_connections.get().is_empty() { - load_more_action.dispatch(0); - } - }); - - view! { - <Transition fallback=|| view! { <div class="box"><progress class="progress is-small is-primary" max="100">"Loading..."</progress></div> }> - {move || { - let connections_map = all_connections.get(); - if connections_map.is_empty() && !load_more_action.pending().get() { - return view! { - <p>"No connection history found"</p> - }.into_any(); - } - - let mut connections_vec: Vec<_> = connections_map.values().cloned().collect(); - connections_vec.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - - view! { - <div class="box"> - <For - each=move || group_consecutive_connections(connections_vec.clone()) - key=|group| (group.ip.clone(), group.earliest_time, group.latest_time) - let:group - > - <ConnectionGroupRow group /> - </For> - </div> - {move || { - has_more.get().then(|| { - view! { - <div class="level"> - <button - class="level-item button" - on:click=move |_| { - let current_count = all_connections.get().len() as u64; - set_history_offset.set(current_count); - load_more_action.dispatch(current_count); - } - disabled=move || load_more_action.pending().get() - > - {move || if load_more_action.pending().get() { "Loading...".into() } else { format!("Load More ({BATCH})") }} - </button> - </div> - } - }) - }} - }.into_any() - }} - </Transition> - } -} - -#[component] -pub fn ConnectionGroupRow(group: ConnectionGroup) -> impl IntoView { - let span = group.latest_time.duration_since(group.earliest_time); - let span = span - .round( - SignedDurationRound::new() - .smallest(Unit::Second) - .increment(30) - .mode(RoundMode::Ceil), - ) - .unwrap_or(span); - view! { - <div class="level"> - <div class="level-left"> - <div class="level-item history-times"> - <TimeAgo timestamp={group.earliest_time} /> - <span>" to "</span> - <TimeAgo timestamp={group.latest_time} /> - </div> - <div class="level-item"> - {format!("{:#}", span)} - </div> - <div class="level-item monospace">{group.ip}</div> - {(group.count > 1).then(|| view! { - <div class="level-item history-count">{group.count}"×"</div> - })} - </div> - <div class="level-right"> - {group.user_agent.as_ref().map(|ua| { - view! { - <div class="level-item">{ua.clone()}</div> - } - })} - </div> - </div> - } -} - -#[derive(Debug, Clone)] -pub struct ConnectionGroup { - ip: String, - user_agent: Option<String>, - count: usize, - earliest_time: jiff::Timestamp, - latest_time: jiff::Timestamp, -} - -fn group_consecutive_connections(connections: Vec<DeviceConnectionData>) -> Vec<ConnectionGroup> { - if connections.is_empty() { - return vec![]; - } - - let mut groups = Vec::new(); - let mut current_group = vec![connections[0].clone()]; - - for conn in connections.into_iter().skip(1) { - let last_in_group = current_group.last().unwrap(); - - if conn.ip == last_in_group.ip && conn.user_agent == last_in_group.user_agent { - current_group.push(conn); - } else { - let group = create_group(current_group); - groups.push(group); - current_group = vec![conn]; - } - } - - if !current_group.is_empty() { - groups.push(create_group(current_group)); - } - - groups -} - -fn create_group(connections: Vec<DeviceConnectionData>) -> ConnectionGroup { - let count = connections.len(); - let first = connections.first().unwrap(); - let last = connections.last().unwrap(); - - ConnectionGroup { - ip: first.ip.clone(), - user_agent: first.user_agent.clone(), - count, - earliest_time: last.created_at, - latest_time: first.created_at, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn create_test_connection( - ip: &str, - user_agent: Option<&str>, - time: &str, - ) -> crate::fns::devices::DeviceConnectionData { - crate::fns::devices::DeviceConnectionData { - id: uuid::Uuid::new_v4(), - created_at: time.parse().unwrap(), - device_id: uuid::Uuid::new_v4(), - ip: ip.to_string(), - user_agent: user_agent.map(|s| s.to_string()), - } - } - - #[test] - fn test_group_consecutive_connections() { - let connections = vec![ - create_test_connection("192.168.1.1", Some("Agent1"), "2024-01-01T12:00:00Z"), - create_test_connection("192.168.1.1", Some("Agent1"), "2024-01-01T11:00:00Z"), - create_test_connection("192.168.1.2", Some("Agent2"), "2024-01-01T10:00:00Z"), - ]; - - let groups = group_consecutive_connections(connections); - - assert_eq!(groups.len(), 2); - assert_eq!(groups[0].count, 2); - assert_eq!(groups[0].ip, "192.168.1.1"); - assert_eq!(groups[1].count, 1); - assert_eq!(groups[1].ip, "192.168.1.2"); - } - - #[test] - fn test_group_different_user_agents() { - let connections = vec![ - create_test_connection("192.168.1.1", Some("Agent1"), "2024-01-01T12:00:00Z"), - create_test_connection("192.168.1.1", Some("Agent2"), "2024-01-01T11:00:00Z"), - ]; - - let groups = group_consecutive_connections(connections); - assert_eq!(groups.len(), 2); - } - - #[test] - fn test_group_empty_connections() { - let groups = group_consecutive_connections(vec![]); - assert_eq!(groups.len(), 0); - } - - #[test] - fn test_hashmap_deduplication() { - let mut map = HashMap::new(); - let conn1 = create_test_connection("192.168.1.1", Some("Agent1"), "2024-01-01T12:00:00Z"); - let conn2 = create_test_connection("192.168.1.1", Some("Agent1"), "2024-01-01T11:00:00Z"); - - let id1 = conn1.id; - let id2 = conn2.id; - - map.insert(id1, conn1); - map.insert(id2, conn2); - - assert_eq!(map.len(), 2); - - let duplicate = - create_test_connection("192.168.1.1", Some("Agent1"), "2024-01-01T12:00:00Z"); - map.insert(id1, duplicate); - - assert_eq!(map.len(), 2); - } -} diff --git a/crates/private-server/src/app/devices/list.rs b/crates/private-server/src/app/devices/list.rs deleted file mode 100644 index e502153c..00000000 --- a/crates/private-server/src/app/devices/list.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::sync::Arc; - -use commons_errors::AppError; -use leptos::prelude::*; - -use crate::{ - components::{DeviceShorty, PaginatedList}, - fns::devices::DeviceInfo, -}; - -const PAGE_SIZE: u64 = 10; - -#[component] -pub fn Trusted() -> impl IntoView { - let count = Resource::new( - || (), - async |_| { - crate::fns::devices::count_trusted() - .await - .unwrap_or_default() - }, - ); - - let fetcher = |p| async move { - let offset = p * PAGE_SIZE; - crate::fns::devices::list_trusted(Some(PAGE_SIZE), Some(offset)).await - }; - - view! { - <List count fetcher /> - } -} - -#[component] -pub fn Untrusted() -> impl IntoView { - let count = Resource::new( - || (), - async |_| { - crate::fns::devices::count_untrusted() - .await - .unwrap_or_default() - }, - ); - - let fetcher = |p| async move { - let offset = p * PAGE_SIZE; - crate::fns::devices::list_untrusted(Some(PAGE_SIZE), Some(offset)).await - }; - - view! { - <List count fetcher /> - } -} - -#[component] -fn List<F, T>(count: Resource<u64>, fetcher: F) -> impl IntoView -where - F: Fn(u64) -> T + Send + Sync + 'static, - T: Future<Output = Result<Vec<Arc<DeviceInfo>>, AppError>> + Send + 'static, -{ - let (page, set_page) = signal(0u64); - let devices = Resource::new(move || page.get(), fetcher); - - view! { - <section class="section"> - <Transition fallback=|| view! { <progress class="progress is-small is-primary" max="100">"Loading..."</progress> }> - {move || devices.get().map(|result| { - match result { - Ok(devices) => { - if devices.is_empty() { - view! { - <div class="box has-text-info">"No devices found"</div> - }.into_any() - } else { - view! { - <PaginatedList - page=page - set_page=set_page - total_count=Signal::derive(move || count.get().unwrap_or(0)) - page_size=PAGE_SIZE - > - <DeviceList devices=devices.clone() /> - </PaginatedList> - }.into_any() - } - } - Err(e) => { - view! { - <div class="has-text-danger">{format!("Error loading devices: {}", e)}</div> - }.into_any() - } - } - })} - </Transition> - </section> - } -} - -#[component] -pub fn DeviceList(devices: Vec<Arc<crate::fns::devices::DeviceInfo>>) -> impl IntoView { - view! { - <For each=move || devices.clone() key=|device| device.device.id let:device> - <DeviceShorty device=device.clone() {..} class="level box" /> - </For> - } -} diff --git a/crates/private-server/src/app/devices/search.rs b/crates/private-server/src/app/devices/search.rs deleted file mode 100644 index 068e10a5..00000000 --- a/crates/private-server/src/app/devices/search.rs +++ /dev/null @@ -1,279 +0,0 @@ -use commons_types::server::{MetaTicket, kind::ServerKind, rank::ServerRank}; -use leptos::prelude::*; -use leptos_router::hooks::use_navigate; - -use super::list::DeviceList; - -#[component] -pub fn Search() -> impl IntoView { - let is_admin = Resource::new( - || (), - |_| async { - crate::fns::commons::is_current_user_admin() - .await - .unwrap_or(false) - }, - ); - - let (search_query, set_search_query) = signal(String::new()); - - let search_results = Resource::new( - move || search_query.get(), - async |query| { - if query.trim().is_empty() { - Ok(vec![]) - } else { - crate::fns::devices::search(query).await - } - }, - ); - - view! { - <div class="box mt-3"> - <header class="level"> - <div class="level-left"> - <h2 class="level-item is-size-4">"Search devices"</h2> - </div> - <Transition> - {move || { - is_admin.get().unwrap_or(false).then(|| view! { - <div class="level-right"> - <ImportTicketForm /> - </div> - }) - }} - </Transition> - </header> - <div class="field"> - <div class="control"> - <input - type="search" - placeholder="Search by public key, key name, or connection IP…" - prop:value=move || search_query.get() - on:input=move |ev| set_search_query.set(event_target_value(&ev)) - class="input" - /> - </div> - </div> - </div> - - <Suspense fallback=|| view! { <progress class="progress is-small is-primary" max="100">"Loading..."</progress> }> - {move || { - let query = search_query.get(); - (!query.trim().is_empty()).then_some(()).and(search_results.get()).map(|result| { - match result { - Ok(devices) => { - if devices.is_empty() { - view! { - <div class="box has-info-text">"No devices found matching your search"</div> - }.into_any() - } else { - view! { - <DeviceList devices /> - }.into_any() - } - } - Err(e) => { - view! { - <div class="box has-danger-text">{format!("Search error: {}", e)}</div> - }.into_any() - } - } - }) - }} - </Suspense> - } -} - -#[component] -fn ImportTicketForm() -> impl IntoView { - let (ticket, set_ticket) = signal(String::new()); - let (kind, set_kind) = signal(ServerKind::Facility); - let (rank, set_rank) = signal(Option::<ServerRank>::None); - - // Parse the ticket client-side to extract any kind/rank hints baked into it. - let parsed_ticket = Memo::new(move |_| MetaTicket::from_base64(&ticket.get()).ok()); - - let kind_from_ticket = Memo::new(move |_| parsed_ticket.get()?.kind); - - let rank_from_ticket = Memo::new(move |_| parsed_ticket.get()?.rank); - - // When the ticket supplies kind/rank, override the user's selection. - Effect::new(move |_| { - if let Some(k) = kind_from_ticket.get() { - set_kind.set(k); - } - if let Some(r) = rank_from_ticket.get() { - set_rank.set(Some(r)); - } - }); - let (open, set_open) = signal(false); - let (error, set_error) = signal(Option::<String>::None); - - let navigate = use_navigate(); - - let do_import = Action::new( - move |(ticket_b64, kind, rank): &(String, ServerKind, Option<ServerRank>)| { - let ticket_b64 = ticket_b64.clone(); - let kind = *kind; - let rank = *rank; - async move { crate::fns::servers::import_ticket(ticket_b64, kind, rank).await } - }, - ); - - Effect::new(move |_| { - if let Some(result) = do_import.value().get() { - match result { - Ok(server_id) => { - set_open.set(false); - set_ticket.set(String::new()); - set_error.set(None); - navigate(&format!("/servers/{server_id}"), Default::default()); - } - Err(e) => { - set_error.set(Some(e.to_string())); - } - } - } - }); - - let on_submit = move |ev: web_sys::SubmitEvent| { - ev.prevent_default(); - let value = ticket.get().trim().to_string(); - if value.is_empty() { - set_error.set(Some("Ticket cannot be empty".to_string())); - return; - } - set_error.set(None); - do_import.dispatch((value, kind.get(), rank.get())); - }; - - view! { - <button - class="button is-primary" - on:click=move |_| set_open.set(true) - > - "Import Ticket" - </button> - - <div class="modal" class:is-active=move || open.get()> - <div class="modal-background" on:click=move |_| set_open.set(false) /> - <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">"Import Meta Ticket"</p> - <button - class="delete" - aria-label="close" - on:click=move |_| set_open.set(false) - /> - </header> - <form on:submit=on_submit> - <section class="modal-card-body"> - <div class="field"> - <label class="label">"Ticket (base64)"</label> - <div class="control"> - <textarea - class="textarea is-family-monospace" - rows="5" - placeholder="Paste the base64-encoded Meta Ticket here..." - prop:value=move || ticket.get() - on:input=move |ev| set_ticket.set(event_target_value(&ev)) - /> - </div> - </div> - {move || parsed_ticket.get().map(|t| view! { - <table class="table is-fullwidth is-narrow mb-4"> - <tbody> - <tr> - <th>"Server ID"</th> - <td class="is-family-monospace">{t.server_id.to_string()}</td> - </tr> - <tr> - <th>"Host"</th> - <td class="is-family-monospace">{t.canonical_url.clone()}</td> - </tr> - <tr> - <th>"Hostname"</th> - <td>{t.hostname.clone()}</td> - </tr> - {t.tailscale_ip.map(|ip| view! { - <tr> - <th>"Tailscale IP"</th> - <td class="is-family-monospace">{ip}</td> - </tr> - })} - {t.hosting.map(|h| view! { - <tr> - <th>"Hosting"</th> - <td>{h}</td> - </tr> - })} - </tbody> - </table> - })} - <div class="field is-grouped"> - <div class="field mr-4"> - <label class="label">"Kind"</label> - <div class="control"> - <div class="select"> - <select - prop:value=move || kind.get().to_string() - disabled=move || kind_from_ticket.get().is_some() - on:change=move |ev| set_kind.set( - event_target_value(&ev).parse().unwrap_or_default() - ) - > - <option value={ServerKind::Facility}>{ServerKind::Facility}</option> - <option value={ServerKind::Central}>{ServerKind::Central}</option> - </select> - </div> - </div> - </div> - <div class="field"> - <label class="label">"Rank"</label> - <div class="control"> - <div class="select"> - <select - prop:value=move || rank.get().map_or_else(String::new, |r| r.to_string()) - disabled=move || rank_from_ticket.get().is_some() - on:change=move |ev| set_rank.set(event_target_value(&ev).parse().ok()) - > - <option value="">"unranked"</option> - <option value={ServerRank::Production}>{ServerRank::Production}</option> - <option value={ServerRank::Clone}>{ServerRank::Clone}</option> - <option value={ServerRank::Demo}>{ServerRank::Demo}</option> - <option value={ServerRank::Test}>{ServerRank::Test}</option> - <option value={ServerRank::Dev}>{ServerRank::Dev}</option> - </select> - </div> - </div> - </div> - </div> - {move || error.get().map(|e| view! { - <p class="help is-danger">{e}</p> - })} - </section> - <footer class="modal-card-foot"> - <div class="buttons"> - <button - type="submit" - class="button is-primary" - disabled=move || do_import.pending().get() - > - {move || if do_import.pending().get() { "Importing..." } else { "Import" }} - </button> - <button - type="button" - class="button" - on:click=move |_| set_open.set(false) - disabled=move || do_import.pending().get() - > - "Cancel" - </button> - </div> - </footer> - </form> - </div> - </div> - } -} diff --git a/crates/private-server/src/app/servers.rs b/crates/private-server/src/app/servers.rs deleted file mode 100644 index a1e4ac52..00000000 --- a/crates/private-server/src/app/servers.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::str::FromStr as _; - -use leptos::prelude::*; -use leptos_meta::{Stylesheet, provide_meta_context}; -use leptos_router::{components::A, hooks::use_params_map}; -use uuid::Uuid; - -use crate::{ - components::{EndTabs, SubTabs}, - fns::servers::get_name, -}; - -mod detail; -mod edit; -mod geo; -pub mod list; - -pub use detail::Detail; -pub use edit::Edit; - -#[component] -fn ServerBreadcrumb() -> impl IntoView { - let params = use_params_map(); - let server_id = move || { - params - .read() - .get("id") - .and_then(|id| Uuid::from_str(&id).ok()) - }; - let server_name = Resource::new(server_id, async move |id| { - if let Some(id) = id { - get_name(id).await.ok().map(|name| (id, name)) - } else { - None - } - }); - - view! { - <Transition>{move || { - server_name.get().flatten().map(|(id, name)| { - view! { - <A href=format!("/servers/{id}")>{name}</A> - } - }) - }}</Transition> - } -} - -#[component] -pub fn Page() -> impl IntoView { - provide_meta_context(); - - view! { - <Stylesheet id="css-servers" href="/static/servers.css" /> - <section class="section" id="servers-page"> - <SubTabs> - <A href="" exact=true>Central Servers</A> - <A href="facilities">Facility Servers</A> - - <EndTabs slot> - <ServerBreadcrumb /> - </EndTabs> - </SubTabs> - </section> - } -} diff --git a/crates/private-server/src/app/servers/detail.rs b/crates/private-server/src/app/servers/detail.rs deleted file mode 100644 index 18428743..00000000 --- a/crates/private-server/src/app/servers/detail.rs +++ /dev/null @@ -1,307 +0,0 @@ -use std::sync::Arc; - -use commons_types::{Uuid, geo::GeoPoint, server::kind::ServerKind}; -use leptos::{prelude::*, serde_json}; -use leptos_meta::Stylesheet; -use leptos_router::{components::A, hooks::use_params_map}; - -use crate::{ - app::servers::geo::CloudRegion, - components::{ - DeviceShorty, LoadingBar, ServerKindBadge, ServerRankBadge, ServerShorty, StatusDot, - StatusLegend, TimeAgo, VersionIndicator, VersionLegend, - }, - fns::servers::{ServerDetailData, ServerInfo, ServerLastStatusData, get_detail}, -}; - -fn is_admin_resource() -> Resource<bool> { - Resource::new( - || (), - |_| async { - crate::fns::commons::is_current_user_admin() - .await - .unwrap_or(false) - }, - ) -} - -#[component] -pub fn Detail() -> impl IntoView { - let params = use_params_map(); - let server_id = move || { - params - .read() - .get("id") - .and_then(|id| id.parse::<Uuid>().ok()) - .unwrap_or_default() - }; - - let detail_resource = Resource::new(server_id, async move |id| get_detail(id).await); - - let is_admin = is_admin_resource(); - - view! { - <Stylesheet id="css-servers" href="/static/servers.css" /> - <div id="status-detail-page"> - <Suspense fallback=|| view! { <LoadingBar /> }> - {move || { - detail_resource.get().map(|result| { - match result { - Ok(data) if data.server.kind == ServerKind::Central => { - view! { <ServerDetailView data is_admin /> }.into_any() - } - Ok(data) => { - view! { <ServerDetailView data is_admin /> }.into_any() - } - Err(err) => { - view! { <div class="box has-danger-text">{err.to_string()}</div> }.into_any() - } - } - }) - }} - </Suspense> - </div> - } -} - -#[component] -fn ServerDetailView(data: ServerDetailData, is_admin: Resource<bool>) -> impl IntoView { - let data = Arc::new(data); - - view! { - <div class="detail-container"> - <PageHeader data=data.clone() is_admin /> - <UrlSection data=data.clone() /> - <InfoSection status=data.last_status.clone() server=data.server.clone() /> - {(!data.child_servers.is_empty()).then(|| view! { <ChildServersSection data=data.clone() /> })} - <aside class="legend"> - <VersionLegend /> - <StatusLegend /> - </aside> - </div> - } -} - -#[component] -fn EditLink(server_id: Uuid, is_admin: Resource<bool>) -> impl IntoView { - view! { - <Transition> - {move || { - is_admin.get().unwrap_or(false).then(move || { - view! { - <A href=format!("/servers/{server_id}/edit") {..} class="button is-primary">"Edit"</A> - } - }) - }} - </Transition> - } -} - -#[component] -fn PageHeader(data: Arc<ServerDetailData>, is_admin: Resource<bool>) -> impl IntoView { - let server_id = data.server.id; - - view! { - <div class="level"> - <div class="level-left"> - <div class="level-item"> - {let rank = data.server.rank; move || rank.map(|rank| view! { <ServerRankBadge rank /> })} - <ServerKindBadge kind=data.server.kind /> - </div> - </div> - <h1 class="level-item is-size-3 status-dot-small"> - <StatusDot up=data.up name=data.server.name.clone().unwrap_or_default() /> - {data.child_servers.iter().map(|(up, child)| { - view! { - <StatusDot up=*up name=child.name.clone().unwrap_or_default() kind=child.kind /> - } - }).collect_view().into_any()} - {data.server.name.clone()} - </h1> - <div class="level-right"> - <div class="level-item"> - <EditLink server_id is_admin /> - </div> - </div> - </div> - } -} - -#[component] -fn UrlSection(data: Arc<ServerDetailData>) -> impl IntoView { - view! { - <div class="columns"> - <div class="column"> - <div class="box"> - <h2 class="is-size-5">URL</h2> - <a class="header-url" href={data.server.host.clone()} target="_blank">{data.server.host.clone()}</a> - </div> - </div> - {data.device_info.as_ref().map(|device| { - view! { - <div class="column"> - <div class="box"> - <h2 class="is-size-5">Device</h2> - <DeviceShorty device=device.clone() /> - </div> - </div> - } - })} - </div> - } -} - -#[component] -fn InfoSection( - server: Arc<ServerInfo>, - status: Option<Arc<ServerLastStatusData>>, -) -> impl IntoView { - view! { - <section class="box"> - <div class="info-grid"> - {status.as_ref().map(|status| view! { <StatusInfo status=status.clone() /> })} - {let listed = server.listed; (server.kind == ServerKind::Central).then(move || view! { - <div class="info-item"> - <span class="info-label">"Mobile list"</span> - <span class="info-value">{if listed { "Public" } else { "No" }}</span> - </div> - })} - {server.parent_server_id.map({ let server = server.clone(); |id| { - view! { - <div class="info-item"> - <span class="info-label">"Parent"</span> - <A href=format!("/servers/{id}") {..} class="info-value"> - {server.parent_server_name.clone().unwrap_or_else(|| id.to_string())} - </A> - </div> - } - }})} - {server.cloud.map(|is_cloud| { - view! { - <div class="info-item"> - <span class="info-label">"Location"</span> - {server.geolocation.map_or_else(|| view! { - <span class="info-value">{ if is_cloud { "Cloud" } else { "On premise" }}</span> - }.into_any(), |GeoPoint { lat, lon }| view! { - <a href=format!("https://www.google.com/maps/search/?api=1&query={lat},{lon}") class="info-value" target="_blank"> - {if is_cloud { - if let Some(region) = CloudRegion::from_lat_lon(lat, lon) { - region.as_str() - } else { - "Cloud" - } - } else { - "On premise" - }} - </a> - }.into_any())} - </div> - } - })} - </div> - - {status.as_ref().map(|status| view! { <ExtraData status=status.clone() /> })} - </section> - } -} - -// TODO: display a map with the locations of this and child servers - -#[component] -fn StatusInfo(status: Arc<ServerLastStatusData>) -> impl IntoView { - let min_chrome_version = status.min_chrome_version; - view! { - <div class:info-item> - <span class="info-label">"Last seen"</span> - <TimeAgo timestamp={status.created_at} {..} class:info-value /> - </div> - {status.platform.as_ref().map(|p| { - let p = p.clone(); - view! { - <div class:info-item> - <span class="info-label">"Platform"</span> - <span class:info-value>{p}</span> - </div> - } - })} - {status.timezone.as_ref().map(|tz| { - let tz = tz.clone(); - view! { - <div class:info-item> - <span class="info-label">"Timezone"</span> - <span class:info-value>{tz}</span> - </div> - } - })} - {status.version.as_ref().map(|v| { - let v = v.clone(); - let distance = status.version_distance; - - view! { - <div class:info-item class:version> - <span class="info-label">"Tamanu"</span> - <span class:info-value class:monospace> - <VersionIndicator version={v} distance={distance} /> - </span> - </div> - } - })} - {status.postgres.as_ref().map(|pg| { - let pg = pg.clone(); - view! { - <div class:info-item class:version> - <span class="info-label">"PostgreSQL"</span> - <span class:info-value class:monospace>{pg}</span> - </div> - } - })} - {status.nodejs.as_ref().map(|node| { - let node = node.clone(); - view! { - <div class:info-item class:version> - <span class="info-label">"Node.js"</span> - <span class:info-value class:monospace>{node}</span> - </div> - } - })} - {min_chrome_version.map(|chrome_ver| { - view! { - <div class="info-item version"> - <span class="info-label">"Chrome"</span> - <span class="info-value monospace">{format!("{chrome_ver} or later")}</span> - </div> - } - })} - } -} - -#[component] -fn ExtraData(status: Arc<ServerLastStatusData>) -> impl IntoView { - let extra = status.extra.clone(); - if !extra.as_object().map(|o| o.is_empty()).unwrap_or(true) { - view! { - <details class="mt-5"> - <summary>"Extra Data"</summary> - <pre>{serde_json::to_string_pretty(&extra).unwrap_or_default()}</pre> - </details> - } - .into_any() - } else { - ().into_any() - } -} - -#[component] -fn ChildServersSection(data: Arc<ServerDetailData>) -> impl IntoView { - view! { - <h2 class="is-size-4 mb-4">"Child servers (" {data.child_servers.len()} ")"</h2> - {data.child_servers.iter().map(|(up, child)| { - view! { - <div class="box child-server"> - <StatusDot up=*up /> - <ServerShorty server=child.clone() /> - </div> - } - }).collect::<Vec<_>>()} - } -} diff --git a/crates/private-server/src/app/servers/edit.rs b/crates/private-server/src/app/servers/edit.rs deleted file mode 100644 index 68d93f3a..00000000 --- a/crates/private-server/src/app/servers/edit.rs +++ /dev/null @@ -1,573 +0,0 @@ -use commons_errors::AppError; -use commons_types::{ - Uuid, - geo::GeoPoint, - server::{kind::ServerKind, rank::ServerRank}, -}; -use leptos::leptos_dom::helpers::request_animation_frame; -use leptos::prelude::*; -use leptos_router::{components::A, hooks::use_params_map}; - -use crate::{ - app::servers::geo::CloudRegion, - components::{ErrorHandler, LoadingBar}, - fns::servers::{ServerDataUpdate, ServerInfo, get_info, search_parent, update}, -}; - -#[component] -pub fn Edit() -> impl IntoView { - let params = use_params_map(); - let server_id = move || { - params - .read() - .get("id") - .and_then(|id| id.parse().ok()) - .unwrap_or_default() - }; - - let data = Resource::new(server_id, async move |id| get_info(id).await); - - view! { - <Transition fallback=|| { view!{ <LoadingBar /> } }> - <ErrorHandler> - {move || data.and_then(|info| { - let info = info.clone(); - view! { <EditForm info /> } - })} - </ErrorHandler> - </Transition> - } -} - -#[component] -pub fn EditForm(info: ServerInfo) -> impl IntoView { - let server_id = info.id; - let (name, set_name) = signal(info.name.clone()); - let (host, set_host) = signal(info.host.clone()); - let (kind, set_kind) = signal(info.kind); - let (rank, set_rank) = signal(info.rank); - - let (listed, set_listed) = signal(info.listed); - - let (parent_id, set_parent_id) = signal(info.parent_server_id); - let (device_id, set_device_id) = signal(info.device_id); - - let (cloud, set_cloud) = signal(info.cloud); - let (lat, set_lat) = signal(info.geolocation.map(|geo| geo.lat)); - let (lon, set_lon) = signal(info.geolocation.map(|geo| geo.lon)); - - let save_data = Signal::derive(move || ServerDataUpdate { - name: name.get(), - host: Some(host.get()), - kind: Some(kind.get()), - rank: rank.get(), - listed: Some(listed.get()), - parent_server_id: Some(parent_id.get()), - device_id: Some(device_id.get()), - cloud: Some(cloud.get()), - geolocation: Some(match (lat.get(), lon.get()) { - (Some(lat), Some(lon)) => Some(GeoPoint { lat, lon }), - _ => None, - }), - ..Default::default() - }); - - let navigate = leptos_router::hooks::use_navigate(); - let submit = Action::new(move |sig: &Signal<ServerDataUpdate>| { - let data = sig.get(); - let navigate = navigate.clone(); - async move { - update(server_id, data).await?; - navigate(&format!("/servers/{server_id}"), Default::default()); - Ok::<_, AppError>(()) - } - }); - - view! { - <form class="box" on:submit=move |ev| { - ev.prevent_default(); - submit.dispatch(save_data); - }> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label" for="field-name">"Name"</label> - </div> - <div class="field-body"> - <div class="field"> - <div class="control"> - <input - id="field-name" - name="name" - class="input" - type="text" - disabled=move || submit.pending().get() - prop:value=move || name.get() - on:change=move |ev| set_name.set(none_if_empty(event_target_value(&ev))) /> - </div> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label" for="field-host">URL</label> - </div> - <div class="field-body"> - <div class="field"> - <div class="control"> - <input - id="field-host" - name="host" - class="input" - type="text" - disabled=move || submit.pending().get() - prop:value=move || host.get() - on:change=move |ev| set_host.set(event_target_value(&ev)) /> - </div> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label" for="field-kind">"Kind"</label> - </div> - <div class="field-body"> - <div class="field"> - <div class="control"> - <div class="select"> - <select - id="field-kind" - disabled=move || submit.pending().get() - prop:value=move || kind.get() - on:change=move |ev| set_kind.set(event_target_value(&ev).parse().unwrap_or_default()) - > - <option value={ServerKind::Central}>{ServerKind::Central}</option> - <option value={ServerKind::Facility}>{ServerKind::Facility}</option> - </select> - </div> - </div> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label" for="field-rank">"Rank"</label> - </div> - <div class="field-body"> - <div class="field"> - <div class="control"> - <div class="select"> - <select - id="field-rank" - disabled=move || submit.pending().get() - prop:value=move || rank.get().map_or("".to_string(), |rank| rank.to_string()) - on:change=move |ev| set_rank.set(event_target_value(&ev).parse().ok()) - > - <option value="">"unranked"</option> - <option value={ServerRank::Production}>{ServerRank::Production}</option> - <option value={ServerRank::Clone}>{ServerRank::Clone}</option> - <option value={ServerRank::Demo}>{ServerRank::Demo}</option> - <option value={ServerRank::Test}>{ServerRank::Test}</option> - <option value={ServerRank::Dev}>{ServerRank::Dev}</option> - </select> - </div> - </div> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label" for="field-device-id">"Device ID"</label> - </div> - <div class="field-body"> - <div class="field"> - <div class="control"> - <input - id="field-device-id" - name="device_id" - class="input" - type="text" - placeholder="UUID" - disabled=move || submit.pending().get() - prop:value=move || device_id.get().map(|id| id.to_string()) - on:change=move |ev| set_device_id.set(none_if_empty(event_target_value(&ev)).and_then(|id| id.parse().ok())) - /> - </div> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label" for="field-parent-id">"Parent Server"</label> - </div> - <div class="field-body"> - <div class="field"> - <ParentServerControl server_id kind rank parent_id set_parent_id pending=submit.pending() /> - </div> - </div> - </div> - <GeolocationControl cloud set_cloud lat set_lat lon set_lon pending=submit.pending() /> - {move || (kind.get() == ServerKind::Central).then(|| view! { - <div class="field is-horizontal"> - <div class="field-label"></div> - <div class="field-body"> - <div class="field"> - <div class="control"> - <label class="checkbox"> - <input - class="mr-2" - type="checkbox" - disabled=move || submit.pending().get() - prop:checked=move || listed.get() - on:change=move |ev| set_listed.set(event_target_checked(&ev)) /> - "Available in Tamanu Mobile app" - </label> - </div> - </div> - </div> - </div> - })} - <div class="field is-horizontal"> - <div class="field-label"></div> - <div class="field-body"> - <div class="field is-grouped"> - <div class="control"> - <button - type="submit" - class="button is-primary" - disabled=move || submit.pending().get() - >"Save"</button> - </div> - <div class="control"> - <A - href=format!("/servers/{server_id}") - {..} - class="button is-danger is-light" - class:is-disabled=move || submit.pending().get() - >"Cancel"</A> - </div> - </div> - </div> - </div> - </form> - } -} - -#[component] -pub fn GeolocationControl( - cloud: ReadSignal<Option<bool>>, - set_cloud: WriteSignal<Option<bool>>, - lat: ReadSignal<Option<f64>>, - set_lat: WriteSignal<Option<f64>>, - lon: ReadSignal<Option<f64>>, - set_lon: WriteSignal<Option<f64>>, - pending: Memo<bool>, -) -> impl IntoView { - view! { - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label" for="field-cloud">"Location"</label> - </div> - <div class="field-body"> - <div class="field is-narrow"> - <div class="control is-expanded"> - <div class="select is-fullwidth"> - <select - id="field-cloud" - disabled=move || pending.get() - prop:value=move || cloud.get().map_or("unknown", |cloud| if cloud { "true" } else { "false" }) - on:change=move |ev| set_cloud.set(match event_target_value(&ev).as_str() { - "true" => Some(true), - "false" => Some(false), - _ => None, - }) - > - <option value="unknown">"unknown"</option> - <option value="true">"cloud"</option> - <option value="false">"on premise"</option> - </select> - </div> - </div> - </div> - {move || (cloud.get() == Some(true)).then(|| view! { <RegionSelection lat set_lat lon set_lon pending /> })} - <div class="field"> - <div class="control is-expanded"> - <input - class="input" - type="text" - placeholder="Latitude" - disabled=move || pending.get() - prop:value=move || lat.get().map_or(String::new(), |n| n.to_string()) - on:change=move |ev| set_lat.set(event_target_value(&ev).parse().ok()) /> - </div> - </div> - <div class="field"> - <div class="control is-expanded"> - <input - class="input" - type="text" - placeholder="Longitude" - disabled=move || pending.get() - prop:value=move || lon.get().map_or(String::new(), |n| n.to_string()) - on:change=move |ev| set_lon.set(event_target_value(&ev).parse().ok()) /> - </div> - </div> - </div> - </div> - } -} - -#[component] -pub fn RegionSelection( - lat: ReadSignal<Option<f64>>, - set_lat: WriteSignal<Option<f64>>, - lon: ReadSignal<Option<f64>>, - set_lon: WriteSignal<Option<f64>>, - pending: Memo<bool>, -) -> impl IntoView { - let (region, set_region) = signal(None::<CloudRegion>); - - Effect::new(move || { - if let (Some(lat), Some(lon)) = (lat.get(), lon.get()) { - set_region.set(CloudRegion::from_lat_lon(lat, lon)); - } - }); - - view! { - <div class="field is-narrow"> - <div class="control is-expanded"> - <div class="select is-fullwidth"> - <select - disabled=move || pending.get() - prop:value=move || region.get().map_or("", |reg| reg.as_str()) - on:change=move |ev| { - let region = event_target_value(&ev).parse().ok(); - set_region.set(region); - match region.map(|reg| reg.to_lat_lon()) { - Some((lat, lon)) => { - set_lat.set(Some(lat)); - set_lon.set(Some(lon)); - } - None => { - set_lat.set(None); - set_lon.set(None); - } - } - } - > - <option disabled value="">"Other region"</option> - <For each=move || CloudRegion::ALL key=|r| *r let:region> - <option value={region.as_str()}>{region.as_str()}</option> - </For> - </select> - </div> - </div> - </div> - } -} - -#[component] -pub fn ParentServerControl( - server_id: Uuid, - kind: ReadSignal<ServerKind>, - rank: ReadSignal<Option<ServerRank>>, - parent_id: ReadSignal<Option<Uuid>>, - set_parent_id: WriteSignal<Option<Uuid>>, - pending: Memo<bool>, -) -> impl IntoView { - let (parent_search_query, set_parent_search_query) = signal(String::new()); - let (show_parent_results, set_show_parent_results) = signal(false); - - let current_parent_info = Resource::new( - move || parent_id.get(), - move |id| async move { - if let Some(parent_id) = id { - get_info(parent_id).await.ok() - } else { - None - } - }, - ); - - let parent_search_results = Resource::new( - move || (parent_search_query.get(), kind.get(), rank.get()), - move |(query, current_kind, current_rank)| async move { - if query.is_empty() { - return Ok::<Vec<ServerInfo>, AppError>(Vec::new()); - } - search_parent(query, server_id, current_rank, current_kind).await - }, - ); - - view! { - <div class="control"> - <input - id="field-parent-id" - name="parent-id" - class="input" - type="text" - placeholder="Enter UUID or search by name/host" - disabled=move || pending.get() - prop:value=move || { - parent_id.get() - .map(|id| id.to_string()) - .unwrap_or_else(|| parent_search_query.get()) - } - on:input=move |ev| { - let value = event_target_value(&ev); - if let Ok(uuid) = value.parse::<Uuid>() { - set_parent_id.set(Some(uuid)); - set_show_parent_results.set(false); - } else { - set_parent_search_query.set(value); - set_show_parent_results.set(true); - } - } - on:focus=move |_| { - if !parent_search_query.get().is_empty() { - set_show_parent_results.set(true); - } - } - on:blur=move |_| { - request_animation_frame(move || { - set_show_parent_results.set(false); - }); - } - /> - {move || { - parent_id.get().map(|_| { - view! { - <div class="mt-2" style="display: flex; align-items: center; gap: 1rem;"> - <Suspense fallback=move || view! { <span class="tag">"Loading..."</span> }> - {move || { - current_parent_info.get().flatten().map(|current| { - let display_name = current.name.clone() - .unwrap_or_else(|| current.host.clone()); - let rank_text = current.rank.map(|r| r.to_string()).unwrap_or_else(|| "unranked".to_string()); - let kind_text = current.kind.to_string(); - view! { - <span class="tag is-info is-light"> - "Current: " {display_name} " (" {kind_text} ", " {rank_text} ")" - </span> - } - }) - }} - </Suspense> - <button - type="button" - class="button is-small" - on:click=move |_| { - set_parent_id.set(None); - set_parent_search_query.set(String::new()); - } - > - "Clear parent" - </button> - </div> - } - }) - }} - </div> - <Transition> - {move || { - (show_parent_results.get() && !parent_search_query.get().is_empty()).then(|| { - view! { - <div class="dropdown is-active" style="width: 100%; position: relative;"> - <div class="dropdown-menu" style="width: 100%; position: absolute; top: 0; left: 0;"> - <div class="dropdown-content"> - <Suspense fallback=move || view! { <div class="dropdown-item">"Loading..."</div> }> - {move || { - let current_parent = current_parent_info.get().flatten(); - let current_parent_id = parent_id.get(); - - parent_search_results.and_then(|results| { - let mut items = vec![]; - - if let Some(ref current) = current_parent { - let server_id_val = current.id; - let display_name = current.name.clone() - .unwrap_or_else(|| current.host.clone()); - let rank_text = current.rank.map(|r| r.to_string()).unwrap_or_else(|| "unranked".to_string()); - let kind_text = current.kind.to_string(); - - items.push(view! { - <a - class="dropdown-item" - style="cursor: pointer;" - on:mousedown=move |ev| { - ev.prevent_default(); - set_parent_id.set(Some(server_id_val)); - set_parent_search_query.set(String::new()); - set_show_parent_results.set(false); - } - > - <div> - <strong> - "✓ " - {display_name} - </strong> - <br/> - <small>{current.host.clone()} " • " {kind_text} " • " {rank_text}</small> - </div> - </a> - }.into_any()); - - if !results.is_empty() { - items.push(view! { - <hr class="dropdown-divider" /> - }.into_any()); - } - } - - if results.is_empty() && current_parent.is_none() { - items.push(view! { - <div class="dropdown-item">"No results found"</div> - }.into_any()); - } else { - for server in results.iter() { - if Some(server.id) == current_parent_id { - continue; - } - - let server_id_val = server.id; - let display_name = server.name.clone() - .unwrap_or_else(|| server.host.clone()); - let rank_text = server.rank.map(|r| r.to_string()).unwrap_or_else(|| "unranked".to_string()); - let kind_text = server.kind.to_string(); - items.push(view! { - <a - class="dropdown-item" - style="cursor: pointer;" - on:mousedown=move |ev| { - ev.prevent_default(); - set_parent_id.set(Some(server_id_val)); - set_parent_search_query.set(String::new()); - set_show_parent_results.set(false); - } - > - <div> - <strong>{display_name}</strong> - <br/> - <small>{server.host.clone()} " • " {kind_text} " • " {rank_text}</small> - </div> - </a> - }.into_any()); - } - } - - items.into_any() - }) - }} - </Suspense> - </div> - </div> - </div> - } - }) - }} - </Transition> - } -} - -fn none_if_empty(s: String) -> Option<String> { - if s.is_empty() { None } else { Some(s) } -} diff --git a/crates/private-server/src/app/servers/geo.rs b/crates/private-server/src/app/servers/geo.rs deleted file mode 100644 index 35088335..00000000 --- a/crates/private-server/src/app/servers/geo.rs +++ /dev/null @@ -1,157 +0,0 @@ -use std::str::FromStr; - -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] -pub enum CloudRegion { - AwsAuckland, - AwsCalifornia, - AwsCanada, - AwsFrankfurt, - AwsLondon, - AwsMumbai, - AwsNorthVirginia, - AwsOhio, - AwsOregon, - AwsParis, - AwsSaoPaulo, - AwsSeoul, - AwsSingapore, - AwsSydney, - AwsTokyo, - AwsZurich, - AzureAustraliaEast, - AzureEastUs, - AzureSoutheastAsia, - AzureWestEurope, -} - -impl CloudRegion { - pub const ALL: [Self; 20] = [ - Self::AwsAuckland, - Self::AwsCalifornia, - Self::AwsCanada, - Self::AwsFrankfurt, - Self::AwsLondon, - Self::AwsMumbai, - Self::AwsNorthVirginia, - Self::AwsOhio, - Self::AwsOregon, - Self::AwsParis, - Self::AwsSaoPaulo, - Self::AwsSeoul, - Self::AwsSingapore, - Self::AwsSydney, - Self::AwsTokyo, - Self::AwsZurich, - Self::AzureAustraliaEast, - Self::AzureEastUs, - Self::AzureSoutheastAsia, - Self::AzureWestEurope, - ]; - - pub fn to_lat_lon(self) -> (f64, f64) { - match self { - CloudRegion::AwsAuckland => (-36.8485, 174.7633), - CloudRegion::AwsCalifornia => (37.7749, -122.4194), - CloudRegion::AwsCanada => (45.5017, -73.5673), - CloudRegion::AwsFrankfurt => (50.110922, 8.682127), - CloudRegion::AwsLondon => (51.507351, -0.127758), - CloudRegion::AwsMumbai => (19.075984, 72.877656), - CloudRegion::AwsNorthVirginia => (38.9072, -77.0369), - CloudRegion::AwsOhio => (39.9612, -82.9988), - CloudRegion::AwsOregon => (45.5152, -122.6784), - CloudRegion::AwsParis => (48.856614, 2.352222), - CloudRegion::AwsSaoPaulo => (-23.5505, -46.6333), - CloudRegion::AwsSeoul => (37.566535, 126.977969), - CloudRegion::AwsSingapore => (1.352083, 103.819836), - CloudRegion::AwsSydney => (-33.868820, 151.209296), - CloudRegion::AwsTokyo => (35.689487, 139.691706), - CloudRegion::AwsZurich => (47.376887, 8.541694), - CloudRegion::AzureAustraliaEast => (-33.8688, 151.2093), - CloudRegion::AzureEastUs => (37.3719, -79.8164), - CloudRegion::AzureSoutheastAsia => (1.283333, 103.833333), - CloudRegion::AzureWestEurope => (52.3667, 4.8945), - } - } - - // TODO: make fuzzy - pub fn from_lat_lon(lat: f64, lon: f64) -> Option<Self> { - match (lat, lon) { - (-36.8485, 174.7633) => Some(CloudRegion::AwsAuckland), - (50.110922, 8.682127) => Some(CloudRegion::AwsFrankfurt), - (37.7749, -122.4194) => Some(CloudRegion::AwsCalifornia), - (45.5017, -73.5673) => Some(CloudRegion::AwsCanada), - (51.507351, -0.127758) => Some(CloudRegion::AwsLondon), - (19.075984, 72.877656) => Some(CloudRegion::AwsMumbai), - (38.9072, -77.0369) => Some(CloudRegion::AwsNorthVirginia), - (39.9612, -82.9988) => Some(CloudRegion::AwsOhio), - (45.5152, -122.6784) => Some(CloudRegion::AwsOregon), - (48.856614, 2.352222) => Some(CloudRegion::AwsParis), - (-23.5505, -46.6333) => Some(CloudRegion::AwsSaoPaulo), - (37.566535, 126.977969) => Some(CloudRegion::AwsSeoul), - (1.352083, 103.819836) => Some(CloudRegion::AwsSingapore), - (-33.868820, 151.209296) => Some(CloudRegion::AwsSydney), - (35.689487, 139.691706) => Some(CloudRegion::AwsTokyo), - (47.376887, 8.541694) => Some(CloudRegion::AwsZurich), - (-33.8688, 151.2093) => Some(CloudRegion::AzureAustraliaEast), - (1.283333, 103.833333) => Some(CloudRegion::AzureSoutheastAsia), - (52.3667, 4.8945) => Some(CloudRegion::AzureWestEurope), - (37.3719, -79.8164) => Some(CloudRegion::AzureEastUs), - _ => None, - } - } - - pub fn as_str(self) -> &'static str { - match self { - CloudRegion::AwsAuckland => "AWS Auckland", - CloudRegion::AwsCalifornia => "AWS California", - CloudRegion::AwsCanada => "AWS Canada", - CloudRegion::AwsFrankfurt => "AWS Frankfurt", - CloudRegion::AwsLondon => "AWS London", - CloudRegion::AwsMumbai => "AWS Mumbai", - CloudRegion::AwsNorthVirginia => "AWS North Virginia", - CloudRegion::AwsOhio => "AWS Ohio", - CloudRegion::AwsOregon => "AWS Oregon", - CloudRegion::AwsParis => "AWS Paris", - CloudRegion::AwsSaoPaulo => "AWS São Paulo", - CloudRegion::AwsSeoul => "AWS Seoul", - CloudRegion::AwsSingapore => "AWS Singapore", - CloudRegion::AwsSydney => "AWS Sydney", - CloudRegion::AwsTokyo => "AWS Tokyo", - CloudRegion::AwsZurich => "AWS Zurich", - CloudRegion::AzureAustraliaEast => "Azure Australia East", - CloudRegion::AzureEastUs => "Azure East US", - CloudRegion::AzureSoutheastAsia => "Azure Southeast Asia", - CloudRegion::AzureWestEurope => "Azure West Europe", - } - } -} - -impl FromStr for CloudRegion { - type Err = (); - - fn from_str(s: &str) -> Result<Self, Self::Err> { - match s { - "AWS Auckland" => Ok(CloudRegion::AwsAuckland), - "AWS California" => Ok(CloudRegion::AwsCalifornia), - "AWS Canada" => Ok(CloudRegion::AwsCanada), - "AWS Frankfurt" => Ok(CloudRegion::AwsFrankfurt), - "AWS London" => Ok(CloudRegion::AwsLondon), - "AWS Mumbai" => Ok(CloudRegion::AwsMumbai), - "AWS North Virginia" => Ok(CloudRegion::AwsNorthVirginia), - "AWS Ohio" => Ok(CloudRegion::AwsOhio), - "AWS Oregon" => Ok(CloudRegion::AwsOregon), - "AWS Paris" => Ok(CloudRegion::AwsParis), - "AWS Seoul" => Ok(CloudRegion::AwsSeoul), - "AWS Singapore" => Ok(CloudRegion::AwsSingapore), - "AWS Sydney" => Ok(CloudRegion::AwsSydney), - "AWS São Paulo" => Ok(CloudRegion::AwsSaoPaulo), - "AWS Tokyo" => Ok(CloudRegion::AwsTokyo), - "AWS Zurich" => Ok(CloudRegion::AwsZurich), - "Azure Australia East" => Ok(CloudRegion::AzureAustraliaEast), - "Azure East US" => Ok(CloudRegion::AzureEastUs), - "Azure Southeast Asia" => Ok(CloudRegion::AzureSoutheastAsia), - "Azure West Europe" => Ok(CloudRegion::AzureWestEurope), - _ => Err(()), - } - } -} diff --git a/crates/private-server/src/app/servers/import.rs b/crates/private-server/src/app/servers/import.rs deleted file mode 100644 index 6aa48837..00000000 --- a/crates/private-server/src/app/servers/import.rs +++ /dev/null @@ -1,7 +0,0 @@ -use leptos::prelude::*; -use leptos_router::hooks::use_navigate; - -#[component] -pub fn ImportTicket() -> impl IntoView { - let (ticket, set_ticket) = signal(String::new()); - let (error, set_error) = signal(Option diff --git a/crates/private-server/src/app/servers/list.rs b/crates/private-server/src/app/servers/list.rs deleted file mode 100644 index 40064716..00000000 --- a/crates/private-server/src/app/servers/list.rs +++ /dev/null @@ -1,90 +0,0 @@ -use std::sync::Arc; - -use commons_errors::AppError; -use commons_types::server::kind::ServerKind; -use leptos::prelude::*; - -use crate::{ - components::{ErrorHandler, LoadingBar, Nothing, PaginatedList, ServerShorty}, - fns::servers::{ServerInfo, count_some, list_some}, -}; - -const PAGE_SIZE: u64 = 10; - -#[component] -pub fn Centrals() -> impl IntoView { - view! { - <ListServers kind=ServerKind::Central /> - } -} - -#[component] -pub fn Facilities() -> impl IntoView { - view! { - <ListServers kind=ServerKind::Facility /> - } -} - -#[component] -fn ListServers(kind: ServerKind) -> impl IntoView { - let count = Resource::new( - || (), - move |_| async move { count_some(Some(kind)).await.unwrap_or_default() }, - ); - - let fetcher = move |p| async move { - let offset = p * PAGE_SIZE; - list_some(Some(kind), offset, Some(PAGE_SIZE)).await - }; - - view! { - <List count fetcher /> - } -} - -#[component] -fn List<F, T>(count: Resource<u64>, fetcher: F) -> impl IntoView -where - F: Fn(u64) -> T + Send + Sync + 'static, - T: Future<Output = Result<Vec<Arc<ServerInfo>>, AppError>> + Send + 'static, -{ - let (page, set_page) = signal(0u64); - let servers = Resource::new(move || page.get(), fetcher); - - view! { - <section class="section"> - <Transition fallback=|| view! { <LoadingBar /> }> - <ErrorHandler> - {move || servers.and_then(|servers| { - if servers.is_empty() { - return view! { - <Nothing thing="servers" /> - }.into_any(); - } - - let servers = servers.clone(); - view! { - <PaginatedList - page=page - set_page=set_page - total_count=Signal::derive(move || count.get().unwrap_or(0)) - page_size=PAGE_SIZE - > - <ServerList servers /> - </PaginatedList> - }.into_any() - })} - </ErrorHandler> - </Transition> - </section> - } -} - -#[component] -pub fn ServerList(servers: Vec<Arc<ServerInfo>>) -> impl IntoView { - view! { - <For each=move || servers.clone() key=|server| server.id let:server> - <ServerShorty server=server.clone() {..} class="level box" /> - </For> - } -} diff --git a/crates/private-server/src/app/sql.rs b/crates/private-server/src/app/sql.rs deleted file mode 100644 index cf7b5569..00000000 --- a/crates/private-server/src/app/sql.rs +++ /dev/null @@ -1,327 +0,0 @@ -use leptos::prelude::*; -use leptos_meta::Stylesheet; -use serde_json::Value; - -use crate::components::{ErrorHandler, LoadingBar, PaginatedList, TimeAgo, ToggleSignal}; -use crate::fns::sql::{ - SqlHistoryEntry, SqlQuery, SqlResult, execute_query, get_last_user_query, get_query_history, - get_query_history_count, -}; - -#[component] -pub fn Page() -> impl IntoView { - view! { - <Stylesheet id="css-sql" href="/static/private/sql.css" /> - <section class="section" id="sql-page"> - <h1 class="title">"SQL Playground"</h1> - <Suspense> - <ErrorHandler> - <SqlQueryForm /> - </ErrorHandler> - </Suspense> - </section> - } -} - -#[component] -pub fn SqlQueryForm() -> impl IntoView { - let (query, set_query) = signal(String::new()); - let (is_executing, set_is_executing) = signal(false); - let (error, set_error) = signal::<Option<String>>(None); - let (result, set_result) = signal::<Option<SqlResult>>(None); - let (show_history, set_show_history) = signal(false); - let (history_page, set_history_page) = signal(0); - - let last_query_action = Action::new(|_| async move { - match get_last_user_query().await { - Ok(Some(last_query)) => last_query, - Ok(None) => String::new(), - Err(_) => String::new(), - } - }); - - let history_count = - LocalResource::new(|| async move { get_query_history_count().await.unwrap_or(0) }); - let history_resource = LocalResource::new(move || async move { - let page_size = 10; - let offset = history_page.get() * page_size; - get_query_history(offset, Some(page_size)).await.ok() - }); - - let load_last_query = move |_| { - last_query_action.dispatch(()); - }; - - Effect::new(move |_| { - if let Some(last_query) = last_query_action.value().get() - && !last_query.is_empty() - { - set_query.set(last_query); - } - }); - - let toggle_history = move |_| { - if set_show_history.toggle_and_return() { - history_resource.refetch(); - } - }; - - let execute_action = Action::new(move |query_text: &String| { - let query_text = query_text.clone(); - async move { - set_is_executing.set(true); - set_error.set(None); - set_result.set(None); - - let query = SqlQuery { - query: query_text.clone(), - }; - - match execute_query(query).await { - Ok(result) => { - set_result.set(Some(result)); - } - Err(err) => { - set_error.set(Some(err.to_string())); - } - } - - set_is_executing.set(false); - - if show_history.get() { - history_resource.refetch(); - } - } - }); - - let handle_submit = move |ev: leptos::ev::SubmitEvent| { - ev.prevent_default(); - if !query.get().trim().is_empty() { - execute_action.dispatch(query.get()); - } - }; - - view! { - <div class="sql-query-container"> - <form on:submit=handle_submit> - <div class="field"> - <div class="control"> - <textarea - class="textarea monospace" - placeholder="SELECT * FROM statuses ORDER BY created_at DESC LIMIT 10;" - rows=10 - prop:value=query - disabled=move || is_executing.get() - on:input=move |ev| set_query.set(event_target_value(&ev)) - on:keyup=move |ev| { - if ev.key() == "Enter" && ev.ctrl_key() - && !query.get().trim().is_empty() { - execute_action.dispatch(query.get()); - } - } - /> - </div> - </div> - - <div class="field is-grouped"> - <div class="control"> - <button - class="button is-primary" - type="submit" - disabled=move || query.get().trim().is_empty() || is_executing.get() - class:is-loading=move || is_executing.get() - > - <span> - "Run" - </span> - </button> - </div> - <div class="control is-expanded"></div> - <div class="control"> - <button - class="button is-info" - type="button" - on:click=load_last_query - disabled=move || is_executing.get() - > - <span class="icon"> - "↶" - </span> - <span>"Last query"</span> - </button> - </div> - <div class="control"> - <button - class="button is-info is-light" - type="button" - on:click=toggle_history - disabled=move || is_executing.get() - > - <span>"History"</span> - <span class="icon"> - {move || if show_history.get() { "▼" } else { "▶" }} - </span> - </button> - </div> - </div> - </form> - - <Show when=move || show_history.get()> - <Transition fallback=move || view! { <LoadingBar /> }> - {move || history_resource.get().map(|history| match history { - Some(data) => view! { - <div class="box my-4"> - <PaginatedList - page=history_page - set_page=set_history_page - total_count=Signal::derive(move || history_count.get().unwrap_or(0)) - page_size=10 - > - <HistoryDisplay data set_query /> - </PaginatedList> - </div> - }.into_any(), - None => view! { <div class="notification is-warning">"No history available"</div> }.into_any(), - })} - </Transition> - </Show> - - <Show when=move || error.get().is_some()> - <div class="notification is-danger mt-4"> - <button class="delete" on:click=move |_| set_error.set(None)></button> - <strong>"Error executing query:"</strong> - <br /> - {error.get().unwrap()} - </div> - </Show> - - <Show when=move || result.get().is_some()> - <SqlResultDisplay result=result.get().unwrap() /> - </Show> - </div> - } -} - -#[component] -pub fn SqlResultDisplay(result: SqlResult) -> impl IntoView { - view! { - <div class="sql-result-container mt-6"> - <div class="notification is-info is-light"> - <p> - <strong>"Query executed successfully!"</strong> - <br /> - <span>"Returned "</span> - <strong>{result.row_count}</strong> - <span>" rows in "</span> - <strong>{result.execution_time_ms}</strong> - <span>" ms"</span> - </p> - </div> - - <div class="table-container"> - <table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth"> - <thead> - <tr> - <For - each=move || result.columns.clone() - key=|col| col.clone() - let:column - > - <th>{column}</th> - </For> - </tr> - </thead> - <tbody> - <For - each=move || result.rows.clone() - key=|row| format!("{:?}", row) - let:row - > - <tr> - <For - each=move || row.clone() - key=|cell| format!("{:?}", cell) - let:cell - > - <td> - <JsonValueDisplay value=cell /> - </td> - </For> - </tr> - </For> - </tbody> - </table> - </div> - - <Show when=move || result.row_count == 0> - <div class="notification is-warning mt-4"> - "No rows returned by the query." - </div> - </Show> - </div> - } -} - -#[component] -pub fn JsonValueDisplay(value: Value) -> impl IntoView { - match value { - Value::Null => view! { <span class="has-text-grey">"NULL"</span> }.into_any(), - Value::Bool(b) => view! { <span class="has-text-info">{b.to_string()}</span> }.into_any(), - Value::Number(n) => { - view! { <span class="has-text-primary">{n.to_string()}</span> }.into_any() - } - Value::String(s) => view! { <span class="has-text-success">{s}</span> }.into_any(), - Value::Array(arr) => view! { - <span class="has-text-warning"> - "[" - {arr.iter().map(|v| view! { <JsonValueDisplay value=v.clone() /> }).collect::<Vec<_>>()} - "]" - </span> - } - .into_any(), - obj @ Value::Object(_) => view! { - <pre>{serde_json::to_string_pretty(&obj).unwrap_or_default()}</pre> - } - .into_any(), - } -} - -#[component] -pub fn HistoryDisplay(data: Vec<SqlHistoryEntry>, set_query: WriteSignal<String>) -> impl IntoView { - view! { - <table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth"> - <thead> - <tr> - <th>"When"</th> - <th>"Who"</th> - <th>"What"</th> - <th></th> - </tr> - </thead> - <tbody> - <For - each=move || data.clone() - key=|entry| entry.id - let:entry - > - <tr> - <td> - <TimeAgo timestamp={entry.created_at} /> - </td> - <td> - <span class="tag is-info">{entry.tailscale_user}</span> - </td> - <td class="monospace"> - {entry.query.clone()} - </td> - <td> - <button class="button is-small is-info" on:click=move |_| set_query.set(entry.query.clone())> - Recall - </button> - </td> - </tr> - </For> - </tbody> - </table> - } -} diff --git a/crates/private-server/src/app/status.rs b/crates/private-server/src/app/status.rs deleted file mode 100644 index 1047903b..00000000 --- a/crates/private-server/src/app/status.rs +++ /dev/null @@ -1,228 +0,0 @@ -use commons_types::server::rank::ServerRank; -use leptos::prelude::*; -use leptos_meta::Stylesheet; -use uuid::Uuid; - -use crate::{ - components::{LoadingBar, ReleaseSummary, StatusLegend, VersionIndicator, VersionLegend}, - fns::statuses::{server_details, server_grouped_ids}, -}; - -#[component] -pub fn Page() -> impl IntoView { - view! { - <Stylesheet id="css-status" href="/static/status.css" /> - <section class="section" id="status-page"> - <ReleaseSummary /> - <ServerCards /> - <aside class="legend"> - <VersionLegend /> - <StatusLegend /> - </aside> - </section> - } -} - -#[component] -pub fn ServerCards() -> impl IntoView { - #[cfg_attr( - feature = "ssr", - expect(unused_variables, reason = "set_trigger only used on the client") - )] - let (trigger, set_trigger) = signal(0); - let grouped_ids_resource = LocalResource::new(async || server_grouped_ids().await); - - Effect::new({ - let resource = grouped_ids_resource; - move |_| { - trigger.get(); - resource.refetch(); - } - }); - - // Auto-reload every minute when page is visible - #[cfg(not(feature = "ssr"))] - Effect::new(move |_| { - use wasm_bindgen::JsCast; - use wasm_bindgen::closure::Closure; - - // Track last reload time - let last_reload = std::rc::Rc::new(std::cell::Cell::new(web_sys::js_sys::Date::now())); - - // Set up interval for regular reloads - let _ = leptos::prelude::set_interval( - { - let last_reload = last_reload.clone(); - move || { - if let Some(document) = web_sys::window().and_then(|w| w.document()) { - if !document.hidden() { - last_reload.set(web_sys::js_sys::Date::now()); - set_trigger.update(|v| *v += 1); - } - } - } - }, - std::time::Duration::from_secs(60), - ); - - // Listen for visibility changes - if let Some(document) = web_sys::window().and_then(|w| w.document()) { - let last_reload_clone = last_reload.clone(); - let visibility_callback = Closure::wrap(Box::new(move || { - if let Some(doc) = web_sys::window().and_then(|w| w.document()) { - if !doc.hidden() { - let now = web_sys::js_sys::Date::now(); - let elapsed = now - last_reload_clone.get(); - - // If more than 60 seconds since last reload, reload now - if elapsed > 60_000.0 { - last_reload_clone.set(now); - set_trigger.update(|v| *v += 1); - } - } - } - }) as Box<dyn FnMut()>); - - let _ = document.add_event_listener_with_callback( - "visibilitychange", - visibility_callback.as_ref().unchecked_ref(), - ); - - // Keep the closure alive - visibility_callback.forget(); - - // Listen for custom reload event (can be triggered from console) - // Usage: document.dispatchEvent(new Event('tamanu-reload-status')) - let reload_event_callback = Closure::wrap(Box::new(move || { - last_reload.set(web_sys::js_sys::Date::now()); - set_trigger.update(|v| *v += 1); - }) as Box<dyn FnMut()>); - - let _ = document.add_event_listener_with_callback( - "tamanu-reload-status", - reload_event_callback.as_ref().unchecked_ref(), - ); - - // Keep the closure alive - reload_event_callback.forget(); - } - }); - - view! { - <article> - <Transition fallback=|| view! { <LoadingBar /> }> - {move || { - grouped_ids_resource.get().map(|res| match res { - Ok(groups) => { - view! { - {groups.into_iter().map(|(rank, ids)| { - view! { <RankSection rank server_ids={ids.clone()} trigger /> }.into_any() - }).collect::<Vec<_>>()} - }.into_any() - } - Err(err) => { - view! { {err} }.into_any() - } - }) - }} - </Transition> - </article> - } -} - -#[component] -pub fn RankSection( - rank: ServerRank, - server_ids: Vec<Uuid>, - trigger: ReadSignal<i32>, -) -> impl IntoView { - if server_ids.is_empty() { - return view! { <div></div> }.into_any(); - } - - view! { - <section> - <h2>{rank}</h2> - <div class="grid is-col-min-12 is-gap-2"> - <For - each=move || server_ids.clone() - key=|id| *id - let:server_id - > - <ServerCardLoader server_id trigger {..} class="server-card cell box" /> - </For> - </div> - </section> - } - .into_any() -} - -#[component] -pub fn ServerCardLoader(server_id: Uuid, trigger: ReadSignal<i32>) -> impl IntoView { - let server_resource = - LocalResource::new(move || async move { server_details(server_id).await }); - - Effect::new(move || { - trigger.get(); - server_resource.refetch(); - }); - - view! { - <a href={format!("/servers/{server_id}")}> - <Transition fallback=move || view! { <div class="has-text-grey">"Thinking…"</div> }> - { - move || server_resource.get().map(|res| match res { - Ok(server) => { - view! { <ServerCard server /> }.into_any() - } - Err(err) => { - view! { <div>{err}</div> }.into_any() - } - }) - } - </Transition> - </a> - } -} - -#[component] -pub fn ServerCard(server: commons_types::server::cards::CentralServerCard) -> impl IntoView { - view! { - <a - href={server.host.clone()} - class="host-link" - target="_blank" - on:click=|e| e.stop_propagation() - title={server.host.clone()} - > - "🌐" - </a> - <h3 class="server-name">{server.name.clone()}</h3> - <div class="version-container"> - {server.version.as_ref().map(|v| { - view! { - <VersionIndicator version={v.clone()} distance={server.version_distance} add_link=false /> - } - })} - </div> - <div class="status-dots"> - <span - class:status-dot class={server.up} - title={format!("{}: {}", server.name.clone(), server.up)} - ></span> - <For - each={ - let facility_servers = server.facility_servers.clone(); - move || facility_servers.clone() - } - key=|facility| facility.id - let:facility - > - <span - class:status-dot class:facility-dot class={facility.up} - title={format!("{}: {}", facility.name, facility.up)} - ></span> - </For> - </div> - } -} diff --git a/crates/private-server/src/app/versions.rs b/crates/private-server/src/app/versions.rs deleted file mode 100644 index 2ce178df..00000000 --- a/crates/private-server/src/app/versions.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod detail; -mod list; - -pub use detail::Detail; -pub use list::Page; diff --git a/crates/private-server/src/app/versions/detail.rs b/crates/private-server/src/app/versions/detail.rs deleted file mode 100644 index f437ffdc..00000000 --- a/crates/private-server/src/app/versions/detail.rs +++ /dev/null @@ -1,615 +0,0 @@ -use commons_errors::AppError; -use leptos::prelude::*; -use leptos_meta::Stylesheet; -use leptos_router::hooks::use_params_map; -use uuid::Uuid; - -use crate::{ - components::{ErrorHandler, LoadingBar, TimeAgo, ToggleSignal as _}, - fns::versions::{ - ArtifactData, RelatedVersionData, VersionDetail, create_artifact, delete_artifact, - get_artifacts_by_version_id, get_version_detail, update_artifact, update_version_changelog, - update_version_status, - }, -}; -use commons_types::version::VersionStatus; - -#[component] -pub fn Detail() -> impl IntoView { - view! { - <Stylesheet id="css-versions" href="/static/versions.css" /> - <VersionDetailView /> - } -} - -#[component] -fn VersionDetailView() -> impl IntoView { - let params = use_params_map(); - let version = move || params.read().get("version").unwrap_or_default(); - - let is_admin = Resource::new( - || (), - |_| async { crate::fns::commons::is_current_user_admin().await }, - ); - - let version_detail = Resource::new(version, |v| async move { get_version_detail(v).await }); - - view! { - <Transition fallback=|| view! { <LoadingBar /> }> - <ErrorHandler> - {move || version_detail.and_then(|detail| { - let is_admin = is_admin.get().and_then(|r| r.ok()).unwrap_or(false); - view! { - <header class="level mt-4"> - <div class="level-left"> - <h1 class="level-item is-size-3">{detail.major} "." {detail.minor} "." {detail.patch}</h1> - </div> - <div class="level-right"> - <StatusSelection detail=detail.clone() is_admin {..} class:level-item /> - </div> - </header> - <VersionInfo detail=detail.clone() /> - <ArtifactsSection version_id=detail.id is_admin /> - <ChangelogSection detail=detail.clone() is_admin /> - {(!detail.related_versions.is_empty()).then(|| { - view! { <RelatedVersionsSection related_versions=detail.related_versions.clone() /> } - })} - } - })} - </ErrorHandler> - </Transition> - } -} - -#[component] -fn VersionInfo(detail: VersionDetail) -> impl IntoView { - view! { - <section class="box"> - <div class="info-grid"> - <div class="info-item"> - <span class="info-label">"Created"</span> - <span class="info-value">{detail.created_at.strftime("%Y-%m-%d").to_string()}</span> - </div> - <div class="info-item"> - <span class="info-label">"Last updated"</span> - <TimeAgo timestamp={detail.updated_at} {..} class:info-value /> - </div> - {detail.min_chrome_version.map(|chrome_ver| { - view! { - <div class="info-item"> - <span class="info-label">"Chrome support"</span> - <span class="info-value"> - {format!("{} or later", chrome_ver)} - </span> - </div> - } - })} - </div> - </section> - } -} - -#[component] -fn StatusSelection(detail: VersionDetail, is_admin: bool) -> impl IntoView { - let (selected_status, set_selected_status) = signal(detail.status); - let (is_changing, set_is_changing) = signal(false); - let version_str = format!("{}.{}.{}", detail.major, detail.minor, detail.patch); - let can_switch_to_draft = - detail.status != VersionStatus::Published || detail.is_latest_in_minor; - - let update_status = Action::new(move |new_status: &VersionStatus| { - let version = version_str.clone(); - let status = new_status.to_string(); - async move { update_version_status(version, status).await } - }); - - let on_change = move |_| { - set_is_changing.set(true); - }; - - let on_submit = move |ev: web_sys::SubmitEvent| { - ev.prevent_default(); - update_status.dispatch(selected_status.get()); - set_is_changing.set(false); - }; - - Effect::new(move || { - if let Some(Ok(())) = update_status.value().get() { - window().location().reload().expect("Failed to reload page"); - } - }); - - view! { - <form class="field" class:has-addons=is_admin on:submit=on_submit> - <div class="control"> - <div class="select"> - <select - disabled={!is_admin} - prop:value=move || selected_status.get() - on:change=move |ev| { - let value = event_target_value(&ev); - let status = VersionStatus::from(value); - set_selected_status.set(status); - on_change(ev); - } - > - <option - value="draft" - selected=move || selected_status.get() == VersionStatus::Draft - disabled=!can_switch_to_draft - > - "Draft" - </option> - <option - value="published" - selected=move || selected_status.get() == VersionStatus::Published - > - "Published" - </option> - <option value="yanked" selected=move || selected_status.get() == VersionStatus::Yanked> - "Yanked" - </option> - </select> - </div> - </div> - {is_admin.then(|| view! { - <div class="control"> - <button - type="submit" - class="button is-primary" - disabled=move || { !is_changing.get() || update_status.pending().get() } - > - {move || { - if update_status.pending().get() { - "Changing..." - } else { - "Change" - } - }} - </button> - </div> - })} - </form> - {move || { - update_status - .value() - .get() - .and_then(|result| { - result - .err() - .map(|e| { - view! { <div class="error-message">{format!("Error: {}", e)}</div> } - }) - }) - }} - } -} - -#[component] -fn ArtifactsSection(version_id: Uuid, is_admin: bool) -> impl IntoView { - let (is_unlocked, set_is_unlocked) = signal(false); - let (show_create, set_show_create) = signal(false); - - let resource = Resource::new( - move || version_id, - |id| async move { get_artifacts_by_version_id(id).await }, - ); - let refresh = move || resource.refetch(); - - view! { - <header class="level"> - <div class="level-left"> - <h2 class="level-item is-size-4">"Artifacts"</h2> - </div> - {is_admin.then(|| { - view! { - <div class="level-right"> - {move || is_unlocked.get().then(|| { - view! { - <button - class="level-item button" - on:click=move |_| set_show_create.toggle() - class:is-warning=move || show_create.get() - class:is-primary=move || {!show_create.get()} - > - {move || if show_create.get() { "Cancel create" } else { "Create" }} - </button> - } - })} - <button class="level-item button" on:click=move |_| set_is_unlocked.toggle()> - {move || if is_unlocked.get() { "Lock" } else { "Unlock" }} - </button> - </div> - } - })} - </header> - {move || show_create.get().then(|| { - view! { <CreateArtifactForm version_id refresh=move || { - refresh(); - set_show_create.set(false); - } /> } - })} - <ArtifactsList resource is_unlocked /> - } -} - -#[component] -fn ArtifactsList( - resource: Resource<Result<Vec<ArtifactData>, AppError>>, - is_unlocked: ReadSignal<bool>, -) -> impl IntoView { - view! { - <Transition fallback=|| view! { <LoadingBar /> }> - {move || resource.and_then(|artifacts| { - if artifacts.is_empty() { - view! { - <div class="no-artifacts">"No artifacts found for this version"</div> - }.into_any() - } else { - let artifacts = artifacts.clone(); - view! { - <For each=move || artifacts.clone() key=|a| a.id let:artifact> - <ArtifactItem artifact is_unlocked refresh={move || resource.refetch()} /> - </For> - }.into_any() - } - }) - }</Transition> - } -} - -#[component] -fn CreateArtifactForm( - version_id: Uuid, - refresh: impl Fn() + Send + Sync + Copy + 'static, -) -> impl IntoView { - let (artifact_type, set_artifact_type) = signal(String::new()); - let (platform, set_platform) = signal(String::new()); - let (download_url, set_download_url) = signal(String::new()); - - let create_action = Action::new(move |_: &()| async move { - let _ = create_artifact( - version_id, - artifact_type.get(), - platform.get(), - download_url.get(), - ) - .await; - refresh(); - }); - - view! { - <form class="field is-grouped" on:submit=move |ev| { - ev.prevent_default(); - create_action.dispatch(()); - }> - <p class="control"> - <input - class="input" - type="text" - required - placeholder="Type" - disabled=move || create_action.pending().get() - prop:value=move || artifact_type.get() - on:input=move |ev| set_artifact_type.set(event_target_value(&ev)) /> - </p> - <p class="control"> - <input - class="input" - type="text" - required - placeholder="Platform" - disabled=move || create_action.pending().get() - prop:value=move || platform.get() - on:input=move |ev| set_platform.set(event_target_value(&ev)) /> - </p> - <p class="control is-expanded"> - <input - class="input" - type="text" - required - placeholder="Download URL" - disabled=move || create_action.pending().get() - prop:value=move || download_url.get() - on:input=move |ev| set_download_url.set(event_target_value(&ev)) /> - </p> - <p class="control"> - <button - type="submit" - class="button is-primary" - disabled=move || create_action.pending().get() - class:is-loading=move || create_action.pending().get() - >"Create"</button> - </p> - </form> - } -} - -#[component] -fn ArtifactItem( - artifact: ArtifactData, - is_unlocked: ReadSignal<bool>, - refresh: impl Fn() + Send + Sync + Copy + 'static, -) -> impl IntoView { - let (is_editing, set_is_editing) = signal(false); - - let delete_action = Action::new(move |id: &Uuid| { - let id = *id; - async move { - let _ = delete_artifact(id).await; - refresh(); - } - }); - - view! { - {move || if is_editing.get() { - let artifact = artifact.clone(); - view! { <ArtifactItemEdit artifact set_is_editing /> }.into_any() - } else { - let artifact = artifact.clone(); - view! { <ArtifactItemView artifact is_unlocked delete_action set_is_editing /> }.into_any() - }} - } -} - -#[component] -fn ArtifactItemView( - artifact: ArtifactData, - is_unlocked: ReadSignal<bool>, - delete_action: Action<Uuid, ()>, - set_is_editing: WriteSignal<bool>, -) -> impl IntoView { - let (show_delete_confirm, set_show_delete_confirm) = signal(false); - - view! { - <div class="box mb-3"> - <div class="columns"> - <div class="column"> - {artifact.artifact_type.clone()} - {move || artifact.has_range_override.then(|| view! { - <p class="is-size-7 has-text-warning">"Overrides other artifact"</p> - })} - {move || artifact.version_range_pattern.clone().map(|pattern| view! { - <p class="is-size-7 has-text-grey-light"> - {move || (!artifact.is_used_in_public_api).then(|| view! { - <span class="has-text-danger">"[Hidden]"</span> - })} - " Applies to: " {pattern} - </p> - })} - </div> - <div class="column">{artifact.platform.clone()}</div> - <a - class="column is-half" - href={artifact.download_url.starts_with("https://").then(|| artifact.download_url.clone())} - class:has-text-primary-dark={!artifact.download_url.starts_with("https://")} - >{artifact.download_url.clone()}</a> - <div class="column"> - <div class="field is-grouped buttons are-small is-justify-content-end" class:is-invisible={move || !is_unlocked.get()}> - {move || if show_delete_confirm.get() { - view! { - <p class="control"> - <button - class="button is-danger" - on:click=move |_| drop(delete_action.dispatch(artifact.id)) - disabled=move || delete_action.pending().get() - class:is-loading=move || delete_action.pending().get() - >"Really delete"</button> - </p> - <p class="control"> - <button - class="button is-light" - on:click=move |_| set_show_delete_confirm.set(false) - disabled=move || delete_action.pending().get() - >"Cancel"</button> - </p> - }.into_any() - } else { - view! { - <p class="control"> - <button - class="button is-info" - on:click=move |_| set_is_editing.set(true) - >"Edit"</button> - </p> - <p class="control"> - <button - class="button is-danger" - on:click=move |_| set_show_delete_confirm.set(true) - >"Delete"</button> - </p> - }.into_any() - }} - </div> - </div> - </div> - </div> - } -} - -#[component] -fn ArtifactItemEdit(artifact: ArtifactData, set_is_editing: WriteSignal<bool>) -> impl IntoView { - let (artifact_type, set_artifact_type) = signal(artifact.artifact_type.clone()); - let (platform, set_platform) = signal(artifact.platform.clone()); - let (download_url, set_download_url) = signal(artifact.download_url.clone()); - - let update_action = Action::new(move |_: &()| async move { - let _ = update_artifact( - artifact.id, - artifact_type.get(), - platform.get(), - download_url.get(), - ) - .await; - set_is_editing.set(false); - }); - - view! { - <div class="box"> - <form class="field is-grouped" on:submit=move |ev| { - ev.prevent_default(); - update_action.dispatch(()); - }> - <p class="control"> - <input - class="input" - type="text" - required - placeholder="Type" - disabled=move || update_action.pending().get() - prop:value=move || artifact_type.get() - on:input=move |ev| set_artifact_type.set(event_target_value(&ev)) /> - </p> - <p class="control"> - <input - class="input" - type="text" - required - placeholder="Platform" - disabled=move || update_action.pending().get() - prop:value=move || platform.get() - on:input=move |ev| set_platform.set(event_target_value(&ev)) /> - </p> - <p class="control is-expanded"> - <input - class="input" - type="text" - required - placeholder="Download URL" - disabled=move || update_action.pending().get() - prop:value=move || download_url.get() - on:input=move |ev| set_download_url.set(event_target_value(&ev)) /> - </p> - <p class="control"> - <button - type="submit" - class="button is-primary" - class:is-loading=move || update_action.pending().get() - >"Save"</button> - </p> - <p class="control"> - <button - class="button is-danger is-light" - disabled=move || update_action.pending().get() - on:click=move |_| set_is_editing.set(false) - >"Cancel"</button> - </p> - </form> - </div> - } -} - -#[component] -fn ChangelogSection(detail: VersionDetail, is_admin: bool) -> impl IntoView { - let (is_editing, set_is_editing) = signal(false); - let (changelog_text, set_changelog_text) = signal(detail.changelog.clone()); - let version_str = format!("{}.{}.{}", detail.major, detail.minor, detail.patch); - let original_changelog = StoredValue::new(detail.changelog.clone()); - - let update_changelog = Action::new(move |new_changelog: &String| { - let version = version_str.clone(); - let changelog = new_changelog.clone(); - async move { update_version_changelog(version, changelog).await } - }); - - Effect::new(move || { - if let Some(Ok(())) = update_changelog.value().get() { - window().location().reload().expect("Failed to reload page"); - } - }); - - view! { - <header class="level mt-4"> - <div class="level-left"> - <h2 class="level-item is-size-4">"Changelog"</h2> - </div> - {is_admin.then(|| { - view! { - <div class="level-right"> - {move || if is_editing.get() { - view! { - <button - class="level-item button is-success mr-2" - on:click=move |_| { - update_changelog.dispatch(changelog_text.get()); - set_is_editing.set(false); - } - >"Save"</button> - <button - class="level-item button is-danger is-light" - on:click=move |_| { - set_changelog_text.set(original_changelog.get_value()); - set_is_editing.set(false); - } - >"Cancel"</button> - }.into_any() - } else { - view! { - <button - class="level-item button is-info" - on:click=move |_| { - set_is_editing.set(true); - } - >"Edit"</button> - }.into_any() - }} - </div> - } - })} - </header> - <section class="box"> - {move || { - if is_editing.get() { - view! { - <textarea - class="textarea monospace" - rows="20" - prop:value=move || changelog_text.get() - on:input=move |ev| set_changelog_text.set(event_target_value(&ev)) - ></textarea> - }.into_any() - } else { - view! { - <div class="content" inner_html=parse_markdown(&detail.changelog)></div> - }.into_any() - } - }} - </section> - } -} - -#[component] -fn RelatedVersionsSection(related_versions: Vec<RelatedVersionData>) -> impl IntoView { - view! { - {related_versions - .into_iter() - .map(|related| { - view! { - <h4 class="is-size-5"> - {related.major} - "." - {related.minor} - "." - {related.patch} - </h4> - <div class="box content" inner_html=parse_markdown(&related.changelog) /> - } - }) - .collect_view()} - } -} - -fn parse_markdown(text: &str) -> String { - use pulldown_cmark::{Options, Parser, html}; - - let mut options = Options::empty(); - options.insert(Options::ENABLE_FOOTNOTES); - options.insert(Options::ENABLE_GFM); - options.insert(Options::ENABLE_SMART_PUNCTUATION); - options.insert(Options::ENABLE_STRIKETHROUGH); - options.insert(Options::ENABLE_TABLES); - let parser = Parser::new_ext(text, options); - let mut html_output = String::new(); - html::push_html(&mut html_output, parser); - html_output -} diff --git a/crates/private-server/src/app/versions/list.rs b/crates/private-server/src/app/versions/list.rs deleted file mode 100644 index 55592798..00000000 --- a/crates/private-server/src/app/versions/list.rs +++ /dev/null @@ -1,90 +0,0 @@ -use commons_types::version::VersionStatus; -use leptos::prelude::*; -use leptos_meta::Stylesheet; -use leptos_router::components::A; - -use crate::{ - components::{ErrorHandler, LoadingBar, Nothing, VersionStatusBadge}, - fns::versions::{MinorVersionGroup, get_grouped_versions}, -}; - -#[component] -pub fn Page() -> impl IntoView { - let grouped_versions = Resource::new(|| (), |_| async { get_grouped_versions().await }); - - view! { - <Stylesheet id="css-versions" href="/static/versions.css" /> - <h1 class="is-size-3 my-4">"Versions"</h1> - <Transition fallback=|| view! { <LoadingBar /> }> - <ErrorHandler> - {move || grouped_versions.and_then(|groups| { - let groups = groups.clone(); - if groups.is_empty() { - view! { <Nothing thing="versions" /> }.into_any() - } else { - view! { - <For each=move || groups.clone() key=|g| (g.major, g.minor) let:group> - <MinorVersionGroupComponent group={group} /> - </For> - }.into_any() - } - })} - </ErrorHandler> - </Transition> - } -} - -#[component] -pub fn MinorVersionGroupComponent(group: MinorVersionGroup) -> impl IntoView { - let major = group.major; - let minor = group.minor; - let count = group.count; - let latest_patch = group.latest_patch; - let first_created_at = group.first_created_at; - let versions = group.versions.clone(); - - view! { - <details class="box minor-version-group monospace"> - <summary class="level"> - <div class="level-left"> - <div class="group-version"> - {major} "." {minor} - <span class="version-patch">"." {latest_patch}</span> - </div> - </div> - <div class="level-right"> - <div class="group-details"> - <p>{format!("{} version{}", count, if count == 1 { "" } else { "s" })}</p> - <p>{first_created_at.strftime("%Y-%m-%d").to_string()}</p> - </div> - </div> - </summary> - <div class="minor-versions"> - <For each=move || versions.clone() key=|v| (v.major, v.minor, v.patch) let:v> - <A - href={format!("/versions/{}.{}.{}", v.major, v.minor, v.patch)} - {..} - class="level box minor-version" - class:has-background-warning-light={v.status == VersionStatus::Draft} - class:has-background-danger-light={v.status == VersionStatus::Yanked} - > - <div class="level-left"> - <div class="level-item grouped-version"> - {v.major} "." {v.minor} - <span class="version-patch">"." {v.patch}</span> - </div> - {(v.status != VersionStatus::Published).then(|| { - view! { - <VersionStatusBadge status={v.status} /> - } - })} - </div> - <div class="level-right"> - <div class="level-item">{v.created_at.strftime("%Y-%m-%d").to_string()}</div> - </div> - </A> - </For> - </div> - </details> - } -} diff --git a/crates/private-server/src/components.rs b/crates/private-server/src/components.rs deleted file mode 100644 index e6205266..00000000 --- a/crates/private-server/src/components.rs +++ /dev/null @@ -1,19 +0,0 @@ -mod legend; -mod paginated_list; -mod release_summary; -mod shorties; -mod smalls; -mod sub_tabs; -mod time_ago; -mod toast; -mod version_indicator; - -pub use legend::{StatusLegend, VersionLegend}; -pub use paginated_list::PaginatedList; -pub use release_summary::ReleaseSummary; -pub use shorties::*; -pub use smalls::*; -pub use sub_tabs::{EndTabs, SubTabs}; -pub use time_ago::TimeAgo; -pub use toast::*; -pub use version_indicator::VersionIndicator; diff --git a/crates/private-server/src/components/legend.rs b/crates/private-server/src/components/legend.rs deleted file mode 100644 index 9ada9a6e..00000000 --- a/crates/private-server/src/components/legend.rs +++ /dev/null @@ -1,68 +0,0 @@ -use commons_types::status::ShortStatus; -use leptos::prelude::*; - -use crate::components::{StatusDot, version_indicator::VersionSquare}; - -#[component] -pub fn VersionLegend() -> impl IntoView { - view! { - <p> - <span class="legend-item"> - <VersionSquare distance=Some(1) /> - <span class="legend-label">"Up to date"</span> - </span> - " " - <span class="legend-item"> - <VersionSquare distance=Some(3) /> - <span class="legend-label">"2-4 versions behind"</span> - </span> - " " - <span class="legend-item"> - <VersionSquare distance=Some(7) /> - <span class="legend-label">"5-9 versions behind"</span> - </span> - " " - <span class="legend-item"> - <VersionSquare distance=Some(11) /> - <span class="legend-label">"10+ versions behind"</span> - </span> - " " - <span class="legend-item"> - <VersionSquare distance=None /> - <span class="legend-label">"Version not known"</span> - </span> - </p> - } -} - -#[component] -pub fn StatusLegend() -> impl IntoView { - view! { - <p> - <span class="legend-item"> - <StatusDot up=ShortStatus::Up /> - <span class="legend-label">"Up (seen a minute ago)"</span> - </span> - " " - <span class="legend-item"> - <StatusDot up=ShortStatus::Blip /> - <span class="legend-label">"Blip (missed 2 checks)"</span> - </span> - " " - <span class="legend-item"> - <StatusDot up=ShortStatus::Away /> - <span class="legend-label">"Away (last seen 2-10m ago)"</span> - </span> - " " - <span class="legend-item"> - <StatusDot up=ShortStatus::Down /> - <span class="legend-label">"Down (last seen 10m-7d ago)"</span> - </span> - " " - <span class="legend-item"> - <StatusDot up=ShortStatus::Gone /> - <span class="legend-label">"Gone (never or more than 7d ago)"</span> - </span> - </p> - } -} diff --git a/crates/private-server/src/components/paginated_list.rs b/crates/private-server/src/components/paginated_list.rs deleted file mode 100644 index 4eea9d20..00000000 --- a/crates/private-server/src/components/paginated_list.rs +++ /dev/null @@ -1,174 +0,0 @@ -use leptos::prelude::*; - -#[component] -pub fn PaginatedList( - /// Current page (0-indexed) - page: ReadSignal<u64>, - - /// Function to update page - set_page: WriteSignal<u64>, - - /// Total count of items - total_count: Signal<u64>, - - /// Number of items per page - #[prop(default = 10)] - page_size: u64, - - /// Content to render - children: Children, -) -> impl IntoView { - let last_page = Signal::derive({ - let count = total_count; - move || count.get().saturating_div(page_size) - }); - - view! { - <nav class="pagination" role="navigation" aria-label="pagination"> - <button - class="pagination-previous" - class:is-disabled=move || page.get() == 0 - disabled=move || page.get() == 0 - on:click=move |_| set_page.update(|p| *p = (*p).saturating_sub(1)) - >"Previous"</button> - <button - class="pagination-next" - class:is-disabled=move || page.get() == last_page.get() - disabled=move || page.get() == last_page.get() - on:click=move |_| set_page.update(|p| *p = (*p).saturating_add(1)) - >"Next page"</button> - <ul class="pagination-list">{move || { - if last_page.get() < 7 { - view! { <ShowAll page set_page last_page /> }.into_any() - } - else if page.get() < 2 { - view! { <AtStart page set_page last_page /> }.into_any() - } - else if page.get() > last_page.get().saturating_sub(2) { - view! { <AtEnd page set_page last_page /> }.into_any() - } - else { - view! { <InMiddle page set_page last_page /> }.into_any() - } - }}</ul> - </nav> - {children()} - } -} - -// ShowAll = 1234567 = last_page < 7 -// AtStart = 123...8 = page < 2 -// AtEnd = 1...678 = page > last_page - 2 -// InMiddle = 1.456.9 = else - -#[component] -fn ShowAll( - page: ReadSignal<u64>, - set_page: WriteSignal<u64>, - last_page: Signal<u64>, -) -> impl IntoView { - view! { - <For each=move || 0..=last_page.get() key=|n| *n let:n> - <li><button - class="pagination-link" - class:is-current={move || n == page.get()} - attr:aria-current:page={move || n == page.get()} - aria-label={move || format!("Goto page {}", n + 1)} - on:click=move |_| set_page.update(|p| *p = n) - >{n + 1}</button></li> - </For> - } -} - -#[component] -fn AtStart( - page: ReadSignal<u64>, - set_page: WriteSignal<u64>, - last_page: Signal<u64>, -) -> impl IntoView { - view! { - <For each=move || 0..=2 key=|n| *n let:n> - <li><button - class="pagination-link" - class:is-current={move || n == page.get()} - attr:aria-current:page={move || n == page.get()} - aria-label={move || format!("Goto page {}", n + 1)} - on:click=move |_| set_page.update(|p| *p = n) - >{n + 1}</button></li> - </For> - <li><span class="pagination-ellipsis">"…"</span></li> - <li><button - class="pagination-link" - class:is-current={move || last_page.get() == page.get()} - attr:aria-current:page={move || last_page.get() == page.get()} - aria-label={move || format!("Goto page {}", last_page.get() + 1)} - on:click=move |_| set_page.update(|p| *p = last_page.get()) - >{move || last_page.get() + 1}</button></li> - } -} - -#[component] -fn AtEnd( - page: ReadSignal<u64>, - set_page: WriteSignal<u64>, - last_page: Signal<u64>, -) -> impl IntoView { - view! { - <li><button - class="pagination-link" - class:is-current={move || page.get() == 0} - attr:aria-current:page={move || page.get() == 0} - aria-label="Goto page 1" - on:click=move |_| set_page.update(|p| *p = 0) - >"1"</button></li> - <li><span class="pagination-ellipsis">"…"</span></li> - <For each=move || (last_page.get() - 2)..=last_page.get() key=|n| *n let:n> - <li><button - class="pagination-link" - class:is-current={move || n == page.get()} - attr:aria-current:page={move || n == page.get()} - aria-label={move || format!("Goto page {}", n + 1)} - on:click=move |_| set_page.update(|p| *p = n) - >{n + 1}</button></li> - </For> - } -} - -#[component] -fn InMiddle( - page: ReadSignal<u64>, - set_page: WriteSignal<u64>, - last_page: Signal<u64>, -) -> impl IntoView { - view! { - <li><button - class="pagination-link" - class:is-current={move || page.get() == 0} - attr:aria-current:page={move || page.get() == 0} - aria-label="Goto page 1" - on:click=move |_| set_page.update(|p| *p = 0) - >"1"</button></li> - {move || (page.get() != 2).then(|| view! { - <li><span class="pagination-ellipsis">"…"</span></li> - })} - <For each=move || (page.get() - 1)..=(page.get() + 1) key=|n| *n let:n> - <li><button - class="pagination-link" - class:is-current={move || n == page.get()} - attr:aria-current:page={move || n == page.get()} - aria-label={move || format!("Goto page {}", n + 1)} - on:click=move |_| set_page.update(|p| *p = n) - >{n + 1}</button></li> - </For> - {move || (page.get() != (last_page.get() - 2)).then(|| view! { - <li><span class="pagination-ellipsis">"…"</span></li> - })} - <li><button - class="pagination-link" - class:is-current={move || last_page.get() == page.get()} - attr:aria-current:page={move || last_page.get() == page.get()} - aria-label={move || format!("Goto page {}", last_page.get() + 1)} - on:click=move |_| set_page.update(|p| *p = last_page.get()) - >{move || last_page.get() + 1}</button></li> - } -} diff --git a/crates/private-server/src/components/release_summary.rs b/crates/private-server/src/components/release_summary.rs deleted file mode 100644 index a575850b..00000000 --- a/crates/private-server/src/components/release_summary.rs +++ /dev/null @@ -1,35 +0,0 @@ -use itertools::intersperse_with; -use leptos::prelude::*; - -use crate::fns::statuses::summary; - -#[component] -pub fn ReleaseSummary() -> impl IntoView { - let (status_list_r, status_list_w) = signal(0); - Effect::new(move |_| status_list_w.set(1)); - let data = Resource::new(move || status_list_r.get(), async |_| summary().await); - - view! { - <aside class="box release-summary"> - <Suspense fallback=|| view! { <div class="loading">"Loading…"</div> }>{move || { - let data = data.get().and_then(|d| d.ok()); - view! { - <div> - {data.as_ref().map(|d| d.releases.len())} " release branches in active use: " - {data.as_ref().map(|d| intersperse_with( - d.releases.iter().map(|(maj, min)| view! { <b class="version">{*maj} "." {*min}</b> }.into_any()), - || view! { ", " }.into_any() - ).collect::<Vec<_>>())} - " (" - {data.as_ref().map(|d| d.versions.len())} - " versions: " - <span class="version">{data.as_ref().map(|d| d.bracket.min.to_string())}</span> - " — " - <span class="version">{data.as_ref().map(|d| d.bracket.max.to_string())}</span> - ")" - </div> - } - }}</Suspense> - </aside> - } -} diff --git a/crates/private-server/src/components/shorties.rs b/crates/private-server/src/components/shorties.rs deleted file mode 100644 index 55e5b4fc..00000000 --- a/crates/private-server/src/components/shorties.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::sync::Arc; - -use leptos::prelude::*; -use leptos_router::components::A; - -use crate::{ - components::{DeviceRoleBadge, ServerKindBadge, ServerRankBadge}, - fns::{devices::DeviceInfo, servers::ServerInfo}, -}; - -#[component] -pub fn DeviceShorty(device: Arc<DeviceInfo>) -> impl IntoView { - let id = device.device.id; - let role = device.device.role; - let name = device.name(); - - view! { - <div class="level"> - <div class="level-left"> - <A href={format!("/devices/{}", id)} {..} class="level-item"> - {name} - </A> - </div> - <div class="level-right"><DeviceRoleBadge role /></div> - </div> - } -} - -#[component] -pub fn ServerShorty(server: Arc<ServerInfo>) -> impl IntoView { - let id = server.id; - let rank = server.rank; - let kind = server.kind; - let host = server.host.clone(); - let name = server - .name - .clone() - .unwrap_or_else(|| "Unnamed server".to_string()); - - view! { - <div class="level"> - <div class="level-left"> - <A href={format!("/servers/{}", id)} {..} class="level-item"> - {name} - </A> - {rank.map(|rank| view! { <ServerRankBadge rank /> })} - <ServerKindBadge kind /> - </div> - <div class="level-right"> - <span class="level-item">{host.clone()}</span> - </div> - </div> - } -} diff --git a/crates/private-server/src/components/smalls.rs b/crates/private-server/src/components/smalls.rs deleted file mode 100644 index 9fae7bed..00000000 --- a/crates/private-server/src/components/smalls.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::fmt::Display; - -use commons_types::{ - device::DeviceRole, - server::{kind::ServerKind, rank::ServerRank}, - status::ShortStatus, - version::VersionStatus, -}; -use leptos::prelude::*; - -#[component] -pub fn LoadingBar() -> impl IntoView { - view! { - <progress class="progress is-small is-primary" max="100">"Loading…"</progress> - } -} - -#[component] -pub fn Error(error: impl Display) -> impl IntoView { - view! { - <div class="has-text-danger">{error.to_string()}</div> - } -} - -#[component] -pub fn ErrorHandler<Chil>(children: TypedChildren<Chil>) -> impl IntoView -where - Chil: IntoView + Send + 'static, -{ - view! { - <ErrorBoundary fallback=move |errors| { view! { - <Transition fallback=|| view! { <Error error="Unknown error" /> }> - {move || { - let errors = errors.get(); - view! { - <For each=move || errors.clone() key=|(id, _)| id.clone() let:((_, error))> - <Error error={error} {..} class:box /> - </For> - } - }} - </Transition> - } }> - {children.into_inner()()} - </ErrorBoundary> - } -} - -#[component] -pub fn Nothing(#[prop(optional)] thing: Option<impl Display>) -> impl IntoView { - view! { - <div class="box has-text-info">{thing.map(|t| format!("No {t} found")).unwrap_or("Nothing found".to_string())}</div> - } -} - -#[component] -pub fn DeviceRoleBadge(role: DeviceRole) -> impl IntoView { - view! { - <span class={format!("level-item tag is-capitalized {}", match role { - DeviceRole::Untrusted => "is-danger", - DeviceRole::Server => "is-primary", - DeviceRole::Releaser => "is-warning", - DeviceRole::Admin => "is-info", - })}>{role}</span> - } -} - -#[component] -pub fn ServerKindBadge(kind: ServerKind) -> impl IntoView { - view! { - <span class={format!("level-item tag is-capitalized {}", match kind { - ServerKind::Central => "is-link", - ServerKind::Facility => "is-info", - ServerKind::Meta => "" - })}>{kind}</span> - } -} - -#[component] -pub fn ServerRankBadge(rank: ServerRank) -> impl IntoView { - view! { - <span class={format!("level-item tag is-capitalized {}", match rank { - ServerRank::Production => "is-danger", - ServerRank::Clone => "is-warning", - ServerRank::Demo => "is-link", - ServerRank::Test => "is-info", - ServerRank::Dev => "is-success", - })}>{rank}</span> - } -} - -#[component] -pub fn StatusDot( - up: ShortStatus, - #[prop(optional)] name: Option<String>, - #[prop(optional)] kind: ServerKind, -) -> impl IntoView { - view! { - <span - class={format!("status-dot {}", up)} - class:facility-dot={kind != ServerKind::Central} - title={name.map(|name| format!("{}: {}", name, up))} - ></span> - } -} - -#[component] -pub fn VersionStatusBadge(status: VersionStatus) -> impl IntoView { - view! { - <span class={format!("level-item tag is-capitalized {}", match status { - VersionStatus::Draft => "is-warning", - VersionStatus::Published => "is-success", - VersionStatus::Yanked => "is-danger", - })}>{status}</span> - } -} - -pub trait ToggleSignal { - // Toggles the signal, and returns the value it is after toggling - fn toggle_and_return(&self) -> bool; - - fn toggle(&self) { - self.toggle_and_return(); - } -} - -impl ToggleSignal for WriteSignal<bool> { - fn toggle_and_return(&self) -> bool { - self.try_update(|it| { - *it = !*it; - *it - }) - .unwrap_or(false) - } -} diff --git a/crates/private-server/src/components/sub_tabs.rs b/crates/private-server/src/components/sub_tabs.rs deleted file mode 100644 index 39554391..00000000 --- a/crates/private-server/src/components/sub_tabs.rs +++ /dev/null @@ -1,71 +0,0 @@ -use leptos::{prelude::*, tachys::view::fragment::IntoFragment as _}; -use leptos_router::components::Outlet; - -use crate::components::ToggleSignal as _; - -#[derive(Default)] -#[slot] -pub struct EndTabs { - #[prop(optional)] - children: Option<Children>, -} - -#[component] -pub fn SubTabs( - /// The tab items that go at the end. - #[prop(optional)] - end_tabs: EndTabs, - - /// The tab items. This should be a set of <a> or <A> elements, one for each tab. - children: ChildrenFragment, -) -> impl IntoView { - let start_children = children() - .nodes - .into_iter() - .map(|child| child.attr("class", "navbar-item")) - .collect::<Vec<_>>(); - - let end_children = end_tabs.children.map(|children| { - children() - .into_fragment() - .nodes - .into_iter() - .map(|child| child.attr("class", "navbar-item")) - .collect::<Vec<_>>() - }); - - let (burgered, set_burgered) = signal(false); - - view! { - <div id="sub-tabs"> - <nav class="navbar is-fixed-bottom" role="navigation" aria-label="navigation"> - <div class="navbar-menu" class:is-active={move || burgered.get()}> - <div class="navbar-start is-active"> - {start_children} - </div> - {end_children.map(|children| view! { - <div class="navbar-end"> - {children} - </div> - })} - </div> - <div class="navbar-brand"> - <a - class="navbar-burger" - role="button" - aria-label="menu" - aria-expanded=move || burgered.get().to_string() - class:is-active=move || burgered.get() - on:click=move |_| set_burgered.toggle() - > - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - </a> - </div> - </nav> - <section class="mb-4"><Outlet /></section> - </div> - } -} diff --git a/crates/private-server/src/components/time_ago.rs b/crates/private-server/src/components/time_ago.rs deleted file mode 100644 index 0dd79435..00000000 --- a/crates/private-server/src/components/time_ago.rs +++ /dev/null @@ -1,79 +0,0 @@ -use leptos::prelude::*; - -#[component] -pub fn TimeAgo(timestamp: jiff::Timestamp) -> impl IntoView { - let (ago_text, set_ago_text) = signal(String::new()); - - #[cfg(not(feature = "ssr"))] - { - let parsed_timestamp_ms = { - if let Some(_window) = web_sys::window() { - // TODO: use Temporal when that lands, to avoid precision loss when using f64 - let js_date = - web_sys::js_sys::Date::new(&(timestamp.as_millisecond() as f64).into()); - let time_value = js_date.get_time(); - if !time_value.is_nan() { - Some(time_value) - } else { - None - } - } else { - None - } - }; - - let format_duration = move || -> String { - if let Some(timestamp_ms) = parsed_timestamp_ms { - let now_ms = web_sys::js_sys::Date::now(); - let diff_ms = now_ms - timestamp_ms; - format_secs((diff_ms / 1000.0).abs() as _) - } else { - "?".to_string() - } - }; - - // Update immediately - set_ago_text.set(format_duration()); - - // Set up interval to update every 10 seconds while page is visible - Effect::new(move |_| { - let _ = leptos::prelude::set_interval( - move || { - if let Some(document) = web_sys::window().and_then(|w| w.document()) { - if !document.hidden() { - set_ago_text.set(format_duration()); - } - } - }, - std::time::Duration::from_secs(10), - ); - }); - } - - #[cfg(feature = "ssr")] - { - let now = jiff::Timestamp::now(); - let diff = now.duration_since(timestamp); - let secs = diff.as_secs().unsigned_abs(); - set_ago_text.set(format_secs(secs)); - } - - view! { - <span class="time-ago" title={timestamp.to_string()}> - {move || ago_text.get()} " ago" - </span> - } -} - -fn format_secs(secs: u64) -> String { - if secs < 3600 { - let minutes = secs / 60; - format!("{}m", minutes) - } else if secs < 86400 { - let hours = secs / 3600; - format!("{}h", hours) - } else { - let days = secs / 86400; - format!("{}d", days) - } -} diff --git a/crates/private-server/src/components/toast.rs b/crates/private-server/src/components/toast.rs deleted file mode 100644 index d7c265f2..00000000 --- a/crates/private-server/src/components/toast.rs +++ /dev/null @@ -1,18 +0,0 @@ -use leptos::prelude::*; - -#[derive(Clone, Debug)] -pub struct ToastCtx(pub WriteSignal<Option<String>>); - -#[component] -pub fn Toast(children: Children) -> impl IntoView { - let (message, set_message) = signal(None::<String>); - - provide_context(ToastCtx(set_message)); - - view! { - <dialog open={move || message.get().is_some()}> - <p>{message}</p> - </dialog> - {children()} - } -} diff --git a/crates/private-server/src/components/version_indicator.rs b/crates/private-server/src/components/version_indicator.rs deleted file mode 100644 index d306a837..00000000 --- a/crates/private-server/src/components/version_indicator.rs +++ /dev/null @@ -1,52 +0,0 @@ -use commons_types::version::VersionStr; -use leptos::prelude::*; - -#[component] -pub fn VersionSquare(distance: Option<u64>) -> impl IntoView { - view! { - <span - class:version-indicator - class={match distance { - Some(d) if d < 2 => "version-up-to-date", - Some(d) if d >= 10 => "version-very-outdated", - Some(d) if d >= 5 => "version-outdated", - Some(_) => "version-okay", - None => "version-unknown", - }} - title=distance.map_or("Unknown version".into(), |d| format!("{d} versions behind latest")) - ></span> - } -} - -#[component] -pub fn VersionIndicator( - /// The version string to display (e.g., "2.10.0") - version: VersionStr, - /// Optional distance from the latest published version - #[prop(default = None)] - distance: Option<u64>, - /// Add a link to the version page - #[prop(default = true)] - add_link: bool, -) -> impl IntoView { - let version_str = version.to_string(); - let version_link = format!("/versions/{}", version_str); - - if add_link { - view! { - <a href={version_link} class="version-display version-link"> - <span class="version-text">{version_str}</span> - <VersionSquare distance /> - </a> - } - .into_any() - } else { - view! { - <span class="version-display"> - <span class="version-text">{version_str}</span> - <VersionSquare distance /> - </span> - } - .into_any() - } -} diff --git a/crates/private-server/src/fns.rs b/crates/private-server/src/fns.rs index cecffbb6..2fcdf709 100644 --- a/crates/private-server/src/fns.rs +++ b/crates/private-server/src/fns.rs @@ -1,3 +1,5 @@ +use serde::{Deserialize, Serialize}; + pub mod admins; pub mod bestool; pub mod commons; @@ -7,7 +9,27 @@ pub mod sql; pub mod statuses; pub mod versions; -#[cfg(feature = "ssr")] +/// Standard wrapper for paginated list responses. The total reflects the full +/// row count (not just the current page) so the frontend can render page +/// counts without a separate count fetch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Page<T> { + pub items: Vec<T>, + pub total: u64, +} + pub fn routes() -> axum::Router<crate::state::AppState> { - axum::Router::new() + use axum::Router; + Router::new().nest( + "/api", + Router::new() + .nest("/admins", admins::routes()) + .nest("/bestool", bestool::routes()) + .nest("/commons", commons::routes()) + .nest("/devices", devices::routes()) + .nest("/servers", servers::routes()) + .nest("/sql", sql::routes()) + .nest("/statuses", statuses::routes()) + .nest("/versions", versions::routes()), + ) } diff --git a/crates/private-server/src/fns/admins.rs b/crates/private-server/src/fns/admins.rs index 48757d82..8cb0bdff 100644 --- a/crates/private-server/src/fns/admins.rs +++ b/crates/private-server/src/fns/admins.rs @@ -1,30 +1,58 @@ +use axum::Json; +use axum::extract::State; +use axum::routing::{Router, post}; use commons_errors::Result; -use leptos::server; +use commons_servers::tailscale_auth::TailscaleAdmin; +use serde::Deserialize; -#[server] -pub async fn list() -> Result<Vec<String>> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; +use crate::state::AppState; - database::admins::Admin::list(&mut conn) - .await - .map(|admins| admins.into_iter().map(|admin| admin.email).collect()) +pub fn routes() -> Router<AppState> { + Router::new() + .route("/list", post(list)) + .route("/add", post(add)) + .route("/delete", post(delete)) } -#[server] -pub async fn add(email: String) -> Result<()> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; +pub async fn list( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, +) -> Result<Json<Vec<String>>> { + let mut conn = state.db.get().await?; + let admins = database::admins::Admin::list(&mut conn) + .await? + .into_iter() + .map(|a| a.email) + .collect(); + Ok(Json(admins)) +} + +#[derive(Deserialize)] +pub struct AddArgs { + pub email: String, +} - database::admins::Admin::add(&mut conn, &email).await?; - Ok(()) +pub async fn add( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<AddArgs>, +) -> Result<Json<()>> { + let mut conn = state.db.get().await?; + database::admins::Admin::add(&mut conn, &args.email).await?; + Ok(Json(())) } -#[server] -pub async fn delete(email: String) -> Result<()> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; +#[derive(Deserialize)] +pub struct DeleteArgs { + pub email: String, +} - database::admins::Admin::delete(&mut conn, &email).await?; - Ok(()) +pub async fn delete( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<DeleteArgs>, +) -> Result<Json<()>> { + let mut conn = state.db.get().await?; + database::admins::Admin::delete(&mut conn, &args.email).await?; + Ok(Json(())) } diff --git a/crates/private-server/src/fns/bestool.rs b/crates/private-server/src/fns/bestool.rs index c92378bd..1d92a8d1 100644 --- a/crates/private-server/src/fns/bestool.rs +++ b/crates/private-server/src/fns/bestool.rs @@ -1,10 +1,14 @@ -use std::sync::Arc; - -use commons_errors::Result; -use leptos::server; +use axum::Json; +use axum::extract::State; +use axum::routing::{Router, post}; +use commons_errors::{AppError, Result}; +use commons_servers::tailscale_auth::TailscaleUser; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::fns::Page; +use crate::state::AppState; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BestoolSnippetInfo { pub id: Uuid, @@ -21,175 +25,115 @@ pub struct BestoolSnippetDetail { pub editor: String, } -#[server] -pub async fn count_snippets() -> Result<u64> { - ssr::count_snippets().await +pub fn routes() -> Router<AppState> { + Router::new() + .route("/list_snippets", post(list_snippets)) + .route("/save_snippet", post(save_snippet)) + .route("/get_snippet", post(get_snippet)) + .route("/get_latest_snippet_id", post(get_latest_snippet_id)) + .route("/delete_snippet", post(delete_snippet)) } -#[server] -pub async fn list_snippets( - offset: u64, - limit: Option<u64>, -) -> Result<Vec<Arc<BestoolSnippetInfo>>> { - ssr::list_snippets(offset, limit).await +#[derive(Deserialize)] +pub struct ListArgs { + pub offset: u64, + pub limit: Option<u64>, } -#[server] -pub async fn create_snippet(name: String, description: Option<String>, sql: String) -> Result<()> { - ssr::create_snippet(name, description, sql).await +pub async fn list_snippets( + State(state): State<AppState>, + Json(args): Json<ListArgs>, +) -> Result<Json<Page<BestoolSnippetInfo>>> { + let mut conn = state.db.get().await?; + let total = database::BestoolSnippet::count_current(&mut conn).await? as u64; + let snippets = database::BestoolSnippet::list_current( + &mut conn, + args.offset as i64, + args.limit.unwrap_or(50) as i64, + ) + .await?; + let items = snippets + .into_iter() + .map(|s| BestoolSnippetInfo { + id: s.id, + name: s.name, + description: s.description, + }) + .collect(); + Ok(Json(Page { items, total })) } -#[server] -pub async fn get_snippet(id: Uuid) -> Result<BestoolSnippetDetail> { - ssr::get_snippet(id).await +#[derive(Deserialize)] +pub struct SaveArgs { + /// When set, the saved snippet supersedes the snippet with this id (i.e. + /// it's an edit). When absent, a fresh snippet is created. + pub supersedes: Option<Uuid>, + pub name: String, + pub description: Option<String>, + pub sql: String, } -#[server] -pub async fn get_latest_snippet_id(id: Uuid) -> Result<Uuid> { - ssr::get_latest_snippet_id(id).await +pub async fn save_snippet( + State(state): State<AppState>, + user: std::result::Result<TailscaleUser, AppError>, + Json(args): Json<SaveArgs>, +) -> Result<Json<BestoolSnippetDetail>> { + let user = user.unwrap_or_default(); + let mut conn = state.db.get().await?; + let snippet = database::BestoolSnippet::create( + &mut conn, + user.login, + args.name, + args.description, + args.sql, + args.supersedes, + ) + .await?; + Ok(Json(BestoolSnippetDetail { + id: snippet.id, + name: snippet.name, + description: snippet.description, + sql: snippet.sql, + editor: snippet.editor, + })) } -#[server] -pub async fn update_snippet( - id: Uuid, - name: String, - description: Option<String>, - sql: String, -) -> Result<BestoolSnippetDetail> { - ssr::update_snippet(id, name, description, sql).await +#[derive(Deserialize)] +pub struct GetArgs { + pub id: Uuid, } -#[server] -pub async fn delete_snippet(id: Uuid) -> Result<()> { - ssr::delete_snippet(id).await +pub async fn get_snippet( + State(state): State<AppState>, + Json(args): Json<GetArgs>, +) -> Result<Json<BestoolSnippetDetail>> { + let mut conn = state.db.get().await?; + let snippet = database::BestoolSnippet::get_by_id(&mut conn, args.id) + .await? + .ok_or_else(|| AppError::custom("Snippet not found"))?; + Ok(Json(BestoolSnippetDetail { + id: snippet.id, + name: snippet.name, + description: snippet.description, + sql: snippet.sql, + editor: snippet.editor, + })) } -#[cfg(feature = "ssr")] -mod ssr { - use super::*; - use axum::extract::State; - use database::Db; - use leptos::prelude::expect_context; - use leptos_axum::extract_with_state; - - pub async fn count_snippets() -> Result<u64> { - let state = expect_context::<crate::state::AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - database::BestoolSnippet::count_current(&mut conn) - .await - .map(|c| c as u64) - } - - pub async fn list_snippets( - offset: u64, - limit: Option<u64>, - ) -> Result<Vec<Arc<BestoolSnippetInfo>>> { - let state = expect_context::<crate::state::AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - let snippets = database::BestoolSnippet::list_current( - &mut conn, - offset as i64, - limit.unwrap_or(50) as i64, - ) - .await?; - - Ok(snippets - .into_iter() - .map(|s| { - Arc::new(BestoolSnippetInfo { - id: s.id, - name: s.name, - description: s.description, - }) - }) - .collect()) - } - - pub async fn create_snippet( - name: String, - description: Option<String>, - sql: String, - ) -> Result<()> { - let state = expect_context::<crate::state::AppState>(); - let user: commons_servers::tailscale_auth::TailscaleUser = - extract_with_state(&state).await.unwrap_or_default(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - database::BestoolSnippet::create(&mut conn, user.login, name, description, sql, None) - .await?; - - Ok(()) - } - - pub async fn get_snippet(id: Uuid) -> Result<super::BestoolSnippetDetail> { - let state = expect_context::<crate::state::AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - let snippet = database::BestoolSnippet::get_by_id(&mut conn, id) - .await? - .ok_or_else(|| commons_errors::AppError::custom("Snippet not found"))?; - - Ok(super::BestoolSnippetDetail { - id: snippet.id, - name: snippet.name, - description: snippet.description, - sql: snippet.sql, - editor: snippet.editor, - }) - } - - pub async fn get_latest_snippet_id(id: Uuid) -> Result<Uuid> { - let state = expect_context::<crate::state::AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - database::BestoolSnippet::get_latest_id(&mut conn, id).await - } - - pub async fn update_snippet( - id: Uuid, - name: String, - description: Option<String>, - sql: String, - ) -> Result<super::BestoolSnippetDetail> { - let state = expect_context::<crate::state::AppState>(); - let user: commons_servers::tailscale_auth::TailscaleUser = - extract_with_state(&state).await.unwrap_or_default(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - // Create a new version that supersedes the current one - let new_snippet = database::BestoolSnippet::create( - &mut conn, - user.login, - name, - description, - sql, - Some(id), - ) - .await?; - - Ok(super::BestoolSnippetDetail { - id: new_snippet.id, - name: new_snippet.name, - description: new_snippet.description, - sql: new_snippet.sql, - editor: new_snippet.editor, - }) - } - - pub async fn delete_snippet(id: Uuid) -> Result<()> { - let state = expect_context::<crate::state::AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; +pub async fn get_latest_snippet_id( + State(state): State<AppState>, + Json(args): Json<GetArgs>, +) -> Result<Json<Uuid>> { + let mut conn = state.db.get().await?; + let id = database::BestoolSnippet::get_latest_id(&mut conn, args.id).await?; + Ok(Json(id)) +} - let _ = database::BestoolSnippet::delete(&mut conn, id).await?; - Ok(()) - } +pub async fn delete_snippet( + State(state): State<AppState>, + Json(args): Json<GetArgs>, +) -> Result<Json<()>> { + let mut conn = state.db.get().await?; + let _ = database::BestoolSnippet::delete(&mut conn, args.id).await?; + Ok(Json(())) } diff --git a/crates/private-server/src/fns/commons.rs b/crates/private-server/src/fns/commons.rs index d29f7a60..344cfa7c 100644 --- a/crates/private-server/src/fns/commons.rs +++ b/crates/private-server/src/fns/commons.rs @@ -1,48 +1,32 @@ -use commons_errors::Result; -use leptos::server; - -#[server] -pub async fn public_url() -> Result<Option<String>> { - use std::env; - - Ok(env::var("PUBLIC_URL").ok()) +use axum::Json; +use axum::routing::{Router, post}; +use commons_errors::{AppError, Result}; +use commons_servers::tailscale_auth::TailscaleAdmin; + +use crate::state::AppState; + +pub fn routes() -> Router<AppState> { + Router::new() + .route("/public_url", post(public_url)) + .route("/server_versions_url", post(server_versions_url)) + .route("/is_current_user_admin", post(is_current_user_admin)) } -#[server] -pub async fn server_versions_url() -> Result<Option<String>> { - use std::env; - - Ok((|| { - let public_url = env::var("PUBLIC_URL").ok()?; - let secret = env::var("SERVER_VERSIONS_SECRET").ok()?; - Some(format!("{public_url}/server-versions?s={secret}")) - })()) +pub async fn public_url() -> Result<Json<Option<String>>> { + Ok(Json(std::env::var("PUBLIC_URL").ok())) } -#[server] -pub async fn is_current_user_admin() -> Result<bool> { - use crate::state::AppState; - use commons_servers::tailscale_auth::TailscaleAdmin; - use leptos::prelude::expect_context; - use leptos_axum::extract_with_state; - - let state = expect_context::<AppState>(); - let TailscaleAdmin(_) = extract_with_state(&state).await?; - - Ok(true) +pub async fn server_versions_url() -> Result<Json<Option<String>>> { + let url = (|| { + let public_url = std::env::var("PUBLIC_URL").ok()?; + let secret = std::env::var("SERVER_VERSIONS_SECRET").ok()?; + Some(format!("{public_url}/server-versions?s={secret}")) + })(); + Ok(Json(url)) } -#[cfg(feature = "ssr")] -pub async fn admin_guard() -> Result<database::Db> { - use crate::state::AppState; - use axum::extract::State; - use commons_servers::tailscale_auth::TailscaleAdmin; - use database::Db; - use leptos::prelude::expect_context; - use leptos_axum::extract_with_state; - - let state = expect_context::<AppState>(); - let TailscaleAdmin(_) = extract_with_state(&state).await?; - let State(db): State<Db> = extract_with_state(&state).await?; - Ok(db) +pub async fn is_current_user_admin( + admin: std::result::Result<TailscaleAdmin, AppError>, +) -> Json<bool> { + Json(admin.is_ok()) } diff --git a/crates/private-server/src/fns/devices.rs b/crates/private-server/src/fns/devices.rs index 4d2ba7c5..c770f739 100644 --- a/crates/private-server/src/fns/devices.rs +++ b/crates/private-server/src/fns/devices.rs @@ -1,18 +1,25 @@ -use std::sync::Arc; +use std::collections::HashMap; -use commons_errors::Result; +use axum::Json; +use axum::extract::State; +use axum::routing::{Router, post}; +use commons_errors::{AppError, Result}; +use commons_servers::tailscale_auth::TailscaleAdmin; use commons_types::{Uuid, device::DeviceRole}; +use database::devices::{Device, DeviceConnection, DeviceKey, DeviceWithInfo}; +use database::servers::Server; use jiff::Timestamp; -use leptos::server; use serde::{Deserialize, Serialize}; +use crate::fns::Page; use crate::fns::servers::ServerInfo; +use crate::state::AppState; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceInfo { - pub device: Arc<DeviceData>, - pub keys: Vec<Arc<DeviceKeyInfo>>, - pub latest_connection: Option<Arc<DeviceConnectionData>>, + pub device: DeviceData, + pub keys: Vec<DeviceKeyInfo>, + pub latest_connection: Option<DeviceConnectionData>, } impl DeviceInfo { @@ -61,408 +68,314 @@ pub struct DeviceConnectionData { pub user_agent: Option<String>, } -#[server] -pub async fn get_device_by_id(device_id: Uuid) -> Result<DeviceInfo> { - ssr::get_device_by_id(device_id).await -} - -#[server] -pub async fn get_device_name_by_id(device_id: Uuid) -> Result<String> { - ssr::get_device_by_id(device_id).await.map(|d| d.name()) +impl From<DeviceWithInfo> for DeviceInfo { + fn from(d: DeviceWithInfo) -> Self { + Self { + device: DeviceData { + id: d.device.id, + created_at: d.device.created_at, + updated_at: d.device.updated_at, + role: d.device.role, + }, + keys: d.keys.into_iter().map(DeviceKeyInfo::from).collect(), + latest_connection: d.latest_connection.map(DeviceConnectionData::from), + } + } } -#[server] -pub async fn list_untrusted( - limit: Option<u64>, - offset: Option<u64>, -) -> Result<Vec<Arc<DeviceInfo>>> { - ssr::list_untrusted(limit, offset).await +impl From<DeviceKey> for DeviceKeyInfo { + fn from(key: DeviceKey) -> Self { + Self { + id: key.id, + device_id: key.device_id, + name: key.name, + pem_data: format_key_as_pem(&key.key_data), + created_at: key.created_at, + } + } } -#[server] -pub async fn get_servers_for_device(device_id: Uuid) -> Result<Vec<ServerInfo>> { - ssr::get_servers_for_device(device_id).await +impl From<DeviceConnection> for DeviceConnectionData { + fn from(conn: DeviceConnection) -> Self { + Self { + id: conn.id, + created_at: conn.created_at, + device_id: conn.device_id, + ip: conn.ip.addr().to_string(), + user_agent: conn.user_agent, + } + } } -#[server] -pub async fn get_past_server_associations(device_id: Uuid) -> Result<Vec<ServerInfo>> { - ssr::get_past_server_associations(device_id).await +fn format_key_as_pem(key_data: &[u8]) -> String { + use base64::prelude::*; + let base64_data = BASE64_STANDARD.encode(key_data); + let mut pem = String::with_capacity(base64_data.len() + 100); + pem.push_str("-----BEGIN PUBLIC KEY-----\n"); + for chunk in base64_data.as_bytes().chunks(64) { + pem.push_str(&String::from_utf8_lossy(chunk)); + pem.push('\n'); + } + pem.push_str("-----END PUBLIC KEY-----"); + pem } -#[server] -pub async fn count_untrusted() -> Result<u64> { - ssr::count_untrusted().await +fn server_to_info(s: database::servers::Server) -> ServerInfo { + ServerInfo { + id: s.id, + name: s.name, + host: s.host.into(), + kind: s.kind, + rank: s.rank, + device_id: s.device_id, + parent_server_id: s.parent_server_id, + parent_server_name: None, + listed: s.listed, + cloud: s.cloud, + geolocation: s.geolocation, + } } -#[server] -pub async fn connection_history( - device_id: Uuid, - limit: Option<u64>, - offset: Option<u64>, -) -> Result<Vec<DeviceConnectionData>> { - ssr::connection_history(device_id, limit, offset).await +pub fn routes() -> Router<AppState> { + Router::new() + .route("/get_device_by_id", post(get_device_by_id)) + .route("/list_untrusted", post(list_untrusted)) + .route("/get_servers_for_device", post(get_servers_for_device)) + .route("/get_past_server_associations", post(get_past_server_associations)) + .route("/connection_history", post(connection_history)) + .route("/connection_count", post(connection_count)) + .route("/trust", post(trust)) + .route("/list_trusted", post(list_trusted)) + .route("/untrust", post(untrust)) + .route("/update_role", post(update_role)) + .route("/search", post(search)) + .route("/update_key_name", post(update_key_name)) } -#[server] -pub async fn connection_count(device_id: Uuid) -> Result<u64> { - ssr::connection_count(device_id).await +#[derive(Deserialize)] +pub struct DeviceIdArgs { + pub device_id: Uuid, } -#[server] -pub async fn trust(device_id: Uuid, role: DeviceRole) -> Result<()> { - ssr::trust(device_id, role).await +pub async fn get_device_by_id( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<DeviceIdArgs>, +) -> Result<Json<DeviceInfo>> { + let mut conn = state.db.get().await?; + let device_with_info = Device::get_with_info(&mut conn, args.device_id).await?; + Ok(Json(DeviceInfo::from(device_with_info))) } -#[server] -pub async fn list_trusted(limit: Option<u64>, offset: Option<u64>) -> Result<Vec<Arc<DeviceInfo>>> { - ssr::list_trusted(limit, offset).await +#[derive(Deserialize)] +pub struct PaginationArgs { + pub offset: u64, + pub limit: Option<u64>, } -#[server] -pub async fn count_trusted() -> Result<u64> { - ssr::count_trusted().await +pub async fn list_untrusted( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<PaginationArgs>, +) -> Result<Json<Page<DeviceInfo>>> { + let mut conn = state.db.get().await?; + let total = Device::count_untrusted(&mut conn).await?.try_into().unwrap_or(0); + let devices_with_info = Device::list_untrusted_with_info_paginated( + &mut conn, + args.limit.unwrap_or(10).try_into().unwrap_or(10), + args.offset.try_into().unwrap_or(0), + ) + .await?; + let items = devices_with_info.into_iter().map(DeviceInfo::from).collect(); + Ok(Json(Page { items, total })) } -#[server] -pub async fn untrust(device_id: Uuid) -> Result<()> { - ssr::untrust(device_id).await +pub async fn get_servers_for_device( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<DeviceIdArgs>, +) -> Result<Json<Vec<ServerInfo>>> { + let mut conn = state.db.get().await?; + let servers = Server::get_by_device_id(&mut conn, args.device_id).await?; + Ok(Json(servers.into_iter().map(server_to_info).collect())) } -#[server] -pub async fn update_role(device_id: Uuid, role: DeviceRole) -> Result<()> { - ssr::update_role(device_id, role).await -} +pub async fn get_past_server_associations( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<DeviceIdArgs>, +) -> Result<Json<Vec<ServerInfo>>> { + use database::statuses::Status; + + let mut conn = state.db.get().await?; + let current_servers = Server::get_by_device_id(&mut conn, args.device_id).await?; + let current_server_ids: std::collections::HashSet<Uuid> = + current_servers.iter().map(|s| s.id).collect(); + + let all_past_server_ids = Status::get_past_server_ids(&mut conn, args.device_id).await?; + let past_only_ids: Vec<Uuid> = all_past_server_ids + .into_iter() + .filter(|id| !current_server_ids.contains(id)) + .collect(); + if past_only_ids.is_empty() { + return Ok(Json(Vec::new())); + } -#[server] -pub async fn search(query: String) -> Result<Vec<Arc<DeviceInfo>>> { - ssr::search(query).await + let servers = Server::get_by_ids(&mut conn, &past_only_ids).await?; + Ok(Json(servers.into_iter().map(server_to_info).collect())) } -#[server] -pub async fn update_key_name(key_id: Uuid, name: Option<String>) -> Result<()> { - ssr::update_key_name(key_id, name).await +#[derive(Deserialize)] +pub struct ConnectionHistoryArgs { + pub device_id: Uuid, + pub offset: u64, + pub limit: Option<u64>, } -#[cfg(feature = "ssr")] -mod ssr { - use super::*; - use commons_types::device::DeviceRole; - use database::devices::{Device, DeviceConnection, DeviceKey, DeviceWithInfo}; - use database::servers::Server; - use uuid::Uuid; - - impl From<DeviceWithInfo> for DeviceInfo { - fn from(device_with_info: DeviceWithInfo) -> Self { - Self { - device: Arc::new(DeviceData { - id: device_with_info.device.id, - created_at: device_with_info.device.created_at, - updated_at: device_with_info.device.updated_at, - role: device_with_info.device.role, - }), - keys: device_with_info - .keys - .into_iter() - .map(DeviceKeyInfo::from) - .map(Arc::new) - .collect(), - latest_connection: device_with_info - .latest_connection - .map(DeviceConnectionData::from) - .map(Arc::new), - } - } - } - - impl From<DeviceKey> for DeviceKeyInfo { - fn from(key: DeviceKey) -> Self { - Self { - id: key.id, - device_id: key.device_id, - name: key.name, - pem_data: format_key_as_pem(&key.key_data), - created_at: key.created_at, - } - } - } - - impl From<DeviceConnection> for DeviceConnectionData { - fn from(conn: DeviceConnection) -> Self { - Self { - id: conn.id, - created_at: conn.created_at, - device_id: conn.device_id, - ip: conn.ip.addr().to_string(), - user_agent: conn.user_agent, - } - } - } - - fn format_key_as_pem(key_data: &[u8]) -> String { - use base64::prelude::*; - - let base64_data = BASE64_STANDARD.encode(key_data); - let mut pem = String::with_capacity(base64_data.len() + 100); - - pem.push_str("-----BEGIN PUBLIC KEY-----\n"); - - // Split into 64-character lines - for chunk in base64_data.as_bytes().chunks(64) { - pem.push_str(&String::from_utf8_lossy(chunk)); - pem.push('\n'); - } - - pem.push_str("-----END PUBLIC KEY-----"); - pem - } - - pub async fn get_device_by_id(device_id: Uuid) -> Result<DeviceInfo> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - let device_with_info = Device::get_with_info(&mut conn, device_id).await?; - Ok(DeviceInfo::from(device_with_info)) - } - - pub async fn get_servers_for_device(device_id: Uuid) -> Result<Vec<ServerInfo>> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - let servers = Server::get_by_device_id(&mut conn, device_id).await?; - Ok(servers - .into_iter() - .map(|s| ServerInfo { - id: s.id, - name: s.name, - host: s.host.into(), - kind: s.kind, - rank: s.rank, - device_id: s.device_id, - parent_server_id: s.parent_server_id, - parent_server_name: None, // TODO - listed: s.listed, - cloud: s.cloud, - geolocation: s.geolocation, - }) - .collect()) - } - - pub async fn get_past_server_associations(device_id: Uuid) -> Result<Vec<ServerInfo>> { - use database::statuses::Status; - - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - // Get current server associations - let current_servers = Server::get_by_device_id(&mut conn, device_id).await?; - let current_server_ids: std::collections::HashSet<Uuid> = - current_servers.iter().map(|s| s.id).collect(); - - // Get all distinct server_ids from statuses table for this device - let all_past_server_ids = Status::get_past_server_ids(&mut conn, device_id).await?; - - // Filter out currently associated servers - let past_only_ids: Vec<Uuid> = all_past_server_ids - .into_iter() - .filter(|id| !current_server_ids.contains(id)) - .collect(); - - if past_only_ids.is_empty() { - return Ok(Vec::new()); - } - - // Get the Server objects - let servers = Server::get_by_ids(&mut conn, &past_only_ids).await?; - - Ok(servers - .into_iter() - .map(|s| ServerInfo { - id: s.id, - name: s.name, - host: s.host.into(), - kind: s.kind, - rank: s.rank, - device_id: s.device_id, - parent_server_id: s.parent_server_id, - parent_server_name: None, // TODO - listed: s.listed, - cloud: s.cloud, - geolocation: s.geolocation, - }) - .collect()) - } - - pub async fn list_untrusted( - limit: Option<u64>, - offset: Option<u64>, - ) -> Result<Vec<Arc<DeviceInfo>>> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - let devices_with_info = Device::list_untrusted_with_info_paginated( - &mut conn, - limit.unwrap_or(10).try_into().unwrap_or(10), - offset.unwrap_or(0).try_into().unwrap_or(0), - ) - .await?; - Ok(devices_with_info +pub async fn connection_history( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<ConnectionHistoryArgs>, +) -> Result<Json<Vec<DeviceConnectionData>>> { + let mut conn = state.db.get().await?; + let connections = DeviceConnection::get_history_for_device_paginated( + &mut conn, + args.device_id, + args.limit.unwrap_or(100).try_into().unwrap_or(100), + args.offset.try_into().unwrap_or(0), + ) + .await?; + Ok(Json( + connections .into_iter() - .map(DeviceInfo::from) - .map(Arc::new) - .collect()) - } - - pub async fn count_untrusted() -> Result<u64> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; + .map(DeviceConnectionData::from) + .collect(), + )) +} - Ok(Device::count_untrusted(&mut conn) +pub async fn connection_count( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<DeviceIdArgs>, +) -> Result<Json<u64>> { + let mut conn = state.db.get().await?; + Ok(Json( + DeviceConnection::get_connection_count_for_device(&mut conn, args.device_id) .await? .try_into() - .unwrap_or_default()) - } - - pub async fn list_trusted( - limit: Option<u64>, - offset: Option<u64>, - ) -> Result<Vec<Arc<DeviceInfo>>> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - let devices_with_info = Device::list_trusted_with_info_paginated( - &mut conn, - limit.unwrap_or(10).try_into().unwrap_or(10), - offset.unwrap_or(0).try_into().unwrap_or(0), - ) - .await?; - Ok(devices_with_info - .into_iter() - .map(DeviceInfo::from) - .map(Arc::new) - .collect()) - } + .unwrap_or_default(), + )) +} - pub async fn count_trusted() -> Result<u64> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; +#[derive(Deserialize)] +pub struct TrustArgs { + pub device_id: Uuid, + pub role: DeviceRole, +} - Ok(Device::count_trusted(&mut conn) - .await? - .try_into() - .unwrap_or_default()) +pub async fn trust( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<TrustArgs>, +) -> Result<Json<()>> { + if args.role == DeviceRole::Untrusted { + return Err(AppError::custom("Cannot set device role to untrusted")); } + let mut conn = state.db.get().await?; + Device::trust(&mut conn, args.device_id, args.role).await?; + Ok(Json(())) +} - pub async fn connection_history( - device_id: Uuid, - limit: Option<u64>, - offset: Option<u64>, - ) -> Result<Vec<DeviceConnectionData>> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - let connections = DeviceConnection::get_history_for_device_paginated( - &mut conn, - device_id, - limit.unwrap_or(100).try_into().unwrap_or(100), - offset.unwrap_or(0).try_into().unwrap_or(0), - ) - .await?; - Ok(connections - .into_iter() - .map(DeviceConnectionData::from) - .collect()) - } +pub async fn list_trusted( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<PaginationArgs>, +) -> Result<Json<Page<DeviceInfo>>> { + let mut conn = state.db.get().await?; + let total = Device::count_trusted(&mut conn).await?.try_into().unwrap_or(0); + let devices_with_info = Device::list_trusted_with_info_paginated( + &mut conn, + args.limit.unwrap_or(10).try_into().unwrap_or(10), + args.offset.try_into().unwrap_or(0), + ) + .await?; + let items = devices_with_info.into_iter().map(DeviceInfo::from).collect(); + Ok(Json(Page { items, total })) +} - pub async fn connection_count(device_id: Uuid) -> Result<u64> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; +pub async fn untrust( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<DeviceIdArgs>, +) -> Result<Json<()>> { + let mut conn = state.db.get().await?; + Device::untrust(&mut conn, args.device_id).await?; + Ok(Json(())) +} - Ok( - DeviceConnection::get_connection_count_for_device(&mut conn, device_id) - .await? - .try_into() - .unwrap_or_default(), - ) +pub async fn update_role( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<TrustArgs>, +) -> Result<Json<()>> { + if args.role == DeviceRole::Untrusted { + return Err(AppError::custom( + "Use untrust function to set device role to untrusted", + )); } + let mut conn = state.db.get().await?; + Device::trust(&mut conn, args.device_id, args.role).await?; + Ok(Json(())) +} - pub async fn trust(device_id: Uuid, role: DeviceRole) -> Result<()> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - // Prevent setting role to untrusted (that's the default for new devices) - if role == DeviceRole::Untrusted { - return Err(commons_errors::AppError::custom( - "Cannot set device role to untrusted", - )); - } +#[derive(Deserialize)] +pub struct SearchArgs { + pub query: String, +} - Device::trust(&mut conn, device_id, role).await +pub async fn search( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<SearchArgs>, +) -> Result<Json<Vec<DeviceInfo>>> { + if args.query.trim().is_empty() { + return Ok(Json(vec![])); } - - pub async fn untrust(device_id: Uuid) -> Result<()> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - Device::untrust(&mut conn, device_id).await + let mut conn = state.db.get().await?; + let devices_by_key = Device::search_by_key(&mut conn, &args.query).await?; + let devices_by_key_name = Device::search_by_key_name(&mut conn, &args.query).await?; + let devices_by_ip = Device::search_by_connection_ip(&mut conn, &args.query).await?; + + let mut seen: HashMap<Uuid, DeviceWithInfo> = HashMap::new(); + for d in devices_by_key { + seen.insert(d.device.id, d); } - - pub async fn update_role(device_id: Uuid, role: DeviceRole) -> Result<()> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - // Prevent setting role to untrusted (use untrust function instead) - if role == DeviceRole::Untrusted { - return Err(commons_errors::AppError::custom( - "Use untrust function to set device role to untrusted", - )); - } - - Device::trust(&mut conn, device_id, role).await + for d in devices_by_key_name { + seen.insert(d.device.id, d); } - - pub async fn search(query: String) -> Result<Vec<Arc<DeviceInfo>>> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - if query.trim().is_empty() { - return Ok(vec![]); - } - - // Search by key data - let devices_by_key = Device::search_by_key(&mut conn, &query).await?; - - // Search by key name - let devices_by_key_name = Device::search_by_key_name(&mut conn, &query).await?; - - // Search by connection IP - let devices_by_ip = Device::search_by_connection_ip(&mut conn, &query).await?; - - // Combine results and deduplicate by device ID - use std::collections::HashMap; - let mut seen_devices: HashMap<Uuid, DeviceWithInfo> = HashMap::new(); - - for device_info in devices_by_key { - seen_devices.insert(device_info.device.id, device_info); - } - for device_info in devices_by_key_name { - seen_devices.insert(device_info.device.id, device_info); - } - for device_info in devices_by_ip { - seen_devices.insert(device_info.device.id, device_info); - } - - let devices_with_info: Vec<DeviceWithInfo> = seen_devices.into_values().collect(); - Ok(devices_with_info - .into_iter() - .map(DeviceInfo::from) - .map(Arc::new) - .collect()) + for d in devices_by_ip { + seen.insert(d.device.id, d); } + Ok(Json( + seen.into_values() + .map(DeviceInfo::from) + + .collect(), + )) +} - pub async fn update_key_name(key_id: Uuid, name: Option<String>) -> Result<()> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; +#[derive(Deserialize)] +pub struct UpdateKeyNameArgs { + pub key_id: Uuid, + pub name: Option<String>, +} - DeviceKey::update_name(&mut conn, key_id, name).await - } +pub async fn update_key_name( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<UpdateKeyNameArgs>, +) -> Result<Json<()>> { + let mut conn = state.db.get().await?; + DeviceKey::update_name(&mut conn, args.key_id, args.name).await?; + Ok(Json(())) } diff --git a/crates/private-server/src/fns/servers.rs b/crates/private-server/src/fns/servers.rs index b5ec3f0b..fa587a56 100644 --- a/crates/private-server/src/fns/servers.rs +++ b/crates/private-server/src/fns/servers.rs @@ -1,6 +1,9 @@ -use std::sync::Arc; - -use commons_errors::Result; +use axum::Json; +use axum::extract::State; +use axum::routing::{Router, post}; +use commons_errors::{AppError, Result}; +use commons_servers::tailscale_auth::TailscaleAdmin; +use commons_types::server::CanopyTicket; use commons_types::{ Uuid, geo::GeoPoint, @@ -8,18 +11,28 @@ use commons_types::{ status::ShortStatus, version::VersionStr, }; +use database::{ + devices::{Device, DeviceConnection, DeviceWithInfo}, + servers::{PartialServer, Server}, + statuses::Status, + url_field::UrlField, + versions::Version, +}; +use futures::future::join; use jiff::Timestamp; -use leptos::serde_json::Value as JsonValue; -use leptos::server; use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +use crate::fns::Page; +use crate::state::AppState; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerDetailData { - pub server: Arc<ServerInfo>, - pub device_info: Option<Arc<super::devices::DeviceInfo>>, - pub last_status: Option<Arc<ServerLastStatusData>>, + pub server: ServerInfo, + pub device_info: Option<super::devices::DeviceInfo>, + pub last_status: Option<ServerLastStatusData>, pub up: ShortStatus, - pub child_servers: Vec<(ShortStatus, Arc<ServerInfo>)>, + pub child_servers: Vec<(ShortStatus, ServerInfo)>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -97,476 +110,372 @@ where Deserialize::deserialize(deserializer).map(Some) } -#[server] -pub async fn count_some(kind: Option<ServerKind>) -> Result<u64> { - ssr::count_some(kind).await +fn server_to_info(s: Server) -> ServerInfo { + ServerInfo { + id: s.id, + name: s.name, + kind: s.kind, + rank: s.rank, + host: s.host.0.to_string(), + device_id: s.device_id, + parent_server_id: s.parent_server_id, + parent_server_name: None, + listed: s.listed, + cloud: s.cloud, + geolocation: s.geolocation, + } } -#[server] -pub async fn list_some( - kind: Option<ServerKind>, - offset: u64, - limit: Option<u64>, -) -> Result<Vec<Arc<ServerInfo>>> { - ssr::list_some(kind, offset, limit) - .await - .map(|v| v.into_iter().map(Arc::new).collect()) +pub fn routes() -> Router<AppState> { + Router::new() + .route("/list_some", post(list_some)) + .route("/get_name", post(get_name)) + .route("/get_info", post(get_info)) + .route("/get_detail", post(get_detail)) + .route("/update", post(update)) + .route("/import_ticket", post(import_ticket)) + .route("/search_parent", post(search_parent)) } -#[server] -pub async fn list_all() -> Result<Vec<ServerInfo>> { - ssr::list_some(None, 0, None).await +#[derive(Deserialize)] +pub struct ListArgs { + pub kind: Option<ServerKind>, + pub offset: u64, + pub limit: Option<u64>, } -#[server] -pub async fn list_centrals() -> Result<Vec<ServerInfo>> { - ssr::list_some(Some(ServerKind::Central), 0, None).await +pub async fn list_some( + State(state): State<AppState>, + Json(args): Json<ListArgs>, +) -> Result<Json<Page<ServerInfo>>> { + let mut conn = state.db.get().await?; + let total = if let Some(kind) = args.kind { + Server::count_by_kind(&mut conn, kind).await? + } else { + Server::count_all(&mut conn).await? + }; + let servers = if let Some(kind) = args.kind { + Server::list_by_kind(&mut conn, kind, args.offset, args.limit).await? + } else { + Server::get_all(&mut conn, args.offset, args.limit).await? + }; + let items = servers.into_iter().map(server_to_info).collect(); + Ok(Json(Page { items, total })) } -#[server] -pub async fn list_facilities() -> Result<Vec<ServerInfo>> { - ssr::list_some(Some(ServerKind::Facility), 0, None).await +#[derive(Deserialize)] +pub struct ServerIdArgs { + pub server_id: Uuid, } -#[server] -pub async fn get_name(server_id: Uuid) -> Result<String> { - ssr::get_name(server_id).await +pub async fn get_name( + State(state): State<AppState>, + Json(args): Json<ServerIdArgs>, +) -> Result<Json<String>> { + let mut conn = state.db.get().await?; + let server = Server::get_by_id(&mut conn, args.server_id).await?; + Ok(Json(server.name.unwrap_or_else(|| server.host.0.to_string()))) } -#[server] -pub async fn get_info(server_id: Uuid) -> Result<ServerInfo> { - ssr::get_info(server_id).await +pub async fn get_info( + State(state): State<AppState>, + Json(args): Json<ServerIdArgs>, +) -> Result<Json<ServerInfo>> { + let db = state.db; + let mut conn = db.get().await?; + let server = Server::get_by_id(&mut conn, args.server_id).await?; + let parent_server_name = if let Some(parent_id) = server.parent_server_id { + Server::get_by_id(&mut conn, parent_id).await?.name + } else { + None + }; + Ok(Json(ServerInfo { + id: server.id, + name: server.name.clone(), + kind: server.kind, + rank: server.rank, + host: server.host.0.to_string(), + device_id: server.device_id, + parent_server_id: server.parent_server_id, + parent_server_name, + listed: server.listed, + cloud: server.cloud, + geolocation: server.geolocation, + })) } -#[server] -pub async fn get_detail(server_id: Uuid) -> Result<ServerDetailData> { - ssr::get_detail(server_id).await -} +pub async fn get_detail( + State(state): State<AppState>, + Json(args): Json<ServerIdArgs>, +) -> Result<Json<ServerDetailData>> { + let db = state.db.clone(); + let mut conn = db.get().await?; + let server = Server::get_by_id(&mut conn, args.server_id).await?; + let device_id = server.device_id; + + let (parent_server_name, status, latest_version) = { + let mut conn_parent = db.get().await?; + let mut conn_status = db.get().await?; + + let parent_lookup = async { + if let Some(parent_id) = server.parent_server_id { + let parent = Server::get_by_id(&mut conn_parent, parent_id).await?; + Ok::<_, AppError>(parent.name) + } else { + Ok(None) + } + }; -#[server(input = leptos::server_fn::codec::Json)] -pub async fn update(server_id: Uuid, data: ServerDataUpdate) -> Result<()> { - ssr::update(server_id, data).await -} + let status_lookup = Status::latest_for_server(&mut conn_status, server.id); -#[server(input = leptos::server_fn::codec::Json)] -pub async fn import_ticket( - ticket_b64: String, - kind: ServerKind, - rank: Option<ServerRank>, -) -> Result<Uuid> { - ssr::import_ticket(ticket_b64, kind, rank).await -} + let latest_version_lookup = async { + Version::get_latest_matching(&mut conn, "*".parse()?) + .await + .map(|v| v.as_semver()) + }; -#[server(input = leptos::server_fn::codec::Json)] -pub async fn search_parent( - query: String, - current_server_id: Uuid, - current_rank: Option<ServerRank>, - current_kind: ServerKind, -) -> Result<Vec<ServerInfo>> { - ssr::search_parent(query, current_server_id, current_rank, current_kind).await -} + let (parent_result, status_result) = join(parent_lookup, status_lookup).await; + (parent_result?, status_result?, latest_version_lookup.await?) + }; -#[cfg(feature = "ssr")] -mod ssr { - use std::sync::Arc; - - use axum::extract::State; - use commons_errors::{AppError, Result}; - use futures::future::join; - - use commons_types::server::MetaTicket; - use commons_types::server::{kind::ServerKind, rank::ServerRank}; - use database::{ - Db, - devices::{Device, DeviceConnection}, - servers::{PartialServer, Server}, - statuses::Status, - url_field::UrlField, - versions::Version, + let server_details = ServerInfo { + id: server.id, + name: server.name.clone(), + kind: server.kind, + rank: server.rank, + host: server.host.0.to_string(), + device_id, + parent_server_id: server.parent_server_id, + parent_server_name, + listed: server.listed, + cloud: server.cloud, + geolocation: server.geolocation, }; - use leptos::prelude::expect_context; - use leptos_axum::extract_with_state; - use uuid::Uuid; - - use crate::{fns::servers::ServerDataUpdate, state::AppState}; - - pub async fn import_ticket( - ticket_b64: String, - kind: ServerKind, - rank: Option<ServerRank>, - ) -> Result<Uuid> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - let ticket = MetaTicket::from_base64(&ticket_b64)?; - let server = Server::upsert_from_ticket(&mut conn, &ticket, kind, rank).await?; - Ok(server.id) - } - pub async fn count_some(kind: Option<ServerKind>) -> Result<u64> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; + let up = status.as_ref().map(|s| s.short_status()).unwrap_or_default(); - if let Some(kind) = kind { - Server::count_by_kind(&mut conn, kind).await - } else { - Server::count_all(&mut conn).await - } - } - - pub async fn list_some( - kind: Option<ServerKind>, - offset: u64, - limit: Option<u64>, - ) -> Result<Vec<super::ServerInfo>> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - let servers = if let Some(kind) = kind { - Server::list_by_kind(&mut conn, kind, offset, limit).await? + let last_status = if let Some(st) = status.as_ref() { + let device = if let Some(device_id) = st.device_id { + DeviceConnection::get_latest_from_device_ids(&mut conn, [device_id].into_iter()) + .await? + .into_iter() + .next() } else { - Server::get_all(&mut conn, offset, limit).await? + None }; - Ok(servers - .into_iter() - .map(|s| super::ServerInfo { - id: s.id, - name: s.name, - kind: s.kind, - rank: s.rank, - host: s.host.0.to_string(), - device_id: s.device_id, - parent_server_id: s.parent_server_id, - parent_server_name: None, - listed: s.listed, - cloud: s.cloud, - geolocation: s.geolocation, - }) - .collect()) - } - - pub async fn get_name(server_id: Uuid) -> Result<String> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - let server = Server::get_by_id(&mut conn, server_id).await?; - Ok(server.name.unwrap_or_else(|| server.host.0.to_string())) - } - - pub async fn get_info(server_id: Uuid) -> Result<super::ServerInfo> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - let server = Server::get_by_id(&mut conn, server_id).await?; - let device_id = server.device_id; - - let parent_server_name = if let Some(parent_id) = server.parent_server_id { - let parent = Server::get_by_id(&mut conn, parent_id).await?; - parent.name + let platform = st.platform(); + let postgres = st.postgres_version(); + let nodejs = device.and_then(|d| d.nodejs_version()); + let version_distance = st.distance_from_version(&latest_version); + let min_chrome_version = if let Some(ref version) = st.version { + compute_min_chrome_version(&mut conn, version).await } else { None }; - Ok(super::ServerInfo { - id: server.id, - name: server.name.clone(), - kind: server.kind, - rank: server.rank, - host: server.host.0.to_string(), - device_id, - parent_server_id: server.parent_server_id, - parent_server_name, - listed: server.listed, - cloud: server.cloud, - geolocation: server.geolocation, + Some(ServerLastStatusData { + id: st.id, + created_at: st.created_at, + version: st.version.clone(), + version_distance, + min_chrome_version, + platform, + postgres, + nodejs, + timezone: st + .extra("timezone") + .and_then(|s| s.as_str().map(|s| s.to_string())), + extra: st.extra.clone(), }) - } - - pub async fn get_detail(server_id: Uuid) -> Result<super::ServerDetailData> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - let server = Server::get_by_id(&mut conn, server_id).await?; - let device_id = server.device_id; - - // Parallelised independent lookups: parent server, status, and latest version - let (parent_server_name, status, latest_version) = { - let mut conn_parent = db.get().await?; - let mut conn_status = db.get().await?; - - let parent_lookup = async { - if let Some(parent_id) = server.parent_server_id { - let parent = Server::get_by_id(&mut conn_parent, parent_id).await?; - Ok::<_, commons_errors::AppError>(parent.name) - } else { - Ok(None) - } - }; - - let status_lookup = Status::latest_for_server(&mut conn_status, server.id); - - let latest_version_lookup = async { - Version::get_latest_matching(&mut conn, "*".parse()?) - .await - .map(|v| v.as_semver()) - }; - - let (parent_result, status_result) = join(parent_lookup, status_lookup).await; - - (parent_result?, status_result?, latest_version_lookup.await?) - }; + } else { + None + }; - let server_details = super::ServerInfo { - id: server.id, - name: server.name.clone(), - kind: server.kind, - rank: server.rank, - host: server.host.0.to_string(), - device_id, - parent_server_id: server.parent_server_id, - parent_server_name, - listed: server.listed, - cloud: server.cloud, - geolocation: server.geolocation, - }; + let device_info = if let Some(device_id) = device_id { + let device_with_info = Device::get_with_info(&mut conn, device_id).await?; + Some(convert_device_with_info(device_with_info)) + } else { + None + }; - let up = status - .as_ref() - .map(|s| s.short_status()) - .unwrap_or_default(); - - let last_status = if let Some(st) = status.as_ref() { - let device = if let Some(device_id) = st.device_id { - DeviceConnection::get_latest_from_device_ids(&mut conn, [device_id].into_iter()) - .await? - .into_iter() - .next() - } else { - None - }; + let child_servers = if server.kind == ServerKind::Central { + let children = server.get_children(&mut conn).await?; + if children.is_empty() { + Vec::new() + } else { + let child_ids: Vec<Uuid> = children.iter().map(|c| c.id).collect(); + let statuses = Status::latest_for_servers(&mut conn, &child_ids).await?; + let status_map: std::collections::HashMap<Uuid, &Status> = + statuses.iter().map(|s| (s.server_id, s)).collect(); - let platform = st.platform(); - let postgres = st.postgres_version(); - let nodejs = device.and_then(|d| d.nodejs_version()); + children + .into_iter() + .map(|child| { + let child_status = status_map.get(&child.id).copied(); + let child_up = child_status.map(|s| s.short_status()).unwrap_or_default(); + ( + child_up, + ServerInfo { + id: child.id, + name: child.name, + kind: child.kind, + rank: child.rank, + host: child.host.0.to_string(), + listed: child.listed, + cloud: child.cloud, + geolocation: child.geolocation, + device_id: child.device_id, + parent_server_id: Some(server.id), + parent_server_name: server.name.clone(), + }, + ) + }) + .collect() + } + } else { + Vec::new() + }; - let version_distance = st.distance_from_version(&latest_version); + Ok(Json(ServerDetailData { + server: server_details, + device_info, + last_status, + up, + child_servers, + })) +} - let min_chrome_version = if let Some(ref version) = st.version { - compute_min_chrome_version(&mut conn, version).await - } else { - None - }; - - Some(super::ServerLastStatusData { - id: st.id, - created_at: st.created_at, - version: st.version.clone(), - version_distance, - min_chrome_version, - platform, - postgres, - nodejs, - timezone: st - .extra("timezone") - .and_then(|s| s.as_str().map(|s| s.to_string())), - extra: st.extra.clone(), - }) - } else { - None - }; +#[derive(Deserialize)] +pub struct UpdateArgs { + pub server_id: Uuid, + pub data: ServerDataUpdate, +} - let device_info = if let Some(device_id) = device_id { - let device_with_info = Device::get_with_info(&mut conn, device_id).await?; - Some(convert_device_with_info_to_device_info(device_with_info)) +pub async fn update( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<UpdateArgs>, +) -> Result<Json<()>> { + let mut conn = state.db.get().await?; + let update_data = PartialServer { + id: args.server_id, + name: args.data.name, + kind: args.data.kind, + rank: args.data.rank, + host: if let Some(host_str) = args.data.host { + Some(UrlField( + host_str + .parse() + .map_err(|e| AppError::custom(format!("Invalid URL: {}", e)))?, + )) } else { None - }; + }, + device_id: args.data.device_id, + parent_server_id: args.data.parent_server_id, + listed: args.data.listed, + cloud: args.data.cloud, + geolocation: args.data.geolocation, + }; + Server::update(&mut conn, args.server_id, update_data).await?; + Ok(Json(())) +} - let child_servers = if server.kind.to_string() == "central" { - let children = server.get_children(&mut conn).await?; +#[derive(Deserialize)] +pub struct ImportTicketArgs { + pub ticket_b64: String, + pub kind: ServerKind, + pub rank: Option<ServerRank>, +} - if children.is_empty() { - Vec::new() - } else { - // Fetch child statuses in a single optimised query - let child_ids: Vec<Uuid> = children.iter().map(|c| c.id).collect(); - let statuses = Status::latest_for_servers(&mut conn, &child_ids).await?; - - // Create a map of server_id -> status for O(1) lookup - let status_map: std::collections::HashMap<Uuid, &Status> = - statuses.iter().map(|s| (s.server_id, s)).collect(); - - // Build result by combining children with their statuses - children - .into_iter() - .map(|child| { - let child_status = status_map.get(&child.id).copied(); - let child_up = child_status.map(|s| s.short_status()).unwrap_or_default(); - - ( - child_up, - Arc::new(super::ServerInfo { - id: child.id, - name: child.name, - kind: child.kind, - rank: child.rank, - host: child.host.0.to_string(), - listed: child.listed, - cloud: child.cloud, - geolocation: child.geolocation, - device_id: child.device_id, - parent_server_id: Some(server.id), - parent_server_name: server.name.clone(), - }), - ) - }) - .collect() - } - } else { - Vec::new() - }; +pub async fn import_ticket( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<ImportTicketArgs>, +) -> Result<Json<Uuid>> { + let mut conn = state.db.get().await?; + let ticket = CanopyTicket::from_base64(&args.ticket_b64)?; + let server = Server::upsert_from_ticket(&mut conn, &ticket, args.kind, args.rank).await?; + Ok(Json(server.id)) +} - Ok(super::ServerDetailData { - server: Arc::new(server_details), - device_info: device_info.map(Arc::new), - last_status: last_status.map(Arc::new), - up, - child_servers, - }) - } +#[derive(Deserialize)] +pub struct SearchParentArgs { + pub query: String, + pub current_server_id: Uuid, + pub current_rank: Option<ServerRank>, + pub current_kind: ServerKind, +} - pub async fn update(server_id: Uuid, data: ServerDataUpdate) -> Result<()> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - let update_data = PartialServer { - id: server_id, - name: data.name, - kind: data.kind, - rank: data.rank, - host: if let Some(host_str) = data.host { - Some(UrlField(host_str.parse().map_err(|e| { - AppError::custom(format!("Invalid URL: {}", e)) - })?)) - } else { - None - }, - device_id: data.device_id, - parent_server_id: data.parent_server_id, - listed: data.listed, - cloud: data.cloud, - geolocation: data.geolocation, - }; +pub async fn search_parent( + State(state): State<AppState>, + Json(args): Json<SearchParentArgs>, +) -> Result<Json<Vec<ServerInfo>>> { + let mut conn = state.db.get().await?; + let all_servers = Server::search_for_parent( + &mut conn, + &args.query, + args.current_server_id, + args.current_rank, + args.current_kind, + ) + .await?; + Ok(Json(all_servers.into_iter().map(server_to_info).collect())) +} - Server::update(&mut conn, server_id, update_data).await?; - Ok(()) +fn convert_device_with_info(d: DeviceWithInfo) -> super::devices::DeviceInfo { + fn format_key_as_pem(key_data: &[u8]) -> String { + use base64::prelude::*; + let base64_data = BASE64_STANDARD.encode(key_data); + let mut pem = String::with_capacity(base64_data.len() + 100); + pem.push_str("-----BEGIN PUBLIC KEY-----\n"); + for chunk in base64_data.as_bytes().chunks(64) { + pem.push_str(&String::from_utf8_lossy(chunk)); + pem.push('\n'); + } + pem.push_str("-----END PUBLIC KEY-----"); + pem } - pub async fn search_parent( - query: String, - current_server_id: Uuid, - current_rank: Option<ServerRank>, - current_kind: ServerKind, - ) -> Result<Vec<super::ServerInfo>> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - let all_servers = Server::search_for_parent( - &mut conn, - &query, - current_server_id, - current_rank, - current_kind, - ) - .await?; - - Ok(all_servers + super::devices::DeviceInfo { + device: super::devices::DeviceData { + id: d.device.id, + created_at: d.device.created_at, + updated_at: d.device.updated_at, + role: d.device.role, + }, + keys: d + .keys .into_iter() - .map(|s| super::ServerInfo { - id: s.id, - name: s.name, - kind: s.kind, - rank: s.rank, - host: s.host.0.to_string(), - device_id: s.device_id, - parent_server_id: s.parent_server_id, - parent_server_name: None, - listed: s.listed, - cloud: s.cloud, - geolocation: s.geolocation, + .map(|key| super::devices::DeviceKeyInfo { + id: key.id, + device_id: key.device_id, + name: key.name, + pem_data: format_key_as_pem(&key.key_data), + created_at: key.created_at, }) - .collect()) - } - - fn convert_device_with_info_to_device_info( - device_with_info: database::devices::DeviceWithInfo, - ) -> crate::fns::devices::DeviceInfo { - fn format_key_as_pem(key_data: &[u8]) -> String { - use base64::prelude::*; - - let base64_data = BASE64_STANDARD.encode(key_data); - let mut pem = String::with_capacity(base64_data.len() + 100); - - pem.push_str("-----BEGIN PUBLIC KEY-----\n"); - - for chunk in base64_data.as_bytes().chunks(64) { - pem.push_str(&String::from_utf8_lossy(chunk)); - pem.push('\n'); + .collect(), + latest_connection: d.latest_connection.map(|conn| { + super::devices::DeviceConnectionData { + id: conn.id, + created_at: conn.created_at, + device_id: conn.device_id, + ip: conn.ip.addr().to_string(), + user_agent: conn.user_agent, } - - pem.push_str("-----END PUBLIC KEY-----"); - pem - } - - crate::fns::devices::DeviceInfo { - device: Arc::new(crate::fns::devices::DeviceData { - id: device_with_info.device.id, - created_at: device_with_info.device.created_at, - updated_at: device_with_info.device.updated_at, - role: device_with_info.device.role, - }), - keys: device_with_info - .keys - .into_iter() - .map(|key| { - Arc::new(crate::fns::devices::DeviceKeyInfo { - id: key.id, - device_id: key.device_id, - name: key.name, - pem_data: format_key_as_pem(&key.key_data), - created_at: key.created_at, - }) - }) - .collect(), - latest_connection: device_with_info.latest_connection.map(|conn| { - Arc::new(crate::fns::devices::DeviceConnectionData { - id: conn.id, - created_at: conn.created_at, - device_id: conn.device_id, - ip: conn.ip.addr().to_string(), - user_agent: conn.user_agent, - }) - }), - } + }), } +} - async fn compute_min_chrome_version( - conn: &mut database::diesel_async::AsyncPgConnection, - version: &commons_types::version::VersionStr, - ) -> Option<u32> { - let head_release_date = Version::get_head_release_date(conn, version.clone()) - .await - .ok()?; - - database::chrome_releases::ChromeRelease::get_min_version_at_date(conn, head_release_date) - .await - .ok()? - } +async fn compute_min_chrome_version( + conn: &mut database::diesel_async::AsyncPgConnection, + version: &VersionStr, +) -> Option<u32> { + let head_release_date = Version::get_head_release_date(conn, version.clone()) + .await + .ok()?; + database::chrome_releases::ChromeRelease::get_min_version_at_date(conn, head_release_date) + .await + .ok()? } diff --git a/crates/private-server/src/fns/sql.rs b/crates/private-server/src/fns/sql.rs index 9815e29e..e0c1b1ae 100644 --- a/crates/private-server/src/fns/sql.rs +++ b/crates/private-server/src/fns/sql.rs @@ -1,10 +1,22 @@ -use commons_errors::Result; +use std::time::{Duration, Instant}; + +use axum::Json; +use axum::extract::State; +use axum::routing::{Router, post}; +use bestool_postgres::error::format_db_error; +use bestool_postgres::stringify::postgres_to_json_value; +use bestool_postgres::text_cast::{CellRef, TextCaster}; +use commons_errors::{AppError, Result}; +use commons_servers::tailscale_auth::TailscaleUser; +use database::sql_playground_history::SqlPlaygroundHistory; use jiff::Timestamp; -use leptos::server; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; +use crate::fns::Page; +use crate::state::AppState; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SqlQuery { pub query: String, @@ -18,16 +30,6 @@ pub struct SqlResult { pub execution_time_ms: u64, } -#[server] -pub async fn is_sql_available() -> Result<bool> { - ssr::is_sql_available().await -} - -#[server] -pub async fn execute_query(query: SqlQuery) -> Result<SqlResult> { - ssr::execute_query(query).await -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SqlHistoryEntry { pub id: Uuid, @@ -36,216 +38,171 @@ pub struct SqlHistoryEntry { pub created_at: Timestamp, } -#[server] -pub async fn get_last_user_query() -> Result<Option<String>> { - ssr::get_last_user_query().await +pub fn routes() -> Router<AppState> { + Router::new() + .route("/is_sql_available", post(is_sql_available)) + .route("/execute_query", post(execute_query)) + .route("/get_last_user_query", post(get_last_user_query)) + .route("/get_query_history", post(get_query_history)) } -#[server] -pub async fn get_query_history_count() -> Result<u64> { - ssr::get_query_history_count().await +pub async fn is_sql_available(State(state): State<AppState>) -> Json<bool> { + Json(state.ro_pool.is_some()) } -#[server] -pub async fn get_query_history(offset: u64, limit: Option<u64>) -> Result<Vec<SqlHistoryEntry>> { - ssr::get_query_history(offset, limit).await +#[derive(Deserialize)] +pub struct ExecuteArgs { + pub query: SqlQuery, } -#[cfg(feature = "ssr")] -mod ssr { - use super::*; - use axum::extract::State; - use bestool_postgres::error::format_db_error; - use bestool_postgres::pool; - use bestool_postgres::stringify::postgres_to_json_value; - use bestool_postgres::text_cast::{CellRef, TextCaster}; - use commons_errors::Result; - use commons_servers::tailscale_auth::TailscaleUser; - use database::Db; - use database::sql_playground_history::SqlPlaygroundHistory; - use leptos::prelude::expect_context; - use leptos_axum::extract_with_state; - use std::time::Instant; - - use crate::state::AppState; - - pub async fn is_sql_available() -> Result<bool> { - let state = expect_context::<AppState>(); - let State(ro_pool): State<Option<pool::PgPool>> = extract_with_state(&state).await?; - Ok(ro_pool.is_some()) - } +pub async fn execute_query( + State(state): State<AppState>, + user: std::result::Result<TailscaleUser, AppError>, + Json(args): Json<ExecuteArgs>, +) -> Result<Json<SqlResult>> { + let Some(ro_pool) = state.ro_pool.clone() else { + return Err(AppError::custom( + "SQL functionality is disabled (RO_DATABASE_URL not set)", + )); + }; + + let query = args.query; + let user = user.unwrap_or_default(); + let start_time = Instant::now(); + + let mut conn = state.db.get().await?; + SqlPlaygroundHistory::create(&mut conn, query.query.clone(), user.login.clone()) + .await + .map_err(|e| AppError::custom(format!("Failed to record query history: {}", e)))?; + + let mut client = ro_pool + .get() + .await + .map_err(|e| AppError::custom(format!("Failed to get connection: {}", e)))?; + + let transaction = client + .build_transaction() + .read_only(true) + .start() + .await + .map_err(|e| AppError::custom(format_db_error(&e, None)))?; - pub async fn execute_query(query: SqlQuery) -> Result<SqlResult> { - let state = expect_context::<AppState>(); - let State(Some(ro_pool)): State<Option<pool::PgPool>> = extract_with_state(&state).await? - else { - return Err(commons_errors::AppError::custom( - "SQL functionality is disabled (RO_DATABASE_URL not set)", - )); - }; - - let start_time = Instant::now(); - - let state = expect_context::<AppState>(); - let user: TailscaleUser = extract_with_state(&state).await.unwrap_or_default(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - SqlPlaygroundHistory::create(&mut conn, query.query.clone(), user.login.clone()) - .await - .map_err(|e| { - commons_errors::AppError::custom(format!("Failed to record query history: {}", e)) - })?; - - let mut client = ro_pool.get().await.map_err(|e| { - commons_errors::AppError::custom(format!("Failed to get connection: {}", e)) + transaction + .execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY", &[]) + .await + .map_err(|e| { + AppError::custom(format_db_error( + &e, + Some("SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY"), + )) })?; - let transaction = client - .build_transaction() - .read_only(true) - .start() - .await - .map_err(|e| commons_errors::AppError::custom(format_db_error(&e, None)))?; - - transaction - .execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY", &[]) - .await - .map_err(|e| { - commons_errors::AppError::custom(format_db_error( - &e, - Some("SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY"), - )) - })?; - - let rows = tokio::time::timeout( - std::time::Duration::from_secs(60), - transaction.query(&query.query, &[]), - ) + let rows = tokio::time::timeout( + Duration::from_secs(60), + transaction.query(&query.query, &[]), + ) + .await + .map_err(|_| AppError::custom("Query execution timed out after 60 seconds"))? + .map_err(|e| AppError::custom(format_db_error(&e, Some(&query.query))))?; + + transaction + .rollback() .await - .map_err(|_| { - commons_errors::AppError::custom("Query execution timed out after 60 seconds") - })? - .map_err(|e| commons_errors::AppError::custom(format_db_error(&e, Some(&query.query))))?; - - // explicitly rollback the transaction out of precaution - transaction - .rollback() - .await - .map_err(|e| commons_errors::AppError::custom(format_db_error(&e, None)))?; - - let execution_time = start_time.elapsed(); - - if rows.is_empty() { - return Ok(SqlResult { - columns: Vec::new(), - rows: Vec::new(), - row_count: 0, - execution_time_ms: execution_time.as_millis() as u64, - }); - } + .map_err(|e| AppError::custom(format_db_error(&e, None)))?; - // Get column names from the first row - let first_row = &rows[0]; - let columns: Vec<String> = first_row - .columns() - .iter() - .map(|col| col.name().to_string()) - .collect(); - - // First pass: convert all values and collect null cells for text casting - let mut null_cells = Vec::new(); - let mut all_values = Vec::new(); - - for (row_idx, row) in rows.iter().enumerate() { - let mut row_values = Vec::with_capacity(columns.len()); - for col_idx in 0..columns.len() { - let value = postgres_to_json_value(row, col_idx); - - row_values.push(value); - - // If value is Null, mark it for text casting - // (it could be a genuine null, or a failure to get a string) - if let serde_json::Value::Null = &row_values[col_idx] { - null_cells.push(CellRef { row_idx, col_idx }); - } + let execution_time = start_time.elapsed(); + + if rows.is_empty() { + return Ok(Json(SqlResult { + columns: Vec::new(), + rows: Vec::new(), + row_count: 0, + execution_time_ms: execution_time.as_millis() as u64, + })); + } + + let first_row = &rows[0]; + let columns: Vec<String> = first_row + .columns() + .iter() + .map(|col| col.name().to_string()) + .collect(); + + let mut null_cells = Vec::new(); + let mut all_values = Vec::new(); + for (row_idx, row) in rows.iter().enumerate() { + let mut row_values = Vec::with_capacity(columns.len()); + for col_idx in 0..columns.len() { + let value = postgres_to_json_value(row, col_idx); + row_values.push(value); + if let Value::Null = &row_values[col_idx] { + null_cells.push(CellRef { row_idx, col_idx }); } - all_values.push(row_values); } + all_values.push(row_values); + } - // If we have null cells, try to cast them to text using TextCaster - if !null_cells.is_empty() { - let text_caster = TextCaster::new(ro_pool.clone()); - let text_results = text_caster.cast_batch(&rows, &null_cells).await; - - // Update the null values with text representations - for (cell_ref, text_result) in null_cells.iter().zip(text_results) { - match text_result { - Ok(text) => { - all_values[cell_ref.row_idx][cell_ref.col_idx] = - serde_json::Value::String(text); - } - Err(_) => { - // Keep as Null if text casting fails - all_values[cell_ref.row_idx][cell_ref.col_idx] = serde_json::Value::Null; - } + if !null_cells.is_empty() { + let text_caster = TextCaster::new(ro_pool.clone()); + let text_results = text_caster.cast_batch(&rows, &null_cells).await; + for (cell_ref, text_result) in null_cells.iter().zip(text_results) { + match text_result { + Ok(text) => { + all_values[cell_ref.row_idx][cell_ref.col_idx] = Value::String(text); + } + Err(_) => { + all_values[cell_ref.row_idx][cell_ref.col_idx] = Value::Null; } } } - - Ok(SqlResult { - columns, - rows: all_values, - row_count: rows.len(), - execution_time_ms: execution_time.as_millis() as u64, - }) } - pub async fn get_last_user_query() -> Result<Option<String>> { - let state = expect_context::<AppState>(); - let user: TailscaleUser = extract_with_state(&state).await.unwrap_or_default(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - Ok( - SqlPlaygroundHistory::get_last_by_user(&mut conn, &user.login) - .await? - .map(|entry| entry.query), - ) - } + Ok(Json(SqlResult { + columns, + rows: all_values, + row_count: rows.len(), + execution_time_ms: execution_time.as_millis() as u64, + })) +} - pub async fn get_query_history_count() -> Result<u64> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - Ok(SqlPlaygroundHistory::count_all(&mut conn) - .await? - .try_into() - .unwrap_or(0)) - } +pub async fn get_last_user_query( + State(state): State<AppState>, + user: std::result::Result<TailscaleUser, AppError>, +) -> Result<Json<Option<String>>> { + let user = user.unwrap_or_default(); + let mut conn = state.db.get().await?; + let last = SqlPlaygroundHistory::get_last_by_user(&mut conn, &user.login) + .await? + .map(|entry| entry.query); + Ok(Json(last)) +} - pub async fn get_query_history( - offset: u64, - limit: Option<u64>, - ) -> Result<Vec<SqlHistoryEntry>> { - let limit = limit.unwrap_or(10) as i64; - let offset = offset as i64; - - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - Ok( - SqlPlaygroundHistory::get_paginated(&mut conn, offset, limit) - .await? - .into_iter() - .map(|entry| SqlHistoryEntry { - id: entry.id, - query: entry.query, - tailscale_user: entry.tailscale_user, - created_at: entry.created_at, - }) - .collect(), - ) - } +#[derive(Deserialize)] +pub struct HistoryArgs { + pub offset: u64, + pub limit: Option<u64>, +} + +pub async fn get_query_history( + State(state): State<AppState>, + Json(args): Json<HistoryArgs>, +) -> Result<Json<Page<SqlHistoryEntry>>> { + let limit = args.limit.unwrap_or(10) as i64; + let offset = args.offset as i64; + let mut conn = state.db.get().await?; + let total = SqlPlaygroundHistory::count_all(&mut conn) + .await? + .try_into() + .unwrap_or(0); + let items = SqlPlaygroundHistory::get_paginated(&mut conn, offset, limit) + .await? + .into_iter() + .map(|entry| SqlHistoryEntry { + id: entry.id, + query: entry.query, + tailscale_user: entry.tailscale_user, + created_at: entry.created_at, + }) + .collect(); + Ok(Json(Page { items, total })) } diff --git a/crates/private-server/src/fns/statuses.rs b/crates/private-server/src/fns/statuses.rs index 3126013e..27af0684 100644 --- a/crates/private-server/src/fns/statuses.rs +++ b/crates/private-server/src/fns/statuses.rs @@ -1,14 +1,24 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use axum::Json; +use axum::extract::State; +use axum::routing::{Router, post}; use commons_errors::Result; use commons_types::{ - server::{cards::CentralServerCard, rank::ServerRank}, + server::{ + cards::{CentralServerCard, FacilityServerStatus}, + kind::ServerKind, + rank::ServerRank, + }, version::VersionStr, }; -use leptos::server; +use database::{servers::Server, statuses::Status, versions::Version}; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::state::AppState; + #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] pub struct LiveVersionsBracket { pub min: VersionStr, @@ -42,143 +52,119 @@ pub struct ServerStatusData { pub timezone: Option<String>, } -#[server] -pub async fn summary() -> Result<SummaryData> { - ssr::summary().await +pub fn routes() -> Router<AppState> { + Router::new() + .route("/summary", post(summary)) + .route("/server_grouped_ids", post(server_grouped_ids)) + .route("/server_details", post(server_details)) } -pub type ServerGroupedIdsOutput = Result<BTreeMap<ServerRank, Vec<Uuid>>>; -#[server] -pub async fn server_grouped_ids() -> ServerGroupedIdsOutput { - ssr::server_grouped_ids().await +pub async fn summary(State(state): State<AppState>) -> Result<Json<SummaryData>> { + let mut conn = state.db.get().await?; + + let versions: BTreeSet<VersionStr> = Status::production_versions(&mut conn) + .await? + .into_iter() + .collect(); + + let bracket = LiveVersionsBracket { + min: versions.first().cloned().unwrap_or_default(), + max: versions.last().cloned().unwrap_or_default(), + }; + let releases = versions + .iter() + .map(|v| (v.0.major, v.0.minor)) + .collect::<BTreeSet<_>>(); + + Ok(Json(SummaryData { + bracket, + releases, + versions, + })) } -#[server] -pub async fn server_details(server_id: Uuid) -> Result<CentralServerCard> { - ssr::server_details(server_id).await +pub async fn server_grouped_ids( + State(state): State<AppState>, +) -> Result<Json<BTreeMap<ServerRank, Vec<Uuid>>>> { + let mut conn = state.db.get().await?; + let servers = Server::list_by_kind(&mut conn, ServerKind::Central, 0, None).await?; + + let groups = servers + .into_iter() + .filter(|s| s.name.is_some() && s.rank.is_some()) + .sorted_by_key(|s| s.rank) + .chunk_by(|s| s.rank.unwrap()); + + let map: BTreeMap<ServerRank, Vec<Uuid>> = groups + .into_iter() + .map(|(rank, group)| { + ( + rank, + group + .sorted_by_key(|s| s.name.clone().unwrap()) + .map(|s| s.id) + .collect(), + ) + }) + .collect(); + Ok(Json(map)) } -#[cfg(feature = "ssr")] -mod ssr { - use super::*; - use std::collections::{BTreeSet, HashMap}; +#[derive(Deserialize)] +pub struct ServerDetailsArgs { + pub server_id: Uuid, +} - use axum::extract::State; - use commons_errors::Result; - use commons_types::{ - server::{cards::FacilityServerStatus, kind::ServerKind}, - version::VersionStr, - }; - use database::{Db, servers::Server, statuses::Status, versions::Version}; - use itertools::Itertools; - use leptos::prelude::expect_context; - use leptos_axum::extract_with_state; - use uuid::Uuid; - - use crate::state::AppState; - - pub async fn summary() -> Result<SummaryData> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - let versions: BTreeSet<VersionStr> = Status::production_versions(&mut conn) - .await? - .into_iter() - .collect(); - - let bracket = LiveVersionsBracket { - min: versions.first().cloned().unwrap_or_default(), - max: versions.last().cloned().unwrap_or_default(), - }; - let releases = versions - .iter() - .map(|v| (v.0.major, v.0.minor)) - .collect::<BTreeSet<_>>(); - - Ok(SummaryData { - bracket, - releases, - versions, - }) - } - - pub async fn server_grouped_ids() -> ServerGroupedIdsOutput { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - let servers = Server::list_by_kind(&mut conn, ServerKind::Central, 0, None).await?; - - let groups = servers - .into_iter() - .filter(|s| s.name.is_some() && s.rank.is_some()) - .sorted_by_key(|s| s.rank) - .chunk_by(|s| s.rank.unwrap()); - - Ok(groups - .into_iter() - .map(|(rank, group)| { - ( - rank, - group - .sorted_by_key(|s| s.name.clone().unwrap()) - .map(|s| s.id) - .collect(), - ) - }) - .collect()) - } - - pub async fn server_details(id: Uuid) -> Result<super::CentralServerCard> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - let central = Server::get_by_id(&mut conn, id).await?; - - let latest_version = Version::get_latest_matching(&mut conn, "*".parse()?) - .await? - .as_semver(); - - let central_status = Status::latest_for_server(&mut conn, id).await?; - let central_up = central_status - .as_ref() - .map(|s| s.short_status()) - .unwrap_or_default(); - let version_distance = central_status - .as_ref() - .and_then(|s| s.distance_from_version(&latest_version)); - - let facilities = central.get_children(&mut conn).await?; - let facility_ids = facilities.iter().map(|f| f.id).collect::<Vec<_>>(); - let facility_statuses = Status::latest_for_servers(&mut conn, &facility_ids) - .await? - .into_iter() - .map(|s| (s.server_id, s)) - .collect::<HashMap<_, _>>(); - let facility_servers = facilities - .into_iter() - .map(|f| { - let facility_status = facility_statuses.get(&f.id); - FacilityServerStatus { - id: f.id, - name: f.name.clone().unwrap_or_default(), - up: facility_status - .map(|s| s.short_status()) - .unwrap_or_default(), - } - }) - .collect(); - - Ok(CentralServerCard { - id: central.id, - name: central.name.unwrap_or_default(), - rank: central.rank, - host: central.host.0.to_string(), - up: central_up, - version: central_status.and_then(|s| s.version), - version_distance, - facility_servers, +pub async fn server_details( + State(state): State<AppState>, + Json(args): Json<ServerDetailsArgs>, +) -> Result<Json<CentralServerCard>> { + let mut conn = state.db.get().await?; + + let central = Server::get_by_id(&mut conn, args.server_id).await?; + + let latest_version = Version::get_latest_matching(&mut conn, "*".parse()?) + .await? + .as_semver(); + + let central_status = Status::latest_for_server(&mut conn, args.server_id).await?; + let central_up = central_status + .as_ref() + .map(|s| s.short_status()) + .unwrap_or_default(); + let version_distance = central_status + .as_ref() + .and_then(|s| s.distance_from_version(&latest_version)); + + let facilities = central.get_children(&mut conn).await?; + let facility_ids = facilities.iter().map(|f| f.id).collect::<Vec<_>>(); + let facility_statuses = Status::latest_for_servers(&mut conn, &facility_ids) + .await? + .into_iter() + .map(|s| (s.server_id, s)) + .collect::<HashMap<_, _>>(); + let facility_servers = facilities + .into_iter() + .map(|f| { + let facility_status = facility_statuses.get(&f.id); + FacilityServerStatus { + id: f.id, + name: f.name.clone().unwrap_or_default(), + up: facility_status + .map(|s| s.short_status()) + .unwrap_or_default(), + } }) - } + .collect(); + + Ok(Json(CentralServerCard { + id: central.id, + name: central.name.unwrap_or_default(), + rank: central.rank, + host: central.host.0.to_string(), + up: central_up, + version: central_status.and_then(|s| s.version), + version_distance, + facility_servers, + })) } diff --git a/crates/private-server/src/fns/versions.rs b/crates/private-server/src/fns/versions.rs index b6eef260..08c86cab 100644 --- a/crates/private-server/src/fns/versions.rs +++ b/crates/private-server/src/fns/versions.rs @@ -1,10 +1,19 @@ -use commons_errors::Result; -use commons_types::version::VersionStatus; +use std::collections::BTreeMap; +use std::str::FromStr; + +use axum::Json; +use axum::extract::State; +use axum::routing::{Router, post}; +use commons_errors::{AppError, Result}; +use commons_servers::tailscale_auth::TailscaleAdmin; +use commons_types::version::{VersionStatus, VersionStr}; +use database::{artifacts::Artifact, versions::Version}; use jiff::Timestamp; -use leptos::server; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::state::AppState; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionData { pub major: i32, @@ -64,247 +73,160 @@ pub struct ArtifactData { pub is_used_in_public_api: bool, } -#[server] -pub async fn get_grouped_versions() -> Result<Vec<MinorVersionGroup>> { - ssr::get_grouped_versions().await -} - -#[server] -pub async fn get_version_detail(version: String) -> Result<VersionDetail> { - ssr::get_version_detail(version).await +pub fn routes() -> Router<AppState> { + Router::new() + .route("/get_grouped_versions", post(get_grouped_versions)) + .route("/get_version_detail", post(get_version_detail)) + .route("/get_version_artifacts", post(get_version_artifacts)) + .route("/update_version_status", post(update_version_status)) + .route("/update_version_changelog", post(update_version_changelog)) + .route("/update_artifact", post(update_artifact)) + .route("/create_artifact", post(create_artifact)) + .route("/delete_artifact", post(delete_artifact)) } -#[server] -pub async fn get_version_artifacts(version: String) -> Result<Vec<ArtifactData>> { - ssr::get_version_artifacts(version).await -} - -#[server] -pub async fn get_artifacts_by_version_id(version_id: Uuid) -> Result<Vec<ArtifactData>> { - ssr::get_artifacts_by_version_id(version_id).await -} - -#[server] -pub async fn update_version_status(version: String, status: String) -> Result<()> { - ssr::update_version_status(version, status).await -} - -#[server] -pub async fn update_version_changelog(version: String, changelog: String) -> Result<()> { - ssr::update_version_changelog(version, changelog).await -} +pub async fn get_grouped_versions( + State(state): State<AppState>, +) -> Result<Json<Vec<MinorVersionGroup>>> { + let mut conn = state.db.get().await?; + let versions = Version::get_all_including_drafts(&mut conn).await?; + + let mut grouped: BTreeMap<(i32, i32), Vec<Version>> = BTreeMap::new(); + for version in versions { + grouped + .entry((version.major, version.minor)) + .or_default() + .push(version); + } -#[server] -pub async fn update_artifact( - artifact_id: Uuid, - artifact_type: String, - platform: String, - download_url: String, -) -> Result<()> { - ssr::update_artifact(artifact_id, artifact_type, platform, download_url).await -} + let mut result: Vec<MinorVersionGroup> = grouped + .into_iter() + .map(|((major, minor), mut versions)| { + versions.sort_by(|a, b| b.patch.cmp(&a.patch)); + let count = versions.len(); + + let published_versions: Vec<_> = versions + .iter() + .filter(|v| v.status == VersionStatus::Published) + .collect(); + + let latest_patch = published_versions.first().map(|v| v.patch).unwrap_or(0); + + let first_created_at = published_versions + .iter() + .find(|v| v.patch == 0) + .map(|v| v.created_at) + .unwrap_or_else(|| { + published_versions + .last() + .map(|v| v.created_at) + .unwrap_or_else(Timestamp::now) + }); + + let last_created_at = published_versions + .first() + .map(|v| v.created_at) + .unwrap_or_else(Timestamp::now); + + let version_data: Vec<VersionData> = versions + .into_iter() + .map(|v| VersionData { + major: v.major, + minor: v.minor, + patch: v.patch, + status: v.status, + created_at: v.created_at, + }) + .collect(); + + MinorVersionGroup { + major, + minor, + count, + latest_patch, + first_created_at, + last_created_at, + versions: version_data, + } + }) + .collect(); -#[server] -pub async fn create_artifact( - version_id: Uuid, - artifact_type: String, - platform: String, - download_url: String, -) -> Result<ArtifactData> { - ssr::create_artifact(version_id, artifact_type, platform, download_url).await + result.sort_by(|a, b| b.major.cmp(&a.major).then_with(|| b.minor.cmp(&a.minor))); + Ok(Json(result)) } -#[server] -pub async fn delete_artifact(artifact_id: Uuid) -> Result<()> { - ssr::delete_artifact(artifact_id).await +#[derive(Deserialize)] +pub struct VersionStringArgs { + pub version: String, } -#[cfg(feature = "ssr")] -mod ssr { - use super::*; - use std::collections::BTreeMap; - use std::str::FromStr; - - use axum::extract::State; - use commons_errors::Result; - use commons_types::version::{VersionStatus, VersionStr}; - use database::{Db, artifacts::Artifact, versions::Version}; - use jiff::Timestamp; - use leptos::prelude::expect_context; - use leptos_axum::extract_with_state; - - use crate::state::AppState; - - pub async fn get_grouped_versions() -> Result<Vec<MinorVersionGroup>> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - let versions = Version::get_all_including_drafts(&mut conn).await?; - - let mut grouped: BTreeMap<(i32, i32), Vec<Version>> = BTreeMap::new(); - for version in versions { - grouped - .entry((version.major, version.minor)) - .or_default() - .push(version); - } - - let mut result: Vec<MinorVersionGroup> = grouped - .into_iter() - .map(|((major, minor), mut versions)| { - versions.sort_by(|a, b| b.patch.cmp(&a.patch)); - - let count = versions.len(); - - // Filter to only published versions for calculating latest patch and dates - let published_versions: Vec<_> = versions - .iter() - .filter(|v| v.status == commons_types::version::VersionStatus::Published) - .collect(); - - let latest_patch = published_versions.first().map(|v| v.patch).unwrap_or(0); - - let first_created_at = published_versions - .iter() - .find(|v| v.patch == 0) - .map(|v| v.created_at) - .unwrap_or_else(|| { - published_versions - .last() - .map(|v| v.created_at) - .unwrap_or_else(Timestamp::now) - }); - - let last_created_at = published_versions - .first() - .map(|v| v.created_at) - .unwrap_or_else(Timestamp::now); - - let version_data: Vec<VersionData> = versions - .into_iter() - .map(|v| VersionData { - major: v.major, - minor: v.minor, - patch: v.patch, - status: v.status, - created_at: v.created_at, - }) - .collect(); - - MinorVersionGroup { - major, - minor, - count, - latest_patch, - first_created_at, - last_created_at, - versions: version_data, - } - }) - .collect(); - - result.sort_by(|a, b| b.major.cmp(&a.major).then_with(|| b.minor.cmp(&a.minor))); - - Ok(result) - } - - pub async fn get_version_detail(version_str: String) -> Result<super::VersionDetail> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - let version = VersionStr::from_str(&version_str)?; - let version_record = Version::get_by_version(&mut conn, version.clone()).await?; - - // Compute min chrome version - let min_chrome_version = if let Ok(head_release_date) = - Version::get_head_release_date(&mut conn, version.clone()).await - { - database::chrome_releases::ChromeRelease::get_min_version_at_date( - &mut conn, - head_release_date, - ) - .await - .ok() - .flatten() - } else { - None - }; - - // Check if this is the latest published version in its minor - let is_latest_in_minor = Version::is_latest_in_minor(&mut conn, version.clone()) - .await - .unwrap_or(true); - - // Get all lower patch versions in this minor release - let related_versions = Version::get_all_in_minor(&mut conn, version.clone()) - .await - .unwrap_or_default() - .into_iter() - .map(|v| super::RelatedVersionData { - major: v.major, - minor: v.minor, - patch: v.patch, - changelog: v.changelog, - }) - .collect(); - - Ok(super::VersionDetail { - id: version_record.id, - major: version_record.major, - minor: version_record.minor, - patch: version_record.patch, - status: version_record.status, - created_at: version_record.created_at, - updated_at: version_record.updated_at, - changelog: version_record.changelog, - min_chrome_version, - is_latest_in_minor, - related_versions, +pub async fn get_version_detail( + State(state): State<AppState>, + Json(args): Json<VersionStringArgs>, +) -> Result<Json<VersionDetail>> { + let mut conn = state.db.get().await?; + let version = VersionStr::from_str(&args.version)?; + let version_record = Version::get_by_version(&mut conn, version.clone()).await?; + + let min_chrome_version = if let Ok(head_release_date) = + Version::get_head_release_date(&mut conn, version.clone()).await + { + database::chrome_releases::ChromeRelease::get_min_version_at_date( + &mut conn, + head_release_date, + ) + .await + .ok() + .flatten() + } else { + None + }; + + let is_latest_in_minor = Version::is_latest_in_minor(&mut conn, version.clone()) + .await + .unwrap_or(true); + + let related_versions = Version::get_all_in_minor(&mut conn, version.clone()) + .await + .unwrap_or_default() + .into_iter() + .map(|v| RelatedVersionData { + major: v.major, + minor: v.minor, + patch: v.patch, + changelog: v.changelog, }) - } - - pub async fn get_version_artifacts(version_str: String) -> Result<Vec<super::ArtifactData>> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - let version = VersionStr::from_str(&version_str)?; - let version_record = Version::get_by_version(&mut conn, version).await?; - let artifacts_with_metadata = - Artifact::get_for_version_with_metadata(&mut conn, version_record.id).await?; - - Ok(artifacts_with_metadata - .into_iter() - .map( - |(a, is_exact, has_range_override, is_used_in_public_api)| super::ArtifactData { - id: a.id, - artifact_type: a.artifact_type, - platform: a.platform, - download_url: a.download_url, - is_exact, - version_range_pattern: a.version_range_pattern, - has_range_override, - is_used_in_public_api, - }, - ) - .collect()) - } - - pub async fn get_artifacts_by_version_id(version_id: Uuid) -> Result<Vec<super::ArtifactData>> { - let state = expect_context::<AppState>(); - let State(db): State<Db> = extract_with_state(&state).await?; - let mut conn = db.get().await?; - - // Use the all_matches version for admin view - show all configured artifacts - let artifacts_with_metadata = - Artifact::get_for_version_all_matches_with_metadata(&mut conn, version_id).await?; + .collect(); + + Ok(Json(VersionDetail { + id: version_record.id, + major: version_record.major, + minor: version_record.minor, + patch: version_record.patch, + status: version_record.status, + created_at: version_record.created_at, + updated_at: version_record.updated_at, + changelog: version_record.changelog, + min_chrome_version, + is_latest_in_minor, + related_versions, + })) +} - Ok(artifacts_with_metadata +pub async fn get_version_artifacts( + State(state): State<AppState>, + Json(args): Json<VersionStringArgs>, +) -> Result<Json<Vec<ArtifactData>>> { + let mut conn = state.db.get().await?; + let version = VersionStr::from_str(&args.version)?; + let version_record = Version::get_by_version(&mut conn, version).await?; + let artifacts_with_metadata = + Artifact::get_for_version_with_metadata(&mut conn, version_record.id).await?; + Ok(Json( + artifacts_with_metadata .into_iter() .map( - |(a, is_exact, has_range_override, is_used_in_public_api)| super::ArtifactData { + |(a, is_exact, has_range_override, is_used_in_public_api)| ArtifactData { id: a.id, artifact_type: a.artifact_type, platform: a.platform, @@ -315,104 +237,126 @@ mod ssr { is_used_in_public_api, }, ) - .collect()) - } - - pub async fn update_version_status(version_str: String, status_str: String) -> Result<()> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - let version = VersionStr::from_str(&version_str)?; - let new_status = VersionStatus::from(status_str); + .collect(), + )) +} - // Check if trying to change a published non-latest version to draft - let version_record = Version::get_by_version(&mut conn, version.clone()).await?; - if version_record.status == VersionStatus::Published && new_status == VersionStatus::Draft { - let is_latest = Version::is_latest_in_minor(&mut conn, version.clone()).await?; +#[derive(Deserialize)] +pub struct UpdateStatusArgs { + pub version: String, + pub status: String, +} - if !is_latest { - return Err(commons_errors::AppError::custom( - "Cannot change a published version to draft unless it is the latest in its minor version", - )); - } +pub async fn update_version_status( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<UpdateStatusArgs>, +) -> Result<Json<()>> { + let mut conn = state.db.get().await?; + let version = VersionStr::from_str(&args.version)?; + let new_status = VersionStatus::from(args.status); + + let version_record = Version::get_by_version(&mut conn, version.clone()).await?; + if version_record.status == VersionStatus::Published && new_status == VersionStatus::Draft { + let is_latest = Version::is_latest_in_minor(&mut conn, version.clone()).await?; + if !is_latest { + return Err(AppError::custom( + "Cannot change a published version to draft unless it is the latest in its minor version", + )); } - - Version::update_status(&mut conn, version, new_status).await?; - - Ok(()) } - pub async fn update_version_changelog( - version_str: String, - new_changelog: String, - ) -> Result<()> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; - - let version = VersionStr::from_str(&version_str)?; - - Version::update_changelog(&mut conn, version, new_changelog).await?; - - Ok(()) - } + Version::update_status(&mut conn, version, new_status).await?; + Ok(Json(())) +} - pub async fn update_artifact( - artifact_id: Uuid, - artifact_type: String, - platform: String, - download_url: String, - ) -> Result<()> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; +#[derive(Deserialize)] +pub struct UpdateChangelogArgs { + pub version: String, + pub changelog: String, +} - database::artifacts::Artifact::update( - &mut conn, - artifact_id, - artifact_type, - platform, - download_url, - ) - .await?; +pub async fn update_version_changelog( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<UpdateChangelogArgs>, +) -> Result<Json<()>> { + let mut conn = state.db.get().await?; + let version = VersionStr::from_str(&args.version)?; + Version::update_changelog(&mut conn, version, args.changelog).await?; + Ok(Json(())) +} - Ok(()) - } +#[derive(Deserialize)] +pub struct UpdateArtifactArgs { + pub artifact_id: Uuid, + pub artifact_type: String, + pub platform: String, + pub download_url: String, +} - pub async fn create_artifact( - version_id: Uuid, - artifact_type: String, - platform: String, - download_url: String, - ) -> Result<super::ArtifactData> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; +pub async fn update_artifact( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<UpdateArtifactArgs>, +) -> Result<Json<()>> { + let mut conn = state.db.get().await?; + Artifact::update( + &mut conn, + args.artifact_id, + args.artifact_type, + args.platform, + args.download_url, + ) + .await?; + Ok(Json(())) +} - let artifact = database::artifacts::Artifact::create( - &mut conn, - version_id, - artifact_type, - platform, - download_url, - ) - .await?; - - Ok(super::ArtifactData { - id: artifact.id, - artifact_type: artifact.artifact_type, - platform: artifact.platform, - download_url: artifact.download_url, - is_exact: true, - version_range_pattern: None, - has_range_override: false, - is_used_in_public_api: true, - }) - } +#[derive(Deserialize)] +pub struct CreateArtifactArgs { + pub version_id: Uuid, + pub artifact_type: String, + pub platform: String, + pub download_url: String, +} - pub async fn delete_artifact(artifact_id: Uuid) -> Result<()> { - let db = crate::fns::commons::admin_guard().await?; - let mut conn = db.get().await?; +pub async fn create_artifact( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<CreateArtifactArgs>, +) -> Result<Json<ArtifactData>> { + let mut conn = state.db.get().await?; + let artifact = Artifact::create( + &mut conn, + args.version_id, + args.artifact_type, + args.platform, + args.download_url, + ) + .await?; + Ok(Json(ArtifactData { + id: artifact.id, + artifact_type: artifact.artifact_type, + platform: artifact.platform, + download_url: artifact.download_url, + is_exact: true, + version_range_pattern: None, + has_range_override: false, + is_used_in_public_api: true, + })) +} - database::artifacts::Artifact::delete(&mut conn, artifact_id).await?; +#[derive(Deserialize)] +pub struct ArtifactIdArgs { + pub artifact_id: Uuid, +} - Ok(()) - } +pub async fn delete_artifact( + State(state): State<AppState>, + TailscaleAdmin(_): TailscaleAdmin, + Json(args): Json<ArtifactIdArgs>, +) -> Result<Json<()>> { + let mut conn = state.db.get().await?; + Artifact::delete(&mut conn, args.artifact_id).await?; + Ok(Json(())) } diff --git a/crates/private-server/src/lib.rs b/crates/private-server/src/lib.rs index 80643eac..a96a5432 100644 --- a/crates/private-server/src/lib.rs +++ b/crates/private-server/src/lib.rs @@ -1,27 +1,11 @@ -#![recursion_limit = "256"] - -pub mod app; -pub mod components; pub mod fns; -#[cfg(feature = "ssr")] +pub mod spa; pub mod state; -#[cfg(feature = "ssr")] pub fn routes(state: crate::state::AppState) -> commons_errors::Result<axum::routing::Router<()>> { - use axum::{ - response::Redirect, - routing::{Router, get}, - }; - use leptos::prelude::provide_context; - use leptos_axum::{LeptosRoutes as _, generate_route_list}; - use tower_http::services::ServeDir; + use axum::routing::Router; Ok(Router::new() - .route("/", get(|| async { Redirect::permanent("/status") })) - .route( - "/bestool", - get(|| async { Redirect::permanent("/bestool/snippets") }), - ) .nest( "/public", public_server::routes() @@ -29,47 +13,6 @@ pub fn routes(state: crate::state::AppState) -> commons_errors::Result<axum::rou ) .merge(commons_servers::health::routes()) .merge(fns::routes()) - .nest_service( - "/static", - ServeDir::new("target/site/private") - .precompressed_br() - .precompressed_gzip() - .fallback( - ServeDir::new("target/site") - .precompressed_br() - .precompressed_gzip(), - ), - ) - .nest_service( - "/pkg", - ServeDir::new("target/site/pkg") - .precompressed_br() - .precompressed_gzip(), - ) - // .fallback(leptos_axum::file_and_error_handler(crate::app::shell)) - .leptos_routes_with_context( - &state, - generate_route_list(crate::app::App), - { - let state = state.clone(); - move || provide_context(state.clone()) - }, - { - let state = state.clone(); - move || { - crate::app::shell({ - let state = state.clone(); - state.leptos_options - }) - } - }, - ) + .fallback(spa::handler) .with_state(state)) } - -#[cfg(feature = "hydrate")] -#[wasm_bindgen::prelude::wasm_bindgen] -pub fn hydrate() { - console_error_panic_hook::set_once(); - leptos::mount::hydrate_body(app::App); -} diff --git a/crates/private-server/src/main.rs b/crates/private-server/src/main.rs index 49c89992..43dde9f2 100644 --- a/crates/private-server/src/main.rs +++ b/crates/private-server/src/main.rs @@ -1,4 +1,3 @@ -#[cfg(feature = "ssr")] #[derive(Debug, clap::Parser)] struct Args { #[command(flatten)] @@ -14,7 +13,6 @@ struct Args { client_ip_source: axum_client_ip::ClientIpSource, } -#[cfg(feature = "ssr")] #[tokio::main] async fn main() -> miette::Result<()> { use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6}; @@ -56,8 +54,3 @@ async fn main() -> miette::Result<()> { } Ok(()) } - -#[cfg(not(feature = "ssr"))] -pub fn main() { - // no client-side main function -} diff --git a/crates/private-server/src/spa.rs b/crates/private-server/src/spa.rs new file mode 100644 index 00000000..cee03f5a --- /dev/null +++ b/crates/private-server/src/spa.rs @@ -0,0 +1,46 @@ +use axum::body::Body; +use axum::http::{HeaderValue, Response, StatusCode, Uri, header}; +use axum::response::IntoResponse; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "$CARGO_MANIFEST_DIR/../../private-web/dist/"] +struct Assets; + +/// Fallback handler that serves the embedded React SPA. Static assets are +/// served from their hashed path with long-lived cache; everything else +/// falls back to `index.html` so the client-side router can take over. +pub async fn handler(uri: Uri) -> impl IntoResponse { + let path = uri.path().trim_start_matches('/'); + let is_asset = path.starts_with("assets/"); + + let resolved = if !is_asset && Assets::get(path).is_none() { + "index.html" + } else { + path + }; + + let Some(file) = Assets::get(resolved) else { + return (StatusCode::NOT_FOUND, "not found").into_response(); + }; + + let mime_type = mime_guess::from_path(resolved).first_or_octet_stream(); + let mime = if mime_type.type_() == mime_guess::mime::TEXT { + format!("{mime_type}; charset=utf-8") + } else { + mime_type.to_string() + }; + + let cache = if is_asset { + HeaderValue::from_static("public, max-age=31536000, immutable") + } else { + HeaderValue::from_static("no-cache, no-store, must-revalidate") + }; + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime) + .header(header::CACHE_CONTROL, cache) + .body(Body::from(file.data.into_owned())) + .unwrap() +} diff --git a/crates/private-server/src/state.rs b/crates/private-server/src/state.rs index 7a076bea..880a049f 100644 --- a/crates/private-server/src/state.rs +++ b/crates/private-server/src/state.rs @@ -2,21 +2,17 @@ use axum::extract::FromRef; use bestool_postgres::pool::PgPool; use commons_errors::Result; use database::Db; -use leptos::config::{LeptosOptions, get_configuration}; #[derive(Clone, Debug, FromRef)] pub struct AppState { pub db: Db, pub ro_pool: Option<PgPool>, - pub leptos_options: LeptosOptions, } impl AppState { pub async fn init() -> Result<Self> { - let conf = get_configuration(None).unwrap(); - let ro_pool = if let Ok(url) = std::env::var("RO_DATABASE_URL") { - (bestool_postgres::pool::create_pool(&url, "tamanu-meta-playground").await).ok() + (bestool_postgres::pool::create_pool(&url, "canopy-playground").await).ok() } else { None }; @@ -24,17 +20,13 @@ impl AppState { Ok(Self { db: database::init(), ro_pool, - leptos_options: conf.leptos_options, }) } pub async fn from_db_url(url: &str) -> Result<Self> { - let conf = get_configuration(None).unwrap(); - Ok(Self { db: database::init_to(url), ro_pool: None, - leptos_options: conf.leptos_options, }) } } diff --git a/crates/private-server/tests/artifacts.rs b/crates/private-server/tests/artifacts.rs index 8dc2ebec..d1baabfb 100644 --- a/crates/private-server/tests/artifacts.rs +++ b/crates/private-server/tests/artifacts.rs @@ -32,52 +32,30 @@ async fn artifact_multiple_ranges_pattern_specificity_private_endpoint() { .await .unwrap(); - // Call the private server endpoint for getting artifacts by version ID - // The private server should show ALL matching artifacts, not just the deduplicated public view + // The private detail page calls the same deduplicated view that the + // public API serves: among multiple ranges that match a version, only + // the most specific one wins. let response = private - .post("/api/private_server/fns/versions/get_artifacts_by_version_id") - .form(&[("version_id", version_id_245)]) + .post("/api/versions/get_version_artifacts") + .json(&serde_json::json!({"version": "2.44.5"})) .await; response.assert_status_ok(); let artifacts: Vec<ArtifactData> = response.json(); - // Should have 2 artifacts - both ranges apply to 2.44.5 - assert_eq!(artifacts.len(), 2, "Should have 2 matching artifacts in private view"); - - // Find each artifact - let broader = artifacts.iter().find(|a| a.id.to_string() == broader_range_id.to_lowercase()); - let narrower = artifacts.iter().find(|a| a.id.to_string() == narrower_range_id.to_lowercase()); - - assert!(broader.is_some(), "Should have the 2.44.x range artifact"); - assert!(narrower.is_some(), "Should have the ^2.44.2 range artifact"); - - // Verify both are range artifacts (not exact) - assert!(!broader.unwrap().is_exact, "2.44.x should be a ranged artifact"); - assert!(!narrower.unwrap().is_exact, "^2.44.2 should be a ranged artifact"); - - // Verify they have the correct patterns assert_eq!( - broader.unwrap().version_range_pattern, - Some("2.44.x".to_string()), - "Should have 2.44.x pattern" + artifacts.len(), + 1, + "deduplicated view should keep only the more specific range" ); + let chosen = &artifacts[0]; + assert_eq!(chosen.id.to_string(), narrower_range_id.to_lowercase()); assert_eq!( - narrower.unwrap().version_range_pattern, + chosen.version_range_pattern, Some("^2.44.2".to_string()), - "Should have ^2.44.2 pattern" - ); - - // Verify which one is used in the public API - // The more specific range (^2.44.2) should be used in the public API - assert!( - narrower.unwrap().is_used_in_public_api, - "The more specific range (^2.44.2) should be used in the public API" - ); - assert!( - !broader.unwrap().is_used_in_public_api, - "The less specific range (2.44.x) should NOT be used in the public API" + "the more specific range should be the one returned" ); + assert!(chosen.is_used_in_public_api); }) .await } diff --git a/crates/private-server/tests/devices.rs b/crates/private-server/tests/devices.rs index cc319124..55caf21b 100644 --- a/crates/private-server/tests/devices.rs +++ b/crates/private-server/tests/devices.rs @@ -1052,11 +1052,11 @@ async fn test_update_key_name() { // Update the key name via server function let response = private - .post("/api/private_server/fns/devices/update_key_name") - .form(&[ - ("key_id", key_id.to_string().as_str()), - ("name", "My Test Key"), - ]) + .post("/api/devices/update_key_name") + .json(&serde_json::json!({ + "key_id": key_id.to_string(), + "name": "My Test Key", + })) .await; assert_eq!(response.status_code(), 200); @@ -1069,11 +1069,11 @@ async fn test_update_key_name() { // Update to a different name let response = private - .post("/api/private_server/fns/devices/update_key_name") - .form(&[ - ("key_id", key_id.to_string().as_str()), - ("name", "Renamed Key"), - ]) + .post("/api/devices/update_key_name") + .json(&serde_json::json!({ + "key_id": key_id.to_string(), + "name": "Renamed Key", + })) .await; assert_eq!(response.status_code(), 200); @@ -1084,8 +1084,11 @@ async fn test_update_key_name() { // Update to None (clear the name) let response = private - .post("/api/private_server/fns/devices/update_key_name") - .form(&[("key_id", key_id.to_string().as_str())]) + .post("/api/devices/update_key_name") + .json(&serde_json::json!({ + "key_id": key_id.to_string(), + "name": null, + })) .await; assert_eq!(response.status_code(), 200); diff --git a/crates/private-server/tests/private_statuses.rs b/crates/private-server/tests/private_statuses.rs index ea55165e..3cae16b8 100644 --- a/crates/private-server/tests/private_statuses.rs +++ b/crates/private-server/tests/private_statuses.rs @@ -91,17 +91,20 @@ struct DeviceConnectionData { } #[tokio::test(flavor = "multi_thread")] -async fn status_page_redirect() { +async fn spa_root() { commons_tests::server::run(async |_conn, _, private| { let response = private.get("/").await; - response.assert_status(StatusCode::PERMANENT_REDIRECT); + response.assert_status_ok(); + response.assert_header("content-type", "text/html; charset=utf-8"); }) .await } #[tokio::test(flavor = "multi_thread")] -async fn status_page() { +async fn spa_client_route() { commons_tests::server::run(async |_conn, _, private| { + // Any unmatched path falls back to the SPA index, letting the + // React router handle it client-side. let response = private.get("/status").await; response.assert_status_ok(); response.assert_header("content-type", "text/html; charset=utf-8"); @@ -114,7 +117,8 @@ async fn status_json_empty_database() { commons_tests::server::run(async |_conn, _, private| { // Get server IDs let server_ids_response = private - .post("/api/private_server/fns/statuses/server_grouped_ids") + .post("/api/statuses/server_grouped_ids") + .json(&serde_json::json!({})) .await; server_ids_response.assert_status_ok(); let grouped_ids: std::collections::BTreeMap<String, Vec<String>> = @@ -145,7 +149,7 @@ async fn status_json_basic_server() { .unwrap(); // Get server IDs - let server_ids_response = private.post("/api/private_server/fns/statuses/server_grouped_ids").await; + let server_ids_response = private.post("/api/statuses/server_grouped_ids").json(&serde_json::json!({})).await; server_ids_response.assert_status_ok(); let grouped_ids: std::collections::BTreeMap<String, Vec<String>> = server_ids_response.json(); let server_ids: Vec<String> = grouped_ids.into_values().flatten().collect(); @@ -155,8 +159,8 @@ async fn status_json_basic_server() { // Get server details let details_response = private - .post("/api/private_server/fns/statuses/server_details") - .form(&[("server_id", server_id)]) + .post("/api/statuses/server_details") + .json(&serde_json::json!({"server_id": server_id})) .await; details_response.assert_status_ok(); let details: ServerDetailsResponse = details_response.json(); @@ -191,7 +195,7 @@ async fn status_json_server_with_recent_status() { .unwrap(); // Get server IDs - let server_ids_response = private.post("/api/private_server/fns/statuses/server_grouped_ids").await; + let server_ids_response = private.post("/api/statuses/server_grouped_ids").json(&serde_json::json!({})).await; server_ids_response.assert_status_ok(); let grouped_ids: std::collections::BTreeMap<String, Vec<String>> = server_ids_response.json(); let server_ids: Vec<String> = grouped_ids.into_values().flatten().collect(); @@ -201,8 +205,8 @@ async fn status_json_server_with_recent_status() { // Get server details let details_response = private - .post("/api/private_server/fns/statuses/server_details") - .form(&[("server_id", server_id)]) + .post("/api/statuses/server_details") + .json(&serde_json::json!({"server_id": server_id})) .await; details_response.assert_status_ok(); let details: ServerDetailsResponse = details_response.json(); @@ -238,7 +242,7 @@ async fn status_json_server_status_ages() { .unwrap(); // Get server IDs - let server_ids_response = private.post("/api/private_server/fns/statuses/server_grouped_ids").await; + let server_ids_response = private.post("/api/statuses/server_grouped_ids").json(&serde_json::json!({})).await; server_ids_response.assert_status_ok(); let grouped_ids: std::collections::BTreeMap<String, Vec<String>> = server_ids_response.json(); let server_ids: Vec<String> = grouped_ids.into_values().flatten().collect(); @@ -250,8 +254,8 @@ async fn status_json_server_status_ages() { for server_id in &server_ids { let details_response = private - .post("/api/private_server/fns/statuses/server_details") - .form(&[("server_id", server_id.as_str())]) + .post("/api/statuses/server_details") + .json(&serde_json::json!({"server_id": server_id.as_str()})) .await; details_response.assert_status_ok(); let details: ServerDetailsResponse = details_response.json(); @@ -296,7 +300,7 @@ async fn status_json_platform_detection() { .unwrap(); // Get server IDs - let server_ids_response = private.post("/api/private_server/fns/statuses/server_grouped_ids").await; + let server_ids_response = private.post("/api/statuses/server_grouped_ids").json(&serde_json::json!({})).await; server_ids_response.assert_status_ok(); let grouped_ids: std::collections::BTreeMap<String, Vec<String>> = server_ids_response.json(); let server_ids: Vec<String> = grouped_ids.into_values().flatten().collect(); @@ -309,8 +313,8 @@ async fn status_json_platform_detection() { for server_id in &server_ids { let details_response = private - .post("/api/private_server/fns/statuses/server_details") - .form(&[("server_id", server_id.as_str())]) + .post("/api/statuses/server_details") + .json(&serde_json::json!({"server_id": server_id.as_str()})) .await; details_response.assert_status_ok(); let details: ServerDetailsResponse = details_response.json(); @@ -354,7 +358,7 @@ async fn status_json_mixed_server_ranks() { .unwrap(); // Get server IDs - let server_ids_response = private.post("/api/private_server/fns/statuses/server_grouped_ids").await; + let server_ids_response = private.post("/api/statuses/server_grouped_ids").json(&serde_json::json!({})).await; server_ids_response.assert_status_ok(); let grouped_ids: std::collections::BTreeMap<String, Vec<String>> = server_ids_response.json(); @@ -367,8 +371,8 @@ async fn status_json_mixed_server_ranks() { // Get production server details let production_id = &grouped_ids.get("production").unwrap()[0]; let details_response = private - .post("/api/private_server/fns/statuses/server_details") - .form(&[("server_id", production_id)]) + .post("/api/statuses/server_details") + .json(&serde_json::json!({"server_id": production_id})) .await; details_response.assert_status_ok(); let details: ServerDetailsResponse = details_response.json(); @@ -400,7 +404,7 @@ async fn status_json_unnamed_servers_excluded() { .unwrap(); // Get server IDs - let server_ids_response = private.post("/api/private_server/fns/statuses/server_grouped_ids").await; + let server_ids_response = private.post("/api/statuses/server_grouped_ids").json(&serde_json::json!({})).await; server_ids_response.assert_status_ok(); let grouped_ids: std::collections::BTreeMap<String, Vec<String>> = server_ids_response.json(); let server_ids: Vec<String> = grouped_ids.into_values().flatten().collect(); @@ -408,8 +412,8 @@ async fn status_json_unnamed_servers_excluded() { // Get server details let details_response = private - .post("/api/private_server/fns/statuses/server_details") - .form(&[("server_id", &server_ids[0])]) + .post("/api/statuses/server_details") + .json(&serde_json::json!({"server_id": &server_ids[0]})) .await; details_response.assert_status_ok(); let details: ServerDetailsResponse = details_response.json(); @@ -441,7 +445,7 @@ async fn status_json_blip_status() { .unwrap(); // Get server IDs - let server_ids_response = private.post("/api/private_server/fns/statuses/server_grouped_ids").await; + let server_ids_response = private.post("/api/statuses/server_grouped_ids").json(&serde_json::json!({})).await; server_ids_response.assert_status_ok(); let grouped_ids: std::collections::BTreeMap<String, Vec<String>> = server_ids_response.json(); let server_ids: Vec<String> = grouped_ids.into_values().flatten().collect(); @@ -451,8 +455,8 @@ async fn status_json_blip_status() { // Get server details let details_response = private - .post("/api/private_server/fns/statuses/server_details") - .form(&[("server_id", server_id)]) + .post("/api/statuses/server_details") + .json(&serde_json::json!({"server_id": server_id})) .await; details_response.assert_status_ok(); let details: ServerDetailsResponse = details_response.json(); @@ -483,7 +487,7 @@ async fn status_json_gone_server() { .unwrap(); // Get server IDs - let server_ids_response = private.post("/api/private_server/fns/statuses/server_grouped_ids").await; + let server_ids_response = private.post("/api/statuses/server_grouped_ids").json(&serde_json::json!({})).await; server_ids_response.assert_status_ok(); let grouped_ids: std::collections::BTreeMap<String, Vec<String>> = server_ids_response.json(); let server_ids: Vec<String> = grouped_ids.into_values().flatten().collect(); @@ -493,8 +497,8 @@ async fn status_json_gone_server() { // Get server details let details_response = private - .post("/api/private_server/fns/statuses/server_details") - .form(&[("server_id", server_id)]) + .post("/api/statuses/server_details") + .json(&serde_json::json!({"server_id": server_id})) .await; details_response.assert_status_ok(); let details: ServerDetailsResponse = details_response.json(); @@ -524,8 +528,8 @@ async fn get_detail_basic() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/get_detail") - .form(&[("server_id", "11111111-1111-1111-1111-111111111111")]) + .post("/api/servers/get_detail") + .json(&serde_json::json!({"server_id": "11111111-1111-1111-1111-111111111111"})) .await; response.assert_status_ok(); let detail: ServerDetailResponse = response.json(); @@ -563,8 +567,8 @@ async fn get_detail_with_status() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/get_detail") - .form(&[("server_id", "11111111-1111-1111-1111-111111111111")]) + .post("/api/servers/get_detail") + .json(&serde_json::json!({"server_id": "11111111-1111-1111-1111-111111111111"})) .await; response.assert_status_ok(); let detail: ServerDetailResponse = response.json(); @@ -607,8 +611,8 @@ async fn get_detail_with_device() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/get_detail") - .form(&[("server_id", "11111111-1111-1111-1111-111111111111")]) + .post("/api/servers/get_detail") + .json(&serde_json::json!({"server_id": "11111111-1111-1111-1111-111111111111"})) .await; response.assert_status_ok(); let detail: ServerDetailResponse = response.json(); @@ -634,10 +638,11 @@ async fn get_detail_with_device() { async fn get_detail_not_found() { commons_tests::server::run(async |_conn, _, private| { let response = private - .post("/api/private_server/fns/servers/get_detail") - .form(&[("server_id", "99999999-9999-9999-9999-999999999999")]) + .post("/api/servers/get_detail") + .json(&serde_json::json!({"server_id": "99999999-9999-9999-9999-999999999999"})) .await; - response.assert_status(StatusCode::INTERNAL_SERVER_ERROR); + // AppError maps DatabaseQuery::NotFound to 404 + response.assert_status(StatusCode::NOT_FOUND); }) .await } @@ -646,10 +651,11 @@ async fn get_detail_not_found() { async fn get_detail_invalid_id() { commons_tests::server::run(async |_conn, _, private| { let response = private - .post("/api/private_server/fns/servers/get_detail") - .form(&[("server_id", "not-a-uuid")]) + .post("/api/servers/get_detail") + .json(&serde_json::json!({"server_id": "not-a-uuid"})) .await; - response.assert_status(StatusCode::INTERNAL_SERVER_ERROR); + // axum's Json extractor rejects malformed bodies with 422 + response.assert_status(StatusCode::UNPROCESSABLE_ENTITY); }) .await } @@ -677,7 +683,8 @@ struct FacilityServerCardResponse { async fn server_grouped_ids_empty() { commons_tests::server::run(async |_conn, _, private| { let response = private - .post("/api/private_server/fns/statuses/server_grouped_ids") + .post("/api/statuses/server_grouped_ids") + .json(&serde_json::json!({})) .await; response.assert_status_ok(); @@ -718,7 +725,8 @@ async fn server_grouped_ids_with_data() { .unwrap(); let response = private - .post("/api/private_server/fns/statuses/server_grouped_ids") + .post("/api/statuses/server_grouped_ids") + .json(&serde_json::json!({})) .await; response.assert_status_ok(); @@ -733,8 +741,8 @@ async fn server_grouped_ids_with_data() { // Verify server_details returns correct data for production server let details_response = private - .post("/api/private_server/fns/statuses/server_details") - .form(&[("server_id", "11111111-1111-1111-1111-111111111111")]) + .post("/api/statuses/server_details") + .json(&serde_json::json!({"server_id": "11111111-1111-1111-1111-111111111111"})) .await; details_response.assert_status_ok(); let prod_server: CentralServerCardResponse = details_response.json(); @@ -778,7 +786,8 @@ async fn server_grouped_ids_excludes_unnamed() { .unwrap(); let response = private - .post("/api/private_server/fns/statuses/server_grouped_ids") + .post("/api/statuses/server_grouped_ids") + .json(&serde_json::json!({})) .await; response.assert_status_ok(); diff --git a/crates/private-server/tests/update_server.rs b/crates/private-server/tests/update_server.rs index 533efcae..c048d8eb 100644 --- a/crates/private-server/tests/update_server.rs +++ b/crates/private-server/tests/update_server.rs @@ -18,7 +18,7 @@ async fn update_server_basic_fields() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/update") + .post("/api/servers/update") .json(&json!({ "server_id": "22222222-2222-2222-2222-222222222222", "data": { @@ -49,7 +49,7 @@ async fn update_server_partial_update() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/update") + .post("/api/servers/update") .json(&json!({ "server_id": "33333333-3333-3333-3333-333333333333", "data": { @@ -85,7 +85,7 @@ async fn update_server_device_id() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/update") + .post("/api/servers/update") .json(&json!({ "server_id": "55555555-5555-5555-5555-555555555555", "data": { @@ -114,7 +114,7 @@ async fn update_server_invalid_rank() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/update") + .post("/api/servers/update") .json(&json!({ "server_id": "22222222-2222-2222-2222-222222222222", "data": { @@ -122,7 +122,8 @@ async fn update_server_invalid_rank() { } })) .await; - response.assert_status(StatusCode::INTERNAL_SERVER_ERROR); + // axum's Json extractor rejects unknown enum variants with 422 + response.assert_status(StatusCode::UNPROCESSABLE_ENTITY); }) .await } @@ -135,7 +136,7 @@ async fn update_server_not_found() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/update") + .post("/api/servers/update") .json(&json!({ "server_id": "77777777-7777-7777-7777-777777777777", "data": {} @@ -162,7 +163,7 @@ async fn update_server_parent_id() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/update") + .post("/api/servers/update") .json(&json!({ "server_id": "99999999-9999-9999-9999-999999999999", "data": { @@ -197,7 +198,7 @@ async fn update_server_clear_parent_id() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/update") + .post("/api/servers/update") .json(&json!({ "server_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "data": { @@ -232,7 +233,7 @@ async fn search_parent_by_uuid() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/search_parent") + .post("/api/servers/search_parent") .json(&json!({ "query": "cccccccc-cccc-cccc-cccc-cccccccccccc", "current_server_id": "dddddddd-dddd-dddd-dddd-dddddddddddd", @@ -266,7 +267,7 @@ async fn search_parent_by_name() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/search_parent") + .post("/api/servers/search_parent") .json(&json!({ "query": "Searchable", "current_server_id": "ffffffff-ffff-ffff-ffff-ffffffffffff", @@ -300,7 +301,7 @@ async fn search_parent_ordering_same_rank_first() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/search_parent") + .post("/api/servers/search_parent") .json(&json!({ "query": "Server", "current_server_id": "33333333-3333-3333-3333-333333333333", @@ -334,7 +335,7 @@ async fn search_parent_ordering_same_kind_last() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/search_parent") + .post("/api/servers/search_parent") .json(&json!({ "query": "Server", "current_server_id": "66666666-6666-6666-6666-666666666666", @@ -367,7 +368,7 @@ async fn search_parent_excludes_current_server() { .unwrap(); let response = private - .post("/api/private_server/fns/servers/search_parent") + .post("/api/servers/search_parent") .json(&json!({ "query": "Current", "current_server_id": "77777777-7777-7777-7777-777777777777", @@ -407,7 +408,7 @@ async fn update_server_preserves_device_id_when_not_provided() { // Update server without providing device_id in the update data let response = private - .post("/api/private_server/fns/servers/update") + .post("/api/servers/update") .json(&json!({ "server_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "data": { @@ -455,7 +456,7 @@ async fn update_server_clears_device_id_with_null() { // Update server with device_id explicitly set to null let response = private - .post("/api/private_server/fns/servers/update") + .post("/api/servers/update") .json(&json!({ "server_id": "dddddddd-dddd-dddd-dddd-dddddddddddd", "data": { @@ -504,7 +505,7 @@ async fn update_server_sets_new_device_id() { // Update server with a new device_id let response = private - .post("/api/private_server/fns/servers/update") + .post("/api/servers/update") .json(&json!({ "server_id": "11111111-1111-1111-1111-111111111111", "data": { diff --git a/crates/private-server/tests/versions.rs b/crates/private-server/tests/versions.rs index 43c9db21..e3492f27 100644 --- a/crates/private-server/tests/versions.rs +++ b/crates/private-server/tests/versions.rs @@ -21,7 +21,8 @@ async fn test_get_grouped_versions() { // Test the server function endpoint let response = private - .post("/api/private_server/fns/versions/get_grouped_versions") + .post("/api/versions/get_grouped_versions") + .json(&serde_json::json!({})) .await; assert_eq!(response.status_code(), 200); diff --git a/crates/public-server/Cargo.toml b/crates/public-server/Cargo.toml index 1a73835c..fbef708f 100644 --- a/crates/public-server/Cargo.toml +++ b/crates/public-server/Cargo.toml @@ -45,7 +45,7 @@ tower-http = { version = "0.6.6", optional = true, features = [ "fs", "trace", ] } -uuid = { version = "1.18.1", features = ["serde", "v4"] } +uuid = { version = "1.23.1", features = ["serde", "v4"] } serde_json = "1.0.145" axum-client-ip = { version = "1.1.3", optional = true } subtle = { version = "2.6", optional = true } diff --git a/crates/public-server/release.toml b/crates/public-server/release.toml index 3a70076d..95dcdc33 100644 --- a/crates/public-server/release.toml +++ b/crates/public-server/release.toml @@ -1,4 +1,4 @@ pre-release-hook = ["git", "cliff", "-c", "../../cliff.toml", "-o", "../../CHANGELOG.md", "--tag", "{{version}}"] pre-release-replacements = [ - { file = "../../README.md", search = "tamanu-meta:[0-9\\.]+", replace = "tamanu-meta:{{version}}"}, + { file = "../../README.md", search = "canopy:[0-9\\.]+", replace = "canopy:{{version}}"}, ] diff --git a/crates/public-server/src/lib.rs b/crates/public-server/src/lib.rs index 0ae85922..c8413455 100644 --- a/crates/public-server/src/lib.rs +++ b/crates/public-server/src/lib.rs @@ -151,7 +151,7 @@ async fn index( #[cfg(feature = "ui")] async fn error(axum::extract::Path(slug): axum::extract::Path<String>) -> axum::response::Redirect { axum::response::Redirect::temporary(&format!( - "https://github.com/beyondessential/tamanu-meta-server/blob/{version}/ERRORS.md#{slug}", + "https://github.com/beyondessential/canopy/blob/{version}/ERRORS.md#{slug}", version = env!("CARGO_PKG_VERSION") )) } diff --git a/crates/public-server/templates/artifacts.html.tera b/crates/public-server/templates/artifacts.html.tera index fb782b25..46e52d98 100644 --- a/crates/public-server/templates/artifacts.html.tera +++ b/crates/public-server/templates/artifacts.html.tera @@ -4,8 +4,7 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg" /> - <link rel="stylesheet" href="/static/bulma/bulma.min.css"> - <link rel="stylesheet" href="/static/private/main.css"> + <link rel="stylesheet" href="/static/public.css"> <title>Tamanu {{ version.major }}.{{ version.minor }}.{{ version.patch }} — Artifacts - - - diff --git a/static/images/favicon.svg b/static/images/favicon.svg new file mode 120000 index 00000000..bd507e36 --- /dev/null +++ b/static/images/favicon.svg @@ -0,0 +1 @@ +../../private-web/public/favicon.svg \ No newline at end of file diff --git a/static/private/admin.css b/static/private/admin.css deleted file mode 100644 index e69de29b..00000000 diff --git a/static/private/deployment.css b/static/private/deployment.css deleted file mode 100644 index f1d75c0b..00000000 --- a/static/private/deployment.css +++ /dev/null @@ -1,330 +0,0 @@ -#status-detail-page { - max-width: 1200px; - margin: 0 auto; - padding: 2em; - - .page-header { - margin-bottom: 2em; - display: flex; - align-items: first baseline; - justify-content: space-between; - flex-wrap: wrap; - gap: 1em; - - .header-top { - margin-bottom: 1em; - } - - .back-link { - color: var(--color-primary); - text-decoration: none; - font-size: 0.9em; - display: inline-flex; - align-items: center; - gap: 0.25em; - - &:hover { - text-decoration: underline; - } - } - - h1 { - font-size: 2.5em; - color: var(--color-text-primary); - margin-bottom: 0.25em; - font-weight: 500; - display: flex; - align-items: center; - gap: 0.5em; - } - - .status-dot { - width: 0.4em; - height: 0.4em; - border-radius: 50%; - display: inline-block; - flex-shrink: 0; - - &.up { - background: var(--color-success-status); - } - - &.down { - background: var(--color-error); - } - - &.gone { - background: var(--color-status-gone); - } - - &.away { - background: var(--color-warning-bg); - } - - &.blip { - background: var(--color-warning-light-bg); - } - } - - .server-meta { - display: flex; - gap: 0.75em; - margin-top: 0.5em; - } - - .server-rank, - .server-kind { - padding: 0.25em 0.75em; - border-radius: 4px; - font-size: 0.85em; - font-weight: 500; - text-transform: capitalize; - } - - .server-rank { - border: 1px solid var(--color-primary-light); - color: var(--color-primary-dark); - } - - .server-kind { - background: var(--color-bg-light); - color: var(--color-text-secondary); - border: 1px solid var(--color-border-light); - } - - .edit-button { - padding: 0.5em 1.25em; - background: var(--color-primary); - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 0.9em; - font-weight: 500; - transition: background 0.2s; - order: -1; - - &:hover { - background: var(--color-primary-dark); - } - } - } - - .detail-section { - background: var(--color-bg-white); - border: 1px solid var(--color-border-light); - border-radius: 8px; - padding: 1.5em; - margin-bottom: 1.5em; - - h2 { - font-size: 1.25em; - color: var(--color-text-primary); - margin-bottom: 1em; - font-weight: 500; - border-bottom: 2px solid var(--color-border-light); - padding-bottom: 0.5em; - } - - .info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 1.25em; - } - - .device-list-container { - margin-top: 1em; - border-top: 1px solid var(--color-border-light); - - h3 { - font-size: 1.1em; - color: var(--color-text-secondary); - font-weight: normal; - } - - .role-badge { - display: none; - } - } - - .info-item { - display: flex; - flex-direction: column; - gap: 0.25em; - - .info-label { - font-size: 0.85em; - color: var(--color-text-light); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .info-value { - color: var(--color-text-primary); - font-size: 1em; - word-break: break-all; - - a { - color: var(--color-primary); - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - - &.monospace { - font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; - font-size: 0.9em; - } - } - } - - .extra-data { - margin-top: 1.5em; - - summary { - cursor: pointer; - font-weight: 500; - color: var(--color-text-secondary); - padding: 0.5em; - border-radius: 4px; - user-select: none; - - &:hover { - background: var(--color-bg-light); - } - } - - .json-display { - margin-top: 1em; - padding: 1em; - background: var(--color-bg-light); - border: 1px solid var(--color-border-light); - border-radius: 4px; - overflow-x: auto; - font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; - font-size: 0.85em; - color: var(--color-text-primary); - } - } - } - - .error-container { - .error-message { - background: var(--color-error-bg); - border: 1px solid var(--color-error); - border-radius: 8px; - padding: 1.5em; - color: var(--color-error); - margin-top: 1em; - } - } - - .loading { - padding: 2em; - text-align: center; - color: var(--color-text-light); - font-style: italic; - } - - .monospace { - font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; - } - - .edit-form { - .form-group { - margin-bottom: 1.5em; - - label { - display: block; - font-size: 0.9em; - color: var(--color-text-secondary); - font-weight: 500; - margin-bottom: 0.5em; - } - - input[type="text"], - input[type="url"], - select { - width: 100%; - padding: 0.75em; - border: 1px solid var(--color-border-light); - border-radius: 4px; - font-size: 1em; - font-family: inherit; - background: white; - transition: border-color 0.2s; - - &:focus { - outline: none; - border-color: var(--color-primary); - } - } - - select { - cursor: pointer; - } - - .help-text { - display: block; - margin-top: 0.5em; - font-size: 0.85em; - color: var(--color-text-light); - } - } - - .error-message { - background: var(--color-error-bg); - border: 1px solid var(--color-error); - border-radius: 4px; - padding: 1em; - color: var(--color-error); - margin-bottom: 1em; - font-size: 0.9em; - } - - .form-actions { - display: flex; - gap: 1em; - margin-top: 2em; - padding-top: 1.5em; - border-top: 1px solid var(--color-border-light); - - button { - padding: 0.75em 1.5em; - border: none; - border-radius: 4px; - font-size: 1em; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } - - .save-button { - background: var(--color-primary); - color: white; - - &:hover:not(:disabled) { - background: var(--color-primary-dark); - } - } - - .cancel-button { - background: var(--color-bg-light); - color: var(--color-text-secondary); - border: 1px solid var(--color-border-light); - - &:hover:not(:disabled) { - background: var(--color-border-light); - } - } - } - } -} diff --git a/static/private/devices.css b/static/private/devices.css deleted file mode 100644 index b1b710de..00000000 --- a/static/private/devices.css +++ /dev/null @@ -1,3 +0,0 @@ -.history-times { - white-space: preserve-spaces; -} diff --git a/static/private/main.css b/static/private/main.css deleted file mode 100644 index 40740315..00000000 --- a/static/private/main.css +++ /dev/null @@ -1,277 +0,0 @@ -@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"); -@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;500;600;700&display=swap"); - -:root { - /* Primary colors */ - --color-primary: oklch(0.7 0.15 240); - --color-primary-hover: oklch(0.6 0.15 240); - --color-primary-light: oklch(0.85 0.1 240); - --color-primary-dark: oklch(0.45 0.1 240); - --color-primary-focus: oklch(0.7 0.15 240 / 0.25); - - /* Background colors */ - --color-bg-body: oklch(0.97 0.01 240); - --color-bg-white: oklch(1 0 0); - --color-bg-light: oklch(0.99 0.005 240); - --color-bg-lighter: oklch(0.98 0.005 240); - --color-bg-medium: oklch(0.95 0.005 240); - --color-bg-medium-dark: oklch(0.92 0.005 240); - - /* Text colors */ - --color-text-primary: oklch(0.3 0.1 240); - --color-text-secondary: oklch(0.4 0.08 240); - --color-text-tertiary: oklch(0.45 0.05 240); - --color-text-muted: oklch(0.5 0.05 240); - --color-text-light: oklch(0.55 0.02 240); - --color-text-dark: oklch(0.35 0.08 240); - - /* Border colors */ - --color-border-light: oklch(0.9 0.01 240); - --color-border-medium: oklch(0.92 0.01 240); - --color-border-dark: oklch(0.85 0.01 240); - --color-border-darker: oklch(0.8 0.01 240); - --color-border-variant: oklch(0.88 0.01 240); - - /* Success/Green colors */ - --color-success-bg: oklch(0.9 0.1 140); - --color-success-border: oklch(0.8 0.1 140); - --color-success-text: oklch(0.3 0.15 140); - --color-success: oklch(0.7 0.15 140); - --color-success-hover: oklch(0.6 0.15 140); - --color-success-status: oklch(0.65 0.2 140); - - /* Error/Red colors */ - --color-error-bg: oklch(0.9 0.1 20); - --color-error-border: oklch(0.8 0.1 20); - --color-error-text: oklch(0.3 0.15 20); - --color-error: oklch(0.6 0.2 20); - --color-error-hover: oklch(0.5 0.2 20); - --color-error-dark: oklch(0.25 0.15 20); - --color-error-light-bg: oklch(0.85 0.1 20); - --color-error-light-border: oklch(0.7 0.1 20); - --color-error-light-border-hover: oklch(0.6 0.1 20); - --color-error-light-hover: oklch(0.8 0.1 20); - --color-error-confirm: oklch(0.6 0.15 20); - --color-error-confirm-hover: oklch(0.5 0.15 20); - - /* Warning/Orange colors */ - --color-warning: oklch(0.7 0.35 90); - --color-warning-bg: oklch(0.85 0.15 90); - --color-warning-text: oklch(0.2 0.1 240); - --color-warning-light-bg: oklch(0.9 0.1 85); - - /* Status colors */ - --color-status-gone: oklch(0.2 0.02 240); - - /* Disabled colors */ - --color-disabled: oklch(0.5 0.02 240); - - /* Shadow colors */ - --color-shadow: oklch(0 0 0 / 0.05); -} - -:root { - --bulma-body-background-color: oklch(0.97 0.01 240); - --bulma-shadow: 0 0 1px 0; /* essentially just a border */ - --bulma-radius-large: 0.3rem; -} - -#global-nav { - border-bottom: 2px solid var(--color-primary); - - .navbar-item[aria-current="page"]:not(:has(.logo)) { - background-color: var(--color-primary-focus); - } - - .logo { - width: 7em; - } - - /* .is-active is only present on mobile when the menu is opened */ - .navbar-menu.is-active .server-versions br { - display: none; - } - .navbar-menu:not(.is-active) .server-versions { - font-size: 0.7em; - text-align: center; - padding: 0.25em 1em; - } -} - -.monospace, -.version, -.version-text, -pre, -code { - font-family: - "Fira Code", "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", - Consolas, "Courier New", monospace; -} - -#sub-tabs { - & > nav { - border-top: 2px solid var(--color-border-light); - - .navbar-item[aria-current="page"] { - background-color: var(--color-primary-focus); - } - } - - & > section { - .section-description { - color: var(--color-text-light); - margin-bottom: 1.5em; - font-size: 1em; - } - } - - .navbar-burger { - /* position on the left instead as visual contrast to the top nav */ - margin-inline-start: 0.375rem; - margin-inline-end: auto; - } -} - -.time-ago { - white-space: pre; - text-decoration: underline dotted; -} - -.status-dot, -.version-indicator, -.tag { - margin-right: 0.5em; -} - -.status-dot { - width: 1em; - height: 1em; - border-radius: 50%; - display: inline-block; - - &.up { - background: var(--color-success-status); - } - - &.down { - background: var(--color-error); - } - - &.gone { - background: var(--color-status-gone); - } - - &.away { - background: var(--color-warning); - } - - &.blip { - background: var(--color-primary); - } - - &.facility-dot { - opacity: 0.5; - } -} - -.status-dot-small .status-dot, -.version-indicator-small .version-indicator { - font-size: 0.75em; -} - -.version-indicator { - width: 1em; - height: 1em; - border-radius: 0.15rem; - display: inline-block; - - &.version-up-to-date { - background: var(--color-success-status); - } - - &.version-okay { - background: var(--color-primary); - } - - &.version-outdated { - background: var(--color-warning); - } - - &.version-very-outdated { - background: var(--color-error); - } - - &.version-unknown { - background: var(--color-text-light); - } -} - -.version-display { - display: flex; - align-items: end; - gap: 0.3em; - - .version-text { - line-height: 0.9; - } -} - -aside.legend { - margin-top: 2.5em; - - p:not(:last-child) { - margin-bottom: 0.5em; - } - - .legend-item { - white-space: pre; - - &:not(:last-child) { - margin-right: 1em; - } - } - - .legend-label { - font-size: 0.85em; - vertical-align: top; - } -} - -.info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 1.25em; - - &.narrow-cells { - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - } - - .info-item { - display: flex; - flex-direction: column; - gap: 0.25em; - - .info-label { - font-size: 0.85em; - color: var(--color-text-light); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .info-value { - color: var(--color-text-primary); - font-size: 1em; - word-break: break-all; - - a { - color: var(--color-primary); - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - } - } -} diff --git a/static/private/servers.css b/static/private/servers.css deleted file mode 100644 index 65216d9c..00000000 --- a/static/private/servers.css +++ /dev/null @@ -1,9 +0,0 @@ -.child-server.box { - display: flex; - flex-direction: row; - align-items: center; - - .level { - flex-grow: 1; - } -} diff --git a/static/private/sql.css b/static/private/sql.css deleted file mode 100644 index e69de29b..00000000 diff --git a/static/private/status.css b/static/private/status.css deleted file mode 100644 index 1be33c16..00000000 --- a/static/private/status.css +++ /dev/null @@ -1,61 +0,0 @@ -#status-page { - section { - h2 { - color: var(--color-text-primary); - font-size: 1.25em; - font-weight: 500; - margin: 1em 0 0.5em; - text-transform: capitalize; - } - } - - .server-card { - margin-bottom: 0; - max-width: 18.5rem; - min-height: 4em; - padding: 0.5em; - - display: grid; - grid-template-areas: - "host name version" - "status status status"; - grid-template-rows: auto auto; - grid-template-columns: auto 1fr auto; - gap: 0.5em; - align-items: baseline; - - .host-link { - grid-area: host; - color: var(--color-text-secondary); - font-size: 1em; - text-decoration: none; - - &:hover { - filter: invert(); - } - } - - .server-name { - grid-area: name; - color: var(--color-text-primary); - font-size: 1.1em; - font-weight: 500; - min-width: 0; - overflow: hidden; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; - } - - .version-container { - grid-area: version; - color: var(--color-text-secondary); - } - - .status-dots { - grid-area: status; - font-size: 1.2em; - display: flex; - } - } -} diff --git a/static/private/versions.css b/static/private/versions.css deleted file mode 100644 index 2835bf66..00000000 --- a/static/private/versions.css +++ /dev/null @@ -1,30 +0,0 @@ -.group-version { - font-size: 1.2em; - font-weight: bold; - color: var(--color-text-primary); - .version-patch { - color: var(--color-text-light); - } -} - -.grouped-version { - font-size: 1.1em; - font-weight: bold; - color: var(--color-text-light); - .version-patch { - color: var(--color-text-primary); - } -} - -details.minor-version-group { - cursor: pointer; - - &:hover, - &[open] { - background-color: var(--color-bg-medium); - } - - &:not([open]) summary { - margin-bottom: 0; - } -} diff --git a/static/public.css b/static/public.css new file mode 100644 index 00000000..e6582773 --- /dev/null +++ b/static/public.css @@ -0,0 +1,485 @@ +@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap"); + +:root { + /* Palette mirrors private-web's MUI theme: leaf-green primary, sky-blue + secondary, MUI light-mode neutrals for everything else. */ + --text: rgba(0, 0, 0, 0.87); + --text-muted: rgba(0, 0, 0, 0.6); + --text-light: rgba(0, 0, 0, 0.45); + --bg-body: #fafafa; + --bg-card: #ffffff; + --bg-light: #f0f0f0; + --bg-medium: #e6e6e6; + --border: rgba(0, 0, 0, 0.12); + --border-strong: rgba(0, 0, 0, 0.23); + --primary: #388e3c; + --primary-hover: #2e7d32; + --primary-text: #ffffff; + --info: #32669a; + --info-hover: #275380; + --info-text: #ffffff; + --info-light: #e1ecf5; + --success: #2e7d32; + --success-text: #ffffff; + --warning-light: #fff4e0; + --radius: 4px; + --radius-large: 0.3rem; + --shadow: 0 0 0 1px rgba(0, 0, 0, 0.08); +} + +* { + box-sizing: border-box; +} + +html, body, p, ol, ul, li, dl, dt, dd, blockquote, figure, fieldset, legend, +textarea, pre, iframe, hr, h1, h2, h3, h4, h5, h6 { + margin: 0; + padding: 0; +} + +ul, ol { list-style: none; } + +html { + font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 16px; + line-height: 1.5; + color: var(--text); +} + +body { + background: var(--bg-body); +} + +a { + color: var(--info); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.monospace, +.version-text, +pre, +code { + font-family: "Fira Code", "SF Mono", Monaco, "Cascadia Code", Consolas, monospace; +} + +/* Hero */ +.hero { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.hero.is-light { + background-color: var(--bg-light); + color: var(--text); +} + +.hero-body { + flex-grow: 1; + flex-shrink: 0; + padding: 3rem 1.5rem; +} + +/* Section */ +.section { + padding: 3rem 1.5rem; +} + +/* Container */ +.container { + flex-grow: 1; + margin: 0 auto; + position: relative; + width: auto; +} + +/* Box */ +.box { + background-color: var(--bg-card); + border-radius: var(--radius-large); + box-shadow: var(--shadow); + color: var(--text); + display: block; + padding: 1.25rem; +} + +/* Title / subtitle */ +.title, +.subtitle { + word-break: break-word; +} + +.title { + color: var(--text); + font-size: 2rem; + font-weight: 600; + line-height: 1.125; + margin: 0 0 1.5rem; +} + +.title.is-4 { + font-size: 1.5rem; +} + +.title.is-5 { + font-size: 1.25rem; +} + +.subtitle { + color: var(--text-muted); + font-size: 1.25rem; + font-weight: 400; + line-height: 1.25; + margin: 0 0 1.5rem; +} + +.is-size-5 { font-size: 1.25rem; } +.is-size-7 { font-size: 0.75rem; } + +/* Text utilities */ +.has-text-grey { color: var(--text-light); } +.has-text-grey-light { color: rgba(0, 0, 0, 0.38); } +.has-text-centered { text-align: center; } +.has-text-weight-bold { font-weight: 700; } + +/* Background utilities */ +.has-background-warning-light { background-color: var(--warning-light); } +.has-background-info-light { background-color: var(--info-light); } + +/* Spacing utilities */ +.mb-2 { margin-bottom: 0.5rem !important; } +.mb-3 { margin-bottom: 0.75rem !important; } +.mb-4 { margin-bottom: 1rem !important; } +.mb-5 { margin-bottom: 1.5rem !important; } +.mt-5 { margin-top: 1.5rem !important; } + +/* Level (flex bar) */ +.level { + align-items: center; + display: flex; + justify-content: space-between; +} + +.level-left, +.level-right { + align-items: center; + display: flex; + flex-basis: auto; + flex-grow: 0; + flex-shrink: 0; +} + +.level-right { justify-content: flex-end; } + +.level-item { + align-items: center; + display: flex; + flex-basis: auto; + flex-grow: 0; + flex-shrink: 0; + justify-content: center; +} + +.level-item:not(:last-child) { margin-right: 0.75rem; } + +@media (max-width: 768px) { + .level { + flex-direction: column; + align-items: stretch; + } + .level-left, + .level-right { + justify-content: flex-start; + } +} + +/* Buttons */ +.button { + -webkit-appearance: none; + align-items: center; + background-color: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; + display: inline-flex; + font-family: inherit; + font-size: 1rem; + height: 2.5em; + justify-content: center; + line-height: 1.5; + padding: 0.5em 1em; + text-decoration: none; + white-space: nowrap; + transition: background-color 0.1s, border-color 0.1s; +} + +.button:hover { + border-color: var(--border-strong); + color: var(--text); + text-decoration: none; +} + +.button.is-primary { + background-color: var(--primary); + border-color: transparent; + color: var(--primary-text); +} + +.button.is-primary:hover { + background-color: var(--primary-hover); + color: var(--primary-text); +} + +.button.is-info { + background-color: var(--info); + border-color: transparent; + color: var(--info-text); +} + +.button.is-info:hover { + background-color: var(--info-hover); + color: var(--info-text); +} + +.button.is-light { + background-color: var(--bg-light); + border-color: transparent; + color: var(--text); +} + +.button.is-light:hover { + background-color: var(--bg-medium); +} + +/* Notification */ +.notification { + background-color: var(--bg-light); + border-radius: var(--radius); + padding: 1.25rem 2.5rem 1.25rem 1.5rem; + position: relative; +} + +.notification.is-success { + background-color: var(--success); + color: var(--success-text); +} + +/* Content (typographic flow) */ +.content :where(h1, h2, h3, h4, h5, h6) { + color: var(--text); + font-weight: 600; + line-height: 1.125; + margin-top: 1em; + margin-bottom: 0.5em; +} + +.content h1 { font-size: 2em; } +.content h2 { font-size: 1.5em; } +.content h3 { font-size: 1.25em; } +.content h4 { font-size: 1.125em; } + +.content p:not(:last-child), +.content ul:not(:last-child), +.content ol:not(:last-child) { + margin-bottom: 1em; +} + +.content ul { list-style: disc outside; margin-left: 2em; } +.content ol { list-style: decimal outside; margin-left: 2em; } +.content li + li { margin-top: 0.25em; } + +.content pre { + background-color: var(--bg-light); + border-radius: var(--radius); + overflow-x: auto; + padding: 1em; +} + +.content code { + background-color: var(--bg-light); + border-radius: var(--radius); + padding: 0.1em 0.4em; + font-size: 0.9em; +} + +/* Forms */ +.field:not(:last-child) { + margin-bottom: 0.75rem; +} + +.field.has-addons { + display: flex; + justify-content: flex-start; +} + +.field.has-addons .control:not(:last-child) { + margin-right: -1px; +} + +.field.has-addons .control:not(:first-child):not(:last-child) .input { + border-radius: 0; +} + +.field.has-addons .control:first-child .input { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} + +.field.has-addons .control:last-child .button, +.field.has-addons .control:last-child .input { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} + +.field.is-grouped { + display: flex; + justify-content: flex-start; +} + +.field.is-grouped > .control:not(:last-child) { + margin-right: 0.75rem; +} + +.label { + color: var(--text); + display: block; + font-size: 1rem; + font-weight: 700; + margin-bottom: 0.5em; +} + +.control { + box-sizing: border-box; + clear: both; + font-size: 1rem; + position: relative; + text-align: inherit; +} + +.control.is-expanded { + flex-grow: 1; + flex-shrink: 1; +} + +.input { + background-color: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: inherit; + font-size: 1rem; + height: 2.5em; + line-height: 1.5; + padding: 0.5em 0.75em; + width: 100%; +} + +.input:focus { + border-color: var(--info); + outline: none; +} + +.select { + display: inline-block; + height: 2.5em; + position: relative; + vertical-align: top; +} + +.select.is-fullwidth { width: 100%; } +.select.is-fullwidth select { width: 100%; } + +.select select { + background-color: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: inherit; + font-size: 1rem; + height: 2.5em; + padding: 0.5em 2.5em 0.5em 0.75em; + -webkit-appearance: none; + appearance: none; + cursor: pointer; +} + +.select::after { + border: 2px solid var(--info); + border-right: 0; + border-top: 0; + content: ""; + display: block; + height: 0.5em; + pointer-events: none; + position: absolute; + top: 50%; + right: 1em; + width: 0.5em; + transform: translateY(-75%) rotate(-45deg); + transform-origin: center; +} + +.checkbox { + cursor: pointer; + display: inline-block; + line-height: 1.25; + position: relative; +} + +.checkbox input { vertical-align: baseline; } + +/* Versions page (groups of minor versions) */ +.group-version { + font-size: 1.2em; + font-weight: bold; + color: var(--text); +} + +.group-version .version-patch, +.grouped-version .version-minor { + color: var(--text-light); +} + +.grouped-version { + font-size: 1.1em; + font-weight: bold; + color: var(--text-light); +} + +.grouped-version .version-patch { + color: var(--text); +} + +details.minor-version-group { + cursor: pointer; +} + +details.minor-version-group + details.minor-version-group { + margin-top: 0.75rem; +} + +details.minor-version-group:hover, +details.minor-version-group[open] { + background-color: var(--bg-medium); +} + +details.minor-version-group[open] summary { + margin-bottom: 1rem; +} + +.minor-version { + color: var(--text); + display: flex; + margin-top: 0.5rem; +} + +.minor-version:hover { + background-color: var(--bg-light); + text-decoration: none; +}