diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc5b5ff --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# ─── Copy to .env for local dev. NEVER commit real values. ─────────────────────── +NODE_ENV=development +PORT=3000 +HOST=127.0.0.1 +# web | worker | all +APP_ROLE=all + +# Postgres (Azure: use Entra auth via managed identity, see docs/deploy.md) +DATABASE_URL=postgres://dashflow:dashflow@localhost:5432/dashflow + +# easyauth (Azure Container Apps) | dev (local only, refuses to run in production) +AUTH_MODE=dev +# Dev auth refuses any HOST but localhost unless you set this (containers need 0.0.0.0). +DEV_AUTH_ALLOW_REMOTE=false +DEV_USER_EMAIL=dev.user@contoso.onmicrosoft.com +DEV_USER_OID=00000000-0000-0000-0000-000000000001 +DEV_USER_TID=00000000-0000-0000-0000-000000000002 + +# Comma-separated home tenant IDs allowed to sign in (empty = any tenant EasyAuth lets through) +ALLOWED_TENANT_IDS= +# Verify X-MS-TOKEN-AAD-ID-TOKEN signature against Entra JWKS (requires token store) +EASYAUTH_VALIDATE_TOKEN=false +EASYAUTH_CLIENT_ID= +# Comma-separated Entra object IDs that become Owner without the setup code +BOOTSTRAP_OWNER_OIDS= + +# 32-byte base64 key for encrypting secrets stored in Postgres: `openssl rand -base64 32` +APP_ENCRYPTION_KEY= +# Optional: Key Vault used for secret references (https://.vault.azure.net) +KEY_VAULT_URI= +# Client ID of the user-assigned managed identity (Azure only) +AZURE_CLIENT_ID= + +# Public base URL, used for OAuth redirect (Partner Center consent) +PUBLIC_BASE_URL=http://localhost:3000 + +# Load synthetic data and allow demo tenants +DEMO_MODE=true +LOG_LEVEL=info diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b18b3ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,20 @@ +--- +name: Bug report +about: Something is broken +labels: bug +--- + +**What happened** + +**What you expected** + +**How to reproduce** + +**Environment** +- Deployment: Container Apps / Docker / other +- Connection type: Azure / Lighthouse / Partner Center / Nerdio / demo +- Image tag or commit: + +**Logs** + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a263604 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: Try the live demo + url: https://bg-red.github.io/dashflow/ + about: See what DashFlow does before installing it + - name: Security issue + url: https://github.com/BG-Red/dashflow/security/advisories/new + about: Report a vulnerability privately, not as a public issue + - name: Question or setup help + url: https://github.com/BG-Red/dashflow/blob/main/SUPPORT.md + about: Connection diagnostics, permissions and troubleshooting diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..955721c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Something DashFlow should do +labels: enhancement +--- + +**What do you need to know or do?** + + +**How do you answer it today?** + +**Which connection types does this apply to?** + + +**Anything else** + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9204554 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: { interval: weekly } + open-pull-requests-limit: 5 + groups: + dev-tooling: + patterns: ["@biomejs/*", "typescript", "vite", "@vitejs/*", "lefthook", "drizzle-kit"] + react: + patterns: ["react", "react-dom", "@types/react", "@types/react-dom"] + - package-ecosystem: github-actions + directory: "/" + schedule: { interval: weekly } + - package-ecosystem: docker + directory: "/" + schedule: { interval: weekly } diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2b45607 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## What this changes + + + +## How it was verified + + + +- [ ] `bun test` (with `TEST_DATABASE_URL` for the SQL and parity suites) +- [ ] `bunx playwright test` +- [ ] `bun run lint && bun run typecheck` +- [ ] Clicked through the change in the running app + +## Checklist + +- [ ] No tenant IDs, subscription IDs, real domains or user names anywhere in the diff +- [ ] New metrics are in the catalog, not ad-hoc SQL +- [ ] Docs updated if behaviour or configuration changed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..13a2586 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: dashflow + POSTGRES_PASSWORD: dashflow + POSTGRES_DB: dashflow_test + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U dashflow" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - name: Lint + run: bun run lint + - name: Typecheck (server and packages) + run: bun run typecheck + - name: Typecheck (web) + run: bunx tsc --noEmit -p apps/web/tsconfig.json + - name: Test + run: bun run test + env: + TEST_DATABASE_URL: postgres://dashflow:dashflow@localhost:5432/dashflow_test + - name: Build web + run: bun run build + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - name: Install the browser + run: bunx playwright install --with-deps chromium + - name: End-to-end (demo build, no database) + run: bunx playwright test + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report + retention-days: 7 + + secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Scan history for secrets and tenant identifiers + uses: gitleaks/gitleaks-action@v2 + env: + GITLEAKS_CONFIG: .gitleaks.toml + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build the container image + uses: docker/build-push-action@v6 + with: + context: . + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + tags: dashflow:ci + + bicep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate the deployment template + run: az bicep build --file infra/main.bicep --stdout > /dev/null diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..481d41e --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,45 @@ +name: Demo + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - name: Build the demo bundle + # The demo runs entirely in the browser: the same UI, a synthetic estate, no server. + run: bun run build:demo + env: + DEMO_BASE: /dashflow/ + - name: Single-page fallback for deep links + run: cp apps/web/dist-demo/index.html apps/web/dist-demo/404.html + - uses: actions/upload-pages-artifact@v3 + with: + path: apps/web/dist-demo + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - id: deploy + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e1f61a3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + packages: write + id-token: write + attestations: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@v5 + with: + # Pinned rather than ${{ github.repository }}: GHCR lowercases image names, so a + # repository with capitals would publish to a path the docs do not mention. + images: ghcr.io/bg-red/dashflow + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + - id: build + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + sbom: true + provenance: mode=max + - name: Attest the build + uses: actions/attest-build-provenance@v2 + with: + subject-name: ghcr.io/bg-red/dashflow + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7aaf48c --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# deps / build +node_modules/ +dist/ +dist-demo/ +*.tsbuildinfo +coverage/ +playwright-report/ +test-results/ + +# secrets — never commit these +.env +.env.* +!.env.example +*.pem +*.pfx +*.key +*.crt +*.p12 +secrets/ +.azure/ +infra/*.parameters.local.json +infra/**/*.local.bicepparam + +# data dumps / exports that could contain tenant data +*.dump +*.sql.gz +exports/ +fixtures/raw/ +.pgdata/ + +# OS / editor +.DS_Store +.idea/ +.vscode/* +!.vscode/extensions.json diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..4811492 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,56 @@ +title = "DashFlow gitleaks config" + +# Beyond the usual credential rules, this config fails on values that identify a real +# customer: tenant / subscription GUIDs, onmicrosoft.com domains and Nerdio instance URLs. +# The placeholders the repo uses on purpose are allow-listed by name. + +[extend] +useDefault = true + +[[rules]] +id = "azure-tenant-or-subscription-id" +description = "Real-looking Entra tenant / subscription / client GUID next to an identifying key" +regex = '''(?i)(tenant|subscription|client|object|principal|app)[_-]?(id)?["'\s:=]{1,6}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})''' +secretGroup = 3 +entropy = 3.0 + [[rules.allowlists]] + description = "Placeholder GUIDs: all zeroes, the demo generator's 0000…4000-8000 range, and all ones" + regexes = [ + '''^0{8}-0{4}-[04]0{3}-[08]0{3}-0{10}\d{2}$''', + '''^0{8}-0{4}-0{4}-0{4}-0{12}$''', + '''^1{8}-1{4}-1{4}-1{4}-1{12}$''', + ] + +[[rules]] +id = "onmicrosoft-domain" +description = "Real onmicrosoft.com tenant domain" +regex = '''(?i)\b([a-z0-9-]{3,})\.onmicrosoft\.com\b''' +secretGroup = 1 + [[rules.allowlists]] + description = "Microsoft's own sample company names, plus generic placeholders" + regexes = [ + '''(?i)^(contoso|fabrikam|adatum|northwind|tailspin|wideworld|proseware|woodgrove|example|yourtenant|tenant|partner|customer|demo)$''', + ] + +[[rules]] +id = "nerdio-instance-url" +description = "Real Nerdio Manager instance URL" +regex = '''(?i)https://(nmw|nmm|nme)-app-([a-z0-9]{6,})\.azurewebsites\.net''' +secretGroup = 2 + [[rules.allowlists]] + description = "Documentation placeholders" + regexes = ['''(?i)^(example|placeholder|yourinstance|xxxxxx+)$'''] + +[allowlist] +description = "Build output, lockfiles, and the deliberately local development key" +paths = [ + '''bun\.lock''', + '''node_modules''', + '''(^|/)dist/''', + '''apps/web/dist''', +] +regexes = [ + # docker-compose's local-only encryption key, and the all-sevens key used in a test. + '''bG9jYWwtZGV2LW9ubHkta2V5LTMyLWJ5dGVzLS0tLQ==''', + '''Buffer\.alloc\(32, 7\)''', +] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6674b15 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to DashFlow are recorded here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow +[semantic versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] — 2026-09-17 + +The first release: everything needed to run AVD dashboards for one tenant or thirty. + +### Added + +- **Connections** — Azure direct (service principal or managed identity), Azure Lighthouse, + Partner Center with GDAP via the Secure Application Model, and Nerdio Manager for MSP or + Enterprise. Each has a guided wizard that generates its prerequisites and proves access with + a permission checklist before anything syncs. +- **Sync engine** — Postgres job queue, per-stream schedules, incremental log windows with + overlap, retention, and live progress over server-sent events. +- **Dashboards** — six templates behind a guided builder, a 25-metric catalog, drag-and-resize + widgets, click-to-drill into the underlying records, per-dimension filters, favourites, + duplication, auto-refresh and CSV export. +- **Explore** — ad-hoc metric, split, filter and visualization, shareable by URL and saveable + onto a dashboard. +- **Pages** — host pool and session host detail with health timelines, an error catalogue, the + busiest users, customers, sync health, users and roles, and settings. +- **Access** — Entra sign-in through Container Apps authentication, plus app roles including + viewers scoped to a single customer, enforced in the query compiler. +- **Deployment** — container image, Docker Compose for local use, and Bicep for Container Apps + with Key Vault and an Entra-authenticated Postgres. +- **Hosted demo** — the full UI over a synthetic estate, with a JavaScript evaluator held to the + SQL engine's results by a parity test in CI. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..703495f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,42 @@ +# Code of conduct + +## The short version + +Be decent. Assume the other person is trying to help. Argue about the code, not the person. + +## Our pledge + +We as members, contributors and maintainers pledge to make participation in this project a +harassment-free experience for everyone, regardless of age, body size, visible or invisible +disability, ethnicity, sex characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, religion, or sexual +identity and orientation. + +## Standards + +Things that make this project better: + +- Being kind and patient, especially with people new to AVD, Azure, or open source +- Accepting constructive feedback gracefully, and giving it the same way +- Focusing on what is best for people running this software +- Saying "I do not know" instead of guessing, particularly about Microsoft or Nerdio APIs + +Things that are not acceptable: + +- Sexualised language or imagery, and unwelcome attention of any kind +- Trolling, insults, derogatory comments, and personal or political attacks +- Publishing anyone's private information without permission — this includes posting tenant + identifiers, customer names or user names from a real environment in an issue +- Anything else that would be inappropriate in a professional setting + +## Enforcement + +Maintainers may remove, edit or reject contributions that do not follow this code, and may +temporarily or permanently ban anyone for behaviour they consider inappropriate. + +Report problems by opening a private security advisory or contacting a maintainer directly. +Reports are handled confidentially, and the reporter's privacy is respected. + +## Attribution + +Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1b7be6c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing + +Thanks for helping. Issues, bug reports and pull requests are all welcome. + +## Before your first commit + +Install [gitleaks](https://github.com/gitleaks/gitleaks) — the pre-commit hook uses it, and CI +will fail without it passing: + +```bash +brew install gitleaks # or see the gitleaks README +bun install # installs the hooks via lefthook +``` + +**Never commit real tenant identifiers.** That includes tenant, subscription, client and object +GUIDs, `*.onmicrosoft.com` domains, Nerdio instance URLs, user names, and anything from a real +environment — including screenshots. Use the placeholders the repo already uses: zero GUIDs, +`contoso.onmicrosoft.com`, and the sample companies in `packages/demo`. For screenshots, run +with `DEMO_MODE=true`. + +## Getting set up + +```bash +bun install +docker compose up postgres -d +cp .env.example .env # set DATABASE_URL and APP_ENCRYPTION_KEY +bun run db:migrate +bun run --filter '@dashflow/server' dev # API on :3000 +bun run --filter '@dashflow/web' dev # UI on :5173, proxies /api +``` + +`DEMO_MODE=true` seeds a synthetic connection on first boot, so you can work on dashboards +without an Azure tenant. + +## Checks + +```bash +bun run lint +bun run typecheck +bun test +TEST_DATABASE_URL=postgres://dashflow:dashflow@localhost:5432/dashflow_test bun run test # includes the SQL tests +``` + +## Where things live + +| Path | What | +|---|---| +| `packages/core` | Metric catalog, dashboard templates, RBAC, zod schemas | +| `packages/db` | Drizzle schema and migrations (`bun run db:generate` after schema edits) | +| `packages/connectors` | One connector per data source; pure normalizers are unit tested | +| `apps/server` | API, auth, query engine, job queue | +| `apps/web` | React SPA | + +## Adding a metric + +1. Add it to `METRICS` in `packages/core/src/metrics.ts` — table, timestamp column, aggregate, + allowed dimensions and visualizations. +2. `bun test packages/core` checks the catalog's invariants. +3. It shows up in the widget picker automatically. + +SQL fragments in the catalog become SQL text verbatim, so they must be static: never +interpolate anything into them. Everything a user can influence is bound as a parameter by +`apps/server/src/query/engine.ts`. There is a test for that; keep it passing. + +## Adding a connector + +Implement the `Connector` interface in `packages/connectors/src/types.ts` (`test`, `discover`, +`sync`) and register it in `createConnector`. Keep API-shaped code out of the server: connectors +return the normalized records in `packages/core/src/avd.ts` and never touch the database. Put +payload-shape logic in pure functions so it can be tested against recorded, **scrubbed** +fixtures. + +## Style + +Biome formats and lints (`bun run format`). Comments explain *why*, not *what*. British or +American spelling, either is fine — just match the file you are in. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0fa2561 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 + +# ─── build ────────────────────────────────────────────────────────────────────── +FROM oven/bun:1.3-alpine AS build +WORKDIR /app + +# Install with the lockfile first so dependency layers cache. +COPY package.json bun.lock tsconfig.base.json ./ +COPY apps/server/package.json apps/server/ +COPY apps/web/package.json apps/web/ +COPY packages/core/package.json packages/core/ +COPY packages/db/package.json packages/db/ +COPY packages/connectors/package.json packages/connectors/ +COPY packages/demo/package.json packages/demo/ +RUN bun install --frozen-lockfile + +COPY . . +RUN bun run --filter '@dashflow/web' build + +# Drop dev dependencies from the layer we copy forward. +RUN rm -rf node_modules && bun install --frozen-lockfile --production + +# ─── runtime ──────────────────────────────────────────────────────────────────── +FROM oven/bun:1.3-alpine AS runtime +WORKDIR /app + +ENV NODE_ENV=production \ + APP_ROLE=all \ + HOST=0.0.0.0 \ + PORT=3000 \ + WEB_DIST=/app/apps/web/dist + +RUN apk add --no-cache curl + +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/package.json ./package.json +COPY --from=build /app/apps/server ./apps/server +COPY --from=build /app/apps/web/dist ./apps/web/dist +COPY --from=build /app/packages ./packages + +# bun's image ships a non-root `bun` user. +USER bun +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS "http://127.0.0.1:${PORT}/healthz" || exit 1 + +CMD ["bun", "apps/server/src/index.ts"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..28a4a74 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DashFlow contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 32eddd3..f45f90e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,212 @@ -# AVD_Dashboards +
+DashFlow + +### Open-source dashboards for Azure Virtual Desktop + +One container. Your tenant. Every customer. + +[**▶ Try the live demo**](https://bg-red.github.io/dashflow/) · [Quickstart](#quickstart) · [Deploy to Azure](#deploy-to-azure) · [Docs](docs/) · [Contributing](CONTRIBUTING.md) + +[![CI](https://github.com/BG-Red/dashflow/actions/workflows/ci.yml/badge.svg)](https://github.com/BG-Red/dashflow/actions/workflows/ci.yml) +[![Demo](https://github.com/BG-Red/dashflow/actions/workflows/pages.yml/badge.svg)](https://bg-red.github.io/dashflow/) +[![License: MIT](https://img.shields.io/badge/license-MIT-black)](LICENSE) +[![Container](https://img.shields.io/badge/ghcr.io-dashflow-2a78d6?logo=docker&logoColor=white)](https://github.com/BG-Red/dashflow/pkgs/container/dashflow) +[![Bun](https://img.shields.io/badge/runtime-Bun%201.3-f9f1e1?logo=bun&logoColor=black)](https://bun.sh) + +A DashFlow dashboard showing sessions, connection success and cost across four customers + +
+ +--- + +DashFlow reads Azure Virtual Desktop data **directly from Azure** or **through Nerdio Manager**, +stores it in your own Postgres, and turns it into dashboards you can build in about a minute. +It is built for people who run AVD for more than one tenant: a service principal, an **Azure +Lighthouse** managing tenant, **Partner Center (GDAP)** customers and **Nerdio Manager for MSP** +accounts all feed the same dashboards. + +No SaaS. No telemetry. No data leaving your tenant. + +## Why it exists + +AVD Insights answers "is this host pool healthy". It does not answer "which of my 30 customers +had a bad week, and what did it cost them". Workbooks can get you there, but you rebuild them +per customer — and nobody wants to hand a customer a Workbook. + +DashFlow is the piece in between: a real product surface over data you already have, with +multi-tenancy, roles and a customer-facing view built in. + +| | AVD Insights | Azure Workbooks | Nerdio reporting | **DashFlow** | +|---|---|---|---|---| +| Many tenants in one view | ✗ | with effort | per instance | **✓** | +| Lighthouse / GDAP aware | ✗ | ✗ | partial | **✓** | +| Azure **and** Nerdio together | ✗ | ✗ | ✗ | **✓** | +| Share one customer's view with them | ✗ | ✗ | ✗ | **✓ scoped viewers** | +| Runs in your own tenant | ✓ | ✓ | ✓ | **✓** | +| Open source | ✗ | ✗ | ✗ | **✓ MIT** | + +## What you get + +**Guided dashboards.** Pick a question — usage, experience, reliability, capacity, cost — preview +the answer against your own data, then adjust. Drag and resize widgets, filter them per +dimension, and click any point to see the records behind it. + +**Explore.** Ask something the templates do not cover: pick a metric, split it, filter it, look +at it. The question lives in the URL, so sharing it is a copy and paste, and saving it onto a +dashboard is one click. + +The Explore screen with a metric, a split and a filter + +**An error catalogue that leads somewhere.** Every AVD error code you are actually hitting, +ranked, with the host pools and customers affected — and the busiest users right beside it, +because that is always the next question. + +The error catalogue with the busiest users below it + +**Setup that proves itself.** The connection wizard generates the exact `az` commands for the +roles it needs, then runs a permission checklist — including the traps, like GDAP granting Entra +roles but not Azure RBAC. Nothing syncs until you have seen it pass. + +The connection wizard listing prerequisites with copyable commands + +Also in the box: a ⌘K command palette, light and dark themes that were each designed rather than +inverted, CSV export everywhere, host pool and session host pages with health timelines, +per-customer viewer roles, and a sync health screen that streams progress live. + +## Quickstart + +```bash +git clone https://github.com/BG-Red/dashflow.git +cd dashflow +docker compose up --build +``` + +Open . Demo mode generates four synthetic customers with 30 days of +history, so every dashboard has something in it from the first second. + +
+Prefer Bun directly? + +```bash +bun install +cp .env.example .env # set DATABASE_URL and APP_ENCRYPTION_KEY +bun run db:migrate +bun run build +bun run start +``` + +
+ +## Deploy to Azure + +[![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2FBG-Red%2Fdashflow%2Fmain%2Finfra%2Fazuredeploy.json) + +The script does the whole thing, including the parts the portal cannot (making the app's managed +identity a Postgres administrator): + +```bash +IMAGE=ghcr.io/bg-red/dashflow:latest ./infra/deploy.sh +``` + +That creates a Container Apps environment with **Container Apps authentication (EasyAuth)** in +front of the app, a user-assigned managed identity, Key Vault, and a Postgres Flexible Server +with **Entra-only auth** — so no database password exists anywhere. + +See [docs/deploy.md](docs/deploy.md) for the app registration, and +[docs/security.md](docs/security.md) for exactly what the app trusts and why. + +## How it fits together + +``` + ┌──────────────── Azure Container Apps ─────────────────┐ + browser ──TLS──▶│ EasyAuth sidecar ──▶ web (Bun · Hono · React) │ + │ │ │ + │ Postgres (Entra auth) │ + │ ▲ │ + │ worker (same image, APP_ROLE=worker) ──▶ connectors ──┼──▶ ARM · Log Analytics + └───────────────────────────────────────────────────────┘ Cost Management + Partner Center + Nerdio REST API +``` + +| Package | What it holds | +|---|---| +| `packages/core` | The metric catalog, dashboard templates, roles and schemas — the only things that become SQL | +| `packages/db` | Drizzle schema and migrations | +| `packages/connectors` | One `test` / `discover` / `sync` interface, five implementations | +| `packages/demo` | The synthetic estate, plus the JavaScript evaluator behind the hosted demo | +| `apps/server` | API, EasyAuth, the query engine, a Postgres job queue and the sync worker | +| `apps/web` | The React app | + +## Connections + +| | How it reaches your data | Use it when | +|---|---|---| +| **Azure (direct)** | Service principal, or the container's managed identity | One tenant — internal IT, or a single customer | +| **Azure Lighthouse** | One identity in your managing tenant, projected into delegated subscriptions | You already onboard customers with Lighthouse | +| **Partner Center (GDAP)** | Partner admin consent once, then per-customer tokens | CSP partners with GDAP relationships | +| **Nerdio Manager** | Nerdio's REST API with client credentials | Environments managed by Nerdio Manager (MSP or Enterprise) | + +Azure and Nerdio records about the same host pool merge on the ARM resource ID, so running both +puts Nerdio's autoscale view next to Azure's connection telemetry. + +[docs/connections.md](docs/connections.md) covers each type; [docs/permissions.md](docs/permissions.md) +lists the exact roles — all of them read-only. + +## What it collects + +| Stream | Source | Default interval | +|---|---|---| +| Inventory — pools, hosts, app groups, scaling plans | ARM `Microsoft.DesktopVirtualization` | hourly | +| Sessions — concurrency, capacity, host health | ARM session and user sessions | 5 minutes | +| Connections, errors, latency, CPU | Log Analytics (`WVDConnections`, `WVDErrors`, `WVDConnectionNetworkData`, `Perf`) | 15 minutes, incremental | +| Cost | Cost Management query API | daily | + +User names are hashed with a per-instance salt by default, so per-user counts work without +storing identities. Retention is per connection, and a daily job enforces it. + +## Two things worth knowing + +**Tenant scope is enforced where the SQL is built, not in the UI.** Widgets reference metric IDs +from a catalog that lives in code; every value a user can influence is a bound parameter. A +viewer scoped to one customer who asks for another gets zero rows — not everything. Tests assert +exactly that, including one that throws SQL injection at a dashboard filter. + +**The demo cannot drift from the product.** The hosted demo computes its numbers in JavaScript +because it has no database — and CI runs that evaluator and the real SQL engine over the same +generated rows, failing if any metric, percentile or bucket disagrees. + +## Light theme, and the customer view + +| Light | Customers | +|---|---| +| | | + +## Roadmap + +See [ROADMAP.md](ROADMAP.md). Next: alert rules with webhook and Teams delivery, scheduled +customer reports, and a Nerdio autoscale-history connector once those endpoints are confirmed +against a live instance. + +## Contributing + +Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Install +[gitleaks](https://github.com/gitleaks/gitleaks) before your first commit: the pre-commit hook +uses it to keep tenant IDs, domains and secrets out of the repository, and CI enforces the same +rules across the full history. + +```bash +bun test # unit tests; set TEST_DATABASE_URL to include the SQL and parity suites +bunx playwright test # end-to-end, against the demo build +bun run lint && bun run typecheck +``` + +--- + +
+ +MIT licensed · Not affiliated with Microsoft or Nerdio · "Azure Virtual Desktop" and "Nerdio" +are their owners' trademarks + +
diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..e197651 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,42 @@ +# Roadmap + +What is done, what is next, and what is deliberately out of scope. Dates are intentions, not +commitments — this is a volunteer project. + +## Shipped + +- Four connection types: Azure direct, Azure Lighthouse, Partner Center (GDAP), Nerdio Manager +- Sync engine with per-stream schedules, incremental log windows and retention +- Metric catalog with 25 metrics, six dashboard templates and a guided builder +- Drag-and-resize dashboards, click-to-drill, per-widget filters, favourites, duplication +- Explore, host pool and session host pages, error catalogue, busiest users +- App roles including viewers scoped to a single customer +- Hosted demo with an evaluator proved against the SQL engine in CI + +## Next + +**Alerting.** Threshold rules per metric ("time to connect p95 above 15s for 30 minutes"), +evaluated by the worker, delivered to a webhook or Teams, with an in-app inbox. The evaluator +and schedule already exist; this is a table, a rule evaluator and a delivery step. + +**Scheduled customer reports.** A dashboard, rendered on a schedule and emailed as a PDF or a +link. Needs a rendering step and SMTP or Graph mail settings. + +**Nerdio autoscale history.** Autoscale actions are currently derived from host state +transitions, which works everywhere. If Nerdio exposes a dependable action history, read it +directly and keep the derivation as a fallback. + +## Being considered + +- Read-only public share links for a single dashboard, with an expiry +- More cost attribution: tags and per-host-pool allocation rather than resource group matching +- FSLogix profile metrics, once there is a dependable source for them +- A Grafana datasource, so DashFlow's metric catalog can be queried from an existing stack +- Per-instance branding on customer-facing dashboards + +## Not planned + +- Writing anything back to Azure or Nerdio. DashFlow is read-only by design, and that is what + makes the permission story simple enough to hand to a customer. +- A hosted SaaS version. Run it yourself; that is the point. +- Non-AVD workloads. A general infrastructure dashboard is a different product. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..250007b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security policy + +## Reporting a vulnerability + +Please report security issues privately using GitHub's +[private vulnerability reporting](https://github.com/BG-Red/dashflow/security/advisories/new) +rather than a public issue. + +Include what you found, how to reproduce it, and the version or commit. Please do not include +real tenant identifiers or customer data in the report — a placeholder reproduction is enough. + +This is a volunteer-maintained open source project, so there is no formal SLA, but reports are +taken seriously and acknowledged as quickly as possible. + +## Supported versions + +The latest tagged release and `main`. + +## Scope + +In scope: authentication and authorization bypasses, tenant-scope escapes (one customer's data +visible to a viewer scoped to another), SQL injection through dashboard or query inputs, secret +disclosure through the API or logs, and anything in the deployment template that weakens the +trust boundary described in [docs/security.md](docs/security.md). + +Out of scope: running the app with `AUTH_MODE=dev` exposed to a network (it warns loudly and +refuses in production), and findings that require an existing owner or admin role. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..9b9c0a9 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,33 @@ +# Getting help + +**Try the [live demo](https://bg-red.github.io/dashflow/)** if you want to see what DashFlow +does before installing anything. + +## Something is not working + +1. **Check the connection diagnostics.** Connections → your connection → Details → Test. Most + problems are a missing role assignment, and the checklist names the exact one. +2. **Check sync health.** Failed runs carry the error the API returned. +3. **Read the troubleshooting section** in [docs/connections.md](docs/connections.md) — it covers + the common cases: no subscriptions visible, empty experience widgets, missing cost, Nerdio + paths that differ by version. + +## Still stuck + +Open an [issue](https://github.com/BG-Red/dashflow/issues/new/choose) with: + +- What you expected and what happened +- Connection type, deployment (Container Apps, Docker, other) and image tag +- The relevant log lines or diagnostic results + +**Please redact tenant IDs, subscription IDs, domains and user names.** Placeholders are fine — +the app's own examples use `00000000-0000-0000-0000-000000000000` and `contoso.onmicrosoft.com`. + +## Security issues + +Do not open a public issue. See [SECURITY.md](SECURITY.md). + +## Feature ideas + +Check [ROADMAP.md](ROADMAP.md) first, then open a feature request. Ideas that come with the +operational reason behind them ("I need this because on Monday mornings…") get built first. diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..edba81a --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,26 @@ +{ + "name": "@dashflow/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts" + }, + "dependencies": { + "@dashflow/connectors": "workspace:*", + "@dashflow/core": "workspace:*", + "@dashflow/db": "workspace:*", + "@dashflow/demo": "workspace:*", + "@azure/identity": "4.13.3", + "@azure/keyvault-secrets": "4.11.2", + "drizzle-orm": "0.45.2", + "hono": "4.13.8", + "jose": "6.2.12", + "pino": "10.3.1", + "zod": "4.6.5" + }, + "devDependencies": { + "pino-pretty": "13.1.3" + } +} diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts new file mode 100644 index 0000000..fd23d44 --- /dev/null +++ b/apps/server/src/app.ts @@ -0,0 +1,111 @@ +import { existsSync } from "node:fs"; +import { join, normalize, resolve } from "node:path"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { secureHeaders } from "hono/secure-headers"; +import { ZodError } from "zod"; +import { AccessDenied } from "./auth/access"; +import { type AppVariables, authMiddleware } from "./auth/middleware"; +import type { AppContext } from "./context"; +import { SecretError } from "./lib/secrets"; +import { connectionRoutes } from "./routes/connections"; +import { dashboardRoutes } from "./routes/dashboards"; +import { entityRoutes } from "./routes/entities"; +import { metaRoutes } from "./routes/meta"; +import { queryRoutes } from "./routes/query"; +import { settingsRoutes } from "./routes/settings"; +import { syncRoutes } from "./routes/sync"; +import { tenantRoutes } from "./routes/tenants"; +import { userRoutes } from "./routes/users"; + +export function createApp(ctx: AppContext) { + const app = new Hono<{ Variables: AppVariables }>(); + + app.use( + "*", + secureHeaders({ + contentSecurityPolicy: { + defaultSrc: ["'self'"], + // The SPA is built with Vite; styles are injected at runtime, scripts are not. + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'"], + imgSrc: ["'self'", "data:"], + connectSrc: ["'self'"], + frameAncestors: ["'none'"], + }, + xFrameOptions: "DENY", + referrerPolicy: "no-referrer", + }), + ); + + /** Liveness and readiness. Deliberately unauthenticated and free of any instance detail. */ + app.get("/healthz", (c) => c.json({ ok: true })); + app.get("/readyz", async (c) => { + try { + await ctx.sql`select 1`; + return c.json({ ok: true }); + } catch { + return c.json({ ok: false }, 503); + } + }); + + const api = new Hono<{ Variables: AppVariables }>(); + api.use("*", authMiddleware(ctx)); + api.route("/", metaRoutes(ctx)); + api.route("/connections", connectionRoutes(ctx)); + api.route("/tenants", tenantRoutes(ctx)); + api.route("/dashboards", dashboardRoutes(ctx)); + api.route("/query", queryRoutes(ctx)); + api.route("/sync", syncRoutes(ctx)); + api.route("/entities", entityRoutes(ctx)); + api.route("/settings", settingsRoutes(ctx)); + api.route("/users", userRoutes(ctx)); + app.route("/api", api); + + app.onError((err, c) => { + if (err instanceof HTTPException) return err.getResponse(); + if (err instanceof AccessDenied) return c.json({ error: err.message }, 403); + if (err instanceof SecretError) return c.json({ error: err.message }, 400); + if (err instanceof ZodError) { + return c.json( + { + error: "That request was not valid.", + issues: err.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })), + }, + 400, + ); + } + ctx.log.error({ err: err.message, stack: err.stack }, "unhandled error"); + return c.json({ error: "Something went wrong. Check the container logs." }, 500); + }); + + // ─── static web app ─────────────────────────────────────────────────────────── + const dist = resolve(process.cwd(), ctx.env.WEB_DIST); + const indexHtml = join(dist, "index.html"); + const hasWeb = existsSync(indexHtml); + if (!hasWeb) { + ctx.log.warn({ dist }, "no built web app found — run `bun run build` (API still works)"); + } + + app.get("*", async (c) => { + if (!hasWeb) return c.json({ error: "The web app is not built." }, 404); + const path = new URL(c.req.url).pathname; + // Serve hashed assets straight from disk; everything else falls back to the SPA shell. + const candidate = normalize(join(dist, path)); + if (candidate.startsWith(dist) && path !== "/" && existsSync(candidate)) { + const file = Bun.file(candidate); + const immutable = path.startsWith("/assets/"); + return new Response(file, { + headers: { + "content-type": file.type, + "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache", + }, + }); + } + return new Response(Bun.file(indexHtml), { + headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" }, + }); + }); + + return app; +} diff --git a/apps/server/src/auth/access.ts b/apps/server/src/auth/access.ts new file mode 100644 index 0000000..aab035c --- /dev/null +++ b/apps/server/src/auth/access.ts @@ -0,0 +1,85 @@ +import { type Access, hasRole, type Role, roleRank } from "@dashflow/core"; +import { type Db, schema } from "@dashflow/db"; +import { and, eq, isNull } from "drizzle-orm"; +import type { Principal } from "./easyauth"; + +export interface SignedInUser { + id: string; + oid: string; + email: string; + displayName: string | null; + access: Access | null; +} + +/** Upsert the user row on every request so "last seen" and renames stay current. */ +export async function touchUser(db: Db, principal: Principal): Promise<{ id: string; disabled: boolean }> { + const [row] = await db + .insert(schema.users) + .values({ + oid: principal.oid, + tenantId: principal.tenantId, + email: principal.email, + displayName: principal.displayName, + lastSeenAt: new Date(), + }) + .onConflictDoUpdate({ + target: schema.users.oid, + set: { + email: principal.email, + displayName: principal.displayName, + tenantId: principal.tenantId, + lastSeenAt: new Date(), + }, + }) + .returning({ id: schema.users.id, disabled: schema.users.disabled }); + return row!; +} + +/** + * Highest role wins. Tenant scope is "all" when any assignment at that role has no tenant, + * otherwise the union of the tenants granted at that role or above. + */ +export async function resolveAccess(db: Db, userId: string): Promise { + const rows = await db + .select({ role: schema.roleAssignments.role, tenantId: schema.roleAssignments.customerTenantId }) + .from(schema.roleAssignments) + .where(eq(schema.roleAssignments.userId, userId)); + if (rows.length === 0) return null; + + let role: Role = rows[0]!.role; + for (const row of rows) if (roleRank(row.role) > roleRank(role)) role = row.role; + + const atRole = rows.filter((r) => r.role === role); + if (atRole.some((r) => r.tenantId === null)) return { role, tenantScope: null }; + return { role, tenantScope: [...new Set(atRole.map((r) => r.tenantId!))] }; +} + +export async function ownerExists(db: Db): Promise { + const rows = await db + .select({ id: schema.roleAssignments.id }) + .from(schema.roleAssignments) + .where(and(eq(schema.roleAssignments.role, "owner"), isNull(schema.roleAssignments.customerTenantId))) + .limit(1); + return rows.length > 0; +} + +export async function grantRole( + db: Db, + opts: { userId: string; role: Role; customerTenantId?: string | null; createdBy?: string | null }, +): Promise { + await db.insert(schema.roleAssignments).values({ + userId: opts.userId, + role: opts.role, + customerTenantId: opts.customerTenantId ?? null, + createdBy: opts.createdBy ?? null, + }); +} + +export function requireAccess(user: SignedInUser | null, role: Role): Access { + if (!user?.access || !hasRole(user.access.role, role)) { + throw new AccessDenied(`This action needs the ${role} role.`); + } + return user.access; +} + +export class AccessDenied extends Error {} diff --git a/apps/server/src/auth/auth.test.ts b/apps/server/src/auth/auth.test.ts new file mode 100644 index 0000000..051b46a --- /dev/null +++ b/apps/server/src/auth/auth.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { loadEnv } from "../env"; +import { SENSITIVE_KEY_PATTERN, scrub } from "../lib/log"; +import { PrincipalError, parseClientPrincipalHeader } from "./easyauth"; + +function principalHeader( + claims: { typ: string; val: string }[], + extra: Record = {}, +): string { + return Buffer.from(JSON.stringify({ auth_typ: "aad", claims, ...extra })).toString("base64"); +} + +const OID = "http://schemas.microsoft.com/identity/claims/objectidentifier"; +const TID = "http://schemas.microsoft.com/identity/claims/tenantid"; + +describe("EasyAuth principal", () => { + test("reads oid, tenant, email and name from the long claim types", () => { + const principal = parseClientPrincipalHeader( + principalHeader([ + { typ: OID, val: "user-oid" }, + { typ: TID, val: "tenant-id" }, + { typ: "preferred_username", val: "someone@contoso.onmicrosoft.com" }, + { typ: "name", val: "Someone" }, + ]), + )!; + expect(principal.oid).toBe("user-oid"); + expect(principal.tenantId).toBe("tenant-id"); + expect(principal.email).toBe("someone@contoso.onmicrosoft.com"); + expect(principal.displayName).toBe("Someone"); + expect(principal.identityProvider).toBe("aad"); + }); + + test("accepts the short claim types too", () => { + const principal = parseClientPrincipalHeader( + principalHeader([ + { typ: "oid", val: "o" }, + { typ: "tid", val: "t" }, + { typ: "upn", val: "u@example.invalid" }, + ]), + )!; + expect(principal.oid).toBe("o"); + expect(principal.email).toBe("u@example.invalid"); + }); + + test("collects app roles", () => { + const principal = parseClientPrincipalHeader( + principalHeader( + [ + { typ: OID, val: "o" }, + { typ: TID, val: "t" }, + { typ: "roles", val: "Reader" }, + { typ: "roles", val: "Writer" }, + ], + { role_typ: "roles" }, + ), + )!; + expect(principal.roles).toEqual(["Reader", "Writer"]); + }); + + test("no header means not signed in, not an error", () => { + expect(parseClientPrincipalHeader(undefined)).toBeNull(); + expect(parseClientPrincipalHeader("")).toBeNull(); + }); + + test("a malformed header is rejected rather than half-trusted", () => { + expect(() => parseClientPrincipalHeader("!!!not-base64-json")).toThrow(PrincipalError); + expect(() => parseClientPrincipalHeader(principalHeader([{ typ: TID, val: "t" }]))).toThrow( + /object identifier/, + ); + expect(() => parseClientPrincipalHeader(principalHeader([{ typ: OID, val: "o" }]))).toThrow(/tenant id/); + }); +}); + +describe("environment guards", () => { + const base = { DATABASE_URL: "postgres://localhost/avd" }; + + test("dev auth is refused in production", () => { + expect(() => loadEnv({ ...base, AUTH_MODE: "dev", NODE_ENV: "production" } as never)).toThrow( + /cannot be used with NODE_ENV=production/, + ); + }); + + test("dev auth only binds to localhost unless that is explicitly overridden", () => { + expect(() => loadEnv({ ...base, AUTH_MODE: "dev", HOST: "0.0.0.0" } as never)).toThrow( + /only binds to localhost/, + ); + expect(loadEnv({ ...base, AUTH_MODE: "dev", HOST: "127.0.0.1" } as never).AUTH_MODE).toBe("dev"); + // Containers have to bind 0.0.0.0, so the override exists — but it still cannot be production. + expect( + loadEnv({ ...base, AUTH_MODE: "dev", HOST: "0.0.0.0", DEV_AUTH_ALLOW_REMOTE: "true" } as never).HOST, + ).toBe("0.0.0.0"); + expect(() => + loadEnv({ + ...base, + AUTH_MODE: "dev", + HOST: "0.0.0.0", + DEV_AUTH_ALLOW_REMOTE: "true", + NODE_ENV: "production", + } as never), + ).toThrow(/NODE_ENV=production/); + }); + + test("token validation needs a client id to validate against", () => { + expect(() => + loadEnv({ ...base, AUTH_MODE: "easyauth", EASYAUTH_VALIDATE_TOKEN: "true" } as never), + ).toThrow(/EASYAUTH_CLIENT_ID/); + }); + + test("an encryption key must be 32 bytes", () => { + expect(() => + loadEnv({ ...base, APP_ENCRYPTION_KEY: Buffer.from("too-short").toString("base64") } as never), + ).toThrow(/32 bytes/); + expect( + loadEnv({ ...base, APP_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString("base64") } as never).PORT, + ).toBe(3000); + }); + + test("a missing database url is a startup error, not a runtime surprise", () => { + expect(() => loadEnv({} as never)).toThrow(/DATABASE_URL/); + }); + + test("allowed tenants parse as a list", () => { + expect(loadEnv({ ...base, ALLOWED_TENANT_IDS: "a, b ,c" } as never).ALLOWED_TENANT_IDS).toEqual([ + "a", + "b", + "c", + ]); + }); +}); + +describe("log redaction", () => { + test("anything that looks like a credential is replaced", () => { + const scrubbed = scrub({ + name: "conn", + clientSecret: "super-secret", + nested: { refresh_token: "rt", apiKey: "ak", tenantId: "t" }, + list: [{ password: "p" }], + }); + expect(JSON.stringify(scrubbed)).not.toContain("super-secret"); + expect(scrubbed.nested.tenantId).toBe("t"); + expect(scrubbed.nested.refresh_token).toBe("[redacted]"); + expect(scrubbed.list[0]!.password).toBe("[redacted]"); + }); + + test("the pattern catches the usual suspects", () => { + for (const key of ["secret", "clientSecret", "PASSWORD", "refresh_token", "apiKey", "Authorization"]) { + expect(SENSITIVE_KEY_PATTERN.test(key)).toBe(true); + } + expect(SENSITIVE_KEY_PATTERN.test("tenantId")).toBe(false); + }); +}); diff --git a/apps/server/src/auth/bootstrap.ts b/apps/server/src/auth/bootstrap.ts new file mode 100644 index 0000000..c394f29 --- /dev/null +++ b/apps/server/src/auth/bootstrap.ts @@ -0,0 +1,80 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { type Db, schema } from "@dashflow/db"; +import { eq } from "drizzle-orm"; +import type { Logger } from "../lib/log"; +import { grantRole, ownerExists } from "./access"; +import type { Principal } from "./easyauth"; + +const SETTING_KEY = "bootstrap.code"; + +interface BootstrapSetting { + hash: string; + createdAt: string; +} + +function hash(code: string): string { + return createHash("sha256").update(code.trim().toUpperCase()).digest("hex"); +} + +/** Human-typable code: 4 groups of 4 from an unambiguous alphabet. */ +function generateCode(): string { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + const bytes = randomBytes(16); + const chars = [...bytes].map((b) => alphabet[b % alphabet.length]); + return [0, 4, 8, 12].map((i) => chars.slice(i, i + 4).join("")).join("-"); +} + +/** + * On first boot, print a one-time code to the container logs. Whoever can read the logs can + * claim ownership — that is the same trust boundary as being able to deploy the app. + */ +export async function ensureBootstrapCode(db: Db, log: Logger): Promise { + if (await ownerExists(db)) return; + const existing = await db.query.settings.findFirst({ where: eq(schema.settings.key, SETTING_KEY) }); + if (existing) { + log.info( + "waiting for someone to claim this instance — re-run with a fresh deploy to rotate the setup code", + ); + return; + } + const code = generateCode(); + await db.insert(schema.settings).values({ + key: SETTING_KEY, + value: { hash: hash(code), createdAt: new Date().toISOString() } satisfies BootstrapSetting, + }); + log.info( + `\n\n ┌─ DashFlow first-run setup ──────────────────────────────┐\n │ Open the app and enter this code to become the owner: │\n │ │\n │ ${code}${" ".repeat(Math.max(0, 25 - code.length))} │\n │ │\n │ It is only accepted until the first owner is created. │\n └───────────────────────────────────────────────────────────────┘\n`, + ); +} + +export async function isClaimed(db: Db): Promise { + return ownerExists(db); +} + +export type ClaimResult = { ok: true } | { ok: false; reason: string }; + +export async function claimOwnership( + db: Db, + opts: { userId: string; code: string; principal: Principal; allowedOids: string[] }, +): Promise { + if (await ownerExists(db)) return { ok: false, reason: "This instance already has an owner." }; + + // BOOTSTRAP_OWNER_OIDS lets infra-as-code pre-authorize an owner without a code. + if (opts.allowedOids.includes(opts.principal.oid)) { + await grantRole(db, { userId: opts.userId, role: "owner" }); + return { ok: true }; + } + + const setting = await db.query.settings.findFirst({ where: eq(schema.settings.key, SETTING_KEY) }); + const expected = (setting?.value as BootstrapSetting | undefined)?.hash; + if (!expected) return { ok: false, reason: "No setup code has been generated. Restart the container." }; + + const a = Buffer.from(hash(opts.code)); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) + return { ok: false, reason: "That code is not correct." }; + + await grantRole(db, { userId: opts.userId, role: "owner" }); + await db.delete(schema.settings).where(eq(schema.settings.key, SETTING_KEY)); + return { ok: true }; +} diff --git a/apps/server/src/auth/easyauth.ts b/apps/server/src/auth/easyauth.ts new file mode 100644 index 0000000..49cdd3e --- /dev/null +++ b/apps/server/src/auth/easyauth.ts @@ -0,0 +1,138 @@ +import { createRemoteJWKSet, jwtVerify } from "jose"; + +/** + * Azure Container Apps / App Service EasyAuth puts the signed-in identity in + * X-MS-CLIENT-PRINCIPAL: base64 of { auth_typ, name_typ, role_typ, claims: [{ typ, val }] }. + * + * These headers are only trustworthy because the platform's auth sidecar terminates every + * request and overwrites them. If the container is reachable without that sidecar, anyone + * can forge them — see docs/security.md. + */ +export interface Principal { + oid: string; + tenantId: string; + email: string; + displayName: string | null; + /** App roles from the app registration, if any are assigned. */ + roles: string[]; + identityProvider: string | null; +} + +interface ClientPrincipal { + auth_typ?: string; + name_typ?: string; + role_typ?: string; + claims?: { typ?: string; val?: string }[]; +} + +const CLAIM_ALIASES = { + oid: ["http://schemas.microsoft.com/identity/claims/objectidentifier", "oid", "sub"], + tid: ["http://schemas.microsoft.com/identity/claims/tenantid", "tid"], + email: [ + "preferred_username", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn", + "upn", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + "email", + ], + name: ["name", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"], +} as const; + +export class PrincipalError extends Error {} + +export function parseClientPrincipalHeader(header: string | undefined | null): Principal | null { + if (!header) return null; + let decoded: ClientPrincipal; + try { + decoded = JSON.parse(Buffer.from(header, "base64").toString("utf8")) as ClientPrincipal; + } catch { + throw new PrincipalError("X-MS-CLIENT-PRINCIPAL is not valid base64 JSON"); + } + const claims = new Map(); + const roles: string[] = []; + const roleType = decoded.role_typ ?? "roles"; + for (const claim of decoded.claims ?? []) { + if (!claim.typ || claim.val === undefined) continue; + if (claim.typ === roleType || claim.typ === "roles") { + roles.push(claim.val); + continue; + } + if (!claims.has(claim.typ)) claims.set(claim.typ, claim.val); + } + const pick = (keys: readonly string[]) => { + for (const key of keys) { + const value = claims.get(key); + if (value) return value; + } + return undefined; + }; + + const oid = pick(CLAIM_ALIASES.oid); + const tenantId = pick(CLAIM_ALIASES.tid); + const email = pick(CLAIM_ALIASES.email); + if (!oid) throw new PrincipalError("EasyAuth principal has no object identifier claim"); + if (!tenantId) throw new PrincipalError("EasyAuth principal has no tenant id claim"); + + return { + oid, + tenantId, + email: email ?? `${oid}@unknown`, + displayName: pick(CLAIM_ALIASES.name) ?? null, + roles, + identityProvider: decoded.auth_typ ?? null, + }; +} + +const jwksCache = new Map>(); + +function jwks(tenantId: string) { + const url = `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`; + let set = jwksCache.get(url); + if (!set) { + set = createRemoteJWKSet(new URL(url), { cacheMaxAge: 12 * 3600_000 }); + jwksCache.set(url, set); + } + return set; +} + +/** + * Defence in depth: verify the id token EasyAuth forwards, so a forged + * X-MS-CLIENT-PRINCIPAL alone is not enough. Requires the token store to be enabled. + */ +export async function verifyIdToken( + token: string | undefined | null, + opts: { clientId: string; tenantId: string }, +): Promise { + if (!token) + throw new PrincipalError("EASYAUTH_VALIDATE_TOKEN is on but X-MS-TOKEN-AAD-ID-TOKEN is missing"); + const { payload } = await jwtVerify(token, jwks(opts.tenantId), { + audience: opts.clientId, + issuer: [ + `https://login.microsoftonline.com/${opts.tenantId}/v2.0`, + `https://sts.windows.net/${opts.tenantId}/`, + ], + clockTolerance: 60, + }); + const oid = (payload.oid ?? payload.sub) as string | undefined; + const tid = payload.tid as string | undefined; + if (!oid || !tid) throw new PrincipalError("Id token is missing the oid or tid claim"); + return { + oid, + tenantId: tid, + email: (payload.preferred_username ?? payload.upn ?? payload.email ?? `${oid}@unknown`) as string, + displayName: (payload.name as string | undefined) ?? null, + roles: Array.isArray(payload.roles) ? (payload.roles as string[]) : [], + identityProvider: "aad", + }; +} + +export function devPrincipal(opts: { email: string; oid: string; tenantId: string }): Principal { + return { + oid: opts.oid, + tenantId: opts.tenantId, + email: opts.email, + displayName: "Local Developer", + roles: [], + identityProvider: "dev", + }; +} diff --git a/apps/server/src/auth/middleware.ts b/apps/server/src/auth/middleware.ts new file mode 100644 index 0000000..28b2d4d --- /dev/null +++ b/apps/server/src/auth/middleware.ts @@ -0,0 +1,73 @@ +import type { MiddlewareHandler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { AppContext } from "../context"; +import { resolveAccess, type SignedInUser, touchUser } from "./access"; +import { + devPrincipal, + type Principal, + PrincipalError, + parseClientPrincipalHeader, + verifyIdToken, +} from "./easyauth"; + +export interface AppVariables { + user: SignedInUser; + principal: Principal; +} + +export function authMiddleware(ctx: AppContext): MiddlewareHandler<{ Variables: AppVariables }> { + const { env, log } = ctx; + + return async (c, next) => { + let principal: Principal | null; + try { + if (env.AUTH_MODE === "dev") { + principal = devPrincipal({ + email: env.DEV_USER_EMAIL, + oid: env.DEV_USER_OID, + tenantId: env.DEV_USER_TID, + }); + } else { + principal = parseClientPrincipalHeader(c.req.header("x-ms-client-principal")); + if (principal && env.EASYAUTH_VALIDATE_TOKEN) { + const verified = await verifyIdToken(c.req.header("x-ms-token-aad-id-token"), { + clientId: env.EASYAUTH_CLIENT_ID, + tenantId: principal.tenantId, + }); + if (verified.oid !== principal.oid) { + throw new PrincipalError("Id token does not match the EasyAuth principal"); + } + principal = verified; + } + } + } catch (err) { + log.warn({ err: (err as Error).message }, "rejected sign-in"); + throw new HTTPException(401, { message: "Could not read your sign-in details." }); + } + + if (!principal) { + throw new HTTPException(401, { + message: + "Not signed in. This app expects to run behind Azure Container Apps authentication (EasyAuth); see docs/security.md.", + }); + } + if (env.ALLOWED_TENANT_IDS.length > 0 && !env.ALLOWED_TENANT_IDS.includes(principal.tenantId)) { + log.warn({ tenantId: principal.tenantId }, "sign-in from a tenant that is not allowed"); + throw new HTTPException(403, { message: "Your Entra tenant is not allowed to use this instance." }); + } + + const row = await touchUser(ctx.db, principal); + if (row.disabled) throw new HTTPException(403, { message: "This account has been disabled." }); + + const access = await resolveAccess(ctx.db, row.id); + c.set("principal", principal); + c.set("user", { + id: row.id, + oid: principal.oid, + email: principal.email, + displayName: principal.displayName, + access, + }); + await next(); + }; +} diff --git a/apps/server/src/context.ts b/apps/server/src/context.ts new file mode 100644 index 0000000..d555b6a --- /dev/null +++ b/apps/server/src/context.ts @@ -0,0 +1,48 @@ +import { createDb, type Db, type SqlClient } from "@dashflow/db"; +import type { Env } from "./env"; +import { createLogger, type Logger } from "./lib/log"; +import { SecretStore } from "./lib/secrets"; + +export interface AppContext { + env: Env; + db: Db; + sql: SqlClient; + log: Logger; + secrets: SecretStore; + /** Set once the worker/web server is shutting down. */ + shuttingDown: boolean; +} + +/** Entra scope for Azure Database for PostgreSQL Flexible Server. */ +const PG_SCOPE = "https://ossrdbms-aad.database.windows.net/.default"; + +export async function createContext(env: Env): Promise { + const log = createLogger({ level: env.LOG_LEVEL, pretty: env.NODE_ENV !== "production" }); + + let tokenProvider: (() => Promise) | undefined; + if (env.DATABASE_USE_ENTRA_AUTH) { + const { ManagedIdentityCredential } = await import("@azure/identity"); + const credential = new ManagedIdentityCredential( + env.AZURE_CLIENT_ID ? { clientId: env.AZURE_CLIENT_ID } : {}, + ); + tokenProvider = async () => { + const token = await credential.getToken(PG_SCOPE); + if (!token) throw new Error("Could not get an Entra token for Postgres"); + return token.token; + }; + } + + const { db, sqlClient } = (() => { + const created = createDb({ url: env.DATABASE_URL, tokenProvider }); + return { db: created.db, sqlClient: created.client }; + })(); + + return { + env, + db, + sql: sqlClient, + log, + secrets: new SecretStore({ db, env, log }), + shuttingDown: false, + }; +} diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts new file mode 100644 index 0000000..c35a78c --- /dev/null +++ b/apps/server/src/env.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +const csv = z + .string() + .default("") + .transform((v) => + v + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + +const bool = z + .string() + .default("false") + .transform((v) => ["1", "true", "yes", "on"].includes(v.toLowerCase())); + +const envSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + PORT: z.coerce.number().int().min(1).max(65_535).default(3000), + HOST: z.string().default("127.0.0.1"), + APP_ROLE: z.enum(["web", "worker", "all"]).default("all"), + DATABASE_URL: z.string().min(1, "DATABASE_URL is required"), + DATABASE_USE_ENTRA_AUTH: bool, + AUTH_MODE: z.enum(["easyauth", "dev"]).default("easyauth"), + DEV_USER_EMAIL: z.string().default("dev.user@contoso.onmicrosoft.com"), + DEV_USER_OID: z.string().default("00000000-0000-0000-0000-000000000001"), + DEV_USER_TID: z.string().default("00000000-0000-0000-0000-000000000002"), + /** Dev auth normally refuses to bind to anything but localhost; Docker needs 0.0.0.0. */ + DEV_AUTH_ALLOW_REMOTE: bool, + ALLOWED_TENANT_IDS: csv, + EASYAUTH_VALIDATE_TOKEN: bool, + EASYAUTH_CLIENT_ID: z.string().default(""), + BOOTSTRAP_OWNER_OIDS: csv, + APP_ENCRYPTION_KEY: z.string().default(""), + KEY_VAULT_URI: z.string().default(""), + AZURE_CLIENT_ID: z.string().default(""), + PUBLIC_BASE_URL: z.string().default("http://localhost:3000"), + DEMO_MODE: bool, + PSEUDONYMIZE_USERS: z + .string() + .default("true") + .transform((v) => !["0", "false", "no", "off"].includes(v.toLowerCase())), + LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"), + /** Where the built web app lives; empty disables static serving. */ + WEB_DIST: z.string().default("apps/web/dist"), +}); + +export type Env = z.infer; + +const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]); + +export function loadEnv(raw: NodeJS.ProcessEnv = process.env): Env { + const parsed = envSchema.safeParse(raw); + if (!parsed.success) { + const issues = parsed.error.issues + .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`) + .join("\n"); + throw new Error(`Invalid environment:\n${issues}`); + } + const env = parsed.data; + + // Dev auth trusts a fake identity, so it must never be reachable from outside the machine. + if (env.AUTH_MODE === "dev") { + if (env.NODE_ENV === "production") { + throw new Error("AUTH_MODE=dev cannot be used with NODE_ENV=production. Use AUTH_MODE=easyauth."); + } + if (!LOCAL_HOSTS.has(env.HOST) && !env.DEV_AUTH_ALLOW_REMOTE) { + throw new Error( + `AUTH_MODE=dev only binds to localhost, but HOST=${env.HOST}. Set HOST=127.0.0.1, or set ` + + "DEV_AUTH_ALLOW_REMOTE=true if this is a container whose port you publish only to your own machine.", + ); + } + } + if (env.AUTH_MODE === "easyauth" && env.EASYAUTH_VALIDATE_TOKEN && !env.EASYAUTH_CLIENT_ID) { + throw new Error( + "EASYAUTH_VALIDATE_TOKEN=true requires EASYAUTH_CLIENT_ID (the app registration client id).", + ); + } + if (env.APP_ENCRYPTION_KEY) { + const bytes = Buffer.from(env.APP_ENCRYPTION_KEY, "base64"); + if (bytes.length !== 32) { + throw new Error("APP_ENCRYPTION_KEY must be 32 bytes, base64 encoded: openssl rand -base64 32"); + } + } + return env; +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..19f6f81 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env bun +import { runMigrations } from "@dashflow/db/migrate"; +import { createApp } from "./app"; +import { ensureBootstrapCode } from "./auth/bootstrap"; +import { createContext } from "./context"; +import { loadEnv } from "./env"; +import { seedDemoConnection } from "./sync/demo-seed"; +import { startWorker } from "./worker"; + +const env = loadEnv(); +const ctx = await createContext(env); + +ctx.log.info( + { role: env.APP_ROLE, authMode: env.AUTH_MODE, demoMode: env.DEMO_MODE, nodeEnv: env.NODE_ENV }, + "starting DashFlow", +); + +await runMigrations(ctx.db, (msg) => ctx.log.info(msg)); +await ensureBootstrapCode(ctx.db, ctx.log); +if (env.DEMO_MODE) await seedDemoConnection(ctx); + +const stoppers: (() => Promise)[] = []; + +if (env.APP_ROLE === "worker" || env.APP_ROLE === "all") { + stoppers.push(await startWorker(ctx)); +} + +let server: ReturnType | null = null; +if (env.APP_ROLE === "web" || env.APP_ROLE === "all") { + const app = createApp(ctx); + server = Bun.serve({ + port: env.PORT, + hostname: env.HOST, + idleTimeout: 120, + fetch: app.fetch, + }); + ctx.log.info(`listening on http://${env.HOST}:${env.PORT}`); + if (env.AUTH_MODE === "dev") { + ctx.log.warn( + `AUTH_MODE=dev — every request is treated as ${env.DEV_USER_EMAIL}. Never use this in production.`, + ); + if (env.DEV_AUTH_ALLOW_REMOTE) { + ctx.log.warn( + `dev auth is listening on ${env.HOST} — anyone who can reach this port is an owner. Publish it to 127.0.0.1 only.`, + ); + } + } +} + +async function shutdown(signal: string) { + if (ctx.shuttingDown) return; + ctx.shuttingDown = true; + ctx.log.info({ signal }, "shutting down"); + await Promise.all(stoppers.map((stop) => stop())); + server?.stop(); + await ctx.sql.end({ timeout: 5 }); + process.exit(0); +} + +process.on("SIGTERM", () => void shutdown("SIGTERM")); +process.on("SIGINT", () => void shutdown("SIGINT")); diff --git a/apps/server/src/lib/events.ts b/apps/server/src/lib/events.ts new file mode 100644 index 0000000..7a143f0 --- /dev/null +++ b/apps/server/src/lib/events.ts @@ -0,0 +1,43 @@ +/** + * A tiny in-process event bus for the progress stream the setup wizard watches. + * Web and worker can run as separate containers, so the web process also listens for + * Postgres NOTIFY — see subscribeToDatabase below. + */ + +export type AppEvent = + | { type: "sync:start"; connectionId: string; tenantId: string; stream: string } + | { + type: "sync:done"; + connectionId: string; + tenantId: string; + stream: string; + stats: Record; + } + | { type: "sync:error"; connectionId: string; tenantId: string; stream: string; error: string } + | { type: "discovery:done"; connectionId: string; summary: Record }; + +type Listener = (event: AppEvent) => void; + +const listeners = new Set(); +let forwarder: ((event: AppEvent) => void) | null = null; + +export function subscribe(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function publish(event: AppEvent): void { + for (const listener of listeners) { + try { + listener(event); + } catch { + // a broken SSE client must never break a sync + } + } + forwarder?.(event); +} + +/** Called by the worker so events reach web replicas through Postgres. */ +export function setForwarder(fn: ((event: AppEvent) => void) | null): void { + forwarder = fn; +} diff --git a/apps/server/src/lib/log.ts b/apps/server/src/lib/log.ts new file mode 100644 index 0000000..fadc60e --- /dev/null +++ b/apps/server/src/lib/log.ts @@ -0,0 +1,67 @@ +import { pino } from "pino"; + +/** + * Anything that could carry a credential or a bearer token is redacted before it reaches + * a log sink. Response bodies from Microsoft / Nerdio are never logged whole — call sites + * log a status code and a short message only. + */ +const REDACT_PATHS = [ + "authorization", + "Authorization", + "password", + "client_secret", + "clientSecret", + "refresh_token", + "refreshToken", + "access_token", + "accessToken", + "id_token", + "secret", + "apiKey", + "api_key", + "*.authorization", + "*.Authorization", + "*.password", + "*.client_secret", + "*.refresh_token", + "*.access_token", + "*.secret", + "headers.authorization", + "headers.cookie", + "headers['x-ms-client-principal']", + "headers['x-ms-token-aad-id-token']", + "headers['x-ms-token-aad-access-token']", + "headers['x-ms-token-aad-refresh-token']", + "req.headers.authorization", + "req.headers.cookie", +]; + +export const SENSITIVE_KEY_PATTERN = + /(secret|password|token|credential|authorization|apikey|api_key|connectionstring)/i; + +/** Recursively replace values whose key looks sensitive. Used for anything we echo back. */ +export function scrub(value: T, depth = 0): T { + if (depth > 6 || value == null) return value; + if (Array.isArray(value)) return value.map((v) => scrub(v, depth + 1)) as unknown as T; + if (typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = SENSITIVE_KEY_PATTERN.test(k) ? "[redacted]" : scrub(v, depth + 1); + } + return out as T; + } + return value; +} + +export function createLogger(opts: { level: string; pretty: boolean }) { + return pino({ + level: opts.level, + redact: { paths: REDACT_PATHS, censor: "[redacted]" }, + base: undefined, + transport: opts.pretty + ? { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } + : undefined, + }); +} + +export type Logger = ReturnType; diff --git a/apps/server/src/lib/secrets.ts b/apps/server/src/lib/secrets.ts new file mode 100644 index 0000000..1191472 --- /dev/null +++ b/apps/server/src/lib/secrets.ts @@ -0,0 +1,148 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import type { SecretInput } from "@dashflow/core"; +import { type Db, schema } from "@dashflow/db"; +import { eq } from "drizzle-orm"; +import type { Env } from "../env"; +import type { Logger } from "./log"; + +/** + * Secrets live in one of two places and never anywhere else: + * - Key Vault, read through the container's managed identity (the DB keeps only the name) + * - Postgres, AES-256-GCM encrypted with APP_ENCRYPTION_KEY + * Plaintext is fetched on demand, cached briefly in memory, and never logged or returned by the API. + */ +const CACHE_TTL_MS = 5 * 60_000; + +export class SecretError extends Error {} + +export class SecretStore { + private readonly db: Db; + private readonly env: Env; + private readonly log: Logger; + private readonly cache = new Map(); + private kvClient: { getSecret(name: string): Promise<{ value?: string }> } | null = null; + + constructor(deps: { db: Db; env: Env; log: Logger }) { + this.db = deps.db; + this.env = deps.env; + this.log = deps.log; + } + + get canEncrypt(): boolean { + return this.env.APP_ENCRYPTION_KEY.length > 0; + } + + get canUseKeyVault(): boolean { + return this.env.KEY_VAULT_URI.length > 0; + } + + private key(): Buffer { + if (!this.canEncrypt) { + throw new SecretError( + "APP_ENCRYPTION_KEY is not set, so secrets cannot be stored. Generate one with: openssl rand -base64 32", + ); + } + return Buffer.from(this.env.APP_ENCRYPTION_KEY, "base64"); + } + + /** Store a secret and return its row id, or null when the input carries no secret. */ + async put(input: SecretInput): Promise { + if (input.kind === "none") return null; + if (input.kind === "keyvault") { + if (!this.canUseKeyVault) { + throw new SecretError("KEY_VAULT_URI is not configured, so Key Vault references cannot be used."); + } + // Fail fast if the identity cannot actually read it. + await this.readKeyVault(input.secretName); + const [row] = await this.db + .insert(schema.secrets) + .values({ kind: "keyvault", keyVaultSecretName: input.secretName }) + .returning({ id: schema.secrets.id }); + return row!.id; + } + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", this.key(), iv); + const ciphertext = Buffer.concat([cipher.update(input.value, "utf8"), cipher.final()]); + const [row] = await this.db + .insert(schema.secrets) + .values({ + kind: "encrypted", + ciphertext: ciphertext.toString("base64"), + iv: iv.toString("base64"), + authTag: cipher.getAuthTag().toString("base64"), + }) + .returning({ id: schema.secrets.id }); + return row!.id; + } + + async replace(secretId: string | null, input: SecretInput): Promise { + if (input.kind === "none") return secretId; + const next = await this.put(input); + if (secretId) await this.db.delete(schema.secrets).where(eq(schema.secrets.id, secretId)); + this.cache.delete(secretId ?? ""); + return next; + } + + async get(secretId: string | null | undefined): Promise { + if (!secretId) return null; + const cached = this.cache.get(secretId); + if (cached && cached.expires > Date.now()) return cached.value; + + const row = await this.db.query.secrets.findFirst({ where: eq(schema.secrets.id, secretId) }); + if (!row) return null; + + let value: string; + if (row.kind === "keyvault") { + value = await this.readKeyVault(row.keyVaultSecretName!); + } else { + if (!row.ciphertext || !row.iv || !row.authTag) throw new SecretError("Stored secret is incomplete"); + const decipher = createDecipheriv("aes-256-gcm", this.key(), Buffer.from(row.iv, "base64")); + decipher.setAuthTag(Buffer.from(row.authTag, "base64")); + try { + value = Buffer.concat([ + decipher.update(Buffer.from(row.ciphertext, "base64")), + decipher.final(), + ]).toString("utf8"); + } catch { + throw new SecretError( + "Could not decrypt a stored secret. APP_ENCRYPTION_KEY has probably changed — re-enter the credential.", + ); + } + } + this.cache.set(secretId, { value, expires: Date.now() + CACHE_TTL_MS }); + return value; + } + + async delete(secretId: string | null | undefined): Promise { + if (!secretId) return; + this.cache.delete(secretId); + await this.db.delete(schema.secrets).where(eq(schema.secrets.id, secretId)); + } + + private async readKeyVault(name: string): Promise { + const client = await this.keyVault(); + try { + const secret = await client.getSecret(name); + if (!secret.value) throw new SecretError(`Key Vault secret "${name}" has no value`); + return secret.value; + } catch (err) { + this.log.warn({ secretName: name, err: (err as Error).message }, "key vault read failed"); + throw new SecretError( + `Could not read Key Vault secret "${name}". Check the vault URI and that this app's identity has the Key Vault Secrets User role.`, + ); + } + } + + private async keyVault() { + if (this.kvClient) return this.kvClient; + const [{ SecretClient }, { DefaultAzureCredential }] = await Promise.all([ + import("@azure/keyvault-secrets"), + import("@azure/identity"), + ]); + const credential = new DefaultAzureCredential( + this.env.AZURE_CLIENT_ID ? { managedIdentityClientId: this.env.AZURE_CLIENT_ID } : {}, + ); + this.kvClient = new SecretClient(this.env.KEY_VAULT_URI, credential); + return this.kvClient; + } +} diff --git a/apps/server/src/query/engine.test.ts b/apps/server/src/query/engine.test.ts new file mode 100644 index 0000000..c1d5d55 --- /dev/null +++ b/apps/server/src/query/engine.test.ts @@ -0,0 +1,199 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import type { Access, QueryRequest } from "@dashflow/core"; +import { createDb, schema } from "@dashflow/db"; +import { runMigrations } from "@dashflow/db/migrate"; +import { sql } from "drizzle-orm"; +import { QueryError, runQuery } from "./engine"; + +/** + * These tests run real SQL, because the thing worth proving — that a Viewer scoped to one + * customer cannot read another, and that filter values cannot break out into SQL — only + * holds end to end. Set TEST_DATABASE_URL to run them: + * TEST_DATABASE_URL=postgres://user@localhost:5432/avd_test bun test + */ +const url = process.env.TEST_DATABASE_URL; +const maybe = url ? describe : describe.skip; + +maybe("query engine", () => { + const { db, client } = createDb({ url: url ?? "postgres://invalid", max: 2 }); + const tenantA = "aaaaaaaa-0000-4000-8000-000000000001"; + const tenantB = "bbbbbbbb-0000-4000-8000-000000000002"; + const poolA = + "/subscriptions/s/resourcegroups/a/providers/microsoft.desktopvirtualization/hostpools/pool-a"; + const poolB = + "/subscriptions/s/resourcegroups/b/providers/microsoft.desktopvirtualization/hostpools/pool-b"; + const from = new Date("2026-09-10T00:00:00Z"); + const to = new Date("2026-09-11T00:00:00Z"); + + const request = (overrides: Partial = {}): QueryRequest => ({ + metric: "connections.count", + viz: "kpi", + topN: 8, + grain: "hour", + from, + to, + tenantIds: null, + filters: {}, + compare: false, + ...overrides, + }); + + const all: Access = { role: "owner", tenantScope: null }; + const onlyA: Access = { role: "viewer", tenantScope: [tenantA] }; + + beforeAll(async () => { + await runMigrations(db, () => {}); + // A clean slate for each run; every fixture id is a placeholder GUID. + await db.execute(sql`truncate connection_facts, session_snapshots, host_pools, customer_tenants, + connections, error_facts, host_health, perf_hourly, cost_daily cascade`); + + const [connection] = await db + .insert(schema.connections) + .values({ + name: "test", + type: "demo", + config: { type: "demo", tenants: 2 }, + schedule: { + inventoryMinutes: 60, + sessionsMinutes: 5, + logsMinutes: 15, + costHours: 24, + backfillDays: 30, + retentionDays: 180, + }, + }) + .returning({ id: schema.connections.id }); + + await db.insert(schema.customerTenants).values([ + { id: tenantA, connectionId: connection!.id, tenantId: "tenant-a", displayName: "Customer A" }, + { id: tenantB, connectionId: connection!.id, tenantId: "tenant-b", displayName: "Customer B" }, + ]); + await db.insert(schema.hostPools).values([ + { + resourceId: poolA, + customerTenantId: tenantA, + name: "pool-a", + location: "eastus", + poolType: "Pooled", + }, + { + resourceId: poolB, + customerTenantId: tenantB, + name: "pool-b", + location: "eastus", + poolType: "Pooled", + }, + ]); + + // 10 connections for A (2 failed), 4 for B. + const facts = [ + ...Array.from({ length: 10 }, (_, i) => ({ + correlationId: `a-${i}`, + tenantId: tenantA, + hostPoolId: poolA, + ts: new Date(from.getTime() + i * 3600_000), + userKey: `user-${i % 5}`, + clientOs: i % 2 === 0 ? "Windows 11" : "macOS", + state: (i < 2 ? "failed" : "connected") as "failed" | "connected", + connectMs: 1000 + i * 100, + })), + ...Array.from({ length: 4 }, (_, i) => ({ + correlationId: `b-${i}`, + tenantId: tenantB, + hostPoolId: poolB, + ts: new Date(from.getTime() + i * 3600_000), + userKey: `other-${i}`, + clientOs: "Windows 11", + state: "connected" as const, + connectMs: 5000, + })), + ]; + await db.insert(schema.connectionFacts).values(facts); + }); + + test("an unrestricted user sees every customer", async () => { + const result = await runQuery(db, request(), all); + expect(result.kind).toBe("scalar"); + if (result.kind === "scalar") expect(result.value).toBe(14); + }); + + test("a scoped viewer only ever sees their own customer", async () => { + const result = await runQuery(db, request(), onlyA); + if (result.kind === "scalar") expect(result.value).toBe(10); + }); + + test("a scoped viewer asking for someone else's tenant gets nothing, not everything", async () => { + const result = await runQuery(db, request({ tenantIds: [tenantB] }), onlyA); + // Zero rows, and emphatically not the 14 an unscoped query returns. + if (result.kind === "scalar") expect(result.value).toBe(0); + }); + + test("a host pool filter cannot reach across the tenant scope", async () => { + const result = await runQuery(db, request({ filters: { hostPools: [poolB] } }), onlyA); + if (result.kind === "scalar") expect(result.value).toBe(0); + }); + + test("filter values are parameters, not SQL", async () => { + const nasty = "Windows 11'; drop table connection_facts; --"; + const result = await runQuery(db, request({ filters: { dims: { clientOs: [nasty] } } }), all); + // Treated as a literal value that matches nothing. + if (result.kind === "scalar") expect(result.value).toBe(0); + // The table is still there, and still has every row. + const check = await runQuery(db, request(), all); + if (check.kind === "scalar") expect(check.value).toBe(14); + }); + + test("dimension filters work for the legitimate case", async () => { + const result = await runQuery(db, request({ filters: { dims: { clientOs: ["macOS"] } } }), all); + if (result.kind === "scalar") expect(result.value).toBe(5); + }); + + test("series are gap-filled across the whole range", async () => { + const result = await runQuery(db, request({ viz: "line", metric: "connections.count" }), all); + expect(result.kind).toBe("series"); + if (result.kind === "series") { + expect(result.series).toHaveLength(1); + // 24 hourly buckets plus the closing boundary. + expect(result.series[0]!.points.length).toBeGreaterThanOrEqual(24); + expect(result.series[0]!.points.some((point) => point.value === null)).toBe(true); + } + }); + + test("breakdowns rank groups and honour topN", async () => { + const result = await runQuery(db, request({ viz: "bar", groupBy: "tenant", topN: 1 }), all); + if (result.kind === "breakdown") { + expect(result.items).toHaveLength(1); + expect(result.items[0]!.name).toBe("Customer A"); + } + }); + + test("a rate metric computes from the same rows", async () => { + const result = await runQuery(db, request({ metric: "connections.success_rate" }), onlyA); + if (result.kind === "scalar") expect(result.value).toBeCloseTo(80, 5); + }); + + test("an unknown metric or a wrong visualization is refused", async () => { + await expect(runQuery(db, request({ metric: "nope" }), all)).rejects.toThrow(QueryError); + await expect(runQuery(db, request({ metric: "sessions.peak", viz: "donut" }), all)).rejects.toThrow( + QueryError, + ); + }); + + test("an empty range is refused", async () => { + await expect(runQuery(db, request({ from: to, to: from }), all)).rejects.toThrow(/empty/); + }); + + test("an invalid time zone never reaches SQL", async () => { + await expect( + runQuery(db, request({ viz: "line" }), all, { tz: "'; drop table users; --" }), + ).rejects.toThrow(/time zone/); + }); + + test("disabling a customer hides its data immediately", async () => { + await db.execute(sql`update customer_tenants set enabled = false where id = ${tenantB}::uuid`); + const result = await runQuery(db, request(), all); + if (result.kind === "scalar") expect(result.value).toBe(10); + await db.execute(sql`update customer_tenants set enabled = true where id = ${tenantB}::uuid`); + await client.end(); + }); +}); diff --git a/apps/server/src/query/engine.ts b/apps/server/src/query/engine.ts new file mode 100644 index 0000000..2fdafd5 --- /dev/null +++ b/apps/server/src/query/engine.ts @@ -0,0 +1,508 @@ +import { + type Access, + type DimensionId, + getMetric, + type MetricDef, + type QueryRequest, + type QueryResult, + type Series, + scopeTenants, + type TimeGrain, +} from "@dashflow/core"; +import type { Db } from "@dashflow/db"; +import { type SQL, sql } from "drizzle-orm"; + +/** + * Compiles a validated QueryRequest into one parameterized SQL statement. + * + * Only two things ever become SQL text: fragments from the metric catalog (they live in + * code, not in the database) and a grain keyword from a closed list. Every value a user + * can influence — time range, tenant ids, host pool ids, dimension filters, top N — is + * bound as a parameter. That is what keeps dashboard building safe for Analysts. + */ + +export class QueryError extends Error {} + +const TZ_PATTERN = /^[A-Za-z][A-Za-z0-9_+\-/]{1,63}$/; + +export interface QueryOptions { + tz?: string; + /** Currency label for cost metrics; resolved from synced data. */ + currency?: string; +} + +interface Compiled { + metric: MetricDef; + /** naive-local bucket expression, or null for non-time queries */ + where: SQL; + from: SQL; +} + +function grainKeyword(grain: TimeGrain): SQL { + // closed list → safe to inline + return sql.raw(grain === "day" ? "'day'" : "'hour'"); +} + +function intervalFor(grain: TimeGrain): SQL { + return sql.raw(grain === "day" ? "interval '1 day'" : "interval '1 hour'"); +} + +function tzOf(opts: QueryOptions): string { + const tz = opts.tz ?? "UTC"; + if (!TZ_PATTERN.test(tz)) throw new QueryError("Invalid time zone"); + return tz; +} + +/** + * Bind a timestamp as an ISO string with an explicit cast. drizzle's raw `execute` hands + * parameters straight to postgres.js, which cannot serialize a JS Date there. + */ +function ts(date: Date): SQL { + return sql`${date.toISOString()}::timestamptz`; +} + +/** + * Build `array[$1, $2]::type[]`. Each element is its own bind parameter: postgres.js cannot + * infer an array type for a single JS array parameter in raw SQL, and binding element by + * element keeps user-supplied values as values. + */ +export function arrayParam(values: string[], castTo: "uuid" | "text"): SQL { + const cast = sql.raw(`::${castTo}[]`); + if (values.length === 0) return sql`array[]${cast}`; + return sql`array[${sql.join( + values.map((value) => sql`${value}`), + sql`, `, + )}]${cast}`; +} + +export function dimensionExpression(metric: MetricDef, dim: DimensionId): SQL { + const expr = metric.dims[dim]; + if (!expr) throw new QueryError(`Metric ${metric.id} cannot be grouped or filtered by ${dim}`); + return sql.raw(expr); +} + +/** FROM + joins. Facts always carry tenant_id; host_pool_id may be null. */ +function buildFrom(metric: MetricDef): SQL { + return sql` + from ${sql.raw(metric.table)} f + join customer_tenants ct on ct.id = f.tenant_id + left join host_pools hp on hp.resource_id = f.host_pool_id`; +} + +function buildWhere(metric: MetricDef, req: QueryRequest, access: Access): SQL { + const tenants = scopeTenants(access, req.tenantIds); + const parts: SQL[] = [ + sql`f.${sql.raw(metric.tsColumn)} >= ${ts(req.from)}`, + sql`f.${sql.raw(metric.tsColumn)} < ${ts(req.to)}`, + sql`ct.enabled`, + sql`(hp.resource_id is null or hp.enabled)`, + ]; + if (metric.where) parts.push(sql`(${sql.raw(metric.where)})`); + + if (tenants !== null) { + if (tenants.length === 0) return sql`false`; // scoped to nothing → no rows, not all rows + parts.push(sql`f.tenant_id = any(${arrayParam(tenants, "uuid")})`); + } + const pools = req.filters.hostPools; + if (pools && pools.length > 0) parts.push(sql`f.host_pool_id = any(${arrayParam(pools, "text")})`); + + for (const [dim, values] of Object.entries(req.filters.dims ?? {})) { + if (!values || values.length === 0) continue; + parts.push(sql`${dimensionExpression(metric, dim as DimensionId)} = any(${arrayParam(values, "text")})`); + } + return sql.join(parts, sql` and `); +} + +function compile(metric: MetricDef, req: QueryRequest, access: Access): Compiled { + return { metric, from: buildFrom(metric), where: buildWhere(metric, req, access) }; +} + +/** How to combine per-bucket values when ranking groups or totalling a series. */ +function rankAgg(metric: MetricDef): SQL { + if (metric.stage2 === "max") return sql.raw("max"); + if (metric.unit === "percent" || metric.unit === "ms" || metric.unit === "seconds") return sql.raw("avg"); + return sql.raw("sum"); +} + +function num(value: unknown): number | null { + if (value === null || value === undefined) return null; + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? n : null; +} + +type Row = Record; + +export async function rowsFrom(db: Db, statement: SQL): Promise { + const result = await db.execute(statement); + return (Array.isArray(result) ? result : ((result as { rows?: Row[] }).rows ?? [])) as Row[]; +} + +// ─── scalar ───────────────────────────────────────────────────────────────────── + +async function scalar(db: Db, c: Compiled, _req: QueryRequest, _opts: QueryOptions): Promise { + const { metric } = c; + const agg = sql.raw(metric.agg); + if (metric.innerKey && metric.stage2) { + const statement = sql` + select ${sql.raw(metric.stage2)}(inner_value) as value from ( + select ${sql.raw(metric.innerKey)} as inner_key, ${agg} as inner_value + ${c.from} + where ${c.where} + group by 1 + ) s`; + const [row] = await rowsFrom(db, statement); + return num(row?.value); + } + const [row] = await rowsFrom(db, sql`select ${agg} as value ${c.from} where ${c.where}`); + return num(row?.value); +} + +// ─── series ───────────────────────────────────────────────────────────────────── + +async function series( + db: Db, + c: Compiled, + req: QueryRequest, + opts: QueryOptions, + fill: boolean, +): Promise { + const { metric } = c; + const tz = tzOf(opts); + const grain = grainKeyword(req.grain); + const agg = sql.raw(metric.agg); + const group = req.groupBy ? dimensionExpression(metric, req.groupBy) : null; + const grpSelect = group ? sql`coalesce(${group}::text, 'Unknown')` : sql`'total'::text`; + + const inner = metric.innerKey + ? sql` + select date_trunc(${grain}, f.${sql.raw(metric.tsColumn)} at time zone ${tz}) as bucket, + ${grpSelect} as grp, + ${sql.raw(metric.innerKey)} as inner_key, + ${agg} as value + ${c.from} + where ${c.where} + group by 1, 2, 3` + : sql` + select date_trunc(${grain}, f.${sql.raw(metric.tsColumn)} at time zone ${tz}) as bucket, + ${grpSelect} as grp, + ${agg} as value + ${c.from} + where ${c.where} + group by 1, 2`; + + const staged = + metric.innerKey && metric.stage2 + ? sql`select bucket, grp, ${sql.raw(metric.stage2)}(value) as value from (${inner}) i group by 1, 2` + : inner; + + const statement = fill + ? sql` + with staged as (${staged}), + top_groups as ( + select grp from staged group by grp order by ${rankAgg(metric)}(value) desc nulls last limit ${req.topN} + ), + buckets as ( + select b as bucket from generate_series( + date_trunc(${grain}, ${ts(req.from)} at time zone ${tz}), + date_trunc(${grain}, ${ts(req.to)} at time zone ${tz}), + ${intervalFor(req.grain)} + ) as b + ) + select (b.bucket at time zone ${tz}) as ts, g.grp as grp, s.value as value + from buckets b + cross join top_groups g + left join staged s on s.bucket = b.bucket and s.grp = g.grp + order by g.grp, b.bucket` + : sql` + with staged as (${staged}) + select (bucket at time zone ${tz}) as ts, grp, value from staged order by grp, bucket`; + + const result = await rowsFrom(db, statement); + const byGroup = new Map(); + for (const row of result) { + const name = String(row.grp ?? "total"); + let s = byGroup.get(name); + if (!s) { + s = { name, points: [] }; + byGroup.set(name, s); + } + s.points.push({ ts: new Date(row.ts as string).toISOString(), value: num(row.value) }); + } + return [...byGroup.values()]; +} + +// ─── breakdown / table ────────────────────────────────────────────────────────── + +async function breakdown(db: Db, c: Compiled, req: QueryRequest): Promise<{ name: string; value: number }[]> { + const { metric } = c; + const agg = sql.raw(metric.agg); + const group = req.groupBy ? dimensionExpression(metric, req.groupBy) : null; + const grpSelect = group ? sql`coalesce(${group}::text, 'Unknown')` : sql`'total'::text`; + + const statement = + metric.innerKey && metric.stage2 + ? sql` + select grp, ${sql.raw(metric.stage2)}(value) as value from ( + select ${grpSelect} as grp, ${sql.raw(metric.innerKey)} as inner_key, ${agg} as value + ${c.from} where ${c.where} group by 1, 2 + ) i + group by 1 order by 2 desc nulls last limit ${req.topN}` + : sql` + select ${grpSelect} as grp, ${agg} as value + ${c.from} where ${c.where} + group by 1 order by 2 desc nulls last limit ${req.topN}`; + + return (await rowsFrom(db, statement)) + .map((row) => ({ name: String(row.grp ?? "Unknown"), value: num(row.value) ?? 0 })) + .filter((item) => item.name !== ""); +} + +// ─── heatmap ──────────────────────────────────────────────────────────────────── + +async function heatmap( + db: Db, + c: Compiled, + _req: QueryRequest, + opts: QueryOptions, +): Promise<{ day: number; hour: number; value: number }[]> { + const { metric } = c; + const tz = tzOf(opts); + const agg = sql.raw(metric.agg); + const local = sql`(f.${sql.raw(metric.tsColumn)} at time zone ${tz})`; + + const statement = + metric.innerKey && metric.stage2 + ? sql` + select day, hour, ${sql.raw(metric.stage2)}(value) as value from ( + select extract(isodow from ${local})::int - 1 as day, + extract(hour from ${local})::int as hour, + ${sql.raw(metric.innerKey)} as inner_key, + ${agg} as value + ${c.from} where ${c.where} group by 1, 2, 3 + ) i + group by 1, 2` + : sql` + select extract(isodow from ${local})::int - 1 as day, + extract(hour from ${local})::int as hour, + ${agg} as value + ${c.from} where ${c.where} group by 1, 2`; + + return (await rowsFrom(db, statement)).map((row) => ({ + day: Number(row.day), + hour: Number(row.hour), + value: num(row.value) ?? 0, + })); +} + +// ─── host grid ────────────────────────────────────────────────────────────────── + +async function hostGrid(db: Db, req: QueryRequest, access: Access) { + const tenants = scopeTenants(access, req.tenantIds); + const filters: SQL[] = [sql`ct.enabled`, sql`hp.enabled`]; + if (tenants !== null) { + if (tenants.length === 0) return []; + filters.push(sql`sh.customer_tenant_id = any(${arrayParam(tenants, "uuid")})`); + } + const pools = req.filters.hostPools; + if (pools && pools.length > 0) filters.push(sql`sh.host_pool_id = any(${arrayParam(pools, "text")})`); + + const result = await rowsFrom( + db, + sql` + select sh.name, coalesce(hp.friendly_name, hp.name) as host_pool, ct.display_name as tenant, + sh.status, sh.sessions, sh.allow_new_session, sh.agent_version, sh.last_heart_beat + from session_hosts sh + join host_pools hp on hp.resource_id = sh.host_pool_id + join customer_tenants ct on ct.id = sh.customer_tenant_id + where ${sql.join(filters, sql` and `)} + order by (sh.status = 'Available') asc, ct.display_name, hp.name, sh.name + limit 500`, + ); + return result.map((row) => ({ + name: String(row.name), + hostPool: String(row.host_pool ?? "—"), + tenant: String(row.tenant ?? "—"), + status: String(row.status ?? "Unknown"), + sessions: Number(row.sessions ?? 0), + allowNewSession: Boolean(row.allow_new_session), + agentVersion: (row.agent_version as string | null) ?? null, + lastHeartBeat: row.last_heart_beat ? new Date(row.last_heart_beat as string).toISOString() : null, + })); +} + +// ─── entry point ──────────────────────────────────────────────────────────────── + +function sparkGrain(req: QueryRequest): TimeGrain { + const hours = (req.to.getTime() - req.from.getTime()) / 3600_000; + return hours > 24 * 10 ? "day" : "hour"; +} + +function previousRange(req: QueryRequest): QueryRequest { + const span = req.to.getTime() - req.from.getTime(); + return { ...req, from: new Date(req.from.getTime() - span), to: new Date(req.from.getTime()) }; +} + +export async function runQuery( + db: Db, + req: QueryRequest, + access: Access, + opts: QueryOptions = {}, +): Promise { + const metric = getMetric(req.metric); + if (!metric) throw new QueryError(`Unknown metric ${req.metric}`); + if (!metric.viz.includes(req.viz)) throw new QueryError(`${metric.label} cannot be shown as ${req.viz}`); + if (req.to <= req.from) throw new QueryError("The time range is empty"); + + const currency = metric.unit === "currency" ? (opts.currency ?? "USD") : undefined; + + // Ratio metrics are two metrics divided, so the numerator and denominator can come from + // different tables (cost per user is cost_daily over connection_facts). + if (metric.ratioOf) { + return ratioQuery(db, metric, req, access, opts); + } + + const c = compile(metric, req, access); + + switch (req.viz) { + case "kpi": { + const value = await scalar(db, c, req, opts); + const previous = req.compare + ? await scalar(db, compile(metric, previousRange(req), access), previousRange(req), opts) + : undefined; + const sparkReq = { ...req, grain: sparkGrain(req), groupBy: undefined, topN: 1 }; + const spark = await series(db, compile(metric, sparkReq, access), sparkReq, opts, true); + return { kind: "scalar", unit: metric.unit, value, previous, spark: spark[0]?.points, currency }; + } + case "line": + case "area": + case "stacked-bar": + return { + kind: "series", + unit: metric.unit, + grain: req.grain, + series: await series(db, c, req, opts, true), + currency, + }; + case "bar": + case "donut": + return { kind: "breakdown", unit: metric.unit, items: await breakdown(db, c, req), currency }; + case "table": { + const items = await breakdown(db, c, req); + const label = req.groupBy ?? "total"; + return { + kind: "table", + unit: metric.unit, + columns: [ + { key: "name", label: label === "total" ? "All" : label }, + { key: "value", label: metric.label, numeric: true }, + ], + rows: items.map((item) => ({ name: item.name, value: item.value })), + currency, + }; + } + case "heatmap": + return { kind: "heatmap", unit: metric.unit, cells: await heatmap(db, c, req, opts), currency }; + case "host-grid": + return { kind: "hosts", hosts: await hostGrid(db, req, access) }; + } +} + +async function ratioQuery( + db: Db, + metric: MetricDef, + req: QueryRequest, + access: Access, + opts: QueryOptions, +): Promise { + const numerator = getMetric(metric.ratioOf!.numerator); + const denominator = getMetric(metric.ratioOf!.denominator); + if (!numerator || !denominator) throw new QueryError(`Metric ${metric.id} references an unknown metric`); + const scale = metric.ratioOf!.scale ?? 1; + const currency = metric.unit === "currency" ? (opts.currency ?? "USD") : undefined; + + const divide = (a: number | null, b: number | null) => + a === null || b === null || b === 0 ? null : (a / b) * scale; + + const asReq = (m: MetricDef, r: QueryRequest): QueryRequest => ({ + ...r, + metric: m.id, + // Drop group/filter dimensions the other metric does not have. + groupBy: r.groupBy && m.dims[r.groupBy] ? r.groupBy : undefined, + filters: { + hostPools: r.filters.hostPools, + dims: Object.fromEntries( + Object.entries(r.filters.dims ?? {}).filter(([dim]) => m.dims[dim as DimensionId] !== undefined), + ) as QueryRequest["filters"]["dims"], + }, + }); + + switch (req.viz) { + case "kpi": { + const [a, b] = await Promise.all([ + scalar(db, compile(numerator, asReq(numerator, req), access), asReq(numerator, req), opts), + scalar(db, compile(denominator, asReq(denominator, req), access), asReq(denominator, req), opts), + ]); + let previous: number | null | undefined; + if (req.compare) { + const prev = previousRange(req); + const [pa, pb] = await Promise.all([ + scalar(db, compile(numerator, asReq(numerator, prev), access), asReq(numerator, prev), opts), + scalar(db, compile(denominator, asReq(denominator, prev), access), asReq(denominator, prev), opts), + ]); + previous = divide(pa, pb); + } + return { kind: "scalar", unit: metric.unit, value: divide(a, b), previous, currency }; + } + case "line": + case "area": + case "stacked-bar": { + const nReq = asReq(numerator, req); + const dReq = asReq(denominator, req); + const [ns, ds] = await Promise.all([ + series(db, compile(numerator, nReq, access), nReq, opts, true), + series(db, compile(denominator, dReq, access), dReq, opts, true), + ]); + const denomByGroupTs = new Map(); + for (const s of ds) for (const p of s.points) denomByGroupTs.set(`${s.name}|${p.ts}`, p.value); + const combined: Series[] = ns.map((s) => ({ + name: s.name, + points: s.points.map((p) => ({ + ts: p.ts, + value: divide( + p.value, + denomByGroupTs.get(`${s.name}|${p.ts}`) ?? denomByGroupTs.get(`total|${p.ts}`) ?? null, + ), + })), + })); + return { kind: "series", unit: metric.unit, grain: req.grain, series: combined, currency }; + } + default: { + const nReq = asReq(numerator, req); + const dReq = asReq(denominator, req); + const [ni, di] = await Promise.all([ + breakdown(db, compile(numerator, nReq, access), nReq), + breakdown(db, compile(denominator, dReq, access), dReq), + ]); + const denomByName = new Map(di.map((item) => [item.name, item.value])); + const items = ni + .map((item) => ({ + name: item.name, + value: divide(item.value, denomByName.get(item.name) ?? denomByName.get("total") ?? null) ?? 0, + })) + .sort((a, b) => b.value - a.value); + if (req.viz === "table") { + return { + kind: "table", + unit: metric.unit, + columns: [ + { key: "name", label: req.groupBy ?? "All" }, + { key: "value", label: metric.label, numeric: true }, + ], + rows: items, + currency, + }; + } + return { kind: "breakdown", unit: metric.unit, items, currency }; + } + } +} diff --git a/apps/server/src/routes/connections.ts b/apps/server/src/routes/connections.ts new file mode 100644 index 0000000..40358e2 --- /dev/null +++ b/apps/server/src/routes/connections.ts @@ -0,0 +1,275 @@ +import { randomUUID } from "node:crypto"; +import { buildConsentUrl, redeemConsentCode } from "@dashflow/connectors"; +import { + connectionConfigSchema, + DEFAULT_SCHEDULE, + secretInputSchema, + syncScheduleSchema, +} from "@dashflow/core"; +import { schema } from "@dashflow/db"; +import { desc, eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; +import { requireAccess } from "../auth/access"; +import type { AppVariables } from "../auth/middleware"; +import type { AppContext } from "../context"; +import { scrub } from "../lib/log"; +import { runDiscovery, testConnection } from "../sync/runner"; +import { enqueue } from "../worker/queue"; + +const createSchema = z.object({ + name: z.string().min(1).max(80), + config: connectionConfigSchema, + secret: secretInputSchema.default({ kind: "none" }), + schedule: syncScheduleSchema.partial().default({}), +}); + +/** In-memory state for the Partner Center consent redirect (one instance, short-lived). */ +const consentState = new Map(); + +export function connectionRoutes(ctx: AppContext) { + const app = new Hono<{ Variables: AppVariables }>(); + + app.get("/", async (c) => { + requireAccess(c.get("user"), "admin"); + const rows = await ctx.db.select().from(schema.connections).orderBy(desc(schema.connections.createdAt)); + const tenantCounts = await ctx.db + .select({ connectionId: schema.customerTenants.connectionId, id: schema.customerTenants.id }) + .from(schema.customerTenants); + + return c.json( + rows.map((row) => ({ + id: row.id, + name: row.name, + type: row.type, + // config can hold tenant and client ids; they are not secrets, but scrub anyway. + config: scrub(row.config), + schedule: row.schedule, + enabled: row.enabled, + status: row.status, + hasSecret: Boolean(row.secretId), + consented: Boolean(row.refreshSecretId), + lastTestedAt: row.lastTestedAt, + lastTest: row.lastTest, + lastSyncAt: row.lastSyncAt, + tenantCount: tenantCounts.filter((t) => t.connectionId === row.id).length, + })), + ); + }); + + app.post("/", async (c) => { + const user = c.get("user"); + requireAccess(user, "admin"); + const body = createSchema.parse(await c.req.json()); + + if (body.config.type === "demo" && !ctx.env.DEMO_MODE) { + throw new HTTPException(400, { message: "Demo connections need DEMO_MODE=true." }); + } + if (body.secret.kind === "inline" && !ctx.secrets.canEncrypt) { + throw new HTTPException(400, { + message: "Set APP_ENCRYPTION_KEY (openssl rand -base64 32) or use a Key Vault reference.", + }); + } + + const secretId = await ctx.secrets.put(body.secret); + const [row] = await ctx.db + .insert(schema.connections) + .values({ + name: body.name, + type: body.config.type, + config: body.config, + schedule: { ...DEFAULT_SCHEDULE, ...body.schedule }, + secretId, + createdBy: user.id, + }) + .returning({ id: schema.connections.id }); + + ctx.log.info({ type: body.config.type, name: body.name }, "connection created"); + return c.json({ id: row!.id }, 201); + }); + + app.patch("/:id", async (c) => { + requireAccess(c.get("user"), "admin"); + const id = c.req.param("id"); + const connection = await ctx.db.query.connections.findFirst({ where: eq(schema.connections.id, id) }); + if (!connection) throw new HTTPException(404, { message: "No such connection" }); + + const body = z + .object({ + name: z.string().min(1).max(80).optional(), + enabled: z.boolean().optional(), + schedule: syncScheduleSchema.partial().optional(), + config: connectionConfigSchema.optional(), + secret: secretInputSchema.optional(), + }) + .parse(await c.req.json()); + + const secretId = body.secret + ? await ctx.secrets.replace(connection.secretId, body.secret) + : connection.secretId; + + await ctx.db + .update(schema.connections) + .set({ + ...(body.name ? { name: body.name } : {}), + ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), + ...(body.schedule ? { schedule: { ...connection.schedule, ...body.schedule } } : {}), + ...(body.config ? { config: body.config } : {}), + secretId, + }) + .where(eq(schema.connections.id, id)); + return c.json({ ok: true }); + }); + + app.delete("/:id", async (c) => { + requireAccess(c.get("user"), "owner"); + const id = c.req.param("id"); + const connection = await ctx.db.query.connections.findFirst({ where: eq(schema.connections.id, id) }); + if (!connection) throw new HTTPException(404, { message: "No such connection" }); + // Deleting a connection deletes its customers and their data — the UI warns first. + await ctx.db.delete(schema.connections).where(eq(schema.connections.id, id)); + await ctx.secrets.delete(connection.secretId); + await ctx.secrets.delete(connection.refreshSecretId); + ctx.log.warn({ name: connection.name }, "connection deleted"); + return c.json({ ok: true }); + }); + + app.post("/:id/test", async (c) => { + requireAccess(c.get("user"), "admin"); + const connection = await ctx.db.query.connections.findFirst({ + where: eq(schema.connections.id, c.req.param("id")), + }); + if (!connection) throw new HTTPException(404, { message: "No such connection" }); + return c.json({ checks: await testConnection(ctx, connection) }); + }); + + app.post("/:id/discover", async (c) => { + requireAccess(c.get("user"), "admin"); + const connection = await ctx.db.query.connections.findFirst({ + where: eq(schema.connections.id, c.req.param("id")), + }); + if (!connection) throw new HTTPException(404, { message: "No such connection" }); + try { + const summary = await runDiscovery(ctx, connection); + return c.json(summary); + } catch (err) { + throw new HTTPException(400, { message: (err as Error).message }); + } + }); + + /** Kick off the first sync for every enabled customer on this connection. */ + app.post("/:id/sync", async (c) => { + requireAccess(c.get("user"), "admin"); + const id = c.req.param("id"); + const backfill = c.req.query("backfill") === "true"; + const tenants = await ctx.db + .select({ id: schema.customerTenants.id }) + .from(schema.customerTenants) + .where(eq(schema.customerTenants.connectionId, id)); + + for (const tenant of tenants) { + for (const stream of ["inventory", "sessions", "logs", "cost"] as const) { + await enqueue(ctx, { + type: "sync", + payload: { connectionId: id, customerTenantId: tenant.id, stream, backfill }, + dedupeKey: `sync:${id}:${tenant.id}:${stream}`, + }); + } + } + return c.json({ queued: tenants.length * 4 }); + }); + + // ─── Partner Center consent (Secure Application Model) ──────────────────────── + + app.post("/:id/consent-url", async (c) => { + requireAccess(c.get("user"), "admin"); + const connection = await ctx.db.query.connections.findFirst({ + where: eq(schema.connections.id, c.req.param("id")), + }); + if (connection?.config.type !== "partner-center") { + throw new HTTPException(400, { message: "This is not a Partner Center connection" }); + } + const state = randomUUID(); + consentState.set(state, { connectionId: connection.id, expires: Date.now() + 15 * 60_000 }); + return c.json({ + url: buildConsentUrl({ + partnerTenantId: connection.config.partnerTenantId, + clientId: connection.config.clientId, + redirectUri: `${ctx.env.PUBLIC_BASE_URL}/api/connections/consent/callback`, + state, + }), + }); + }); + + app.get("/consent/callback", async (c) => { + const user = c.get("user"); + requireAccess(user, "admin"); + const code = c.req.query("code"); + const state = c.req.query("state"); + const error = c.req.query("error_description") ?? c.req.query("error"); + if (error) return c.html(consentPage(`Consent was not completed: ${escapeHtml(error)}`, false)); + if (!code || !state) return c.html(consentPage("The consent response was incomplete.", false)); + + const pending = consentState.get(state); + consentState.delete(state); + if (!pending || pending.expires < Date.now()) { + return c.html( + consentPage("That consent link has expired. Start again from the connection page.", false), + ); + } + + const connection = await ctx.db.query.connections.findFirst({ + where: eq(schema.connections.id, pending.connectionId), + }); + if (connection?.config.type !== "partner-center") { + return c.html(consentPage("The connection no longer exists.", false)); + } + + try { + const clientSecret = await ctx.secrets.get(connection.secretId); + if (!clientSecret) throw new Error("This connection has no client secret stored."); + const { refreshToken, upn } = await redeemConsentCode({ + partnerTenantId: connection.config.partnerTenantId, + clientId: connection.config.clientId, + clientSecret, + code, + redirectUri: `${ctx.env.PUBLIC_BASE_URL}/api/connections/consent/callback`, + }); + const refreshSecretId = await ctx.secrets.replace(connection.refreshSecretId, { + kind: "inline", + value: refreshToken, + }); + await ctx.db + .update(schema.connections) + .set({ + refreshSecretId, + config: { + ...connection.config, + consentedBy: upn ?? undefined, + consentedAt: new Date().toISOString(), + }, + }) + .where(eq(schema.connections.id, connection.id)); + ctx.log.info({ connection: connection.name }, "partner center consent stored"); + return c.html(consentPage("Consent stored. You can close this tab and continue in the app.", true)); + } catch (err) { + ctx.log.warn({ err: (err as Error).message }, "partner center consent failed"); + return c.html(consentPage(escapeHtml((err as Error).message), false)); + } + }); + + return app; +} + +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`); +} + +function consentPage(message: string, ok: boolean): string { + return `Partner Center consent + +

${ok ? "✅ Consent complete" : "⚠️ Consent problem"}

${message}

`; +} diff --git a/apps/server/src/routes/dashboards.ts b/apps/server/src/routes/dashboards.ts new file mode 100644 index 0000000..4aa30ba --- /dev/null +++ b/apps/server/src/routes/dashboards.ts @@ -0,0 +1,225 @@ +import { dashboardSchema, hasRole, type Role, roleRank, templateFor } from "@dashflow/core"; +import { schema } from "@dashflow/db"; +import { and, desc, eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; +import { requireAccess } from "../auth/access"; +import type { AppVariables } from "../auth/middleware"; +import type { AppContext } from "../context"; + +type DashboardRow = typeof schema.dashboards.$inferSelect; + +/** A dashboard is visible if you own it, or it is shared with your role or below. */ +function canRead(row: DashboardRow, userId: string, role: Role): boolean { + if (row.ownerId === userId) return true; + if (row.visibility !== "shared") return false; + return roleRank(role) >= roleRank(row.sharedWithRole); +} + +function canWrite(row: DashboardRow, userId: string, role: Role): boolean { + return row.ownerId === userId || hasRole(role, "admin"); +} + +export function dashboardRoutes(ctx: AppContext) { + const app = new Hono<{ Variables: AppVariables }>(); + + app.get("/", async (c) => { + const user = c.get("user"); + const access = requireAccess(user, "viewer"); + const [rows, favorites] = await Promise.all([ + ctx.db.select().from(schema.dashboards).orderBy(desc(schema.dashboards.updatedAt)), + ctx.db + .select({ dashboardId: schema.dashboardFavorites.dashboardId }) + .from(schema.dashboardFavorites) + .where(eq(schema.dashboardFavorites.userId, user.id)), + ]); + const favorited = new Set(favorites.map((row) => row.dashboardId)); + + return c.json( + rows + .filter((row) => canRead(row, user.id, access.role)) + .map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + goal: row.goal, + widgetCount: row.widgets.length, + visibility: row.visibility, + sharedWithRole: row.sharedWithRole, + defaultPreset: row.defaultPreset, + tenantScope: row.tenantScope, + isOwner: row.ownerId === user.id, + favorite: favorited.has(row.id), + updatedAt: row.updatedAt, + })) + .sort((a, b) => Number(b.favorite) - Number(a.favorite)), + ); + }); + + app.get("/:id", async (c) => { + const user = c.get("user"); + const access = requireAccess(user, "viewer"); + const row = await ctx.db.query.dashboards.findFirst({ + where: eq(schema.dashboards.id, c.req.param("id")), + }); + if (!row || !canRead(row, user.id, access.role)) + throw new HTTPException(404, { message: "No such dashboard" }); + return c.json({ ...row, isOwner: row.ownerId === user.id, canEdit: canWrite(row, user.id, access.role) }); + }); + + app.post("/", async (c) => { + const user = c.get("user"); + requireAccess(user, "analyst"); + const body = dashboardSchema.parse(await c.req.json()); + const [row] = await ctx.db + .insert(schema.dashboards) + .values({ + name: body.name, + description: body.description, + goal: body.goal, + widgets: body.widgets, + defaultPreset: body.defaultPreset, + tenantScope: body.tenantScope, + visibility: body.visibility, + sharedWithRole: body.sharedWithRole, + ownerId: user.id, + }) + .returning({ id: schema.dashboards.id }); + return c.json({ id: row!.id }, 201); + }); + + /** Build a dashboard from a template — the last step of the guided builder. */ + app.post("/from-template", async (c) => { + const user = c.get("user"); + requireAccess(user, "analyst"); + const body = z + .object({ + goal: z.string(), + name: z.string().min(1).max(120).optional(), + tenantScope: z.array(z.string().uuid()).nullable().default(null), + defaultPreset: z.enum(["24h", "7d", "30d", "90d"]).default("7d"), + /** Widget ids the user unchecked in the preview step. */ + omit: z.array(z.string()).default([]), + }) + .parse(await c.req.json()); + + const template = templateFor(body.goal as never); + if (!template) throw new HTTPException(400, { message: "No such template" }); + + const widgets = template.widgets.filter((widget) => !body.omit.includes(widget.id)); + const [row] = await ctx.db + .insert(schema.dashboards) + .values({ + name: body.name ?? template.name, + description: template.description, + goal: template.goal, + widgets, + defaultPreset: body.defaultPreset, + tenantScope: body.tenantScope, + visibility: "private", + sharedWithRole: "viewer", + ownerId: user.id, + }) + .returning({ id: schema.dashboards.id }); + return c.json({ id: row!.id }, 201); + }); + + app.put("/:id", async (c) => { + const user = c.get("user"); + const access = requireAccess(user, "analyst"); + const id = c.req.param("id"); + const row = await ctx.db.query.dashboards.findFirst({ where: eq(schema.dashboards.id, id) }); + if (!row) throw new HTTPException(404, { message: "No such dashboard" }); + if (!canWrite(row, user.id, access.role)) { + throw new HTTPException(403, { message: "Only the owner or an admin can change this dashboard." }); + } + const body = dashboardSchema.parse(await c.req.json()); + await ctx.db + .update(schema.dashboards) + .set({ + name: body.name, + description: body.description, + goal: body.goal, + widgets: body.widgets, + defaultPreset: body.defaultPreset, + tenantScope: body.tenantScope, + visibility: body.visibility, + sharedWithRole: body.sharedWithRole, + updatedAt: new Date(), + }) + .where(eq(schema.dashboards.id, id)); + return c.json({ ok: true }); + }); + + app.put("/:id/favorite", async (c) => { + const user = c.get("user"); + const access = requireAccess(user, "viewer"); + const row = await ctx.db.query.dashboards.findFirst({ + where: eq(schema.dashboards.id, c.req.param("id")), + }); + if (!row || !canRead(row, user.id, access.role)) + throw new HTTPException(404, { message: "No such dashboard" }); + await ctx.db + .insert(schema.dashboardFavorites) + .values({ userId: user.id, dashboardId: row.id }) + .onConflictDoNothing(); + return c.json({ ok: true }); + }); + + app.delete("/:id/favorite", async (c) => { + const user = c.get("user"); + requireAccess(user, "viewer"); + await ctx.db + .delete(schema.dashboardFavorites) + .where( + and( + eq(schema.dashboardFavorites.userId, user.id), + eq(schema.dashboardFavorites.dashboardId, c.req.param("id")), + ), + ); + return c.json({ ok: true }); + }); + + /** Copy a dashboard someone shared, so it can be changed without touching theirs. */ + app.post("/:id/duplicate", async (c) => { + const user = c.get("user"); + const access = requireAccess(user, "analyst"); + const source = await ctx.db.query.dashboards.findFirst({ + where: eq(schema.dashboards.id, c.req.param("id")), + }); + if (!source || !canRead(source, user.id, access.role)) { + throw new HTTPException(404, { message: "No such dashboard" }); + } + const [copy] = await ctx.db + .insert(schema.dashboards) + .values({ + name: `${source.name} (copy)`, + description: source.description, + goal: source.goal, + widgets: source.widgets, + defaultPreset: source.defaultPreset, + tenantScope: source.tenantScope, + visibility: "private", + sharedWithRole: "viewer", + ownerId: user.id, + }) + .returning({ id: schema.dashboards.id }); + return c.json({ id: copy!.id }, 201); + }); + + app.delete("/:id", async (c) => { + const user = c.get("user"); + const access = requireAccess(user, "analyst"); + const row = await ctx.db.query.dashboards.findFirst({ + where: eq(schema.dashboards.id, c.req.param("id")), + }); + if (!row) throw new HTTPException(404, { message: "No such dashboard" }); + if (!canWrite(row, user.id, access.role)) + throw new HTTPException(403, { message: "Not yours to delete." }); + await ctx.db.delete(schema.dashboards).where(eq(schema.dashboards.id, row.id)); + return c.json({ ok: true }); + }); + + return app; +} diff --git a/apps/server/src/routes/entities.ts b/apps/server/src/routes/entities.ts new file mode 100644 index 0000000..4c498f6 --- /dev/null +++ b/apps/server/src/routes/entities.ts @@ -0,0 +1,226 @@ +import { scopeTenants } from "@dashflow/core"; +import { schema } from "@dashflow/db"; +import { and, eq, sql } from "drizzle-orm"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { requireAccess } from "../auth/access"; +import type { AppVariables } from "../auth/middleware"; +import type { AppContext } from "../context"; +import { arrayParam, rowsFrom } from "../query/engine"; + +/** + * Lookups for the detail pages — one host pool, one session host, the error catalogue, the + * busiest users. Anything chartable on those pages still goes through the metric engine; these + * routes only answer "what is this thing, and what is around it". + */ +export function entityRoutes(ctx: AppContext) { + const app = new Hono<{ Variables: AppVariables }>(); + + /** Tenant scope as a SQL fragment, or null when the user may see nothing. */ + const tenantFilter = (tenants: string[] | null, column: string) => { + if (tenants === null) return sql`true`; + if (tenants.length === 0) return null; + return sql`${sql.raw(column)} = any(${arrayParam(tenants, "uuid")})`; + }; + + app.get("/pools/:id", async (c) => { + const access = requireAccess(c.get("user"), "viewer"); + const resourceId = decodeURIComponent(c.req.param("id")).toLowerCase(); + const allowed = scopeTenants(access, null); + + const pool = await ctx.db + .select({ + resourceId: schema.hostPools.resourceId, + name: schema.hostPools.name, + friendlyName: schema.hostPools.friendlyName, + location: schema.hostPools.location, + poolType: schema.hostPools.poolType, + loadBalancer: schema.hostPools.loadBalancer, + maxSessions: schema.hostPools.maxSessions, + autoscaleEnabled: schema.hostPools.autoscaleEnabled, + startVmOnConnect: schema.hostPools.startVmOnConnect, + validationEnvironment: schema.hostPools.validationEnvironment, + sources: schema.hostPools.sources, + subscriptionId: schema.hostPools.subscriptionId, + updatedAt: schema.hostPools.updatedAt, + tenantId: schema.hostPools.customerTenantId, + tenantName: schema.customerTenants.displayName, + }) + .from(schema.hostPools) + .innerJoin(schema.customerTenants, eq(schema.customerTenants.id, schema.hostPools.customerTenantId)) + .where(eq(schema.hostPools.resourceId, resourceId)) + .limit(1); + + const row = pool[0]; + if (!row) throw new HTTPException(404, { message: "No such host pool" }); + if (allowed !== null && !allowed.includes(row.tenantId)) { + // Same answer as a pool that does not exist — scope is not a hint. + throw new HTTPException(404, { message: "No such host pool" }); + } + + const hosts = await ctx.db + .select({ + resourceId: schema.sessionHosts.resourceId, + name: schema.sessionHosts.name, + status: schema.sessionHosts.status, + allowNewSession: schema.sessionHosts.allowNewSession, + sessions: schema.sessionHosts.sessions, + agentVersion: schema.sessionHosts.agentVersion, + osVersion: schema.sessionHosts.osVersion, + lastHeartBeat: schema.sessionHosts.lastHeartBeat, + updateState: schema.sessionHosts.updateState, + source: schema.sessionHosts.source, + }) + .from(schema.sessionHosts) + .where(eq(schema.sessionHosts.hostPoolId, resourceId)); + + return c.json({ pool: row, hosts }); + }); + + app.get("/hosts/:pool/:name", async (c) => { + const access = requireAccess(c.get("user"), "viewer"); + const hostPoolId = decodeURIComponent(c.req.param("pool")).toLowerCase(); + const name = decodeURIComponent(c.req.param("name")); + const allowed = scopeTenants(access, null); + + const [host] = await ctx.db + .select({ + resourceId: schema.sessionHosts.resourceId, + name: schema.sessionHosts.name, + status: schema.sessionHosts.status, + allowNewSession: schema.sessionHosts.allowNewSession, + sessions: schema.sessionHosts.sessions, + agentVersion: schema.sessionHosts.agentVersion, + osVersion: schema.sessionHosts.osVersion, + lastHeartBeat: schema.sessionHosts.lastHeartBeat, + updateState: schema.sessionHosts.updateState, + vmResourceId: schema.sessionHosts.vmResourceId, + source: schema.sessionHosts.source, + tenantId: schema.sessionHosts.customerTenantId, + tenantName: schema.customerTenants.displayName, + hostPoolId: schema.sessionHosts.hostPoolId, + hostPoolName: schema.hostPools.name, + hostPoolFriendlyName: schema.hostPools.friendlyName, + }) + .from(schema.sessionHosts) + .innerJoin(schema.customerTenants, eq(schema.customerTenants.id, schema.sessionHosts.customerTenantId)) + .innerJoin(schema.hostPools, eq(schema.hostPools.resourceId, schema.sessionHosts.hostPoolId)) + .where(and(eq(schema.sessionHosts.hostPoolId, hostPoolId), eq(schema.sessionHosts.name, name))) + .limit(1); + + if (!host) throw new HTTPException(404, { message: "No such session host" }); + if (allowed !== null && !allowed.includes(host.tenantId)) { + throw new HTTPException(404, { message: "No such session host" }); + } + + const [health, errors] = await Promise.all([ + rowsFrom( + ctx.db, + sql` + select ts, status, healthy, drain, sessions + from host_health + where host_pool_id = ${hostPoolId} and session_host = ${name} and ts > now() - interval '7 days' + order by ts desc + limit 500`, + ), + rowsFrom( + ctx.db, + sql` + select ts, code, source, service_error, message + from error_facts + where host_pool_id = ${hostPoolId} and ts > now() - interval '7 days' + order by ts desc + limit 50`, + ), + ]); + + return c.json({ host, health, errors }); + }); + + /** The error catalogue: what is failing, how often, where, and when it last happened. */ + app.get("/errors", async (c) => { + const access = requireAccess(c.get("user"), "viewer"); + const days = Math.min(90, Math.max(1, Number(c.req.query("days") ?? 7))); + const allowed = scopeTenants(access, null); + const scope = tenantFilter(allowed, "f.tenant_id"); + if (!scope) return c.json({ groups: [] }); + + const groups = await rowsFrom( + ctx.db, + sql` + select f.code, + coalesce(nullif(f.source, ''), 'Unknown') as source, + bool_or(f.service_error) as service_error, + count(*) as hits, + count(distinct f.host_pool_id) as pools, + count(distinct f.tenant_id) as tenants, + max(f.ts) as last_seen, + (array_agg(f.message order by f.ts desc))[1] as sample + from error_facts f + join customer_tenants ct on ct.id = f.tenant_id + where ct.enabled + and f.ts > now() - ${sql.raw(`interval '${days} days'`)} + and ${scope} + group by 1, 2 + order by hits desc + limit 200`, + ); + + return c.json({ + groups: groups.map((row) => ({ + code: String(row.code), + source: String(row.source), + serviceError: Boolean(row.service_error), + hits: Number(row.hits), + pools: Number(row.pools), + tenants: Number(row.tenants), + lastSeen: row.last_seen ? new Date(row.last_seen as string).toISOString() : null, + sample: row.sample ? String(row.sample).slice(0, 300) : null, + })), + }); + }); + + /** Busiest users. Honours pseudonymization: the key is whatever was stored. */ + app.get("/users", async (c) => { + const access = requireAccess(c.get("user"), "viewer"); + const days = Math.min(90, Math.max(1, Number(c.req.query("days") ?? 7))); + const allowed = scopeTenants(access, null); + const scope = tenantFilter(allowed, "f.tenant_id"); + if (!scope) return c.json({ users: [], pseudonymized: ctx.env.PSEUDONYMIZE_USERS }); + + const users = await rowsFrom( + ctx.db, + sql` + select f.user_key, + count(*) as connections, + count(*) filter (where f.state = 'failed') as failed, + count(distinct f.host_pool_id) as pools, + round(percentile_cont(0.95) within group (order by f.connect_ms))::int as connect_p95, + sum(coalesce(f.duration_sec, 0)) as seconds, + max(f.ts) as last_seen + from connection_facts f + join customer_tenants ct on ct.id = f.tenant_id + where ct.enabled + and f.ts > now() - ${sql.raw(`interval '${days} days'`)} + and ${scope} + group by 1 + order by connections desc + limit 200`, + ); + + return c.json({ + pseudonymized: ctx.env.PSEUDONYMIZE_USERS, + users: users.map((row) => ({ + user: String(row.user_key), + connections: Number(row.connections), + failed: Number(row.failed), + pools: Number(row.pools), + connectP95: row.connect_p95 == null ? null : Number(row.connect_p95), + hours: Number(row.seconds ?? 0) / 3600, + lastSeen: row.last_seen ? new Date(row.last_seen as string).toISOString() : null, + })), + }); + }); + + return app; +} diff --git a/apps/server/src/routes/meta.ts b/apps/server/src/routes/meta.ts new file mode 100644 index 0000000..778b04c --- /dev/null +++ b/apps/server/src/routes/meta.ts @@ -0,0 +1,112 @@ +import { + CATEGORY_INFO, + CONNECTION_TYPE_INFO, + DASHBOARD_TEMPLATES, + DIMENSIONS, + METRICS, + ROLE_INFO, +} from "@dashflow/core"; +import { schema } from "@dashflow/db"; +import { and, eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { resolveAccess } from "../auth/access"; +import { claimOwnership, isClaimed } from "../auth/bootstrap"; +import type { AppVariables } from "../auth/middleware"; +import type { AppContext } from "../context"; + +/** Everything the web app needs to render itself: who am I, what can I see, what exists. */ +export function metaRoutes(ctx: AppContext) { + const app = new Hono<{ Variables: AppVariables }>(); + + app.get("/me", async (c) => { + const user = c.get("user"); + const claimed = await isClaimed(ctx.db); + const tenants = user.access + ? await ctx.db + .select({ + id: schema.customerTenants.id, + displayName: schema.customerTenants.displayName, + domain: schema.customerTenants.domain, + connectionId: schema.customerTenants.connectionId, + }) + .from(schema.customerTenants) + .where(eq(schema.customerTenants.enabled, true)) + : []; + + const visible = + user.access?.tenantScope === null + ? tenants + : tenants.filter((tenant) => user.access!.tenantScope!.includes(tenant.id)); + + const connectionCount = user.access + ? (await ctx.db.select({ id: schema.connections.id }).from(schema.connections)).length + : 0; + + return c.json({ + user: { + id: user.id, + email: user.email, + displayName: user.displayName, + role: user.access?.role ?? null, + tenantScope: user.access?.tenantScope ?? null, + }, + instance: { + claimed, + authMode: ctx.env.AUTH_MODE, + demoMode: ctx.env.DEMO_MODE, + pseudonymizeUsers: ctx.env.PSEUDONYMIZE_USERS, + canStoreSecrets: ctx.secrets.canEncrypt, + canUseKeyVault: ctx.secrets.canUseKeyVault, + connectionCount, + }, + tenants: visible, + }); + }); + + /** First-run: turn the one-time code from the container logs into an owner. */ + app.post("/setup/claim", async (c) => { + const user = c.get("user"); + const principal = c.get("principal"); + const body = await c.req.json<{ code?: string }>().catch(() => ({ code: "" })); + const result = await claimOwnership(ctx.db, { + userId: user.id, + code: body.code ?? "", + principal, + allowedOids: ctx.env.BOOTSTRAP_OWNER_OIDS, + }); + if (!result.ok) return c.json({ error: result.reason }, 400); + ctx.log.info({ email: user.email }, "instance ownership claimed"); + const access = await resolveAccess(ctx.db, user.id); + return c.json({ ok: true, access }); + }); + + /** The metric catalog, templates and enum labels — the guided builder is driven by this. */ + app.get("/catalog", async (c) => { + const nerdioConnections = await ctx.db + .select({ id: schema.connections.id }) + .from(schema.connections) + .where(and(eq(schema.connections.type, "nerdio"), eq(schema.connections.enabled, true))); + + return c.json({ + metrics: METRICS.map((metric) => ({ + id: metric.id, + label: metric.label, + description: metric.description, + category: metric.category, + unit: metric.unit, + betterWhen: metric.betterWhen, + viz: metric.viz, + dims: Object.keys(metric.dims), + requires: metric.requires ?? null, + available: metric.requires !== "nerdio" || nerdioConnections.length > 0, + })), + dimensions: DIMENSIONS, + categories: CATEGORY_INFO, + templates: DASHBOARD_TEMPLATES, + connectionTypes: CONNECTION_TYPE_INFO, + roles: ROLE_INFO, + }); + }); + + return app; +} diff --git a/apps/server/src/routes/query.ts b/apps/server/src/routes/query.ts new file mode 100644 index 0000000..65ad17f --- /dev/null +++ b/apps/server/src/routes/query.ts @@ -0,0 +1,219 @@ +import { type DimensionId, getMetric, queryRequestSchema, scopeTenants } from "@dashflow/core"; +import { schema } from "@dashflow/db"; +import { desc, sql } from "drizzle-orm"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; +import { requireAccess } from "../auth/access"; +import type { AppVariables } from "../auth/middleware"; +import type { AppContext } from "../context"; +import { arrayParam, dimensionExpression, QueryError, rowsFrom, runQuery } from "../query/engine"; + +const batchSchema = z.object({ + tz: z.string().max(64).optional(), + queries: z + .array(z.object({ key: z.string().max(64) }).and(queryRequestSchema)) + .min(1) + .max(24), +}); + +/** Columns worth showing when drilling into each fact table. */ +const DETAIL_SHAPES = { + connection_facts: { + select: sql`f.ts, ct.display_name as tenant, coalesce(hp.friendly_name, hp.name) as host_pool, + f.user_key as "user", f.session_host, f.client_os, f.client_type, f.gateway_region, + f.state, f.connect_ms, f.rtt_ms, f.duration_sec`, + order: sql`f.ts desc`, + }, + error_facts: { + select: sql`f.ts, ct.display_name as tenant, coalesce(hp.friendly_name, hp.name) as host_pool, + f.code, f.source, f.service_error, f.message, f.correlation_id`, + order: sql`f.ts desc`, + }, + session_snapshots: { + select: sql`f.ts, ct.display_name as tenant, coalesce(hp.friendly_name, hp.name) as host_pool, + f.active_sessions, f.disconnected_sessions, f.capacity, f.available_hosts, f.total_hosts`, + order: sql`f.ts desc`, + }, + host_health: { + select: sql`f.ts, ct.display_name as tenant, coalesce(hp.friendly_name, hp.name) as host_pool, + f.session_host, f.status, f.healthy, f.drain, f.sessions`, + order: sql`f.ts desc`, + }, + perf_hourly: { + select: sql`f.ts, ct.display_name as tenant, coalesce(hp.friendly_name, hp.name) as host_pool, + f.session_host, f.cpu_pct, f.mem_available_mb`, + order: sql`f.ts desc`, + }, + cost_daily: { + select: sql`f.ts, ct.display_name as tenant, coalesce(hp.friendly_name, hp.name) as host_pool, + f.meter_category, f.cost, f.currency, f.estimated_savings`, + order: sql`f.ts desc`, + }, + autoscale_events: { + select: sql`f.ts, ct.display_name as tenant, coalesce(hp.friendly_name, hp.name) as host_pool, + f.action, f.hosts_before, f.hosts_after, f.detail`, + order: sql`f.ts desc`, + }, +} as const; + +/** The only path from a dashboard widget to the database. Every widget goes through here. */ +export function queryRoutes(ctx: AppContext) { + const app = new Hono<{ Variables: AppVariables }>(); + + const currency = async (): Promise => { + const [row] = await ctx.db + .select({ currency: schema.costDaily.currency }) + .from(schema.costDaily) + .orderBy(desc(schema.costDaily.ts)) + .limit(1); + return row?.currency ?? "USD"; + }; + + app.post("/", async (c) => { + const access = requireAccess(c.get("user"), "viewer"); + const body = queryRequestSchema.parse(await c.req.json()); + try { + const result = await runQuery(ctx.db, body, access, { + tz: c.req.query("tz") ?? undefined, + currency: await currency(), + }); + return c.json(result); + } catch (err) { + if (err instanceof QueryError) throw new HTTPException(400, { message: err.message }); + throw err; + } + }); + + /** One round trip for a whole dashboard. */ + app.post("/batch", async (c) => { + const access = requireAccess(c.get("user"), "viewer"); + const body = batchSchema.parse(await c.req.json()); + const resolvedCurrency = await currency(); + + const results = await Promise.all( + body.queries.map(async ({ key, ...request }) => { + try { + const result = await runQuery(ctx.db, request, access, { tz: body.tz, currency: resolvedCurrency }); + return { key, result }; + } catch (err) { + if (!(err instanceof QueryError)) { + ctx.log.error({ err: (err as Error).message, metric: request.metric }, "widget query failed"); + } + return { key, error: (err as Error).message }; + } + }), + ); + return c.json({ results }); + }); + + /** + * The rows behind a number. A widget passes its own metric and filters plus whatever the + * user clicked, and gets back the underlying facts — same tenant scoping and the same + * parameter discipline as the chart query. + */ + app.post("/detail", async (c) => { + const access = requireAccess(c.get("user"), "viewer"); + const body = z + .object({ + metric: z.string().default("connections.count"), + from: z.coerce.date(), + to: z.coerce.date(), + tenantIds: z.array(z.string().uuid()).nullable().default(null), + hostPools: z.array(z.string()).default([]), + /** The dimension the widget was grouped by, plus the value that was clicked. */ + groupBy: z.string().max(40).optional(), + groupValue: z.string().max(200).optional(), + limit: z.number().int().min(1).max(2000).default(200), + }) + .parse(await c.req.json()); + + const metric = getMetric(body.metric); + if (!metric) throw new HTTPException(400, { message: `Unknown metric ${body.metric}` }); + const shape = DETAIL_SHAPES[metric.table as keyof typeof DETAIL_SHAPES]; + if (!shape) throw new HTTPException(400, { message: "This metric has no row-level view." }); + + const tenants = scopeTenants(access, body.tenantIds); + const filters = [ + sql`f.ts >= ${body.from.toISOString()}::timestamptz`, + sql`f.ts < ${body.to.toISOString()}::timestamptz`, + sql`ct.enabled`, + ]; + if (metric.where) filters.push(sql`(${sql.raw(metric.where)})`); + if (tenants !== null) { + if (tenants.length === 0) return c.json({ rows: [], pseudonymized: ctx.env.PSEUDONYMIZE_USERS }); + filters.push(sql`f.tenant_id = any(${arrayParam(tenants, "uuid")})`); + } + if (body.hostPools.length > 0) { + filters.push(sql`f.host_pool_id = any(${arrayParam(body.hostPools, "text")})`); + } + if (body.groupBy && body.groupValue) { + try { + const expression = dimensionExpression(metric, body.groupBy as DimensionId); + filters.push(sql`${expression} = ${body.groupValue}`); + } catch (err) { + if (err instanceof QueryError) throw new HTTPException(400, { message: err.message }); + throw err; + } + } + + const rows = await rowsFrom( + ctx.db, + sql` + select ${shape.select} + from ${sql.raw(metric.table)} f + join customer_tenants ct on ct.id = f.tenant_id + left join host_pools hp on hp.resource_id = f.host_pool_id + where ${sql.join(filters, sql` and `)} + order by ${shape.order} + limit ${body.limit}`, + ); + + return c.json({ rows, pseudonymized: ctx.env.PSEUDONYMIZE_USERS, table: metric.table }); + }); + + /** Distinct values for a dimension, so filters offer real choices rather than free text. */ + app.get("/values", async (c) => { + const access = requireAccess(c.get("user"), "viewer"); + const metricId = c.req.query("metric") ?? ""; + const dim = c.req.query("dim") ?? ""; + const metric = getMetric(metricId); + if (!metric) throw new HTTPException(400, { message: `Unknown metric ${metricId}` }); + + let expression: ReturnType; + try { + expression = dimensionExpression(metric, dim as DimensionId); + } catch (err) { + if (err instanceof QueryError) throw new HTTPException(400, { message: err.message }); + throw err; + } + + const tenants = scopeTenants(access, null); + const filters = [sql`ct.enabled`, sql`f.ts > now() - interval '30 days'`]; + if (tenants !== null) { + if (tenants.length === 0) return c.json({ values: [] }); + filters.push(sql`f.tenant_id = any(${arrayParam(tenants, "uuid")})`); + } + + const rows = await rowsFrom( + ctx.db, + sql` + select ${expression}::text as value, count(*) as hits + from ${sql.raw(metric.table)} f + join customer_tenants ct on ct.id = f.tenant_id + left join host_pools hp on hp.resource_id = f.host_pool_id + where ${sql.join(filters, sql` and `)} + group by 1 + order by 2 desc + limit 200`, + ); + + return c.json({ + values: rows + .map((row) => ({ value: String(row.value ?? ""), hits: Number(row.hits ?? 0) })) + .filter((row) => row.value.length > 0), + }); + }); + + return app; +} diff --git a/apps/server/src/routes/settings.ts b/apps/server/src/routes/settings.ts new file mode 100644 index 0000000..5e9f36e --- /dev/null +++ b/apps/server/src/routes/settings.ts @@ -0,0 +1,70 @@ +import { schema } from "@dashflow/db"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import { requireAccess } from "../auth/access"; +import type { AppVariables } from "../auth/middleware"; +import type { AppContext } from "../context"; + +const INSTANCE_KEY = "instance"; + +const instanceSchema = z.object({ + name: z.string().min(1).max(80).default("DashFlow"), + /** Shown on dashboards handed to a customer. */ + organizationName: z.string().max(120).default(""), +}); +export type InstanceSettings = z.infer; + +export async function readInstanceSettings(ctx: AppContext): Promise { + const row = await ctx.db.query.settings.findFirst({ where: eq(schema.settings.key, INSTANCE_KEY) }); + const parsed = instanceSchema.safeParse(row?.value ?? {}); + return parsed.success ? parsed.data : instanceSchema.parse({}); +} + +/** Instance-level settings, plus the facts an operator needs when something looks wrong. */ +export function settingsRoutes(ctx: AppContext) { + const app = new Hono<{ Variables: AppVariables }>(); + + app.get("/", async (c) => { + requireAccess(c.get("user"), "admin"); + const instance = await readInstanceSettings(ctx); + const [connections, tenants, dashboards] = await Promise.all([ + ctx.db.select({ id: schema.connections.id }).from(schema.connections), + ctx.db.select({ id: schema.customerTenants.id }).from(schema.customerTenants), + ctx.db.select({ id: schema.dashboards.id }).from(schema.dashboards), + ]); + + return c.json({ + instance, + runtime: { + version: process.env.APP_VERSION ?? "dev", + commit: (process.env.APP_COMMIT ?? "").slice(0, 7), + authMode: ctx.env.AUTH_MODE, + appRole: ctx.env.APP_ROLE, + demoMode: ctx.env.DEMO_MODE, + pseudonymizeUsers: ctx.env.PSEUDONYMIZE_USERS, + keyVault: ctx.secrets.canUseKeyVault, + encryptionKey: ctx.secrets.canEncrypt, + tokenValidation: ctx.env.EASYAUTH_VALIDATE_TOKEN, + allowedTenants: ctx.env.ALLOWED_TENANT_IDS, + }, + counts: { + connections: connections.length, + customers: tenants.length, + dashboards: dashboards.length, + }, + }); + }); + + app.put("/", async (c) => { + requireAccess(c.get("user"), "owner"); + const body = instanceSchema.parse(await c.req.json()); + await ctx.db + .insert(schema.settings) + .values({ key: INSTANCE_KEY, value: body }) + .onConflictDoUpdate({ target: schema.settings.key, set: { value: body, updatedAt: new Date() } }); + return c.json({ ok: true }); + }); + + return app; +} diff --git a/apps/server/src/routes/sync.ts b/apps/server/src/routes/sync.ts new file mode 100644 index 0000000..1864071 --- /dev/null +++ b/apps/server/src/routes/sync.ts @@ -0,0 +1,103 @@ +import { schema } from "@dashflow/db"; +import { desc, eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { streamSSE } from "hono/streaming"; +import { z } from "zod"; +import { requireAccess } from "../auth/access"; +import type { AppVariables } from "../auth/middleware"; +import type { AppContext } from "../context"; +import { subscribe } from "../lib/events"; +import { enqueue } from "../worker/queue"; + +/** Sync health: recent runs, queue depth, and a live stream for the wizard's progress view. */ +export function syncRoutes(ctx: AppContext) { + const app = new Hono<{ Variables: AppVariables }>(); + + app.get("/runs", async (c) => { + requireAccess(c.get("user"), "admin"); + const runs = await ctx.db + .select({ + id: schema.syncRuns.id, + stream: schema.syncRuns.stream, + status: schema.syncRuns.status, + startedAt: schema.syncRuns.startedAt, + finishedAt: schema.syncRuns.finishedAt, + stats: schema.syncRuns.stats, + error: schema.syncRuns.error, + connectionName: schema.connections.name, + tenantName: schema.customerTenants.displayName, + }) + .from(schema.syncRuns) + .innerJoin(schema.connections, eq(schema.connections.id, schema.syncRuns.connectionId)) + .leftJoin(schema.customerTenants, eq(schema.customerTenants.id, schema.syncRuns.customerTenantId)) + .orderBy(desc(schema.syncRuns.startedAt)) + .limit(100); + + const queued = await ctx.db.select({ id: schema.jobs.id, status: schema.jobs.status }).from(schema.jobs); + return c.json({ + runs, + queue: { + queued: queued.filter((job) => job.status === "queued").length, + running: queued.filter((job) => job.status === "running").length, + failed: queued.filter((job) => job.status === "failed").length, + }, + }); + }); + + app.post("/trigger", async (c) => { + requireAccess(c.get("user"), "admin"); + const body = z + .object({ + connectionId: z.string().uuid(), + customerTenantId: z.string().uuid().optional(), + stream: z.enum(["inventory", "sessions", "logs", "cost"]), + backfill: z.boolean().default(false), + }) + .parse(await c.req.json()); + + const tenants = body.customerTenantId + ? [{ id: body.customerTenantId }] + : await ctx.db + .select({ id: schema.customerTenants.id }) + .from(schema.customerTenants) + .where(eq(schema.customerTenants.connectionId, body.connectionId)); + + for (const tenant of tenants) { + await enqueue(ctx, { + type: "sync", + payload: { + connectionId: body.connectionId, + customerTenantId: tenant.id, + stream: body.stream, + backfill: body.backfill, + }, + dedupeKey: `sync:${body.connectionId}:${tenant.id}:${body.stream}`, + }); + } + return c.json({ queued: tenants.length }); + }); + + /** Server-sent events so the setup wizard can show sync progress as it happens. */ + app.get("/events", async (c) => { + requireAccess(c.get("user"), "admin"); + return streamSSE(c, async (stream) => { + let open = true; + const unsubscribe = subscribe((event) => { + if (!open) return; + void stream.writeSSE({ data: JSON.stringify(event), event: event.type }); + }); + stream.onAbort(() => { + open = false; + unsubscribe(); + }); + // Heartbeat so proxies do not close an idle stream. + while (open && !ctx.shuttingDown) { + await stream.writeSSE({ data: JSON.stringify({ type: "ping", at: Date.now() }), event: "ping" }); + await stream.sleep(20_000); + } + unsubscribe(); + }); + }); + + return app; +} diff --git a/apps/server/src/routes/tenants.ts b/apps/server/src/routes/tenants.ts new file mode 100644 index 0000000..ec9deed --- /dev/null +++ b/apps/server/src/routes/tenants.ts @@ -0,0 +1,138 @@ +import { scopeTenants } from "@dashflow/core"; +import { schema } from "@dashflow/db"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import { requireAccess } from "../auth/access"; +import type { AppVariables } from "../auth/middleware"; +import type { AppContext } from "../context"; + +/** Customers, their subscriptions, workspaces and host pools — the "what do we sync" screen. */ +export function tenantRoutes(ctx: AppContext) { + const app = new Hono<{ Variables: AppVariables }>(); + + app.get("/", async (c) => { + const access = requireAccess(c.get("user"), "viewer"); + const connectionId = c.req.query("connectionId"); + + const tenants = await ctx.db + .select({ + id: schema.customerTenants.id, + connectionId: schema.customerTenants.connectionId, + tenantId: schema.customerTenants.tenantId, + displayName: schema.customerTenants.displayName, + domain: schema.customerTenants.domain, + enabled: schema.customerTenants.enabled, + meta: schema.customerTenants.meta, + connectionName: schema.connections.name, + connectionType: schema.connections.type, + }) + .from(schema.customerTenants) + .innerJoin(schema.connections, eq(schema.connections.id, schema.customerTenants.connectionId)) + .where(connectionId ? eq(schema.customerTenants.connectionId, connectionId) : sql`true`); + + const allowed = scopeTenants(access, null); + const visible = allowed === null ? tenants : tenants.filter((tenant) => allowed.includes(tenant.id)); + if (visible.length === 0) return c.json([]); + + const ids = visible.map((tenant) => tenant.id); + const [subs, workspaces, pools] = await Promise.all([ + ctx.db.select().from(schema.subscriptions).where(inArray(schema.subscriptions.customerTenantId, ids)), + ctx.db + .select() + .from(schema.logAnalyticsWorkspaces) + .where(inArray(schema.logAnalyticsWorkspaces.customerTenantId, ids)), + ctx.db + .select({ + resourceId: schema.hostPools.resourceId, + customerTenantId: schema.hostPools.customerTenantId, + name: schema.hostPools.name, + friendlyName: schema.hostPools.friendlyName, + location: schema.hostPools.location, + poolType: schema.hostPools.poolType, + maxSessions: schema.hostPools.maxSessions, + autoscaleEnabled: schema.hostPools.autoscaleEnabled, + sources: schema.hostPools.sources, + enabled: schema.hostPools.enabled, + }) + .from(schema.hostPools) + .where(inArray(schema.hostPools.customerTenantId, ids)), + ]); + + return c.json( + visible.map((tenant) => ({ + ...tenant, + subscriptions: subs.filter((s) => s.customerTenantId === tenant.id), + workspaces: workspaces.filter((w) => w.customerTenantId === tenant.id), + hostPools: pools.filter((p) => p.customerTenantId === tenant.id), + })), + ); + }); + + /** Turn customers, subscriptions, workspaces or pools on and off for syncing. */ + app.post("/selection", async (c) => { + requireAccess(c.get("user"), "admin"); + const body = z + .object({ + tenants: z.array(z.object({ id: z.string().uuid(), enabled: z.boolean() })).default([]), + subscriptions: z.array(z.object({ id: z.string().uuid(), enabled: z.boolean() })).default([]), + workspaces: z.array(z.object({ id: z.string().uuid(), enabled: z.boolean() })).default([]), + hostPools: z.array(z.object({ resourceId: z.string(), enabled: z.boolean() })).default([]), + }) + .parse(await c.req.json()); + + for (const item of body.tenants) { + await ctx.db + .update(schema.customerTenants) + .set({ enabled: item.enabled }) + .where(eq(schema.customerTenants.id, item.id)); + } + for (const item of body.subscriptions) { + await ctx.db + .update(schema.subscriptions) + .set({ enabled: item.enabled }) + .where(eq(schema.subscriptions.id, item.id)); + } + for (const item of body.workspaces) { + await ctx.db + .update(schema.logAnalyticsWorkspaces) + .set({ enabled: item.enabled }) + .where(eq(schema.logAnalyticsWorkspaces.id, item.id)); + } + for (const item of body.hostPools) { + await ctx.db + .update(schema.hostPools) + .set({ enabled: item.enabled }) + .where(eq(schema.hostPools.resourceId, item.resourceId)); + } + return c.json({ ok: true }); + }); + + /** Host pools the signed-in user may filter by, for the dashboard scope picker. */ + app.get("/host-pools", async (c) => { + const access = requireAccess(c.get("user"), "viewer"); + const allowed = scopeTenants(access, null); + const rows = await ctx.db + .select({ + resourceId: schema.hostPools.resourceId, + name: schema.hostPools.name, + friendlyName: schema.hostPools.friendlyName, + tenantId: schema.hostPools.customerTenantId, + tenantName: schema.customerTenants.displayName, + poolType: schema.hostPools.poolType, + location: schema.hostPools.location, + }) + .from(schema.hostPools) + .innerJoin(schema.customerTenants, eq(schema.customerTenants.id, schema.hostPools.customerTenantId)) + .where( + and( + eq(schema.hostPools.enabled, true), + eq(schema.customerTenants.enabled, true), + allowed === null ? sql`true` : inArray(schema.hostPools.customerTenantId, allowed), + ), + ); + return c.json(rows); + }); + + return app; +} diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts new file mode 100644 index 0000000..25719df --- /dev/null +++ b/apps/server/src/routes/users.ts @@ -0,0 +1,112 @@ +import { roleSchema } from "@dashflow/core"; +import { schema } from "@dashflow/db"; +import { and, eq, isNull } from "drizzle-orm"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; +import { grantRole, requireAccess } from "../auth/access"; +import type { AppVariables } from "../auth/middleware"; +import type { AppContext } from "../context"; + +/** + * Users appear here the first time they sign in through EasyAuth, with no access at all. + * An admin then grants a role — optionally limited to specific customers, which is how an + * MSP gives a customer's own staff a view of just their environment. + */ +export function userRoutes(ctx: AppContext) { + const app = new Hono<{ Variables: AppVariables }>(); + + app.get("/", async (c) => { + requireAccess(c.get("user"), "admin"); + const users = await ctx.db.select().from(schema.users); + const assignments = await ctx.db + .select({ + id: schema.roleAssignments.id, + userId: schema.roleAssignments.userId, + role: schema.roleAssignments.role, + customerTenantId: schema.roleAssignments.customerTenantId, + tenantName: schema.customerTenants.displayName, + }) + .from(schema.roleAssignments) + .leftJoin( + schema.customerTenants, + eq(schema.customerTenants.id, schema.roleAssignments.customerTenantId), + ); + + return c.json( + users.map((user) => ({ + id: user.id, + email: user.email, + displayName: user.displayName, + disabled: user.disabled, + lastSeenAt: user.lastSeenAt, + createdAt: user.createdAt, + assignments: assignments.filter((assignment) => assignment.userId === user.id), + })), + ); + }); + + app.post("/:id/roles", async (c) => { + const actor = c.get("user"); + const access = requireAccess(actor, "admin"); + const body = z + .object({ role: roleSchema, customerTenantId: z.string().uuid().nullable().default(null) }) + .parse(await c.req.json()); + + if (body.role === "owner" && access.role !== "owner") { + throw new HTTPException(403, { message: "Only an owner can create another owner." }); + } + const target = await ctx.db.query.users.findFirst({ where: eq(schema.users.id, c.req.param("id")) }); + if (!target) throw new HTTPException(404, { message: "No such user" }); + + await grantRole(ctx.db, { + userId: target.id, + role: body.role, + customerTenantId: body.customerTenantId, + createdBy: actor.id, + }); + ctx.log.info({ target: target.email, role: body.role }, "role granted"); + return c.json({ ok: true }); + }); + + app.delete("/roles/:assignmentId", async (c) => { + const actor = c.get("user"); + const access = requireAccess(actor, "admin"); + const assignment = await ctx.db.query.roleAssignments.findFirst({ + where: eq(schema.roleAssignments.id, c.req.param("assignmentId")), + }); + if (!assignment) throw new HTTPException(404, { message: "No such assignment" }); + if (assignment.role === "owner") { + if (access.role !== "owner") + throw new HTTPException(403, { message: "Only an owner can remove an owner." }); + const owners = await ctx.db + .select({ id: schema.roleAssignments.id }) + .from(schema.roleAssignments) + .where( + and(eq(schema.roleAssignments.role, "owner"), isNull(schema.roleAssignments.customerTenantId)), + ); + if (owners.length <= 1) { + throw new HTTPException(400, { + message: "This is the last owner — grant ownership to someone else first.", + }); + } + } + await ctx.db.delete(schema.roleAssignments).where(eq(schema.roleAssignments.id, assignment.id)); + return c.json({ ok: true }); + }); + + app.post("/:id/disabled", async (c) => { + const actor = c.get("user"); + requireAccess(actor, "admin"); + const body = z.object({ disabled: z.boolean() }).parse(await c.req.json()); + if (c.req.param("id") === actor.id) + throw new HTTPException(400, { message: "You cannot disable yourself." }); + await ctx.db + .update(schema.users) + .set({ disabled: body.disabled }) + .where(eq(schema.users.id, c.req.param("id"))); + return c.json({ ok: true }); + }); + + return app; +} diff --git a/apps/server/src/sync/autoscale.ts b/apps/server/src/sync/autoscale.ts new file mode 100644 index 0000000..6828160 --- /dev/null +++ b/apps/server/src/sync/autoscale.ts @@ -0,0 +1,104 @@ +import { schema } from "@dashflow/db"; +import { sql } from "drizzle-orm"; +import type { AppContext } from "../context"; +import { rowsFrom } from "../query/engine"; + +/** + * Autoscale actions, derived rather than imported. + * + * Neither the AVD ARM API nor Nerdio's REST API exposes a dependable "autoscale did X" feed, so + * rather than ship a metric that can never populate, this reads what actually happened to the + * hosts: consecutive `host_health` snapshots for the same session host. A host that goes from + * Shutdown to Available was started; the reverse was stopped; drain mode flipping either way is + * a drain action. That holds whether the scaling was done by an Azure scaling plan, by Nerdio, + * or by somebody clicking a button — which is what an operator actually wants to see. + */ + +export interface AutoscaleDerivation { + scanned: number; + written: number; +} + +/** Look back far enough to catch what a missed run left behind, not the whole history. */ +const LOOKBACK_HOURS = 6; + +const OFF_STATES = ["shutdown", "unavailable(vmnotrunning)"]; + +export async function deriveAutoscaleEvents(ctx: AppContext): Promise { + const windowStart = new Date(Date.now() - LOOKBACK_HOURS * 3600_000); + const off = sql`array['shutdown', 'unavailable(vmnotrunning)']`; + + const rows = await rowsFrom( + ctx.db, + sql` + with ordered as ( + select h.ts, h.tenant_id, h.host_pool_id, h.session_host, h.status, h.drain, + lag(h.status) over w as previous_status, + lag(h.drain) over w as previous_drain, + lag(h.ts) over w as previous_ts + from host_health h + where h.ts > ${windowStart.toISOString()}::timestamptz + window w as (partition by h.host_pool_id, h.session_host order by h.ts) + ), + transitions as ( + select ts, tenant_id, host_pool_id, session_host, previous_status, status, + case + when lower(previous_status) = any(${off}) and not (lower(status) = any(${off})) then 'start' + when lower(status) = any(${off}) and not (lower(previous_status) = any(${off})) then 'stop' + when drain and not previous_drain then 'drain' + when previous_drain and not drain then 'undrain' + end as action + from ordered + where previous_ts is not null + ) + select ts, tenant_id, host_pool_id, session_host, previous_status, status, action + from transitions + where action is not null + order by ts + limit 20000`, + ); + + if (rows.length === 0) return { scanned: 0, written: 0 }; + + const values = rows.map((row) => { + const ts = new Date(row.ts as string); + const host = String(row.session_host); + const action = String(row.action); + return { + // Stable natural key: re-running over the same window inserts nothing new. + key: `${String(row.host_pool_id)}|${host}|${ts.toISOString()}|${action}`.slice(0, 200), + ts, + tenantId: String(row.tenant_id), + hostPoolId: String(row.host_pool_id), + action, + detail: `${host}: ${String(row.previous_status)} → ${String(row.status)}`, + }; + }); + + let written = 0; + for (let i = 0; i < values.length; i += 500) { + const inserted = await ctx.db + .insert(schema.autoscaleEvents) + .values(values.slice(i, i + 500)) + .onConflictDoNothing() + .returning({ key: schema.autoscaleEvents.key }); + written += inserted.length; + } + + if (written > 0) ctx.log.debug({ written, scanned: rows.length }, "derived autoscale events"); + return { scanned: rows.length, written }; +} + +/** Exposed for tests: the state machine, without the database. */ +export function classifyTransition( + previous: { status: string; drain: boolean }, + next: { status: string; drain: boolean }, +): "start" | "stop" | "drain" | "undrain" | null { + const wasOff = OFF_STATES.includes(previous.status.toLowerCase()); + const isOff = OFF_STATES.includes(next.status.toLowerCase()); + if (wasOff && !isOff) return "start"; + if (isOff && !wasOff) return "stop"; + if (next.drain && !previous.drain) return "drain"; + if (previous.drain && !next.drain) return "undrain"; + return null; +} diff --git a/apps/server/src/sync/demo-seed.ts b/apps/server/src/sync/demo-seed.ts new file mode 100644 index 0000000..1453ec8 --- /dev/null +++ b/apps/server/src/sync/demo-seed.ts @@ -0,0 +1,47 @@ +import { DEFAULT_SCHEDULE } from "@dashflow/core"; +import { schema } from "@dashflow/db"; +import { eq } from "drizzle-orm"; +import type { AppContext } from "../context"; +import { enqueue } from "../worker/queue"; +import { runDiscovery } from "./runner"; + +/** + * With DEMO_MODE=true, create one synthetic connection so a fresh container has something to + * look at. It is a normal connection row, so the setup wizard, sync engine and dashboards all + * behave exactly as they do against a real tenant. + */ +export async function seedDemoConnection(ctx: AppContext): Promise { + const existing = await ctx.db.query.connections.findFirst({ where: eq(schema.connections.type, "demo") }); + if (existing) return; + + const [row] = await ctx.db + .insert(schema.connections) + .values({ + name: "Demo data (synthetic)", + type: "demo", + config: { type: "demo", tenants: 4 }, + schedule: { ...DEFAULT_SCHEDULE, backfillDays: 30 }, + status: "ok", + }) + .returning({ id: schema.connections.id }); + + const connection = await ctx.db.query.connections.findFirst({ where: eq(schema.connections.id, row!.id) }); + if (!connection) return; + + await runDiscovery(ctx, connection); + const tenants = await ctx.db + .select({ id: schema.customerTenants.id }) + .from(schema.customerTenants) + .where(eq(schema.customerTenants.connectionId, connection.id)); + + for (const tenant of tenants) { + for (const stream of ["inventory", "sessions", "logs", "cost"] as const) { + await enqueue(ctx, { + type: "sync", + payload: { connectionId: connection.id, customerTenantId: tenant.id, stream, backfill: true }, + dedupeKey: `sync:${connection.id}:${tenant.id}:${stream}`, + }); + } + } + ctx.log.info({ tenants: tenants.length }, "demo connection seeded — synthetic data is loading"); +} diff --git a/apps/server/src/sync/ingest.ts b/apps/server/src/sync/ingest.ts new file mode 100644 index 0000000..a3799a9 --- /dev/null +++ b/apps/server/src/sync/ingest.ts @@ -0,0 +1,420 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { SyncBatch } from "@dashflow/core"; +import { type Db, schema } from "@dashflow/db"; +import { eq, sql } from "drizzle-orm"; +import type { AppContext } from "../context"; + +/** + * Writes a connector's normalized batch into Postgres. Everything is an upsert keyed on the + * ARM resource id (inventory) or a stable natural key (facts), so a sync can be re-run over + * an overlapping window without creating duplicates. + */ + +const CHUNK = 500; + +function chunks(items: T[], size = CHUNK): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +let cachedSalt: string | null = null; + +/** One random salt per instance, so hashed user keys cannot be matched across deployments. */ +async function userSalt(db: Db): Promise { + if (cachedSalt) return cachedSalt; + const row = await db.query.settings.findFirst({ where: eq(schema.settings.key, "users.salt") }); + if (row && typeof (row.value as { salt?: string }).salt === "string") { + cachedSalt = (row.value as { salt: string }).salt; + return cachedSalt; + } + const salt = randomBytes(24).toString("base64url"); + await db.insert(schema.settings).values({ key: "users.salt", value: { salt } }).onConflictDoNothing(); + cachedSalt = salt; + return salt; +} + +export interface IngestScope { + connectionId: string; + /** Entra tenant id (or a Nerdio account key) → customer_tenants.id */ + tenantRowId: string; + tenantId: string; +} + +export interface IngestStats extends Record { + hostPools: number; + sessionHosts: number; + appGroups: number; + scalingPlans: number; + sessionSnapshots: number; + hostHealth: number; + connections: number; + errors: number; + perf: number; + costs: number; + autoscaleEvents: number; + skippedUnknownPool: number; +} + +const emptyStats = (): IngestStats => ({ + hostPools: 0, + sessionHosts: 0, + appGroups: 0, + scalingPlans: 0, + sessionSnapshots: 0, + hostHealth: 0, + connections: 0, + errors: 0, + perf: 0, + costs: 0, + autoscaleEvents: 0, + skippedUnknownPool: 0, +}); + +export async function ingest(ctx: AppContext, scope: IngestScope, batch: SyncBatch): Promise { + const { db } = ctx; + const stats = emptyStats(); + const tenantRowId = scope.tenantRowId; + + // ─── inventory ──────────────────────────────────────────────────────────────── + for (const group of chunks(batch.hostPools ?? [])) { + await db + .insert(schema.hostPools) + .values( + group.map((pool) => ({ + resourceId: pool.resourceId, + customerTenantId: tenantRowId, + connectionId: scope.connectionId, + subscriptionId: pool.subscriptionId, + name: pool.name, + friendlyName: pool.friendlyName ?? null, + location: pool.location, + poolType: pool.poolType, + loadBalancer: pool.loadBalancer ?? null, + maxSessions: pool.maxSessions ?? null, + startVmOnConnect: pool.startVmOnConnect ?? null, + validationEnvironment: pool.validationEnvironment ?? null, + preferredAppGroupType: pool.preferredAppGroupType ?? null, + autoscaleEnabled: pool.autoscaleEnabled ?? null, + sources: [pool.source], + updatedAt: new Date(), + })), + ) + .onConflictDoUpdate({ + target: schema.hostPools.resourceId, + set: { + name: sql`excluded.name`, + friendlyName: sql`excluded.friendly_name`, + location: sql`excluded.location`, + poolType: sql`excluded.pool_type`, + loadBalancer: sql`excluded.load_balancer`, + maxSessions: sql`coalesce(excluded.max_sessions, ${schema.hostPools.maxSessions})`, + startVmOnConnect: sql`excluded.start_vm_on_connect`, + validationEnvironment: sql`excluded.validation_environment`, + preferredAppGroupType: sql`excluded.preferred_app_group_type`, + // Nerdio knows about autoscale, Azure does not — keep whichever said "true". + autoscaleEnabled: sql`coalesce(excluded.autoscale_enabled, ${schema.hostPools.autoscaleEnabled})`, + // Track every source that has seen this pool so the UI can show "Azure + Nerdio". + sources: sql`( + select jsonb_agg(distinct s) from jsonb_array_elements(${schema.hostPools.sources} || excluded.sources) as t(s) + )`, + updatedAt: new Date(), + }, + }); + stats.hostPools += group.length; + } + + const knownPools = await knownPoolIds(ctx, tenantRowId); + + for (const group of chunks(batch.sessionHosts ?? [])) { + const rows = group + .filter((host) => knownPools.has(host.hostPoolId)) + .map((host) => ({ + resourceId: host.resourceId, + hostPoolId: host.hostPoolId, + customerTenantId: tenantRowId, + name: host.name, + vmResourceId: host.vmResourceId ?? null, + status: host.status, + allowNewSession: host.allowNewSession, + sessions: host.sessions, + agentVersion: host.agentVersion ?? null, + osVersion: host.osVersion ?? null, + lastHeartBeat: host.lastHeartBeat ? new Date(host.lastHeartBeat) : null, + updateState: host.updateState ?? null, + source: host.source, + updatedAt: new Date(), + })); + stats.skippedUnknownPool += group.length - rows.length; + if (rows.length === 0) continue; + await db + .insert(schema.sessionHosts) + .values(rows) + .onConflictDoUpdate({ + target: schema.sessionHosts.resourceId, + set: { + status: sql`excluded.status`, + allowNewSession: sql`excluded.allow_new_session`, + sessions: sql`excluded.sessions`, + agentVersion: sql`excluded.agent_version`, + osVersion: sql`excluded.os_version`, + lastHeartBeat: sql`excluded.last_heart_beat`, + updateState: sql`excluded.update_state`, + updatedAt: new Date(), + }, + }); + stats.sessionHosts += rows.length; + } + + for (const group of chunks(batch.appGroups ?? [])) { + await db + .insert(schema.appGroups) + .values( + group.map((appGroup) => ({ + resourceId: appGroup.resourceId, + customerTenantId: tenantRowId, + hostPoolId: appGroup.hostPoolId && knownPools.has(appGroup.hostPoolId) ? appGroup.hostPoolId : null, + name: appGroup.name, + groupType: appGroup.groupType, + workspaceId: appGroup.workspaceId ?? null, + updatedAt: new Date(), + })), + ) + .onConflictDoUpdate({ + target: schema.appGroups.resourceId, + set: { name: sql`excluded.name`, groupType: sql`excluded.group_type`, updatedAt: new Date() }, + }); + stats.appGroups += group.length; + } + + for (const group of chunks(batch.scalingPlans ?? [])) { + await db + .insert(schema.scalingPlans) + .values( + group.map((plan) => ({ + resourceId: plan.resourceId, + customerTenantId: tenantRowId, + name: plan.name, + timeZone: plan.timeZone ?? null, + hostPoolIds: plan.hostPoolIds, + schedules: plan.schedules, + updatedAt: new Date(), + })), + ) + .onConflictDoUpdate({ + target: schema.scalingPlans.resourceId, + set: { + name: sql`excluded.name`, + timeZone: sql`excluded.time_zone`, + hostPoolIds: sql`excluded.host_pool_ids`, + schedules: sql`excluded.schedules`, + updatedAt: new Date(), + }, + }); + stats.scalingPlans += group.length; + } + + // ─── facts ──────────────────────────────────────────────────────────────────── + for (const group of chunks(batch.sessionSnapshots ?? [])) { + const rows = group + .filter((snapshot) => knownPools.has(snapshot.hostPoolId)) + .map((snapshot) => ({ + ts: new Date(snapshot.ts), + hostPoolId: snapshot.hostPoolId, + tenantId: tenantRowId, + activeSessions: snapshot.activeSessions, + disconnectedSessions: snapshot.disconnectedSessions, + capacity: snapshot.capacity, + availableHosts: snapshot.availableHosts, + totalHosts: snapshot.totalHosts, + })); + if (rows.length === 0) continue; + await db + .insert(schema.sessionSnapshots) + .values(rows) + .onConflictDoUpdate({ + target: [schema.sessionSnapshots.ts, schema.sessionSnapshots.hostPoolId], + set: { + activeSessions: sql`excluded.active_sessions`, + disconnectedSessions: sql`excluded.disconnected_sessions`, + capacity: sql`excluded.capacity`, + availableHosts: sql`excluded.available_hosts`, + totalHosts: sql`excluded.total_hosts`, + }, + }); + stats.sessionSnapshots += rows.length; + } + + for (const group of chunks(batch.hostHealth ?? [])) { + const rows = group + .filter((record) => knownPools.has(record.hostPoolId)) + .map((record) => ({ + ts: new Date(record.ts), + sessionHost: record.sessionHost, + hostPoolId: record.hostPoolId, + tenantId: tenantRowId, + status: record.status, + healthy: record.healthy, + drain: record.drain, + sessions: record.sessions, + })); + if (rows.length === 0) continue; + await db + .insert(schema.hostHealth) + .values(rows) + .onConflictDoUpdate({ + target: [schema.hostHealth.ts, schema.hostHealth.hostPoolId, schema.hostHealth.sessionHost], + set: { + status: sql`excluded.status`, + healthy: sql`excluded.healthy`, + drain: sql`excluded.drain`, + sessions: sql`excluded.sessions`, + }, + }); + stats.hostHealth += rows.length; + } + + if (batch.connections?.length) { + const salt = ctx.env.PSEUDONYMIZE_USERS ? await userSalt(db) : null; + const userKey = (user: string) => + salt + ? createHash("sha256").update(`${salt}|${user.toLowerCase()}`).digest("base64url").slice(0, 32) + : user.toLowerCase(); + + for (const group of chunks(batch.connections)) { + await db + .insert(schema.connectionFacts) + .values( + group.map((fact) => ({ + correlationId: fact.correlationId, + tenantId: tenantRowId, + hostPoolId: fact.hostPoolId && knownPools.has(fact.hostPoolId) ? fact.hostPoolId : null, + ts: new Date(fact.ts), + userKey: userKey(fact.user), + sessionHost: fact.sessionHost ?? null, + clientOs: fact.clientOs ?? null, + clientType: fact.clientType ?? null, + clientVersion: fact.clientVersion ?? null, + gatewayRegion: fact.gatewayRegion ?? null, + state: fact.state, + connectMs: fact.connectMs ?? null, + rttMs: fact.rttMs ?? null, + bandwidthKbps: fact.bandwidthKbps ?? null, + durationSec: fact.durationSec ?? null, + })), + ) + .onConflictDoUpdate({ + target: schema.connectionFacts.correlationId, + set: { + // A later run may see the session complete, so let newer data win. + state: sql`excluded.state`, + connectMs: sql`coalesce(excluded.connect_ms, ${schema.connectionFacts.connectMs})`, + rttMs: sql`coalesce(excluded.rtt_ms, ${schema.connectionFacts.rttMs})`, + bandwidthKbps: sql`coalesce(excluded.bandwidth_kbps, ${schema.connectionFacts.bandwidthKbps})`, + durationSec: sql`coalesce(excluded.duration_sec, ${schema.connectionFacts.durationSec})`, + sessionHost: sql`coalesce(excluded.session_host, ${schema.connectionFacts.sessionHost})`, + hostPoolId: sql`coalesce(excluded.host_pool_id, ${schema.connectionFacts.hostPoolId})`, + }, + }); + stats.connections += group.length; + } + } + + for (const group of chunks(batch.errors ?? [])) { + await db + .insert(schema.errorFacts) + .values( + group.map((error) => ({ + key: error.key, + tenantId: tenantRowId, + hostPoolId: error.hostPoolId && knownPools.has(error.hostPoolId) ? error.hostPoolId : null, + ts: new Date(error.ts), + correlationId: error.correlationId ?? null, + code: error.code, + source: error.source, + message: error.message, + serviceError: error.serviceError, + })), + ) + .onConflictDoNothing(); + stats.errors += group.length; + } + + for (const group of chunks(batch.perf ?? [])) { + await db + .insert(schema.perfHourly) + .values( + group.map((record) => ({ + ts: new Date(record.ts), + sessionHost: record.sessionHost, + hostPoolId: record.hostPoolId && knownPools.has(record.hostPoolId) ? record.hostPoolId : null, + tenantId: tenantRowId, + cpuPct: record.cpuPct ?? null, + memAvailableMb: record.memAvailableMb ?? null, + })), + ) + .onConflictDoUpdate({ + target: [schema.perfHourly.ts, schema.perfHourly.sessionHost], + set: { cpuPct: sql`excluded.cpu_pct`, memAvailableMb: sql`excluded.mem_available_mb` }, + }); + stats.perf += group.length; + } + + for (const group of chunks(batch.costs ?? [])) { + await db + .insert(schema.costDaily) + .values( + group.map((cost) => ({ + key: `${tenantRowId}|${cost.date}|${cost.hostPoolId ?? "-"}|${cost.meterCategory}`, + ts: new Date(`${cost.date}T00:00:00Z`), + day: cost.date, + tenantId: tenantRowId, + hostPoolId: cost.hostPoolId && knownPools.has(cost.hostPoolId) ? cost.hostPoolId : null, + meterCategory: cost.meterCategory, + cost: cost.cost.toFixed(4), + currency: cost.currency, + estimatedSavings: cost.estimatedSavings != null ? cost.estimatedSavings.toFixed(4) : null, + })), + ) + .onConflictDoUpdate({ + target: schema.costDaily.key, + set: { + cost: sql`excluded.cost`, + currency: sql`excluded.currency`, + estimatedSavings: sql`coalesce(excluded.estimated_savings, ${schema.costDaily.estimatedSavings})`, + }, + }); + stats.costs += group.length; + } + + for (const group of chunks(batch.autoscaleEvents ?? [])) { + await db + .insert(schema.autoscaleEvents) + .values( + group + .filter((event) => knownPools.has(event.hostPoolId)) + .map((event) => ({ + key: event.key, + ts: new Date(event.ts), + tenantId: tenantRowId, + hostPoolId: event.hostPoolId, + action: event.action, + hostsBefore: event.hostsBefore ?? null, + hostsAfter: event.hostsAfter ?? null, + detail: event.detail ?? null, + })), + ) + .onConflictDoNothing(); + stats.autoscaleEvents += group.length; + } + + return stats; +} + +async function knownPoolIds(ctx: AppContext, tenantRowId: string): Promise> { + const rows = await ctx.db + .select({ resourceId: schema.hostPools.resourceId }) + .from(schema.hostPools) + .where(eq(schema.hostPools.customerTenantId, tenantRowId)); + return new Set(rows.map((row) => row.resourceId)); +} diff --git a/apps/server/src/sync/runner.ts b/apps/server/src/sync/runner.ts new file mode 100644 index 0000000..39e2d41 --- /dev/null +++ b/apps/server/src/sync/runner.ts @@ -0,0 +1,318 @@ +import { type Connector, createConnector } from "@dashflow/connectors"; +import type { DiagnosticCheck, SyncStream } from "@dashflow/core"; +import { schema } from "@dashflow/db"; +import { and, eq, inArray, lt } from "drizzle-orm"; +import type { AppContext } from "../context"; +import { publish } from "../lib/events"; +import { ingest } from "./ingest"; + +type ConnectionRow = typeof schema.connections.$inferSelect; + +/** Build a connector from a stored connection, resolving its secrets on the way. */ +export async function connectorFor(ctx: AppContext, connection: ConnectionRow): Promise { + const [secret, refreshToken] = await Promise.all([ + ctx.secrets.get(connection.secretId), + ctx.secrets.get(connection.refreshSecretId), + ]); + + return createConnector(connection.config, { + log: ctx.log.child({ connection: connection.name, type: connection.type }), + secret, + refreshToken, + onRefreshToken: async (token) => { + // Partner Center rotates refresh tokens; persist the new one or the next sync fails. + const next = await ctx.secrets.replace(connection.refreshSecretId, { kind: "inline", value: token }); + if (next && next !== connection.refreshSecretId) { + await ctx.db + .update(schema.connections) + .set({ refreshSecretId: next }) + .where(eq(schema.connections.id, connection.id)); + } + }, + }); +} + +export async function testConnection(ctx: AppContext, connection: ConnectionRow): Promise { + let checks: DiagnosticCheck[]; + try { + const connector = await connectorFor(ctx, connection); + checks = await connector.test(); + } catch (err) { + checks = [ + { + id: "unexpected", + label: "Test the connection", + status: "fail", + detail: (err as Error).message, + fix: "Check the connection settings, then try again.", + }, + ]; + } + const worst = checks.some((c) => c.status === "fail") + ? "error" + : checks.some((c) => c.status === "warn") + ? "degraded" + : "ok"; + await ctx.db + .update(schema.connections) + .set({ lastTestedAt: new Date(), lastTest: checks, status: worst }) + .where(eq(schema.connections.id, connection.id)); + return checks; +} + +export interface DiscoverySummary { + tenants: number; + subscriptions: number; + workspaces: number; + hostPools: number; +} + +/** Discover customers, subscriptions and workspaces, and store them for the operator to pick from. */ +export async function runDiscovery(ctx: AppContext, connection: ConnectionRow): Promise { + const connector = await connectorFor(ctx, connection); + const discovery = await connector.discover(); + + for (const tenant of discovery.tenants) { + await ctx.db + .insert(schema.customerTenants) + .values({ + connectionId: connection.id, + tenantId: tenant.tenantId, + displayName: tenant.displayName, + domain: tenant.domain ?? null, + meta: tenant.meta ?? null, + }) + .onConflictDoUpdate({ + target: [schema.customerTenants.connectionId, schema.customerTenants.tenantId], + set: { displayName: tenant.displayName, domain: tenant.domain ?? null, meta: tenant.meta ?? null }, + }); + } + + const tenantRows = await ctx.db + .select({ id: schema.customerTenants.id, tenantId: schema.customerTenants.tenantId }) + .from(schema.customerTenants) + .where(eq(schema.customerTenants.connectionId, connection.id)); + const rowIdFor = new Map(tenantRows.map((row) => [row.tenantId, row.id])); + + for (const subscription of discovery.subscriptions) { + const tenantRowId = rowIdFor.get(subscription.tenantId); + if (!tenantRowId) continue; + await ctx.db + .insert(schema.subscriptions) + .values({ + customerTenantId: tenantRowId, + subscriptionId: subscription.subscriptionId, + displayName: subscription.displayName, + }) + .onConflictDoUpdate({ + target: [schema.subscriptions.customerTenantId, schema.subscriptions.subscriptionId], + set: { displayName: subscription.displayName }, + }); + } + + for (const workspace of discovery.workspaces) { + const tenantRowId = rowIdFor.get(workspace.tenantId); + if (!tenantRowId) continue; + await ctx.db + .insert(schema.logAnalyticsWorkspaces) + .values({ + customerTenantId: tenantRowId, + resourceId: workspace.resourceId, + workspaceId: workspace.workspaceId, + name: workspace.name, + hostPoolIds: workspace.hostPoolIds, + }) + .onConflictDoUpdate({ + target: [schema.logAnalyticsWorkspaces.customerTenantId, schema.logAnalyticsWorkspaces.resourceId], + set: { name: workspace.name, workspaceId: workspace.workspaceId, hostPoolIds: workspace.hostPoolIds }, + }); + } + + return { + tenants: discovery.tenants.length, + subscriptions: discovery.subscriptions.length, + workspaces: discovery.workspaces.length, + hostPools: discovery.hostPoolCount, + }; +} + +export interface SyncOneInput { + connectionId: string; + customerTenantId: string; + stream: SyncStream; + /** Ignore the watermark and pull the full backfill window. */ + backfill?: boolean; +} + +/** Sync one stream for one customer. This is the unit of work the job queue schedules. */ +export async function syncOne(ctx: AppContext, input: SyncOneInput): Promise { + const connection = await ctx.db.query.connections.findFirst({ + where: eq(schema.connections.id, input.connectionId), + }); + if (!connection?.enabled) return; + + const tenant = await ctx.db.query.customerTenants.findFirst({ + where: and( + eq(schema.customerTenants.id, input.customerTenantId), + eq(schema.customerTenants.connectionId, connection.id), + ), + }); + if (!tenant?.enabled) return; + + const [run] = await ctx.db + .insert(schema.syncRuns) + .values({ + connectionId: connection.id, + customerTenantId: tenant.id, + stream: input.stream, + status: "running", + }) + .returning({ id: schema.syncRuns.id }); + + const log = ctx.log.child({ + connection: connection.name, + tenant: tenant.displayName, + stream: input.stream, + }); + publish({ type: "sync:start", connectionId: connection.id, tenantId: tenant.id, stream: input.stream }); + + try { + const [subscriptions, workspaces, pools, watermark] = await Promise.all([ + ctx.db + .select({ subscriptionId: schema.subscriptions.subscriptionId }) + .from(schema.subscriptions) + .where( + and(eq(schema.subscriptions.customerTenantId, tenant.id), eq(schema.subscriptions.enabled, true)), + ), + ctx.db + .select({ + workspaceId: schema.logAnalyticsWorkspaces.workspaceId, + hostPoolIds: schema.logAnalyticsWorkspaces.hostPoolIds, + }) + .from(schema.logAnalyticsWorkspaces) + .where( + and( + eq(schema.logAnalyticsWorkspaces.customerTenantId, tenant.id), + eq(schema.logAnalyticsWorkspaces.enabled, true), + ), + ), + ctx.db + .select({ resourceId: schema.hostPools.resourceId }) + .from(schema.hostPools) + .where(and(eq(schema.hostPools.customerTenantId, tenant.id), eq(schema.hostPools.enabled, true))), + ctx.db.query.watermarks.findFirst({ + where: and( + eq(schema.watermarks.connectionId, connection.id), + eq(schema.watermarks.customerTenantId, tenant.id), + eq(schema.watermarks.stream, input.stream), + ), + }), + ]); + + const until = new Date(); + const connector = await connectorFor(ctx, connection); + const batch = await connector.sync({ + stream: input.stream, + tenantId: tenant.tenantId, + subscriptionIds: subscriptions.map((s) => s.subscriptionId), + workspaces: workspaces.map((w) => ({ workspaceId: w.workspaceId, hostPoolIds: w.hostPoolIds })), + hostPoolIds: pools.map((p) => p.resourceId), + since: input.backfill ? undefined : (watermark?.value ?? undefined), + until, + backfillDays: connection.schedule.backfillDays, + }); + + const stats = await ingest( + ctx, + { connectionId: connection.id, tenantRowId: tenant.id, tenantId: tenant.tenantId }, + batch, + ); + + await ctx.db + .insert(schema.watermarks) + .values({ + connectionId: connection.id, + customerTenantId: tenant.id, + stream: input.stream, + value: until, + }) + .onConflictDoUpdate({ + target: [ + schema.watermarks.connectionId, + schema.watermarks.customerTenantId, + schema.watermarks.stream, + ], + set: { value: until }, + }); + + await ctx.db + .update(schema.syncRuns) + .set({ status: "ok", finishedAt: new Date(), stats }) + .where(eq(schema.syncRuns.id, run!.id)); + await ctx.db + .update(schema.connections) + .set({ lastSyncAt: new Date() }) + .where(eq(schema.connections.id, connection.id)); + + log.info({ stats }, "sync finished"); + publish({ + type: "sync:done", + connectionId: connection.id, + tenantId: tenant.id, + stream: input.stream, + stats, + }); + } catch (err) { + const message = (err as Error).message.slice(0, 1000); + log.error({ err: message }, "sync failed"); + await ctx.db + .update(schema.syncRuns) + .set({ status: "error", finishedAt: new Date(), error: message }) + .where(eq(schema.syncRuns.id, run!.id)); + publish({ + type: "sync:error", + connectionId: connection.id, + tenantId: tenant.id, + stream: input.stream, + error: message, + }); + throw err; + } +} + +/** Apply the retention setting by deleting old facts. Runs once a day per connection. */ +export async function applyRetention(ctx: AppContext, connectionId: string): Promise { + const connection = await ctx.db.query.connections.findFirst({ + where: eq(schema.connections.id, connectionId), + }); + if (!connection) return; + const cutoff = new Date(Date.now() - connection.schedule.retentionDays * 86_400_000); + const tenants = await ctx.db + .select({ id: schema.customerTenants.id }) + .from(schema.customerTenants) + .where(eq(schema.customerTenants.connectionId, connectionId)); + if (tenants.length === 0) return; + const ids = tenants.map((t) => t.id); + + await Promise.all([ + ctx.db + .delete(schema.sessionSnapshots) + .where(and(inArray(schema.sessionSnapshots.tenantId, ids), lt(schema.sessionSnapshots.ts, cutoff))), + ctx.db + .delete(schema.connectionFacts) + .where(and(inArray(schema.connectionFacts.tenantId, ids), lt(schema.connectionFacts.ts, cutoff))), + ctx.db + .delete(schema.errorFacts) + .where(and(inArray(schema.errorFacts.tenantId, ids), lt(schema.errorFacts.ts, cutoff))), + ctx.db + .delete(schema.hostHealth) + .where(and(inArray(schema.hostHealth.tenantId, ids), lt(schema.hostHealth.ts, cutoff))), + ctx.db + .delete(schema.perfHourly) + .where(and(inArray(schema.perfHourly.tenantId, ids), lt(schema.perfHourly.ts, cutoff))), + ctx.db + .delete(schema.costDaily) + .where(and(inArray(schema.costDaily.tenantId, ids), lt(schema.costDaily.ts, cutoff))), + ]); + ctx.log.info({ connection: connection.name, cutoff }, "retention applied"); +} diff --git a/apps/server/src/worker/index.ts b/apps/server/src/worker/index.ts new file mode 100644 index 0000000..81bc274 --- /dev/null +++ b/apps/server/src/worker/index.ts @@ -0,0 +1,173 @@ +import type { SyncStream } from "@dashflow/core"; +import { schema } from "@dashflow/db"; +import { and, eq, sql } from "drizzle-orm"; +import type { AppContext } from "../context"; +import { deriveAutoscaleEvents } from "../sync/autoscale"; +import { applyRetention, runDiscovery, syncOne, testConnection } from "../sync/runner"; +import { claim, complete, enqueue, fail, type Job, requeueStale, workerId } from "./queue"; + +const POLL_INTERVAL_MS = 5_000; +const SCHEDULE_INTERVAL_MS = 60_000; + +/** Per-stream schedule, in minutes, from the connection's settings. */ +function intervalFor( + stream: SyncStream, + schedule: (typeof schema.connections.$inferSelect)["schedule"], +): number { + switch (stream) { + case "inventory": + return schedule.inventoryMinutes; + case "sessions": + return schedule.sessionsMinutes; + case "logs": + return schedule.logsMinutes; + case "cost": + return schedule.costHours * 60; + } +} + +const STREAMS: SyncStream[] = ["inventory", "sessions", "logs", "cost"]; + +/** + * Queue the work that is due. Runs every minute; `dedupeKey` means a slow customer never + * piles up duplicate jobs, and one noisy tenant cannot starve the others. + */ +export async function scheduleDueWork(ctx: AppContext): Promise { + const connections = await ctx.db + .select() + .from(schema.connections) + .where(eq(schema.connections.enabled, true)); + let queued = 0; + + for (const connection of connections) { + const tenants = await ctx.db + .select({ id: schema.customerTenants.id }) + .from(schema.customerTenants) + .where( + and(eq(schema.customerTenants.connectionId, connection.id), eq(schema.customerTenants.enabled, true)), + ); + + for (const tenant of tenants) { + for (const stream of STREAMS) { + const last = await ctx.db.query.watermarks.findFirst({ + where: and( + eq(schema.watermarks.connectionId, connection.id), + eq(schema.watermarks.customerTenantId, tenant.id), + eq(schema.watermarks.stream, stream), + ), + }); + const dueAt = last ? last.value.getTime() + intervalFor(stream, connection.schedule) * 60_000 : 0; + if (dueAt > Date.now()) continue; + await enqueue(ctx, { + type: "sync", + payload: { connectionId: connection.id, customerTenantId: tenant.id, stream, backfill: !last }, + dedupeKey: `sync:${connection.id}:${tenant.id}:${stream}`, + }); + queued++; + } + } + + await enqueue(ctx, { + type: "retention", + payload: { connectionId: connection.id }, + runAt: new Date(Date.now() + 60_000), + dedupeKey: `retention:${connection.id}:${new Date().toISOString().slice(0, 10)}`, + }); + } + return queued; +} + +async function handle(ctx: AppContext, job: Job): Promise { + switch (job.type) { + case "sync": { + const stream = job.payload.stream as SyncStream; + await syncOne(ctx, { + connectionId: String(job.payload.connectionId), + customerTenantId: String(job.payload.customerTenantId), + stream, + backfill: Boolean(job.payload.backfill), + }); + // Host state changes only become visible once new health snapshots have landed. + if (stream === "sessions" || stream === "inventory") await deriveAutoscaleEvents(ctx); + return; + } + case "discovery": { + const connection = await ctx.db.query.connections.findFirst({ + where: eq(schema.connections.id, String(job.payload.connectionId)), + }); + if (connection) await runDiscovery(ctx, connection); + return; + } + case "test": { + const connection = await ctx.db.query.connections.findFirst({ + where: eq(schema.connections.id, String(job.payload.connectionId)), + }); + if (connection) await testConnection(ctx, connection); + return; + } + case "retention": + await applyRetention(ctx, String(job.payload.connectionId)); + return; + default: + ctx.log.warn({ type: job.type }, "unknown job type"); + } +} + +export async function startWorker(ctx: AppContext): Promise<() => Promise> { + const id = workerId(); + ctx.log.info({ workerId: id }, "worker started"); + + const released = await requeueStale(ctx); + if (released > 0) ctx.log.info({ released }, "requeued jobs from a previous run"); + + let stopped = false; + const timers: Timer[] = []; + + const loop = async () => { + while (!stopped && !ctx.shuttingDown) { + try { + const jobs = await claim(ctx, id, 2); + if (jobs.length === 0) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + continue; + } + for (const job of jobs) { + try { + await handle(ctx, job); + await complete(ctx, job.id); + } catch (err) { + await fail(ctx, job, (err as Error).message); + } + } + } catch (err) { + ctx.log.error({ err: (err as Error).message }, "worker loop error"); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + } + }; + + const scheduler = async () => { + try { + // Only one replica should schedule at a time. + const [lock] = (await ctx.db.execute(sql`select pg_try_advisory_lock(918273645) as ok`)) as unknown as { + ok: boolean; + }[]; + if (lock?.ok) { + const queued = await scheduleDueWork(ctx); + if (queued > 0) ctx.log.debug({ queued }, "queued due work"); + await ctx.db.execute(sql`select pg_advisory_unlock(918273645)`); + } + } catch (err) { + ctx.log.error({ err: (err as Error).message }, "scheduler error"); + } + }; + + void loop(); + void scheduler(); + timers.push(setInterval(scheduler, SCHEDULE_INTERVAL_MS)); + + return async () => { + stopped = true; + for (const timer of timers) clearInterval(timer); + }; +} diff --git a/apps/server/src/worker/queue.ts b/apps/server/src/worker/queue.ts new file mode 100644 index 0000000..00cdf4f --- /dev/null +++ b/apps/server/src/worker/queue.ts @@ -0,0 +1,105 @@ +import { randomUUID } from "node:crypto"; +import { schema } from "@dashflow/db"; +import { and, eq, sql } from "drizzle-orm"; +import type { AppContext } from "../context"; + +/** + * A job queue in Postgres, claimed with `for update skip locked`. No Redis, no extra service — + * a self-hosted dashboard app should need one database and nothing else. + */ + +export interface Job { + id: number; + type: string; + payload: Record; + attempts: number; +} + +export const MAX_ATTEMPTS = 4; + +export interface EnqueueOptions { + type: string; + payload: Record; + runAt?: Date; + /** Collapses duplicates: a second identical queued job is ignored. */ + dedupeKey?: string; +} + +export async function enqueue(ctx: AppContext, options: EnqueueOptions): Promise { + await ctx.db + .insert(schema.jobs) + .values({ + type: options.type, + payload: options.payload, + runAt: options.runAt ?? new Date(), + dedupeKey: options.dedupeKey ?? null, + }) + .onConflictDoNothing(); +} + +export async function claim(ctx: AppContext, workerId: string, limit = 1): Promise { + const result = await ctx.db.execute(sql` + with claimed as ( + select id from jobs + where status = 'queued' and run_at <= now() + order by run_at + for update skip locked + limit ${limit} + ) + update jobs j + set status = 'running', locked_at = now(), locked_by = ${workerId}, attempts = j.attempts + 1 + from claimed + where j.id = claimed.id + returning j.id, j.type, j.payload, j.attempts`); + + const rows = (Array.isArray(result) ? result : ((result as { rows?: unknown[] }).rows ?? [])) as { + id: number | string; + type: string; + payload: Record; + attempts: number; + }[]; + return rows.map((row) => ({ ...row, id: Number(row.id) })); +} + +export async function complete(ctx: AppContext, jobId: number): Promise { + await ctx.db.delete(schema.jobs).where(eq(schema.jobs.id, jobId)); +} + +export async function fail(ctx: AppContext, job: Job, error: string): Promise { + if (job.attempts >= MAX_ATTEMPTS) { + await ctx.db + .update(schema.jobs) + .set({ status: "failed", lastError: error.slice(0, 2000), lockedAt: null, lockedBy: null }) + .where(eq(schema.jobs.id, job.id)); + return; + } + // Exponential backoff: 1, 4, 9 minutes. + const delayMs = job.attempts ** 2 * 60_000; + await ctx.db + .update(schema.jobs) + .set({ + status: "queued", + runAt: new Date(Date.now() + delayMs), + lastError: error.slice(0, 2000), + lockedAt: null, + lockedBy: null, + }) + .where(eq(schema.jobs.id, job.id)); +} + +/** Release jobs whose worker died mid-flight. */ +export async function requeueStale(ctx: AppContext, olderThanMinutes = 30): Promise { + const result = await ctx.db + .update(schema.jobs) + .set({ status: "queued", lockedAt: null, lockedBy: null }) + .where( + and( + eq(schema.jobs.status, "running"), + sql`${schema.jobs.lockedAt} < now() - ${sql.raw(`interval '${olderThanMinutes} minutes'`)}`, + ), + ) + .returning({ id: schema.jobs.id }); + return result.length; +} + +export const workerId = (): string => `${process.env.HOSTNAME ?? "local"}-${randomUUID().slice(0, 8)}`; diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..ed81aca --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,14 @@ + + + + + + + DashFlow + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..62d9a63 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,35 @@ +{ + "name": "@dashflow/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit -p tsconfig.json && vite build", + "preview": "vite preview", + "build:demo": "vite build --mode demo" + }, + "dependencies": { + "@dashflow/core": "workspace:*", + "@fontsource-variable/inter": "5.3.0", + "@tanstack/react-query": "5.103.1", + "clsx": "2.1.1", + "echarts": "6.1.0", + "lucide-react": "1.47.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "react-grid-layout": "2.2.4", + "tailwind-merge": "3.7.0", + "wouter": "3.11.0", + "@dashflow/demo": "workspace:*" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.3", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "tailwindcss": "4.3.3", + "typescript": "5.9.3", + "vite": "8.3.0" + } +} diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx new file mode 100644 index 0000000..cb02c06 --- /dev/null +++ b/apps/web/src/app.tsx @@ -0,0 +1,135 @@ +import { useQuery } from "@tanstack/react-query"; +import { Route, Switch } from "wouter"; +import { AppShell } from "./components/layout"; +import { Alert, Card, Skeleton } from "./components/ui"; +import { api } from "./lib/api"; +import { ClaimPage, NoAccessPage } from "./routes/claim"; +import { ConnectionNew } from "./routes/connection-new"; +import { ConnectionsPage } from "./routes/connections"; +import { CustomersPage } from "./routes/customers"; +import { DashboardNew } from "./routes/dashboard-new"; +import { DashboardView } from "./routes/dashboard-view"; +import { DashboardsList } from "./routes/dashboards-list"; +import { ErrorsPage } from "./routes/errors"; +import { ExplorePage } from "./routes/explore"; +import { HostDetailPage } from "./routes/host-detail"; +import { PoolDetailPage } from "./routes/pool-detail"; +import { SettingsPage } from "./routes/settings"; +import { SyncPage } from "./routes/sync"; +import { UsersPage } from "./routes/users"; + +const ROLE_RANK = { viewer: 0, analyst: 1, admin: 2, owner: 3 } as const; + +export function App() { + const me = useQuery({ queryKey: ["me"], queryFn: api.me, retry: 1 }); + const catalog = useQuery({ + queryKey: ["catalog"], + queryFn: api.catalog, + enabled: Boolean(me.data?.user.role), + }); + + if (me.isPending) { + return ( +
+ + +
+ ); + } + + if (me.isError) { + return ( +
+ + {(me.error as Error).message} +

+ DashFlow expects to sit behind Azure Container Apps authentication. If you are running it locally, + start the server with AUTH_MODE=dev. +

+
+
+ ); + } + + const data = me.data!; + if (!data.instance.claimed) return ; + if (!data.user.role) return ; + + const rank = ROLE_RANK[data.user.role]; + const canEditDashboards = rank >= ROLE_RANK.analyst; + const isAdmin = rank >= ROLE_RANK.admin; + + if (catalog.isPending || !catalog.data) { + return ( + + + + ); + } + + return ( + + + } /> + + canEditDashboards ? ( + + ) : ( + + ) + } + /> + + {(params) => } + + } + /> + + {(params) => } + + + {(params) => ( + + )} + + } /> + } /> + (isAdmin ? : )} + /> + (isAdmin ? : )} + /> + (isAdmin ? : )} /> + (isAdmin ? : )} + /> + (isAdmin ? : )} + /> + + That page does not exist. + + + + ); +} + +function Forbidden({ need }: { need: string }) { + return ( + + This page needs the {need} role or higher. + + ); +} diff --git a/apps/web/src/components/chart.tsx b/apps/web/src/components/chart.tsx new file mode 100644 index 0000000..c378510 --- /dev/null +++ b/apps/web/src/components/chart.tsx @@ -0,0 +1,132 @@ +import { BarChart, HeatmapChart, LineChart, PieChart } from "echarts/charts"; +import { GridComponent, LegendComponent, TooltipComponent, VisualMapComponent } from "echarts/components"; +import * as echarts from "echarts/core"; +import { CanvasRenderer } from "echarts/renderers"; +import { useEffect, useMemo, useRef } from "react"; +import { useTheme } from "../lib/theme"; +import { cssVar } from "../lib/utils"; + +echarts.use([ + BarChart, + LineChart, + PieChart, + HeatmapChart, + GridComponent, + TooltipComponent, + LegendComponent, + VisualMapComponent, + CanvasRenderer, +]); + +export interface ChartTokens { + ink: string; + inkSecondary: string; + inkMuted: string; + grid: string; + surface: string; + border: string; +} + +/** + * Charts read their colours from the CSS tokens, so they follow the theme. The hook + * re-reads them when the resolved theme changes, which makes the chart options depend on + * the theme through real data rather than a magic dependency. + */ +export function useChartTokens(): ChartTokens { + const { version } = useTheme(); + // readTokens() reads live CSS custom properties, so the theme version is its only signal. + // biome-ignore lint/correctness/useExhaustiveDependencies: version is the invalidation signal + return useMemo(() => readTokens(), [version]); +} + +export function readTokens(): ChartTokens { + return { + ink: cssVar("--text-primary", "#111"), + inkSecondary: cssVar("--text-secondary", "#555"), + inkMuted: cssVar("--text-muted", "#888"), + grid: cssVar("--grid", "#eee"), + surface: cssVar("--surface-1", "#fff"), + border: cssVar("--border", "#ddd"), + }; +} + +/** + * Shared chart chrome: recessive grid and axes, text in ink tokens rather than series + * colours, and a crosshair tooltip. Individual charts add only their series. + */ +export interface BaseOption { + animationDuration: number; + grid: Record; + textStyle: Record; + tooltip: Record; + legend: Record; +} + +export function baseOption(tokens: ChartTokens): BaseOption { + return { + animationDuration: 260, + grid: { left: 8, right: 12, top: 8, bottom: 4, containLabel: true }, + textStyle: { fontFamily: "ui-sans-serif, system-ui, sans-serif", color: tokens.inkSecondary }, + tooltip: { + backgroundColor: tokens.surface, + borderColor: tokens.border, + borderWidth: 1, + padding: [8, 10], + textStyle: { color: tokens.ink, fontSize: 12 }, + extraCssText: "box-shadow: 0 6px 24px rgba(0,0,0,0.12); border-radius: 8px;", + axisPointer: { type: "line", lineStyle: { color: tokens.border, width: 1 } }, + }, + legend: { + type: "scroll", + bottom: 0, + itemWidth: 10, + itemHeight: 10, + itemGap: 14, + icon: "roundRect", + textStyle: { color: tokens.inkSecondary, fontSize: 11 }, + inactiveColor: tokens.inkMuted, + }, + }; +} + +export function Chart({ + option, + height, + className, + onEvents, +}: { + option: echarts.EChartsCoreOption; + height: number; + className?: string; + onEvents?: Record void>; +}) { + const ref = useRef(null); + const instance = useRef(null); + + useEffect(() => { + if (!ref.current) return; + instance.current = echarts.init(ref.current, undefined, { renderer: "canvas" }); + const observer = new ResizeObserver(() => instance.current?.resize()); + observer.observe(ref.current); + return () => { + observer.disconnect(); + instance.current?.dispose(); + instance.current = null; + }; + }, []); + + useEffect(() => { + instance.current?.setOption(option, { notMerge: true }); + }, [option]); + + useEffect(() => { + const chart = instance.current; + if (!chart || !onEvents) return; + for (const [event, handler] of Object.entries(onEvents)) chart.on(event, handler); + return () => { + for (const event of Object.keys(onEvents)) chart.off(event); + }; + }, [onEvents]); + + return
; +} diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx new file mode 100644 index 0000000..bdf7359 --- /dev/null +++ b/apps/web/src/components/command-palette.tsx @@ -0,0 +1,295 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + Building2, + Compass, + CornerDownLeft, + LayoutDashboard, + Moon, + Plug, + Search, + Settings, + Sun, + TriangleAlert, + Users, +} from "lucide-react"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useLocation } from "wouter"; +import { api } from "../lib/api"; +import { useTheme } from "../lib/theme"; +import { cn } from "../lib/utils"; +import { Kbd } from "./ui"; + +interface Command { + id: string; + label: string; + hint?: string; + group: string; + icon: ReactNode; + run: () => void; +} + +/** + * ⌘K. The fastest path to any dashboard, page or host pool — the thing that separates a tool + * people tolerate from one they live in. + */ +export function CommandPalette() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [index, setIndex] = useState(0); + const [, navigate] = useLocation(); + const { resolved, setChoice } = useTheme(); + const inputRef = useRef(null); + + const { data: dashboards } = useQuery({ queryKey: ["dashboards"], queryFn: api.dashboards, enabled: open }); + const { data: pools } = useQuery({ queryKey: ["host-pools"], queryFn: api.hostPools, enabled: open }); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setOpen((value) => !value); + setQuery(""); + setIndex(0); + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, []); + + useEffect(() => { + if (open) requestAnimationFrame(() => inputRef.current?.focus()); + }, [open]); + + const commands = useMemo(() => { + const go = (path: string) => () => { + navigate(path); + setOpen(false); + }; + const base: Command[] = [ + { + id: "nav-dashboards", + label: "Dashboards", + group: "Go to", + icon: , + run: go("/"), + }, + { + id: "nav-explore", + label: "Explore", + group: "Go to", + icon: , + run: go("/explore"), + }, + { + id: "nav-customers", + label: "Customers", + group: "Go to", + icon: , + run: go("/customers"), + }, + { + id: "nav-errors", + label: "Errors", + group: "Go to", + icon: , + run: go("/errors"), + }, + { + id: "nav-connections", + label: "Connections", + group: "Go to", + icon: , + run: go("/connections"), + }, + { + id: "nav-sync", + label: "Sync health", + group: "Go to", + icon: , + run: go("/sync"), + }, + { + id: "nav-users", + label: "Users & roles", + group: "Go to", + icon: , + run: go("/users"), + }, + { + id: "nav-settings", + label: "Settings", + group: "Go to", + icon: , + run: go("/settings"), + }, + { + id: "action-new-dashboard", + label: "New dashboard", + hint: "Start the guided builder", + group: "Actions", + icon: , + run: go("/dashboards/new"), + }, + { + id: "action-new-connection", + label: "Add a connection", + group: "Actions", + icon: , + run: go("/connections/new"), + }, + { + id: "action-theme", + label: resolved === "dark" ? "Switch to light theme" : "Switch to dark theme", + group: "Actions", + icon: resolved === "dark" ? : , + run: () => { + setChoice(resolved === "dark" ? "light" : "dark"); + setOpen(false); + }, + }, + ]; + + for (const dashboard of dashboards ?? []) { + base.push({ + id: `dashboard-${dashboard.id}`, + label: dashboard.name, + hint: `${dashboard.widgetCount} widgets`, + group: "Dashboards", + icon: , + run: go(`/dashboards/${dashboard.id}`), + }); + } + for (const pool of (pools ?? []).slice(0, 50)) { + base.push({ + id: `pool-${pool.resourceId}`, + label: pool.friendlyName ?? pool.name, + hint: pool.tenantName, + group: "Host pools", + icon: , + run: go(`/pools/${encodeURIComponent(pool.resourceId)}`), + }); + } + return base; + }, [dashboards, pools, navigate, resolved, setChoice]); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return commands.slice(0, 14); + return commands + .filter( + (command) => + command.label.toLowerCase().includes(needle) || (command.hint ?? "").toLowerCase().includes(needle), + ) + .slice(0, 20); + }, [commands, query]); + + if (!open) return null; + + const grouped: Record = {}; + for (const command of filtered) { + const group = grouped[command.group] ?? []; + group.push(command); + grouped[command.group] = group; + } + let running = -1; + + return createPortal( +
+ + ); + })} +
+ )) + )} +
+ + , + document.body, + ); +} diff --git a/apps/web/src/components/drill-drawer.tsx b/apps/web/src/components/drill-drawer.tsx new file mode 100644 index 0000000..1c2c0e2 --- /dev/null +++ b/apps/web/src/components/drill-drawer.tsx @@ -0,0 +1,139 @@ +import { formatUnit, getMetric } from "@dashflow/core"; +import { useQuery } from "@tanstack/react-query"; +import { Download } from "lucide-react"; +import { api } from "../lib/api"; +import { downloadCsv, formatDateTime } from "../lib/utils"; +import { Alert, Badge, Button, DataTable, Drawer, EmptyState, Skeleton } from "./ui"; +import type { DrillTarget } from "./widgets"; + +export interface DrillRequest extends DrillTarget { + metric: string; + groupBy?: string; + tenantIds: string[] | null; + hostPools: string[]; + /** Falls back to the dashboard's range when the click carried no bucket. */ + rangeFrom: string; + rangeTo: string; +} + +/** Columns that deserve special formatting; everything else is printed as it comes back. */ +const FORMATTERS: Record string> = { + ts: (value) => formatDateTime(value as string), + connect_ms: (value) => (value == null ? "—" : formatUnit("ms", Number(value))), + rtt_ms: (value) => (value == null ? "—" : formatUnit("ms", Number(value))), + duration_sec: (value) => (value == null ? "—" : formatUnit("seconds", Number(value))), + cpu_pct: (value) => (value == null ? "—" : `${Number(value).toFixed(1)}%`), + cost: (value) => (value == null ? "—" : formatUnit("currency", Number(value))), + estimated_savings: (value) => (value == null ? "—" : formatUnit("currency", Number(value))), +}; + +function label(key: string): string { + return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * The rows behind a click. Every chart is a question; this is the answer one level down, + * without leaving the dashboard. + */ +export function DrillDrawer({ request, onClose }: { request: DrillRequest | null; onClose: () => void }) { + const metric = request ? getMetric(request.metric) : undefined; + + const { data, isPending, error } = useQuery({ + queryKey: ["drill", request], + enabled: Boolean(request), + queryFn: () => + api.detail({ + metric: request!.metric, + from: request!.from ?? request!.rangeFrom, + to: request!.to ?? request!.rangeTo, + tenantIds: request!.tenantIds, + hostPools: request!.hostPools, + groupBy: request!.groupBy, + groupValue: request!.groupValue, + limit: 500, + }), + }); + + const rows = data?.rows ?? []; + const columns = rows.length > 0 ? Object.keys(rows[0]!) : []; + + return ( + + {data?.pseudonymized && data.table === "connection_facts" ? ( + + User names are hashed on this instance (PSEUDONYMIZE_USERS) + + ) : null} + + + + } + > + {isPending ? ( +
+ {[0, 1, 2, 3, 4].map((key) => ( + + ))} +
+ ) : error ? ( +
+ + {(error as Error).message} + +
+ ) : rows.length === 0 ? ( + + ) : ( + <> +
+ {rows.length} rows + {rows.length === 500 ? showing the most recent 500 : null} +
+ String(index)} + pageSize={25} + columns={columns.map((key) => ({ + key, + label: label(key), + numeric: typeof rows[0]?.[key] === "number", + render: (row) => { + const value = (row as Record)[key]; + const formatter = FORMATTERS[key]; + if (formatter) return formatter(value); + if (value === null || value === undefined) return "—"; + if (typeof value === "boolean") return value ? "yes" : "no"; + return String(value); + }, + }))} + /> + + )} +
+ ); +} diff --git a/apps/web/src/components/layout.tsx b/apps/web/src/components/layout.tsx new file mode 100644 index 0000000..c123d02 --- /dev/null +++ b/apps/web/src/components/layout.tsx @@ -0,0 +1,304 @@ +import { TIME_PRESETS, type TimePreset } from "@dashflow/core"; +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + Building2, + ChevronsLeft, + Compass, + LayoutDashboard, + type LucideIcon, + Monitor, + Moon, + PanelsTopLeft, + Plug, + Search, + Settings, + Sun, + TriangleAlert, + Users, +} from "lucide-react"; +import { type ReactNode, useEffect, useState } from "react"; +import { Link, useLocation } from "wouter"; +import { api, type Me } from "../lib/api"; +import { useScope } from "../lib/scope"; +import { type ThemeChoice, useTheme } from "../lib/theme"; +import { cn } from "../lib/utils"; +import { CommandPalette } from "./command-palette"; +import { Badge, Kbd, MultiSelect, SegmentedControl } from "./ui"; + +const NAV: { href: string; label: string; icon: LucideIcon; minRole: "viewer" | "analyst" | "admin" }[] = [ + { href: "/", label: "Dashboards", icon: LayoutDashboard, minRole: "viewer" }, + { href: "/explore", label: "Explore", icon: Compass, minRole: "viewer" }, + { href: "/customers", label: "Customers", icon: Building2, minRole: "viewer" }, + { href: "/errors", label: "Errors", icon: TriangleAlert, minRole: "viewer" }, + { href: "/connections", label: "Connections", icon: Plug, minRole: "admin" }, + { href: "/sync", label: "Sync health", icon: Activity, minRole: "admin" }, + { href: "/users", label: "Users & roles", icon: Users, minRole: "admin" }, + { href: "/settings", label: "Settings", icon: Settings, minRole: "admin" }, +]; + +const ROLE_RANK = { viewer: 0, analyst: 1, admin: 2, owner: 3 } as const; +const SIDEBAR_KEY = "dashflow.sidebar"; + +export function Logo({ collapsed }: { collapsed?: boolean }) { + return ( + + + + + + + + + + + + + {!collapsed ? ( + DashFlow + ) : null} + + ); +} + +function ThemeToggle() { + const { choice, setChoice } = useTheme(); + const order: ThemeChoice[] = ["system", "dark", "light"]; + const Icon = choice === "system" ? Monitor : choice === "light" ? Sun : Moon; + return ( + + ); +} + +/** Customers, host pools and the time range — the scope every screen reads. */ +function ScopeBar({ me }: { me: Me }) { + const { tenantIds, setTenantIds, hostPools, setHostPools, preset, setPreset } = useScope(); + const { data: pools } = useQuery({ queryKey: ["host-pools"], queryFn: api.hostPools }); + + return ( +
+ {me.tenants.length > 1 ? ( + } + options={me.tenants.map((tenant) => ({ + value: tenant.id, + label: tenant.displayName, + hint: tenant.domain ?? undefined, + }))} + selected={tenantIds} + onChange={setTenantIds} + /> + ) : null} + + {pools && pools.length > 1 ? ( + } + options={pools.map((pool) => ({ + value: pool.resourceId, + label: pool.friendlyName ?? pool.name, + hint: `${pool.tenantName} · ${pool.poolType}`, + }))} + selected={hostPools.length === 0 ? null : hostPools} + onChange={(next) => setHostPools(next ?? [])} + /> + ) : null} + + setPreset(value as TimePreset)} + options={(Object.keys(TIME_PRESETS) as TimePreset[]).map((key) => ({ + value: key, + label: key, + title: TIME_PRESETS[key].label, + }))} + /> +
+ ); +} + +export function AppShell({ me, children }: { me: Me; children: ReactNode }) { + const [location] = useLocation(); + const role = me.user.role ?? "viewer"; + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(SIDEBAR_KEY) === "1"; + } catch { + return false; + } + }); + + useEffect(() => { + try { + localStorage.setItem(SIDEBAR_KEY, collapsed ? "1" : "0"); + } catch { + // A remembered sidebar is a convenience, not a requirement. + } + }, [collapsed]); + + const visibleNav = NAV.filter((item) => ROLE_RANK[role] >= ROLE_RANK[item.minRole]); + + return ( +
+ + + + +
+
+ + + + + + +
+ + +
+
+ +
{children}
+
+
+ ); +} + +export function PageHeader({ + title, + description, + actions, + breadcrumb, +}: { + title: ReactNode; + description?: ReactNode; + actions?: ReactNode; + breadcrumb?: { label: string; href?: string }[]; +}) { + return ( +
+ {breadcrumb && breadcrumb.length > 0 ? ( + + ) : null} +
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+
+ ); +} diff --git a/apps/web/src/components/ui/data-table.tsx b/apps/web/src/components/ui/data-table.tsx new file mode 100644 index 0000000..7b8b96f --- /dev/null +++ b/apps/web/src/components/ui/data-table.tsx @@ -0,0 +1,190 @@ +import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight } from "lucide-react"; +import { type ReactNode, useMemo, useState } from "react"; +import { cn } from "../../lib/utils"; +import { EmptyState } from "./primitives"; + +export interface Column { + key: string; + label: string; + /** Right-aligned and tabular. */ + numeric?: boolean; + width?: string; + /** Value used for sorting; defaults to the rendered cell when it is a string or number. */ + sortValue?: (row: T) => string | number | null; + render?: (row: T) => ReactNode; + sortable?: boolean; +} + +/** + * The workhorse table: sortable headers, sticky head, and pagination that only appears when + * it is needed. Sorting happens client-side because every caller already has the page's rows. + */ +export function DataTable({ + columns, + rows, + rowKey, + empty = "Nothing to show", + emptyDescription, + pageSize = 0, + dense, + onRowClick, + maxHeight, +}: { + columns: Column[]; + rows: T[]; + rowKey: (row: T, index: number) => string; + empty?: string; + emptyDescription?: ReactNode; + /** 0 disables pagination. */ + pageSize?: number; + dense?: boolean; + onRowClick?: (row: T) => void; + maxHeight?: number; +}) { + const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null); + const [page, setPage] = useState(0); + + const sorted = useMemo(() => { + if (!sort) return rows; + const column = columns.find((candidate) => candidate.key === sort.key); + if (!column) return rows; + const value = (row: T) => { + if (column.sortValue) return column.sortValue(row); + const raw = (row as Record)[column.key]; + return typeof raw === "number" || typeof raw === "string" ? raw : null; + }; + return [...rows].sort((a, b) => { + const av = value(a); + const bv = value(b); + if (av === null) return 1; + if (bv === null) return -1; + const result = + typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv)); + return sort.dir === "asc" ? result : -result; + }); + }, [rows, sort, columns]); + + const pageCount = pageSize > 0 ? Math.max(1, Math.ceil(sorted.length / pageSize)) : 1; + const current = Math.min(page, pageCount - 1); + const visible = pageSize > 0 ? sorted.slice(current * pageSize, current * pageSize + pageSize) : sorted; + + if (rows.length === 0) return ; + + const toggleSort = (key: string) => + setSort((currentSort) => + currentSort?.key === key + ? currentSort.dir === "asc" + ? { key, dir: "desc" } + : null + : { key, dir: "desc" }, + ); + + return ( +
+
+ + + + {columns.map((column) => { + const sortable = column.sortable !== false; + const active = sort?.key === column.key; + return ( + + ); + })} + + + + {visible.map((row, index) => ( + onRowClick(row) : undefined} + className={cn( + "border-b border-[var(--border)] last:border-0", + onRowClick && "cursor-pointer", + "hover:bg-[var(--surface-2)]", + )} + > + {columns.map((column) => ( + + ))} + + ))} + +
+ {sortable ? ( + + ) : ( + column.label + )} +
+ {column.render + ? column.render(row) + : (((row as Record)[column.key] as ReactNode) ?? "—")} +
+
+ + {pageSize > 0 && pageCount > 1 ? ( +
+ + {current * pageSize + 1}–{Math.min(sorted.length, (current + 1) * pageSize)} of {sorted.length} + +
+ + + {current + 1} / {pageCount} + + +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts new file mode 100644 index 0000000..55efd82 --- /dev/null +++ b/apps/web/src/components/ui/index.ts @@ -0,0 +1,5 @@ +export * from "./data-table"; +export * from "./inputs"; +export * from "./overlays"; +export * from "./primitives"; +export * from "./toast"; diff --git a/apps/web/src/components/ui/inputs.tsx b/apps/web/src/components/ui/inputs.tsx new file mode 100644 index 0000000..8c38207 --- /dev/null +++ b/apps/web/src/components/ui/inputs.tsx @@ -0,0 +1,283 @@ +import { Check, ChevronDown, Search, X } from "lucide-react"; +import { type ReactNode, useMemo, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; +import { Popover } from "./overlays"; + +/** A segmented control for small, mutually exclusive choices — time ranges, view modes. */ +export function SegmentedControl({ + value, + options, + onChange, + size = "md", + ariaLabel, +}: { + value: T; + options: { value: T; label: ReactNode; title?: string }[]; + onChange: (value: T) => void; + size?: "sm" | "md"; + ariaLabel?: string; +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +export interface Option { + value: string; + label: string; + hint?: string; +} + +/** + * Multi-select with search. Used for customers, host pools and dimension filters, so it has to + * stay usable at a few hundred options — hence the filter box and the capped render. + */ +export function MultiSelect({ + options, + selected, + onChange, + placeholder = "All", + label, + icon, + className, + allLabel = "All", + maxVisible = 300, +}: { + options: Option[]; + /** null means "everything", which is not the same as an empty selection. */ + selected: string[] | null; + onChange: (next: string[] | null) => void; + placeholder?: string; + label?: string; + icon?: ReactNode; + className?: string; + allLabel?: string; + maxVisible?: number; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const anchor = useRef(null); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + const matches = needle + ? options.filter( + (option) => + option.label.toLowerCase().includes(needle) || (option.hint ?? "").toLowerCase().includes(needle), + ) + : options; + return matches.slice(0, maxVisible); + }, [options, query, maxVisible]); + + const isAll = selected === null; + const count = isAll ? options.length : selected.length; + const summary = isAll + ? `${allLabel} (${options.length})` + : count === 0 + ? placeholder + : count === 1 + ? (options.find((option) => option.value === selected[0])?.label ?? "1 selected") + : `${count} selected`; + + const toggle = (value: string) => { + const current = isAll ? options.map((option) => option.value) : selected; + const next = current.includes(value) ? current.filter((item) => item !== value) : [...current, value]; + onChange(next.length === options.length ? null : next); + }; + + return ( +
+ + + setOpen(false)} className="w-72"> +
+ + setQuery(event.target.value)} + placeholder="Search…" + className="h-7 w-full bg-transparent text-[13px] text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)]" + /> + {query ? ( + + ) : null} +
+ + + +
+ {filtered.length === 0 ? ( +

No matches

+ ) : ( + filtered.map((option) => { + const active = isAll || selected.includes(option.value); + return ( + + ); + }) + )} +
+ + {!isAll ? ( +
+ +
+ ) : null} +
+
+ ); +} + +/** Single-select styled to match MultiSelect, for metric and dimension pickers. */ +export function SelectMenu({ + options, + value, + onChange, + placeholder = "Select…", + icon, + className, + searchable = true, + width = "w-64", +}: { + options: Option[]; + value: string | null; + onChange: (value: string) => void; + placeholder?: string; + icon?: ReactNode; + className?: string; + searchable?: boolean; + width?: string; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return options; + return options.filter( + (option) => + option.label.toLowerCase().includes(needle) || (option.hint ?? "").toLowerCase().includes(needle), + ); + }, [options, query]); + + const current = options.find((option) => option.value === value); + + return ( +
+ + + setOpen(false)} align="left" className={width}> + {searchable ? ( +
+ + setQuery(event.target.value)} + placeholder="Search…" + className="h-7 w-full bg-transparent text-[13px] outline-none placeholder:text-[var(--text-muted)]" + /> +
+ ) : null} +
+ {filtered.map((option) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/ui/overlays.tsx b/apps/web/src/components/ui/overlays.tsx new file mode 100644 index 0000000..7614cd2 --- /dev/null +++ b/apps/web/src/components/ui/overlays.tsx @@ -0,0 +1,235 @@ +import { X } from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "../../lib/utils"; + +/** + * Overlays are hand-rolled rather than pulled from a component library: three of them, each + * a few dozen lines, against a dependency that would have to be themed anyway. They share + * escape-to-close, focus restoration and a scroll lock. + */ + +function useDismiss(open: boolean, onClose: () => void) { + const previouslyFocused = useRef(null); + + useEffect(() => { + if (!open) return; + previouslyFocused.current = document.activeElement as HTMLElement | null; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + }; + document.addEventListener("keydown", onKey); + const { overflow } = document.body.style; + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = overflow; + previouslyFocused.current?.focus?.(); + }; + }, [open, onClose]); +} + +export function Drawer({ + open, + onClose, + title, + description, + footer, + width = "wide", + children, +}: { + open: boolean; + onClose: () => void; + title: ReactNode; + description?: ReactNode; + footer?: ReactNode; + width?: "wide" | "narrow"; + children: ReactNode; +}) { + useDismiss(open, onClose); + if (!open) return null; + + return createPortal( +
+ + +
{children}
+ {footer ? ( +
+ {footer} +
+ ) : null} + +
, + document.body, + ); +} + +export function Modal({ + open, + onClose, + title, + description, + footer, + children, + size = "md", +}: { + open: boolean; + onClose: () => void; + title: ReactNode; + description?: ReactNode; + footer?: ReactNode; + children: ReactNode; + size?: "sm" | "md" | "lg"; +}) { + useDismiss(open, onClose); + if (!open) return null; + + return createPortal( +
+ + +
{children}
+ {footer ? ( +
+ {footer} +
+ ) : null} +
+ , + document.body, + ); +} + +/** A popover anchored to its trigger, closed by an outside click or escape. */ +export function Popover({ + open, + onClose, + align = "right", + className, + children, +}: { + open: boolean; + onClose: () => void; + align?: "left" | "right"; + className?: string; + children: ReactNode; +}) { + const ref = useRef(null); + const close = useCallback(() => onClose(), [onClose]); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) close(); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") close(); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKey); + }; + }, [open, close]); + + if (!open) return null; + return ( +
+ {children} +
+ ); +} + +export function MenuItem({ + onClick, + icon, + danger, + children, +}: { + onClick: () => void; + icon?: ReactNode; + danger?: boolean; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/primitives.tsx b/apps/web/src/components/ui/primitives.tsx new file mode 100644 index 0000000..0e92931 --- /dev/null +++ b/apps/web/src/components/ui/primitives.tsx @@ -0,0 +1,432 @@ +import type { ComponentProps, CSSProperties, ReactNode } from "react"; +import { cn } from "../../lib/utils"; + +/** + * The base layer: plain elements wearing the design tokens, no component library to keep in + * sync. Anything used on more than one screen lives here. + */ + +export function Button({ + className, + variant = "default", + size = "md", + ...props +}: ComponentProps<"button"> & { + variant?: "default" | "outline" | "ghost" | "subtle" | "danger"; + size?: "xs" | "sm" | "md" | "lg"; +}) { + return ( + + ); +} + +export function Spinner({ className }: { className?: string }) { + return ( + + ); +} + +export function Skeleton({ className, style }: { className?: string; style?: CSSProperties }) { + return
; +} + +export function EmptyState({ + title, + description, + action, + icon, + compact, +}: { + title: string; + description?: ReactNode; + action?: ReactNode; + icon?: ReactNode; + compact?: boolean; +}) { + return ( +
+ {icon ? ( +
+ {icon} +
+ ) : null} +

{title}

+ {description ?

{description}

: null} + {action ?
{action}
: null} +
+ ); +} + +export function Alert({ + tone = "neutral", + title, + children, +}: { + tone?: "neutral" | "good" | "warning" | "critical"; + title?: ReactNode; + children?: ReactNode; +}) { + return ( +
+ {title ?

{title}

: null} + {children} +
+ ); +} + +export function Stepper({ + steps, + current, + onSelect, +}: { + steps: { id: string; label: string }[]; + current: number; + onSelect?: (index: number) => void; +}) { + return ( +
    + {steps.map((step, index) => { + const state = index === current ? "current" : index < current ? "done" : "todo"; + return ( +
  1. + + {index < steps.length - 1 ? › : null} +
  2. + ); + })} +
+ ); +} + +/** Kept for simple static tables; anything sortable or long should use DataTable. */ +export function Table({ + columns, + rows, + empty = "Nothing to show", + dense, +}: { + columns: { key: string; label: string; numeric?: boolean; width?: string }[]; + rows: Record[]; + empty?: string; + dense?: boolean; +}) { + if (rows.length === 0) return ; + return ( +
+ + + + {columns.map((column) => ( + + ))} + + + + {rows.map((row, index) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ {column.label} +
+ {row[column.key] ?? "—"} +
+
+ ); +} + +export function StatusDot({ status }: { status: "pass" | "warn" | "fail" | "skip" | "running" }) { + const color = + status === "pass" + ? "var(--good)" + : status === "warn" + ? "var(--warning)" + : status === "fail" + ? "var(--critical)" + : "var(--text-muted)"; + return ( + + + + ); +} + +export function CodeBlock({ code, label }: { code: string; label?: string }) { + return ( +
+
+ + {label ?? "shell"} + + +
+
+        {code}
+      
+
+ ); +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx new file mode 100644 index 0000000..649e30e --- /dev/null +++ b/apps/web/src/components/ui/toast.tsx @@ -0,0 +1,108 @@ +import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react"; +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "../../lib/utils"; + +/** Actions confirm themselves here instead of pushing an alert into the page layout. */ + +export type ToastTone = "info" | "success" | "warning" | "error"; + +interface Toast { + id: number; + tone: ToastTone; + title: string; + description?: string; +} + +interface ToastContextValue { + toast: (toast: Omit) => void; + success: (title: string, description?: string) => void; + error: (title: string, description?: string) => void; +} + +const ToastContext = createContext(null); +let nextId = 1; + +const ICONS = { + info: Info, + success: CheckCircle2, + warning: AlertTriangle, + error: XCircle, +} as const; + +const TONE_CLASS: Record = { + info: "text-[var(--accent)]", + success: "text-[var(--good)]", + warning: "text-[var(--warning)]", + error: "text-[var(--critical)]", +}; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const dismiss = useCallback((id: number) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const toast = useCallback( + (input: Omit) => { + const id = nextId++; + setToasts((current) => [...current, { ...input, id }].slice(-4)); + // Errors stay long enough to read the reason; confirmations get out of the way. + setTimeout(() => dismiss(id), input.tone === "error" ? 9000 : 4500); + }, + [dismiss], + ); + + const value = useMemo( + () => ({ + toast, + success: (title, description) => toast({ tone: "success", title, description }), + error: (title, description) => toast({ tone: "error", title, description }), + }), + [toast], + ); + + return ( + + {children} + {createPortal( +
+ {toasts.map((item) => { + const Icon = ICONS[item.tone]; + return ( +
+ +
+

{item.title}

+ {item.description ? ( +

{item.description}

+ ) : null} +
+ +
+ ); + })} +
, + document.body, + )} +
+ ); +} + +export function useToast(): ToastContextValue { + const context = useContext(ToastContext); + if (!context) throw new Error("useToast must be used inside ToastProvider"); + return context; +} diff --git a/apps/web/src/components/widgets.tsx b/apps/web/src/components/widgets.tsx new file mode 100644 index 0000000..92679a3 --- /dev/null +++ b/apps/web/src/components/widgets.tsx @@ -0,0 +1,693 @@ +import { formatUnit, type MetricUnit, type QueryResult, SIZE_SPEC, type WidgetSpec } from "@dashflow/core"; +import type * as echarts from "echarts/core"; +import { + ArrowDownRight, + ArrowRight, + ArrowUpRight, + ChartNoAxesColumn, + Table as TableIcon, + TriangleAlert, +} from "lucide-react"; +import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { seriesColors } from "../lib/series-color"; +import { cn, cssVar, SEQUENTIAL_VARS } from "../lib/utils"; +import { baseOption, Chart, useChartTokens } from "./chart"; +import { Badge, Card, CardHeader, DataTable, EmptyState, Skeleton, StatusDot } from "./ui"; + +const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +/** What was clicked, so the caller can open the rows behind it. */ +export interface DrillTarget { + label: string; + /** The series or group the click landed on, if the chart is split. */ + groupValue?: string; + /** Bucket start and end, for time series. */ + from?: string; + to?: string; +} + +function fmt(unit: string, value: number | null | undefined, currency?: string): string { + if (value == null) return "—"; + return formatUnit(unit as MetricUnit, value, currency); +} + +/** Direction of a change, and whether that direction is good for this metric. */ +function trend( + value: number | null | undefined, + previous: number | null | undefined, + betterWhen: "lower" | "higher" | "neutral", +) { + if (value == null || previous == null || previous === 0) return null; + const change = ((value - previous) / Math.abs(previous)) * 100; + if (Math.abs(change) < 0.5) return { change, tone: "neutral" as const, icon: ArrowRight }; + const improving = betterWhen === "neutral" ? null : betterWhen === "lower" ? change < 0 : change > 0; + return { + change, + tone: improving === null ? ("neutral" as const) : improving ? ("good" as const) : ("bad" as const), + icon: change > 0 ? ArrowUpRight : ArrowDownRight, + }; +} + +function Sparkline({ points, tone }: { points: { value: number | null }[]; tone: string }) { + const path = useMemo(() => { + const values = points.map((point) => point.value).filter((value): value is number => value != null); + if (values.length < 2) return null; + const min = Math.min(...values); + const max = Math.max(...values); + const span = max - min || 1; + const step = 100 / (points.length - 1); + let started = false; + const segments: string[] = []; + points.forEach((point, index) => { + if (point.value == null) { + started = false; + return; + } + const x = index * step; + const y = 26 - ((point.value - min) / span) * 22; + segments.push(`${started ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`); + started = true; + }); + return segments.join(" "); + }, [points]); + + if (!path) return null; + return ( + + + + ); +} + +function KpiTile({ + result, + betterWhen, +}: { + result: Extract; + betterWhen: "lower" | "higher" | "neutral"; +}) { + const movement = trend(result.value, result.previous, betterWhen); + const Icon = movement?.icon; + const accent = cssVar("--series-1", "#3987e5"); + + return ( +
+
+

+ {fmt(result.unit, result.value, result.currency)} +

+ {movement && Icon ? ( +

+ + {Math.abs(movement.change).toFixed(movement.change < 10 ? 1 : 0)}% + vs previous +

+ ) : ( +

no comparison available

+ )} +
+ {result.spark && result.spark.length > 1 ? ( +
+ +
+ ) : null} +
+ ); +} + +function bucketMs(grain: string): number { + return grain === "day" ? 86_400_000 : 3_600_000; +} + +function SeriesWidget({ + widget, + result, + height, + onDrill, +}: { + widget: WidgetSpec; + result: Extract; + height: number; + onDrill?: (target: DrillTarget) => void; +}) { + const tokens = useChartTokens(); + const option = useMemo(() => { + const colors = seriesColors(result.series.map((series) => series.name)); + const base = baseOption(tokens); + const stacked = widget.viz === "stacked-bar"; + const area = widget.viz === "area"; + const multi = result.series.length > 1; + + return { + ...base, + color: colors, + legend: { ...base.legend, show: multi }, + grid: { left: 8, right: 14, top: 10, bottom: multi ? 28 : 6, containLabel: true }, + tooltip: { + ...base.tooltip, + trigger: "axis", + valueFormatter: (value: number) => fmt(result.unit, value, result.currency), + }, + xAxis: { + type: "time", + axisLine: { lineStyle: { color: tokens.border } }, + axisTick: { show: false }, + axisLabel: { color: tokens.inkMuted, fontSize: 11, hideOverlap: true }, + splitLine: { show: false }, + }, + yAxis: { + type: "value", + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: tokens.inkMuted, + fontSize: 11, + formatter: (value: number) => fmt(result.unit, value, result.currency), + }, + splitLine: { lineStyle: { color: tokens.grid, width: 1 } }, + }, + series: result.series.map((series, index) => ({ + name: series.name, + type: stacked ? "bar" : "line", + stack: stacked ? "total" : undefined, + smooth: false, + showSymbol: false, + symbolSize: 9, + connectNulls: false, + lineStyle: { width: 2 }, + // A 2px surface gap between stacked fills, with rounded ends on the top segment. + itemStyle: stacked + ? { borderColor: tokens.surface, borderWidth: 2, borderRadius: [4, 4, 0, 0] } + : undefined, + areaStyle: area ? { opacity: 0.16, color: colors[index % colors.length] } : undefined, + emphasis: { focus: "series" }, + data: series.points.map((point) => [point.ts, point.value]), + })), + } as echarts.EChartsCoreOption; + }, [result, widget.viz, tokens]); + + const events = useMemo( + () => + onDrill + ? { + click: (params: unknown) => { + const point = params as { seriesName?: string; value?: [string, number] }; + const ts = point.value?.[0]; + if (!ts) return; + const start = new Date(ts); + onDrill({ + label: + point.seriesName && point.seriesName !== "total" + ? `${widget.title} · ${point.seriesName}` + : widget.title, + groupValue: point.seriesName === "total" ? undefined : point.seriesName, + from: start.toISOString(), + to: new Date(start.getTime() + bucketMs(result.grain)).toISOString(), + }); + }, + } + : undefined, + [onDrill, result.grain, widget.title], + ); + + if (result.series.every((series) => series.points.every((point) => point.value == null))) { + return ; + } + return ; +} + +function BreakdownWidget({ + widget, + result, + height, + onDrill, +}: { + widget: WidgetSpec; + result: Extract; + height: number; + onDrill?: (target: DrillTarget) => void; +}) { + const tokens = useChartTokens(); + const option = useMemo(() => { + const base = baseOption(tokens); + const donut = widget.viz === "donut"; + + if (donut) { + const colors = seriesColors(result.items.map((item) => item.name)); + return { + ...base, + color: colors, + legend: { ...base.legend, show: true }, + tooltip: { + ...base.tooltip, + trigger: "item", + valueFormatter: (value: number) => fmt(result.unit, value, result.currency), + }, + series: [ + { + type: "pie", + radius: ["54%", "78%"], + center: ["50%", "44%"], + avoidLabelOverlap: true, + label: { show: false }, + itemStyle: { borderColor: tokens.surface, borderWidth: 2 }, + data: result.items.map((item) => ({ name: item.name, value: item.value })), + }, + ], + } as echarts.EChartsCoreOption; + } + + // Horizontal bars: the label carries the identity, so one accent colour is enough. + const sorted = [...result.items].sort((a, b) => a.value - b.value); + return { + ...base, + legend: { show: false }, + grid: { left: 8, right: 52, top: 6, bottom: 6, containLabel: true }, + tooltip: { + ...base.tooltip, + trigger: "item", + valueFormatter: (value: number) => fmt(result.unit, value, result.currency), + }, + xAxis: { + type: "value", + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false }, + }, + yAxis: { + type: "category", + data: sorted.map((item) => item.name), + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: tokens.inkSecondary, fontSize: 11, width: 140, overflow: "truncate" }, + }, + series: [ + { + type: "bar", + barMaxWidth: 15, + itemStyle: { color: cssVar("--series-1", "#3987e5"), borderRadius: [0, 4, 4, 0] }, + label: { + show: true, + position: "right", + color: tokens.inkSecondary, + fontSize: 11, + formatter: (params: { value: number }) => fmt(result.unit, params.value, result.currency), + }, + data: sorted.map((item) => item.value), + }, + ], + } as echarts.EChartsCoreOption; + }, [result, widget.viz, tokens]); + + const events = useMemo( + () => + onDrill + ? { + click: (params: unknown) => { + const point = params as { name?: string }; + if (!point.name) return; + onDrill({ label: `${widget.title} · ${point.name}`, groupValue: point.name }); + }, + } + : undefined, + [onDrill, widget.title], + ); + + if (result.items.length === 0) return ; + return ; +} + +function HeatmapWidget({ + result, + height, +}: { + result: Extract; + height: number; +}) { + const tokens = useChartTokens(); + const option = useMemo(() => { + const base = baseOption(tokens); + const max = Math.max(1, ...result.cells.map((cell) => cell.value)); + return { + ...base, + legend: { show: false }, + grid: { left: 8, right: 16, top: 8, bottom: 44, containLabel: true }, + tooltip: { + ...base.tooltip, + trigger: "item", + formatter: (params: { value: [number, number, number] }) => + `${DAYS[params.value[1]] ?? ""} ${String(params.value[0]).padStart(2, "0")}:00
${fmt( + result.unit, + params.value[2], + result.currency, + )}`, + }, + xAxis: { + type: "category", + data: Array.from({ length: 24 }, (_, hour) => String(hour)), + splitArea: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: tokens.inkMuted, + fontSize: 10, + interval: 2, + formatter: (value: string) => value.padStart(2, "0"), + }, + }, + yAxis: { + type: "category", + data: DAYS, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: tokens.inkMuted, fontSize: 10 }, + }, + visualMap: { + min: 0, + max, + calculable: false, + orient: "horizontal", + left: "center", + bottom: 0, + itemWidth: 10, + itemHeight: 80, + textStyle: { color: tokens.inkMuted, fontSize: 10 }, + inRange: { color: SEQUENTIAL_VARS.map((name) => cssVar(name, "#cde2fb")) }, + formatter: (value: number) => fmt(result.unit, value, result.currency), + }, + series: [ + { + type: "heatmap", + data: result.cells.map((cell) => [cell.hour, cell.day, cell.value]), + itemStyle: { borderColor: tokens.surface, borderWidth: 2, borderRadius: 3 }, + emphasis: { itemStyle: { borderColor: tokens.ink, borderWidth: 1 } }, + progressive: 0, + }, + ], + } as echarts.EChartsCoreOption; + }, [result, tokens]); + + if (result.cells.length === 0) return ; + return ; +} + +function HostGridWidget({ result }: { result: Extract }) { + if (result.hosts.length === 0) { + return ( + + ); + } + const tone = (status: string, drain: boolean) => { + const s = status.toLowerCase(); + if (s === "available") return drain ? "warning" : "good"; + if (s === "shutdown" || s.includes("vmnotrunning")) return "neutral"; + return "critical"; + }; + + return ( +
+ {result.hosts.slice(0, 200).map((host) => ( +
+
+

{host.name}

+

+ {host.tenant} · {host.hostPool} +

+
+
+ + {host.sessions} sessions + + {!host.allowNewSession ? drain : null} + + {host.status} + +
+
+ ))} +
+ ); +} + +function NoData() { + return ( + + ); +} + +/** + * Any chart can be read as a table. Three light-mode series colours sit below 3:1 contrast on + * the light surface, and the palette's relief rule requires visible labels or a table view in + * that case — so every widget carries one. + */ +function ResultTable({ + result, + height, + onDrill, +}: { + result: QueryResult; + height: number; + onDrill?: (target: DrillTarget) => void; +}) { + const rows: { name: string; value: number | null }[] = []; + let unit = "count"; + let currency: string | undefined; + + switch (result.kind) { + case "scalar": + unit = result.unit; + currency = result.currency; + rows.push({ name: "Value", value: result.value }); + if (result.previous != null) rows.push({ name: "Previous period", value: result.previous }); + break; + case "series": + unit = result.unit; + currency = result.currency; + for (const series of result.series) { + for (const point of series.points) { + if (point.value == null) continue; + rows.push({ + name: `${series.name === "total" ? "" : `${series.name} · `}${new Date(point.ts).toLocaleString()}`, + value: point.value, + }); + } + } + break; + case "breakdown": + unit = result.unit; + currency = result.currency; + rows.push(...result.items); + break; + case "table": + unit = result.unit; + currency = result.currency; + rows.push( + ...result.rows.map((row) => ({ + name: String(row.name ?? "—"), + value: typeof row.value === "number" ? row.value : null, + })), + ); + break; + case "heatmap": + unit = result.unit; + currency = result.currency; + rows.push( + ...result.cells.map((cell) => ({ + name: `${DAYS[cell.day] ?? "?"} ${String(cell.hour).padStart(2, "0")}:00`, + value: cell.value, + })), + ); + break; + case "hosts": + rows.push( + ...result.hosts.map((host) => ({ name: `${host.name} · ${host.status}`, value: host.sessions })), + ); + break; + } + + return ( + 40 ? 20 : 0} + rows={rows} + rowKey={(row, index) => `${row.name}-${index}`} + onRowClick={onDrill ? (row) => onDrill({ label: row.name, groupValue: row.name }) : undefined} + columns={[ + { key: "name", label: "Series" }, + { + key: "value", + label: "Value", + numeric: true, + sortValue: (row) => row.value, + render: (row) => fmt(unit, row.value, currency), + }, + ]} + /> + ); +} + +export function WidgetBody({ + widget, + result, + error, + loading, + betterWhen, + height, + asTable, + onDrill, +}: { + widget: WidgetSpec; + result?: QueryResult; + error?: string; + loading?: boolean; + betterWhen: "lower" | "higher" | "neutral"; + height: number; + asTable?: boolean; + onDrill?: (target: DrillTarget) => void; +}) { + if (loading) { + return ( +
+ +
+ ); + } + if (error) { + return ( +
+ + {error} +
+ ); + } + if (!result) return ; + if (asTable) return ; + + switch (result.kind) { + case "scalar": + return ; + case "series": + return ; + case "breakdown": + return ; + case "heatmap": + return ; + case "hosts": + return ; + case "table": + return ; + } +} + +/** A widget card. In a grid it fills its cell; standalone it uses the size preset's height. */ +export function WidgetCard({ + widget, + result, + error, + loading, + betterWhen, + actions, + onDrill, + fill, + dragHandleClass, +}: { + widget: WidgetSpec; + result?: QueryResult; + error?: string; + loading?: boolean; + betterWhen: "lower" | "higher" | "neutral"; + actions?: ReactNode; + onDrill?: (target: DrillTarget) => void; + /** Stretch to the parent's height (grid mode) instead of the size preset. */ + fill?: boolean; + dragHandleClass?: string; +}) { + const spec = SIZE_SPEC[widget.size]; + const [asTable, setAsTable] = useState(false); + const [bodyHeight, setBodyHeight] = useState(spec.height); + const canTable = widget.viz !== "kpi"; + + // In grid mode the card is sized by its cell, so the chart measures the space it was given. + const measure = useCallback( + (node: HTMLDivElement | null) => { + if (!node || !fill) return; + const observer = new ResizeObserver(() => setBodyHeight(Math.max(80, node.clientHeight))); + observer.observe(node); + setBodyHeight(Math.max(80, node.clientHeight)); + return () => observer.disconnect(); + }, + [fill], + ); + + return ( + + + {canTable && result ? ( + + ) : null} + {actions} + + } + /> +
+ +
+
+ ); +} + +export { StatusDot }; diff --git a/apps/web/src/lib/api-mock.ts b/apps/web/src/lib/api-mock.ts new file mode 100644 index 0000000..3630358 --- /dev/null +++ b/apps/web/src/lib/api-mock.ts @@ -0,0 +1,681 @@ +import { + CATEGORY_INFO, + CONNECTION_TYPE_INFO, + DASHBOARD_TEMPLATES, + type DashboardSpec, + DIMENSIONS, + METRICS, + type QueryRequest, + ROLE_INFO, + templateFor, +} from "@dashflow/core"; +import { buildDataset, type Dataset } from "@dashflow/demo/dataset"; +import { evaluate } from "@dashflow/demo/evaluator"; +import type { DetailRow, api as RealApi } from "./api"; + +/** + * The demo build's API: the same surface as the HTTP client, answered from a synthetic estate + * generated in the browser. It exists so anyone can click through DashFlow without deploying + * anything — and the evaluator behind it is checked against the real SQL engine by + * `packages/demo/src/parity.test.ts`, so the demo cannot quietly drift from the product. + */ + +let dataset: Dataset | null = null; +const data = (): Dataset => { + dataset ??= buildDataset({ tenants: 4, days: 30 }); + return dataset; +}; + +const STORAGE_KEY = "dashflow.demo.dashboards"; + +interface StoredDashboard extends DashboardSpec { + id: string; + updatedAt: string; + favorite: boolean; +} + +function seedDashboards(): StoredDashboard[] { + return ["executive", "experience", "reliability"].map((goal, index) => { + const template = templateFor(goal as never)!; + return { + id: `demo-${goal}`, + name: template.name, + description: template.description, + goal: template.goal, + widgets: template.widgets, + defaultPreset: "7d" as const, + tenantScope: null, + visibility: "shared" as const, + sharedWithRole: "viewer" as const, + updatedAt: new Date(Date.now() - index * 3_600_000).toISOString(), + favorite: index === 0, + }; + }); +} + +function loadDashboards(): StoredDashboard[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw) as StoredDashboard[]; + } catch { + // A demo that cannot write to storage still works; it just forgets. + } + const seeded = seedDashboards(); + saveDashboards(seeded); + return seeded; +} + +function saveDashboards(dashboards: StoredDashboard[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(dashboards)); + } catch { + // ignore + } +} + +const delay = (value: T, ms = 120): Promise => + new Promise((resolve) => setTimeout(() => resolve(value), ms)); + +const poolLabel = (pool: { friendlyName?: string | null; name: string }) => pool.friendlyName ?? pool.name; + +function tenantName(id: string): string { + return data().tenants.find((tenant) => tenant.id === id)?.displayName ?? "Unknown"; +} + +function runQuery(request: QueryRequest & { tz?: string }) { + return evaluate(data(), { + ...request, + from: new Date(request.from), + to: new Date(request.to), + }); +} + +/** Rows behind a click, matching the shape the real detail endpoint returns. */ +function detailRows(body: { + metric?: string; + from: string; + to: string; + tenantIds: string[] | null; + hostPools: string[]; + groupBy?: string; + groupValue?: string; + limit?: number; +}) { + const from = new Date(body.from).getTime(); + const to = new Date(body.to).getTime(); + const metric = body.metric ?? "connections.count"; + const isError = metric.startsWith("errors."); + const source = isError ? data().errors : data().connections; + + const rows = source + .filter((row) => { + const at = new Date(row.ts).getTime(); + if (at < from || at >= to) return false; + if (body.tenantIds?.length && !body.tenantIds.includes(row.tenantId)) return false; + if (body.hostPools.length && !body.hostPools.includes(row.hostPoolId ?? "")) return false; + if (body.groupBy && body.groupValue) { + const pool = data().pools.find((candidate) => candidate.resourceId === row.hostPoolId); + const value = + body.groupBy === "hostPool" + ? pool + ? poolLabel(pool) + : "Unknown" + : body.groupBy === "tenant" + ? tenantName(row.tenantId) + : String((row as unknown as Record)[body.groupBy] ?? ""); + if (value !== body.groupValue) return false; + } + return true; + }) + .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime()) + .slice(0, body.limit ?? 200); + + if (isError) { + return rows.map((row) => { + const error = row as (typeof dataset extends null ? never : Dataset)["errors"][number]; + const pool = data().pools.find((candidate) => candidate.resourceId === error.hostPoolId); + return { + ts: error.ts, + tenant: tenantName(error.tenantId), + host_pool: pool ? poolLabel(pool) : "—", + code: error.code, + source: error.source, + service_error: error.serviceError, + message: error.message, + } satisfies DetailRow; + }); + } + + return rows.map((row) => { + const connection = row as unknown as { + ts: string; + tenantId: string; + hostPoolId: string | null; + user: string; + sessionHost: string | null; + clientOs: string | null; + clientType: string | null; + gatewayRegion: string | null; + state: string; + connectMs: number | null; + rttMs: number | null; + durationSec: number | null; + }; + const pool = data().pools.find((candidate) => candidate.resourceId === connection.hostPoolId); + return { + ts: connection.ts, + tenant: tenantName(connection.tenantId), + host_pool: pool ? poolLabel(pool) : "—", + user: connection.user, + session_host: connection.sessionHost, + client_os: connection.clientOs, + client_type: connection.clientType, + gateway_region: connection.gatewayRegion, + state: connection.state, + connect_ms: connection.connectMs, + rtt_ms: connection.rttMs, + duration_sec: connection.durationSec, + } satisfies DetailRow; + }); +} + +const readOnly = () => { + throw new Error("This is the hosted demo — connections and users are read-only here."); +}; + +export const mockApi: typeof RealApi = { + me: () => + delay({ + user: { + id: "demo-user", + email: "you@demo.dashflow", + displayName: "Demo visitor", + role: "owner" as const, + tenantScope: null, + }, + instance: { + claimed: true, + authMode: "easyauth" as const, + demoMode: true, + pseudonymizeUsers: true, + canStoreSecrets: false, + canUseKeyVault: false, + connectionCount: 1, + }, + tenants: data().tenants.map((tenant) => ({ + id: tenant.id, + displayName: tenant.displayName, + domain: tenant.domain, + connectionId: "demo", + })), + }), + + claim: () => delay({ ok: true }), + + catalog: () => + delay({ + metrics: METRICS.map((metric) => ({ + id: metric.id, + label: metric.label, + description: metric.description, + category: metric.category, + unit: metric.unit, + betterWhen: metric.betterWhen, + viz: [...metric.viz], + dims: Object.keys(metric.dims), + requires: metric.requires ?? null, + available: metric.id !== "autoscale.actions" && metric.id !== "cost.savings", + })), + dimensions: DIMENSIONS, + categories: CATEGORY_INFO, + templates: DASHBOARD_TEMPLATES, + connectionTypes: CONNECTION_TYPE_INFO, + roles: ROLE_INFO, + }), + + connections: () => + delay([ + { + id: "demo", + name: "Demo data (synthetic)", + type: "demo" as const, + config: { type: "demo", tenants: 4 }, + schedule: { + inventoryMinutes: 60, + sessionsMinutes: 5, + logsMinutes: 15, + costHours: 24, + backfillDays: 30, + retentionDays: 180, + }, + enabled: true, + status: "ok" as const, + hasSecret: false, + consented: false, + lastTestedAt: new Date().toISOString(), + lastTest: [ + { id: "demo", label: "Demo connection", status: "pass" as const, detail: "4 synthetic customers" }, + { + id: "demo-note", + label: "This is not real data", + status: "warn" as const, + detail: "Everything here is generated in your browser", + fix: "Deploy DashFlow and add an Azure, Lighthouse, Partner Center or Nerdio connection.", + }, + ], + lastSyncAt: new Date().toISOString(), + tenantCount: data().tenants.length, + }, + ]), + createConnection: readOnly, + updateConnection: readOnly, + deleteConnection: readOnly, + testConnection: () => delay({ checks: [] }), + discover: readOnly, + syncConnection: readOnly, + consentUrl: readOnly, + + tenants: () => + delay( + data().tenants.map((tenant) => ({ + id: tenant.id, + connectionId: "demo", + connectionName: "Demo data (synthetic)", + connectionType: "demo", + tenantId: tenant.id, + displayName: tenant.displayName, + domain: tenant.domain, + enabled: true, + meta: { demo: true }, + subscriptions: [], + workspaces: [], + hostPools: data() + .pools.filter((pool) => pool.tenantId === tenant.id) + .map((pool) => ({ + resourceId: pool.resourceId, + name: pool.name, + friendlyName: pool.friendlyName ?? null, + location: pool.location, + poolType: pool.poolType, + maxSessions: pool.maxSessions ?? null, + autoscaleEnabled: pool.autoscaleEnabled ?? null, + sources: ["demo"], + enabled: true, + })), + })), + ), + saveSelection: readOnly, + hostPools: () => + delay( + data().pools.map((pool) => ({ + resourceId: pool.resourceId, + name: pool.name, + friendlyName: pool.friendlyName ?? null, + tenantId: pool.tenantId, + tenantName: pool.tenantName, + poolType: pool.poolType, + location: pool.location, + })), + ), + + dashboards: () => + delay( + loadDashboards().map((dashboard) => ({ + id: dashboard.id, + name: dashboard.name, + description: dashboard.description, + goal: dashboard.goal, + widgetCount: dashboard.widgets.length, + visibility: dashboard.visibility, + sharedWithRole: dashboard.sharedWithRole, + defaultPreset: dashboard.defaultPreset, + tenantScope: dashboard.tenantScope, + isOwner: true, + favorite: dashboard.favorite, + updatedAt: dashboard.updatedAt, + })), + ), + dashboard: (id) => { + const dashboard = loadDashboards().find((candidate) => candidate.id === id); + if (!dashboard) return Promise.reject(new Error("No such dashboard")); + return delay({ ...dashboard, isOwner: true, canEdit: true }); + }, + createDashboard: (body) => { + const dashboards = loadDashboards(); + const id = `demo-${Math.random().toString(36).slice(2, 8)}`; + dashboards.unshift({ ...body, id, updatedAt: new Date().toISOString(), favorite: false }); + saveDashboards(dashboards); + return delay({ id }); + }, + createFromTemplate: (body) => { + const template = templateFor(body.goal as never); + if (!template) return Promise.reject(new Error("No such template")); + const dashboards = loadDashboards(); + const id = `demo-${Math.random().toString(36).slice(2, 8)}`; + dashboards.unshift({ + id, + name: body.name ?? template.name, + description: template.description, + goal: template.goal, + widgets: template.widgets.filter((widget) => !body.omit.includes(widget.id)), + defaultPreset: body.defaultPreset as StoredDashboard["defaultPreset"], + tenantScope: body.tenantScope, + visibility: "private", + sharedWithRole: "viewer", + updatedAt: new Date().toISOString(), + favorite: false, + }); + saveDashboards(dashboards); + return delay({ id }); + }, + saveDashboard: (id, body) => { + const dashboards = loadDashboards(); + const index = dashboards.findIndex((candidate) => candidate.id === id); + if (index >= 0) { + dashboards[index] = { ...dashboards[index]!, ...body, updatedAt: new Date().toISOString() }; + saveDashboards(dashboards); + } + return delay({ ok: true as const }); + }, + deleteDashboard: (id) => { + saveDashboards(loadDashboards().filter((candidate) => candidate.id !== id)); + return delay({ ok: true as const }); + }, + duplicateDashboard: (id) => { + const dashboards = loadDashboards(); + const source = dashboards.find((candidate) => candidate.id === id); + if (!source) return Promise.reject(new Error("No such dashboard")); + const copy = { + ...source, + id: `demo-${Math.random().toString(36).slice(2, 8)}`, + name: `${source.name} (copy)`, + favorite: false, + updatedAt: new Date().toISOString(), + }; + dashboards.unshift(copy); + saveDashboards(dashboards); + return delay({ id: copy.id }); + }, + setFavorite: (id, favorite) => { + const dashboards = loadDashboards(); + const index = dashboards.findIndex((candidate) => candidate.id === id); + if (index >= 0) { + dashboards[index] = { ...dashboards[index]!, favorite }; + saveDashboards(dashboards); + } + return delay({ ok: true as const }); + }, + + query: (body) => delay(runQuery(body), 60), + queryBatch: (body) => + delay( + { + results: body.queries.map(({ key, ...request }) => { + try { + return { key, result: runQuery(request) }; + } catch (error) { + return { key, error: (error as Error).message }; + } + }), + }, + 80, + ), + detail: (body) => delay({ rows: detailRows(body), pseudonymized: true, table: "connection_facts" }), + dimensionValues: (metric, dim) => { + const request: QueryRequest = { + metric, + viz: "bar", + groupBy: dim as never, + topN: 50, + grain: "day", + from: new Date(Date.now() - 30 * 86_400_000), + to: new Date(), + tenantIds: null, + filters: {}, + compare: false, + }; + const result = evaluate(data(), request); + return delay({ + values: + result.kind === "breakdown" + ? result.items.map((item) => ({ value: item.name, hits: item.value })) + : [], + }); + }, + + pool: (resourceId) => { + const pool = data().pools.find((candidate) => candidate.resourceId === resourceId); + if (!pool) return Promise.reject(new Error("No such host pool")); + return delay({ + pool: { + resourceId: pool.resourceId, + name: pool.name, + friendlyName: pool.friendlyName ?? null, + location: pool.location, + poolType: pool.poolType, + loadBalancer: pool.loadBalancer ?? null, + maxSessions: pool.maxSessions ?? null, + autoscaleEnabled: pool.autoscaleEnabled ?? null, + startVmOnConnect: pool.startVmOnConnect ?? null, + validationEnvironment: pool.validationEnvironment ?? null, + sources: ["demo"], + subscriptionId: pool.subscriptionId, + updatedAt: new Date().toISOString(), + tenantId: pool.tenantId, + tenantName: pool.tenantName, + }, + hosts: data() + .hosts.filter((host) => host.hostPoolId === resourceId) + .map((host) => ({ + resourceId: host.resourceId, + name: host.name, + status: host.status, + allowNewSession: host.allowNewSession, + sessions: host.sessions, + agentVersion: host.agentVersion ?? null, + osVersion: host.osVersion ?? null, + lastHeartBeat: host.lastHeartBeat ?? null, + updateState: host.updateState ?? null, + source: "demo", + })), + }); + }, + + host: (poolId, name) => { + const host = data().hosts.find((candidate) => candidate.hostPoolId === poolId && candidate.name === name); + const pool = data().pools.find((candidate) => candidate.resourceId === poolId); + if (!host || !pool) return Promise.reject(new Error("No such session host")); + const since = Date.now() - 7 * 86_400_000; + return delay({ + host: { + resourceId: host.resourceId, + name: host.name, + status: host.status, + allowNewSession: host.allowNewSession, + sessions: host.sessions, + agentVersion: host.agentVersion ?? null, + osVersion: host.osVersion ?? null, + lastHeartBeat: host.lastHeartBeat ?? null, + updateState: host.updateState ?? null, + vmResourceId: host.vmResourceId ?? null, + source: "demo", + tenantId: host.tenantId, + tenantName: pool.tenantName, + hostPoolId: pool.resourceId, + hostPoolName: pool.name, + hostPoolFriendlyName: pool.friendlyName ?? null, + }, + health: data() + .hostHealth.filter( + (row) => + row.hostPoolId === poolId && row.sessionHost === name && new Date(row.ts).getTime() > since, + ) + .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime()) + .slice(0, 500) as unknown as Record[], + errors: data() + .errors.filter((row) => row.hostPoolId === poolId && new Date(row.ts).getTime() > since) + .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime()) + .slice(0, 50) + .map((row) => ({ ...row, service_error: row.serviceError })) as unknown as Record[], + }); + }, + + errorGroups: (days) => { + const since = Date.now() - days * 86_400_000; + const groups = new Map< + string, + { + code: string; + source: string; + serviceError: boolean; + hits: number; + pools: Set; + tenants: Set; + lastSeen: string; + sample: string; + } + >(); + for (const row of data().errors) { + if (new Date(row.ts).getTime() < since) continue; + const key = `${row.code}|${row.source}`; + const group = groups.get(key) ?? { + code: row.code, + source: row.source, + serviceError: row.serviceError, + hits: 0, + pools: new Set(), + tenants: new Set(), + lastSeen: row.ts, + sample: row.message, + }; + group.hits += 1; + if (row.hostPoolId) group.pools.add(row.hostPoolId); + group.tenants.add(row.tenantId); + if (row.ts > group.lastSeen) group.lastSeen = row.ts; + groups.set(key, group); + } + return delay({ + groups: [...groups.values()] + .sort((a, b) => b.hits - a.hits) + .map((group) => ({ + code: group.code, + source: group.source, + serviceError: group.serviceError, + hits: group.hits, + pools: group.pools.size, + tenants: group.tenants.size, + lastSeen: group.lastSeen, + sample: group.sample, + })), + }); + }, + + topUsers: (days) => { + const since = Date.now() - days * 86_400_000; + const users = new Map< + string, + { + connections: number; + failed: number; + pools: Set; + connect: number[]; + seconds: number; + lastSeen: string; + } + >(); + for (const row of data().connections) { + if (new Date(row.ts).getTime() < since) continue; + const entry = users.get(row.user) ?? { + connections: 0, + failed: 0, + pools: new Set(), + connect: [], + seconds: 0, + lastSeen: row.ts, + }; + entry.connections += 1; + if (row.state === "failed") entry.failed += 1; + if (row.hostPoolId) entry.pools.add(row.hostPoolId); + if (row.connectMs != null) entry.connect.push(row.connectMs); + entry.seconds += row.durationSec ?? 0; + if (row.ts > entry.lastSeen) entry.lastSeen = row.ts; + users.set(row.user, entry); + } + return delay({ + pseudonymized: false, + users: [...users.entries()] + .sort((a, b) => b[1].connections - a[1].connections) + .slice(0, 200) + .map(([user, entry]) => { + const sorted = entry.connect.sort((a, b) => a - b); + return { + user, + connections: entry.connections, + failed: entry.failed, + pools: entry.pools.size, + connectP95: sorted.length ? Math.round(sorted[Math.floor(sorted.length * 0.95)] ?? 0) : null, + hours: entry.seconds / 3600, + lastSeen: entry.lastSeen, + }; + }), + }); + }, + + settings: () => + delay({ + instance: { name: "DashFlow demo", organizationName: "" }, + runtime: { + version: "demo", + commit: "", + authMode: "dev", + appRole: "all", + demoMode: true, + pseudonymizeUsers: false, + keyVault: false, + encryptionKey: false, + tokenValidation: false, + allowedTenants: [], + }, + counts: { connections: 1, customers: data().tenants.length, dashboards: loadDashboards().length }, + }), + saveSettings: readOnly, + + syncRuns: () => + delay({ + runs: ["inventory", "sessions", "logs", "cost"].flatMap((stream, index) => + data() + .tenants.slice(0, 2) + .map((tenant, tenantIndex) => ({ + id: `${stream}-${tenantIndex}`, + stream, + status: "ok" as const, + startedAt: new Date(Date.now() - (index + 1) * 600_000).toISOString(), + finishedAt: new Date(Date.now() - (index + 1) * 600_000 + 4200).toISOString(), + stats: { hostPools: 3, sessionSnapshots: 288, connections: 420 }, + error: null, + connectionName: "Demo data (synthetic)", + tenantName: tenant.displayName, + })), + ), + queue: { queued: 0, running: 0, failed: 0 }, + }), + triggerSync: readOnly, + + users: () => + delay([ + { + id: "demo-user", + email: "you@demo.dashflow", + displayName: "Demo visitor", + disabled: false, + lastSeenAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + assignments: [ + { id: "demo-assignment", role: "owner" as const, customerTenantId: null, tenantName: null }, + ], + }, + ]), + grantRole: readOnly, + revokeRole: readOnly, + setDisabled: readOnly, +}; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..6f3021c --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,392 @@ +import type { + DashboardGoal, + DashboardSpec, + DashboardTemplate, + DiagnosticCheck, + QueryRequest, + QueryResult, + Role, + SyncSchedule, + WidgetSpec, +} from "@dashflow/core"; + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly issues?: { path: string; message: string }[], + ) { + super(message); + } +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`/api${path}`, { + ...init, + headers: { ...(init?.body ? { "content-type": "application/json" } : {}), ...init?.headers }, + }); + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { + error?: string; + message?: string; + issues?: { path: string; message: string }[]; + }; + throw new ApiError( + payload.error ?? payload.message ?? `Request failed (${response.status})`, + response.status, + payload.issues, + ); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} + +const post = (path: string, body?: unknown) => + request(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) }); + +// ─── shapes the API returns ───────────────────────────────────────────────────── + +export interface Me { + user: { + id: string; + email: string; + displayName: string | null; + role: Role | null; + tenantScope: string[] | null; + }; + instance: { + claimed: boolean; + authMode: "easyauth" | "dev"; + demoMode: boolean; + pseudonymizeUsers: boolean; + canStoreSecrets: boolean; + canUseKeyVault: boolean; + connectionCount: number; + }; + tenants: { id: string; displayName: string; domain: string | null; connectionId: string }[]; +} + +export interface CatalogMetric { + id: string; + label: string; + description: string; + category: string; + unit: string; + betterWhen: "lower" | "higher" | "neutral"; + viz: WidgetSpec["viz"][]; + dims: string[]; + requires: string | null; + available: boolean; +} + +export interface Catalog { + metrics: CatalogMetric[]; + dimensions: Record; + categories: Record; + templates: DashboardTemplate[]; + connectionTypes: Record; + roles: Record; +} + +export interface ConnectionSummary { + id: string; + name: string; + type: "azure" | "lighthouse" | "partner-center" | "nerdio" | "demo"; + config: Record; + schedule: SyncSchedule; + enabled: boolean; + status: "new" | "ok" | "degraded" | "error"; + hasSecret: boolean; + consented: boolean; + lastTestedAt: string | null; + lastTest: DiagnosticCheck[] | null; + lastSyncAt: string | null; + tenantCount: number; +} + +export interface TenantDetail { + id: string; + connectionId: string; + connectionName: string; + connectionType: string; + tenantId: string; + displayName: string; + domain: string | null; + enabled: boolean; + meta: Record | null; + subscriptions: { id: string; subscriptionId: string; displayName: string; enabled: boolean }[]; + workspaces: { id: string; name: string; resourceId: string; enabled: boolean; hostPoolIds: string[] }[]; + hostPools: { + resourceId: string; + name: string; + friendlyName: string | null; + location: string; + poolType: string; + maxSessions: number | null; + autoscaleEnabled: boolean | null; + sources: string[]; + enabled: boolean; + }[]; +} + +export interface HostPoolOption { + resourceId: string; + name: string; + friendlyName: string | null; + tenantId: string; + tenantName: string; + poolType: string; + location: string; +} + +/** A row of raw facts behind a chart point; columns vary by table. */ +export type DetailRow = Record; + +export interface ErrorGroup { + code: string; + source: string; + serviceError: boolean; + hits: number; + pools: number; + tenants: number; + lastSeen: string | null; + sample: string | null; +} + +export interface TopUser { + user: string; + connections: number; + failed: number; + pools: number; + connectP95: number | null; + hours: number; + lastSeen: string | null; +} + +export interface PoolDetail { + pool: { + resourceId: string; + name: string; + friendlyName: string | null; + location: string; + poolType: string; + loadBalancer: string | null; + maxSessions: number | null; + autoscaleEnabled: boolean | null; + startVmOnConnect: boolean | null; + validationEnvironment: boolean | null; + sources: string[]; + subscriptionId: string | null; + updatedAt: string; + tenantId: string; + tenantName: string; + }; + hosts: { + resourceId: string; + name: string; + status: string; + allowNewSession: boolean; + sessions: number; + agentVersion: string | null; + osVersion: string | null; + lastHeartBeat: string | null; + updateState: string | null; + source: string; + }[]; +} + +export interface HostDetail { + host: { + resourceId: string; + name: string; + status: string; + allowNewSession: boolean; + sessions: number; + agentVersion: string | null; + osVersion: string | null; + lastHeartBeat: string | null; + updateState: string | null; + vmResourceId: string | null; + source: string; + tenantId: string; + tenantName: string; + hostPoolId: string; + hostPoolName: string; + hostPoolFriendlyName: string | null; + }; + health: Record[]; + errors: Record[]; +} + +export interface SettingsPayload { + instance: { name: string; organizationName: string }; + runtime: { + version: string; + commit: string; + authMode: string; + appRole: string; + demoMode: boolean; + pseudonymizeUsers: boolean; + keyVault: boolean; + encryptionKey: boolean; + tokenValidation: boolean; + allowedTenants: string[]; + }; + counts: { connections: number; customers: number; dashboards: number }; +} + +export interface DashboardListItem { + id: string; + name: string; + description: string; + goal: DashboardGoal; + widgetCount: number; + visibility: "private" | "shared"; + sharedWithRole: Role; + defaultPreset: string; + tenantScope: string[] | null; + isOwner: boolean; + favorite: boolean; + updatedAt: string; +} + +export interface DashboardDetail extends DashboardSpec { + id: string; + isOwner: boolean; + canEdit: boolean; + favorite: boolean; + updatedAt: string; +} + +export interface SyncRun { + id: string; + stream: string; + status: "running" | "ok" | "partial" | "error"; + startedAt: string; + finishedAt: string | null; + stats: Record | null; + error: string | null; + connectionName: string; + tenantName: string | null; +} + +export interface UserRow { + id: string; + email: string; + displayName: string | null; + disabled: boolean; + lastSeenAt: string | null; + createdAt: string; + assignments: { id: string; role: Role; customerTenantId: string | null; tenantName: string | null }[]; +} + +export interface BatchQueryResult { + results: { key: string; result?: QueryResult; error?: string }[]; +} + +const httpApi = { + me: () => request("/me"), + claim: (code: string) => post<{ ok: boolean }>("/setup/claim", { code }), + catalog: () => request("/catalog"), + + connections: () => request("/connections"), + createConnection: (body: { + name: string; + config: Record; + secret?: { kind: "none" } | { kind: "inline"; value: string } | { kind: "keyvault"; secretName: string }; + schedule?: Partial; + }) => post<{ id: string }>("/connections", body), + updateConnection: (id: string, body: Record) => + request<{ ok: true }>(`/connections/${id}`, { method: "PATCH", body: JSON.stringify(body) }), + deleteConnection: (id: string) => request<{ ok: true }>(`/connections/${id}`, { method: "DELETE" }), + testConnection: (id: string) => post<{ checks: DiagnosticCheck[] }>(`/connections/${id}/test`), + discover: (id: string) => + post<{ tenants: number; subscriptions: number; workspaces: number; hostPools: number }>( + `/connections/${id}/discover`, + ), + syncConnection: (id: string, backfill = false) => + post<{ queued: number }>(`/connections/${id}/sync?backfill=${backfill}`), + consentUrl: (id: string) => post<{ url: string }>(`/connections/${id}/consent-url`), + + tenants: (connectionId?: string) => + request(`/tenants${connectionId ? `?connectionId=${connectionId}` : ""}`), + saveSelection: (body: { + tenants?: { id: string; enabled: boolean }[]; + subscriptions?: { id: string; enabled: boolean }[]; + workspaces?: { id: string; enabled: boolean }[]; + hostPools?: { resourceId: string; enabled: boolean }[]; + }) => post<{ ok: true }>("/tenants/selection", body), + hostPools: () => request("/tenants/host-pools"), + + dashboards: () => request("/dashboards"), + dashboard: (id: string) => request(`/dashboards/${id}`), + createDashboard: (body: DashboardSpec) => post<{ id: string }>("/dashboards", body), + createFromTemplate: (body: { + goal: string; + name?: string; + tenantScope: string[] | null; + defaultPreset: string; + omit: string[]; + }) => post<{ id: string }>("/dashboards/from-template", body), + saveDashboard: (id: string, body: DashboardSpec) => + request<{ ok: true }>(`/dashboards/${id}`, { method: "PUT", body: JSON.stringify(body) }), + deleteDashboard: (id: string) => request<{ ok: true }>(`/dashboards/${id}`, { method: "DELETE" }), + duplicateDashboard: (id: string) => post<{ id: string }>(`/dashboards/${id}/duplicate`), + setFavorite: (id: string, favorite: boolean) => + request<{ ok: true }>(`/dashboards/${id}/favorite`, { method: favorite ? "PUT" : "DELETE" }), + + query: (body: QueryRequest & { tz?: string }) => post("/query", body), + queryBatch: (body: { tz: string; queries: ({ key: string } & QueryRequest)[] }) => + post("/query/batch", body), + detail: (body: { + metric?: string; + from: string; + to: string; + tenantIds: string[] | null; + hostPools: string[]; + groupBy?: string; + groupValue?: string; + limit?: number; + }) => post<{ rows: DetailRow[]; pseudonymized: boolean; table?: string }>("/query/detail", body), + dimensionValues: (metric: string, dim: string) => + request<{ values: { value: string; hits: number }[] }>( + `/query/values?metric=${encodeURIComponent(metric)}&dim=${encodeURIComponent(dim)}`, + ), + + syncRuns: () => + request<{ runs: SyncRun[]; queue: { queued: number; running: number; failed: number } }>("/sync/runs"), + triggerSync: (body: { + connectionId: string; + stream: string; + backfill?: boolean; + customerTenantId?: string; + }) => post<{ queued: number }>("/sync/trigger", body), + + pool: (resourceId: string) => request(`/entities/pools/${encodeURIComponent(resourceId)}`), + host: (poolId: string, name: string) => + request(`/entities/hosts/${encodeURIComponent(poolId)}/${encodeURIComponent(name)}`), + errorGroups: (days: number) => request<{ groups: ErrorGroup[] }>(`/entities/errors?days=${days}`), + topUsers: (days: number) => + request<{ users: TopUser[]; pseudonymized: boolean }>(`/entities/users?days=${days}`), + + settings: () => request("/settings"), + saveSettings: (body: { name: string; organizationName: string }) => + request<{ ok: true }>("/settings", { method: "PUT", body: JSON.stringify(body) }), + + users: () => request("/users"), + grantRole: (userId: string, body: { role: Role; customerTenantId: string | null }) => + post<{ ok: true }>(`/users/${userId}/roles`, body), + revokeRole: (assignmentId: string) => + request<{ ok: true }>(`/users/roles/${assignmentId}`, { method: "DELETE" }), + setDisabled: (userId: string, disabled: boolean) => + post<{ ok: true }>(`/users/${userId}/disabled`, { disabled }), +}; + +/** + * The hosted demo ships the same UI with a mock client, so the whole app is clickable with no + * server. `import.meta.env.VITE_MOCK_API` is replaced at build time, so the branch not taken — + * and the demo data generator behind it — is dropped from the production bundle. + */ +export const api = ( + import.meta.env.VITE_MOCK_API === "1" ? (await import("./api-mock")).mockApi : httpApi +) as typeof httpApi; diff --git a/apps/web/src/lib/scope.tsx b/apps/web/src/lib/scope.tsx new file mode 100644 index 0000000..0e2aff8 --- /dev/null +++ b/apps/web/src/lib/scope.tsx @@ -0,0 +1,88 @@ +import { resolveRange, type TimeGrain, type TimePreset } from "@dashflow/core"; +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { browserTimeZone } from "./utils"; + +/** + * The global scope every dashboard reads: time range, which customers, which host pools. + * Kept in memory plus localStorage so a refresh does not reset what you were looking at. + */ +export interface Scope { + preset: TimePreset; + tenantIds: string[] | null; + hostPools: string[]; +} + +interface ScopeContextValue extends Scope { + tz: string; + range: { from: Date; to: Date; grain: TimeGrain }; + setPreset: (preset: TimePreset) => void; + setTenantIds: (tenantIds: string[] | null) => void; + setHostPools: (hostPools: string[]) => void; +} + +const ScopeContext = createContext(null); +const STORAGE_KEY = "avd.scope"; + +function readStored(): Scope { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + return { + preset: (parsed.preset ?? "7d") as TimePreset, + tenantIds: parsed.tenantIds ?? null, + hostPools: parsed.hostPools ?? [], + }; + } + } catch { + // A missing or unreadable preference is not an error. + } + return { preset: "7d", tenantIds: null, hostPools: [] }; +} + +export function ScopeProvider({ children }: { children: ReactNode }) { + const [scope, setScope] = useState(readStored); + const tz = useMemo(browserTimeZone, []); + + const update = useCallback((next: Partial) => { + setScope((current) => { + const merged = { ...current, ...next }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(merged)); + } catch { + // ignore + } + return merged; + }); + }, []); + + // "Last 24 hours" has to keep meaning that, but a range that changes every render would + // give every dashboard a new query key on every render. So the clock ticks on its own, + // at the same cadence as the fastest sync stream. + const [tick, setTick] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setTick(Date.now()), 5 * 60_000); + return () => clearInterval(timer); + }, []); + const range = useMemo(() => resolveRange(scope.preset, new Date(tick)), [scope.preset, tick]); + + const value = useMemo( + () => ({ + ...scope, + tz, + range, + setPreset: (preset) => update({ preset }), + setTenantIds: (tenantIds) => update({ tenantIds }), + setHostPools: (hostPools) => update({ hostPools }), + }), + [scope, tz, update, range], + ); + + return {children}; +} + +export function useScope(): ScopeContextValue { + const context = useContext(ScopeContext); + if (!context) throw new Error("useScope must be used inside ScopeProvider"); + return context; +} diff --git a/apps/web/src/lib/series-color.ts b/apps/web/src/lib/series-color.ts new file mode 100644 index 0000000..ea89595 --- /dev/null +++ b/apps/web/src/lib/series-color.ts @@ -0,0 +1,39 @@ +import { cssVar, SERIES_VARS } from "./utils"; + +/** + * Colour follows the entity, not its rank. + * + * If colours were handed out by array position, filtering one host pool out of a chart would + * repaint every survivor and quietly change what the reader thinks they are looking at. So a + * name claims a slot the first time it is seen and keeps it for the session — the same pool is + * the same colour on every chart, on every dashboard. + * + * Slots are assigned in the palette's fixed order and never generated: a ninth series reuses + * the ramp rather than inventing a hue outside the validated set. + */ +const slots = new Map(); + +export function colorSlot(name: string): number { + const existing = slots.get(name); + if (existing !== undefined) return existing; + const slot = slots.size % SERIES_VARS.length; + slots.set(name, slot); + return slot; +} + +export function seriesColor(name: string): string { + const variable = SERIES_VARS[colorSlot(name)] ?? SERIES_VARS[0]!; + return cssVar(variable, "#3987e5"); +} + +/** Colours for a set of names, stable regardless of the order they arrive in. */ +export function seriesColors(names: string[]): string[] { + return names.map(seriesColor); +} + +/** A single series ("total") always takes the first slot — it is the subject of the chart. */ +export const PRIMARY_SERIES = "total"; + +export function resetSeriesColors(): void { + slots.clear(); +} diff --git a/apps/web/src/lib/theme.tsx b/apps/web/src/lib/theme.tsx new file mode 100644 index 0000000..009272a --- /dev/null +++ b/apps/web/src/lib/theme.tsx @@ -0,0 +1,74 @@ +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; + +export type ThemeChoice = "system" | "light" | "dark"; + +interface ThemeContextValue { + choice: ThemeChoice; + resolved: "light" | "dark"; + setChoice: (choice: ThemeChoice) => void; + /** Bumped whenever the effective theme changes, so charts can re-read their tokens. */ + version: number; +} + +const ThemeContext = createContext(null); +const STORAGE_KEY = "avd.theme"; + +function readStored(): ThemeChoice { + try { + const value = localStorage.getItem(STORAGE_KEY); + if (value === "light" || value === "dark" || value === "system") return value; + } catch { + // Private windows and blocked storage are fine — fall back to the OS setting. + } + return "system"; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [choice, setChoiceState] = useState(readStored); + const [systemDark, setSystemDark] = useState( + () => typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches, + ); + + useEffect(() => { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = (event: MediaQueryListEvent) => setSystemDark(event.matches); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); + }, []); + + const resolved: "light" | "dark" = choice === "system" ? (systemDark ? "dark" : "light") : choice; + + useEffect(() => { + const root = document.documentElement; + if (choice === "system") root.removeAttribute("data-theme"); + else root.setAttribute("data-theme", choice); + }, [choice]); + + const setChoice = useCallback((next: ThemeChoice) => { + setChoiceState(next); + try { + localStorage.setItem(STORAGE_KEY, next); + } catch { + // Not being able to remember the choice is not worth an error. + } + }, []); + + const [version, setVersion] = useState(0); + useEffect(() => { + // Wait a frame so the new tokens are applied before charts read them. + const id = requestAnimationFrame(() => setVersion((v) => v + 1)); + return () => cancelAnimationFrame(id); + }, []); + + const value = useMemo( + () => ({ choice, resolved, setChoice, version }), + [choice, resolved, setChoice, version], + ); + return {children}; +} + +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) throw new Error("useTheme must be used inside ThemeProvider"); + return context; +} diff --git a/apps/web/src/lib/use-measure.ts b/apps/web/src/lib/use-measure.ts new file mode 100644 index 0000000..636d592 --- /dev/null +++ b/apps/web/src/lib/use-measure.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * The element's own content width, observed. The grid needs a real number to lay widgets out + * against, and a wrong one is visible: widgets either overflow the page or leave a gap. + */ +export function useElementWidth(): { + ref: (node: T | null) => void; + width: number; + measured: boolean; +} { + const [width, setWidth] = useState(0); + const observer = useRef(null); + + const ref = useCallback((node: T | null) => { + observer.current?.disconnect(); + if (!node) return; + const update = () => setWidth(node.clientWidth); + update(); + observer.current = new ResizeObserver(update); + observer.current.observe(node); + }, []); + + useEffect(() => () => observer.current?.disconnect(), []); + + return { ref, width, measured: width > 0 }; +} diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts new file mode 100644 index 0000000..d60ea6c --- /dev/null +++ b/apps/web/src/lib/utils.ts @@ -0,0 +1,86 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} + +/** The browser's zone, sent with every query so buckets and heatmaps line up with the workday. */ +export const browserTimeZone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +}; + +export function formatDateTime(value: string | Date | null | undefined): string { + if (!value) return "—"; + const date = typeof value === "string" ? new Date(value) : value; + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); +} + +export function formatRelative(value: string | Date | null | undefined): string { + if (!value) return "never"; + const date = typeof value === "string" ? new Date(value) : value; + const seconds = (Date.now() - date.getTime()) / 1000; + const units: [Intl.RelativeTimeFormatUnit, number][] = [ + ["second", 60], + ["minute", 60], + ["hour", 24], + ["day", 30], + ["month", 12], + ["year", Number.POSITIVE_INFINITY], + ]; + let amount = seconds; + for (const [unit, size] of units) { + if (Math.abs(amount) < size) { + return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(-Math.round(amount), unit); + } + amount /= size; + } + return formatDateTime(date); +} + +export function csvEscape(value: unknown): string { + const text = value == null ? "" : String(value); + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +export function downloadCsv(filename: string, rows: object[]): void { + if (rows.length === 0) return; + const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))]; + const body = [ + columns.join(","), + ...rows.map((row) => + columns.map((column) => csvEscape((row as Record)[column])).join(","), + ), + ].join("\n"); + const url = URL.createObjectURL(new Blob([body], { type: "text/csv;charset=utf-8" })); + const link = document.createElement("a"); + link.href = url; + link.download = filename.endsWith(".csv") ? filename : `${filename}.csv`; + link.click(); + URL.revokeObjectURL(url); +} + +/** Read a CSS custom property, so charts follow the theme tokens rather than hard-coded hex. */ +export function cssVar(name: string, fallback = "#888"): string { + if (typeof window === "undefined") return fallback; + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; +} + +export const SERIES_VARS = [ + "--series-1", + "--series-2", + "--series-3", + "--series-4", + "--series-5", + "--series-6", + "--series-7", + "--series-8", +] as const; + +export const SEQUENTIAL_VARS = ["--seq-1", "--seq-2", "--seq-3", "--seq-4", "--seq-5", "--seq-6"] as const; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..a5b3612 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,41 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./app"; +import { ToastProvider } from "./components/ui"; +import { ScopeProvider } from "./lib/scope"; +import { ThemeProvider } from "./lib/theme"; +import "react-grid-layout/css/styles.css"; +import "./styles.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: false, + retry: (failureCount, error) => { + // Never retry an authorization problem — it will not fix itself. + const status = (error as { status?: number }).status; + if (status === 401 || status === 403 || status === 404) return false; + return failureCount < 2; + }, + }, + }, +}); + +const root = document.getElementById("root"); +if (!root) throw new Error("Missing #root"); + +createRoot(root).render( + + + + + + + + + + + , +); diff --git a/apps/web/src/routes/claim.tsx b/apps/web/src/routes/claim.tsx new file mode 100644 index 0000000..636e3e7 --- /dev/null +++ b/apps/web/src/routes/claim.tsx @@ -0,0 +1,102 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { KeyRound, ShieldCheck } from "lucide-react"; +import { useState } from "react"; +import { Alert, Button, Card, CodeBlock, Field, Input } from "../components/ui"; +import { api, type Me } from "../lib/api"; + +/** + * First run. The one-time code is printed to the container logs, so whoever can read the + * logs — the person who deployed this — becomes the owner. Nobody else can claim it. + */ +export function ClaimPage({ me }: { me: Me }) { + const queryClient = useQueryClient(); + const [code, setCode] = useState(""); + const claim = useMutation({ + mutationFn: () => api.claim(code), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["me"] }), + }); + + return ( +
+
+ + AVD + +
+

DashFlow

+

Self-hosted Azure Virtual Desktop insight

+
+
+ + +
+ +
+

Claim this instance

+

+ You are signed in as{" "} + {me.user.email}. Enter the + one-time setup code from the container logs to become the owner. +

+
+
+ + -g --tail 200 | grep -A3 "first-run setup" + +# Docker +docker compose logs app | grep -A3 "first-run setup"`} + /> + + + setCode(event.target.value.toUpperCase())} + onKeyDown={(event) => { + if (event.key === "Enter" && code.length > 3) claim.mutate(); + }} + /> + + + {claim.isError ? {(claim.error as Error).message} : null} + + + +

+ Deploying with infrastructure as code? Set BOOTSTRAP_OWNER_OIDS to the Entra object IDs + that should become owners, and this step is skipped entirely. +

+
+
+ ); +} + +/** Shown to a signed-in user who has no role yet. */ +export function NoAccessPage({ me }: { me: Me }) { + return ( +
+ +

You do not have access yet

+

+ Your sign-in worked, but nobody has granted you a role in this instance. An admin can do that under + Users & roles. +

+

+ Signed in as {me.user.email}. Ask them to search for that address. +

+
+
+ ); +} diff --git a/apps/web/src/routes/connection-help.ts b/apps/web/src/routes/connection-help.ts new file mode 100644 index 0000000..bb46307 --- /dev/null +++ b/apps/web/src/routes/connection-help.ts @@ -0,0 +1,155 @@ +import type { ConnectionType } from "@dashflow/core"; + +/** + * The prerequisite steps for each connection type, with copyable commands. Values the + * operator types in the wizard are substituted in the browser only — nothing here is sent + * anywhere, and every example value is a placeholder. + */ + +export const ROLE_LIST = [ + "Reader", + "Desktop Virtualization Reader", + "Log Analytics Reader", + "Cost Management Reader", +]; + +export interface HelpStep { + title: string; + body: string; + code?: string; + codeLabel?: string; +} + +export function prerequisiteSteps( + type: ConnectionType, + values: { + tenantId?: string; + clientId?: string; + subscriptionId?: string; + baseUrl?: string; + redirectUri: string; + }, +): HelpStep[] { + const tenant = values.tenantId || ""; + const client = values.clientId || ""; + const subscription = values.subscriptionId || ""; + + switch (type) { + case "azure": + return [ + { + title: "Create an app registration (or use this container's managed identity)", + body: "A managed identity is the safer option when you run this on Azure — there is no secret to store or rotate. Use an app registration when the app runs elsewhere.", + codeLabel: "az cli", + code: `az ad app create --display-name "DashFlow" +APP_ID=$(az ad app list --display-name "DashFlow" --query "[0].appId" -o tsv) +az ad sp create --id "$APP_ID" +# Create a client secret (store it in Key Vault, not in a file) +az ad app credential reset --id "$APP_ID" --years 1 --query password -o tsv`, + }, + { + title: "Grant read-only Azure roles", + body: "Assign these at the subscription, or at the resource groups that hold AVD if you prefer a tighter scope. Cost Management Reader is optional and only powers the cost widgets.", + codeLabel: "az cli", + code: ROLE_LIST.map( + (role) => + `az role assignment create --assignee "${client}" --role "${role}" --scope "/subscriptions/${subscription}"`, + ).join("\n"), + }, + { + title: "Send AVD diagnostics to Log Analytics", + body: "Connections, errors, checkpoints and network data only exist if each host pool has a diagnostic setting pointing at a Log Analytics workspace. Without it you still get inventory and live sessions, but no experience or reliability history.", + codeLabel: "az cli", + code: `az monitor diagnostic-settings create \\ + --name avd-to-law \\ + --resource "/subscriptions/${subscription}/resourceGroups//providers/Microsoft.DesktopVirtualization/hostPools/" \\ + --workspace "/subscriptions/${subscription}/resourceGroups//providers/Microsoft.OperationalInsights/workspaces/" \\ + --logs '[{"categoryGroup":"allLogs","enabled":true}]'`, + }, + ]; + + case "lighthouse": + return [ + { + title: "Create the app registration in your managing tenant", + body: "One identity in the managing tenant is all you need — Lighthouse projects it into every delegated customer subscription.", + codeLabel: "az cli", + code: `az ad app create --display-name "DashFlow (Lighthouse)" +APP_ID=$(az ad app list --display-name "DashFlow (Lighthouse)" --query "[0].appId" -o tsv) +az ad sp create --id "$APP_ID" +# Note the service principal OBJECT id — Lighthouse authorizations use it +az ad sp show --id "$APP_ID" --query id -o tsv`, + }, + { + title: "Add the identity to your delegated authorizations", + body: "In the Lighthouse offer (ARM template or Managed Services marketplace plan), add an authorization for this service principal with these role definition IDs. Customers must accept the updated offer before the access appears.", + codeLabel: "role definition ids", + code: `Reader acdd72a7-3385-48ef-bd42-f606fba81ae7 +Desktop Virtualization Reader 49a72310-ab8d-41df-bbb0-79b649203868 +Log Analytics Reader 73c42c96-874c-492b-b04d-ab87d138a893 +Cost Management Reader 72fafb9e-0641-4937-9268-a91bfd8191a3`, + }, + { + title: "Confirm the delegation landed", + body: "Delegated subscriptions report your managing tenant. If this list is empty, the offer has not been accepted for those subscriptions yet.", + codeLabel: "az cli", + code: `az account list --query "[?managedByTenants[?tenantId=='${tenant}']].{name:name,id:id,tenant:tenantId}" -o table`, + }, + ]; + + case "partner-center": + return [ + { + title: "Register a multi-tenant app in your partner tenant", + body: "It needs a web redirect URI pointing back at this instance, and a client secret.", + codeLabel: "redirect uri", + code: values.redirectUri, + }, + { + title: "Add delegated API permissions", + body: "Partner Center API (user_impersonation), Azure Service Management (user_impersonation) and Microsoft Graph DelegatedAdminRelationship.Read.All. Grant admin consent in the partner tenant.", + codeLabel: "delegated scopes", + code: `https://api.partnercenter.microsoft.com/user_impersonation +https://management.azure.com/user_impersonation +https://graph.microsoft.com/DelegatedAdminRelationship.Read.All +offline_access`, + }, + { + title: "Consent as a partner admin with the AdminAgents role", + body: "The next step opens a Microsoft sign-in. The refresh token it returns is stored as a secret and used to get per-customer tokens — this is the Secure Application Model, so no customer-specific credential ever exists.", + }, + { + title: "Remember: GDAP is Entra, Azure RBAC is separate", + body: "A GDAP relationship grants Entra roles in the customer tenant. Reading AVD needs Azure RBAC on the customer's subscription as well — assign the same four read-only roles to your partner security group there (Azure Lighthouse is the usual way to automate that). The connection test says so explicitly if ARM refuses.", + }, + ]; + + case "nerdio": + return [ + { + title: "Turn on the Nerdio Manager REST API", + body: "In Nerdio Manager: Settings → Integrations → REST API. Enable it, then note the API scope it shows (it looks like api:///.default).", + }, + { + title: "Create an app registration for this app", + body: "Nerdio's REST API authenticates with client credentials from the tenant that hosts Nerdio Manager. Create the app, add a secret, then authorize it in Nerdio's REST API settings.", + codeLabel: "az cli", + code: `az ad app create --display-name "DashFlow (Nerdio)" +APP_ID=$(az ad app list --display-name "DashFlow (Nerdio)" --query "[0].appId" -o tsv) +az ad sp create --id "$APP_ID" +az ad app credential reset --id "$APP_ID" --years 1 --query password -o tsv`, + }, + { + title: "Find your instance URL", + body: "It is the URL you open Nerdio Manager with, without a trailing slash. The connection test reads the instance's own API definition and tells you if any of the default paths need overriding for your version.", + codeLabel: "example", + code: values.baseUrl || "https://nmw-app-example.azurewebsites.net", + }, + ]; + + default: + return []; + } +} + +export const CHECK_ORDER: Record = { fail: 0, warn: 1, pass: 2, skip: 3 }; diff --git a/apps/web/src/routes/connection-new.tsx b/apps/web/src/routes/connection-new.tsx new file mode 100644 index 0000000..8555bfa --- /dev/null +++ b/apps/web/src/routes/connection-new.tsx @@ -0,0 +1,513 @@ +import { CONNECTION_TYPE_INFO, type ConnectionType, type DiagnosticCheck } from "@dashflow/core"; +import { useMutation } from "@tanstack/react-query"; +import { ArrowLeft, ArrowRight, CheckCircle2, ExternalLink, Search, Sparkles } from "lucide-react"; +import { useState } from "react"; +import { Link, useLocation } from "wouter"; +import { PageHeader } from "../components/layout"; +import { Alert, Badge, Button, Card, Field, Input, Select, Stepper, Switch } from "../components/ui"; +import { api, type Me } from "../lib/api"; +import { ChecksList, Prerequisites } from "./connections"; + +const STEPS = [ + { id: "type", label: "Connection type" }, + { id: "prereq", label: "Prerequisites" }, + { id: "credentials", label: "Credentials" }, + { id: "test", label: "Test" }, + { id: "sync", label: "First sync" }, +]; + +type SecretMode = "managed-identity" | "inline" | "keyvault"; + +interface FormState { + name: string; + tenantId: string; + clientId: string; + subscriptionIds: string; + secretMode: SecretMode; + secretValue: string; + keyVaultName: string; + includeHomeTenant: boolean; + // Nerdio + edition: "msp" | "enterprise"; + baseUrl: string; + scope: string; +} + +const EMPTY: FormState = { + name: "", + tenantId: "", + clientId: "", + subscriptionIds: "", + secretMode: "inline", + secretValue: "", + keyVaultName: "", + includeHomeTenant: false, + edition: "msp", + baseUrl: "", + scope: "", +}; + +/** + * Adding a connection is where most self-hosted tools lose people: the credential is easy, + * the permissions are not. So the wizard generates the exact commands first, then proves the + * access with a checklist before anything is synced. + */ +export function ConnectionNew({ me }: { me: Me }) { + const [, navigate] = useLocation(); + const [step, setStep] = useState(0); + const [type, setType] = useState(null); + const [form, setForm] = useState(EMPTY); + const [connectionId, setConnectionId] = useState(null); + const [checks, setChecks] = useState(null); + + const redirectUri = `${window.location.origin}/api/connections/consent/callback`; + const set = (next: Partial) => setForm((current) => ({ ...current, ...next })); + + const create = useMutation({ + mutationFn: async () => { + const config = buildConfig(type!, form); + const secret = + form.secretMode === "inline" && form.secretValue + ? ({ kind: "inline", value: form.secretValue } as const) + : form.secretMode === "keyvault" && form.keyVaultName + ? ({ kind: "keyvault", secretName: form.keyVaultName } as const) + : ({ kind: "none" } as const); + return api.createConnection({ name: form.name || defaultName(type!), config, secret }); + }, + onSuccess: (result) => { + setConnectionId(result.id); + setStep(3); + test.mutate(result.id); + }, + }); + + const test = useMutation({ + mutationFn: api.testConnection, + onSuccess: (result) => setChecks(result.checks), + }); + + const discover = useMutation({ mutationFn: api.discover }); + const sync = useMutation({ mutationFn: (id: string) => api.syncConnection(id, true) }); + const consent = useMutation({ + mutationFn: api.consentUrl, + onSuccess: (result) => window.open(result.url, "_blank", "noopener"), + }); + + const worstStatus = checks?.some((check) => check.status === "fail") + ? "fail" + : checks?.some((check) => check.status === "warn") + ? "warn" + : "pass"; + + return ( + <> + navigate("/connections")}> + Cancel + + } + /> +
+ index <= step && setStep(index)} /> +
+ + {step === 0 ? ( +
+ {(Object.keys(CONNECTION_TYPE_INFO) as Exclude[]).map((key) => { + const info = CONNECTION_TYPE_INFO[key]; + return ( + + ); + })} +
+ ) : null} + + {step === 1 && type ? ( + + +
+ + +
+
+ ) : null} + + {step === 2 && type ? ( + + + set({ name: event.target.value })} /> + + + {type === "nerdio" ? ( + <> + + + + + set({ baseUrl: event.target.value })} + /> + + + set({ scope: event.target.value })} + /> + + + ) : null} + + + set({ tenantId: event.target.value.trim() })} + /> + + + {type === "azure" ? ( + + + + ) : null} + + {form.secretMode !== "managed-identity" || type !== "azure" ? ( + + set({ clientId: event.target.value.trim() })} + /> + + ) : null} + + {type !== "azure" ? ( + + + + ) : null} + + {form.secretMode === "inline" ? ( + + set({ secretValue: event.target.value })} + /> + + ) : null} + + {form.secretMode === "keyvault" ? ( + + set({ keyVaultName: event.target.value.trim() })} + /> + + ) : null} + + {type === "azure" ? ( + + set({ subscriptionIds: event.target.value })} + placeholder="00000000-0000-0000-0000-000000000000" + /> + + ) : null} + + {type === "lighthouse" ? ( +
+ set({ includeHomeTenant: value })} + /> + + Also include subscriptions in the managing tenant itself + +
+ ) : null} + + {create.isError ? {(create.error as Error).message} : null} + +
+ + +
+
+ ) : null} + + {step === 3 && connectionId ? ( + + {type === "partner-center" ? ( + +

+ Sign in as a partner admin with the AdminAgents role to grant consent. The refresh token is + stored as a secret and used to get per-customer tokens. +

+ +
+ ) : null} + +
+

Permission checks

+ +
+ + {test.isPending && !checks ? ( +

Probing the APIs this connection needs…

+ ) : checks ? ( + <> + + {worstStatus === "fail" ? ( + + Fix the failing checks above, then run the test again. The most common cause is a role + assignment that has not propagated yet — it can take a few minutes. + + ) : worstStatus === "warn" ? ( + + The connection works, but some data will be missing until the warnings are addressed. + + ) : ( + + Everything this connection needs is in place. + + )} + + ) : null} + +
+ + +
+
+ ) : null} + + {step === 4 && connectionId ? ( + + {discover.isPending ? ( +

+ Enumerating customers, subscriptions, host pools and Log Analytics workspaces… +

+ ) : discover.isError ? ( + + {(discover.error as Error).message} + + ) : discover.data ? ( + <> + + {discover.data.tenants} customer(s), {discover.data.subscriptions} subscription(s),{" "} + {discover.data.workspaces} Log Analytics workspace(s), {discover.data.hostPools} host pool(s). + +

+ Everything discovered is enabled by default. Use{" "} + + Customers + {" "} + to narrow it down before the first backfill, or start syncing now — the backfill pulls the + last 30 days by default. +

+ {sync.isSuccess ? ( + + Queued {sync.data.queued} jobs. Watch progress on the{" "} + + sync health + {" "} + page — the first pass can take a few minutes. + + ) : null} +
+ + + + + {sync.isSuccess ? ( + + + + ) : null} +
+ + ) : null} +
+ ) : null} + + ); +} + +function defaultName(type: ConnectionType): string { + switch (type) { + case "azure": + return "Azure"; + case "lighthouse": + return "Lighthouse"; + case "partner-center": + return "Partner Center"; + case "nerdio": + return "Nerdio Manager"; + default: + return "Connection"; + } +} + +function buildConfig(type: ConnectionType, form: FormState): Record { + const credentialMode = + form.secretMode === "managed-identity" ? "managed-identity" : ("client-secret" as const); + switch (type) { + case "azure": + return { + type: "azure", + tenantId: form.tenantId, + credentialMode, + clientId: form.clientId || undefined, + subscriptionIds: form.subscriptionIds + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + }; + case "lighthouse": + return { + type: "lighthouse", + tenantId: form.tenantId, + credentialMode, + clientId: form.clientId || undefined, + includeHomeTenant: form.includeHomeTenant, + }; + case "partner-center": + return { + type: "partner-center", + partnerTenantId: form.tenantId, + clientId: form.clientId, + credentialMode: "client-secret", + }; + case "nerdio": + return { + type: "nerdio", + edition: form.edition, + baseUrl: form.baseUrl, + tenantId: form.tenantId, + clientId: form.clientId, + scope: form.scope, + credentialMode: "client-secret", + endpoints: {}, + }; + default: + return { type: "demo", tenants: 4 }; + } +} diff --git a/apps/web/src/routes/connections.tsx b/apps/web/src/routes/connections.tsx new file mode 100644 index 0000000..9828edc --- /dev/null +++ b/apps/web/src/routes/connections.tsx @@ -0,0 +1,348 @@ +import { CONNECTION_TYPE_INFO, type ConnectionType, type DiagnosticCheck } from "@dashflow/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, ExternalLink, Plug, RefreshCw, Search, Trash2, TriangleAlert } from "lucide-react"; +import { useState } from "react"; +import { Link } from "wouter"; +import { PageHeader } from "../components/layout"; +import { + Alert, + Badge, + Button, + Card, + CardHeader, + CodeBlock, + EmptyState, + Field, + Input, + Select, + Skeleton, + StatusDot, + Switch, +} from "../components/ui"; +import { api, type ConnectionSummary, type Me } from "../lib/api"; +import { formatRelative } from "../lib/utils"; +import { CHECK_ORDER, prerequisiteSteps } from "./connection-help"; + +export function ChecksList({ checks }: { checks: DiagnosticCheck[] }) { + const sorted = [...checks].sort((a, b) => (CHECK_ORDER[a.status] ?? 9) - (CHECK_ORDER[b.status] ?? 9)); + return ( +
    + {sorted.map((check) => ( +
  • + +
    +

    + {check.label} + {check.status === "skip" ? ( + (not used) + ) : null} +

    + {check.detail ? ( +

    {check.detail}

    + ) : null} + {check.fix && check.status !== "pass" ? ( +

    + How to fix: + {check.fix} +

    + ) : null} +
    +
  • + ))} +
+ ); +} + +function statusTone(status: ConnectionSummary["status"]) { + return status === "ok" + ? "good" + : status === "degraded" + ? "warning" + : status === "error" + ? "critical" + : "neutral"; +} + +export function ConnectionsPage({ me }: { me: Me }) { + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery({ queryKey: ["connections"], queryFn: api.connections }); + const [expanded, setExpanded] = useState(null); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ["connections"] }); + void queryClient.invalidateQueries({ queryKey: ["me"] }); + void queryClient.invalidateQueries({ queryKey: ["tenants"] }); + }; + + const test = useMutation({ mutationFn: api.testConnection, onSuccess: invalidate }); + const discover = useMutation({ mutationFn: api.discover, onSuccess: invalidate }); + const sync = useMutation({ + mutationFn: ({ id, backfill }: { id: string; backfill: boolean }) => api.syncConnection(id, backfill), + }); + const remove = useMutation({ mutationFn: api.deleteConnection, onSuccess: invalidate }); + const toggle = useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => api.updateConnection(id, { enabled }), + onSuccess: invalidate, + }); + const consent = useMutation({ + mutationFn: api.consentUrl, + onSuccess: (result) => window.open(result.url, "_blank", "noopener"), + }); + + return ( + <> + + + + } + /> + + {isLoading ? ( + + ) : !data || data.length === 0 ? ( + + } + title="No connections yet" + description="Connect Azure directly, an Azure Lighthouse managing tenant, your Partner Center customers, or Nerdio Manager." + action={ + + + + } + /> + + ) : ( +
+ {data.map((connection) => { + const info = CONNECTION_TYPE_INFO[connection.type as Exclude]; + const open = expanded === connection.id; + return ( + + + {connection.name} + {connection.status} + {!connection.enabled ? paused : null} + + } + subtitle={`${info?.label ?? connection.type} · ${connection.tenantCount} customer(s) · last sync ${formatRelative(connection.lastSyncAt)}`} + actions={ + <> + toggle.mutate({ id: connection.id, enabled })} + label="Enabled" + /> + + + } + /> + + {open ? ( +
+
+ + + + + {connection.type === "partner-center" ? ( + + ) : null} + +
+ + {discover.isSuccess && discover.variables === connection.id ? ( + + Found {discover.data.tenants} customer(s), {discover.data.subscriptions}{" "} + subscription(s), {discover.data.workspaces} workspace(s) and {discover.data.hostPools}{" "} + host pool(s).{" "} + + Choose what to sync + + . + + ) : null} + {discover.isError && discover.variables === connection.id ? ( + + {(discover.error as Error).message} + + ) : null} + {sync.isSuccess ? Queued {sync.data.queued} sync jobs. : null} + +
+
+

+ Diagnostics{" "} + {connection.lastTestedAt ? `· ${formatRelative(connection.lastTestedAt)}` : ""} +

+ {connection.lastTest ? ( + + ) : ( +

Not tested yet.

+ )} +
+
+

+ Schedule +

+ +
+
+
+ ) : null} +
+ ); + })} +
+ )} + + {me.instance.demoMode ? ( +
+ + A synthetic connection generates fake customers and data so you can explore the app. Set{" "} + DEMO_MODE=false and delete that connection before using this instance for real work. + +
+ ) : null} + + ); +} + +function ScheduleEditor({ connection, onSaved }: { connection: ConnectionSummary; onSaved: () => void }) { + const [schedule, setSchedule] = useState(connection.schedule); + const save = useMutation({ + mutationFn: () => api.updateConnection(connection.id, { schedule }), + onSuccess: onSaved, + }); + const fields: { key: keyof typeof schedule; label: string; suffix: string }[] = [ + { key: "inventoryMinutes", label: "Inventory", suffix: "min" }, + { key: "sessionsMinutes", label: "Sessions", suffix: "min" }, + { key: "logsMinutes", label: "Logs", suffix: "min" }, + { key: "costHours", label: "Cost", suffix: "hours" }, + { key: "backfillDays", label: "Backfill", suffix: "days" }, + { key: "retentionDays", label: "Retention", suffix: "days" }, + ]; + + return ( +
+
+ {fields.map((field) => ( + + ))} +
+ + {save.isError ? {(save.error as Error).message} : null} +
+ ); +} + +/** Reusable prerequisite panel, shown in the new-connection wizard. */ +export function Prerequisites({ + type, + values, +}: { + type: ConnectionType; + values: { + tenantId?: string; + clientId?: string; + subscriptionId?: string; + baseUrl?: string; + redirectUri: string; + }; +}) { + const steps = prerequisiteSteps(type, values); + return ( +
    + {steps.map((step, index) => ( +
  1. +
    + + {index + 1} + +
    +

    {step.title}

    +

    {step.body}

    +
    +
    + {step.code ? ( +
    + +
    + ) : null} +
  2. + ))} +
+ ); +} + +export { Field, Select, TriangleAlert }; diff --git a/apps/web/src/routes/customers.tsx b/apps/web/src/routes/customers.tsx new file mode 100644 index 0000000..c1bb76a --- /dev/null +++ b/apps/web/src/routes/customers.tsx @@ -0,0 +1,222 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Building2, ChevronRight } from "lucide-react"; +import { useState } from "react"; +import { PageHeader } from "../components/layout"; +import { Alert, Badge, Button, Card, EmptyState, Skeleton, Switch } from "../components/ui"; +import { api, type TenantDetail } from "../lib/api"; +import { cn } from "../lib/utils"; + +/** Pick which customers, subscriptions, workspaces and host pools are synced and charted. */ +export function CustomersPage({ canEdit }: { canEdit: boolean }) { + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery({ queryKey: ["tenants"], queryFn: () => api.tenants() }); + const [open, setOpen] = useState(null); + + const save = useMutation({ + mutationFn: api.saveSelection, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["tenants"] }); + void queryClient.invalidateQueries({ queryKey: ["me"] }); + void queryClient.invalidateQueries({ queryKey: ["host-pools"] }); + }, + }); + + if (isLoading) return ; + + return ( + <> + + + {!data || data.length === 0 ? ( + + } + title="Nothing discovered yet" + description="Add a connection and run discovery — customers, subscriptions, host pools and Log Analytics workspaces show up here." + /> + + ) : ( +
+ {data.map((tenant) => ( + setOpen(open === tenant.id ? null : tenant.id)} + onChange={(payload) => save.mutate(payload)} + /> + ))} +
+ )} + + {save.isError ? ( +
+ {(save.error as Error).message} +
+ ) : null} + + ); +} + +function TenantRow({ + tenant, + open, + canEdit, + onToggleOpen, + onChange, +}: { + tenant: TenantDetail; + open: boolean; + canEdit: boolean; + onToggleOpen: () => void; + onChange: (payload: Parameters[0]) => void; +}) { + const gdap = tenant.meta?.gdapStatus as string | undefined; + const enabledPools = tenant.hostPools.filter((pool) => pool.enabled).length; + + return ( + +
+ + onChange({ tenants: [{ id: tenant.id, enabled }] })} + /> +
+ + {open ? ( +
+
+ {tenant.subscriptions.length === 0 ? ( +

None found.

+ ) : ( + tenant.subscriptions.map((subscription) => ( + onChange({ subscriptions: [{ id: subscription.id, enabled }] })} + /> + )) + )} +
+ +
+ {tenant.workspaces.length === 0 ? ( +

+ None found. Without a workspace there is no connection, error or latency history — only + inventory and live sessions. +

+ ) : ( + tenant.workspaces.map((workspace) => ( + onChange({ workspaces: [{ id: workspace.id, enabled }] })} + /> + )) + )} +
+ +
+ {tenant.hostPools.length === 0 ? ( +

+ None yet — they appear after the first inventory sync. +

+ ) : ( + tenant.hostPools.map((pool) => ( + 1 ? ` · ${pool.sources.join(" + ")}` : ""}`} + checked={pool.enabled} + disabled={!canEdit} + badge={pool.autoscaleEnabled ? "autoscale" : undefined} + onChange={(enabled) => onChange({ hostPools: [{ resourceId: pool.resourceId, enabled }] })} + /> + )) + )} +
+
+ ) : null} +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function ToggleRow({ + label, + sublabel, + checked, + disabled, + badge, + onChange, +}: { + label: string; + sublabel?: string; + checked: boolean; + disabled?: boolean; + badge?: string; + onChange: (next: boolean) => void; +}) { + return ( +
+
+

+ {label} + {badge ? ( + + {badge} + + ) : null} +

+ {sublabel ?

{sublabel}

: null} +
+ +
+ ); +} + +export { Button }; diff --git a/apps/web/src/routes/dashboard-new.tsx b/apps/web/src/routes/dashboard-new.tsx new file mode 100644 index 0000000..1d33ba3 --- /dev/null +++ b/apps/web/src/routes/dashboard-new.tsx @@ -0,0 +1,331 @@ +import { METRICS_BY_ID, type TimePreset, type WidgetSpec } from "@dashflow/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { ArrowLeft, ArrowRight, Check, Sparkles } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useLocation } from "wouter"; +import { PageHeader } from "../components/layout"; +import { Alert, Badge, Button, Card, Field, Input, Select, Stepper, Switch } from "../components/ui"; +import { WidgetCard } from "../components/widgets"; +import { api, type Catalog, type Me } from "../lib/api"; +import { useScope } from "../lib/scope"; + +const STEPS = [ + { id: "goal", label: "What do you want to know?" }, + { id: "scope", label: "Whose data?" }, + { id: "preview", label: "Preview and adjust" }, +]; + +/** + * The guided build: pick a question, pick a scope, then see the real dashboard before + * saving it. Previewing with live data is the point — a template that looks good but has + * no data behind it is worse than none. + */ +export function DashboardNew({ catalog, me }: { catalog: Catalog; me: Me }) { + const [, navigate] = useLocation(); + const scope = useScope(); + const [step, setStep] = useState(0); + const [goal, setGoal] = useState(null); + const [name, setName] = useState(""); + const [preset, setPreset] = useState(scope.preset); + const [tenantIds, setTenantIds] = useState(scope.tenantIds); + const [pinScope, setPinScope] = useState(false); + const [omitted, setOmitted] = useState([]); + + const template = catalog.templates.find((candidate) => candidate.goal === goal); + const widgets = useMemo( + () => (template?.widgets ?? []).filter((widget) => !omitted.includes(widget.id)), + [template, omitted], + ); + + const queries = useMemo(() => { + const from = new Date(Date.now() - presetHours(preset) * 3600_000); + const to = new Date(); + return widgets.map((widget) => ({ + key: widget.id, + metric: widget.metric, + viz: widget.viz, + groupBy: widget.groupBy, + topN: widget.topN, + grain: + widget.grain === "auto" + ? presetHours(preset) > 24 * 10 + ? ("day" as const) + : ("hour" as const) + : widget.grain, + from, + to, + tenantIds, + filters: widget.filters, + compare: widget.viz === "kpi", + })); + }, [widgets, preset, tenantIds]); + + const preview = useQuery({ + queryKey: ["preview", goal, queries], + queryFn: () => api.queryBatch({ tz: scope.tz, queries: queries.map((query) => ({ ...query })) }), + enabled: step === 2 && queries.length > 0, + }); + + const create = useMutation({ + mutationFn: () => + api.createFromTemplate({ + goal: goal!, + name: name.trim() || undefined, + tenantScope: pinScope ? tenantIds : null, + defaultPreset: preset, + omit: omitted, + }), + onSuccess: (result) => navigate(`/dashboards/${result.id}`), + }); + + const byKey = new Map((preview.data?.results ?? []).map((entry) => [entry.key, entry])); + const emptyWidgets = (preview.data?.results ?? []) + .filter((entry) => isEmpty(entry)) + .map((entry) => entry.key); + + return ( + <> + navigate("/")}> + Cancel + + } + /> + +
+ +
+ + {step === 0 ? ( +
+ {catalog.templates.map((candidate) => ( + + ))} +
+ ) : null} + + {step === 1 && template ? ( + + + setName(event.target.value)} + placeholder={template.name} + /> + + + + + + + +
+ + {me.tenants.map((tenant) => ( + + ))} +
+
+ +
+ + + Pin these customers to the dashboard (otherwise it follows the switcher at the top) + +
+ +
+ + +
+
+ ) : null} + + {step === 2 && template ? ( +
+ +
+

{name || template.name}

+

+ {widgets.length} widgets · {preset} ·{" "} + {tenantIds === null ? "all customers" : `${tenantIds.length} customer(s)`} +

+
+
+ + +
+
+ + {create.isError ? {(create.error as Error).message} : null} + + {emptyWidgets.length > 0 ? ( + + {emptyWidgets.length} of {widgets.length} widgets came back empty for this range and scope. That + usually means the matching data has not synced yet — host pool diagnostics for connection and + error data, the Perf table for CPU, or Cost Management for spend. You can drop them below and + add them later. + + ) : null} + +
+ {widgets.map((widget) => { + const entry = byKey.get(widget.id); + const metric = METRICS_BY_ID[widget.metric]; + return ( + setOmitted((current) => [...current, widget.id])} + > + Drop + + } + /> + ); + })} +
+ + {omitted.length > 0 ? ( + +

Dropped widgets

+
+ {omitted.map((id) => { + const widget = template.widgets.find((candidate) => candidate.id === id); + return ( + + ); + })} +
+
+ ) : null} +
+ ) : null} + + ); +} + +function presetHours(preset: TimePreset): number { + return preset === "24h" ? 24 : preset === "7d" ? 24 * 7 : preset === "30d" ? 24 * 30 : 24 * 90; +} + +function isEmpty(entry: { result?: { kind: string } & Record; error?: string }): boolean { + if (entry.error) return true; + const result = entry.result; + if (!result) return true; + switch (result.kind) { + case "scalar": + return result.value == null; + case "series": + return (result.series as { points: { value: number | null }[] }[]).every((series) => + series.points.every((point) => point.value == null), + ); + case "breakdown": + return (result.items as unknown[]).length === 0; + case "table": + return (result.rows as unknown[]).length === 0; + case "heatmap": + return (result.cells as unknown[]).length === 0; + case "hosts": + return (result.hosts as unknown[]).length === 0; + default: + return false; + } +} + +export type { WidgetSpec }; diff --git a/apps/web/src/routes/dashboard-view.tsx b/apps/web/src/routes/dashboard-view.tsx new file mode 100644 index 0000000..068792d --- /dev/null +++ b/apps/web/src/routes/dashboard-view.tsx @@ -0,0 +1,674 @@ +import { + type DashboardSpec, + defaultLayout, + GRID_COLUMNS, + GRID_ROW_HEIGHT, + METRICS_BY_ID, + type TimePreset, + type WidgetSpec, +} from "@dashflow/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Copy, Download, Pencil, Plus, RefreshCw, Save, Settings2, Star, Trash2, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { GridLayout, type Layout, type LayoutItem } from "react-grid-layout"; +import { useLocation } from "wouter"; +import { DrillDrawer, type DrillRequest } from "../components/drill-drawer"; +import { PageHeader } from "../components/layout"; +import { + Badge, + Button, + Card, + EmptyState, + Input, + MultiSelect, + SegmentedControl, + Select, + Skeleton, + Switch, + useToast, +} from "../components/ui"; +import { type DrillTarget, WidgetCard } from "../components/widgets"; +import { api, type Catalog } from "../lib/api"; +import { useScope } from "../lib/scope"; +import { useElementWidth } from "../lib/use-measure"; +import { cn, downloadCsv } from "../lib/utils"; + +const DRAG_HANDLE = "df-drag-handle"; + +/** One request per widget, batched into a single round trip. */ +function buildQueries( + widgets: WidgetSpec[], + scope: ReturnType, + dashboardScope: string[] | null, +) { + const tenantIds = dashboardScope ?? scope.tenantIds; + return widgets.map((widget) => ({ + key: widget.id, + metric: widget.metric, + viz: widget.viz, + groupBy: widget.groupBy, + topN: widget.topN, + grain: widget.grain === "auto" ? scope.range.grain : widget.grain, + from: scope.range.from, + to: scope.range.to, + tenantIds, + filters: { + hostPools: [...(widget.filters.hostPools ?? []), ...scope.hostPools], + dims: widget.filters.dims, + }, + compare: widget.viz === "kpi" ? widget.compare : false, + })); +} + +const REFRESH_OPTIONS = [ + { value: "off", label: "Off" }, + { value: "30", label: "30s" }, + { value: "60", label: "1m" }, + { value: "300", label: "5m" }, +] as const; + +type RefreshValue = (typeof REFRESH_OPTIONS)[number]["value"]; + +export function DashboardView({ id, catalog }: { id: string; catalog: Catalog }) { + const queryClient = useQueryClient(); + const scope = useScope(); + const toast = useToast(); + const [, navigate] = useLocation(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [drill, setDrill] = useState(null); + const [refresh, setRefresh] = useState("off"); + const { ref: gridRef, width, measured } = useElementWidth(); + + const { data: dashboard, isLoading } = useQuery({ + queryKey: ["dashboard", id], + queryFn: () => api.dashboard(id), + }); + + useEffect(() => { + if (dashboard && !editing) setDraft(null); + }, [dashboard, editing]); + + const spec: DashboardSpec | null = draft ?? (dashboard ? toSpec(dashboard) : null); + const widgets = useMemo(() => (spec ? defaultLayout(spec.widgets) : []), [spec]); + + const queries = useMemo( + () => (widgets.length > 0 ? buildQueries(widgets, scope, spec?.tenantScope ?? null) : []), + [widgets, scope, spec?.tenantScope], + ); + + const results = useQuery({ + queryKey: ["dashboard-data", id, queries], + queryFn: () => api.queryBatch({ tz: scope.tz, queries: queries.map((query) => ({ ...query })) }), + enabled: queries.length > 0, + staleTime: 30_000, + refetchInterval: refresh === "off" ? false : Number(refresh) * 1000, + }); + + const save = useMutation({ + mutationFn: (next: DashboardSpec) => api.saveDashboard(id, next), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["dashboard", id] }); + await queryClient.invalidateQueries({ queryKey: ["dashboards"] }); + setEditing(false); + setDraft(null); + toast.success("Dashboard saved"); + }, + onError: (error) => toast.error("Could not save", (error as Error).message), + }); + + const remove = useMutation({ + mutationFn: () => api.deleteDashboard(id), + onSuccess: () => { + toast.success("Dashboard deleted"); + navigate("/"); + }, + }); + + const duplicate = useMutation({ + mutationFn: () => api.duplicateDashboard(id), + onSuccess: (result) => { + toast.success("Copied", "You are now looking at your own copy."); + navigate(`/dashboards/${result.id}`); + }, + }); + + const favorite = useMutation({ + mutationFn: (next: boolean) => api.setFavorite(id, next), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["dashboards"] }); + void queryClient.invalidateQueries({ queryKey: ["dashboard", id] }); + }, + }); + + const update = useCallback( + (next: Partial) => + setDraft((current) => ({ ...(current ?? (spec as DashboardSpec)), ...next })), + [spec], + ); + + const onLayoutChange = useCallback( + (layout: Layout) => { + if (!editing || !spec) return; + const byId = new Map(layout.map((item) => [item.i, item])); + const nextWidgets = spec.widgets.map((widget) => { + const position = byId.get(widget.id); + return position + ? { ...widget, layout: { x: position.x, y: position.y, w: position.w, h: position.h } } + : widget; + }); + // Only rewrite the draft when something actually moved, or every render loops. + const changed = nextWidgets.some((widget, index) => { + const before = spec.widgets[index]?.layout; + return ( + before?.x !== widget.layout?.x || + before?.y !== widget.layout?.y || + before?.w !== widget.layout?.w || + before?.h !== widget.layout?.h + ); + }); + if (changed) update({ widgets: nextWidgets }); + }, + [editing, spec, update], + ); + + if (isLoading || !dashboard || !spec) { + return ( +
+ +
+ {[0, 1, 2, 3].map((key) => ( + + ))} +
+
+ ); + } + + const byKey = new Map((results.data?.results ?? []).map((entry) => [entry.key, entry])); + const updateWidget = (widgetId: string, next: Partial) => + update({ + widgets: spec.widgets.map((widget) => (widget.id === widgetId ? { ...widget, ...next } : widget)), + }); + + const openDrill = (widget: WidgetSpec) => (target: DrillTarget) => + setDrill({ + ...target, + metric: widget.metric, + groupBy: widget.groupBy, + tenantIds: spec.tenantScope ?? scope.tenantIds, + hostPools: [...(widget.filters.hostPools ?? []), ...scope.hostPools], + rangeFrom: scope.range.from.toISOString(), + rangeTo: scope.range.to.toISOString(), + }); + + const exportCsv = () => { + const rows: Record[] = []; + for (const widget of widgets) { + const result = byKey.get(widget.id)?.result; + if (!result) continue; + if (result.kind === "scalar") + rows.push({ widget: widget.title, series: "", bucket: "", value: result.value }); + if (result.kind === "series") { + for (const series of result.series) { + for (const point of series.points) { + rows.push({ widget: widget.title, series: series.name, bucket: point.ts, value: point.value }); + } + } + } + if (result.kind === "breakdown" || result.kind === "table") { + const items = result.kind === "breakdown" ? result.items : result.rows; + for (const item of items) { + rows.push({ widget: widget.title, series: String(item.name), bucket: "", value: item.value }); + } + } + } + downloadCsv(`${spec.name.replace(/\s+/g, "-").toLowerCase()}-${scope.preset}.csv`, rows); + }; + + return ( + <> + + setRefresh(value)} + options={REFRESH_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + title: option.value === "off" ? "No auto refresh" : `Refresh every ${option.label}`, + }))} + /> + + + + {dashboard.canEdit ? ( + editing ? ( + <> + + + + ) : ( + <> + + + + ) + ) : ( + + )} + + } + /> + + {editing ? ( + +
+ + + +
+ update({ tenantScope: pinned ? (scope.tenantIds ?? null) : null })} + label="Pin customers" + /> + + Pin to the current customers{spec.tenantScope ? ` (${spec.tenantScope.length})` : ""} + +
+
+ + +
+
+ +

+ Drag a widget by its title to move it, or pull the bottom-right corner to resize. +

+ + {showAdd ? ( + { + update({ widgets: [...spec.widgets, widget] }); + setShowAdd(false); + }} + /> + ) : null} +
+ ) : null} + + {widgets.length === 0 ? ( + + { + setEditing(true); + setShowAdd(true); + }} + > + Add a widget + + ) : null + } + /> + + ) : ( + /* The grid needs a real width: rendering before the container is measured would lay + every widget out against a guess and then visibly jump. */ +
+ {!measured ? ( + + ) : ( + ({ i: widget.id, ...widget.layout, minW: 2, minH: 3 }))} + > + {widgets.map((widget) => { + const entry = byKey.get(widget.id); + const metric = METRICS_BY_ID[widget.metric]; + return ( +
+ updateWidget(widget.id, next)} + onRemove={() => + update({ + widgets: spec.widgets.filter((candidate) => candidate.id !== widget.id), + }) + } + /> + ) : null + } + /> +
+ ); + })} +
+ )} +
+ )} + + setDrill(null)} /> + + ); +} + +function WidgetEditor({ + widget, + catalog, + onChange, + onRemove, +}: { + widget: WidgetSpec; + catalog: Catalog; + onChange: (next: Partial) => void; + onRemove: () => void; +}) { + const [open, setOpen] = useState(false); + const metric = catalog.metrics.find((candidate) => candidate.id === widget.metric); + const [filterDim, setFilterDim] = useState(""); + + const { data: values } = useQuery({ + queryKey: ["dim-values", widget.metric, filterDim], + queryFn: () => api.dimensionValues(widget.metric, filterDim), + enabled: Boolean(filterDim), + }); + + const dims = (widget.filters.dims ?? {}) as Record; + const selectedValues = filterDim ? (dims[filterDim] ?? null) : null; + + return ( +
+ + {open ? ( +
+ + + + +
+

Filter

+ + {filterDim ? ( + ({ + value: item.value, + label: item.value, + hint: `${item.hits} rows`, + }))} + selected={selectedValues} + onChange={(next) => { + const nextDims = { ...dims }; + if (next === null || next.length === 0) delete nextDims[filterDim]; + else nextDims[filterDim] = next; + onChange({ + filters: { ...widget.filters, dims: nextDims as WidgetSpec["filters"]["dims"] }, + }); + }} + /> + ) : null} + {Object.keys(dims).length > 0 ? ( +
+ {Object.entries(dims).map(([dim, list]) => ( + + {catalog.dimensions[dim]?.short ?? dim}: {list.length} + + ))} +
+ ) : null} +
+ +
+ + +
+
+ ) : null} +
+ ); +} + +function AddWidgetPanel({ catalog, onAdd }: { catalog: Catalog; onAdd: (widget: WidgetSpec) => void }) { + const [category, setCategory] = useState(Object.keys(catalog.categories)[0] ?? "usage"); + const metrics = catalog.metrics.filter((metric) => metric.category === category); + + return ( +
+
+ {Object.entries(catalog.categories).map(([key, info]) => ( + + ))} +
+
+ {metrics.map((metric) => ( + + ))} +
+
+ ); +} + +function toSpec(dashboard: Awaited>): DashboardSpec { + return { + name: dashboard.name, + description: dashboard.description, + goal: dashboard.goal, + widgets: dashboard.widgets, + defaultPreset: dashboard.defaultPreset, + tenantScope: dashboard.tenantScope, + visibility: dashboard.visibility, + sharedWithRole: dashboard.sharedWithRole, + }; +} diff --git a/apps/web/src/routes/dashboards-list.tsx b/apps/web/src/routes/dashboards-list.tsx new file mode 100644 index 0000000..9ecdaaf --- /dev/null +++ b/apps/web/src/routes/dashboards-list.tsx @@ -0,0 +1,218 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Clock, Copy, LayoutDashboard, Plus, Search, Star, Trash2, Users } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Link, useLocation } from "wouter"; +import { PageHeader } from "../components/layout"; +import { + Badge, + Button, + Card, + EmptyState, + Input, + SegmentedControl, + Skeleton, + useToast, +} from "../components/ui"; +import { api } from "../lib/api"; +import { formatRelative } from "../lib/utils"; + +type SortKey = "recent" | "name"; + +export function DashboardsList({ canEdit }: { canEdit: boolean }) { + const queryClient = useQueryClient(); + const toast = useToast(); + const [, navigate] = useLocation(); + const [query, setQuery] = useState(""); + const [sort, setSort] = useState("recent"); + + const { data, isLoading } = useQuery({ queryKey: ["dashboards"], queryFn: api.dashboards }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["dashboards"] }); + const remove = useMutation({ + mutationFn: api.deleteDashboard, + onSuccess: () => { + void invalidate(); + toast.success("Dashboard deleted"); + }, + }); + const duplicate = useMutation({ + mutationFn: api.duplicateDashboard, + onSuccess: (result) => { + void invalidate(); + navigate(`/dashboards/${result.id}`); + }, + }); + const favorite = useMutation({ + mutationFn: ({ id, next }: { id: string; next: boolean }) => api.setFavorite(id, next), + onSuccess: invalidate, + }); + + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + const filtered = (data ?? []).filter( + (dashboard) => + !needle || + dashboard.name.toLowerCase().includes(needle) || + dashboard.description.toLowerCase().includes(needle), + ); + return [...filtered].sort((a, b) => { + if (a.favorite !== b.favorite) return Number(b.favorite) - Number(a.favorite); + return sort === "name" + ? a.name.localeCompare(b.name) + : new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); + }, [data, query, sort]); + + return ( + <> + + + + ) : null + } + /> + + {(data ?? []).length > 0 ? ( +
+
+ + setQuery(event.target.value)} + placeholder="Filter dashboards…" + className="h-9 w-64 pl-8" + /> +
+ setSort(value as SortKey)} + options={[ + { value: "recent", label: "Recent" }, + { value: "name", label: "Name" }, + ]} + /> + + {visible.length} of {(data ?? []).length} + +
+ ) : null} + + {isLoading ? ( +
+ {[0, 1, 2].map((key) => ( + + ))} +
+ ) : !data || data.length === 0 ? ( + + } + title="No dashboards yet" + description={ + canEdit + ? "The guided builder starts from a template — executive overview, user experience, reliability, capacity or cost — and fills it with your own data." + : "Ask an analyst or admin to share a dashboard with you." + } + action={ + canEdit ? ( + + + + ) : null + } + /> + + ) : visible.length === 0 ? ( + + + + ) : ( +
+ {visible.map((dashboard) => ( + +
+ +

{dashboard.name}

+

+ {dashboard.description || "No description"} +

+ + +
+ +
+ {dashboard.widgetCount} widgets + + {dashboard.visibility === "shared" ? ( + <> + {dashboard.sharedWithRole}+ + + ) : ( + "private" + )} + + + {dashboard.defaultPreset} + + {dashboard.tenantScope ? {dashboard.tenantScope.length} customers : null} +
+ +
+ + updated {formatRelative(dashboard.updatedAt)} + +
+ {canEdit ? ( + + ) : null} + {dashboard.isOwner ? ( + + ) : null} +
+
+
+ ))} +
+ )} + + ); +} diff --git a/apps/web/src/routes/errors.tsx b/apps/web/src/routes/errors.tsx new file mode 100644 index 0000000..b5990b4 --- /dev/null +++ b/apps/web/src/routes/errors.tsx @@ -0,0 +1,170 @@ +import { formatUnit } from "@dashflow/core"; +import { useQuery } from "@tanstack/react-query"; +import { TriangleAlert, Users } from "lucide-react"; +import { useState } from "react"; +import { DrillDrawer, type DrillRequest } from "../components/drill-drawer"; +import { PageHeader } from "../components/layout"; +import { Badge, Card, CardHeader, DataTable, SegmentedControl } from "../components/ui"; +import { api } from "../lib/api"; +import { useScope } from "../lib/scope"; +import { downloadCsv, formatRelative } from "../lib/utils"; + +const RANGES = [ + { value: "1", label: "24h" }, + { value: "7", label: "7d" }, + { value: "30", label: "30d" }, +] as const; + +/** + * What is failing, ranked. Every row opens the actual error records behind it, and the busiest + * users sit alongside because "who is affected" is the next question every time. + */ +export function ErrorsPage() { + const scope = useScope(); + const [days, setDays] = useState<(typeof RANGES)[number]["value"]>("7"); + const [drill, setDrill] = useState(null); + + const errors = useQuery({ queryKey: ["error-groups", days], queryFn: () => api.errorGroups(Number(days)) }); + const users = useQuery({ queryKey: ["top-users", days], queryFn: () => api.topUsers(Number(days)) }); + + const from = new Date(Date.now() - Number(days) * 86_400_000).toISOString(); + const to = new Date().toISOString(); + + return ( + <> + setDays(value)} + options={RANGES.map((range) => ({ value: range.value, label: range.label }))} + /> + } + /> + + + downloadCsv(`errors-${days}d.csv`, errors.data?.groups ?? [])} + > + Export CSV + + } + /> + `${row.code}-${row.source}`} + pageSize={20} + empty={errors.isPending ? "Loading…" : "No errors in this range"} + emptyDescription="Either nothing is failing, or host pool diagnostics are not flowing into Log Analytics yet." + onRowClick={(row) => + setDrill({ + label: row.code, + metric: "errors.count", + groupBy: "errorCode", + groupValue: row.code, + tenantIds: scope.tenantIds, + hostPools: scope.hostPools, + rangeFrom: from, + rangeTo: to, + }) + } + columns={[ + { + key: "code", + label: "Code", + render: (row) => ( + + + {row.code} + + ), + }, + { key: "source", label: "Source" }, + { + key: "serviceError", + label: "Side", + render: (row) => + row.serviceError ? service : customer, + sortValue: (row) => (row.serviceError ? 1 : 0), + }, + { key: "hits", label: "Hits", numeric: true, render: (row) => row.hits.toLocaleString() }, + { key: "pools", label: "Pools", numeric: true }, + { key: "tenants", label: "Customers", numeric: true }, + { + key: "lastSeen", + label: "Last seen", + render: (row) => formatRelative(row.lastSeen), + sortValue: (row) => (row.lastSeen ? new Date(row.lastSeen).getTime() : 0), + }, + ]} + /> + + + + } + /> + row.user} + pageSize={20} + empty={users.isPending ? "Loading…" : "No connections in this range"} + columns={[ + { + key: "user", + label: "User", + render: (row) => {row.user}, + }, + { key: "connections", label: "Connections", numeric: true }, + { + key: "failed", + label: "Failed", + numeric: true, + render: (row) => + row.failed > 0 ? {row.failed} : "0", + }, + { key: "pools", label: "Pools", numeric: true }, + { + key: "connectP95", + label: "Connect p95", + numeric: true, + render: (row) => (row.connectP95 == null ? "—" : formatUnit("ms", row.connectP95)), + }, + { + key: "hours", + label: "Hours", + numeric: true, + render: (row) => row.hours.toFixed(1), + }, + { + key: "lastSeen", + label: "Last seen", + render: (row) => formatRelative(row.lastSeen), + sortValue: (row) => (row.lastSeen ? new Date(row.lastSeen).getTime() : 0), + }, + ]} + /> + + + setDrill(null)} /> + + ); +} diff --git a/apps/web/src/routes/explore.tsx b/apps/web/src/routes/explore.tsx new file mode 100644 index 0000000..a729e74 --- /dev/null +++ b/apps/web/src/routes/explore.tsx @@ -0,0 +1,349 @@ +import { + CATEGORY_INFO, + type DimensionId, + getMetric, + type MetricCategory, + type WidgetSpec, +} from "@dashflow/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { BarChart3, Compass, Plus, Share2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useLocation, useSearch } from "wouter"; +import { DrillDrawer, type DrillRequest } from "../components/drill-drawer"; +import { PageHeader } from "../components/layout"; +import { + Badge, + Button, + Card, + EmptyState, + Modal, + MultiSelect, + SegmentedControl, + SelectMenu, + useToast, +} from "../components/ui"; +import { type DrillTarget, WidgetCard } from "../components/widgets"; +import { api, type Catalog } from "../lib/api"; +import { useScope } from "../lib/scope"; + +/** + * Ad-hoc questions. Pick a metric, split it, filter it, look at it — and when the answer is + * worth keeping, save it onto a dashboard as a widget. The state lives in the URL, so a link + * to an interesting view is just a copy and paste. + */ +export function ExplorePage({ catalog, canSave }: { catalog: Catalog; canSave: boolean }) { + const scope = useScope(); + const toast = useToast(); + const [, navigate] = useLocation(); + const search = useSearch(); + + const params = useMemo(() => new URLSearchParams(search), [search]); + const [metric, setMetric] = useState(params.get("metric") ?? "connections.count"); + const [viz, setViz] = useState((params.get("viz") as WidgetSpec["viz"]) ?? "line"); + const [groupBy, setGroupBy] = useState(params.get("groupBy") ?? ""); + const [filterDim, setFilterDim] = useState(params.get("filterDim") ?? ""); + const [filterValues, setFilterValues] = useState( + params.get("filterValues") ? params.get("filterValues")!.split("~") : null, + ); + const [drill, setDrill] = useState(null); + const [saveOpen, setSaveOpen] = useState(false); + + const definition = getMetric(metric); + const catalogMetric = catalog.metrics.find((candidate) => candidate.id === metric); + const allowedViz = catalogMetric?.viz ?? ["line"]; + const dims = catalogMetric?.dims ?? []; + + // Keep the visualization legal when the metric changes under it. + useEffect(() => { + if (!allowedViz.includes(viz)) setViz(allowedViz[0]!); + if (groupBy && !dims.includes(groupBy)) setGroupBy(""); + if (filterDim && !dims.includes(filterDim)) { + setFilterDim(""); + setFilterValues(null); + } + }, [allowedViz, viz, dims, groupBy, filterDim]); + + // Mirror the question into the URL so it can be shared. + useEffect(() => { + const next = new URLSearchParams(); + next.set("metric", metric); + next.set("viz", viz); + if (groupBy) next.set("groupBy", groupBy); + if (filterDim && filterValues?.length) { + next.set("filterDim", filterDim); + next.set("filterValues", filterValues.join("~")); + } + window.history.replaceState(null, "", `/explore?${next.toString()}`); + }, [metric, viz, groupBy, filterDim, filterValues]); + + const { data: values } = useQuery({ + queryKey: ["dim-values", metric, filterDim], + queryFn: () => api.dimensionValues(metric, filterDim), + enabled: Boolean(filterDim), + }); + + const widget: WidgetSpec = useMemo( + () => ({ + id: "explore", + title: definition?.label ?? metric, + subtitle: groupBy ? `split by ${catalog.dimensions[groupBy]?.label ?? groupBy}` : undefined, + viz, + metric, + groupBy: (groupBy || undefined) as DimensionId | undefined, + topN: 10, + grain: "auto", + filters: filterDim && filterValues?.length ? { dims: { [filterDim]: filterValues } as never } : {}, + compare: true, + size: "xl", + }), + [definition, metric, viz, groupBy, filterDim, filterValues, catalog.dimensions], + ); + + const result = useQuery({ + queryKey: [ + "explore", + widget, + scope.range.from.getTime(), + scope.range.to.getTime(), + scope.tenantIds, + scope.hostPools, + ], + queryFn: () => + api.query({ + metric: widget.metric, + viz: widget.viz, + groupBy: widget.groupBy, + topN: widget.topN, + grain: scope.range.grain, + from: scope.range.from, + to: scope.range.to, + tenantIds: scope.tenantIds, + filters: { hostPools: scope.hostPools, dims: widget.filters.dims }, + compare: widget.viz === "kpi", + tz: scope.tz, + }), + }); + + const dashboards = useQuery({ queryKey: ["dashboards"], queryFn: api.dashboards, enabled: saveOpen }); + + const saveToDashboard = useMutation({ + mutationFn: async (dashboardId: string) => { + const dashboard = await api.dashboard(dashboardId); + const widgetToAdd: WidgetSpec = { + ...widget, + id: `${metric.replace(/\W+/g, "-")}-${Math.random().toString(36).slice(2, 7)}`, + size: viz === "kpi" ? "sm" : "md", + layout: undefined, + }; + await api.saveDashboard(dashboardId, { + name: dashboard.name, + description: dashboard.description, + goal: dashboard.goal, + widgets: [...dashboard.widgets, widgetToAdd], + defaultPreset: dashboard.defaultPreset, + tenantScope: dashboard.tenantScope, + visibility: dashboard.visibility, + sharedWithRole: dashboard.sharedWithRole, + }); + return dashboardId; + }, + onSuccess: (dashboardId) => { + setSaveOpen(false); + toast.success("Added to dashboard"); + navigate(`/dashboards/${dashboardId}`); + }, + onError: (error) => toast.error("Could not add the widget", (error as Error).message), + }); + + const byCategory = useMemo(() => { + const groups: Record = {}; + for (const item of catalog.metrics) { + const list = groups[item.category] ?? []; + list.push({ + value: item.id, + label: item.label, + hint: item.available ? item.description : `needs a ${item.requires} connection`, + }); + groups[item.category] = list; + } + return groups; + }, [catalog.metrics]); + + const onDrill = (target: DrillTarget) => + setDrill({ + ...target, + metric, + groupBy: groupBy || undefined, + tenantIds: scope.tenantIds, + hostPools: scope.hostPools, + rangeFrom: scope.range.from.toISOString(), + rangeTo: scope.range.to.toISOString(), + }); + + return ( + <> + + + {canSave ? ( + + ) : null} + + } + /> + + + + + + + + +
+ setViz(value as WidgetSpec["viz"])} + options={allowedViz.map((option) => ({ value: option, label: option.replace("-", " ") }))} + /> +
+
+ + {catalogMetric && !catalogMetric.available ? ( +
+ + This metric needs a {catalogMetric.requires} connection before it returns anything. + +
+ ) : null} + +
+ +
+ +

+ Click any point, bar or row to see the underlying records. +

+ + setSaveOpen(false)} + title="Add to a dashboard" + description="The widget keeps this metric, split and filter." + > + {dashboards.isPending ? ( +

Loading dashboards…

+ ) : (dashboards.data ?? []).length === 0 ? ( + } + title="No dashboards yet" + description="Build one first, then save widgets onto it." + action={} + /> + ) : ( +
+ {(dashboards.data ?? []).map((dashboard) => ( + + ))} +
+ )} +
+ + setDrill(null)} /> + + ); +} diff --git a/apps/web/src/routes/host-detail.tsx b/apps/web/src/routes/host-detail.tsx new file mode 100644 index 0000000..eab8152 --- /dev/null +++ b/apps/web/src/routes/host-detail.tsx @@ -0,0 +1,153 @@ +import { useQuery } from "@tanstack/react-query"; +import { PageHeader } from "../components/layout"; +import { Badge, Card, CardHeader, DataTable, EmptyState, Skeleton } from "../components/ui"; +import { api } from "../lib/api"; +import { formatDateTime, formatRelative } from "../lib/utils"; + +/** + * One session host. The health timeline is the point: a host that flaps between Available and + * Unavailable looks fine in any snapshot, and obvious here. + */ +export function HostDetailPage({ poolId, name }: { poolId: string; name: string }) { + const { data, isPending, error } = useQuery({ + queryKey: ["host", poolId, name], + queryFn: () => api.host(poolId, name), + }); + + if (isPending) return ; + if (error || !data) { + return ( + + + + ); + } + + const { host, health, errors } = data; + const healthRows = health as { + ts: string; + status: string; + healthy: boolean; + drain: boolean; + sessions: number; + }[]; + const errorRows = errors as { + ts: string; + code: string; + source: string; + service_error: boolean; + message: string; + }[]; + + // Collapse the timeline into runs, so "flapping" is visible as a row count rather than noise. + const runs: { from: string; to: string; status: string; drain: boolean }[] = []; + for (const row of [...healthRows].reverse()) { + const last = runs.at(-1); + if (last && last.status === row.status && last.drain === row.drain) { + last.to = row.ts; + } else { + runs.push({ from: row.ts, to: row.ts, status: row.status, drain: row.drain }); + } + } + runs.reverse(); + + const unhealthy = (status: string) => + !["available", "shutdown", "unavailable(vmnotrunning)"].includes(status.toLowerCase()); + + return ( + <> + + {host.status} + {!host.allowNewSession ? drain mode : null} + {host.source} + + } + /> + +
+ + + + +
+ +
+ + + `${run.from}-${index}`} + empty="No health history yet" + columns={[ + { key: "from", label: "From", render: (run) => formatDateTime(run.from) }, + { key: "to", label: "Until", render: (run) => formatDateTime(run.to) }, + { + key: "status", + label: "State", + render: (run) => ( + + {run.status} + {run.drain ? drain : null} + + ), + }, + ]} + /> + + + + + `${row.ts}-${index}`} + empty="No errors recorded" + emptyDescription="Nothing has been reported for this host pool in the last week." + columns={[ + { key: "ts", label: "When", render: (row) => formatRelative(row.ts) }, + { key: "code", label: "Code" }, + { key: "source", label: "Source" }, + { + key: "service_error", + label: "Side", + render: (row) => + row.service_error ? service : customer, + }, + ]} + /> + +
+ + {host.vmResourceId ? ( +

{host.vmResourceId}

+ ) : null} + + ); +} + +function Fact({ label, value }: { label: string; value: string }) { + return ( + +

{label}

+

{value}

+
+ ); +} diff --git a/apps/web/src/routes/pool-detail.tsx b/apps/web/src/routes/pool-detail.tsx new file mode 100644 index 0000000..85c025d --- /dev/null +++ b/apps/web/src/routes/pool-detail.tsx @@ -0,0 +1,228 @@ +import { formatUnit, METRICS_BY_ID, type WidgetSpec } from "@dashflow/core"; +import { useQuery } from "@tanstack/react-query"; +import { ExternalLink, Server } from "lucide-react"; +import { useMemo } from "react"; +import { Link } from "wouter"; +import { PageHeader } from "../components/layout"; +import { Badge, Card, CardHeader, DataTable, EmptyState, Skeleton } from "../components/ui"; +import { WidgetCard } from "../components/widgets"; +import { api } from "../lib/api"; +import { useScope } from "../lib/scope"; +import { formatRelative } from "../lib/utils"; + +const POOL_WIDGETS: Pick[] = [ + { id: "p-sessions", title: "Active sessions", metric: "sessions.active", viz: "area", size: "lg" }, + { id: "p-util", title: "Capacity utilization", metric: "capacity.utilization", viz: "line", size: "lg" }, + { id: "p-connect", title: "Time to connect (p95)", metric: "connect.p95", viz: "line", size: "md" }, + { id: "p-errors", title: "Errors by code", metric: "errors.count", viz: "bar", size: "md" }, +]; + +function hostTone(status: string, drain: boolean): "good" | "warning" | "critical" | "neutral" { + const value = status.toLowerCase(); + if (value === "available") return drain ? "warning" : "good"; + if (value === "shutdown" || value.includes("vmnotrunning")) return "neutral"; + return "critical"; +} + +/** Everything about one host pool: its configuration, its hosts, and how it has been behaving. */ +export function PoolDetailPage({ resourceId }: { resourceId: string }) { + const scope = useScope(); + const { data, isPending, error } = useQuery({ + queryKey: ["pool", resourceId], + queryFn: () => api.pool(resourceId), + }); + + const queries = useMemo( + () => + POOL_WIDGETS.map((widget) => ({ + key: widget.id, + metric: widget.metric, + viz: widget.viz, + groupBy: widget.metric === "errors.count" ? ("errorCode" as const) : undefined, + topN: 8, + grain: scope.range.grain, + from: scope.range.from, + to: scope.range.to, + tenantIds: null, + filters: { hostPools: [resourceId] }, + compare: false, + })), + [resourceId, scope.range.from, scope.range.to, scope.range.grain], + ); + + const results = useQuery({ + queryKey: ["pool-data", resourceId, queries], + queryFn: () => api.queryBatch({ tz: scope.tz, queries: queries.map((query) => ({ ...query })) }), + }); + + if (isPending) return ; + if (error || !data) { + return ( + + + + ); + } + + const { pool, hosts } = data; + const byKey = new Map((results.data?.results ?? []).map((entry) => [entry.key, entry])); + const available = hosts.filter((host) => host.status === "Available").length; + const sessions = hosts.reduce((sum, host) => sum + host.sessions, 0); + const unhealthy = hosts.filter( + (host) => !["available", "shutdown", "unavailable(vmnotrunning)"].includes(host.status.toLowerCase()), + ).length; + + return ( + <> + + {pool.autoscaleEnabled ? autoscale : null} + {pool.sources.map((source) => ( + {source} + ))} + + } + /> + +
+ + + 0 ? "critical" : "good"} + hint={unhealthy > 0 ? "needs attention" : "all good"} + /> + +
+ +
+ {POOL_WIDGETS.map((widget) => { + const entry = byKey.get(widget.id); + const metric = METRICS_BY_ID[widget.metric]; + return ( +
+ +
+ ); + })} +
+ + + + host.resourceId} + pageSize={25} + empty="No session hosts yet" + emptyDescription="They appear after the first inventory sync." + columns={[ + { + key: "name", + label: "Host", + render: (host) => ( + + + {host.name} + + ), + }, + { + key: "status", + label: "Status", + render: (host) => ( + + {host.status} + {!host.allowNewSession ? drain : null} + + ), + }, + { key: "sessions", label: "Sessions", numeric: true }, + { key: "agentVersion", label: "Agent" }, + { key: "osVersion", label: "OS build" }, + { + key: "lastHeartBeat", + label: "Last heartbeat", + render: (host) => formatRelative(host.lastHeartBeat), + sortValue: (host) => (host.lastHeartBeat ? new Date(host.lastHeartBeat).getTime() : 0), + }, + ]} + /> + + +

+ + {pool.resourceId} +

+ + ); +} + +function Stat({ + label, + value, + hint, + tone, +}: { + label: string; + value: string; + hint?: string; + tone?: "good" | "critical"; +}) { + return ( + +

{label}

+

+ {value} +

+ {hint ?

{hint}

: null} +
+ ); +} + +export { formatUnit }; diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx new file mode 100644 index 0000000..614bc27 --- /dev/null +++ b/apps/web/src/routes/settings.tsx @@ -0,0 +1,172 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, ShieldCheck, XCircle } from "lucide-react"; +import { useEffect, useState } from "react"; +import { PageHeader } from "../components/layout"; +import { Alert, Badge, Button, Card, CardHeader, Field, Input, Skeleton, useToast } from "../components/ui"; +import { api, type Me } from "../lib/api"; + +/** Instance settings, plus the security posture an operator should be able to see at a glance. */ +export function SettingsPage({ me }: { me: Me }) { + const queryClient = useQueryClient(); + const toast = useToast(); + const { data, isPending } = useQuery({ queryKey: ["settings"], queryFn: api.settings }); + const [name, setName] = useState(""); + const [organizationName, setOrganizationName] = useState(""); + + useEffect(() => { + if (data) { + setName(data.instance.name); + setOrganizationName(data.instance.organizationName); + } + }, [data]); + + const save = useMutation({ + mutationFn: () => api.saveSettings({ name, organizationName }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["settings"] }); + toast.success("Settings saved"); + }, + onError: (error) => toast.error("Could not save", (error as Error).message), + }); + + if (isPending || !data) return ; + + const isOwner = me.user.role === "owner"; + const posture = [ + { + label: "Sign-in handled by Entra ID (EasyAuth)", + ok: data.runtime.authMode === "easyauth", + detail: + data.runtime.authMode === "easyauth" + ? "Requests are authenticated by the platform before they reach the app." + : "AUTH_MODE=dev trusts every request. Fine locally, never in production.", + }, + { + label: "Id token signature verified", + ok: data.runtime.tokenValidation, + detail: data.runtime.tokenValidation + ? "The forwarded id token is checked against Entra's keys." + : "Optional. Set EASYAUTH_VALIDATE_TOKEN=true with the token store enabled for defence in depth.", + }, + { + label: "Connection secrets in Key Vault", + ok: data.runtime.keyVault, + detail: data.runtime.keyVault + ? "Secrets are read at runtime with the managed identity; only names are stored." + : data.runtime.encryptionKey + ? "No Key Vault configured — secrets are AES-256-GCM encrypted in Postgres instead." + : "Neither Key Vault nor APP_ENCRYPTION_KEY is configured, so secrets cannot be stored at all.", + }, + { + label: "User names pseudonymized", + ok: data.runtime.pseudonymizeUsers, + detail: data.runtime.pseudonymizeUsers + ? "UPNs are hashed with a per-instance salt before they are stored." + : "PSEUDONYMIZE_USERS=false — real user names are stored and shown in drill-through.", + }, + { + label: "Sign-in restricted to specific tenants", + ok: data.runtime.allowedTenants.length > 0, + detail: + data.runtime.allowedTenants.length > 0 + ? `${data.runtime.allowedTenants.length} tenant(s) allowed.` + : "ALLOWED_TENANT_IDS is empty, so any tenant EasyAuth admits can sign in.", + }, + ]; + + return ( + <> + + +
+ + +
+ + setName(event.target.value)} /> + + + setOrganizationName(event.target.value)} + /> + + {isOwner ? ( + + ) : ( + Only an owner can change these. + )} +
+
+ + + +
    + {posture.map((item) => ( +
  • + {item.ok ? ( + + ) : ( + + )} +
    +

    {item.label}

    +

    {item.detail}

    +
    +
  • + ))} +
+
+ + + +
+ + + + + + + +
+
+ + + +
+

+ + DashFlow only ever makes read calls to Microsoft and Nerdio. Nothing is written back to your + tenants, and no telemetry leaves this container. +

+

+ Retention is set per connection, and a daily job deletes facts past it. Turning a customer off + keeps their history but hides it from dashboards. +

+ {data.runtime.demoMode ? ( + + A synthetic connection is generating fake customers. Set DEMO_MODE=false and + delete that connection before this instance is used for real work. + + ) : null} +
+
+
+ + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + <> +
{label}
+
+ {value} +
+ + ); +} diff --git a/apps/web/src/routes/sync.tsx b/apps/web/src/routes/sync.tsx new file mode 100644 index 0000000..4269719 --- /dev/null +++ b/apps/web/src/routes/sync.tsx @@ -0,0 +1,182 @@ +import { useQuery } from "@tanstack/react-query"; +import { Activity } from "lucide-react"; +import { useEffect, useState } from "react"; +import { PageHeader } from "../components/layout"; +import { Alert, Badge, Card, CardHeader, EmptyState, Skeleton, Table } from "../components/ui"; +import { api, type SyncRun } from "../lib/api"; +import { formatRelative } from "../lib/utils"; + +interface LiveEvent { + id: number; + type: string; + stream?: string; + error?: string; + stats?: Record; + at?: number; +} + +let nextEventId = 1; + +/** Sync health: the queue, recent runs, and a live feed while a backfill is running. */ +export function SyncPage() { + const { data, isLoading } = useQuery({ + queryKey: ["sync-runs"], + queryFn: api.syncRuns, + refetchInterval: 10_000, + }); + const [events, setEvents] = useState([]); + + useEffect(() => { + const source = new EventSource("/api/sync/events"); + const onMessage = (event: MessageEvent) => { + try { + const parsed = JSON.parse(event.data) as Omit; + if (parsed.type === "ping") return; + setEvents((current) => [{ ...parsed, id: nextEventId++ }, ...current].slice(0, 30)); + } catch { + // A malformed frame is not worth surfacing. + } + }; + for (const type of ["sync:start", "sync:done", "sync:error"]) { + source.addEventListener(type, onMessage as EventListener); + } + return () => source.close(); + }, []); + + if (isLoading) return ; + + const runs = data?.runs ?? []; + const failing = runs.filter((run) => run.status === "error"); + + return ( + <> + + +
+ + + + +
+ + {failing.length > 0 ? ( +
+ + {failing[0]!.error?.slice(0, 300)} + +
+ ) : null} + + {events.length > 0 ? ( + + +
    + {events.map((event) => ( +
  • + + {event.type.replace("sync:", "")} + + {event.stream} + {event.stats ? ( + + {Object.entries(event.stats) + .filter(([, value]) => value > 0) + .map(([key, value]) => `${key} ${value}`) + .join(" · ") || "no new rows"} + + ) : null} + {event.error ? ( + {event.error.slice(0, 120)} + ) : null} +
  • + ))} +
+
+ ) : null} + + + + {runs.length === 0 ? ( + } title="No sync runs yet" /> + ) : ( + ({ + __key: run.id, + when: formatRelative(run.startedAt), + connection: run.connectionName, + tenant: run.tenantName ?? "—", + stream: run.stream, + status: ( + + {run.status} + + ), + rows: rowCount(run), + took: duration(run), + }))} + /> + )} + + + ); +} + +function Stat({ label, value, tone }: { label: string; value: number; tone?: "neutral" | "critical" }) { + return ( + +

{label}

+

+ {value} +

+
+ ); +} + +function rowCount(run: SyncRun): string { + if (!run.stats) return "—"; + const total = Object.entries(run.stats) + .filter(([key]) => key !== "skippedUnknownPool") + .reduce((sum, [, value]) => sum + value, 0); + return total.toLocaleString(); +} + +function duration(run: SyncRun): string { + if (!run.finishedAt) return "…"; + const ms = new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime(); + return ms > 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`; +} diff --git a/apps/web/src/routes/users.tsx b/apps/web/src/routes/users.tsx new file mode 100644 index 0000000..9b7f315 --- /dev/null +++ b/apps/web/src/routes/users.tsx @@ -0,0 +1,169 @@ +import { ROLE_INFO, type Role } from "@dashflow/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { UserPlus, X } from "lucide-react"; +import { useState } from "react"; +import { PageHeader } from "../components/layout"; +import { Alert, Badge, Button, Card, EmptyState, Select, Skeleton, Switch, Table } from "../components/ui"; +import { api, type Me } from "../lib/api"; +import { formatRelative } from "../lib/utils"; + +/** + * Users arrive here automatically the first time they sign in through EasyAuth, with no + * access at all. Granting a viewer role scoped to one customer is how an MSP shares a + * dashboard with that customer's own staff. + */ +export function UsersPage({ me }: { me: Me }) { + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery({ queryKey: ["users"], queryFn: api.users }); + const [granting, setGranting] = useState(null); + const [role, setRole] = useState("viewer"); + const [tenantId, setTenantId] = useState(""); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["users"] }); + const grant = useMutation({ + mutationFn: (userId: string) => api.grantRole(userId, { role, customerTenantId: tenantId || null }), + onSuccess: () => { + setGranting(null); + void invalidate(); + }, + }); + const revoke = useMutation({ mutationFn: api.revokeRole, onSuccess: invalidate }); + const disable = useMutation({ + mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => api.setDisabled(id, disabled), + onSuccess: invalidate, + }); + + if (isLoading) return ; + + return ( + <> + + +
+ {(Object.keys(ROLE_INFO) as Role[]).map((key) => ( + +

{ROLE_INFO[key].label}

+

+ {ROLE_INFO[key].description} +

+
+ ))} +
+ + {grant.isError ? ( +
+ {(grant.error as Error).message} +
+ ) : null} + {revoke.isError ? ( +
+ {(revoke.error as Error).message} +
+ ) : null} + + + {!data || data.length === 0 ? ( + + ) : ( +
({ + __key: user.id, + user: ( + + + {user.displayName ?? user.email} + + {user.email} + + ), + access: + user.assignments.length === 0 ? ( + no access + ) : ( + + {user.assignments.map((assignment) => ( + + + {assignment.role} + {assignment.tenantName ? ` · ${assignment.tenantName}` : ""} + + + + ))} + + ), + seen: formatRelative(user.lastSeenAt), + enabled: ( + disable.mutate({ id: user.id, disabled: !enabled })} + label="Enabled" + /> + ), + actions: + granting === user.id ? ( + + + + + + + ) : ( + + + + ), + }))} + /> + )} + + + ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..5fec3c8 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,373 @@ +@import "tailwindcss"; +@import "@fontsource-variable/inter"; + +/* + * DashFlow design tokens. + * + * Dark is the designed default — this is a console people keep open all day — and light is + * designed second rather than inverted. The eight categorical series colours and the + * sequential ramp are a validated palette: they clear the lightness, chroma, colour-vision + * and contrast gates in both modes. Re-pick them only by re-running that validation. + */ +:root { + color-scheme: dark; + + /* Elevation ladder: page → panel → raised → overlay. */ + --surface-0: #0c0e12; + --surface-1: #12151b; + --surface-2: #181c23; + --surface-3: #20252e; + --border: #252b35; + --border-strong: #38404d; + + --text-primary: #f2f4f8; + --text-secondary: #b3bccb; + --text-muted: #7d8899; + + --accent: #3987e5; + --accent-hover: #4f95e9; + --accent-ink: #ffffff; + --accent-soft: #14243a; + + --good: #35b37e; + --warning: #d9a029; + --serious: #d95926; + --critical: #e2575a; + --good-soft: #10261e; + --warning-soft: #2a2211; + --critical-soft: #2c1618; + + --series-1: #3987e5; + --series-2: #d95926; + --series-3: #199e70; + --series-4: #c98500; + --series-5: #d55181; + --series-6: #008300; + --series-7: #9085e9; + --series-8: #e66767; + + /* Sequential ramp: near-zero recedes into the dark surface. */ + --seq-1: #0d366b; + --seq-2: #104281; + --seq-3: #184f95; + --seq-4: #256abf; + --seq-5: #3987e5; + --seq-6: #86b6ef; + + --grid: #1f242d; + + --elev-1: 0 1px 2px rgb(0 0 0 / 0.4); + --elev-2: 0 4px 16px -4px rgb(0 0 0 / 0.5); + --elev-3: 0 18px 48px -12px rgb(0 0 0 / 0.65); + + --radius: 10px; + --radius-sm: 7px; + --radius-lg: 14px; + + --ease: cubic-bezier(0.22, 0.61, 0.36, 1); +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + color-scheme: light; + + --surface-0: #f6f7f9; + --surface-1: #ffffff; + --surface-2: #f1f3f6; + --surface-3: #e8ebf0; + --border: #e3e6ec; + --border-strong: #c8cdd7; + + --text-primary: #0e1116; + --text-secondary: #4d5666; + --text-muted: #68707f; + + --accent: #2a78d6; + --accent-hover: #2166bb; + --accent-ink: #ffffff; + --accent-soft: #e9f1fd; + + --good: #16794c; + --warning: #9a6b00; + --serious: #c2481b; + --critical: #c93a3d; + --good-soft: #e7f5ee; + --warning-soft: #fdf4e3; + --critical-soft: #fdecec; + + --series-1: #2a78d6; + --series-2: #eb6834; + --series-3: #1baf7a; + --series-4: #eda100; + --series-5: #e87ba4; + --series-6: #008300; + --series-7: #4a3aa7; + --series-8: #e34948; + + --seq-1: #cde2fb; + --seq-2: #9ec5f4; + --seq-3: #6da7ec; + --seq-4: #3987e5; + --seq-5: #256abf; + --seq-6: #184f95; + + --grid: #ebedf1; + + --elev-1: 0 1px 2px rgb(16 24 40 / 0.06); + --elev-2: 0 6px 20px -6px rgb(16 24 40 / 0.14); + --elev-3: 0 20px 48px -16px rgb(16 24 40 / 0.22); + } +} + +:root[data-theme="light"] { + color-scheme: light; + + --surface-0: #f6f7f9; + --surface-1: #ffffff; + --surface-2: #f1f3f6; + --surface-3: #e8ebf0; + --border: #e3e6ec; + --border-strong: #c8cdd7; + + --text-primary: #0e1116; + --text-secondary: #4d5666; + --text-muted: #68707f; + + --accent: #2a78d6; + --accent-hover: #2166bb; + --accent-ink: #ffffff; + --accent-soft: #e9f1fd; + + --good: #16794c; + --warning: #9a6b00; + --serious: #c2481b; + --critical: #c93a3d; + --good-soft: #e7f5ee; + --warning-soft: #fdf4e3; + --critical-soft: #fdecec; + + --series-1: #2a78d6; + --series-2: #eb6834; + --series-3: #1baf7a; + --series-4: #eda100; + --series-5: #e87ba4; + --series-6: #008300; + --series-7: #4a3aa7; + --series-8: #e34948; + + --seq-1: #cde2fb; + --seq-2: #9ec5f4; + --seq-3: #6da7ec; + --seq-4: #3987e5; + --seq-5: #256abf; + --seq-6: #184f95; + + --grid: #ebedf1; + + --elev-1: 0 1px 2px rgb(16 24 40 / 0.06); + --elev-2: 0 6px 20px -6px rgb(16 24 40 / 0.14); + --elev-3: 0 20px 48px -16px rgb(16 24 40 / 0.22); +} + +@theme inline { + --color-surface-0: var(--surface-0); + --color-surface-1: var(--surface-1); + --color-surface-2: var(--surface-2); + --color-surface-3: var(--surface-3); + --color-line: var(--border); + --color-line-strong: var(--border-strong); + --color-ink: var(--text-primary); + --color-ink-secondary: var(--text-secondary); + --color-ink-muted: var(--text-muted); + --color-accent: var(--accent); + --color-accent-ink: var(--accent-ink); + --color-accent-soft: var(--accent-soft); + --color-good: var(--good); + --color-warning: var(--warning); + --color-serious: var(--serious); + --color-critical: var(--critical); + --radius-card: var(--radius); + --shadow-e1: var(--elev-1); + --shadow-e2: var(--elev-2); + --shadow-e3: var(--elev-3); + --font-sans: "Inter Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + background: var(--surface-0); + color: var(--text-primary); + font-family: var(--font-sans); + font-feature-settings: "cv02", "cv03", "cv04", "ss01"; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +* { + border-color: var(--border); +} + +/* Numbers that sit in columns or change in place must not jitter. */ +.tnum, +table td, +table th { + font-variant-numeric: tabular-nums; +} + +::selection { + background: var(--accent-soft); + color: var(--text-primary); +} + +:where(button, a, input, select, textarea, [tabindex]):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* Scrollbars that belong to a dark console rather than the OS default. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-thumb { + background: var(--border-strong); + border: 3px solid transparent; + background-clip: content-box; + border-radius: 999px; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); + background-clip: content-box; +} + +@keyframes df-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes df-fade-up { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes df-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes df-slide-in { + from { + transform: translateX(16px); + opacity: 0; + } + to { + transform: none; + opacity: 1; + } +} + +@keyframes df-shimmer { + to { + background-position: -200% 0; + } +} + +.df-fade-in { + animation: df-fade-up 200ms var(--ease); +} + +.df-fade { + animation: df-fade 160ms var(--ease); +} + +.df-slide-in { + animation: df-slide-in 220ms var(--ease); +} + +.df-skeleton { + background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface-3) 50%, var(--surface-2) 75%); + background-size: 200% 100%; + animation: df-shimmer 1.4s infinite linear; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + /* biome-ignore lint/complexity/noImportantStyles: this has to beat every utility class */ + animation-duration: 0.01ms !important; + /* biome-ignore lint/complexity/noImportantStyles: this has to beat every utility class */ + animation-iteration-count: 1 !important; + /* biome-ignore lint/complexity/noImportantStyles: this has to beat every utility class */ + transition-duration: 0.01ms !important; + } +} + +/* react-grid-layout: themed placeholder and resize handle. */ +.react-grid-item.react-grid-placeholder { + background: var(--accent); + opacity: 0.16; + border-radius: var(--radius); + transition-duration: 120ms; +} + +.react-grid-item > .react-resizable-handle { + width: 18px; + height: 18px; + right: 2px; + bottom: 2px; + opacity: 0; + transition: opacity 120ms var(--ease); + cursor: se-resize; +} + +.react-grid-item > .react-resizable-handle::after { + content: ""; + position: absolute; + right: 5px; + bottom: 5px; + width: 7px; + height: 7px; + border-right: 2px solid var(--text-muted); + border-bottom: 2px solid var(--text-muted); + border-radius: 0 0 2px 0; +} + +.df-editing .react-grid-item:hover > .react-resizable-handle { + opacity: 1; +} + +.df-editing .react-grid-item { + cursor: grab; +} + +.df-editing .react-grid-item.react-draggable-dragging { + cursor: grabbing; + box-shadow: var(--elev-3); + z-index: 20; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..b4c1827 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "types": ["vite/client"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..3675f82 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,16 @@ +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig(({ mode }) => ({ + // `bun run build:demo` produces the GitHub Pages bundle: mock API, repo-relative base path. + base: process.env.DEMO_BASE ?? "/", + define: { "import.meta.env.VITE_MOCK_API": JSON.stringify(mode === "demo" ? "1" : "0") }, + plugins: [react(), tailwindcss()], + server: { + port: 5173, + // The API (and its dev-mode fake identity) lives in the Bun server. + proxy: { "/api": "http://127.0.0.1:3000" }, + }, + build: { outDir: mode === "demo" ? "dist-demo" : "dist", sourcemap: false, chunkSizeWarningLimit: 1600 }, +})); diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..8851eec --- /dev/null +++ b/biome.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json", + "files": { + "includes": ["**", "!**/dist", "!**/node_modules", "!**/drizzle/meta", "!bun.lock"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 110 + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always" + } + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "warn" + }, + "a11y": { + "noSvgWithoutTitle": "off" + } + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "overrides": [ + { + "includes": ["apps/web/src/**"], + "linter": { + "rules": { + "a11y": { + "noLabelWithoutControl": "off" + } + } + } + } + ] +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..b71909a --- /dev/null +++ b/bun.lock @@ -0,0 +1,736 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "avd-dashboards", + "devDependencies": { + "@biomejs/biome": "2.5.14", + "@playwright/test": "1.63.0", + "@types/bun": "1.4.2", + "lefthook": "2.1.14", + "typescript": "5.9.3", + }, + }, + "apps/server": { + "name": "@dashflow/server", + "version": "0.1.0", + "dependencies": { + "@azure/identity": "4.13.3", + "@azure/keyvault-secrets": "4.11.2", + "@dashflow/connectors": "workspace:*", + "@dashflow/core": "workspace:*", + "@dashflow/db": "workspace:*", + "@dashflow/demo": "workspace:*", + "drizzle-orm": "0.45.2", + "hono": "4.13.8", + "jose": "6.2.12", + "pino": "10.3.1", + "zod": "4.6.5", + }, + "devDependencies": { + "pino-pretty": "13.1.3", + }, + }, + "apps/web": { + "name": "@dashflow/web", + "version": "0.1.0", + "dependencies": { + "@dashflow/core": "workspace:*", + "@dashflow/demo": "workspace:*", + "@fontsource-variable/inter": "5.3.0", + "@tanstack/react-query": "5.103.1", + "clsx": "2.1.1", + "echarts": "6.1.0", + "lucide-react": "1.47.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "react-grid-layout": "2.2.4", + "tailwind-merge": "3.7.0", + "wouter": "3.11.0", + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.3", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "tailwindcss": "4.3.3", + "typescript": "5.9.3", + "vite": "8.3.0", + }, + }, + "packages/connectors": { + "name": "@dashflow/connectors", + "version": "0.1.0", + "dependencies": { + "@azure/identity": "4.13.3", + "@dashflow/core": "workspace:*", + "@dashflow/demo": "workspace:*", + "zod": "4.6.5", + }, + }, + "packages/core": { + "name": "@dashflow/core", + "version": "0.1.0", + "dependencies": { + "zod": "4.6.5", + }, + }, + "packages/db": { + "name": "@dashflow/db", + "version": "0.1.0", + "dependencies": { + "@dashflow/core": "workspace:*", + "drizzle-orm": "0.45.2", + "postgres": "3.4.9", + }, + "devDependencies": { + "drizzle-kit": "0.31.10", + }, + }, + "packages/demo": { + "name": "@dashflow/demo", + "version": "0.1.0", + "dependencies": { + "@dashflow/core": "workspace:*", + }, + "devDependencies": { + "@dashflow/db": "workspace:*", + "drizzle-orm": "0.45.2", + }, + }, + }, + "packages": { + "@azure-rest/core-client": ["@azure-rest/core-client@2.9.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.24.0", "@azure/core-tracing": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.8", "tslib": "^2.6.2" } }, "sha512-6933vNLqh06RR7rumnrq3UZIeBtRLeBHDPMrieDPIljiCaMMwt0nRw++nd3TOxiohtasNtm29rC+b/9ZVKCOqg=="], + + "@azure/abort-controller": ["@azure/abort-controller@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg=="], + + "@azure/core-auth": ["@azure/core-auth@1.11.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg=="], + + "@azure/core-client": ["@azure/core-client@1.11.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ=="], + + "@azure/core-lro": ["@azure/core-lro@2.7.2", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-util": "^1.2.0", "@azure/logger": "^1.0.0", "tslib": "^2.6.2" } }, "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw=="], + + "@azure/core-paging": ["@azure/core-paging@1.7.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g=="], + + "@azure/core-process": ["@azure/core-process@1.0.0", "", {}, "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg=="], + + "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.25.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA=="], + + "@azure/core-tracing": ["@azure/core-tracing@1.4.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ=="], + + "@azure/core-util": ["@azure/core-util@1.14.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw=="], + + "@azure/identity": ["@azure/identity@4.13.3", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^6.0.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A=="], + + "@azure/keyvault-common": ["@azure/keyvault-common@2.1.0", "", { "dependencies": { "@azure-rest/core-client": "^2.3.3", "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-rest-pipeline": "^1.8.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.10.0", "@azure/logger": "^1.1.4", "tslib": "^2.2.0" } }, "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw=="], + + "@azure/keyvault-secrets": ["@azure/keyvault-secrets@4.11.2", "", { "dependencies": { "@azure-rest/core-client": "^2.3.3", "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-lro": "^2.7.2", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.0", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/keyvault-common": "^2.1.0", "@azure/logger": "^1.1.4", "tslib": "^2.8.1" } }, "sha512-ECj/kwZbZlQXj2kfWivSICbKwj6W3chmFhv8qUdauqYnjvZ0hWZBFSsZWux7W2nX3MP49PLUCusXk+hAg3pipg=="], + + "@azure/logger": ["@azure/logger@1.4.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA=="], + + "@azure/msal-browser": ["@azure/msal-browser@5.22.0", "", { "dependencies": { "@azure/msal-common": "16.14.1" } }, "sha512-5kgu9xeEKgGc2JeidxAtU15NJTqiH/CMCRRQAJ4Rac56kB7KVg91vbNmn+z3RO1vNomPN69UvjKG9h1Pghx6dQ=="], + + "@azure/msal-common": ["@azure/msal-common@16.14.1", "", {}, "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ=="], + + "@azure/msal-node": ["@azure/msal-node@6.0.1", "", { "dependencies": { "@azure/msal-common": "16.14.1", "jsonwebtoken": "^9.0.0" } }, "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ=="], + + "@biomejs/biome": ["@biomejs/biome@2.5.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.14", "@biomejs/cli-darwin-x64": "2.5.14", "@biomejs/cli-linux-arm64": "2.5.14", "@biomejs/cli-linux-arm64-musl": "2.5.14", "@biomejs/cli-linux-x64": "2.5.14", "@biomejs/cli-linux-x64-musl": "2.5.14", "@biomejs/cli-win32-arm64": "2.5.14", "@biomejs/cli-win32-x64": "2.5.14" }, "bin": { "biome": "bin/biome" } }, "sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ=="], + + "@dashflow/connectors": ["@dashflow/connectors@workspace:packages/connectors"], + + "@dashflow/core": ["@dashflow/core@workspace:packages/core"], + + "@dashflow/db": ["@dashflow/db@workspace:packages/db"], + + "@dashflow/demo": ["@dashflow/demo@workspace:packages/demo"], + + "@dashflow/server": ["@dashflow/server@workspace:apps/server"], + + "@dashflow/web": ["@dashflow/web@workspace:apps/web"], + + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@oxc-project/types": ["@oxc-project/types@0.150.0", "", {}, "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.9", "", { "os": "android", "cpu": "arm" }, "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.9", "", { "os": "android", "cpu": "arm64" }, "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.9", "", { "os": "linux", "cpu": "arm" }, "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.9", "", { "os": "none", "cpu": "arm64" }, "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.103.1", "", {}, "sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.103.1", "", { "dependencies": { "@tanstack/query-core": "5.103.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@22.20.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.9", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "fast-copy": ["fast-copy@4.1.1", "", {}, "sha512-A4QTJmuiztpGtr6AMeJts9R4hbj2ZBUwtOaKrG6rw2y7t6+IaJKjz5M3XDs8BUznxDH43FVc6A0y/gWlMl4UtA=="], + + "fast-equals": ["fast-equals@4.0.3", "", {}, "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + + "hono": ["hono@4.13.8", "", {}, "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lefthook": ["lefthook@2.1.14", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.14", "lefthook-darwin-x64": "2.1.14", "lefthook-freebsd-arm64": "2.1.14", "lefthook-freebsd-x64": "2.1.14", "lefthook-linux-arm64": "2.1.14", "lefthook-linux-x64": "2.1.14", "lefthook-openbsd-arm64": "2.1.14", "lefthook-openbsd-x64": "2.1.14", "lefthook-windows-arm64": "2.1.14", "lefthook-windows-x64": "2.1.14" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-Y9TL1dmpRlIdSp73SJ6bb+xAZ4s2SinGTK3pgC9PRWMSjMr2iOfowPGnlSOfAo5fu6S5rdiItsQDxeLEWCGk4g=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VMUAqY81+8ph7Mc0iROd3JWqj2sOuK7SYDyd8ZgzYVKChCt+Nnz8ehd4gUwprJy7QI28IFSUh1M3wnIRiYh+fA=="], + + "lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-uSmjcPRU+yB+D6vk+u6WJDnyHlB+XIv1QZw97N2+qNGG2bNSLH/7MuJ6puCdJv+lVPBEO5wBjE3JIbaEzPOQCw=="], + + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-po/pmjbp/7BjE9RFfeDnh+CeNsA0d+utppOYKB425I7mgI1GcmT8GAZ9DDut4DIutNjVqKL/lzrj+F87Gm40Rg=="], + + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-+KiXiFjJf7YdHUYjzhDsklcA5bAFZN+pNcz/NbBftrhVTwzNALNoK+SYEKt+9QJsrzL6tpDWmHHFpeLGp+8t8w=="], + + "lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-bjeJB16ftJaPWGTedssh8ZrqRy1ACfQuTV7a0O3NU1FoIsJtfHOm0o0mV4cPf4q+ZCVCwq1Xf2I11qUpuJFV4Q=="], + + "lefthook-linux-x64": ["lefthook-linux-x64@2.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-gYExzjU2w3uKrP1MklbHENS1cXUdCQRIiEJkykI7q4RjhIHi+mQEUAZLxZw93a6jE2pNWaO0me5tB8EbEt4/GQ=="], + + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.14", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MCW76gTlEMBF3Ds8O63UTxjaSguha/5zT0U3Vr719I1sXueSpttfRmm/5YfIB4MygB116yA/Vi+DsizT/X0kcg=="], + + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.14", "", { "os": "openbsd", "cpu": "x64" }, "sha512-salAc1PAhLI6xrB9pth5UxALQlTOGvZAfaAvgFwixhkdu8D//uW8zS3faFq31ktd1bDAy5wDI8p9yg+fF5+2uA=="], + + "lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-q3JC6lBrYLzkhXEhfnmFxJF47gSd5JeDweH3aNrHoLGX/KkRiOrk9RSyoRwSuGq03EYxSkJhl1FLj2GUxF7VqQ=="], + + "lefthook-windows-x64": ["lefthook-windows-x64@2.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-WwIxdwW1lDAaJUU3ETcKlCtdqluGr3Cy+LjKvP4QIQnFf0FEXL78THstIkZ6k6qhfUn7/WvyqSIf0cbTAKw2nQ=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lucide-react": ["lucide-react@1.47.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + + "process-warning": ["process-warning@5.1.0", "", {}, "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "react-draggable": ["react-draggable@4.7.2", "", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-pmqDx72EqhMGoJe/x42ZO2wJ9TIz7qJ5OPnFu6lVWWznEuJumlSjR+J0V4tNyDFhUo6dVvv56FeiTqN+arTgnw=="], + + "react-grid-layout": ["react-grid-layout@2.2.4", "", { "dependencies": { "clsx": "^2.1.1", "fast-equals": "^4.0.3", "prop-types": "^15.8.1", "react-draggable": "^4.4.6", "react-resizable": "^3.1.3", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA=="], + + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-resizable": ["react-resizable@3.2.0", "", { "dependencies": { "prop-types": "15.x", "react-draggable": "^4.5.0" }, "peerDependencies": { "react": ">= 16.3", "react-dom": ">= 16.3" } }, "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="], + + "resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rolldown": ["rolldown@1.2.9", "", { "dependencies": { "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.9", "@rolldown/binding-android-arm64": "1.2.9", "@rolldown/binding-darwin-arm64": "1.2.9", "@rolldown/binding-darwin-x64": "1.2.9", "@rolldown/binding-freebsd-x64": "1.2.9", "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", "@rolldown/binding-linux-arm64-gnu": "1.2.9", "@rolldown/binding-linux-arm64-musl": "1.2.9", "@rolldown/binding-linux-ppc64-gnu": "1.2.9", "@rolldown/binding-linux-s390x-gnu": "1.2.9", "@rolldown/binding-linux-x64-gnu": "1.2.9", "@rolldown/binding-linux-x64-musl": "1.2.9", "@rolldown/binding-openharmony-arm64": "1.2.9", "@rolldown/binding-win32-arm64-msvc": "1.2.9", "@rolldown/binding-win32-x64-msvc": "1.2.9" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + + "tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], + + "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], + + "wouter": ["wouter@3.11.0", "", { "dependencies": { "regexparam": "^3.0.0", "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-xyMLHhytdGhIjXVRcSjeYbXd7TR1lrxVTPLGBWmmyW52ZuWVQ1jXmsgoJ4G5s4tjloJCq7h4i2eREljWUQ4+/A=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "zrender": ["zrender@6.1.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], + + "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + + "zrender/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..eeedde7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +# Local development: Postgres plus the app with dev auth and synthetic data. +# AUTH_MODE=dev trusts every request, so the app port is published to 127.0.0.1 only. +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: dashflow + POSTGRES_PASSWORD: dashflow + POSTGRES_DB: dashflow + ports: ["127.0.0.1:5432:5432"] + volumes: ["pgdata:/var/lib/postgresql/data"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dashflow"] + interval: 5s + timeout: 3s + retries: 10 + + app: + build: . + depends_on: + postgres: { condition: service_healthy } + ports: ["127.0.0.1:3000:3000"] + environment: + NODE_ENV: development + HOST: 0.0.0.0 + PORT: 3000 + APP_ROLE: all + DATABASE_URL: postgres://dashflow:dashflow@postgres:5432/dashflow + AUTH_MODE: dev + DEV_AUTH_ALLOW_REMOTE: "true" + DEMO_MODE: "true" + # Local-only key. Generate your own for anything real: openssl rand -base64 32 + APP_ENCRYPTION_KEY: bG9jYWwtZGV2LW9ubHkta2V5LTMyLWJ5dGVzLS0tLQ== + LOG_LEVEL: info + +volumes: + pgdata: diff --git a/docs/brand/mark.svg b/docs/brand/mark.svg new file mode 100644 index 0000000..31c7ee1 --- /dev/null +++ b/docs/brand/mark.svg @@ -0,0 +1,16 @@ + + DashFlow + + + + + + + + + + + + + + diff --git a/docs/brand/wordmark.svg b/docs/brand/wordmark.svg new file mode 100644 index 0000000..1584d70 --- /dev/null +++ b/docs/brand/wordmark.svg @@ -0,0 +1,17 @@ + + DashFlow + + + + + + + + + + + + + DashFlow + diff --git a/docs/connections.md b/docs/connections.md new file mode 100644 index 0000000..837f9ea --- /dev/null +++ b/docs/connections.md @@ -0,0 +1,84 @@ +# Connections + +A connection is one way of reaching AVD data. You can have several at once, and they can +overlap — Azure and Nerdio data about the same host pool merge on the ARM resource ID. + +The wizard (Connections → Add connection) walks through: type → prerequisites with generated +commands → credentials → **permission test** → discovery → first sync. Nothing is collected +until the test passes. + +## Azure (direct) + +One Entra tenant. Authenticate with a service principal (client secret or certificate) or with +the container's own managed identity — the managed identity is the better option on Azure +because there is no secret to store or rotate. + +Choose specific subscriptions, or leave the list empty to use every subscription the identity +can see. + +## Azure Lighthouse + +One identity in your managing tenant. Delegated subscriptions report your tenant in +`managedByTenants`, which is how customers are discovered — there is no per-customer +credential. Include the managing tenant's own subscriptions with the "include home tenant" +toggle. + +## Partner Center (GDAP) + +For CSP partners. A partner admin consents once; the resulting refresh token is exchanged for a +token in each customer tenant on demand (the Secure Application Model). Customers come from the +Partner Center API, and Microsoft Graph tells the app which GDAP relationships are active. + +Remember that GDAP grants Entra roles — Azure RBAC in each customer subscription is a separate +step. See [permissions.md](permissions.md). + +## Nerdio Manager + +Works with Nerdio Manager for MSP (accounts become customers) and for Enterprise (one +environment). Client credentials against the instance's REST API. + +Because REST paths differ between editions and versions, the connection test reads your +instance's own API definition, compares it against the defaults, and lists the paths it does +expose if something does not line up. Override any of them per connection: + +```json +{ + "endpoints": { + "accounts": "/rest-api/v1/accounts", + "hostPools": "/rest-api/v1/host-pool", + "sessionHosts": "/rest-api/v1/host-pool/{subscriptionId}/{resourceGroup}/{hostPoolName}/host" + } +} +``` + +Nerdio does not expose connection-level telemetry, so latency, errors and connection history +still come from Azure. Pair a Nerdio connection with an Azure or Lighthouse one for the full +picture. + +## What each connection syncs + +| Stream | What | Interval (default) | +|---|---|---| +| `inventory` | Host pools, session hosts, app groups, scaling plans | 60 min | +| `sessions` | Active/disconnected sessions, capacity, host health | 5 min | +| `logs` | Connections, errors, latency, CPU/memory | 15 min, incremental with a 2 h overlap | +| `cost` | Daily cost per resource group and meter | 24 h | + +The first run backfills `backfillDays` (30 by default). Watch it on the Sync health page. + +## Troubleshooting + +**"No subscriptions are visible"** — the identity has no Reader anywhere, or, under GDAP, has +Entra roles but no Azure RBAC. + +**Inventory works, but experience and reliability widgets are empty** — host pool diagnostic +settings are missing. That is where `WVDConnections` and friends come from. + +**Cost widgets are empty** — Cost Management Reader is not assigned, or the subscription is too +new to have data. + +**Nerdio calls 404** — your instance's API paths differ; the connection test lists the real +ones, and you can override them. + +**A customer disappeared** — check Customers: something disabled is hidden from dashboards but +keeps its history. diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..f6ebc7e --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,82 @@ +# Deploying + +## 1. Create the sign-in app registration + +Container Apps authentication needs an Entra app registration: + +```bash +az ad app create --display-name "DashFlow" --sign-in-audience AzureADMyOrg +APP_ID=$(az ad app list --display-name "DashFlow" --query "[0].appId" -o tsv) +az ad app credential reset --id "$APP_ID" --years 1 --query password -o tsv # keep this +``` + +Leave the redirect URIs for now — the app's URL does not exist yet. + +## 2. Deploy + +```bash +RG=rg-dashflow LOCATION=eastus IMAGE=ghcr.io/bg-red/dashflow:latest ./infra/deploy.sh +``` + +The script asks for the client ID and secret, deploys `infra/main.bicep`, makes the app's +managed identity a Postgres administrator, and prints the URLs you need. + +What it creates: + +| Resource | Why | +|---|---| +| Container Apps environment + Log Analytics | Runtime and the app's own logs | +| Container app `-web` with `authConfigs` | The UI and API, behind EasyAuth | +| Container app `-worker` (`APP_ROLE=worker`) | Syncs, scaled separately | +| User-assigned managed identity | Key Vault, Postgres, and optionally Azure data access | +| Key Vault (RBAC) | The EasyAuth secret, the encryption key, connection secrets | +| Postgres Flexible Server, Entra-only auth | Storage, with no password anywhere | + +## 3. Finish the app registration + +Add these redirect URIs to the app registration (both printed by the script): + +- `https:///.auth/login/aad/callback` — sign-in +- `https:///api/connections/consent/callback` — only for Partner Center connections + +## 4. Claim and connect + +Open the app. If you passed `bootstrapOwnerObjectIds` (the script passes your own object ID), +you are already the owner; otherwise read the one-time code from the logs: + +```bash +az containerapp logs show -n -web -g --tail 200 | grep -A3 "first-run setup" +``` + +Then add a connection and follow the wizard. + +## Updating + +```bash +az containerapp update -n -web -g --image ghcr.io/bg-red/dashflow:v0.2.0 +az containerapp update -n -worker -g --image ghcr.io/bg-red/dashflow:v0.2.0 +``` + +Migrations run at startup under a Postgres advisory lock, so replicas starting at once are safe. + +## Running it somewhere else + +Any container host works. The requirements are: + +- Postgres 14+ (`DATABASE_URL`) +- Something in front that authenticates users and sets `X-MS-CLIENT-PRINCIPAL` the way EasyAuth + does — **or** accept that whoever reaches the port is whoever they claim to be. Read + [security.md](security.md) before exposing it. +- `APP_ENCRYPTION_KEY` (32 bytes, base64) if you want to store connection secrets without Key + Vault: `openssl rand -base64 32` + +Split web and worker by running the same image twice with `APP_ROLE=web` and `APP_ROLE=worker`. +`APP_ROLE=all` (the default) runs both in one process, which is fine for a single-instance +deployment. + +## Sizing + +The defaults handle a few thousand session hosts. What grows is `connection_facts` — roughly +one row per connection. At 50,000 connections a day and 180 days of retention that is about 9M +rows, comfortable on the Burstable tier. Turn down `retentionDays` per connection, or raise the +Postgres SKU, if you go much beyond that. diff --git a/docs/images/connections.png b/docs/images/connections.png new file mode 100644 index 0000000..ceb966f Binary files /dev/null and b/docs/images/connections.png differ diff --git a/docs/images/customers.png b/docs/images/customers.png new file mode 100644 index 0000000..210cc7b Binary files /dev/null and b/docs/images/customers.png differ diff --git a/docs/images/dashboard-light.png b/docs/images/dashboard-light.png new file mode 100644 index 0000000..1389fee Binary files /dev/null and b/docs/images/dashboard-light.png differ diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png new file mode 100644 index 0000000..acfd75f Binary files /dev/null and b/docs/images/dashboard.png differ diff --git a/docs/images/errors.png b/docs/images/errors.png new file mode 100644 index 0000000..8b8975d Binary files /dev/null and b/docs/images/errors.png differ diff --git a/docs/images/explore.png b/docs/images/explore.png new file mode 100644 index 0000000..1cad726 Binary files /dev/null and b/docs/images/explore.png differ diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..8ab1a94 --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,65 @@ +# Permissions + +Everything this app needs is read-only. + +## Azure (direct, Lighthouse, and per-customer under GDAP) + +| Role | Role definition ID | Needed for | Required? | +|---|---|---|---| +| Reader | `acdd72a7-3385-48ef-bd42-f606fba81ae7` | Listing subscriptions and resources | Yes | +| Desktop Virtualization Reader | `49a72310-ab8d-41df-bbb0-79b649203868` | Host pools, session hosts, app groups, scaling plans, user sessions | Yes | +| Log Analytics Reader | `73c42c96-874c-492b-b04d-ab87d138a893` | Querying `WVD*` and `Perf` tables | For experience, reliability and CPU data | +| Cost Management Reader | `72fafb9e-0641-4937-9268-a91bfd8191a3` | Cost Management query API | For cost widgets | + +Assign at the subscription, or at the resource groups that contain AVD plus the Log Analytics +workspace if you want a tighter scope. + +**Diagnostic settings matter as much as roles.** Connections, errors, checkpoints and network +data only exist in Log Analytics if each host pool has a diagnostic setting sending them there. +Without it you still get inventory and live sessions, but no history, latency or error +analysis. The connection test reports this as a warning with the fix. + +## Azure Lighthouse + +Add the app's **service principal object ID** to the delegated authorizations in your offer, +with the four role definition IDs above. Customers must accept the updated offer before the +access appears. Tokens are always issued by your managing tenant. + +## Partner Center (GDAP) + +A multi-tenant app registration in the partner tenant, with: + +- **Redirect URI** — `https:///api/connections/consent/callback` +- **Delegated permissions** — Partner Center `user_impersonation`, Azure Service Management + `user_impersonation`, Microsoft Graph `DelegatedAdminRelationship.Read.All`, `offline_access` +- Consent granted by a partner admin holding the **AdminAgents** role + +The refresh token from that consent is stored as a secret and exchanged per customer tenant +(the Secure Application Model), so no per-customer credential ever exists. + +**The trap:** a GDAP relationship grants **Entra** roles in the customer tenant. Reading AVD +needs **Azure RBAC** on the customer's subscription as well. Assign the four roles above to +your partner security group inside each customer subscription — Azure Lighthouse is the usual +way to automate that. The connection diagnostics say so explicitly when ARM refuses. + +## Nerdio Manager + +1. In Nerdio Manager: **Settings → Integrations → REST API** — enable it and note the API + scope (`api:///.default`). +2. Create an app registration in the tenant hosting Nerdio Manager, add a client secret, and + authorize it in those same REST API settings. +3. The role you give it in Nerdio should be read-only. This app only issues GET requests. + +Nerdio's REST paths differ between MSP and Enterprise editions and between versions, so the +connection test fetches your instance's own API definition and tells you which paths to +override (per-connection `endpoints`) if the defaults do not match. + +## The app's own Azure identity + +When deployed with the included Bicep, the container's user-assigned managed identity needs: + +- **Key Vault Secrets User** on the vault (the template assigns it) +- To be a **Postgres administrator** for the Flexible Server (the deploy script adds it) + +That identity can also be used as the *Azure (direct)* connection credential, which means no +secret to store or rotate at all. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..495dd50 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,77 @@ +# Security model + +## What the app trusts + +The app has **no login page of its own**. On Azure it runs behind Container Apps +authentication ("EasyAuth"): the platform terminates every request, authenticates the user +against Entra ID, and injects `X-MS-CLIENT-PRINCIPAL` with the signed-in identity. + +That header is only trustworthy **because nothing can reach the container except through that +sidecar**. If you expose the container's port directly, anyone can forge the header and become +whoever they like. So: + +- Keep ingress on the container app, with `authConfigs` enabled and + `unauthenticatedClientAction` set to redirect or 401 (the Bicep template does this). +- Do not put the image behind a load balancer or gateway that bypasses the auth sidecar. +- Defence in depth: set `EASYAUTH_VALIDATE_TOKEN=true` and `EASYAUTH_CLIENT_ID=`, and + the app additionally verifies the forwarded `X-MS-TOKEN-AAD-ID-TOKEN` against Entra's JWKS. + This needs the token store enabled. +- `ALLOWED_TENANT_IDS` restricts which home tenants may sign in at all. + +`AUTH_MODE=dev` fabricates an identity for local work. It refuses to start when +`NODE_ENV=production`, and it refuses any bind address other than localhost unless you +explicitly set `DEV_AUTH_ALLOW_REMOTE=true` (which containers need; publish that port to +127.0.0.1 only). + +## First-run ownership + +On first boot, a one-time code is printed to the container logs. Whoever can read the logs — +the person who deployed the app — can claim ownership once. After that, the code is deleted. +`BOOTSTRAP_OWNER_OIDS` pre-authorizes owners so infrastructure-as-code deployments skip the +step entirely. + +Everyone else signs in and lands with **no access** until an admin grants a role. + +## Roles + +| Role | Can | +|---|---| +| Viewer | Open dashboards shared with them; optionally limited to specific customers | +| Analyst | Build, edit and share dashboards | +| Admin | Manage connections, customers, sync and users | +| Owner | Everything, including other owners | + +A viewer can be scoped to one or more customers. That scope is enforced **in the SQL +compiler**, not in the UI: every query intersects the requested tenants with the ones the user +may see, and a request for someone else's tenant returns no rows rather than falling back to +"all". The tests in `apps/server/src/query/engine.test.ts` assert exactly that. + +## Credentials + +Connection secrets live in one of two places and nowhere else: + +1. **Key Vault** — the database stores only the secret's name; the value is read at runtime + with the container's managed identity. Preferred. +2. **Postgres, AES-256-GCM encrypted** with `APP_ENCRYPTION_KEY` (32 bytes, base64). Use this + when there is no Key Vault. + +Secrets are never returned by the API, never rendered in the UI, and never logged: the logger +redacts anything whose key looks like a credential, and the API scrubs config objects before +echoing them. Partner Center refresh tokens rotate on use and the new token is persisted +automatically. + +The app makes **only read calls** to Microsoft and Nerdio APIs. The roles it asks for are all +`*Reader` roles — see [permissions.md](permissions.md). + +## Privacy of user data + +By default (`PSEUDONYMIZE_USERS=true`) a user's UPN is hashed with a random per-instance salt +before it is stored, so "unique users" and per-user counts work without keeping identifiers. +Set it to `false` if you need to identify individuals in drill-through, and tell your users. + +Retention is per connection (`retentionDays`, default 180) and a daily job deletes facts older +than that. + +## Reporting a vulnerability + +Please do not open a public issue. See [SECURITY.md](../SECURITY.md). diff --git a/e2e/dashflow.spec.ts b/e2e/dashflow.spec.ts new file mode 100644 index 0000000..4268bc4 --- /dev/null +++ b/e2e/dashflow.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from "@playwright/test"; + +/** + * Smoke tests against the demo build — the same UI as production, with the mock API, so no + * database is needed in CI. These cover the paths unit tests cannot: a chart actually + * rendering, a drill-through opening, the palette responding to a keystroke. + */ + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + // Dark is the designed default; pin it so screenshots and assertions are stable. + localStorage.setItem("dashflow.theme", "dark"); + }); +}); + +test("the app loads with its dashboards", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Dashboards", level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Executive overview" })).toBeVisible(); +}); + +test("a dashboard renders KPIs and charts from the synthetic estate", async ({ page }) => { + await page.goto("/"); + await page.getByRole("heading", { name: "Executive overview" }).click(); + + await expect(page.getByText("Unique users")).toBeVisible(); + // A KPI that rendered has a formatted number under it, not a skeleton. + await expect(page.locator(".tabular-nums").first()).not.toBeEmpty(); + await expect(page.locator("canvas").first()).toBeVisible({ timeout: 15_000 }); +}); + +test("drilling into an error code opens the records behind it", async ({ page }) => { + await page.goto("/errors"); + await expect(page.getByText("Error codes")).toBeVisible(); + + // The first data row of the error catalogue. + await page.getByRole("row").nth(1).click(); + + const drawer = page.getByRole("dialog"); + await expect(drawer).toBeVisible(); + await expect(drawer.getByRole("button", { name: /Export CSV/ })).toBeVisible(); + await drawer.getByRole("button", { name: "Close panel" }).click(); + await expect(drawer).toBeHidden(); +}); + +test("the guided builder previews a template against real data", async ({ page }) => { + await page.goto("/dashboards/new"); + await page.getByRole("heading", { name: "Capacity and scaling" }).click(); + await page.getByRole("button", { name: "Preview", exact: true }).click(); + + await expect(page.getByRole("button", { name: /Create dashboard/ })).toBeVisible(); + await expect(page.locator("canvas").first()).toBeVisible({ timeout: 15_000 }); +}); + +test("explore answers an ad-hoc question and keeps it in the URL", async ({ page }) => { + await page.goto("/explore"); + await expect(page.getByRole("heading", { name: "Explore", level: 1 })).toBeVisible(); + await expect(page.locator("canvas").first()).toBeVisible({ timeout: 15_000 }); + + await page.getByRole("button", { name: "bar", exact: true }).click(); + await expect(page).toHaveURL(/viz=bar/); +}); + +test("the error catalogue lists codes and the busiest users", async ({ page }) => { + await page.goto("/errors"); + await expect(page.getByText("Error codes")).toBeVisible(); + await expect(page.getByText("Busiest users")).toBeVisible(); + await expect(page.getByRole("row").nth(1)).toBeVisible(); +}); + +test("the command palette opens on the keyboard and navigates", async ({ page }) => { + await page.goto("/"); + // The shortcut is registered when the shell mounts, so wait for it to be on screen first. + await expect(page.getByRole("heading", { name: "Dashboards", level: 1 })).toBeVisible(); + await page.keyboard.press("ControlOrMeta+k"); + + const palette = page.getByRole("dialog", { name: "Command palette" }); + await expect(palette).toBeVisible(); + await palette.getByPlaceholder(/Search dashboards/).fill("explore"); + await page.keyboard.press("Enter"); + + await expect(page.getByRole("heading", { name: "Explore", level: 1 })).toBeVisible(); +}); + +test("the theme toggle switches to light and back", async ({ page }) => { + await page.goto("/"); + const toggle = page.getByRole("button", { name: /^Theme:/ }); + await toggle.click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", /light|dark/); +}); diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts new file mode 100644 index 0000000..cdd131b --- /dev/null +++ b/e2e/screenshots.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; + +/** + * The README's screenshots, captured from the demo build so they can never contain a real + * tenant. Run with `bunx playwright test e2e/screenshots.spec.ts --grep @screenshot`. + */ + +const shots = [ + { name: "dashboard", path: "/", open: "Executive overview" }, + { name: "explore", path: "/explore" }, + { name: "errors", path: "/errors" }, + { name: "connections", path: "/connections/new" }, + { name: "customers", path: "/customers" }, +]; + +test.describe("@screenshot", () => { + test.use({ viewport: { width: 1440, height: 900 } }); + + for (const shot of shots) { + test(`capture ${shot.name}`, async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("dashflow.theme", "dark")); + await page.goto(shot.path); + if (shot.open) await page.getByRole("heading", { name: shot.open }).click(); + // Charts animate in; give them a moment so nothing is captured mid-transition. + await expect(page.locator("canvas").first()) + .toBeVisible({ timeout: 20_000 }) + .catch(() => {}); + await page.waitForTimeout(1500); + await page.screenshot({ path: `docs/images/${shot.name}.png`, animations: "disabled" }); + }); + } + + test("capture light theme", async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("dashflow.theme", "light")); + await page.goto("/"); + await page.getByRole("heading", { name: "User experience" }).click(); + await expect(page.locator("canvas").first()) + .toBeVisible({ timeout: 20_000 }) + .catch(() => {}); + await page.waitForTimeout(1500); + await page.screenshot({ path: "docs/images/dashboard-light.png", animations: "disabled" }); + }); +}); diff --git a/infra/azuredeploy.json b/infra/azuredeploy.json new file mode 100644 index 0000000..32cc039 --- /dev/null +++ b/infra/azuredeploy.json @@ -0,0 +1,468 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "3529486910016356614" + } + }, + "parameters": { + "name": { + "type": "string", + "defaultValue": "dashflow", + "minLength": 3, + "maxLength": 12, + "metadata": { + "description": "Short name used as a prefix for every resource." + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for all resources." + } + }, + "image": { + "type": "string", + "metadata": { + "description": "Container image to run, e.g. ghcr.io/bg-red/dashflow:v0.1.0" + } + }, + "authClientId": { + "type": "string", + "metadata": { + "description": "Entra app registration (client) ID used by Container Apps authentication." + } + }, + "authClientSecret": { + "type": "securestring", + "metadata": { + "description": "Client secret for that app registration. Stored in Key Vault, never in outputs." + } + }, + "authTenantId": { + "type": "string", + "defaultValue": "[subscription().tenantId]", + "metadata": { + "description": "Entra tenant that may sign in. Defaults to this subscription tenant." + } + }, + "bootstrapOwnerObjectIds": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Entra object IDs that become owners without the one-time setup code." + } + }, + "postgresAdminObjectId": { + "type": "string", + "metadata": { + "description": "Object ID of the person or group that administers the Postgres server." + } + }, + "postgresAdminName": { + "type": "string", + "metadata": { + "description": "Display name for that Postgres administrator." + } + }, + "postgresSku": { + "type": "string", + "defaultValue": "Standard_B2s", + "metadata": { + "description": "Postgres SKU. Burstable is fine for a few thousand session hosts." + } + }, + "postgresStorageGb": { + "type": "int", + "defaultValue": 64, + "metadata": { + "description": "Postgres storage in GB." + } + }, + "appEncryptionKey": { + "type": "securestring", + "defaultValue": "", + "metadata": { + "description": "32-byte base64 key used to encrypt connection secrets stored in Postgres.\nLeave empty and one is derived from the resource group id — deterministic, so a redeploy keeps\nworking, but generate your own for real use: openssl rand -base64 32\n" + } + } + }, + "variables": { + "suffix": "[uniqueString(resourceGroup().id)]", + "prefix": "[toLower(format('{0}{1}', parameters('name'), substring(variables('suffix'), 0, 5)))]", + "dbName": "dashflow", + "tags": { + "app": "dashflow", + "azd-env-name": "[parameters('name')]" + }, + "derivedKeyMaterial": "[format('{0}{1}{2}', uniqueString(resourceGroup().id, 'k1'), uniqueString(resourceGroup().id, 'k2'), uniqueString(resourceGroup().id, 'k3'))]", + "encryptionKey": "[if(empty(parameters('appEncryptionKey')), base64(substring(variables('derivedKeyMaterial'), 0, 32)), parameters('appEncryptionKey'))]" + }, + "resources": [ + { + "type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "apiVersion": "2023-01-31", + "name": "[format('{0}-id', variables('prefix'))]", + "location": "[parameters('location')]", + "tags": "[variables('tags')]" + }, + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2023-09-01", + "name": "[format('{0}-logs', variables('prefix'))]", + "location": "[parameters('location')]", + "tags": "[variables('tags')]", + "properties": { + "sku": { + "name": "PerGB2018" + }, + "retentionInDays": 30 + } + }, + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2023-07-01", + "name": "[format('{0}-kv', variables('prefix'))]", + "location": "[parameters('location')]", + "tags": "[variables('tags')]", + "properties": { + "sku": { + "family": "A", + "name": "standard" + }, + "tenantId": "[subscription().tenantId]", + "enableRbacAuthorization": true, + "enableSoftDelete": true, + "softDeleteRetentionInDays": 7, + "publicNetworkAccess": "Enabled" + } + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2023-07-01", + "name": "[format('{0}/{1}', format('{0}-kv', variables('prefix')), 'easyauth-client-secret')]", + "properties": { + "value": "[parameters('authClientSecret')]" + }, + "dependsOn": ["[resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix')))]"] + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2023-07-01", + "name": "[format('{0}/{1}', format('{0}-kv', variables('prefix')), 'app-encryption-key')]", + "properties": { + "value": "[variables('encryptionKey')]" + }, + "dependsOn": ["[resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix')))]"] + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix')))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix'))), resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix'))), '4633458b-17de-408a-b874-0445c86b69e6')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix'))), '2023-01-31').principalId]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix')))]", + "[resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix')))]" + ] + }, + { + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "apiVersion": "2024-08-01", + "name": "[format('{0}-pg', variables('prefix'))]", + "location": "[parameters('location')]", + "tags": "[variables('tags')]", + "sku": { + "name": "[parameters('postgresSku')]", + "tier": "[if(startsWith(parameters('postgresSku'), 'Standard_B'), 'Burstable', 'GeneralPurpose')]" + }, + "properties": { + "version": "16", + "storage": { + "storageSizeGB": "[parameters('postgresStorageGb')]", + "autoGrow": "Enabled" + }, + "backup": { + "backupRetentionDays": 14, + "geoRedundantBackup": "Disabled" + }, + "highAvailability": { + "mode": "Disabled" + }, + "authConfig": { + "activeDirectoryAuth": "Enabled", + "passwordAuth": "Disabled", + "tenantId": "[subscription().tenantId]" + }, + "network": { + "publicNetworkAccess": "Enabled" + } + } + }, + { + "type": "Microsoft.DBforPostgreSQL/flexibleServers/databases", + "apiVersion": "2024-08-01", + "name": "[format('{0}/{1}', format('{0}-pg', variables('prefix')), variables('dbName'))]", + "properties": { + "charset": "UTF8", + "collation": "en_US.utf8" + }, + "dependsOn": [ + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', format('{0}-pg', variables('prefix')))]" + ] + }, + { + "type": "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules", + "apiVersion": "2024-08-01", + "name": "[format('{0}/{1}', format('{0}-pg', variables('prefix')), 'AllowAllAzureServices')]", + "properties": { + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + "dependsOn": [ + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', format('{0}-pg', variables('prefix')))]" + ] + }, + { + "type": "Microsoft.DBforPostgreSQL/flexibleServers/administrators", + "apiVersion": "2024-08-01", + "name": "[format('{0}/{1}', format('{0}-pg', variables('prefix')), parameters('postgresAdminObjectId'))]", + "properties": { + "principalName": "[parameters('postgresAdminName')]", + "principalType": "User", + "tenantId": "[subscription().tenantId]" + }, + "dependsOn": [ + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers/databases', format('{0}-pg', variables('prefix')), variables('dbName'))]", + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', format('{0}-pg', variables('prefix')))]" + ] + }, + { + "type": "Microsoft.App/managedEnvironments", + "apiVersion": "2024-03-01", + "name": "[format('{0}-env', variables('prefix'))]", + "location": "[parameters('location')]", + "tags": "[variables('tags')]", + "properties": { + "appLogsConfiguration": { + "destination": "log-analytics", + "logAnalyticsConfiguration": { + "customerId": "[reference(resourceId('Microsoft.OperationalInsights/workspaces', format('{0}-logs', variables('prefix'))), '2023-09-01').customerId]", + "sharedKey": "[listKeys(resourceId('Microsoft.OperationalInsights/workspaces', format('{0}-logs', variables('prefix'))), '2023-09-01').primarySharedKey]" + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('{0}-logs', variables('prefix')))]" + ] + }, + { + "type": "Microsoft.App/containerApps", + "apiVersion": "2024-03-01", + "name": "[format('{0}-web', variables('prefix'))]", + "location": "[parameters('location')]", + "tags": "[union(variables('tags'), createObject('azd-service-name', 'web'))]", + "identity": { + "type": "UserAssigned", + "userAssignedIdentities": { + "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix'))))]": {} + } + }, + "properties": { + "managedEnvironmentId": "[resourceId('Microsoft.App/managedEnvironments', format('{0}-env', variables('prefix')))]", + "configuration": { + "ingress": { + "external": true, + "targetPort": 3000, + "transport": "auto", + "allowInsecure": false + }, + "secrets": [ + { + "name": "app-encryption-key", + "keyVaultUrl": "[format('{0}secrets/app-encryption-key', reference(resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix'))), '2023-07-01').vaultUri)]", + "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix')))]" + }, + { + "name": "easyauth-client-secret", + "keyVaultUrl": "[format('{0}secrets/easyauth-client-secret', reference(resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix'))), '2023-07-01').vaultUri)]", + "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix')))]" + } + ] + }, + "template": { + "containers": [ + { + "name": "web", + "image": "[parameters('image')]", + "resources": { + "cpu": "[json('0.5')]", + "memory": "1Gi" + }, + "env": "[concat(createArray(createObject('name', 'NODE_ENV', 'value', 'production'), createObject('name', 'AUTH_MODE', 'value', 'easyauth'), createObject('name', 'HOST', 'value', '0.0.0.0'), createObject('name', 'PORT', 'value', '3000'), createObject('name', 'AZURE_CLIENT_ID', 'value', reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix'))), '2023-01-31').clientId), createObject('name', 'DATABASE_USE_ENTRA_AUTH', 'value', 'true'), createObject('name', 'DATABASE_URL', 'value', format('postgres://{0}@{1}:5432/{2}?sslmode=require', format('{0}-id', variables('prefix')), reference(resourceId('Microsoft.DBforPostgreSQL/flexibleServers', format('{0}-pg', variables('prefix'))), '2024-08-01').fullyQualifiedDomainName, variables('dbName'))), createObject('name', 'KEY_VAULT_URI', 'value', reference(resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix'))), '2023-07-01').vaultUri), createObject('name', 'APP_ENCRYPTION_KEY', 'secretRef', 'app-encryption-key'), createObject('name', 'ALLOWED_TENANT_IDS', 'value', parameters('authTenantId')), createObject('name', 'EASYAUTH_CLIENT_ID', 'value', parameters('authClientId')), createObject('name', 'BOOTSTRAP_OWNER_OIDS', 'value', join(parameters('bootstrapOwnerObjectIds'), ',')), createObject('name', 'DEMO_MODE', 'value', 'false'), createObject('name', 'LOG_LEVEL', 'value', 'info')), createArray(createObject('name', 'APP_ROLE', 'value', 'web'), createObject('name', 'PUBLIC_BASE_URL', 'value', format('https://{0}-web.{1}', variables('prefix'), reference(resourceId('Microsoft.App/managedEnvironments', format('{0}-env', variables('prefix'))), '2024-03-01').defaultDomain))))]", + "probes": [ + { + "type": "Liveness", + "httpGet": { + "path": "/healthz", + "port": 3000 + }, + "periodSeconds": 30 + }, + { + "type": "Readiness", + "httpGet": { + "path": "/readyz", + "port": 3000 + }, + "periodSeconds": 10 + } + ] + } + ], + "scale": { + "minReplicas": 1, + "maxReplicas": 3 + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.App/managedEnvironments', format('{0}-env', variables('prefix')))]", + "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix')))]", + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers/administrators', format('{0}-pg', variables('prefix')), parameters('postgresAdminObjectId'))]", + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', format('{0}-pg', variables('prefix')))]", + "[resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix')))]", + "[extensionResourceId(resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix'))), 'Microsoft.Authorization/roleAssignments', guid(resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix'))), resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix'))), '4633458b-17de-408a-b874-0445c86b69e6'))]" + ] + }, + { + "type": "Microsoft.App/containerApps/authConfigs", + "apiVersion": "2024-03-01", + "name": "[format('{0}/{1}', format('{0}-web', variables('prefix')), 'current')]", + "properties": { + "platform": { + "enabled": true + }, + "globalValidation": { + "unauthenticatedClientAction": "RedirectToLoginPage", + "redirectToProvider": "azureactivedirectory", + "excludedPaths": ["/healthz", "/readyz"] + }, + "identityProviders": { + "azureActiveDirectory": { + "enabled": true, + "registration": { + "clientId": "[parameters('authClientId')]", + "clientSecretSettingName": "easyauth-client-secret", + "openIdIssuer": "[format('{0}{1}/v2.0', environment().authentication.loginEndpoint, parameters('authTenantId'))]" + }, + "validation": { + "allowedAudiences": [ + "[format('api://{0}', parameters('authClientId'))]", + "[parameters('authClientId')]" + ], + "defaultAuthorizationPolicy": { + "allowedApplications": [] + } + } + } + }, + "login": { + "tokenStore": { + "enabled": true + } + } + }, + "dependsOn": ["[resourceId('Microsoft.App/containerApps', format('{0}-web', variables('prefix')))]"] + }, + { + "type": "Microsoft.App/containerApps", + "apiVersion": "2024-03-01", + "name": "[format('{0}-worker', variables('prefix'))]", + "location": "[parameters('location')]", + "tags": "[union(variables('tags'), createObject('azd-service-name', 'worker'))]", + "identity": { + "type": "UserAssigned", + "userAssignedIdentities": { + "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix'))))]": {} + } + }, + "properties": { + "managedEnvironmentId": "[resourceId('Microsoft.App/managedEnvironments', format('{0}-env', variables('prefix')))]", + "configuration": { + "secrets": [ + { + "name": "app-encryption-key", + "keyVaultUrl": "[format('{0}secrets/app-encryption-key', reference(resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix'))), '2023-07-01').vaultUri)]", + "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix')))]" + }, + { + "name": "easyauth-client-secret", + "keyVaultUrl": "[format('{0}secrets/easyauth-client-secret', reference(resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix'))), '2023-07-01').vaultUri)]", + "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix')))]" + } + ] + }, + "template": { + "containers": [ + { + "name": "worker", + "image": "[parameters('image')]", + "resources": { + "cpu": "[json('0.5')]", + "memory": "1Gi" + }, + "env": "[concat(createArray(createObject('name', 'NODE_ENV', 'value', 'production'), createObject('name', 'AUTH_MODE', 'value', 'easyauth'), createObject('name', 'HOST', 'value', '0.0.0.0'), createObject('name', 'PORT', 'value', '3000'), createObject('name', 'AZURE_CLIENT_ID', 'value', reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix'))), '2023-01-31').clientId), createObject('name', 'DATABASE_USE_ENTRA_AUTH', 'value', 'true'), createObject('name', 'DATABASE_URL', 'value', format('postgres://{0}@{1}:5432/{2}?sslmode=require', format('{0}-id', variables('prefix')), reference(resourceId('Microsoft.DBforPostgreSQL/flexibleServers', format('{0}-pg', variables('prefix'))), '2024-08-01').fullyQualifiedDomainName, variables('dbName'))), createObject('name', 'KEY_VAULT_URI', 'value', reference(resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix'))), '2023-07-01').vaultUri), createObject('name', 'APP_ENCRYPTION_KEY', 'secretRef', 'app-encryption-key'), createObject('name', 'ALLOWED_TENANT_IDS', 'value', parameters('authTenantId')), createObject('name', 'EASYAUTH_CLIENT_ID', 'value', parameters('authClientId')), createObject('name', 'BOOTSTRAP_OWNER_OIDS', 'value', join(parameters('bootstrapOwnerObjectIds'), ',')), createObject('name', 'DEMO_MODE', 'value', 'false'), createObject('name', 'LOG_LEVEL', 'value', 'info')), createArray(createObject('name', 'APP_ROLE', 'value', 'worker')))]" + } + ], + "scale": { + "minReplicas": 1, + "maxReplicas": 2 + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.App/managedEnvironments', format('{0}-env', variables('prefix')))]", + "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix')))]", + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers/administrators', format('{0}-pg', variables('prefix')), parameters('postgresAdminObjectId'))]", + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', format('{0}-pg', variables('prefix')))]", + "[resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix')))]", + "[extensionResourceId(resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix'))), 'Microsoft.Authorization/roleAssignments', guid(resourceId('Microsoft.KeyVault/vaults', format('{0}-kv', variables('prefix'))), resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix'))), '4633458b-17de-408a-b874-0445c86b69e6'))]" + ] + } + ], + "outputs": { + "webUrl": { + "type": "string", + "value": "[format('https://{0}', reference(resourceId('Microsoft.App/containerApps', format('{0}-web', variables('prefix'))), '2024-03-01').configuration.ingress.fqdn)]" + }, + "identityClientId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix'))), '2023-01-31').clientId]" + }, + "identityPrincipalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-id', variables('prefix'))), '2023-01-31').principalId]" + }, + "keyVaultName": { + "type": "string", + "value": "[format('{0}-kv', variables('prefix'))]" + }, + "postgresHost": { + "type": "string", + "value": "[reference(resourceId('Microsoft.DBforPostgreSQL/flexibleServers', format('{0}-pg', variables('prefix'))), '2024-08-01').fullyQualifiedDomainName]" + }, + "consentRedirectUri": { + "type": "string", + "value": "[format('https://{0}/api/connections/consent/callback', reference(resourceId('Microsoft.App/containerApps', format('{0}-web', variables('prefix'))), '2024-03-01').configuration.ingress.fqdn)]" + } + } +} diff --git a/infra/deploy.sh b/infra/deploy.sh new file mode 100755 index 0000000..59be349 --- /dev/null +++ b/infra/deploy.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# One-shot deploy to Azure Container Apps. Reads values interactively; nothing is written to disk. +set -euo pipefail + +RG="${RG:-rg-dashflow}" +LOCATION="${LOCATION:-eastus}" +NAME="${NAME:-avddash}" +IMAGE="${IMAGE:-ghcr.io/bg-red/dashflow:latest}" + +command -v az >/dev/null || { echo "Azure CLI is required: https://aka.ms/azure-cli"; exit 1; } + +ME=$(az ad signed-in-user show --query '{id:id,upn:userPrincipalName}' -o json) +ADMIN_ID=$(echo "$ME" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])') +ADMIN_UPN=$(echo "$ME" | python3 -c 'import json,sys;print(json.load(sys.stdin)["upn"])') + +read -rp "Entra app (client) ID for sign-in: " AUTH_CLIENT_ID +read -rsp "Client secret for that app: " AUTH_CLIENT_SECRET +echo + +az group create -n "$RG" -l "$LOCATION" -o none + +az deployment group create \ + -g "$RG" \ + -f infra/main.bicep \ + -p name="$NAME" image="$IMAGE" authClientId="$AUTH_CLIENT_ID" authClientSecret="$AUTH_CLIENT_SECRET" \ + postgresAdminObjectId="$ADMIN_ID" postgresAdminName="$ADMIN_UPN" \ + bootstrapOwnerObjectIds="[\"$ADMIN_ID\"]" \ + -o json --query properties.outputs > /tmp/dashflow-outputs.json + +IDENTITY_PRINCIPAL=$(python3 -c 'import json;print(json.load(open("/tmp/dashflow-outputs.json"))["identityPrincipalId"]["value"])') +IDENTITY_NAME="$(python3 -c 'import json;print(json.load(open("/tmp/dashflow-outputs.json"))["postgresHost"]["value"].split(".")[0])')" +PG_SERVER=$(python3 -c 'import json;print(json.load(open("/tmp/dashflow-outputs.json"))["postgresHost"]["value"].split(".")[0])') +WEB_URL=$(python3 -c 'import json;print(json.load(open("/tmp/dashflow-outputs.json"))["webUrl"]["value"])') +REDIRECT=$(python3 -c 'import json;print(json.load(open("/tmp/dashflow-outputs.json"))["consentRedirectUri"]["value"])') + +# Administrator resources need a name known before deployment, so the app identity is added here. +echo "Making the app identity a Postgres administrator…" +az postgres flexible-server ad-admin create \ + -g "$RG" -s "$PG_SERVER" \ + -u "$(az identity list -g "$RG" --query "[0].name" -o tsv)" \ + -i "$IDENTITY_PRINCIPAL" \ + -t ServicePrincipal -o none + +rm -f /tmp/dashflow-outputs.json + +cat < => + config.subscriptionIds.length > 0 + ? config.subscriptionIds + : (await collector.arm.listSubscriptions()).map((s) => s.subscriptionId); + + return { + type: "azure", + + async test(): Promise { + const checks: DiagnosticCheck[] = [ + check( + "credential", + config.credentialMode === "managed-identity" + ? "Use the app's managed identity" + : "Use the configured credential", + config.credentialMode !== "managed-identity" && !ctx.secret ? "fail" : "pass", + config.credentialMode !== "managed-identity" && !ctx.secret + ? "No secret is stored for this connection" + : undefined, + "Re-enter the client secret or certificate on the connection page.", + ), + ]; + if (checks[0]!.status === "fail") return checks; + return [...checks, ...(await collector.diagnose({ subscriptionIds: config.subscriptionIds }))]; + }, + + async discover(): Promise { + const tenants = await collector.arm.listTenants().catch(() => []); + const self = tenants.find((t) => t.tenantId.toLowerCase() === config.tenantId.toLowerCase()); + const subscriptions = await collector.arm.listSubscriptions().catch(() => []); + const selected = + config.subscriptionIds.length > 0 + ? config.subscriptionIds + : subscriptions.map((s) => s.subscriptionId); + const rest = await collector.discover(selected); + + return { + tenants: [ + { + tenantId: config.tenantId, + displayName: self?.displayName ?? "Azure tenant", + domain: self?.defaultDomain, + }, + ], + subscriptions: subscriptions + .filter((s) => selected.includes(s.subscriptionId)) + .map((s) => ({ + tenantId: config.tenantId, + subscriptionId: s.subscriptionId, + displayName: s.displayName, + })), + workspaces: rest.workspaces, + hostPoolCount: rest.hostPoolCount, + }; + }, + + async sync(input: SyncInput): Promise { + const subscriptionIds = + input.subscriptionIds.length > 0 ? input.subscriptionIds : await subscriptionsFor(); + switch (input.stream) { + case "inventory": + return collector.inventory(subscriptionIds); + case "sessions": + return collector.sessions(input.hostPoolIds); + case "logs": + return collector.logs(input); + case "cost": + return collector.cost({ ...input, subscriptionIds }); + } + }, + }; +} diff --git a/packages/connectors/src/azure/arm.ts b/packages/connectors/src/azure/arm.ts new file mode 100644 index 0000000..491c3ee --- /dev/null +++ b/packages/connectors/src/azure/arm.ts @@ -0,0 +1,237 @@ +import type { AppGroupRecord, HostPoolRecord, ScalingPlanRecord, SessionHostRecord } from "@dashflow/core"; +import { normalizeResourceId, parseResourceId } from "@dashflow/core"; +import { armList, httpJson, type TokenFn } from "../http"; +import { SCOPES } from "./credentials"; + +export const API = { + desktopVirtualization: "2024-04-03", + resourceGraph: "2022-10-01", + subscriptions: "2022-12-01", + diagnosticSettings: "2021-05-01-preview", + operationalInsights: "2022-10-01", + costManagement: "2023-11-01", +} as const; + +const ARM = "https://management.azure.com"; + +interface ArmResource { + id: string; + name: string; + location?: string; + properties?: Record; +} + +export interface ArmClient { + /** Tenants this identity can see; a service principal only ever sees its own. */ + listTenants(): Promise<{ tenantId: string; displayName?: string; defaultDomain?: string }[]>; + listSubscriptions(): Promise< + { subscriptionId: string; displayName: string; tenantId?: string; managedByTenants: string[] }[] + >; + listHostPools(subscriptionId: string): Promise; + listSessionHosts(hostPoolId: string): Promise; + listUserSessions(hostPoolId: string): Promise<{ sessionState: string; sessionHost: string }[]>; + listAppGroups(subscriptionId: string): Promise; + listScalingPlans(subscriptionId: string): Promise; + /** Log Analytics workspaces that receive diagnostics for this host pool. */ + diagnosticWorkspaces(hostPoolId: string): Promise; + workspaceCustomerId(resourceId: string): Promise<{ workspaceId: string; name: string } | null>; + resourceGraph(query: string, subscriptionIds: string[]): Promise; + raw(path: string, apiVersion: string): Promise; +} + +export function createArmClient(token: TokenFn, tenantId: string): ArmClient { + const get = (path: string, apiVersion: string) => + token(SCOPES.arm, tenantId).then((t) => + httpJson(`${ARM}${path}${path.includes("?") ? "&" : "?"}api-version=${apiVersion}`, t), + ); + const list = (path: string, apiVersion: string) => + armList(`${ARM}${path}?api-version=${apiVersion}`, token, SCOPES.arm, tenantId); + + return { + async listTenants() { + const tenants = await list<{ tenantId: string; displayName?: string; defaultDomain?: string }>( + "/tenants", + API.subscriptions, + ); + return tenants.filter((t) => Boolean(t.tenantId)); + }, + + async listSubscriptions() { + const subs = await list<{ + subscriptionId: string; + displayName: string; + tenantId?: string; + managedByTenants?: { tenantId: string }[]; + state?: string; + }>("/subscriptions", API.subscriptions); + return subs + .filter((s) => s.state !== "Disabled" && s.state !== "Deleted") + .map((s) => ({ + subscriptionId: s.subscriptionId, + displayName: s.displayName, + tenantId: s.tenantId, + managedByTenants: (s.managedByTenants ?? []).map((m) => m.tenantId), + })); + }, + + async listHostPools(subscriptionId) { + const pools = await list( + `/subscriptions/${subscriptionId}/providers/Microsoft.DesktopVirtualization/hostPools`, + API.desktopVirtualization, + ); + return pools.map((pool) => { + const props = pool.properties ?? {}; + return { + resourceId: normalizeResourceId(pool.id), + tenantId, + subscriptionId, + name: pool.name, + friendlyName: (props.friendlyName as string) ?? null, + location: pool.location ?? "unknown", + poolType: (props.hostPoolType as string) ?? "Pooled", + loadBalancer: (props.loadBalancerType as string) ?? null, + maxSessions: typeof props.maxSessionLimit === "number" ? props.maxSessionLimit : null, + startVmOnConnect: (props.startVMOnConnect as boolean) ?? null, + validationEnvironment: (props.validationEnvironment as boolean) ?? null, + preferredAppGroupType: (props.preferredAppGroupType as string) ?? null, + autoscaleEnabled: null, + source: "azure", + } satisfies HostPoolRecord; + }); + }, + + async listSessionHosts(hostPoolId) { + const hosts = await list(`${hostPoolId}/sessionHosts`, API.desktopVirtualization); + return hosts.map((host) => { + const props = host.properties ?? {}; + // ARM returns "hostpoolname/vmname.domain"; keep just the host part. + const name = host.name.includes("/") ? host.name.slice(host.name.indexOf("/") + 1) : host.name; + return { + resourceId: normalizeResourceId(host.id), + hostPoolId: normalizeResourceId(hostPoolId), + tenantId, + name, + vmResourceId: props.resourceId ? normalizeResourceId(props.resourceId as string) : null, + status: (props.status as string) ?? "Unknown", + allowNewSession: props.allowNewSession !== false, + sessions: typeof props.sessions === "number" ? props.sessions : 0, + agentVersion: (props.agentVersion as string) ?? null, + osVersion: (props.osVersion as string) ?? null, + lastHeartBeat: (props.lastHeartBeat as string) ?? null, + updateState: (props.updateState as string) ?? null, + source: "azure", + } satisfies SessionHostRecord; + }); + }, + + async listUserSessions(hostPoolId) { + const sessions = await list(`${hostPoolId}/userSessions`, API.desktopVirtualization); + return sessions.map((session) => { + const props = session.properties ?? {}; + const parts = session.name.split("/"); + return { + sessionState: (props.sessionState as string) ?? "Unknown", + sessionHost: parts.length > 1 ? parts[1]! : session.name, + }; + }); + }, + + async listAppGroups(subscriptionId) { + const groups = await list( + `/subscriptions/${subscriptionId}/providers/Microsoft.DesktopVirtualization/applicationGroups`, + API.desktopVirtualization, + ); + return groups.map((group) => { + const props = group.properties ?? {}; + return { + resourceId: normalizeResourceId(group.id), + hostPoolId: props.hostPoolArmPath ? normalizeResourceId(props.hostPoolArmPath as string) : null, + tenantId, + name: group.name, + groupType: (props.applicationGroupType as string) ?? "Desktop", + workspaceId: props.workspaceArmPath ? normalizeResourceId(props.workspaceArmPath as string) : null, + } satisfies AppGroupRecord; + }); + }, + + async listScalingPlans(subscriptionId) { + const plans = await list( + `/subscriptions/${subscriptionId}/providers/Microsoft.DesktopVirtualization/scalingPlans`, + API.desktopVirtualization, + ); + return plans.map((plan) => { + const props = plan.properties ?? {}; + const references = (props.hostPoolReferences as { hostPoolArmPath?: string }[] | undefined) ?? []; + return { + resourceId: normalizeResourceId(plan.id), + tenantId, + name: plan.name, + timeZone: (props.timeZone as string) ?? null, + hostPoolIds: references + .map((r) => (r.hostPoolArmPath ? normalizeResourceId(r.hostPoolArmPath) : "")) + .filter(Boolean), + schedules: Array.isArray(props.schedules) ? (props.schedules as unknown[]).length : 0, + } satisfies ScalingPlanRecord; + }); + }, + + async diagnosticWorkspaces(hostPoolId) { + const settings = await get<{ value?: { properties?: { workspaceId?: string } }[] }>( + `${hostPoolId}/providers/Microsoft.Insights/diagnosticSettings`, + API.diagnosticSettings, + ); + return [ + ...new Set( + (settings.value ?? []) + .map((s) => s.properties?.workspaceId) + .filter((id): id is string => typeof id === "string" && id.length > 0) + .map(normalizeResourceId), + ), + ]; + }, + + async workspaceCustomerId(resourceId) { + const workspace = await get<{ name?: string; properties?: { customerId?: string } }>( + resourceId, + API.operationalInsights, + ); + const customerId = workspace.properties?.customerId; + if (!customerId) return null; + return { + workspaceId: customerId, + name: workspace.name ?? parseResourceId(resourceId).name ?? resourceId, + }; + }, + + async resourceGraph(query: string, subscriptionIds: string[]) { + const out: T[] = []; + let skipToken: string | undefined; + let pages = 0; + do { + const accessToken = await token(SCOPES.arm, tenantId); + const page = await httpJson<{ data?: T[]; $skipToken?: string }>( + `${ARM}/providers/Microsoft.ResourceGraph/resources?api-version=${API.resourceGraph}`, + accessToken, + { + method: "POST", + body: { + query, + ...(subscriptionIds.length > 0 ? { subscriptions: subscriptionIds } : {}), + options: { + $top: 1000, + resultFormat: "objectArray", + ...(skipToken ? { $skipToken: skipToken } : {}), + }, + }, + }, + ); + if (page.data) out.push(...page.data); + skipToken = page.$skipToken; + pages++; + } while (skipToken && pages < 50); + return out; + }, + + raw: get, + }; +} diff --git a/packages/connectors/src/azure/collectors.ts b/packages/connectors/src/azure/collectors.ts new file mode 100644 index 0000000..c0781c0 --- /dev/null +++ b/packages/connectors/src/azure/collectors.ts @@ -0,0 +1,530 @@ +import { createHash } from "node:crypto"; +import type { + DiagnosticCheck, + Discovery, + HostHealthRecord, + SessionHostRecord, + SyncBatch, +} from "@dashflow/core"; +import { normalizeResourceId, parseResourceId } from "@dashflow/core"; +import type { TokenFn } from "../http"; +import { type ConnectorError, type ConnectorLogger, check, type SyncInput } from "../types"; +import { API, type ArmClient, createArmClient } from "./arm"; +import { attributeCost, queryDailyCost } from "./cost"; +import { + CONNECTIONS_QUERY, + type ConnectionRow, + ERRORS_QUERY, + type ErrorRow, + kql, + PERF_QUERY, + type PerfRow, + TABLE_PROBE_QUERY, + withWindow, +} from "./logAnalytics"; + +const HEALTHY_STATUSES = new Set(["available"]); +/** Hosts that are simply switched off are not a fault — autoscale does that on purpose. */ +const NEUTRAL_STATUSES = new Set(["shutdown", "unavailable(vmnotrunning)"]); + +export function isHealthy(status: string): boolean { + const s = status.toLowerCase(); + return HEALTHY_STATUSES.has(s) || NEUTRAL_STATUSES.has(s); +} + +function shortHostName(name: string): string { + const withoutPool = name.includes("/") ? name.slice(name.indexOf("/") + 1) : name; + return withoutPool.split(".")[0]!.toLowerCase(); +} + +/** + * Collection logic shared by every Azure-backed connector. Azure direct, Lighthouse and + * Partner Center differ only in how they get a token and which tenants they enumerate — + * once there is a token for a tenant, the data collection is identical. + */ +export class AzureCollector { + readonly arm: ArmClient; + + /** + * `tenantId` is the customer the records belong to. `tokenTenantId` is the tenant that + * issues tokens — the same thing for a direct connection, but the managing tenant for + * Azure Lighthouse, where one identity reaches many customers' subscriptions. + */ + constructor( + private readonly token: TokenFn, + readonly tenantId: string, + private readonly log: ConnectorLogger, + private readonly tokenTenantId: string = tenantId, + ) { + this.arm = createArmClient(token, tokenTenantId); + } + + /** Host pools and Log Analytics workspaces for the selected subscriptions. */ + async discover(subscriptionIds: string[]): Promise> { + const subs = subscriptionIds.length + ? subscriptionIds + : (await this.arm.listSubscriptions()).map((s) => s.subscriptionId); + + const pools = (await Promise.all(subs.map((sub) => this.arm.listHostPools(sub).catch(() => [])))).flat(); + + const workspaces = new Map(); + for (const pool of pools) { + const ids = await this.arm.diagnosticWorkspaces(pool.resourceId).catch(() => []); + for (const id of ids) { + const entry = workspaces.get(id) ?? { resourceId: id, hostPoolIds: [] }; + entry.hostPoolIds.push(pool.resourceId); + workspaces.set(id, entry); + } + } + + const resolved = await Promise.all( + [...workspaces.values()].map(async (workspace) => { + const info = await this.arm.workspaceCustomerId(workspace.resourceId).catch(() => null); + if (!info) return null; + return { + tenantId: this.tenantId, + resourceId: workspace.resourceId, + workspaceId: info.workspaceId, + name: info.name, + hostPoolIds: workspace.hostPoolIds, + }; + }), + ); + + return { + subscriptions: subs.map((subscriptionId) => ({ + tenantId: this.tenantId, + subscriptionId, + displayName: subscriptionId, + })), + workspaces: resolved.filter((w): w is NonNullable => w !== null), + hostPoolCount: pools.length, + }; + } + + async inventory(subscriptionIds: string[]): Promise { + const batch: SyncBatch = { + hostPools: [], + sessionHosts: [], + appGroups: [], + scalingPlans: [], + hostHealth: [], + }; + const ts = new Date().toISOString(); + + for (const subscriptionId of subscriptionIds) { + const [pools, appGroups, scalingPlans] = await Promise.all([ + this.arm.listHostPools(subscriptionId), + this.arm.listAppGroups(subscriptionId).catch((err) => { + this.log.warn({ err: (err as Error).message, subscriptionId }, "could not list application groups"); + return []; + }), + this.arm.listScalingPlans(subscriptionId).catch(() => []), + ]); + batch.hostPools!.push(...pools); + batch.appGroups!.push(...appGroups); + batch.scalingPlans!.push(...scalingPlans); + + const scaledPools = new Set(scalingPlans.flatMap((plan) => plan.hostPoolIds)); + for (const pool of pools) { + if (scaledPools.has(pool.resourceId)) pool.autoscaleEnabled = true; + const hosts = await this.arm.listSessionHosts(pool.resourceId).catch((err) => { + this.log.warn({ err: (err as Error).message, pool: pool.name }, "could not list session hosts"); + return [] as SessionHostRecord[]; + }); + batch.sessionHosts!.push(...hosts); + batch.hostHealth!.push(...hostHealthFrom(hosts, pool.resourceId, this.tenantId, ts)); + } + } + return batch; + } + + /** A point-in-time view of sessions and hosts, one row per host pool. */ + async sessions(hostPoolIds: string[]): Promise { + const ts = roundToFiveMinutes(new Date()).toISOString(); + const batch: SyncBatch = { sessionSnapshots: [], sessionHosts: [], hostHealth: [] }; + + for (const hostPoolId of hostPoolIds) { + const hosts = await this.arm.listSessionHosts(hostPoolId).catch((err) => { + this.log.warn({ err: (err as Error).message, hostPoolId }, "could not list session hosts"); + return [] as SessionHostRecord[]; + }); + if (hosts.length === 0) continue; + batch.sessionHosts!.push(...hosts); + batch.hostHealth!.push(...hostHealthFrom(hosts, hostPoolId, this.tenantId, ts)); + + const userSessions = await this.arm.listUserSessions(hostPoolId).catch(() => []); + const active = userSessions.filter((s) => s.sessionState.toLowerCase() === "active").length; + const disconnected = userSessions.filter((s) => s.sessionState.toLowerCase() === "disconnected").length; + const availableHosts = hosts.filter((h) => h.status === "Available").length; + const totalSessions = hosts.reduce((sum, host) => sum + host.sessions, 0); + + // maxSessionLimit lives on the pool; capacity uses the per-host limit when we know it. + type PoolProps = { properties?: { maxSessionLimit?: number; hostPoolType?: string } }; + const pool: PoolProps = await this.arm + .raw(hostPoolId, API.desktopVirtualization) + .catch(() => ({}) as PoolProps); + const perHostLimit = + pool.properties?.maxSessionLimit ?? (pool.properties?.hostPoolType === "Personal" ? 1 : 0); + + batch.sessionSnapshots!.push({ + ts, + hostPoolId, + tenantId: this.tenantId, + activeSessions: userSessions.length > 0 ? active : totalSessions, + disconnectedSessions: disconnected, + capacity: perHostLimit > 0 ? availableHosts * perHostLimit : totalSessions, + availableHosts, + totalHosts: hosts.length, + }); + } + return batch; + } + + async logs(input: SyncInput): Promise { + const window = logWindow(input); + const batch: SyncBatch = { connections: [], errors: [], perf: [] }; + // Perf rows arrive with a computer name, so build a host → pool map first. + const hostToPool = await this.hostToPoolMap(input.hostPoolIds); + + for (const workspace of input.workspaces) { + const poolFallback = workspace.hostPoolIds.length === 1 ? workspace.hostPoolIds[0]! : null; + + const [connections, errors, perf] = await Promise.all([ + kql( + this.token, + this.tokenTenantId, + workspace.workspaceId, + withWindow(CONNECTIONS_QUERY, window), + window, + ).catch((err) => this.logQueryFailure(err, workspace.workspaceId, "WVDConnections")), + kql(this.token, this.tokenTenantId, workspace.workspaceId, ERRORS_QUERY, window).catch( + (err) => this.logQueryFailure(err, workspace.workspaceId, "WVDErrors"), + ), + kql(this.token, this.tokenTenantId, workspace.workspaceId, PERF_QUERY, window).catch( + () => [] as PerfRow[], + ), + ]); + + for (const row of connections) { + if (!row.CorrelationId || !row.Ts) continue; + const hostPoolId = row.PoolId ? normalizeResourceId(row.PoolId) : poolFallback; + batch.connections!.push({ + correlationId: row.CorrelationId, + tenantId: this.tenantId, + hostPoolId, + ts: new Date(row.Ts).toISOString(), + user: row.UserName ?? "unknown", + sessionHost: row.SessionHostName ? shortHostName(row.SessionHostName) : null, + clientOs: row.ClientOS ?? null, + clientType: row.ClientType ?? null, + clientVersion: row.ClientVersion ?? null, + gatewayRegion: row.GatewayRegion ?? null, + state: row.ConnState === "connected" ? "connected" : "failed", + connectMs: nonNegative(row.ConnectMs), + rttMs: nonNegative(row.RttMs), + bandwidthKbps: nonNegative(row.BandwidthKbps), + durationSec: nonNegative(row.DurationSec), + }); + } + + for (const row of errors) { + if (!row.TimeGenerated) continue; + const ts = new Date(row.TimeGenerated).toISOString(); + const code = String(row.Code ?? "Unknown"); + batch.errors!.push({ + key: hashKey([this.tenantId, row.CorrelationId ?? "", ts, code, row.Source ?? ""]), + ts, + tenantId: this.tenantId, + hostPoolId: row.PoolId ? normalizeResourceId(row.PoolId) : poolFallback, + correlationId: row.CorrelationId ?? null, + code, + source: row.Source ?? "Unknown", + message: (row.Message ?? "").slice(0, 500), + serviceError: Boolean(row.ServiceError), + }); + } + + for (const row of perf) { + if (!row.Computer || !row.Ts) continue; + const host = shortHostName(row.Computer); + batch.perf!.push({ + ts: new Date(row.Ts).toISOString(), + tenantId: this.tenantId, + hostPoolId: hostToPool.get(host) ?? poolFallback, + sessionHost: host, + cpuPct: typeof row.CpuPct === "number" ? row.CpuPct : null, + memAvailableMb: typeof row.MemAvailableMb === "number" ? row.MemAvailableMb : null, + }); + } + } + return batch; + } + + async cost(input: SyncInput): Promise { + const to = input.until; + const from = input.since ?? new Date(to.getTime() - input.backfillDays * 86_400_000); + const poolByResourceGroup = await this.resourceGroupToPoolMap(input.hostPoolIds); + const costs: SyncBatch["costs"] = []; + + for (const subscriptionId of input.subscriptionIds) { + const rows = await queryDailyCost(this.token, this.tokenTenantId, subscriptionId, { from, to }).catch( + (err) => { + this.log.warn({ err: (err as Error).message, subscriptionId }, "cost query failed"); + return []; + }, + ); + costs.push(...attributeCost(rows, this.tenantId, poolByResourceGroup)); + } + return { costs }; + } + + /** Probe the permissions this connector needs and explain what to fix. */ + async diagnose(opts: { subscriptionIds: string[] }): Promise { + const checks: DiagnosticCheck[] = []; + + let subscriptions: { subscriptionId: string; displayName: string }[] = []; + try { + subscriptions = await this.arm.listSubscriptions(); + checks.push( + check( + "arm-token", + "Sign in to Azure Resource Manager", + "pass", + `${subscriptions.length} subscription(s) visible`, + ), + ); + } catch (err) { + const e = err as ConnectorError; + checks.push( + check( + "arm-token", + "Sign in to Azure Resource Manager", + "fail", + e.message, + e.hint ?? "Check the tenant id, client id and secret.", + ), + ); + return checks; + } + + const subs = opts.subscriptionIds.length + ? opts.subscriptionIds + : subscriptions.map((s) => s.subscriptionId); + if (subs.length === 0) { + checks.push( + check( + "subscriptions", + "Find subscriptions", + "fail", + "No subscriptions are visible to this identity", + "Assign at least Reader on the subscriptions that contain AVD, or select them explicitly.", + ), + ); + return checks; + } + + let pools: { resourceId: string; name: string }[] = []; + try { + pools = (await Promise.all(subs.slice(0, 10).map((sub) => this.arm.listHostPools(sub)))).flat(); + checks.push( + check( + "host-pools", + "Read AVD host pools", + pools.length > 0 ? "pass" : "warn", + pools.length > 0 + ? `${pools.length} host pool(s) found` + : "No host pools found in these subscriptions", + pools.length > 0 ? undefined : "Assign Desktop Virtualization Reader where the host pools live.", + ), + ); + } catch (err) { + const e = err as ConnectorError; + checks.push( + check( + "host-pools", + "Read AVD host pools", + "fail", + e.message, + "Assign the Desktop Virtualization Reader role.", + ), + ); + } + + if (pools.length > 0) { + try { + await this.arm.listSessionHosts(pools[0]!.resourceId); + checks.push(check("session-hosts", "Read session hosts", "pass")); + } catch (err) { + checks.push( + check( + "session-hosts", + "Read session hosts", + "fail", + (err as Error).message, + "Desktop Virtualization Reader covers session hosts; check it is assigned at the resource group or subscription.", + ), + ); + } + + const workspaceIds = await this.arm.diagnosticWorkspaces(pools[0]!.resourceId).catch(() => []); + if (workspaceIds.length === 0) { + checks.push( + check( + "diagnostics", + "AVD diagnostics sent to Log Analytics", + "warn", + "No diagnostic settings found on the first host pool", + "Without diagnostics there are no connections, errors or latency to chart. Enable diagnostic settings on each host pool and send them to a Log Analytics workspace.", + ), + ); + } else { + checks.push( + check( + "diagnostics", + "AVD diagnostics sent to Log Analytics", + "pass", + `${workspaceIds.length} workspace(s)`, + ), + ); + const info = await this.arm.workspaceCustomerId(workspaceIds[0]!).catch(() => null); + if (!info) { + checks.push( + check( + "workspace-read", + "Read the Log Analytics workspace", + "fail", + "Could not read the workspace resource", + "Assign Log Analytics Reader on the workspace (or its resource group).", + ), + ); + } else { + try { + const probe = await kql<{ Table: string; Rows: number }>( + this.token, + this.tokenTenantId, + info.workspaceId, + TABLE_PROBE_QUERY, + { from: new Date(Date.now() - 7 * 86_400_000), to: new Date() }, + ); + const found = probe.filter((row) => Number(row.Rows) > 0).map((row) => row.Table); + checks.push( + check( + "la-query", + "Query AVD logs", + found.includes("WVDConnections") ? "pass" : "warn", + found.length > 0 + ? `Tables with data: ${found.join(", ")}` + : "No AVD tables have data in the last 7 days", + found.includes("WVDConnections") + ? undefined + : "Enable the Connection, Error, Checkpoint and NetworkData categories in the host pool diagnostic settings.", + ), + ); + } catch (err) { + checks.push( + check( + "la-query", + "Query AVD logs", + "fail", + (err as Error).message, + "Assign Log Analytics Reader so this app can run queries against the workspace.", + ), + ); + } + } + } + } + + try { + await queryDailyCost(this.token, this.tokenTenantId, subs[0]!, { + from: new Date(Date.now() - 3 * 86_400_000), + to: new Date(), + }); + checks.push(check("cost", "Read cost data", "pass")); + } catch (err) { + checks.push( + check( + "cost", + "Read cost data", + "warn", + (err as Error).message, + "Optional. Assign Cost Management Reader on the subscription to chart spend.", + ), + ); + } + + return checks; + } + + private async hostToPoolMap(hostPoolIds: string[]): Promise> { + const map = new Map(); + for (const hostPoolId of hostPoolIds) { + const hosts = await this.arm.listSessionHosts(hostPoolId).catch(() => []); + for (const host of hosts) map.set(shortHostName(host.name), hostPoolId); + } + return map; + } + + private async resourceGroupToPoolMap(hostPoolIds: string[]): Promise> { + const map = new Map(); + for (const hostPoolId of hostPoolIds) { + const hosts = await this.arm.listSessionHosts(hostPoolId).catch(() => []); + for (const host of hosts) { + const rg = host.vmResourceId ? parseResourceId(host.vmResourceId).resourceGroup : undefined; + if (rg) map.set(rg.toLowerCase(), hostPoolId); + } + // The host pool's own resource group counts too (it holds the pool and app groups). + const poolRg = parseResourceId(hostPoolId).resourceGroup; + if (poolRg && !map.has(poolRg.toLowerCase())) map.set(poolRg.toLowerCase(), hostPoolId); + } + return map; + } + + private logQueryFailure(err: unknown, _workspaceId: string, table: string): T[] { + this.log.warn({ err: (err as Error).message, table }, "log analytics query failed"); + return []; + } +} + +export function hostHealthFrom( + hosts: SessionHostRecord[], + hostPoolId: string, + tenantId: string, + ts: string, +): HostHealthRecord[] { + return hosts.map((host) => ({ + ts, + tenantId, + hostPoolId, + sessionHost: host.name, + status: host.status, + healthy: isHealthy(host.status), + drain: !host.allowNewSession, + sessions: host.sessions, + })); +} + +/** Snapshots line up on 5-minute boundaries so concurrency sums across pools. */ +export function roundToFiveMinutes(date: Date): Date { + const ms = 5 * 60_000; + return new Date(Math.floor(date.getTime() / ms) * ms); +} + +/** Re-read a little of the previous window so late-arriving rows are not missed. */ +export function logWindow(input: SyncInput): { from: Date; to: Date } { + const OVERLAP_MS = 2 * 3600_000; + const from = input.since + ? new Date(input.since.getTime() - OVERLAP_MS) + : new Date(input.until.getTime() - input.backfillDays * 86_400_000); + return { from, to: input.until }; +} + +function nonNegative(value: unknown): number | null { + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) && n >= 0 ? Math.round(n) : null; +} + +export function hashKey(parts: string[]): string { + return createHash("sha1").update(parts.join("|")).digest("base64url").slice(0, 27); +} diff --git a/packages/connectors/src/azure/cost.ts b/packages/connectors/src/azure/cost.ts new file mode 100644 index 0000000..8386505 --- /dev/null +++ b/packages/connectors/src/azure/cost.ts @@ -0,0 +1,100 @@ +import type { CostRecord } from "@dashflow/core"; +import { httpJson, type TokenFn } from "../http"; +import { API } from "./arm"; +import { SCOPES } from "./credentials"; + +interface CostQueryResponse { + properties?: { + columns?: { name: string; type: string }[]; + rows?: unknown[][]; + }; +} + +/** + * Daily actual cost per resource group and meter category. Cost Management has no notion of + * a host pool, so the caller maps resource groups to host pools using the session hosts' + * VM resource ids — good enough for "what does this pool cost", and honest about it in the UI. + */ +export async function queryDailyCost( + token: TokenFn, + tenantId: string, + subscriptionId: string, + window: { from: Date; to: Date }, +): Promise<{ day: string; resourceGroup: string; meterCategory: string; cost: number; currency: string }[]> { + const accessToken = await token(SCOPES.arm, tenantId); + const response = await httpJson( + `https://management.azure.com/subscriptions/${subscriptionId}/providers/Microsoft.CostManagement/query?api-version=${API.costManagement}`, + accessToken, + { + method: "POST", + timeoutMs: 120_000, + body: { + type: "ActualCost", + timeframe: "Custom", + timePeriod: { from: window.from.toISOString(), to: window.to.toISOString() }, + dataset: { + granularity: "Daily", + aggregation: { totalCost: { name: "Cost", function: "Sum" } }, + grouping: [ + { type: "Dimension", name: "ResourceGroupName" }, + { type: "Dimension", name: "MeterCategory" }, + ], + }, + }, + }, + ); + + const columns = response.properties?.columns ?? []; + const rows = response.properties?.rows ?? []; + const index = (name: string) => columns.findIndex((c) => c.name.toLowerCase() === name.toLowerCase()); + const iCost = index("Cost"); + const iDate = index("UsageDate"); + const iRg = index("ResourceGroupName"); + const iMeter = index("MeterCategory"); + const iCurrency = index("Currency"); + + return rows + .map((row) => { + // UsageDate comes back as the number 20260917. + const rawDate = String(row[iDate] ?? ""); + const day = + rawDate.length === 8 + ? `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}` + : rawDate.slice(0, 10); + return { + day, + resourceGroup: String(row[iRg] ?? "").toLowerCase(), + meterCategory: String(row[iMeter] ?? "Other") || "Other", + cost: Number(row[iCost] ?? 0), + currency: String(row[iCurrency] ?? "USD"), + }; + }) + .filter((row) => row.day.length === 10 && Number.isFinite(row.cost)); +} + +/** Turn resource-group level cost into per-host-pool cost using a resource group → pool map. */ +export function attributeCost( + rows: { day: string; resourceGroup: string; meterCategory: string; cost: number; currency: string }[], + tenantId: string, + poolByResourceGroup: Map, +): CostRecord[] { + const out = new Map(); + for (const row of rows) { + const hostPoolId = poolByResourceGroup.get(row.resourceGroup) ?? null; + const key = `${tenantId}|${row.day}|${hostPoolId ?? "-"}|${row.meterCategory}`; + const existing = out.get(key); + if (existing) { + existing.cost += row.cost; + continue; + } + out.set(key, { + date: row.day, + tenantId, + hostPoolId, + meterCategory: row.meterCategory, + cost: row.cost, + currency: row.currency, + }); + } + return [...out.values()]; +} diff --git a/packages/connectors/src/azure/credentials.ts b/packages/connectors/src/azure/credentials.ts new file mode 100644 index 0000000..4b0f0e6 --- /dev/null +++ b/packages/connectors/src/azure/credentials.ts @@ -0,0 +1,133 @@ +import type { CredentialMode } from "@dashflow/core"; +import type { TokenFn } from "../http"; +import { ConnectorError } from "../types"; + +export const SCOPES = { + arm: "https://management.azure.com/.default", + logAnalytics: "https://api.loganalytics.io/.default", + graph: "https://graph.microsoft.com/.default", + partnerCenter: "https://api.partnercenter.microsoft.com/.default", +} as const; + +interface CachedToken { + token: string; + expiresOn: number; +} + +/** + * Builds a token function for an Entra app or managed identity. Tokens are cached per + * (tenant, scope) until 5 minutes before expiry — a multi-tenant sync asks for a lot of them. + */ +export function createTokenFn(opts: { + mode: CredentialMode; + tenantId: string; + clientId?: string; + secret: string | null; + /** Allow requesting tokens for other tenants (Partner Center / multi-tenant apps). */ + allowOtherTenants?: boolean; +}): TokenFn { + const cache = new Map(); + + return async (scope, tenantId) => { + const targetTenant = (opts.allowOtherTenants ? tenantId : undefined) ?? opts.tenantId; + const key = `${targetTenant}|${scope}`; + const cached = cache.get(key); + if (cached && cached.expiresOn - 300_000 > Date.now()) return cached.token; + + const credential = await buildCredential({ ...opts, tenantId: targetTenant }); + const result = await credential.getToken(scope); + if (!result) throw new ConnectorError(`Could not get a token for ${scope}`); + cache.set(key, { token: result.token, expiresOn: result.expiresOnTimestamp }); + return result.token; + }; +} + +async function buildCredential(opts: { + mode: CredentialMode; + tenantId: string; + clientId?: string; + secret: string | null; +}) { + const identity = await import("@azure/identity"); + switch (opts.mode) { + case "managed-identity": + return new identity.ManagedIdentityCredential(opts.clientId ? { clientId: opts.clientId } : {}); + case "client-secret": + if (!opts.clientId) + throw new ConnectorError("A client id is required for client secret authentication"); + if (!opts.secret) throw new ConnectorError("The client secret for this connection is missing"); + return new identity.ClientSecretCredential(opts.tenantId, opts.clientId, opts.secret); + case "client-certificate": { + if (!opts.clientId) throw new ConnectorError("A client id is required for certificate authentication"); + if (!opts.secret) throw new ConnectorError("The certificate for this connection is missing"); + return new identity.ClientCertificateCredential(opts.tenantId, opts.clientId, { + certificate: opts.secret, + }); + } + } +} + +/** + * Secure Application Model: exchange a partner admin's refresh token for an access token in + * a customer tenant. This is how GDAP delegated access works — no per-customer secret exists. + */ +export function createRefreshTokenFn(opts: { + clientId: string; + clientSecret: string; + refreshToken: string; + partnerTenantId: string; + onRotate?: (token: string) => Promise; + fetchImpl?: typeof fetch; +}): TokenFn { + const cache = new Map(); + let refreshToken = opts.refreshToken; + const doFetch = opts.fetchImpl ?? fetch; + + return async (scope, tenantId) => { + const tenant = tenantId ?? opts.partnerTenantId; + const key = `${tenant}|${scope}`; + const cached = cache.get(key); + if (cached && cached.expiresOn - 300_000 > Date.now()) return cached.token; + + const body = new URLSearchParams({ + client_id: opts.clientId, + client_secret: opts.clientSecret, + grant_type: "refresh_token", + refresh_token: refreshToken, + scope, + }); + const response = await doFetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + const payload = (await response.json().catch(() => ({}))) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + error?: string; + error_description?: string; + }; + if (!response.ok || !payload.access_token) { + throw new ConnectorError( + `Token exchange for tenant ${tenant} failed: ${payload.error ?? response.status} ${( + payload.error_description ?? "" + ).slice(0, 200)}`, + { + status: response.status, + hint: "The partner consent may have expired, or this customer has no active GDAP relationship granting the roles this app needs. Re-run consent from the connection page.", + }, + ); + } + if (payload.refresh_token && payload.refresh_token !== refreshToken) { + refreshToken = payload.refresh_token; + await opts.onRotate?.(payload.refresh_token); + } + const token = { + token: payload.access_token, + expiresOn: Date.now() + (payload.expires_in ?? 3600) * 1000, + }; + cache.set(key, token); + return token.token; + }; +} diff --git a/packages/connectors/src/azure/logAnalytics.ts b/packages/connectors/src/azure/logAnalytics.ts new file mode 100644 index 0000000..242f3db --- /dev/null +++ b/packages/connectors/src/azure/logAnalytics.ts @@ -0,0 +1,147 @@ +import { httpJson, type TokenFn } from "../http"; +import { ConnectorError } from "../types"; +import { SCOPES } from "./credentials"; + +const LA_BASE = "https://api.loganalytics.io/v1"; + +interface LaTable { + name: string; + columns: { name: string; type: string }[]; + rows: unknown[][]; +} + +/** Run KQL against one workspace and return rows as objects. */ +export async function kql>( + token: TokenFn, + tenantId: string, + workspaceId: string, + query: string, + timespan: { from: Date; to: Date }, +): Promise { + const accessToken = await token(SCOPES.logAnalytics, tenantId); + const body = { + query, + timespan: `${timespan.from.toISOString()}/${timespan.to.toISOString()}`, + }; + const response = await httpJson<{ tables?: LaTable[]; error?: { message?: string } }>( + `${LA_BASE}/workspaces/${workspaceId}/query`, + accessToken, + { method: "POST", body, timeoutMs: 180_000 }, + ); + if (response.error) + throw new ConnectorError(`Log Analytics query failed: ${response.error.message ?? "unknown"}`); + const table = response.tables?.find((t) => t.name === "PrimaryResult") ?? response.tables?.[0]; + if (!table) return []; + return table.rows.map((row) => { + const out: Record = {}; + table.columns.forEach((column, index) => { + out[column.name] = row[index]; + }); + return out as T; + }); +} + +/** Which AVD tables exist and have recent data — used by the connection diagnostics. */ +export const TABLE_PROBE_QUERY = ` +let tables = dynamic(["WVDConnections","WVDErrors","WVDCheckpoints","WVDAgentHealthStatus","WVDConnectionNetworkData","Perf"]); +union isfuzzy=true + (WVDConnections | where TimeGenerated > ago(7d) | summarize Rows = count() | extend Table = "WVDConnections"), + (WVDErrors | where TimeGenerated > ago(7d) | summarize Rows = count() | extend Table = "WVDErrors"), + (WVDConnectionNetworkData | where TimeGenerated > ago(7d) | summarize Rows = count() | extend Table = "WVDConnectionNetworkData"), + (WVDAgentHealthStatus | where TimeGenerated > ago(7d) | summarize Rows = count() | extend Table = "WVDAgentHealthStatus"), + (Perf | where TimeGenerated > ago(7d) | summarize Rows = count() | extend Table = "Perf") +| project Table, Rows`; + +/** + * One row per connection attempt. AVD writes several WVDConnections rows per connection + * (Started / Connected / Completed) sharing a CorrelationId, so we fold them into one fact: + * time to connect, session length, and whether it ever reached "connected". + * + * Rows are re-read with an overlap each run and upserted by CorrelationId, so a connection + * that is still in progress at the end of a window gets corrected by the next run. + */ +export const CONNECTIONS_QUERY = ` +let windowEnd = _endTime; +WVDConnections +| summarize + StartTime = minif(TimeGenerated, State == "Started"), + ConnectTime = minif(TimeGenerated, State == "Connected"), + EndTime = maxif(TimeGenerated, State == "Completed"), + UserName = take_any(UserName), + SessionHostName = take_anyif(SessionHostName, isnotempty(SessionHostName)), + ClientOS = take_anyif(ClientOS, isnotempty(ClientOS)), + ClientType = take_anyif(ClientType, isnotempty(ClientType)), + ClientVersion = take_anyif(ClientVersion, isnotempty(ClientVersion)), + GatewayRegion = take_anyif(GatewayRegion, isnotempty(GatewayRegion)), + PoolId = take_any(_ResourceId) + by CorrelationId +| where isnotnull(ConnectTime) or isnotnull(EndTime) or StartTime < windowEnd - 15m +| extend ConnectMs = iff(isnotnull(ConnectTime) and isnotnull(StartTime), toint((ConnectTime - StartTime) / 1ms), int(null)) +| extend DurationSec = iff(isnotnull(EndTime) and isnotnull(ConnectTime), toint((EndTime - ConnectTime) / 1s), int(null)) +| extend ConnState = iff(isnotnull(ConnectTime), "connected", "failed") +| join kind=leftouter ( + WVDConnectionNetworkData + | summarize RttMs = toint(percentile(EstRoundTripTimeInMs, 50)), BandwidthKbps = toint(avg(EstAvailableBandwidthKBps)) + by CorrelationId + ) on CorrelationId +| project CorrelationId, Ts = coalesce(StartTime, ConnectTime, EndTime), UserName, SessionHostName, + ClientOS, ClientType, ClientVersion, GatewayRegion, ConnState, ConnectMs, DurationSec, RttMs, BandwidthKbps, PoolId +| where isnotnull(Ts) +| take 200000`; + +export const ERRORS_QUERY = ` +WVDErrors +| extend Symbolic = tostring(column_ifexists("CodeSymbolic", "")) +| project TimeGenerated, CorrelationId, Code = iff(isempty(Symbolic), tostring(Code), Symbolic), + Message = substring(tostring(Message), 0, 500), Source = tostring(Source), + ServiceError = tobool(column_ifexists("ServiceError", false)), PoolId = _ResourceId +| take 200000`; + +export const PERF_QUERY = ` +Perf +| where (ObjectName == "Processor" and CounterName == "% Processor Time" and InstanceName == "_Total") + or (ObjectName == "Memory" and CounterName == "Available MBytes") +| summarize CpuPct = avgif(CounterValue, CounterName == "% Processor Time"), + MemAvailableMb = avgif(CounterValue, CounterName == "Available MBytes") + by Computer, Ts = bin(TimeGenerated, 1h) +| where isnotnull(CpuPct) or isnotnull(MemAvailableMb) +| take 200000`; + +export interface ConnectionRow extends Record { + CorrelationId: string; + Ts: string; + UserName?: string; + SessionHostName?: string; + ClientOS?: string; + ClientType?: string; + ClientVersion?: string; + GatewayRegion?: string; + ConnState: string; + ConnectMs?: number | null; + DurationSec?: number | null; + RttMs?: number | null; + BandwidthKbps?: number | null; + PoolId?: string; +} + +export interface ErrorRow extends Record { + TimeGenerated: string; + CorrelationId?: string; + Code?: string; + Message?: string; + Source?: string; + ServiceError?: boolean; + PoolId?: string; +} + +export interface PerfRow extends Record { + Computer: string; + Ts: string; + CpuPct?: number | null; + MemAvailableMb?: number | null; +} + +/** `_endTime` is not a KQL builtin, so substitute it before sending. */ +export function withWindow(query: string, window: { from: Date; to: Date }): string { + return query.replace(/_endTime/g, `datetime(${window.to.toISOString()})`); +} diff --git a/packages/connectors/src/connectors.test.ts b/packages/connectors/src/connectors.test.ts new file mode 100644 index 0000000..3c38941 --- /dev/null +++ b/packages/connectors/src/connectors.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createArmClient } from "./azure/arm"; +import { hashKey, isHealthy, logWindow, roundToFiveMinutes } from "./azure/collectors"; +import { attributeCost } from "./azure/cost"; +import { createNerdioConnector, normalizeNerdioHostPool, normalizeNerdioSessionHost } from "./nerdio"; +import { buildConsentUrl } from "./partner-center"; +import type { ConnectorLogger, SyncInput } from "./types"; + +const silentLog: ConnectorLogger = { debug() {}, info() {}, warn() {}, error() {} }; +const token = async () => "fake-token"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** Stub the global fetch with a URL → JSON map, and record what was requested. */ +function stubFetch(routes: Record) { + const calls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calls.push(url); + const match = Object.keys(routes).find((key) => url.includes(key)); + if (!match) return new Response("not found", { status: 404, statusText: "Not Found" }); + return new Response(JSON.stringify(routes[match]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + return calls; +} + +describe("host health rules", () => { + test("a shut down host is not a fault, an unavailable one is", () => { + expect(isHealthy("Available")).toBe(true); + expect(isHealthy("Shutdown")).toBe(true); + expect(isHealthy("Unavailable(VMNotRunning)")).toBe(true); + expect(isHealthy("Unavailable")).toBe(false); + expect(isHealthy("NeedsAssistance")).toBe(false); + expect(isHealthy("UpgradeFailed")).toBe(false); + }); +}); + +describe("incremental log windows", () => { + const until = new Date("2026-09-17T12:00:00Z"); + const base: SyncInput = { + stream: "logs", + tenantId: "t", + subscriptionIds: [], + workspaces: [], + hostPoolIds: [], + until, + backfillDays: 30, + }; + + test("the first run reaches back over the backfill window", () => { + expect(logWindow(base).from.toISOString()).toBe("2026-08-18T12:00:00.000Z"); + }); + + test("later runs overlap the previous window so late rows are not lost", () => { + const since = new Date("2026-09-17T11:00:00Z"); + expect(logWindow({ ...base, since }).from.toISOString()).toBe("2026-09-17T09:00:00.000Z"); + }); + + test("snapshots land on five minute boundaries so pools add up", () => { + expect(roundToFiveMinutes(new Date("2026-09-17T12:07:43Z")).toISOString()).toBe( + "2026-09-17T12:05:00.000Z", + ); + }); + + test("fact keys are stable and short", () => { + const key = hashKey(["tenant", "corr", "2026-09-17T12:00:00Z", "Code", "RDAgent"]); + expect(key).toBe(hashKey(["tenant", "corr", "2026-09-17T12:00:00Z", "Code", "RDAgent"])); + expect(key).not.toBe(hashKey(["tenant", "corr", "2026-09-17T12:00:00Z", "Other", "RDAgent"])); + expect(key.length).toBeLessThanOrEqual(27); + }); +}); + +describe("ARM normalization", () => { + test("host pools become normalized records with lowercase resource ids", async () => { + stubFetch({ + "Microsoft.DesktopVirtualization/hostPools": { + value: [ + { + id: "/subscriptions/SUB/resourceGroups/RG/providers/Microsoft.DesktopVirtualization/hostPools/HP-1", + name: "HP-1", + location: "eastus", + properties: { + friendlyName: "Sales desktops", + hostPoolType: "Pooled", + loadBalancerType: "BreadthFirst", + maxSessionLimit: 8, + startVMOnConnect: true, + }, + }, + ], + }, + }); + const arm = createArmClient(token, "tenant-1"); + const [pool] = await arm.listHostPools("sub-1"); + expect(pool!.resourceId).toBe( + "/subscriptions/sub/resourcegroups/rg/providers/microsoft.desktopvirtualization/hostpools/hp-1", + ); + expect(pool!.friendlyName).toBe("Sales desktops"); + expect(pool!.maxSessions).toBe(8); + expect(pool!.source).toBe("azure"); + expect(pool!.tenantId).toBe("tenant-1"); + }); + + test("session host names lose the host pool prefix", async () => { + stubFetch({ + sessionHosts: { + value: [ + { + id: "/subscriptions/s/resourceGroups/r/providers/Microsoft.DesktopVirtualization/hostPools/hp/sessionHosts/hp/vm-0.corp.example", + name: "hp/vm-0.corp.example", + properties: { + status: "Available", + allowNewSession: false, + sessions: 3, + agentVersion: "1.0.0", + resourceId: + "/subscriptions/S/resourceGroups/R/providers/Microsoft.Compute/virtualMachines/vm-0", + }, + }, + ], + }, + }); + const arm = createArmClient(token, "tenant-1"); + const [host] = await arm.listSessionHosts("/subscriptions/s/hostpools/hp"); + expect(host!.name).toBe("vm-0.corp.example"); + expect(host!.allowNewSession).toBe(false); + expect(host!.sessions).toBe(3); + expect(host!.vmResourceId).toContain("microsoft.compute/virtualmachines/vm-0"); + }); + + test("throttling is retried and does not surface as an error", async () => { + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL) => { + calls++; + if (calls === 1) { + return new Response("{}", { + status: 429, + statusText: "Too Many Requests", + headers: { "retry-after": "0" }, + }); + } + return new Response(JSON.stringify({ value: [] }), { status: 200 }); + }) as typeof fetch; + const arm = createArmClient(token, "tenant-1"); + expect(await arm.listHostPools("sub-1")).toEqual([]); + expect(calls).toBe(2); + }); +}); + +describe("cost attribution", () => { + test("resource group cost is folded into host pools and unmatched cost is kept", () => { + const rows = [ + { + day: "2026-09-16", + resourceGroup: "rg-avd", + meterCategory: "Virtual Machines", + cost: 10, + currency: "USD", + }, + { + day: "2026-09-16", + resourceGroup: "rg-avd", + meterCategory: "Virtual Machines", + cost: 5, + currency: "USD", + }, + { day: "2026-09-16", resourceGroup: "rg-other", meterCategory: "Storage", cost: 2, currency: "USD" }, + ]; + const result = attributeCost(rows, "tenant-1", new Map([["rg-avd", "pool-1"]])); + expect(result).toHaveLength(2); + const pooled = result.find((row) => row.hostPoolId === "pool-1")!; + expect(pooled.cost).toBe(15); + expect(result.find((row) => row.hostPoolId === null)!.cost).toBe(2); + }); +}); + +describe("Partner Center consent", () => { + test("the consent url asks for offline access and the partner scopes", () => { + const url = new URL( + buildConsentUrl({ + partnerTenantId: "00000000-0000-0000-0000-000000000001", + clientId: "00000000-0000-0000-0000-000000000002", + redirectUri: "https://example.invalid/api/connections/consent/callback", + state: "state-1", + }), + ); + expect(url.origin).toBe("https://login.microsoftonline.com"); + expect(url.pathname).toContain("00000000-0000-0000-0000-000000000001"); + const scope = url.searchParams.get("scope")!; + expect(scope).toContain("offline_access"); + expect(scope).toContain("api.partnercenter.microsoft.com/user_impersonation"); + expect(scope).toContain("management.azure.com/user_impersonation"); + expect(url.searchParams.get("state")).toBe("state-1"); + }); +}); + +describe("Nerdio connector", () => { + const config = { + type: "nerdio" as const, + edition: "msp" as const, + baseUrl: "https://nerdio.invalid", + tenantId: "00000000-0000-0000-0000-000000000001", + clientId: "00000000-0000-0000-0000-000000000002", + scope: "api://00000000-0000-0000-0000-000000000003/.default", + credentialMode: "client-secret" as const, + endpoints: {}, + }; + + test("a host pool without an ARM id gets one built from subscription, resource group and name", () => { + const pool = normalizeNerdioHostPool( + { + name: "hp-contoso", + subscriptionId: "00000000-0000-0000-0000-0000000000ff", + resourceGroup: "RG-AVD", + maxSessionLimit: 6, + autoScaleEnabled: true, + region: "eastus", + }, + { tenantKey: "00000000-0000-0000-0000-00000000000a" }, + )!; + // Matching Azure's id exactly is what lets Nerdio and Azure data merge on one row. + expect(pool.resourceId).toBe( + "/subscriptions/00000000-0000-0000-0000-0000000000ff/resourcegroups/rg-avd/providers/microsoft.desktopvirtualization/hostpools/hp-contoso", + ); + expect(pool.maxSessions).toBe(6); + expect(pool.autoscaleEnabled).toBe(true); + expect(pool.location).toBe("eastus"); + expect(pool.source).toBe("nerdio"); + }); + + test("an explicit ARM id wins and is lowercased", () => { + const pool = normalizeNerdioHostPool( + { + name: "HP", + armId: "/subscriptions/S/resourceGroups/R/providers/Microsoft.DesktopVirtualization/hostPools/HP", + }, + { tenantKey: "t" }, + )!; + expect(pool.resourceId).toBe( + "/subscriptions/s/resourcegroups/r/providers/microsoft.desktopvirtualization/hostpools/hp", + ); + }); + + test("a pool with nothing to build an ARM id from is skipped rather than guessed at", () => { + expect(normalizeNerdioHostPool({ name: "hp-orphan" }, { tenantKey: "t" })).toBeNull(); + expect( + normalizeNerdioHostPool({ subscriptionId: "s", resourceGroup: "r" }, { tenantKey: "t" }), + ).toBeNull(); + }); + + test("session hosts are keyed under their pool and tolerate alternate field names", () => { + const host = normalizeNerdioSessionHost( + { + hostName: "hp/vm-3.corp.example", + sessionCount: 2, + healthStatus: "Available", + allowNewSessions: false, + }, + { resourceId: "/subscriptions/s/hostpools/hp", tenantId: "t" }, + ); + expect(host.name).toBe("vm-3"); + expect(host.hostPoolId).toBe("/subscriptions/s/hostpools/hp"); + expect(host.sessions).toBe(2); + expect(host.status).toBe("Available"); + expect(host.allowNewSession).toBe(false); + }); + + test("a missing secret fails the test with an actionable message", async () => { + const connector = createNerdioConnector(config, { log: silentLog, secret: null }); + const checks = await connector.test(); + expect(checks[0]!.status).toBe("fail"); + expect(checks[0]!.fix).toContain("client secret"); + }); +}); diff --git a/packages/connectors/src/demo.ts b/packages/connectors/src/demo.ts new file mode 100644 index 0000000..d0cea1d --- /dev/null +++ b/packages/connectors/src/demo.ts @@ -0,0 +1,61 @@ +import type { DiagnosticCheck, Discovery, SyncBatch } from "@dashflow/core"; +import { demoBatch, demoTenants } from "@dashflow/demo"; +import { type Connector, type ConnectorContext, check, type SyncInput } from "./types"; + +interface DemoConfig { + type: "demo"; + tenants: number; +} + +/** Fully synthetic connector so the whole pipeline can be exercised without an Azure tenant. */ +export function createDemoConnector(config: DemoConfig, _ctx: ConnectorContext): Connector { + const tenants = demoTenants(config.tenants); + + return { + type: "demo", + + async test(): Promise { + return [ + check("demo", "Demo connection", "pass", `${tenants.length} sample customers with synthetic data`), + check( + "demo-note", + "This is not real data", + "warn", + "Everything under this connection is generated locally", + "Add an Azure, Lighthouse, Partner Center or Nerdio connection to chart a real environment.", + ), + ]; + }, + + async discover(): Promise { + return { + tenants: tenants.map((tenant) => ({ + tenantId: tenant.tenantId, + displayName: tenant.displayName, + domain: tenant.domain, + meta: { demo: true }, + })), + subscriptions: tenants.map((tenant) => ({ + tenantId: tenant.tenantId, + subscriptionId: tenant.subscriptionId, + displayName: `${tenant.displayName} — AVD`, + })), + workspaces: tenants.map((tenant) => ({ + tenantId: tenant.tenantId, + resourceId: `/subscriptions/${tenant.subscriptionId}/resourcegroups/rg-avd/providers/microsoft.operationalinsights/workspaces/law-avd`, + workspaceId: tenant.tenantId, + name: "law-avd (demo)", + hostPoolIds: tenant.pools.map((pool) => pool.resourceId), + })), + hostPoolCount: tenants.reduce((sum, tenant) => sum + tenant.pools.length, 0), + }; + }, + + async sync(input: SyncInput): Promise { + const tenant = tenants.find((t) => t.tenantId === input.tenantId); + if (!tenant) return {}; + const from = input.since ?? new Date(input.until.getTime() - input.backfillDays * 86_400_000); + return demoBatch(tenant, input.stream, from, input.until); + }, + }; +} diff --git a/packages/connectors/src/http.ts b/packages/connectors/src/http.ts new file mode 100644 index 0000000..c4dc696 --- /dev/null +++ b/packages/connectors/src/http.ts @@ -0,0 +1,111 @@ +import { ConnectorError } from "./types"; + +export type TokenFn = (scope: string, tenantId?: string) => Promise; + +export interface HttpOptions { + method?: "GET" | "POST"; + body?: unknown; + headers?: Record; + /** Retry budget for 429 and 5xx. */ + attempts?: number; + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +const DEFAULT_ATTEMPTS = 4; +const DEFAULT_TIMEOUT_MS = 90_000; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * One place for retries, throttling and error shaping. Response bodies are never logged; + * only a truncated message reaches the caller, which then reaches the diagnostics UI. + */ +export async function httpJson(url: string, token: string, opts: HttpOptions = {}): Promise { + const attempts = opts.attempts ?? DEFAULT_ATTEMPTS; + const doFetch = opts.fetchImpl ?? fetch; + let lastError: ConnectorError | null = null; + + for (let attempt = 1; attempt <= attempts; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + try { + const response = await doFetch(url, { + method: opts.method ?? "GET", + headers: { + authorization: `Bearer ${token}`, + accept: "application/json", + ...(opts.body !== undefined ? { "content-type": "application/json" } : {}), + ...opts.headers, + }, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + signal: controller.signal, + }); + + if (response.ok) { + if (response.status === 204) return undefined as T; + return (await response.json()) as T; + } + + const text = (await response.text().catch(() => "")).slice(0, 400); + const retryable = response.status === 429 || response.status === 408 || response.status >= 500; + lastError = new ConnectorError(`${response.status} ${response.statusText}: ${text}`, { + status: response.status, + hint: hintFor(response.status), + }); + if (!retryable || attempt === attempts) throw lastError; + + const retryAfter = Number(response.headers.get("retry-after")); + await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500); + } catch (err) { + if (err instanceof ConnectorError) { + if (attempt === attempts) throw err; + continue; + } + lastError = new ConnectorError((err as Error).message, { cause: err }); + if (attempt === attempts) throw lastError; + await sleep(2 ** attempt * 500); + } finally { + clearTimeout(timer); + } + } + throw lastError ?? new ConnectorError("Request failed"); +} + +function hintFor(status: number): string | undefined { + switch (status) { + case 401: + return "The token was rejected. Check the client id, secret and tenant id."; + case 403: + return "Authenticated, but not authorized. Check the role assignments listed in docs/permissions.md."; + case 404: + return "Not found. The resource may not exist, or the identity cannot see it."; + case 429: + return "Throttled by the API. The sync will back off and retry."; + default: + return undefined; + } +} + +/** Follow ARM-style `nextLink` paging and concatenate `value` arrays. */ +export async function armList( + url: string, + token: TokenFn, + scope: string, + tenantId: string, + opts: HttpOptions = {}, +): Promise { + const out: T[] = []; + let next: string | undefined = url; + let pages = 0; + while (next && pages < 100) { + const accessToken = await token(scope, tenantId); + const page: { value?: T[]; nextLink?: string } = await httpJson(next, accessToken, opts); + if (page.value) out.push(...page.value); + next = page.nextLink; + pages++; + } + return out; +} diff --git a/packages/connectors/src/index.ts b/packages/connectors/src/index.ts new file mode 100644 index 0000000..fee32af --- /dev/null +++ b/packages/connectors/src/index.ts @@ -0,0 +1,35 @@ +import type { ConnectionConfig } from "@dashflow/core"; +import { createAzureConnector } from "./azure-direct"; +import { createDemoConnector } from "./demo"; +import { createLighthouseConnector } from "./lighthouse"; +import { createNerdioConnector } from "./nerdio"; +import { createPartnerCenterConnector } from "./partner-center"; +import type { Connector, ConnectorContext } from "./types"; + +export { API } from "./azure/arm"; +export { AzureCollector, hashKey, isHealthy, roundToFiveMinutes } from "./azure/collectors"; +export { SCOPES } from "./azure/credentials"; +export { + NERDIO_DEFAULT_ENDPOINTS, + type NerdioEndpointKey, + normalizeNerdioHostPool, + normalizeNerdioSessionHost, +} from "./nerdio"; +export { buildConsentUrl, PARTNER_CONSENT_SCOPES, redeemConsentCode } from "./partner-center"; +export * from "./types"; + +/** The one place that maps a stored connection to an implementation. */ +export function createConnector(config: ConnectionConfig, ctx: ConnectorContext): Connector { + switch (config.type) { + case "azure": + return createAzureConnector(config, ctx); + case "lighthouse": + return createLighthouseConnector(config, ctx); + case "partner-center": + return createPartnerCenterConnector(config, ctx); + case "nerdio": + return createNerdioConnector(config, ctx); + case "demo": + return createDemoConnector(config, ctx); + } +} diff --git a/packages/connectors/src/lighthouse.ts b/packages/connectors/src/lighthouse.ts new file mode 100644 index 0000000..4254c7c --- /dev/null +++ b/packages/connectors/src/lighthouse.ts @@ -0,0 +1,125 @@ +import type { DiagnosticCheck, Discovery, LighthouseConfig, SyncBatch } from "@dashflow/core"; +import { AzureCollector } from "./azure/collectors"; +import { createTokenFn } from "./azure/credentials"; +import { type Connector, type ConnectorContext, check, type SyncInput } from "./types"; + +/** + * Azure Lighthouse: one identity in the managing tenant already has RBAC on every delegated + * subscription, so tokens always come from the managing tenant. Customers are discovered from + * the subscriptions themselves — each delegated subscription reports its own home tenant. + */ +export function createLighthouseConnector(config: LighthouseConfig, ctx: ConnectorContext): Connector { + const token = createTokenFn({ + mode: config.credentialMode, + tenantId: config.tenantId, + clientId: config.clientId, + secret: ctx.secret, + }); + /** Collector used for tenant-agnostic ARM calls (listing subscriptions). */ + const home = new AzureCollector(token, config.tenantId, ctx.log); + const collectorFor = (customerTenantId: string) => + new AzureCollector(token, customerTenantId, ctx.log, config.tenantId); + + async function delegated() { + const subs = await home.arm.listSubscriptions(); + return subs.filter((sub) => { + const isDelegated = sub.managedByTenants.some((t) => t.toLowerCase() === config.tenantId.toLowerCase()); + const isHome = (sub.tenantId ?? "").toLowerCase() === config.tenantId.toLowerCase(); + return isDelegated || (config.includeHomeTenant && isHome); + }); + } + + return { + type: "lighthouse", + + async test(): Promise { + const checks: DiagnosticCheck[] = []; + let subs: Awaited> = []; + try { + subs = await delegated(); + const customers = new Set(subs.map((s) => s.tenantId ?? "unknown")); + checks.push( + check( + "lighthouse-subscriptions", + "Find delegated subscriptions", + subs.length > 0 ? "pass" : "fail", + subs.length > 0 + ? `${subs.length} subscription(s) across ${customers.size} customer tenant(s)` + : "No delegated subscriptions are visible", + subs.length > 0 + ? undefined + : "Onboard customers to Azure Lighthouse and include this app's identity in the delegated authorizations, with at least Reader, Desktop Virtualization Reader and Log Analytics Reader.", + ), + ); + } catch (err) { + checks.push( + check( + "lighthouse-subscriptions", + "Find delegated subscriptions", + "fail", + (err as Error).message, + "Check the managing tenant id, client id and secret.", + ), + ); + return checks; + } + + // Diagnose against the first customer so failures name a real permission problem. + const firstCustomer = subs.find((s) => s.tenantId)?.tenantId; + if (firstCustomer) { + const sample = subs.filter((s) => s.tenantId === firstCustomer).map((s) => s.subscriptionId); + checks.push(...(await collectorFor(firstCustomer).diagnose({ subscriptionIds: sample }))); + } + return checks; + }, + + async discover(): Promise { + const subs = await delegated(); + const byTenant = new Map(); + for (const sub of subs) { + const tenantId = sub.tenantId ?? config.tenantId; + const list = byTenant.get(tenantId) ?? []; + list.push({ subscriptionId: sub.subscriptionId, displayName: sub.displayName }); + byTenant.set(tenantId, list); + } + + const discovery: Discovery = { tenants: [], subscriptions: [], workspaces: [], hostPoolCount: 0 }; + for (const [tenantId, tenantSubs] of byTenant) { + const collector = collectorFor(tenantId); + const names = await collector.arm.listTenants().catch(() => []); + const info = names.find((t) => t.tenantId.toLowerCase() === tenantId.toLowerCase()); + discovery.tenants.push({ + tenantId, + displayName: info?.displayName ?? tenantSubs[0]?.displayName ?? tenantId, + domain: info?.defaultDomain, + meta: { delegated: true, subscriptions: tenantSubs.length }, + }); + discovery.subscriptions.push( + ...tenantSubs.map((sub) => ({ + tenantId, + subscriptionId: sub.subscriptionId, + displayName: sub.displayName, + })), + ); + const rest = await collector.discover(tenantSubs.map((s) => s.subscriptionId)); + discovery.workspaces.push(...rest.workspaces); + discovery.hostPoolCount += rest.hostPoolCount; + } + return discovery; + }, + + async sync(input: SyncInput): Promise { + const collector = collectorFor(input.tenantId); + switch (input.stream) { + case "inventory": + return collector.inventory(input.subscriptionIds); + case "sessions": + return collector.sessions(input.hostPoolIds); + case "logs": + return collector.logs(input); + case "cost": + return collector.cost(input); + } + }, + }; +} diff --git a/packages/connectors/src/nerdio.ts b/packages/connectors/src/nerdio.ts new file mode 100644 index 0000000..ed991d6 --- /dev/null +++ b/packages/connectors/src/nerdio.ts @@ -0,0 +1,432 @@ +import type { + DiagnosticCheck, + Discovery, + HostPoolRecord, + NerdioConfig, + SessionHostRecord, + SyncBatch, +} from "@dashflow/core"; +import { normalizeResourceId } from "@dashflow/core"; +import { hostHealthFrom, isHealthy, roundToFiveMinutes } from "./azure/collectors"; +import { createTokenFn } from "./azure/credentials"; +import { httpJson, type TokenFn } from "./http"; +import { type Connector, type ConnectorContext, ConnectorError, check, type SyncInput } from "./types"; + +/** + * Nerdio Manager exposes a REST API whose paths differ between Manager for MSP (NMM) and + * Manager for Enterprise (NME), and between versions. Rather than guess, the connector ships + * these defaults, then verifies them against the instance's own Swagger document during the + * connection test and tells the user which paths to override (connection config `endpoints`). + * + * Not affiliated with or endorsed by Nerdio. + */ +export const NERDIO_DEFAULT_ENDPOINTS = { + /** MSP only: the customer accounts this instance manages. */ + accounts: "/rest-api/v1/accounts", + hostPools: "/rest-api/v1/host-pool", + sessionHosts: "/rest-api/v1/host-pool/{subscriptionId}/{resourceGroup}/{hostPoolName}/host", + autoscaleConfig: + "/rest-api/v1/host-pool/{subscriptionId}/{resourceGroup}/{hostPoolName}/auto-scale-configuration", + /** Optional: only used when an instance exposes it. */ + costSummary: "", +} as const; + +export type NerdioEndpointKey = keyof typeof NERDIO_DEFAULT_ENDPOINTS; + +const SWAGGER_CANDIDATES = [ + "/swagger/v1/swagger.json", + "/rest-api/swagger/v1/swagger.json", + "/swagger/v1/swagger.yaml", +]; + +/** Compare paths ignoring parameter names: /a/{x}/b matches /a/{id}/b. */ +function pathShape(path: string): string { + return path + .replace(/\{[^}]+\}/g, "{}") + .replace(/\/+$/, "") + .toLowerCase(); +} + +function pick(obj: Record, keys: string[]): T | undefined { + for (const key of keys) { + const value = obj[key]; + if (value !== undefined && value !== null && value !== "") return value as T; + } + return undefined; +} + +function armHostPoolId(subscriptionId: string, resourceGroup: string, name: string): string { + return normalizeResourceId( + `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DesktopVirtualization/hostPools/${name}`, + ); +} + +/** + * Pure normalizers, kept separate from the HTTP client so they can be tested against + * recorded payload shapes without a live Nerdio instance. + */ +export function normalizeNerdioHostPool( + row: Record, + ctx: { tenantKey: string; subscriptionFallback?: string }, +): HostPoolRecord | null { + const name = pick(row, ["name", "hostPoolName", "poolName"]); + const subscriptionId = pick(row, ["subscriptionId", "subscription", "subscriptionGuid"]); + const resourceGroup = pick(row, ["resourceGroup", "resourceGroupName", "rg"]); + const armId = pick(row, ["armId", "resourceId", "id", "hostPoolArmPath"]); + const resourceId = armId?.startsWith("/subscriptions/") + ? normalizeResourceId(armId) + : name && subscriptionId && resourceGroup + ? armHostPoolId(subscriptionId, resourceGroup, name) + : null; + if (!resourceId || !name) return null; + + return { + resourceId, + tenantId: ctx.tenantKey, + subscriptionId: subscriptionId ?? ctx.subscriptionFallback ?? "", + name, + friendlyName: pick(row, ["friendlyName", "displayName"]) ?? null, + location: pick(row, ["location", "region"]) ?? "unknown", + poolType: pick(row, ["hostPoolType", "type", "poolType"]) ?? "Pooled", + loadBalancer: pick(row, ["loadBalancerType"]) ?? null, + maxSessions: Number(pick(row, ["maxSessionLimit", "maxSessions"]) ?? 0) || null, + startVmOnConnect: pick(row, ["startVmOnConnect", "startVMOnConnect"]) ?? null, + validationEnvironment: pick(row, ["validationEnvironment"]) ?? null, + preferredAppGroupType: pick(row, ["preferredAppGroupType"]) ?? null, + autoscaleEnabled: + pick(row, ["autoScaleEnabled", "autoscaleEnabled", "isAutoScaleEnabled"]) ?? null, + source: "nerdio", + }; +} + +export function normalizeNerdioSessionHost( + row: Record, + pool: Pick, +): SessionHostRecord { + const name = String(pick(row, ["name", "hostName", "vmName", "sessionHostName"]) ?? "unknown"); + const short = name.includes("/") ? name.slice(name.indexOf("/") + 1) : name; + const vmId = pick(row, ["vmId", "vmArmId", "resourceId", "armId"]); + return { + resourceId: `${pool.resourceId}/sessionhosts/${short.toLowerCase()}`, + hostPoolId: pool.resourceId, + tenantId: pool.tenantId, + name: short.split(".")[0]!.toLowerCase(), + vmResourceId: vmId ? normalizeResourceId(vmId) : null, + status: String(pick(row, ["status", "healthStatus", "sessionHostStatus"]) ?? "Unknown"), + allowNewSession: pick(row, ["allowNewSession", "allowNewSessions"]) ?? true, + sessions: Number(pick(row, ["sessions", "sessionCount", "activeSessions"]) ?? 0), + agentVersion: pick(row, ["agentVersion"]) ?? null, + osVersion: pick(row, ["osVersion", "imageVersion"]) ?? null, + lastHeartBeat: pick(row, ["lastHeartBeat", "lastHeartbeat"]) ?? null, + updateState: pick(row, ["updateState"]) ?? null, + source: "nerdio", + }; +} + +interface NerdioAccount { + id: string; + name: string; + tenantId?: string; +} + +export function createNerdioConnector(config: NerdioConfig, ctx: ConnectorContext): Connector { + const token: TokenFn = createTokenFn({ + mode: config.credentialMode, + tenantId: config.tenantId, + clientId: config.clientId, + secret: ctx.secret, + }); + const scope = config.scope.endsWith("/.default") + ? config.scope + : `${config.scope.replace(/\/$/, "")}/.default`; + const endpoint = (key: NerdioEndpointKey): string => config.endpoints[key] ?? NERDIO_DEFAULT_ENDPOINTS[key]; + + async function api(path: string, params: Record = {}): Promise { + if (!path) throw new ConnectorError("This Nerdio endpoint is not configured for this instance"); + const filled = Object.entries(params).reduce( + (acc, [key, value]) => acc.replace(`{${key}}`, encodeURIComponent(value)), + path, + ); + const accessToken = await token(scope, config.tenantId); + return httpJson(`${config.baseUrl}${filled}`, accessToken, { + fetchImpl: ctx.fetch, + timeoutMs: 60_000, + }); + } + + function unwrap(payload: unknown): T[] { + if (Array.isArray(payload)) return payload as T[]; + if (payload && typeof payload === "object") { + for (const key of ["items", "value", "data", "results", "hostPools", "hosts", "accounts"]) { + const value = (payload as Record)[key]; + if (Array.isArray(value)) return value as T[]; + } + } + return []; + } + + async function accounts(): Promise { + if (config.edition !== "msp") + return [{ id: "default", name: "Nerdio Manager", tenantId: config.tenantId }]; + const payload = await api(endpoint("accounts")); + return unwrap>(payload).map((row) => ({ + id: String(pick(row, ["id", "accountId", "guid"]) ?? ""), + name: String(pick(row, ["name", "displayName", "accountName"]) ?? "Account"), + tenantId: pick(row, ["tenantId", "aadTenantId", "azureTenantId", "customerTenantId"]), + })); + } + + function tenantKey(account: NerdioAccount): string { + return account.tenantId ?? `nerdio-account-${account.id}`; + } + + async function hostPools(account: NerdioAccount): Promise { + const payload = await api(endpoint("hostPools"), { accountId: account.id }); + const pools: HostPoolRecord[] = []; + for (const row of unwrap>(payload)) { + const pool = normalizeNerdioHostPool(row, { tenantKey: tenantKey(account) }); + if (!pool) { + ctx.log.warn({ account: account.name }, "skipped a Nerdio host pool with no Azure resource id"); + continue; + } + pools.push(pool); + } + return pools; + } + + async function sessionHosts(account: NerdioAccount, pool: HostPoolRecord): Promise { + const parts = pool.resourceId.split("/"); + const payload = await api(endpoint("sessionHosts"), { + accountId: account.id, + subscriptionId: parts[2] ?? pool.subscriptionId, + resourceGroup: parts[4] ?? "", + hostPoolName: pool.name, + }); + return unwrap>(payload).map((row) => normalizeNerdioSessionHost(row, pool)); + } + + async function swaggerPaths(): Promise { + const doFetch = ctx.fetch ?? fetch; + const accessToken = await token(scope, config.tenantId).catch(() => null); + for (const candidate of SWAGGER_CANDIDATES) { + try { + const response = await doFetch(`${config.baseUrl}${candidate}`, { + headers: accessToken ? { authorization: `Bearer ${accessToken}` } : {}, + }); + if (!response.ok) continue; + const text = await response.text(); + if (candidate.endsWith(".json")) { + const document = JSON.parse(text) as { paths?: Record }; + if (document.paths) return Object.keys(document.paths); + } else { + // Minimal YAML scrape: collect top-level keys under "paths:". + const matches = [...text.matchAll(/^\s{2}(\/[^\s:]+):/gm)].map((m) => m[1]!); + if (matches.length > 0) return matches; + } + } catch { + // try the next candidate + } + } + return null; + } + + return { + type: "nerdio", + + async test(): Promise { + const checks: DiagnosticCheck[] = []; + if (!ctx.secret) { + return [ + check( + "credential", + "Nerdio API credential", + "fail", + "No secret is stored", + "Re-enter the client secret.", + ), + ]; + } + try { + await token(scope, config.tenantId); + checks.push(check("token", "Get a token for the Nerdio API", "pass", `scope ${scope}`)); + } catch (err) { + return [ + check( + "token", + "Get a token for the Nerdio API", + "fail", + (err as Error).message, + "Check the tenant id, client id, secret and the API scope from Nerdio Manager → Settings → Integrations → REST API.", + ), + ]; + } + + const paths = await swaggerPaths(); + if (!paths) { + checks.push( + check( + "swagger", + "Read the instance's API definition", + "warn", + "Could not fetch a Swagger document", + "Not fatal — the default paths will be tried. If calls fail, note the correct paths from your instance's Swagger UI and set them as endpoint overrides.", + ), + ); + } else { + const shapes = new Set(paths.map(pathShape)); + const needed: NerdioEndpointKey[] = + config.edition === "msp" + ? ["accounts", "hostPools", "sessionHosts"] + : ["hostPools", "sessionHosts"]; + const missing = needed.filter((key) => !shapes.has(pathShape(endpoint(key)))); + const suggestions = paths.filter((p) => /host-?pool/i.test(p)).slice(0, 8); + checks.push( + check( + "swagger", + "Match API paths against this instance", + missing.length === 0 ? "pass" : "warn", + missing.length === 0 + ? `${paths.length} operations, all required paths present` + : `These configured paths are not in the API definition: ${missing.join(", ")}`, + missing.length === 0 + ? undefined + : `Set endpoint overrides on this connection. Host pool paths this instance does expose: ${suggestions.join(", ") || "none found"}`, + ), + ); + } + + try { + const list = await accounts(); + checks.push( + check( + "accounts", + config.edition === "msp" ? "List Nerdio accounts" : "Reach the Nerdio API", + list.length > 0 ? "pass" : "warn", + `${list.length} account(s)`, + list.length > 0 ? undefined : "The API responded but returned no accounts.", + ), + ); + const first = list[0]; + if (first) { + const pools = await hostPools(first); + checks.push( + check( + "host-pools", + "Read host pools", + pools.length > 0 ? "pass" : "warn", + `${pools.length} host pool(s) in ${first.name}`, + pools.length > 0 + ? undefined + : "No host pools came back. Check the REST API user's scope in Nerdio.", + ), + ); + if (!first.tenantId) { + checks.push( + check( + "tenant-mapping", + "Map accounts to Entra tenants", + "warn", + "Nerdio did not report an Entra tenant id for this account", + "Customers will be listed under a Nerdio account name instead of a tenant. Add an Azure or Lighthouse connection alongside this one to merge the data by ARM resource id.", + ), + ); + } + } + } catch (err) { + const e = err as ConnectorError; + checks.push( + check( + "accounts", + "Call the Nerdio API", + "fail", + e.message, + e.hint ?? "Confirm the base URL and that this app is allowed in Nerdio's REST API settings.", + ), + ); + } + + checks.push( + check( + "cost", + "Cost and savings from Nerdio", + config.endpoints.costSummary ? "pass" : "skip", + config.endpoints.costSummary + ? "Using the configured cost endpoint" + : "No cost endpoint configured — add an Azure connection for spend, or set a costSummary override", + ), + ); + return checks; + }, + + async discover(): Promise { + const list = await accounts(); + const discovery: Discovery = { tenants: [], subscriptions: [], workspaces: [], hostPoolCount: 0 }; + for (const account of list) { + const pools = await hostPools(account).catch((err) => { + ctx.log.warn({ err: (err as Error).message, account: account.name }, "could not list host pools"); + return []; + }); + discovery.tenants.push({ + tenantId: tenantKey(account), + displayName: account.name, + meta: { nerdioAccountId: account.id, hostPools: pools.length, source: "nerdio" }, + }); + const subs = new Set(pools.map((pool) => pool.subscriptionId).filter(Boolean)); + discovery.subscriptions.push( + ...[...subs].map((subscriptionId) => ({ + tenantId: tenantKey(account), + subscriptionId, + displayName: subscriptionId, + })), + ); + discovery.hostPoolCount += pools.length; + } + return discovery; + }, + + async sync(input: SyncInput): Promise { + const list = await accounts(); + const account = list.find((a) => tenantKey(a) === input.tenantId); + if (!account) return {}; + + if (input.stream === "logs" || input.stream === "cost") { + // Nerdio does not expose connection-level telemetry; those streams come from Azure. + return {}; + } + + const pools = await hostPools(account); + const batch: SyncBatch = { hostPools: pools, sessionHosts: [], hostHealth: [], sessionSnapshots: [] }; + const ts = roundToFiveMinutes(new Date()).toISOString(); + + for (const pool of pools) { + const hosts = await sessionHosts(account, pool).catch((err) => { + ctx.log.warn( + { err: (err as Error).message, pool: pool.name }, + "could not list Nerdio session hosts", + ); + return [] as SessionHostRecord[]; + }); + batch.sessionHosts!.push(...hosts); + batch.hostHealth!.push(...hostHealthFrom(hosts, pool.resourceId, pool.tenantId, ts)); + + if (input.stream === "sessions" && hosts.length > 0) { + const availableHosts = hosts.filter( + (host) => isHealthy(host.status) && host.status !== "Shutdown", + ).length; + const sessions = hosts.reduce((sum, host) => sum + host.sessions, 0); + batch.sessionSnapshots!.push({ + ts, + hostPoolId: pool.resourceId, + tenantId: pool.tenantId, + activeSessions: sessions, + disconnectedSessions: 0, + capacity: pool.maxSessions ? availableHosts * pool.maxSessions : sessions, + availableHosts, + totalHosts: hosts.length, + }); + } + } + return batch; + }, + }; +} diff --git a/packages/connectors/src/partner-center.ts b/packages/connectors/src/partner-center.ts new file mode 100644 index 0000000..9dfd9b4 --- /dev/null +++ b/packages/connectors/src/partner-center.ts @@ -0,0 +1,333 @@ +import type { DiagnosticCheck, Discovery, PartnerCenterConfig, SyncBatch } from "@dashflow/core"; +import { AzureCollector } from "./azure/collectors"; +import { createRefreshTokenFn, SCOPES } from "./azure/credentials"; +import { httpJson } from "./http"; +import { type Connector, type ConnectorContext, ConnectorError, check, type SyncInput } from "./types"; + +const PARTNER_CENTER = "https://api.partnercenter.microsoft.com/v1"; +const GRAPH = "https://graph.microsoft.com/v1.0"; + +/** Scopes the partner admin consents to once; the refresh token is then reused per customer. */ +export const PARTNER_CONSENT_SCOPES = [ + "openid", + "profile", + "offline_access", + "https://api.partnercenter.microsoft.com/user_impersonation", + "https://management.azure.com/user_impersonation", + "https://graph.microsoft.com/DelegatedAdminRelationship.Read.All", +].join(" "); + +export function buildConsentUrl(opts: { + partnerTenantId: string; + clientId: string; + redirectUri: string; + state: string; +}): string { + const params = new URLSearchParams({ + client_id: opts.clientId, + response_type: "code", + redirect_uri: opts.redirectUri, + response_mode: "query", + scope: PARTNER_CONSENT_SCOPES, + state: opts.state, + prompt: "consent", + }); + return `https://login.microsoftonline.com/${opts.partnerTenantId}/oauth2/v2.0/authorize?${params}`; +} + +/** Exchange the auth code for the long-lived refresh token (Secure Application Model). */ +export async function redeemConsentCode(opts: { + partnerTenantId: string; + clientId: string; + clientSecret: string; + code: string; + redirectUri: string; + fetchImpl?: typeof fetch; +}): Promise<{ refreshToken: string; upn: string | null }> { + const doFetch = opts.fetchImpl ?? fetch; + const response = await doFetch( + `https://login.microsoftonline.com/${opts.partnerTenantId}/oauth2/v2.0/token`, + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: opts.clientId, + client_secret: opts.clientSecret, + grant_type: "authorization_code", + code: opts.code, + redirect_uri: opts.redirectUri, + scope: PARTNER_CONSENT_SCOPES, + }), + }, + ); + const payload = (await response.json().catch(() => ({}))) as { + refresh_token?: string; + id_token?: string; + error_description?: string; + error?: string; + }; + if (!response.ok || !payload.refresh_token) { + throw new ConnectorError( + `Consent failed: ${payload.error ?? response.status} ${(payload.error_description ?? "").slice(0, 200)}`, + ); + } + let upn: string | null = null; + if (payload.id_token) { + try { + const claims = JSON.parse(Buffer.from(payload.id_token.split(".")[1]!, "base64url").toString("utf8")); + upn = (claims.preferred_username ?? claims.upn ?? null) as string | null; + } catch { + upn = null; + } + } + return { refreshToken: payload.refresh_token, upn }; +} + +interface PartnerCustomer { + id: string; + companyProfile?: { tenantId?: string; domain?: string; companyName?: string }; +} + +/** + * CSP partners: customers come from Partner Center, and access to each customer's Azure + * resources goes through a GDAP relationship. Note the split that trips everyone up — + * GDAP grants *Entra* roles, while reading AVD needs *Azure RBAC* on the subscription. The + * diagnostics below say so explicitly when ARM refuses. + */ +export function createPartnerCenterConnector(config: PartnerCenterConfig, ctx: ConnectorContext): Connector { + if (!ctx.refreshToken) { + const missing: Connector = { + type: "partner-center", + async test() { + return [ + check( + "consent", + "Partner admin consent", + "fail", + "No refresh token is stored for this connection", + "Open the connection and run 'Grant consent' as a partner admin with the AdminAgents role.", + ), + ]; + }, + async discover() { + throw new ConnectorError("This Partner Center connection has not been consented yet."); + }, + async sync() { + throw new ConnectorError("This Partner Center connection has not been consented yet."); + }, + }; + return missing; + } + + const token = createRefreshTokenFn({ + clientId: config.clientId, + clientSecret: ctx.secret ?? "", + refreshToken: ctx.refreshToken, + partnerTenantId: config.partnerTenantId, + onRotate: ctx.onRefreshToken, + fetchImpl: ctx.fetch, + }); + const collectorFor = (customerTenantId: string) => + new AzureCollector(token, customerTenantId, ctx.log, customerTenantId); + + async function listCustomers(): Promise { + const doFetch = ctx.fetch ?? fetch; + const accessToken = await token(SCOPES.partnerCenter, config.partnerTenantId); + const out: PartnerCustomer[] = []; + let continuation: string | null = null; + let pages = 0; + + do { + const url: string = continuation + ? `${PARTNER_CENTER}/customers?size=200&seekOperation=Next` + : `${PARTNER_CENTER}/customers?size=200`; + const response: Response = await doFetch(url, { + headers: { + authorization: `Bearer ${accessToken}`, + accept: "application/json", + ...(continuation ? { "MS-ContinuationToken": continuation } : {}), + }, + }); + if (!response.ok) { + throw new ConnectorError(`Partner Center returned ${response.status}`, { + status: response.status, + hint: + response.status === 403 + ? "The consenting account needs the AdminAgents role in Partner Center, and the app must be registered as a Partner Center app." + : undefined, + }); + } + const payload = (await response.json()) as { items?: PartnerCustomer[] }; + out.push(...(payload.items ?? [])); + continuation = response.headers.get("MS-ContinuationToken"); + pages++; + } while (continuation && pages < 25); + + return out.filter((customer) => Boolean(customer.companyProfile?.tenantId)); + } + + /** GDAP relationships tell us which customers we can actually act on, and with which roles. */ + async function listGdap(): Promise> { + const accessToken = await token(SCOPES.graph, config.partnerTenantId); + const map = new Map(); + try { + const payload = await httpJson<{ + value?: { + customer?: { tenantId?: string }; + status?: string; + endDateTime?: string; + accessDetails?: { unifiedRoles?: { roleDefinitionId?: string }[] }; + }[]; + }>(`${GRAPH}/tenantRelationships/delegatedAdminRelationships?$top=500`, accessToken); + for (const relationship of payload.value ?? []) { + const tenantId = relationship.customer?.tenantId; + if (!tenantId) continue; + const existing = map.get(tenantId); + const roles = (relationship.accessDetails?.unifiedRoles ?? []) + .map((role) => role.roleDefinitionId ?? "") + .filter(Boolean); + if (!existing || relationship.status === "active") { + map.set(tenantId, { + status: relationship.status ?? "unknown", + roles, + endsAt: relationship.endDateTime, + }); + } + } + } catch (err) { + ctx.log.warn({ err: (err as Error).message }, "could not read GDAP relationships"); + } + return map; + } + + return { + type: "partner-center", + + async test(): Promise { + const checks: DiagnosticCheck[] = []; + let customers: PartnerCustomer[] = []; + try { + customers = await listCustomers(); + checks.push( + check("partner-center", "List Partner Center customers", "pass", `${customers.length} customer(s)`), + ); + } catch (err) { + const e = err as ConnectorError; + checks.push(check("partner-center", "List Partner Center customers", "fail", e.message, e.hint)); + return checks; + } + + const gdap = await listGdap(); + const active = [...gdap.values()].filter((r) => r.status === "active").length; + checks.push( + check( + "gdap", + "Active GDAP relationships", + active > 0 ? "pass" : "warn", + `${active} active relationship(s) for ${customers.length} customer(s)`, + active > 0 + ? undefined + : "Create GDAP relationships with your customers. Without one, tokens for their tenant will be refused.", + ), + ); + + // Probe ARM for the first customer that has an active relationship. + const candidate = + customers.find((c) => gdap.get(c.companyProfile!.tenantId!)?.status === "active") ?? customers[0]; + if (candidate?.companyProfile?.tenantId) { + const tenantId = candidate.companyProfile.tenantId; + try { + const collector = collectorFor(tenantId); + const subs = await collector.arm.listSubscriptions(); + checks.push( + check( + "customer-arm", + "Reach a customer's Azure subscriptions", + subs.length > 0 ? "pass" : "warn", + `${candidate.companyProfile.companyName ?? tenantId}: ${subs.length} subscription(s)`, + subs.length > 0 + ? undefined + : "GDAP grants Entra roles, not Azure RBAC. Assign Reader, Desktop Virtualization Reader and Log Analytics Reader to your partner security group inside the customer's subscription (Azure Lighthouse is the usual way to automate this).", + ), + ); + if (subs.length > 0) { + checks.push( + ...(await collector.diagnose({ subscriptionIds: subs.map((s) => s.subscriptionId) })), + ); + } + } catch (err) { + const e = err as ConnectorError; + checks.push( + check( + "customer-arm", + "Reach a customer's Azure subscriptions", + "fail", + e.message, + e.hint ?? + "GDAP grants Entra roles, not Azure RBAC. Assign Azure roles in the customer's subscription as well.", + ), + ); + } + } + return checks; + }, + + async discover(): Promise { + const [customers, gdap] = await Promise.all([listCustomers(), listGdap()]); + const discovery: Discovery = { tenants: [], subscriptions: [], workspaces: [], hostPoolCount: 0 }; + + for (const customer of customers) { + const tenantId = customer.companyProfile!.tenantId!; + const relationship = gdap.get(tenantId); + discovery.tenants.push({ + tenantId, + displayName: customer.companyProfile?.companyName ?? tenantId, + domain: customer.companyProfile?.domain, + meta: { + partnerCenterCustomerId: customer.id, + gdapStatus: relationship?.status ?? "none", + gdapEndsAt: relationship?.endsAt, + }, + }); + + // Only probe Azure for customers we can actually reach, so discovery stays quick. + if (relationship?.status !== "active") continue; + try { + const collector = collectorFor(tenantId); + const subs = await collector.arm.listSubscriptions(); + discovery.subscriptions.push( + ...subs.map((sub) => ({ + tenantId, + subscriptionId: sub.subscriptionId, + displayName: sub.displayName, + })), + ); + const rest = await collector.discover(subs.map((s) => s.subscriptionId)); + discovery.workspaces.push(...rest.workspaces); + discovery.hostPoolCount += rest.hostPoolCount; + } catch (err) { + ctx.log.warn({ err: (err as Error).message, tenantId }, "customer discovery failed"); + } + } + return discovery; + }, + + async sync(input: SyncInput): Promise { + const collector = collectorFor(input.tenantId); + const subscriptionIds = + input.subscriptionIds.length > 0 + ? input.subscriptionIds + : (await collector.arm.listSubscriptions()).map((s) => s.subscriptionId); + switch (input.stream) { + case "inventory": + return collector.inventory(subscriptionIds); + case "sessions": + return collector.sessions(input.hostPoolIds); + case "logs": + return collector.logs(input); + case "cost": + return collector.cost({ ...input, subscriptionIds }); + } + }, + }; +} diff --git a/packages/connectors/src/types.ts b/packages/connectors/src/types.ts new file mode 100644 index 0000000..25e1ebd --- /dev/null +++ b/packages/connectors/src/types.ts @@ -0,0 +1,62 @@ +import type { ConnectionConfig, DiagnosticCheck, Discovery, SyncBatch, SyncStream } from "@dashflow/core"; + +export interface ConnectorLogger { + debug(obj: unknown, msg?: string): void; + info(obj: unknown, msg?: string): void; + warn(obj: unknown, msg?: string): void; + error(obj: unknown, msg?: string): void; +} + +/** Everything a connector needs from the host, so connectors never touch the database. */ +export interface ConnectorContext { + log: ConnectorLogger; + /** Client secret, certificate PEM, or API secret — resolved from Key Vault or the encrypted store. */ + secret: string | null; + /** Partner Center refresh token (Secure Application Model). */ + refreshToken?: string | null; + /** Called when a refresh token is rotated, so the host can persist the new one. */ + onRefreshToken?: (token: string) => Promise; + /** Overridable for tests. */ + fetch?: typeof fetch; +} + +export interface SyncInput { + stream: SyncStream; + /** Entra tenant of the customer being synced. */ + tenantId: string; + subscriptionIds: string[]; + /** Log Analytics workspaces selected for this customer. */ + workspaces: { workspaceId: string; hostPoolIds: string[] }[]; + /** Host pools currently known for this customer, lowercase ARM ids. */ + hostPoolIds: string[]; + /** Incremental watermark; undefined means "backfill from `until` minus the backfill window". */ + since?: Date; + until: Date; + backfillDays: number; +} + +export interface Connector { + readonly type: ConnectionConfig["type"]; + /** Probe credentials and permissions; never throws for a permission problem. */ + test(): Promise; + discover(): Promise; + sync(input: SyncInput): Promise; +} + +export class ConnectorError extends Error { + readonly status?: number; + readonly hint?: string; + constructor(message: string, opts: { status?: number; hint?: string; cause?: unknown } = {}) { + super(message, { cause: opts.cause }); + this.status = opts.status; + this.hint = opts.hint; + } +} + +export const check = ( + id: string, + label: string, + status: DiagnosticCheck["status"], + detail?: string, + fix?: string, +): DiagnosticCheck => ({ id, label, status, detail, fix }); diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..3758b92 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,12 @@ +{ + "name": "@dashflow/core", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "zod": "4.6.5" + } +} diff --git a/packages/core/src/avd.ts b/packages/core/src/avd.ts new file mode 100644 index 0000000..310fdef --- /dev/null +++ b/packages/core/src/avd.ts @@ -0,0 +1,206 @@ +/** + * Normalized AVD records produced by connectors. Connector-specific shapes never + * leave packages/connectors; the server only persists these. + * Host pool / session host keys are lowercase ARM resource IDs so Azure and Nerdio data dedupe. + */ + +export type DataSource = "azure" | "nerdio" | "demo"; + +export interface DiscoveredTenant { + tenantId: string; + displayName: string; + domain?: string; + /** e.g. GDAP relationship status, Lighthouse offer name, Nerdio account id */ + meta?: Record; +} + +export interface DiscoveredSubscription { + tenantId: string; + subscriptionId: string; + displayName: string; +} + +export interface DiscoveredWorkspace { + tenantId: string; + resourceId: string; + /** Log Analytics "customerId" used by the query API. */ + workspaceId: string; + name: string; + /** Host pool resource IDs whose diagnostics are sent here. */ + hostPoolIds: string[]; +} + +export interface Discovery { + tenants: DiscoveredTenant[]; + subscriptions: DiscoveredSubscription[]; + workspaces: DiscoveredWorkspace[]; + hostPoolCount: number; +} + +export interface HostPoolRecord { + resourceId: string; + tenantId: string; + subscriptionId: string; + name: string; + friendlyName?: string | null; + location: string; + poolType: "Pooled" | "Personal" | string; + loadBalancer?: string | null; + maxSessions?: number | null; + startVmOnConnect?: boolean | null; + validationEnvironment?: boolean | null; + preferredAppGroupType?: string | null; + autoscaleEnabled?: boolean | null; + source: DataSource; +} + +export interface SessionHostRecord { + resourceId: string; + hostPoolId: string; + tenantId: string; + name: string; + vmResourceId?: string | null; + status: string; + allowNewSession: boolean; + sessions: number; + agentVersion?: string | null; + osVersion?: string | null; + lastHeartBeat?: string | null; + updateState?: string | null; + source: DataSource; +} + +export interface AppGroupRecord { + resourceId: string; + hostPoolId: string | null; + tenantId: string; + name: string; + groupType: string; + workspaceId?: string | null; +} + +export interface ScalingPlanRecord { + resourceId: string; + tenantId: string; + name: string; + timeZone?: string | null; + hostPoolIds: string[]; + schedules: number; +} + +export interface SessionSnapshotRecord { + ts: string; + hostPoolId: string; + tenantId: string; + activeSessions: number; + disconnectedSessions: number; + capacity: number; + availableHosts: number; + totalHosts: number; +} + +export interface ConnectionFactRecord { + correlationId: string; + tenantId: string; + hostPoolId: string | null; + ts: string; + user: string; + sessionHost?: string | null; + clientOs?: string | null; + clientType?: string | null; + clientVersion?: string | null; + gatewayRegion?: string | null; + state: "connected" | "failed"; + connectMs?: number | null; + rttMs?: number | null; + bandwidthKbps?: number | null; + durationSec?: number | null; +} + +export interface ErrorFactRecord { + key: string; + ts: string; + tenantId: string; + hostPoolId: string | null; + correlationId?: string | null; + code: string; + source: string; + message: string; + serviceError: boolean; +} + +export interface HostHealthRecord { + ts: string; + tenantId: string; + hostPoolId: string; + sessionHost: string; + status: string; + healthy: boolean; + drain: boolean; + sessions: number; +} + +export interface PerfRecord { + ts: string; + tenantId: string; + hostPoolId: string | null; + sessionHost: string; + cpuPct?: number | null; + memAvailableMb?: number | null; +} + +export interface CostRecord { + date: string; + tenantId: string; + hostPoolId: string | null; + meterCategory: string; + cost: number; + currency: string; + estimatedSavings?: number | null; +} + +export interface AutoscaleEventRecord { + key: string; + ts: string; + tenantId: string; + hostPoolId: string; + action: string; + hostsBefore?: number | null; + hostsAfter?: number | null; + detail?: string | null; +} + +/** A batch of normalized records. Tenant IDs are Entra tenant GUIDs; the server maps them to rows. */ +export interface SyncBatch { + hostPools?: HostPoolRecord[]; + sessionHosts?: SessionHostRecord[]; + appGroups?: AppGroupRecord[]; + scalingPlans?: ScalingPlanRecord[]; + sessionSnapshots?: SessionSnapshotRecord[]; + hostHealth?: HostHealthRecord[]; + connections?: ConnectionFactRecord[]; + errors?: ErrorFactRecord[]; + perf?: PerfRecord[]; + costs?: CostRecord[]; + autoscaleEvents?: AutoscaleEventRecord[]; +} + +export type SyncStream = "inventory" | "sessions" | "logs" | "cost"; + +export function normalizeResourceId(id: string): string { + return id.trim().toLowerCase().replace(/\/+$/, ""); +} + +/** Parse /subscriptions/{sub}/resourceGroups/{rg}/... */ +export function parseResourceId(id: string): { + subscriptionId?: string; + resourceGroup?: string; + name?: string; +} { + const parts = id.split("/").filter(Boolean); + const get = (key: string) => { + const i = parts.findIndex((p) => p.toLowerCase() === key.toLowerCase()); + return i >= 0 ? parts[i + 1] : undefined; + }; + return { subscriptionId: get("subscriptions"), resourceGroup: get("resourceGroups"), name: parts.at(-1) }; +} diff --git a/packages/core/src/connections.ts b/packages/core/src/connections.ts new file mode 100644 index 0000000..1547d2b --- /dev/null +++ b/packages/core/src/connections.ts @@ -0,0 +1,141 @@ +import { z } from "zod"; + +export const CONNECTION_TYPES = ["azure", "lighthouse", "partner-center", "nerdio", "demo"] as const; +export const connectionTypeSchema = z.enum(CONNECTION_TYPES); +export type ConnectionType = z.infer; + +const guid = z + .string() + .trim() + .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "Must be a GUID"); + +/** How a connection authenticates to Microsoft Entra. Secrets are never part of config. */ +export const credentialModeSchema = z.enum(["managed-identity", "client-secret", "client-certificate"]); +export type CredentialMode = z.infer; + +export const syncScheduleSchema = z.object({ + inventoryMinutes: z.number().int().min(15).max(1440).default(60), + sessionsMinutes: z.number().int().min(5).max(60).default(5), + logsMinutes: z.number().int().min(5).max(240).default(15), + costHours: z.number().int().min(6).max(48).default(24), + backfillDays: z.number().int().min(1).max(90).default(30), + retentionDays: z.number().int().min(7).max(730).default(180), +}); +export type SyncSchedule = z.infer; +export const DEFAULT_SCHEDULE: SyncSchedule = syncScheduleSchema.parse({}); + +const entraAppConfig = z.object({ + tenantId: guid, + credentialMode: credentialModeSchema, + /** Required for client-secret / client-certificate; optional user-assigned MI client id otherwise. */ + clientId: guid.optional(), +}); + +export const azureConfigSchema = entraAppConfig.extend({ + type: z.literal("azure"), + /** Empty = every subscription the identity can see. */ + subscriptionIds: z.array(guid).default([]), +}); + +export const lighthouseConfigSchema = entraAppConfig.extend({ + type: z.literal("lighthouse"), + /** Also include subscriptions in the managing tenant itself. */ + includeHomeTenant: z.boolean().default(false), +}); + +export const partnerCenterConfigSchema = z.object({ + type: z.literal("partner-center"), + partnerTenantId: guid, + clientId: guid, + credentialMode: z.literal("client-secret"), + /** Set by the consent flow; the refresh token itself is stored as a secret. */ + consentedBy: z.string().optional(), + consentedAt: z.string().optional(), +}); + +export const nerdioConfigSchema = z.object({ + type: z.literal("nerdio"), + edition: z.enum(["msp", "enterprise"]), + /** e.g. https://nmw-app-xxxx.azurewebsites.net — no trailing slash. */ + baseUrl: z + .string() + .url() + .refine((u) => u.startsWith("https://"), "Must be https") + .transform((u) => u.replace(/\/+$/, "")), + tenantId: guid, + clientId: guid, + /** API scope shown in Nerdio Settings → Integrations → REST API, e.g. api:///.default */ + scope: z.string().min(3), + credentialMode: z.enum(["client-secret", "client-certificate"]), + /** Optional overrides when an instance's API paths differ from the defaults. */ + endpoints: z.record(z.string(), z.string()).default({}), +}); + +export const demoConfigSchema = z.object({ + type: z.literal("demo"), + tenants: z.number().int().min(1).max(12).default(4), +}); + +export const connectionConfigSchema = z.discriminatedUnion("type", [ + azureConfigSchema, + lighthouseConfigSchema, + partnerCenterConfigSchema, + nerdioConfigSchema, + demoConfigSchema, +]); +export type ConnectionConfig = z.infer; +export type AzureConfig = z.infer; +export type LighthouseConfig = z.infer; +export type PartnerCenterConfig = z.infer; +export type NerdioConfig = z.infer; + +/** How the secret for a connection is provided. */ +export const secretInputSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("none") }), + z.object({ kind: z.literal("inline"), value: z.string().min(1).max(16_000) }), + z.object({ + kind: z.literal("keyvault"), + secretName: z + .string() + .regex(/^[0-9a-zA-Z-]{1,127}$/, "Key Vault secret names use letters, digits and dashes"), + }), +]); +export type SecretInput = z.infer; + +export const CONNECTION_TYPE_INFO: Record< + Exclude, + { label: string; tagline: string; bestFor: string; multiTenant: boolean } +> = { + azure: { + label: "Azure (direct)", + tagline: "Service principal or managed identity in one tenant.", + bestFor: "Internal IT teams, or a single customer environment.", + multiTenant: false, + }, + lighthouse: { + label: "Azure Lighthouse", + tagline: "One identity in your managing tenant sees every delegated subscription.", + bestFor: "MSPs that already onboard customers with Lighthouse.", + multiTenant: true, + }, + "partner-center": { + label: "Partner Center (GDAP)", + tagline: "Enumerate CSP customers and access them with delegated admin relationships.", + bestFor: "CSP partners managing customers through GDAP.", + multiTenant: true, + }, + nerdio: { + label: "Nerdio Manager", + tagline: "Read host pools, hosts and autoscale from Nerdio Manager's REST API.", + bestFor: "Environments managed with Nerdio Manager for MSP or Enterprise.", + multiTenant: true, + }, +}; + +export interface DiagnosticCheck { + id: string; + label: string; + status: "pass" | "warn" | "fail" | "skip"; + detail?: string; + fix?: string; +} diff --git a/packages/core/src/core.test.ts b/packages/core/src/core.test.ts new file mode 100644 index 0000000..53597e2 --- /dev/null +++ b/packages/core/src/core.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import { DIMENSIONS, formatUnit, getMetric, METRICS, VIZ_KINDS } from "./metrics"; +import { type Access, scopeTenants } from "./rbac"; +import { DASHBOARD_TEMPLATES } from "./templates"; +import { resolveRange } from "./time"; +import { dashboardSchema, widgetSchema } from "./widgets"; + +describe("metric catalog", () => { + test("every metric has a unique id and at least one visualization", () => { + const ids = new Set(); + for (const metric of METRICS) { + expect(ids.has(metric.id)).toBe(false); + ids.add(metric.id); + expect(metric.viz.length).toBeGreaterThan(0); + for (const viz of metric.viz) expect(VIZ_KINDS).toContain(viz); + } + }); + + test("dimension keys are all declared", () => { + for (const metric of METRICS) { + for (const dim of Object.keys(metric.dims)) expect(Object.keys(DIMENSIONS)).toContain(dim); + } + }); + + test("ratio metrics point at metrics that exist", () => { + for (const metric of METRICS) { + if (!metric.ratioOf) continue; + expect(getMetric(metric.ratioOf.numerator)).toBeDefined(); + expect(getMetric(metric.ratioOf.denominator)).toBeDefined(); + } + }); + + test("two-stage metrics declare both an inner key and an outer aggregate", () => { + for (const metric of METRICS) { + expect(Boolean(metric.innerKey)).toBe(Boolean(metric.stage2)); + } + }); + + test("SQL fragments never interpolate anything", () => { + // The compiler puts these straight into SQL, so they must be static text. + for (const metric of METRICS) { + const fragments = [ + metric.agg, + metric.where ?? "", + metric.innerKey ?? "", + ...Object.values(metric.dims), + ]; + for (const fragment of fragments) { + expect(fragment).not.toContain("${"); + expect(fragment).not.toContain(";"); + } + } + }); +}); + +describe("dashboard templates", () => { + test("every template widget passes widget validation", () => { + for (const template of DASHBOARD_TEMPLATES) { + for (const widget of template.widgets) { + const result = widgetSchema.safeParse(widget); + if (!result.success) { + throw new Error( + `${template.goal}/${widget.id}: ${result.error.issues.map((i) => i.message).join(", ")}`, + ); + } + } + } + }); + + test("a template can be saved as a dashboard", () => { + const template = DASHBOARD_TEMPLATES[0]!; + const parsed = dashboardSchema.parse({ + name: template.name, + goal: template.goal, + widgets: template.widgets, + }); + expect(parsed.widgets.length).toBe(template.widgets.length); + expect(parsed.tenantScope).toBeNull(); + }); + + test("goals are unique", () => { + const goals = DASHBOARD_TEMPLATES.map((template) => template.goal); + expect(new Set(goals).size).toBe(goals.length); + }); +}); + +describe("widget validation", () => { + const base = { id: "w1", title: "Test", metric: "sessions.active", viz: "kpi" as const }; + + test("rejects an unknown metric", () => { + expect(widgetSchema.safeParse({ ...base, metric: "not.a.metric" }).success).toBe(false); + }); + + test("rejects a visualization the metric does not support", () => { + expect(widgetSchema.safeParse({ ...base, viz: "host-grid" }).success).toBe(false); + }); + + test("rejects a dimension the metric cannot group by", () => { + expect(widgetSchema.safeParse({ ...base, viz: "bar", groupBy: "errorCode" }).success).toBe(false); + expect(widgetSchema.safeParse({ ...base, viz: "bar", groupBy: "hostPool" }).success).toBe(true); + }); +}); + +describe("tenant scoping", () => { + const unrestricted: Access = { role: "admin", tenantScope: null }; + const restricted: Access = { role: "viewer", tenantScope: ["a", "b"] }; + + test("an unrestricted user gets exactly what they asked for", () => { + expect(scopeTenants(unrestricted, null)).toBeNull(); + expect(scopeTenants(unrestricted, ["x"])).toEqual(["x"]); + }); + + test("a restricted user is limited to their own tenants", () => { + expect(scopeTenants(restricted, null)).toEqual(["a", "b"]); + expect(scopeTenants(restricted, ["a"])).toEqual(["a"]); + }); + + test("a restricted user cannot reach another tenant", () => { + expect(scopeTenants(restricted, ["c"])).toEqual([]); + expect(scopeTenants(restricted, ["a", "c"])).toEqual(["a"]); + }); +}); + +describe("formatting and ranges", () => { + test("units read the way an operator expects", () => { + expect(formatUnit("ms", 450)).toBe("450 ms"); + expect(formatUnit("ms", 2500)).toBe("2.5 s"); + expect(formatUnit("percent", 97.85)).toBe("98%"); + expect(formatUnit("seconds", 5400)).toBe("1.5 h"); + expect(formatUnit("count", Number.NaN)).toBe("—"); + }); + + test("presets resolve to a range and a sensible grain", () => { + const now = new Date("2026-09-17T12:00:00Z"); + expect(resolveRange("24h", now).grain).toBe("hour"); + expect(resolveRange("30d", now).grain).toBe("day"); + expect(resolveRange("7d", now).from.toISOString()).toBe("2026-09-10T12:00:00.000Z"); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..1c5c665 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,7 @@ +export * from "./avd"; +export * from "./connections"; +export * from "./metrics"; +export * from "./rbac"; +export * from "./templates"; +export * from "./time"; +export * from "./widgets"; diff --git a/packages/core/src/metrics.ts b/packages/core/src/metrics.ts new file mode 100644 index 0000000..6d8bdeb --- /dev/null +++ b/packages/core/src/metrics.ts @@ -0,0 +1,521 @@ +import { z } from "zod"; + +/** + * The metric catalog is the only thing that can turn into SQL. Widgets reference metric + * and dimension IDs; the server compiles them with these fragments. Nothing a user types + * ever reaches the query as SQL text — see apps/server/src/query/compile.ts. + */ + +export const DIMENSIONS = { + tenant: { label: "Customer", short: "Customer" }, + hostPool: { label: "Host pool", short: "Pool" }, + poolType: { label: "Pool type", short: "Type" }, + location: { label: "Azure region", short: "Region" }, + sessionHost: { label: "Session host", short: "Host" }, + clientOs: { label: "Client OS", short: "Client OS" }, + clientType: { label: "Client type", short: "Client" }, + gatewayRegion: { label: "Gateway region", short: "Gateway" }, + errorCode: { label: "Error code", short: "Code" }, + errorSource: { label: "Error source", short: "Source" }, + hostStatus: { label: "Host status", short: "Status" }, + meterCategory: { label: "Meter category", short: "Meter" }, +} as const; + +export type DimensionId = keyof typeof DIMENSIONS; +export const dimensionSchema = z.enum(Object.keys(DIMENSIONS) as [DimensionId, ...DimensionId[]]); + +export const VIZ_KINDS = [ + "kpi", + "line", + "area", + "bar", + "stacked-bar", + "donut", + "table", + "heatmap", + "host-grid", +] as const; +export const vizSchema = z.enum(VIZ_KINDS); +export type VizKind = z.infer; + +export type MetricUnit = "count" | "sessions" | "ms" | "seconds" | "percent" | "currency" | "hosts"; +export type MetricCategory = "usage" | "experience" | "reliability" | "capacity" | "cost"; + +export interface MetricDef { + id: string; + label: string; + description: string; + category: MetricCategory; + unit: MetricUnit; + /** Lower is better for latency/errors; used for trend colouring. */ + betterWhen: "lower" | "higher" | "neutral"; + /** Physical table (see packages/db/src/schema.ts). Aliased as `f` when compiled. */ + table: + | "session_snapshots" + | "connection_facts" + | "error_facts" + | "host_health" + | "perf_hourly" + | "cost_daily" + | "autoscale_events"; + tsColumn: string; + /** Extra row filter, SQL fragment in terms of `f`. */ + where?: string; + /** Aggregate over rows inside one bucket+group. */ + agg: string; + /** + * When set, rows are first aggregated per (bucket, group, innerKey) with `agg`, + * then combined across innerKey with this. Needed for anything that is a sum across + * host pools but an average across time (concurrency, capacity). + */ + innerKey?: string; + stage2?: "avg" | "max" | "sum"; + /** Dimension → SQL expression. Available aliases: f, hp (host_pools), ct (customer_tenants). */ + dims: Partial>; + viz: readonly VizKind[]; + /** Only meaningful when a connector of this kind has synced. */ + requires?: "nerdio"; + /** Computed from two other metrics rather than SQL. */ + ratioOf?: { numerator: string; denominator: string; scale?: number }; +} + +const POOL_DIMS = { + tenant: "ct.display_name", + hostPool: "coalesce(hp.friendly_name, hp.name)", + poolType: "hp.pool_type", + location: "hp.location", +} as const; + +export const METRICS: MetricDef[] = [ + // ─── usage ──────────────────────────────────────────────────────────────────── + { + id: "sessions.active", + label: "Active sessions", + description: "Average number of active sessions, from 5-minute snapshots.", + category: "usage", + unit: "sessions", + betterWhen: "neutral", + table: "session_snapshots", + tsColumn: "ts", + agg: "sum(f.active_sessions)", + innerKey: "f.ts", + stage2: "avg", + dims: POOL_DIMS, + viz: ["kpi", "line", "area", "bar", "stacked-bar", "heatmap", "table"], + }, + { + id: "sessions.peak", + label: "Peak sessions", + description: "Highest concurrent session count in the period.", + category: "usage", + unit: "sessions", + betterWhen: "neutral", + table: "session_snapshots", + tsColumn: "ts", + agg: "sum(f.active_sessions)", + innerKey: "f.ts", + stage2: "max", + dims: POOL_DIMS, + viz: ["kpi", "line", "bar", "table"], + }, + { + id: "sessions.disconnected", + label: "Disconnected sessions", + description: "Average number of disconnected sessions still holding a host.", + category: "usage", + unit: "sessions", + betterWhen: "lower", + table: "session_snapshots", + tsColumn: "ts", + agg: "sum(f.disconnected_sessions)", + innerKey: "f.ts", + stage2: "avg", + dims: POOL_DIMS, + viz: ["kpi", "line", "area", "bar", "table"], + }, + { + id: "connections.count", + label: "Connections", + description: "Number of user connections.", + category: "usage", + unit: "count", + betterWhen: "neutral", + table: "connection_facts", + tsColumn: "ts", + agg: "count(*)", + dims: { + ...POOL_DIMS, + clientOs: "coalesce(nullif(f.client_os, ''), 'Unknown')", + clientType: "coalesce(nullif(f.client_type, ''), 'Unknown')", + gatewayRegion: "coalesce(nullif(f.gateway_region, ''), 'Unknown')", + sessionHost: "coalesce(nullif(f.session_host, ''), 'Unknown')", + }, + viz: ["kpi", "line", "area", "bar", "stacked-bar", "donut", "heatmap", "table"], + }, + { + id: "users.unique", + label: "Unique users", + description: "Distinct users who connected.", + category: "usage", + unit: "count", + betterWhen: "neutral", + table: "connection_facts", + tsColumn: "ts", + agg: "count(distinct f.user_key)", + dims: { ...POOL_DIMS, clientOs: "coalesce(nullif(f.client_os, ''), 'Unknown')" }, + viz: ["kpi", "line", "bar", "table"], + }, + // ─── experience ─────────────────────────────────────────────────────────────── + { + id: "connect.p95", + label: "Time to connect (p95)", + description: "95th percentile of the time from connection start to connected.", + category: "experience", + unit: "ms", + betterWhen: "lower", + table: "connection_facts", + tsColumn: "ts", + where: "f.connect_ms is not null and f.state = 'connected'", + agg: "percentile_cont(0.95) within group (order by f.connect_ms)", + dims: { + ...POOL_DIMS, + clientOs: "coalesce(nullif(f.client_os, ''), 'Unknown')", + clientType: "coalesce(nullif(f.client_type, ''), 'Unknown')", + gatewayRegion: "coalesce(nullif(f.gateway_region, ''), 'Unknown')", + sessionHost: "coalesce(nullif(f.session_host, ''), 'Unknown')", + }, + viz: ["kpi", "line", "bar", "table"], + }, + { + id: "connect.median", + label: "Time to connect (median)", + description: "Median time from connection start to connected.", + category: "experience", + unit: "ms", + betterWhen: "lower", + table: "connection_facts", + tsColumn: "ts", + where: "f.connect_ms is not null and f.state = 'connected'", + agg: "percentile_cont(0.5) within group (order by f.connect_ms)", + dims: { + ...POOL_DIMS, + clientOs: "coalesce(nullif(f.client_os, ''), 'Unknown')", + gatewayRegion: "coalesce(nullif(f.gateway_region, ''), 'Unknown')", + }, + viz: ["kpi", "line", "bar", "table"], + }, + { + id: "rtt.median", + label: "Round trip time (median)", + description: "Median estimated round trip time reported by the client.", + category: "experience", + unit: "ms", + betterWhen: "lower", + table: "connection_facts", + tsColumn: "ts", + where: "f.rtt_ms is not null", + agg: "percentile_cont(0.5) within group (order by f.rtt_ms)", + dims: { + ...POOL_DIMS, + gatewayRegion: "coalesce(nullif(f.gateway_region, ''), 'Unknown')", + clientOs: "coalesce(nullif(f.client_os, ''), 'Unknown')", + sessionHost: "coalesce(nullif(f.session_host, ''), 'Unknown')", + }, + viz: ["kpi", "line", "bar", "table", "heatmap"], + }, + { + id: "rtt.p95", + label: "Round trip time (p95)", + description: "95th percentile round trip time — the tail users complain about.", + category: "experience", + unit: "ms", + betterWhen: "lower", + table: "connection_facts", + tsColumn: "ts", + where: "f.rtt_ms is not null", + agg: "percentile_cont(0.95) within group (order by f.rtt_ms)", + dims: { ...POOL_DIMS, gatewayRegion: "coalesce(nullif(f.gateway_region, ''), 'Unknown')" }, + viz: ["kpi", "line", "bar", "table"], + }, + { + id: "session.duration.median", + label: "Session duration (median)", + description: "Median length of a completed session.", + category: "usage", + unit: "seconds", + betterWhen: "neutral", + table: "connection_facts", + tsColumn: "ts", + where: "f.duration_sec is not null", + agg: "percentile_cont(0.5) within group (order by f.duration_sec)", + dims: POOL_DIMS, + viz: ["kpi", "line", "bar", "table"], + }, + // ─── reliability ────────────────────────────────────────────────────────────── + { + id: "connections.failed", + label: "Failed connections", + description: "Connections that never reached the connected state.", + category: "reliability", + unit: "count", + betterWhen: "lower", + table: "connection_facts", + tsColumn: "ts", + where: "f.state = 'failed'", + agg: "count(*)", + dims: { + ...POOL_DIMS, + clientOs: "coalesce(nullif(f.client_os, ''), 'Unknown')", + gatewayRegion: "coalesce(nullif(f.gateway_region, ''), 'Unknown')", + sessionHost: "coalesce(nullif(f.session_host, ''), 'Unknown')", + }, + viz: ["kpi", "line", "bar", "stacked-bar", "table"], + }, + { + id: "connections.success_rate", + label: "Connection success rate", + description: "Share of connections that reached the connected state.", + category: "reliability", + unit: "percent", + betterWhen: "higher", + table: "connection_facts", + tsColumn: "ts", + agg: "100.0 * count(*) filter (where f.state = 'connected') / nullif(count(*), 0)", + dims: { + ...POOL_DIMS, + clientOs: "coalesce(nullif(f.client_os, ''), 'Unknown')", + gatewayRegion: "coalesce(nullif(f.gateway_region, ''), 'Unknown')", + }, + viz: ["kpi", "line", "area", "bar", "table"], + }, + { + id: "errors.count", + label: "Errors", + description: "Errors reported on the AVD control plane and agents.", + category: "reliability", + unit: "count", + betterWhen: "lower", + table: "error_facts", + tsColumn: "ts", + agg: "count(*)", + dims: { + ...POOL_DIMS, + errorCode: "f.code", + errorSource: "coalesce(nullif(f.source, ''), 'Unknown')", + }, + viz: ["kpi", "line", "bar", "stacked-bar", "donut", "table"], + }, + { + id: "errors.service", + label: "Service-side errors", + description: "Errors Microsoft flagged as service errors rather than customer-side.", + category: "reliability", + unit: "count", + betterWhen: "lower", + table: "error_facts", + tsColumn: "ts", + where: "f.service_error", + agg: "count(*)", + dims: { ...POOL_DIMS, errorCode: "f.code", errorSource: "coalesce(nullif(f.source, ''), 'Unknown')" }, + viz: ["kpi", "line", "bar", "table"], + }, + { + id: "hosts.unhealthy", + label: "Unhealthy hosts", + description: "Session hosts not reporting Available.", + category: "reliability", + unit: "hosts", + betterWhen: "lower", + table: "host_health", + tsColumn: "ts", + agg: "count(*) filter (where not f.healthy)", + innerKey: "f.ts", + stage2: "avg", + dims: { ...POOL_DIMS, hostStatus: "f.status", sessionHost: "f.session_host" }, + viz: ["kpi", "line", "bar", "table", "host-grid"], + }, + { + id: "hosts.drain", + label: "Hosts in drain mode", + description: "Session hosts that are up but not accepting new sessions.", + category: "reliability", + unit: "hosts", + betterWhen: "lower", + table: "host_health", + tsColumn: "ts", + agg: "count(*) filter (where f.drain)", + innerKey: "f.ts", + stage2: "avg", + dims: { ...POOL_DIMS, sessionHost: "f.session_host" }, + viz: ["kpi", "line", "bar", "table"], + }, + // ─── capacity ───────────────────────────────────────────────────────────────── + { + id: "capacity.utilization", + label: "Capacity utilization", + description: "Active sessions as a share of the maximum sessions the running hosts allow.", + category: "capacity", + unit: "percent", + betterWhen: "neutral", + table: "session_snapshots", + tsColumn: "ts", + agg: "100.0 * sum(f.active_sessions) / nullif(sum(f.capacity), 0)", + innerKey: "f.ts", + stage2: "avg", + dims: POOL_DIMS, + viz: ["kpi", "line", "area", "bar", "heatmap", "table"], + }, + { + id: "hosts.available", + label: "Available hosts", + description: "Average number of session hosts available to take sessions.", + category: "capacity", + unit: "hosts", + betterWhen: "neutral", + table: "session_snapshots", + tsColumn: "ts", + agg: "sum(f.available_hosts)", + innerKey: "f.ts", + stage2: "avg", + dims: POOL_DIMS, + viz: ["kpi", "line", "area", "bar", "table"], + }, + { + id: "sessions.per_host", + label: "Sessions per host", + description: "Average active sessions on each available host.", + category: "capacity", + unit: "sessions", + betterWhen: "neutral", + table: "session_snapshots", + tsColumn: "ts", + agg: "sum(f.active_sessions)::numeric / nullif(sum(f.available_hosts), 0)", + innerKey: "f.ts", + stage2: "avg", + dims: POOL_DIMS, + viz: ["kpi", "line", "bar", "table"], + }, + { + id: "cpu.avg", + label: "CPU average", + description: "Average processor time across session hosts (needs the Perf table).", + category: "capacity", + unit: "percent", + betterWhen: "lower", + table: "perf_hourly", + tsColumn: "ts", + where: "f.cpu_pct is not null", + agg: "avg(f.cpu_pct)", + dims: { ...POOL_DIMS, sessionHost: "f.session_host" }, + viz: ["kpi", "line", "area", "bar", "table", "heatmap"], + }, + { + id: "memory.available", + label: "Available memory", + description: "Average available megabytes across session hosts (needs the Perf table).", + category: "capacity", + unit: "count", + betterWhen: "higher", + table: "perf_hourly", + tsColumn: "ts", + where: "f.mem_available_mb is not null", + agg: "avg(f.mem_available_mb)", + dims: { ...POOL_DIMS, sessionHost: "f.session_host" }, + viz: ["kpi", "line", "bar", "table"], + }, + // ─── cost ───────────────────────────────────────────────────────────────────── + { + id: "cost.total", + label: "Cost", + description: "Actual cost of the resource groups that host AVD workloads.", + category: "cost", + unit: "currency", + betterWhen: "lower", + table: "cost_daily", + tsColumn: "ts", + agg: "sum(f.cost)", + dims: { ...POOL_DIMS, meterCategory: "f.meter_category" }, + viz: ["kpi", "line", "area", "bar", "stacked-bar", "donut", "table"], + }, + { + id: "cost.savings", + label: "Autoscale savings", + description: "Savings Nerdio attributes to autoscale compared with always-on hosts.", + category: "cost", + unit: "currency", + betterWhen: "higher", + table: "cost_daily", + tsColumn: "ts", + where: "f.estimated_savings is not null", + agg: "sum(f.estimated_savings)", + dims: POOL_DIMS, + viz: ["kpi", "line", "bar", "table"], + requires: "nerdio", + }, + { + id: "cost.per_user", + label: "Cost per user", + description: "Cost divided by the number of unique users who connected.", + category: "cost", + unit: "currency", + betterWhen: "lower", + table: "cost_daily", + tsColumn: "ts", + agg: "sum(f.cost)", + dims: POOL_DIMS, + viz: ["kpi", "line", "bar", "table"], + ratioOf: { numerator: "cost.total", denominator: "users.unique" }, + }, + { + id: "autoscale.actions", + label: "Autoscale actions", + description: "Host start, stop, resize and rebuild actions performed by Nerdio autoscale.", + category: "cost", + unit: "count", + betterWhen: "neutral", + table: "autoscale_events", + tsColumn: "ts", + agg: "count(*)", + dims: { ...POOL_DIMS, errorSource: "f.action" }, + viz: ["kpi", "line", "bar", "stacked-bar", "table"], + requires: "nerdio", + }, +]; + +export const METRICS_BY_ID: Record = Object.fromEntries(METRICS.map((m) => [m.id, m])); + +export function getMetric(id: string): MetricDef | undefined { + return METRICS_BY_ID[id]; +} + +export const CATEGORY_INFO: Record = { + usage: { label: "Usage", description: "Who is using the environment, and how much." }, + experience: { label: "User experience", description: "How fast and smooth sessions feel." }, + reliability: { label: "Reliability", description: "Failures, errors and host health." }, + capacity: { label: "Capacity", description: "Headroom, utilization and host performance." }, + cost: { label: "Cost", description: "Spend, savings and cost per user." }, +}; + +export function formatUnit(unit: MetricUnit, value: number, currency = "USD"): string { + if (!Number.isFinite(value)) return "—"; + switch (unit) { + case "ms": + return value >= 1000 ? `${(value / 1000).toFixed(1)} s` : `${Math.round(value)} ms`; + case "seconds": { + const m = Math.round(value / 60); + return m >= 60 ? `${(m / 60).toFixed(1)} h` : `${m} min`; + } + case "percent": + return `${value.toFixed(value < 10 ? 1 : 0)}%`; + case "currency": + return new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: value < 100 ? 2 : 0, + }).format(value); + default: + return value >= 1000 + ? value.toLocaleString(undefined, { maximumFractionDigits: 0 }) + : value.toLocaleString(undefined, { maximumFractionDigits: value < 10 ? 1 : 0 }); + } +} diff --git a/packages/core/src/rbac.ts b/packages/core/src/rbac.ts new file mode 100644 index 0000000..6c11257 --- /dev/null +++ b/packages/core/src/rbac.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +/** Ordered lowest → highest privilege. */ +export const ROLES = ["viewer", "analyst", "admin", "owner"] as const; +export const roleSchema = z.enum(ROLES); +export type Role = z.infer; + +export const ROLE_INFO: Record = { + viewer: { + label: "Viewer", + description: "View dashboards shared with them, optionally limited to specific customers.", + }, + analyst: { label: "Analyst", description: "Build, edit and share dashboards." }, + admin: { label: "Admin", description: "Manage connections, tenants, sync and users." }, + owner: { label: "Owner", description: "Full control, including other owners and instance settings." }, +}; + +export function roleRank(role: Role): number { + return ROLES.indexOf(role); +} + +export function hasRole(actual: Role | null | undefined, required: Role): boolean { + return actual != null && roleRank(actual) >= roleRank(required); +} + +/** + * A user's effective access. `tenantScope === null` means all customer tenants; + * otherwise only the listed customer_tenants.id values. + */ +export interface Access { + role: Role; + tenantScope: string[] | null; +} + +/** Intersect a requested tenant filter with what the user may see. Returns null for "all". */ +export function scopeTenants(access: Access, requested?: string[] | null): string[] | null { + const wanted = requested && requested.length > 0 ? requested : null; + if (access.tenantScope === null) return wanted; + if (!wanted) return access.tenantScope; + const allowed = new Set(access.tenantScope); + return wanted.filter((t) => allowed.has(t)); +} diff --git a/packages/core/src/templates.ts b/packages/core/src/templates.ts new file mode 100644 index 0000000..0e5cf1a --- /dev/null +++ b/packages/core/src/templates.ts @@ -0,0 +1,351 @@ +import type { DashboardGoal, WidgetSpec } from "./widgets"; + +export interface DashboardTemplate { + goal: DashboardGoal; + name: string; + headline: string; + description: string; + /** Questions this dashboard answers — shown in the guided builder. */ + answers: string[]; + requires?: "nerdio"; + widgets: WidgetSpec[]; +} + +type W = Partial & Pick; + +const w = (spec: W): WidgetSpec => ({ + topN: 8, + grain: "auto", + filters: {}, + compare: true, + size: "md", + ...spec, +}); + +export const DASHBOARD_TEMPLATES: DashboardTemplate[] = [ + { + goal: "executive", + name: "Executive overview", + headline: "The one screen to open every morning", + description: "Usage, reliability and cost at a glance, with trends against the previous period.", + answers: [ + "How many people are using AVD, and is that growing?", + "Are connections succeeding?", + "What is the environment costing?", + ], + widgets: [ + w({ id: "kpi-users", title: "Unique users", viz: "kpi", metric: "users.unique", size: "sm" }), + w({ id: "kpi-peak", title: "Peak sessions", viz: "kpi", metric: "sessions.peak", size: "sm" }), + w({ + id: "kpi-success", + title: "Connection success", + viz: "kpi", + metric: "connections.success_rate", + size: "sm", + }), + w({ id: "kpi-cost", title: "Cost", viz: "kpi", metric: "cost.total", size: "sm" }), + w({ + id: "sessions-trend", + title: "Active sessions over time", + subtitle: "Average concurrency per bucket", + viz: "area", + metric: "sessions.active", + size: "lg", + }), + w({ + id: "connections-by-pool", + title: "Connections by host pool", + viz: "bar", + metric: "connections.count", + groupBy: "hostPool", + size: "lg", + }), + w({ + id: "heat", + title: "When people work", + subtitle: "Average concurrency by day and hour", + viz: "heatmap", + metric: "sessions.active", + size: "xl", + }), + ], + }, + { + goal: "experience", + name: "User experience", + headline: "How fast sessions feel", + description: "Connection times and round trip latency, sliced by pool, client and gateway region.", + answers: [ + "Is logging in slow, and for whom?", + "Which gateway regions have the worst latency?", + "Do certain clients perform badly?", + ], + widgets: [ + w({ id: "kpi-connect", title: "Time to connect (p95)", viz: "kpi", metric: "connect.p95", size: "sm" }), + w({ + id: "kpi-connect-med", + title: "Time to connect (median)", + viz: "kpi", + metric: "connect.median", + size: "sm", + }), + w({ id: "kpi-rtt", title: "RTT (median)", viz: "kpi", metric: "rtt.median", size: "sm" }), + w({ id: "kpi-rtt-p95", title: "RTT (p95)", viz: "kpi", metric: "rtt.p95", size: "sm" }), + w({ + id: "connect-trend", + title: "Time to connect trend", + viz: "line", + metric: "connect.p95", + size: "lg", + }), + w({ + id: "rtt-by-gateway", + title: "RTT by gateway region", + viz: "bar", + metric: "rtt.median", + groupBy: "gatewayRegion", + size: "lg", + }), + w({ + id: "connect-by-client", + title: "Time to connect by client OS", + viz: "table", + metric: "connect.p95", + groupBy: "clientOs", + size: "md", + }), + w({ + id: "worst-hosts", + title: "Slowest session hosts", + viz: "table", + metric: "connect.p95", + groupBy: "sessionHost", + topN: 10, + size: "md", + }), + ], + }, + { + goal: "reliability", + name: "Reliability", + headline: "What is breaking, and where", + description: "Failed connections, error codes and session host health over time.", + answers: [ + "Which errors are most common?", + "Are failures concentrated in one host pool or host?", + "Are hosts unhealthy or stuck in drain mode?", + ], + widgets: [ + w({ + id: "kpi-failed", + title: "Failed connections", + viz: "kpi", + metric: "connections.failed", + size: "sm", + }), + w({ + id: "kpi-rate", + title: "Success rate", + viz: "kpi", + metric: "connections.success_rate", + size: "sm", + }), + w({ id: "kpi-errors", title: "Errors", viz: "kpi", metric: "errors.count", size: "sm" }), + w({ id: "kpi-unhealthy", title: "Unhealthy hosts", viz: "kpi", metric: "hosts.unhealthy", size: "sm" }), + w({ + id: "errors-trend", + title: "Errors over time by source", + viz: "stacked-bar", + metric: "errors.count", + groupBy: "errorSource", + size: "lg", + }), + w({ + id: "errors-top", + title: "Top error codes", + viz: "table", + metric: "errors.count", + groupBy: "errorCode", + topN: 12, + size: "lg", + }), + w({ + id: "failed-by-pool", + title: "Failed connections by host pool", + viz: "bar", + metric: "connections.failed", + groupBy: "hostPool", + size: "md", + }), + w({ + id: "host-grid", + title: "Session host health", + viz: "host-grid", + metric: "hosts.unhealthy", + size: "md", + }), + ], + }, + { + goal: "capacity", + name: "Capacity and scaling", + headline: "Headroom and right-sizing", + description: "Utilization against capacity, sessions per host and host performance.", + answers: [ + "Do we have too much or too little capacity?", + "When do we hit peak load?", + "Are hosts CPU or memory bound?", + ], + widgets: [ + w({ + id: "kpi-util", + title: "Capacity utilization", + viz: "kpi", + metric: "capacity.utilization", + size: "sm", + }), + w({ + id: "kpi-perhost", + title: "Sessions per host", + viz: "kpi", + metric: "sessions.per_host", + size: "sm", + }), + w({ id: "kpi-avail", title: "Available hosts", viz: "kpi", metric: "hosts.available", size: "sm" }), + w({ id: "kpi-cpu", title: "CPU average", viz: "kpi", metric: "cpu.avg", size: "sm" }), + w({ + id: "util-trend", + title: "Utilization over time", + viz: "area", + metric: "capacity.utilization", + size: "lg", + }), + w({ + id: "util-heat", + title: "Utilization by day and hour", + viz: "heatmap", + metric: "capacity.utilization", + size: "lg", + }), + w({ + id: "sessions-by-pool", + title: "Concurrency by host pool", + viz: "stacked-bar", + metric: "sessions.active", + groupBy: "hostPool", + size: "lg", + }), + w({ + id: "disconnected", + title: "Disconnected sessions", + subtitle: "Sessions holding capacity without a user", + viz: "line", + metric: "sessions.disconnected", + size: "md", + }), + ], + }, + { + goal: "cost", + name: "Cost and savings", + headline: "Where the money goes", + description: "Spend by customer, pool and meter, plus cost per user and autoscale savings.", + answers: [ + "What does each customer or pool cost?", + "What is our cost per user?", + "Is autoscale paying off?", + ], + widgets: [ + w({ id: "kpi-cost", title: "Cost", viz: "kpi", metric: "cost.total", size: "sm" }), + w({ id: "kpi-per-user", title: "Cost per user", viz: "kpi", metric: "cost.per_user", size: "sm" }), + w({ id: "kpi-savings", title: "Autoscale savings", viz: "kpi", metric: "cost.savings", size: "sm" }), + w({ id: "kpi-users", title: "Unique users", viz: "kpi", metric: "users.unique", size: "sm" }), + w({ + id: "cost-trend", + title: "Daily cost", + viz: "area", + metric: "cost.total", + grain: "day", + size: "lg", + }), + w({ + id: "cost-by-meter", + title: "Cost by meter category", + viz: "donut", + metric: "cost.total", + groupBy: "meterCategory", + size: "lg", + }), + w({ + id: "cost-by-pool", + title: "Cost by host pool", + viz: "table", + metric: "cost.total", + groupBy: "hostPool", + topN: 12, + size: "md", + }), + w({ + id: "autoscale", + title: "Autoscale actions", + viz: "bar", + metric: "autoscale.actions", + groupBy: "errorSource", + size: "md", + }), + ], + }, + { + goal: "customer-report", + name: "Customer report", + headline: "One page you can hand to a customer", + description: "Per-customer usage, experience and spend — pick a single customer in the scope step.", + answers: [ + "What did this customer use this month?", + "Did they have a good experience?", + "What did their environment cost?", + ], + widgets: [ + w({ id: "kpi-users", title: "Unique users", viz: "kpi", metric: "users.unique", size: "sm" }), + w({ id: "kpi-sessions", title: "Connections", viz: "kpi", metric: "connections.count", size: "sm" }), + w({ + id: "kpi-success", + title: "Success rate", + viz: "kpi", + metric: "connections.success_rate", + size: "sm", + }), + w({ id: "kpi-cost", title: "Cost", viz: "kpi", metric: "cost.total", size: "sm" }), + w({ + id: "usage", + title: "Daily connections", + viz: "bar", + metric: "connections.count", + groupBy: "hostPool", + size: "lg", + }), + w({ id: "experience", title: "Time to connect (p95)", viz: "line", metric: "connect.p95", size: "lg" }), + w({ + id: "pools", + title: "Host pools", + viz: "table", + metric: "sessions.peak", + groupBy: "hostPool", + topN: 20, + size: "xl", + }), + ], + }, + { + goal: "blank", + name: "Start from scratch", + headline: "Build it yourself", + description: "An empty canvas. Add widgets from the metric catalog.", + answers: [], + widgets: [], + }, +]; + +export function templateFor(goal: DashboardGoal): DashboardTemplate | undefined { + return DASHBOARD_TEMPLATES.find((t) => t.goal === goal); +} diff --git a/packages/core/src/time.ts b/packages/core/src/time.ts new file mode 100644 index 0000000..7e91e3a --- /dev/null +++ b/packages/core/src/time.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const TIME_PRESETS = { + "24h": { label: "Last 24 hours", hours: 24, grain: "hour" }, + "7d": { label: "Last 7 days", hours: 24 * 7, grain: "hour" }, + "30d": { label: "Last 30 days", hours: 24 * 30, grain: "day" }, + "90d": { label: "Last 90 days", hours: 24 * 90, grain: "day" }, +} as const; +export type TimePreset = keyof typeof TIME_PRESETS; +export const timePresetSchema = z.enum(Object.keys(TIME_PRESETS) as [TimePreset, ...TimePreset[]]); + +export const timeGrainSchema = z.enum(["hour", "day"]); +export type TimeGrain = z.infer; + +export function resolveRange( + preset: TimePreset, + now = new Date(), +): { from: Date; to: Date; grain: TimeGrain } { + const p = TIME_PRESETS[preset]; + return { from: new Date(now.getTime() - p.hours * 3600_000), to: now, grain: p.grain }; +} diff --git a/packages/core/src/widgets.ts b/packages/core/src/widgets.ts new file mode 100644 index 0000000..6ca381e --- /dev/null +++ b/packages/core/src/widgets.ts @@ -0,0 +1,198 @@ +import { z } from "zod"; +import { dimensionSchema, getMetric, vizSchema } from "./metrics"; +import { timeGrainSchema, timePresetSchema } from "./time"; + +export const widgetSizeSchema = z.enum(["sm", "md", "lg", "xl"]); +export type WidgetSize = z.infer; + +/** Column span on a 12-column grid, a body height, and the grid rows a size occupies. */ +export const SIZE_SPEC: Record = { + sm: { span: 3, height: 120, rows: 3 }, + md: { span: 6, height: 260, rows: 7 }, + lg: { span: 6, height: 360, rows: 9 }, + xl: { span: 12, height: 380, rows: 10 }, +}; + +/** One grid row in pixels, including the gap — the unit drag and resize snap to. */ +export const GRID_ROW_HEIGHT = 44; +export const GRID_COLUMNS = 12; + +/** + * Where a widget sits once someone has dragged it. Dashboards saved before layouts existed + * simply have none, and fall back to flowing by size — so nothing needs migrating. + */ +export const widgetLayoutSchema = z.object({ + x: z + .number() + .int() + .min(0) + .max(GRID_COLUMNS - 1), + y: z.number().int().min(0).max(400), + w: z.number().int().min(1).max(GRID_COLUMNS), + h: z.number().int().min(2).max(40), +}); +export type WidgetLayout = z.infer; + +export const widgetFiltersSchema = z.object({ + hostPools: z.array(z.string()).optional(), + /** Dimension value allow-list, e.g. { clientOs: ["Windows"] }. Values are always parameterized. */ + dims: z.partialRecord(dimensionSchema, z.array(z.string().max(200))).optional(), +}); +export type WidgetFilters = z.infer; + +export const widgetSchema = z + .object({ + id: z.string().min(1).max(64), + title: z.string().min(1).max(120), + subtitle: z.string().max(200).optional(), + viz: vizSchema, + metric: z.string().min(1).max(64), + groupBy: dimensionSchema.optional(), + topN: z.number().int().min(1).max(50).default(8), + grain: z.union([z.literal("auto"), timeGrainSchema]).default("auto"), + filters: widgetFiltersSchema.default({}), + /** KPI only: also fetch the previous period for a delta. */ + compare: z.boolean().default(true), + size: widgetSizeSchema.default("md"), + layout: widgetLayoutSchema.optional(), + }) + .refine((w) => getMetric(w.metric) !== undefined, { message: "Unknown metric" }) + .refine((w) => getMetric(w.metric)?.viz.includes(w.viz) ?? false, { + message: "This metric does not support that visualization", + }) + .refine((w) => !w.groupBy || getMetric(w.metric)?.dims[w.groupBy] !== undefined, { + message: "This metric cannot be grouped by that dimension", + }); +export type WidgetSpec = z.infer; + +export const dashboardGoals = [ + "executive", + "experience", + "reliability", + "capacity", + "cost", + "customer-report", + "blank", +] as const; +export const dashboardGoalSchema = z.enum(dashboardGoals); +export type DashboardGoal = z.infer; + +export const dashboardSchema = z.object({ + name: z.string().min(1).max(120), + description: z.string().max(400).default(""), + goal: dashboardGoalSchema.default("blank"), + widgets: z.array(widgetSchema).max(40), + defaultPreset: timePresetSchema.default("7d"), + /** null = every customer the viewer may see. */ + tenantScope: z.array(z.string().uuid()).nullable().default(null), + visibility: z.enum(["private", "shared"]).default("private"), + /** Minimum role that can open a shared dashboard. */ + sharedWithRole: z.enum(["viewer", "analyst", "admin"]).default("viewer"), +}); +export type DashboardSpec = z.infer; + +// ─── query contract ───────────────────────────────────────────────────────────── + +export const queryRequestSchema = z.object({ + metric: z.string().min(1).max(64), + viz: vizSchema, + groupBy: dimensionSchema.optional(), + topN: z.number().int().min(1).max(50).default(8), + grain: timeGrainSchema.default("hour"), + from: z.coerce.date(), + to: z.coerce.date(), + tenantIds: z.array(z.string().uuid()).nullable().default(null), + filters: widgetFiltersSchema.default({}), + compare: z.boolean().default(false), +}); +export type QueryRequest = z.infer; + +export interface SeriesPoint { + ts: string; + value: number | null; +} +export interface Series { + name: string; + points: SeriesPoint[]; +} + +export type QueryResult = + | { + kind: "scalar"; + unit: string; + value: number | null; + previous?: number | null; + spark?: SeriesPoint[]; + currency?: string; + } + | { kind: "series"; unit: string; grain: string; series: Series[]; currency?: string } + | { kind: "breakdown"; unit: string; items: { name: string; value: number }[]; currency?: string } + | { + kind: "table"; + unit: string; + columns: { key: string; label: string; numeric?: boolean }[]; + rows: Record[]; + currency?: string; + } + | { + kind: "heatmap"; + unit: string; + /** value per [dayOfWeek 0-6 (Mon first), hourOfDay 0-23] */ + cells: { day: number; hour: number; value: number }[]; + currency?: string; + } + | { + kind: "hosts"; + hosts: { + name: string; + hostPool: string; + tenant: string; + status: string; + sessions: number; + allowNewSession: boolean; + agentVersion: string | null; + lastHeartBeat: string | null; + }[]; + }; + +/** Lay widgets out left to right by size, for dashboards that have never been arranged. */ +export function defaultLayout(widgets: WidgetSpec[]): (WidgetSpec & { layout: WidgetLayout })[] { + let x = 0; + let y = 0; + let rowHeight = 0; + return widgets.map((widget) => { + if (widget.layout) { + return { ...widget, layout: widget.layout }; + } + const spec = SIZE_SPEC[widget.size]; + if (x + spec.span > GRID_COLUMNS) { + x = 0; + y += rowHeight; + rowHeight = 0; + } + const layout = { x, y, w: spec.span, h: spec.rows }; + x += spec.span; + rowHeight = Math.max(rowHeight, spec.rows); + return { ...widget, layout }; + }); +} + +export function vizResultKind(viz: WidgetSpec["viz"]): QueryResult["kind"] { + switch (viz) { + case "kpi": + return "scalar"; + case "line": + case "area": + case "stacked-bar": + return "series"; + case "bar": + case "donut": + return "breakdown"; + case "table": + return "table"; + case "heatmap": + return "heatmap"; + case "host-grid": + return "hosts"; + } +} diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 0000000..958c7cc --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -0,0 +1,9 @@ +import type { Config } from "drizzle-kit"; + +export default { + schema: "./src/schema.ts", + out: "./drizzle", + dialect: "postgresql", + dbCredentials: { url: process.env.DATABASE_URL ?? "postgres://dashflow:dashflow@localhost:5432/dashflow" }, + casing: "snake_case", +} satisfies Config; diff --git a/packages/db/drizzle/0000_init.sql b/packages/db/drizzle/0000_init.sql new file mode 100644 index 0000000..af94a14 --- /dev/null +++ b/packages/db/drizzle/0000_init.sql @@ -0,0 +1,332 @@ +CREATE TABLE "app_groups" ( + "resource_id" text PRIMARY KEY NOT NULL, + "customer_tenant_id" uuid NOT NULL, + "host_pool_id" text, + "name" text NOT NULL, + "group_type" text DEFAULT 'Desktop' NOT NULL, + "workspace_id" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "autoscale_events" ( + "key" text PRIMARY KEY NOT NULL, + "ts" timestamp with time zone NOT NULL, + "tenant_id" uuid NOT NULL, + "host_pool_id" text, + "action" text NOT NULL, + "hosts_before" integer, + "hosts_after" integer, + "detail" text +); +--> statement-breakpoint +CREATE TABLE "connection_facts" ( + "correlation_id" text PRIMARY KEY NOT NULL, + "tenant_id" uuid NOT NULL, + "host_pool_id" text, + "ts" timestamp with time zone NOT NULL, + "user_key" text NOT NULL, + "session_host" text, + "client_os" text, + "client_type" text, + "client_version" text, + "gateway_region" text, + "state" text NOT NULL, + "connect_ms" integer, + "rtt_ms" integer, + "bandwidth_kbps" integer, + "duration_sec" integer +); +--> statement-breakpoint +CREATE TABLE "connections" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "type" text NOT NULL, + "config" jsonb NOT NULL, + "schedule" jsonb NOT NULL, + "secret_id" uuid, + "refresh_secret_id" uuid, + "enabled" boolean DEFAULT true NOT NULL, + "status" text DEFAULT 'new' NOT NULL, + "last_tested_at" timestamp with time zone, + "last_test" jsonb, + "last_sync_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_by" uuid +); +--> statement-breakpoint +CREATE TABLE "cost_daily" ( + "key" text PRIMARY KEY NOT NULL, + "ts" timestamp with time zone NOT NULL, + "day" date NOT NULL, + "tenant_id" uuid NOT NULL, + "host_pool_id" text, + "meter_category" text DEFAULT 'Other' NOT NULL, + "cost" numeric(18, 4) NOT NULL, + "currency" text DEFAULT 'USD' NOT NULL, + "estimated_savings" numeric(18, 4) +); +--> statement-breakpoint +CREATE TABLE "customer_tenants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "connection_id" uuid NOT NULL, + "tenant_id" text NOT NULL, + "display_name" text NOT NULL, + "domain" text, + "enabled" boolean DEFAULT true NOT NULL, + "meta" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "customer_tenants_conn_tenant" UNIQUE("connection_id","tenant_id") +); +--> statement-breakpoint +CREATE TABLE "dashboards" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "description" text DEFAULT '' NOT NULL, + "goal" text DEFAULT 'blank' NOT NULL, + "widgets" jsonb NOT NULL, + "default_preset" text DEFAULT '7d' NOT NULL, + "tenant_scope" jsonb, + "visibility" text DEFAULT 'private' NOT NULL, + "shared_with_role" text DEFAULT 'viewer' NOT NULL, + "owner_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "error_facts" ( + "key" text PRIMARY KEY NOT NULL, + "tenant_id" uuid NOT NULL, + "host_pool_id" text, + "ts" timestamp with time zone NOT NULL, + "correlation_id" text, + "code" text NOT NULL, + "source" text, + "message" text, + "service_error" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE "host_health" ( + "ts" timestamp with time zone NOT NULL, + "session_host" text NOT NULL, + "host_pool_id" text NOT NULL, + "tenant_id" uuid NOT NULL, + "status" text NOT NULL, + "healthy" boolean NOT NULL, + "drain" boolean DEFAULT false NOT NULL, + "sessions" integer DEFAULT 0 NOT NULL, + CONSTRAINT "host_health_ts_host_pool_id_session_host_pk" PRIMARY KEY("ts","host_pool_id","session_host") +); +--> statement-breakpoint +CREATE TABLE "host_pools" ( + "resource_id" text PRIMARY KEY NOT NULL, + "customer_tenant_id" uuid NOT NULL, + "connection_id" uuid, + "subscription_id" text, + "name" text NOT NULL, + "friendly_name" text, + "location" text DEFAULT 'unknown' NOT NULL, + "pool_type" text DEFAULT 'Pooled' NOT NULL, + "load_balancer" text, + "max_sessions" integer, + "start_vm_on_connect" boolean, + "validation_environment" boolean, + "preferred_app_group_type" text, + "autoscale_enabled" boolean, + "sources" jsonb DEFAULT '[]'::jsonb NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "jobs" ( + "id" bigserial PRIMARY KEY NOT NULL, + "type" text NOT NULL, + "dedupe_key" text, + "payload" jsonb NOT NULL, + "run_at" timestamp with time zone DEFAULT now() NOT NULL, + "status" text DEFAULT 'queued' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "locked_at" timestamp with time zone, + "locked_by" text, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "jobs_dedupe_unique" UNIQUE NULLS NOT DISTINCT("dedupe_key","status") +); +--> statement-breakpoint +CREATE TABLE "log_analytics_workspaces" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "customer_tenant_id" uuid NOT NULL, + "resource_id" text NOT NULL, + "workspace_id" text NOT NULL, + "name" text NOT NULL, + "host_pool_ids" jsonb DEFAULT '[]'::jsonb NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + CONSTRAINT "la_workspaces_tenant_resource" UNIQUE("customer_tenant_id","resource_id") +); +--> statement-breakpoint +CREATE TABLE "perf_hourly" ( + "ts" timestamp with time zone NOT NULL, + "session_host" text NOT NULL, + "host_pool_id" text, + "tenant_id" uuid NOT NULL, + "cpu_pct" double precision, + "mem_available_mb" double precision, + CONSTRAINT "perf_hourly_ts_session_host_pk" PRIMARY KEY("ts","session_host") +); +--> statement-breakpoint +CREATE TABLE "role_assignments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "role" text NOT NULL, + "customer_tenant_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_by" uuid +); +--> statement-breakpoint +CREATE TABLE "scaling_plans" ( + "resource_id" text PRIMARY KEY NOT NULL, + "customer_tenant_id" uuid NOT NULL, + "name" text NOT NULL, + "time_zone" text, + "host_pool_ids" jsonb DEFAULT '[]'::jsonb NOT NULL, + "schedules" integer DEFAULT 0 NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "secrets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "kind" text NOT NULL, + "ciphertext" text, + "iv" text, + "auth_tag" text, + "key_vault_secret_name" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "rotated_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "session_hosts" ( + "resource_id" text PRIMARY KEY NOT NULL, + "host_pool_id" text NOT NULL, + "customer_tenant_id" uuid NOT NULL, + "name" text NOT NULL, + "vm_resource_id" text, + "status" text DEFAULT 'Unknown' NOT NULL, + "allow_new_session" boolean DEFAULT true NOT NULL, + "sessions" integer DEFAULT 0 NOT NULL, + "agent_version" text, + "os_version" text, + "last_heart_beat" timestamp with time zone, + "update_state" text, + "source" text DEFAULT 'azure' NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session_snapshots" ( + "ts" timestamp with time zone NOT NULL, + "host_pool_id" text NOT NULL, + "tenant_id" uuid NOT NULL, + "active_sessions" integer DEFAULT 0 NOT NULL, + "disconnected_sessions" integer DEFAULT 0 NOT NULL, + "capacity" integer DEFAULT 0 NOT NULL, + "available_hosts" integer DEFAULT 0 NOT NULL, + "total_hosts" integer DEFAULT 0 NOT NULL, + CONSTRAINT "session_snapshots_ts_host_pool_id_pk" PRIMARY KEY("ts","host_pool_id") +); +--> statement-breakpoint +CREATE TABLE "settings" ( + "key" text PRIMARY KEY NOT NULL, + "value" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "customer_tenant_id" uuid NOT NULL, + "subscription_id" text NOT NULL, + "display_name" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + CONSTRAINT "subscriptions_tenant_sub" UNIQUE("customer_tenant_id","subscription_id") +); +--> statement-breakpoint +CREATE TABLE "sync_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "connection_id" uuid NOT NULL, + "customer_tenant_id" uuid, + "stream" text NOT NULL, + "status" text NOT NULL, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "finished_at" timestamp with time zone, + "stats" jsonb, + "error" text +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "oid" text NOT NULL, + "tenant_id" text NOT NULL, + "email" text NOT NULL, + "display_name" text, + "disabled" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_seen_at" timestamp with time zone, + CONSTRAINT "users_oid_unique" UNIQUE("oid") +); +--> statement-breakpoint +CREATE TABLE "watermarks" ( + "connection_id" uuid NOT NULL, + "customer_tenant_id" uuid NOT NULL, + "stream" text NOT NULL, + "value" timestamp with time zone NOT NULL, + CONSTRAINT "watermarks_connection_id_customer_tenant_id_stream_pk" PRIMARY KEY("connection_id","customer_tenant_id","stream") +); +--> statement-breakpoint +ALTER TABLE "app_groups" ADD CONSTRAINT "app_groups_customer_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("customer_tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app_groups" ADD CONSTRAINT "app_groups_host_pool_id_host_pools_resource_id_fk" FOREIGN KEY ("host_pool_id") REFERENCES "public"."host_pools"("resource_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "autoscale_events" ADD CONSTRAINT "autoscale_events_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "autoscale_events" ADD CONSTRAINT "autoscale_events_host_pool_id_host_pools_resource_id_fk" FOREIGN KEY ("host_pool_id") REFERENCES "public"."host_pools"("resource_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "connection_facts" ADD CONSTRAINT "connection_facts_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "connection_facts" ADD CONSTRAINT "connection_facts_host_pool_id_host_pools_resource_id_fk" FOREIGN KEY ("host_pool_id") REFERENCES "public"."host_pools"("resource_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "connections" ADD CONSTRAINT "connections_secret_id_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "public"."secrets"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "connections" ADD CONSTRAINT "connections_refresh_secret_id_secrets_id_fk" FOREIGN KEY ("refresh_secret_id") REFERENCES "public"."secrets"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "connections" ADD CONSTRAINT "connections_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cost_daily" ADD CONSTRAINT "cost_daily_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cost_daily" ADD CONSTRAINT "cost_daily_host_pool_id_host_pools_resource_id_fk" FOREIGN KEY ("host_pool_id") REFERENCES "public"."host_pools"("resource_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "customer_tenants" ADD CONSTRAINT "customer_tenants_connection_id_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "error_facts" ADD CONSTRAINT "error_facts_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "error_facts" ADD CONSTRAINT "error_facts_host_pool_id_host_pools_resource_id_fk" FOREIGN KEY ("host_pool_id") REFERENCES "public"."host_pools"("resource_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_health" ADD CONSTRAINT "host_health_host_pool_id_host_pools_resource_id_fk" FOREIGN KEY ("host_pool_id") REFERENCES "public"."host_pools"("resource_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_health" ADD CONSTRAINT "host_health_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_pools" ADD CONSTRAINT "host_pools_customer_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("customer_tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_pools" ADD CONSTRAINT "host_pools_connection_id_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."connections"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "log_analytics_workspaces" ADD CONSTRAINT "log_analytics_workspaces_customer_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("customer_tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "perf_hourly" ADD CONSTRAINT "perf_hourly_host_pool_id_host_pools_resource_id_fk" FOREIGN KEY ("host_pool_id") REFERENCES "public"."host_pools"("resource_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "perf_hourly" ADD CONSTRAINT "perf_hourly_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_assignments" ADD CONSTRAINT "role_assignments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_assignments" ADD CONSTRAINT "role_assignments_customer_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("customer_tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_assignments" ADD CONSTRAINT "role_assignments_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scaling_plans" ADD CONSTRAINT "scaling_plans_customer_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("customer_tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_hosts" ADD CONSTRAINT "session_hosts_host_pool_id_host_pools_resource_id_fk" FOREIGN KEY ("host_pool_id") REFERENCES "public"."host_pools"("resource_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_hosts" ADD CONSTRAINT "session_hosts_customer_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("customer_tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_snapshots" ADD CONSTRAINT "session_snapshots_host_pool_id_host_pools_resource_id_fk" FOREIGN KEY ("host_pool_id") REFERENCES "public"."host_pools"("resource_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_snapshots" ADD CONSTRAINT "session_snapshots_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_customer_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("customer_tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sync_runs" ADD CONSTRAINT "sync_runs_connection_id_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sync_runs" ADD CONSTRAINT "sync_runs_customer_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("customer_tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "watermarks" ADD CONSTRAINT "watermarks_connection_id_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "watermarks" ADD CONSTRAINT "watermarks_customer_tenant_id_customer_tenants_id_fk" FOREIGN KEY ("customer_tenant_id") REFERENCES "public"."customer_tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "autoscale_events_tenant_ts" ON "autoscale_events" USING btree ("tenant_id","ts");--> statement-breakpoint +CREATE INDEX "connection_facts_tenant_ts" ON "connection_facts" USING btree ("tenant_id","ts");--> statement-breakpoint +CREATE INDEX "connection_facts_pool_ts" ON "connection_facts" USING btree ("host_pool_id","ts");--> statement-breakpoint +CREATE INDEX "cost_daily_tenant_ts" ON "cost_daily" USING btree ("tenant_id","ts");--> statement-breakpoint +CREATE INDEX "dashboards_owner_idx" ON "dashboards" USING btree ("owner_id");--> statement-breakpoint +CREATE INDEX "error_facts_tenant_ts" ON "error_facts" USING btree ("tenant_id","ts");--> statement-breakpoint +CREATE INDEX "error_facts_code" ON "error_facts" USING btree ("code");--> statement-breakpoint +CREATE INDEX "host_health_tenant_ts" ON "host_health" USING btree ("tenant_id","ts");--> statement-breakpoint +CREATE INDEX "host_pools_tenant_idx" ON "host_pools" USING btree ("customer_tenant_id");--> statement-breakpoint +CREATE INDEX "jobs_claim_idx" ON "jobs" USING btree ("status","run_at");--> statement-breakpoint +CREATE INDEX "perf_hourly_tenant_ts" ON "perf_hourly" USING btree ("tenant_id","ts");--> statement-breakpoint +CREATE INDEX "role_assignments_user_idx" ON "role_assignments" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_hosts_pool_idx" ON "session_hosts" USING btree ("host_pool_id");--> statement-breakpoint +CREATE INDEX "session_snapshots_tenant_ts" ON "session_snapshots" USING btree ("tenant_id","ts");--> statement-breakpoint +CREATE INDEX "sync_runs_recent_idx" ON "sync_runs" USING btree ("started_at"); \ No newline at end of file diff --git a/packages/db/drizzle/0001_dashboard_favorites.sql b/packages/db/drizzle/0001_dashboard_favorites.sql new file mode 100644 index 0000000..8b7c5bb --- /dev/null +++ b/packages/db/drizzle/0001_dashboard_favorites.sql @@ -0,0 +1,9 @@ +CREATE TABLE "dashboard_favorites" ( + "user_id" uuid NOT NULL, + "dashboard_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "dashboard_favorites_user_id_dashboard_id_pk" PRIMARY KEY("user_id","dashboard_id") +); +--> statement-breakpoint +ALTER TABLE "dashboard_favorites" ADD CONSTRAINT "dashboard_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dashboard_favorites" ADD CONSTRAINT "dashboard_favorites_dashboard_id_dashboards_id_fk" FOREIGN KEY ("dashboard_id") REFERENCES "public"."dashboards"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0000_snapshot.json b/packages/db/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..d416460 --- /dev/null +++ b/packages/db/drizzle/meta/0000_snapshot.json @@ -0,0 +1,2446 @@ +{ + "id": "6b627ee7-01a6-4ed2-a5c4-3882d11af576", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_groups": { + "name": "app_groups", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_type": { + "name": "group_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Desktop'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "app_groups_customer_tenant_id_customer_tenants_id_fk": { + "name": "app_groups_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "app_groups", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_groups_host_pool_id_host_pools_resource_id_fk": { + "name": "app_groups_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "app_groups", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autoscale_events": { + "name": "autoscale_events", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hosts_before": { + "name": "hosts_before", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hosts_after": { + "name": "hosts_after", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "autoscale_events_tenant_ts": { + "name": "autoscale_events_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autoscale_events_tenant_id_customer_tenants_id_fk": { + "name": "autoscale_events_tenant_id_customer_tenants_id_fk", + "tableFrom": "autoscale_events", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autoscale_events_host_pool_id_host_pools_resource_id_fk": { + "name": "autoscale_events_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "autoscale_events", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection_facts": { + "name": "connection_facts", + "schema": "", + "columns": { + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_key": { + "name": "user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_host": { + "name": "session_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_os": { + "name": "client_os", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_type": { + "name": "client_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_region": { + "name": "gateway_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connect_ms": { + "name": "connect_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rtt_ms": { + "name": "rtt_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bandwidth_kbps": { + "name": "bandwidth_kbps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_sec": { + "name": "duration_sec", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "connection_facts_tenant_ts": { + "name": "connection_facts_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connection_facts_pool_ts": { + "name": "connection_facts_pool_ts", + "columns": [ + { + "expression": "host_pool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connection_facts_tenant_id_customer_tenants_id_fk": { + "name": "connection_facts_tenant_id_customer_tenants_id_fk", + "tableFrom": "connection_facts", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_facts_host_pool_id_host_pools_resource_id_fk": { + "name": "connection_facts_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "connection_facts", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "refresh_secret_id": { + "name": "refresh_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test": { + "name": "last_test", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "connections_secret_id_secrets_id_fk": { + "name": "connections_secret_id_secrets_id_fk", + "tableFrom": "connections", + "tableTo": "secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "connections_refresh_secret_id_secrets_id_fk": { + "name": "connections_refresh_secret_id_secrets_id_fk", + "tableFrom": "connections", + "tableTo": "secrets", + "columnsFrom": [ + "refresh_secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "connections_created_by_users_id_fk": { + "name": "connections_created_by_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_daily": { + "name": "cost_daily", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meter_category": { + "name": "meter_category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Other'" + }, + "cost": { + "name": "cost", + "type": "numeric(18, 4)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "estimated_savings": { + "name": "estimated_savings", + "type": "numeric(18, 4)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cost_daily_tenant_ts": { + "name": "cost_daily_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_daily_tenant_id_customer_tenants_id_fk": { + "name": "cost_daily_tenant_id_customer_tenants_id_fk", + "tableFrom": "cost_daily", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cost_daily_host_pool_id_host_pools_resource_id_fk": { + "name": "cost_daily_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "cost_daily", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_tenants": { + "name": "customer_tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "customer_tenants_connection_id_connections_id_fk": { + "name": "customer_tenants_connection_id_connections_id_fk", + "tableFrom": "customer_tenants", + "tableTo": "connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "customer_tenants_conn_tenant": { + "name": "customer_tenants_conn_tenant", + "nullsNotDistinct": false, + "columns": [ + "connection_id", + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'blank'" + }, + "widgets": { + "name": "widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_preset": { + "name": "default_preset", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'7d'" + }, + "tenant_scope": { + "name": "tenant_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "shared_with_role": { + "name": "shared_with_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dashboards_owner_idx": { + "name": "dashboards_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dashboards_owner_id_users_id_fk": { + "name": "dashboards_owner_id_users_id_fk", + "tableFrom": "dashboards", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_facts": { + "name": "error_facts", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_error": { + "name": "service_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "error_facts_tenant_ts": { + "name": "error_facts_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_facts_code": { + "name": "error_facts_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "error_facts_tenant_id_customer_tenants_id_fk": { + "name": "error_facts_tenant_id_customer_tenants_id_fk", + "tableFrom": "error_facts", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "error_facts_host_pool_id_host_pools_resource_id_fk": { + "name": "error_facts_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "error_facts", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_health": { + "name": "host_health", + "schema": "", + "columns": { + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "session_host": { + "name": "session_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "healthy": { + "name": "healthy", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "drain": { + "name": "drain", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "host_health_tenant_ts": { + "name": "host_health_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_health_host_pool_id_host_pools_resource_id_fk": { + "name": "host_health_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "host_health", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_tenant_id_customer_tenants_id_fk": { + "name": "host_health_tenant_id_customer_tenants_id_fk", + "tableFrom": "host_health", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_health_ts_host_pool_id_session_host_pk": { + "name": "host_health_ts_host_pool_id_session_host_pk", + "columns": [ + "ts", + "host_pool_id", + "session_host" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_pools": { + "name": "host_pools", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "friendly_name": { + "name": "friendly_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "pool_type": { + "name": "pool_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Pooled'" + }, + "load_balancer": { + "name": "load_balancer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_sessions": { + "name": "max_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "start_vm_on_connect": { + "name": "start_vm_on_connect", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "validation_environment": { + "name": "validation_environment", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "preferred_app_group_type": { + "name": "preferred_app_group_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autoscale_enabled": { + "name": "autoscale_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "host_pools_tenant_idx": { + "name": "host_pools_tenant_idx", + "columns": [ + { + "expression": "customer_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_pools_customer_tenant_id_customer_tenants_id_fk": { + "name": "host_pools_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "host_pools", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_pools_connection_id_connections_id_fk": { + "name": "host_pools_connection_id_connections_id_fk", + "tableFrom": "host_pools", + "tableTo": "connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "jobs_claim_idx": { + "name": "jobs_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "jobs_dedupe_unique": { + "name": "jobs_dedupe_unique", + "nullsNotDistinct": true, + "columns": [ + "dedupe_key", + "status" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_analytics_workspaces": { + "name": "log_analytics_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_pool_ids": { + "name": "host_pool_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "log_analytics_workspaces_customer_tenant_id_customer_tenants_id_fk": { + "name": "log_analytics_workspaces_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "log_analytics_workspaces", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "la_workspaces_tenant_resource": { + "name": "la_workspaces_tenant_resource", + "nullsNotDistinct": false, + "columns": [ + "customer_tenant_id", + "resource_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.perf_hourly": { + "name": "perf_hourly", + "schema": "", + "columns": { + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "session_host": { + "name": "session_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cpu_pct": { + "name": "cpu_pct", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "mem_available_mb": { + "name": "mem_available_mb", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "perf_hourly_tenant_ts": { + "name": "perf_hourly_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "perf_hourly_host_pool_id_host_pools_resource_id_fk": { + "name": "perf_hourly_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "perf_hourly", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "perf_hourly_tenant_id_customer_tenants_id_fk": { + "name": "perf_hourly_tenant_id_customer_tenants_id_fk", + "tableFrom": "perf_hourly", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "perf_hourly_ts_session_host_pk": { + "name": "perf_hourly_ts_session_host_pk", + "columns": [ + "ts", + "session_host" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_assignments": { + "name": "role_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "role_assignments_user_idx": { + "name": "role_assignments_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_assignments_user_id_users_id_fk": { + "name": "role_assignments_user_id_users_id_fk", + "tableFrom": "role_assignments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_assignments_customer_tenant_id_customer_tenants_id_fk": { + "name": "role_assignments_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "role_assignments", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_assignments_created_by_users_id_fk": { + "name": "role_assignments_created_by_users_id_fk", + "tableFrom": "role_assignments", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scaling_plans": { + "name": "scaling_plans", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_zone": { + "name": "time_zone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_pool_ids": { + "name": "host_pool_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "schedules": { + "name": "schedules", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "scaling_plans_customer_tenant_id_customer_tenants_id_fk": { + "name": "scaling_plans_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "scaling_plans", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_tag": { + "name": "auth_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_vault_secret_name": { + "name": "key_vault_secret_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_hosts": { + "name": "session_hosts", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vm_resource_id": { + "name": "vm_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unknown'" + }, + "allow_new_session": { + "name": "allow_new_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "os_version": { + "name": "os_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_heart_beat": { + "name": "last_heart_beat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "update_state": { + "name": "update_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'azure'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_hosts_pool_idx": { + "name": "session_hosts_pool_idx", + "columns": [ + { + "expression": "host_pool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_hosts_host_pool_id_host_pools_resource_id_fk": { + "name": "session_hosts_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "session_hosts", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_hosts_customer_tenant_id_customer_tenants_id_fk": { + "name": "session_hosts_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "session_hosts", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_snapshots": { + "name": "session_snapshots", + "schema": "", + "columns": { + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_sessions": { + "name": "active_sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "disconnected_sessions": { + "name": "disconnected_sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_hosts": { + "name": "available_hosts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_hosts": { + "name": "total_hosts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "session_snapshots_tenant_ts": { + "name": "session_snapshots_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_snapshots_host_pool_id_host_pools_resource_id_fk": { + "name": "session_snapshots_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "session_snapshots", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_snapshots_tenant_id_customer_tenants_id_fk": { + "name": "session_snapshots_tenant_id_customer_tenants_id_fk", + "tableFrom": "session_snapshots", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_snapshots_ts_host_pool_id_pk": { + "name": "session_snapshots_ts_host_pool_id_pk", + "columns": [ + "ts", + "host_pool_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_customer_tenant_id_customer_tenants_id_fk": { + "name": "subscriptions_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "subscriptions", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_tenant_sub": { + "name": "subscriptions_tenant_sub", + "nullsNotDistinct": false, + "columns": [ + "customer_tenant_id", + "subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stats": { + "name": "stats", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sync_runs_recent_idx": { + "name": "sync_runs_recent_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_runs_connection_id_connections_id_fk": { + "name": "sync_runs_connection_id_connections_id_fk", + "tableFrom": "sync_runs", + "tableTo": "connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_runs_customer_tenant_id_customer_tenants_id_fk": { + "name": "sync_runs_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "sync_runs", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "oid": { + "name": "oid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_oid_unique": { + "name": "users_oid_unique", + "nullsNotDistinct": false, + "columns": [ + "oid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watermarks": { + "name": "watermarks", + "schema": "", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "watermarks_connection_id_connections_id_fk": { + "name": "watermarks_connection_id_connections_id_fk", + "tableFrom": "watermarks", + "tableTo": "connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watermarks_customer_tenant_id_customer_tenants_id_fk": { + "name": "watermarks_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "watermarks", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watermarks_connection_id_customer_tenant_id_stream_pk": { + "name": "watermarks_connection_id_customer_tenant_id_stream_pk", + "columns": [ + "connection_id", + "customer_tenant_id", + "stream" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..28964fd --- /dev/null +++ b/packages/db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,2513 @@ +{ + "id": "1ab85b77-11f9-411f-b55a-43d842582da4", + "prevId": "6b627ee7-01a6-4ed2-a5c4-3882d11af576", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_groups": { + "name": "app_groups", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_type": { + "name": "group_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Desktop'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "app_groups_customer_tenant_id_customer_tenants_id_fk": { + "name": "app_groups_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "app_groups", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_groups_host_pool_id_host_pools_resource_id_fk": { + "name": "app_groups_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "app_groups", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autoscale_events": { + "name": "autoscale_events", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hosts_before": { + "name": "hosts_before", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hosts_after": { + "name": "hosts_after", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "autoscale_events_tenant_ts": { + "name": "autoscale_events_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autoscale_events_tenant_id_customer_tenants_id_fk": { + "name": "autoscale_events_tenant_id_customer_tenants_id_fk", + "tableFrom": "autoscale_events", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autoscale_events_host_pool_id_host_pools_resource_id_fk": { + "name": "autoscale_events_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "autoscale_events", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection_facts": { + "name": "connection_facts", + "schema": "", + "columns": { + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_key": { + "name": "user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_host": { + "name": "session_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_os": { + "name": "client_os", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_type": { + "name": "client_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_region": { + "name": "gateway_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connect_ms": { + "name": "connect_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rtt_ms": { + "name": "rtt_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bandwidth_kbps": { + "name": "bandwidth_kbps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_sec": { + "name": "duration_sec", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "connection_facts_tenant_ts": { + "name": "connection_facts_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connection_facts_pool_ts": { + "name": "connection_facts_pool_ts", + "columns": [ + { + "expression": "host_pool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connection_facts_tenant_id_customer_tenants_id_fk": { + "name": "connection_facts_tenant_id_customer_tenants_id_fk", + "tableFrom": "connection_facts", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_facts_host_pool_id_host_pools_resource_id_fk": { + "name": "connection_facts_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "connection_facts", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "refresh_secret_id": { + "name": "refresh_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test": { + "name": "last_test", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "connections_secret_id_secrets_id_fk": { + "name": "connections_secret_id_secrets_id_fk", + "tableFrom": "connections", + "tableTo": "secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "connections_refresh_secret_id_secrets_id_fk": { + "name": "connections_refresh_secret_id_secrets_id_fk", + "tableFrom": "connections", + "tableTo": "secrets", + "columnsFrom": [ + "refresh_secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "connections_created_by_users_id_fk": { + "name": "connections_created_by_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_daily": { + "name": "cost_daily", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meter_category": { + "name": "meter_category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Other'" + }, + "cost": { + "name": "cost", + "type": "numeric(18, 4)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "estimated_savings": { + "name": "estimated_savings", + "type": "numeric(18, 4)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cost_daily_tenant_ts": { + "name": "cost_daily_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_daily_tenant_id_customer_tenants_id_fk": { + "name": "cost_daily_tenant_id_customer_tenants_id_fk", + "tableFrom": "cost_daily", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cost_daily_host_pool_id_host_pools_resource_id_fk": { + "name": "cost_daily_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "cost_daily", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_tenants": { + "name": "customer_tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "customer_tenants_connection_id_connections_id_fk": { + "name": "customer_tenants_connection_id_connections_id_fk", + "tableFrom": "customer_tenants", + "tableTo": "connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "customer_tenants_conn_tenant": { + "name": "customer_tenants_conn_tenant", + "nullsNotDistinct": false, + "columns": [ + "connection_id", + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_favorites": { + "name": "dashboard_favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_favorites_user_id_users_id_fk": { + "name": "dashboard_favorites_user_id_users_id_fk", + "tableFrom": "dashboard_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dashboard_favorites_dashboard_id_dashboards_id_fk": { + "name": "dashboard_favorites_dashboard_id_dashboards_id_fk", + "tableFrom": "dashboard_favorites", + "tableTo": "dashboards", + "columnsFrom": [ + "dashboard_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dashboard_favorites_user_id_dashboard_id_pk": { + "name": "dashboard_favorites_user_id_dashboard_id_pk", + "columns": [ + "user_id", + "dashboard_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'blank'" + }, + "widgets": { + "name": "widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_preset": { + "name": "default_preset", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'7d'" + }, + "tenant_scope": { + "name": "tenant_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "shared_with_role": { + "name": "shared_with_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dashboards_owner_idx": { + "name": "dashboards_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dashboards_owner_id_users_id_fk": { + "name": "dashboards_owner_id_users_id_fk", + "tableFrom": "dashboards", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_facts": { + "name": "error_facts", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_error": { + "name": "service_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "error_facts_tenant_ts": { + "name": "error_facts_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_facts_code": { + "name": "error_facts_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "error_facts_tenant_id_customer_tenants_id_fk": { + "name": "error_facts_tenant_id_customer_tenants_id_fk", + "tableFrom": "error_facts", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "error_facts_host_pool_id_host_pools_resource_id_fk": { + "name": "error_facts_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "error_facts", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_health": { + "name": "host_health", + "schema": "", + "columns": { + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "session_host": { + "name": "session_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "healthy": { + "name": "healthy", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "drain": { + "name": "drain", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "host_health_tenant_ts": { + "name": "host_health_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_health_host_pool_id_host_pools_resource_id_fk": { + "name": "host_health_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "host_health", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_tenant_id_customer_tenants_id_fk": { + "name": "host_health_tenant_id_customer_tenants_id_fk", + "tableFrom": "host_health", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_health_ts_host_pool_id_session_host_pk": { + "name": "host_health_ts_host_pool_id_session_host_pk", + "columns": [ + "ts", + "host_pool_id", + "session_host" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_pools": { + "name": "host_pools", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "friendly_name": { + "name": "friendly_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "pool_type": { + "name": "pool_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Pooled'" + }, + "load_balancer": { + "name": "load_balancer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_sessions": { + "name": "max_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "start_vm_on_connect": { + "name": "start_vm_on_connect", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "validation_environment": { + "name": "validation_environment", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "preferred_app_group_type": { + "name": "preferred_app_group_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autoscale_enabled": { + "name": "autoscale_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "host_pools_tenant_idx": { + "name": "host_pools_tenant_idx", + "columns": [ + { + "expression": "customer_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_pools_customer_tenant_id_customer_tenants_id_fk": { + "name": "host_pools_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "host_pools", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_pools_connection_id_connections_id_fk": { + "name": "host_pools_connection_id_connections_id_fk", + "tableFrom": "host_pools", + "tableTo": "connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "jobs_claim_idx": { + "name": "jobs_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "jobs_dedupe_unique": { + "name": "jobs_dedupe_unique", + "nullsNotDistinct": true, + "columns": [ + "dedupe_key", + "status" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_analytics_workspaces": { + "name": "log_analytics_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_pool_ids": { + "name": "host_pool_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "log_analytics_workspaces_customer_tenant_id_customer_tenants_id_fk": { + "name": "log_analytics_workspaces_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "log_analytics_workspaces", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "la_workspaces_tenant_resource": { + "name": "la_workspaces_tenant_resource", + "nullsNotDistinct": false, + "columns": [ + "customer_tenant_id", + "resource_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.perf_hourly": { + "name": "perf_hourly", + "schema": "", + "columns": { + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "session_host": { + "name": "session_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cpu_pct": { + "name": "cpu_pct", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "mem_available_mb": { + "name": "mem_available_mb", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "perf_hourly_tenant_ts": { + "name": "perf_hourly_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "perf_hourly_host_pool_id_host_pools_resource_id_fk": { + "name": "perf_hourly_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "perf_hourly", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "perf_hourly_tenant_id_customer_tenants_id_fk": { + "name": "perf_hourly_tenant_id_customer_tenants_id_fk", + "tableFrom": "perf_hourly", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "perf_hourly_ts_session_host_pk": { + "name": "perf_hourly_ts_session_host_pk", + "columns": [ + "ts", + "session_host" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_assignments": { + "name": "role_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "role_assignments_user_idx": { + "name": "role_assignments_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_assignments_user_id_users_id_fk": { + "name": "role_assignments_user_id_users_id_fk", + "tableFrom": "role_assignments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_assignments_customer_tenant_id_customer_tenants_id_fk": { + "name": "role_assignments_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "role_assignments", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_assignments_created_by_users_id_fk": { + "name": "role_assignments_created_by_users_id_fk", + "tableFrom": "role_assignments", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scaling_plans": { + "name": "scaling_plans", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_zone": { + "name": "time_zone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_pool_ids": { + "name": "host_pool_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "schedules": { + "name": "schedules", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "scaling_plans_customer_tenant_id_customer_tenants_id_fk": { + "name": "scaling_plans_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "scaling_plans", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_tag": { + "name": "auth_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_vault_secret_name": { + "name": "key_vault_secret_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_hosts": { + "name": "session_hosts", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vm_resource_id": { + "name": "vm_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unknown'" + }, + "allow_new_session": { + "name": "allow_new_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "os_version": { + "name": "os_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_heart_beat": { + "name": "last_heart_beat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "update_state": { + "name": "update_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'azure'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_hosts_pool_idx": { + "name": "session_hosts_pool_idx", + "columns": [ + { + "expression": "host_pool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_hosts_host_pool_id_host_pools_resource_id_fk": { + "name": "session_hosts_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "session_hosts", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_hosts_customer_tenant_id_customer_tenants_id_fk": { + "name": "session_hosts_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "session_hosts", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_snapshots": { + "name": "session_snapshots", + "schema": "", + "columns": { + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "host_pool_id": { + "name": "host_pool_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_sessions": { + "name": "active_sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "disconnected_sessions": { + "name": "disconnected_sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_hosts": { + "name": "available_hosts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_hosts": { + "name": "total_hosts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "session_snapshots_tenant_ts": { + "name": "session_snapshots_tenant_ts", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_snapshots_host_pool_id_host_pools_resource_id_fk": { + "name": "session_snapshots_host_pool_id_host_pools_resource_id_fk", + "tableFrom": "session_snapshots", + "tableTo": "host_pools", + "columnsFrom": [ + "host_pool_id" + ], + "columnsTo": [ + "resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_snapshots_tenant_id_customer_tenants_id_fk": { + "name": "session_snapshots_tenant_id_customer_tenants_id_fk", + "tableFrom": "session_snapshots", + "tableTo": "customer_tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_snapshots_ts_host_pool_id_pk": { + "name": "session_snapshots_ts_host_pool_id_pk", + "columns": [ + "ts", + "host_pool_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_customer_tenant_id_customer_tenants_id_fk": { + "name": "subscriptions_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "subscriptions", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_tenant_sub": { + "name": "subscriptions_tenant_sub", + "nullsNotDistinct": false, + "columns": [ + "customer_tenant_id", + "subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stats": { + "name": "stats", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sync_runs_recent_idx": { + "name": "sync_runs_recent_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_runs_connection_id_connections_id_fk": { + "name": "sync_runs_connection_id_connections_id_fk", + "tableFrom": "sync_runs", + "tableTo": "connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_runs_customer_tenant_id_customer_tenants_id_fk": { + "name": "sync_runs_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "sync_runs", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "oid": { + "name": "oid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_oid_unique": { + "name": "users_oid_unique", + "nullsNotDistinct": false, + "columns": [ + "oid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watermarks": { + "name": "watermarks", + "schema": "", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "customer_tenant_id": { + "name": "customer_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "watermarks_connection_id_connections_id_fk": { + "name": "watermarks_connection_id_connections_id_fk", + "tableFrom": "watermarks", + "tableTo": "connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watermarks_customer_tenant_id_customer_tenants_id_fk": { + "name": "watermarks_customer_tenant_id_customer_tenants_id_fk", + "tableFrom": "watermarks", + "tableTo": "customer_tenants", + "columnsFrom": [ + "customer_tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watermarks_connection_id_customer_tenant_id_stream_pk": { + "name": "watermarks_connection_id_customer_tenant_id_stream_pk", + "columns": [ + "connection_id", + "customer_tenant_id", + "stream" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json new file mode 100644 index 0000000..6c53a4e --- /dev/null +++ b/packages/db/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1789667952203, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1789696633805, + "tag": "0001_dashboard_favorites", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..db4332f --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,22 @@ +{ + "name": "@dashflow/db", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./schema": "./src/schema.ts", + "./migrate": "./src/migrate.ts" + }, + "scripts": { + "generate": "drizzle-kit generate" + }, + "dependencies": { + "@dashflow/core": "workspace:*", + "drizzle-orm": "0.45.2", + "postgres": "3.4.9" + }, + "devDependencies": { + "drizzle-kit": "0.31.10" + } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..9409137 --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,32 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "./schema"; + +export * as schema from "./schema"; +export { schema as tables }; + +export type Db = ReturnType["db"]; +export type SqlClient = ReturnType["client"]; + +export interface DbOptions { + url: string; + max?: number; + /** + * Returns an Entra access token used as the Postgres password. Set this on Azure so the + * container authenticates to Postgres Flexible Server with its managed identity and no + * password is ever stored. + */ + tokenProvider?: () => Promise; + onNotice?: (notice: unknown) => void; +} + +export function createDb(opts: DbOptions) { + const client = postgres(opts.url, { + max: opts.max ?? 10, + prepare: false, + onnotice: opts.onNotice ?? (() => {}), + ...(opts.tokenProvider ? { password: opts.tokenProvider } : {}), + }); + const db = drizzle(client, { schema, casing: "snake_case" }); + return { db, client }; +} diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts new file mode 100644 index 0000000..fb1e686 --- /dev/null +++ b/packages/db/src/migrate.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env bun +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { sql } from "drizzle-orm"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import { createDb, type Db } from "./index"; + +const MIGRATIONS_FOLDER = join(dirname(fileURLToPath(import.meta.url)), "..", "drizzle"); +/** Arbitrary but stable key so two replicas starting at once do not race. */ +const LOCK_KEY = 728_431_907; + +export async function runMigrations(db: Db, log: (msg: string) => void = console.log): Promise { + await db.execute(sql`select pg_advisory_lock(${LOCK_KEY})`); + try { + log("applying migrations"); + await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + log("migrations up to date"); + } finally { + await db.execute(sql`select pg_advisory_unlock(${LOCK_KEY})`); + } +} + +if (import.meta.main) { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL is required"); + process.exit(1); + } + const { db, client } = createDb({ url, max: 1 }); + try { + await runMigrations(db); + } finally { + await client.end(); + } +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts new file mode 100644 index 0000000..4fac8e2 --- /dev/null +++ b/packages/db/src/schema.ts @@ -0,0 +1,488 @@ +import type { + ConnectionConfig, + DashboardSpec, + DiagnosticCheck, + Role, + SyncSchedule, + WidgetSpec, +} from "@dashflow/core"; +import { relations, sql } from "drizzle-orm"; +import { + bigserial, + boolean, + date, + doublePrecision, + index, + integer, + jsonb, + numeric, + pgTable, + primaryKey, + text, + timestamp, + unique, + uuid, +} from "drizzle-orm/pg-core"; + +const ts = (name: string) => timestamp(name, { withTimezone: true, mode: "date" }); +const now = () => ts("created_at").notNull().defaultNow(); + +// ─── instance, users, access ──────────────────────────────────────────────────── + +/** Instance-level key/value settings: setup progress, instance name, user pseudonymization. */ +export const settings = pgTable("settings", { + key: text("key").primaryKey(), + value: jsonb("value").notNull(), + updatedAt: ts("updated_at").notNull().defaultNow(), +}); + +export const users = pgTable( + "users", + { + id: uuid("id").primaryKey().defaultRandom(), + /** Entra object id from the EasyAuth principal. */ + oid: text("oid").notNull(), + tenantId: text("tenant_id").notNull(), + email: text("email").notNull(), + displayName: text("display_name"), + disabled: boolean("disabled").notNull().default(false), + createdAt: now(), + lastSeenAt: ts("last_seen_at"), + }, + (t) => [unique("users_oid_unique").on(t.oid)], +); + +export const roleAssignments = pgTable( + "role_assignments", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + role: text("role").$type().notNull(), + /** null = all customers; otherwise limited to this customer tenant row. */ + customerTenantId: uuid("customer_tenant_id").references(() => customerTenants.id, { + onDelete: "cascade", + }), + createdAt: now(), + createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), + }, + (t) => [index("role_assignments_user_idx").on(t.userId)], +); + +// ─── secrets and connections ─────────────────────────────────────────────────── + +/** + * Either an AES-256-GCM ciphertext (kind = "encrypted") or a pointer to a Key Vault + * secret (kind = "keyvault"). Plaintext never lands in this table. + */ +export const secrets = pgTable("secrets", { + id: uuid("id").primaryKey().defaultRandom(), + kind: text("kind").$type<"encrypted" | "keyvault">().notNull(), + ciphertext: text("ciphertext"), + iv: text("iv"), + authTag: text("auth_tag"), + keyVaultSecretName: text("key_vault_secret_name"), + createdAt: now(), + rotatedAt: ts("rotated_at"), +}); + +export const connections = pgTable("connections", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + type: text("type").$type().notNull(), + config: jsonb("config").$type().notNull(), + schedule: jsonb("schedule").$type().notNull(), + secretId: uuid("secret_id").references(() => secrets.id, { onDelete: "set null" }), + /** Partner Center refresh token, kept apart from the client secret. */ + refreshSecretId: uuid("refresh_secret_id").references(() => secrets.id, { onDelete: "set null" }), + enabled: boolean("enabled").notNull().default(true), + status: text("status").$type<"new" | "ok" | "degraded" | "error">().notNull().default("new"), + lastTestedAt: ts("last_tested_at"), + lastTest: jsonb("last_test").$type(), + lastSyncAt: ts("last_sync_at"), + createdAt: now(), + createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), +}); + +export const customerTenants = pgTable( + "customer_tenants", + { + id: uuid("id").primaryKey().defaultRandom(), + connectionId: uuid("connection_id") + .notNull() + .references(() => connections.id, { onDelete: "cascade" }), + tenantId: text("tenant_id").notNull(), + displayName: text("display_name").notNull(), + domain: text("domain"), + enabled: boolean("enabled").notNull().default(true), + meta: jsonb("meta").$type>(), + createdAt: now(), + }, + (t) => [unique("customer_tenants_conn_tenant").on(t.connectionId, t.tenantId)], +); + +export const subscriptions = pgTable( + "subscriptions", + { + id: uuid("id").primaryKey().defaultRandom(), + customerTenantId: uuid("customer_tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + subscriptionId: text("subscription_id").notNull(), + displayName: text("display_name").notNull(), + enabled: boolean("enabled").notNull().default(true), + }, + (t) => [unique("subscriptions_tenant_sub").on(t.customerTenantId, t.subscriptionId)], +); + +export const logAnalyticsWorkspaces = pgTable( + "log_analytics_workspaces", + { + id: uuid("id").primaryKey().defaultRandom(), + customerTenantId: uuid("customer_tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + resourceId: text("resource_id").notNull(), + /** The workspace "customerId" GUID used by api.loganalytics.io. */ + workspaceId: text("workspace_id").notNull(), + name: text("name").notNull(), + hostPoolIds: jsonb("host_pool_ids").$type().notNull().default(sql`'[]'::jsonb`), + enabled: boolean("enabled").notNull().default(true), + }, + (t) => [unique("la_workspaces_tenant_resource").on(t.customerTenantId, t.resourceId)], +); + +// ─── sync plumbing ───────────────────────────────────────────────────────────── + +export const jobs = pgTable( + "jobs", + { + id: bigserial("id", { mode: "number" }).primaryKey(), + type: text("type").notNull(), + /** Deduplicates queued work, e.g. "logs::". */ + dedupeKey: text("dedupe_key"), + payload: jsonb("payload").$type>().notNull(), + runAt: ts("run_at").notNull().defaultNow(), + status: text("status").$type<"queued" | "running" | "done" | "failed">().notNull().default("queued"), + attempts: integer("attempts").notNull().default(0), + lockedAt: ts("locked_at"), + lockedBy: text("locked_by"), + lastError: text("last_error"), + createdAt: now(), + }, + (t) => [ + index("jobs_claim_idx").on(t.status, t.runAt), + unique("jobs_dedupe_unique").on(t.dedupeKey, t.status).nullsNotDistinct(), + ], +); + +export const syncRuns = pgTable( + "sync_runs", + { + id: uuid("id").primaryKey().defaultRandom(), + connectionId: uuid("connection_id") + .notNull() + .references(() => connections.id, { onDelete: "cascade" }), + customerTenantId: uuid("customer_tenant_id").references(() => customerTenants.id, { + onDelete: "cascade", + }), + stream: text("stream").notNull(), + status: text("status").$type<"running" | "ok" | "partial" | "error">().notNull(), + startedAt: ts("started_at").notNull().defaultNow(), + finishedAt: ts("finished_at"), + stats: jsonb("stats").$type>(), + error: text("error"), + }, + (t) => [index("sync_runs_recent_idx").on(t.startedAt)], +); + +export const watermarks = pgTable( + "watermarks", + { + connectionId: uuid("connection_id") + .notNull() + .references(() => connections.id, { onDelete: "cascade" }), + customerTenantId: uuid("customer_tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + stream: text("stream").notNull(), + value: ts("value").notNull(), + }, + (t) => [primaryKey({ columns: [t.connectionId, t.customerTenantId, t.stream] })], +); + +// ─── AVD inventory ───────────────────────────────────────────────────────────── + +export const hostPools = pgTable( + "host_pools", + { + /** Lowercased ARM resource id — the join key for every fact table. */ + resourceId: text("resource_id").primaryKey(), + customerTenantId: uuid("customer_tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + connectionId: uuid("connection_id").references(() => connections.id, { onDelete: "set null" }), + subscriptionId: text("subscription_id"), + name: text("name").notNull(), + friendlyName: text("friendly_name"), + location: text("location").notNull().default("unknown"), + poolType: text("pool_type").notNull().default("Pooled"), + loadBalancer: text("load_balancer"), + maxSessions: integer("max_sessions"), + startVmOnConnect: boolean("start_vm_on_connect"), + validationEnvironment: boolean("validation_environment"), + preferredAppGroupType: text("preferred_app_group_type"), + autoscaleEnabled: boolean("autoscale_enabled"), + sources: jsonb("sources").$type().notNull().default(sql`'[]'::jsonb`), + enabled: boolean("enabled").notNull().default(true), + firstSeenAt: now(), + updatedAt: ts("updated_at").notNull().defaultNow(), + }, + (t) => [index("host_pools_tenant_idx").on(t.customerTenantId)], +); + +export const sessionHosts = pgTable( + "session_hosts", + { + resourceId: text("resource_id").primaryKey(), + hostPoolId: text("host_pool_id") + .notNull() + .references(() => hostPools.resourceId, { onDelete: "cascade" }), + customerTenantId: uuid("customer_tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + name: text("name").notNull(), + vmResourceId: text("vm_resource_id"), + status: text("status").notNull().default("Unknown"), + allowNewSession: boolean("allow_new_session").notNull().default(true), + sessions: integer("sessions").notNull().default(0), + agentVersion: text("agent_version"), + osVersion: text("os_version"), + lastHeartBeat: ts("last_heart_beat"), + updateState: text("update_state"), + source: text("source").notNull().default("azure"), + updatedAt: ts("updated_at").notNull().defaultNow(), + }, + (t) => [index("session_hosts_pool_idx").on(t.hostPoolId)], +); + +export const appGroups = pgTable("app_groups", { + resourceId: text("resource_id").primaryKey(), + customerTenantId: uuid("customer_tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + hostPoolId: text("host_pool_id").references(() => hostPools.resourceId, { onDelete: "set null" }), + name: text("name").notNull(), + groupType: text("group_type").notNull().default("Desktop"), + workspaceId: text("workspace_id"), + updatedAt: ts("updated_at").notNull().defaultNow(), +}); + +export const scalingPlans = pgTable("scaling_plans", { + resourceId: text("resource_id").primaryKey(), + customerTenantId: uuid("customer_tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + name: text("name").notNull(), + timeZone: text("time_zone"), + hostPoolIds: jsonb("host_pool_ids").$type().notNull().default(sql`'[]'::jsonb`), + schedules: integer("schedules").notNull().default(0), + updatedAt: ts("updated_at").notNull().defaultNow(), +}); + +// ─── facts ───────────────────────────────────────────────────────────────────── + +export const sessionSnapshots = pgTable( + "session_snapshots", + { + ts: ts("ts").notNull(), + hostPoolId: text("host_pool_id") + .notNull() + .references(() => hostPools.resourceId, { onDelete: "cascade" }), + tenantId: uuid("tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + activeSessions: integer("active_sessions").notNull().default(0), + disconnectedSessions: integer("disconnected_sessions").notNull().default(0), + capacity: integer("capacity").notNull().default(0), + availableHosts: integer("available_hosts").notNull().default(0), + totalHosts: integer("total_hosts").notNull().default(0), + }, + (t) => [ + primaryKey({ columns: [t.ts, t.hostPoolId] }), + index("session_snapshots_tenant_ts").on(t.tenantId, t.ts), + ], +); + +export const connectionFacts = pgTable( + "connection_facts", + { + correlationId: text("correlation_id").primaryKey(), + tenantId: uuid("tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + hostPoolId: text("host_pool_id").references(() => hostPools.resourceId, { onDelete: "set null" }), + ts: ts("ts").notNull(), + /** UPN, or a salted hash of it when pseudonymization is on (the default). */ + userKey: text("user_key").notNull(), + sessionHost: text("session_host"), + clientOs: text("client_os"), + clientType: text("client_type"), + clientVersion: text("client_version"), + gatewayRegion: text("gateway_region"), + state: text("state").$type<"connected" | "failed">().notNull(), + connectMs: integer("connect_ms"), + rttMs: integer("rtt_ms"), + bandwidthKbps: integer("bandwidth_kbps"), + durationSec: integer("duration_sec"), + }, + (t) => [ + index("connection_facts_tenant_ts").on(t.tenantId, t.ts), + index("connection_facts_pool_ts").on(t.hostPoolId, t.ts), + ], +); + +export const errorFacts = pgTable( + "error_facts", + { + key: text("key").primaryKey(), + tenantId: uuid("tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + hostPoolId: text("host_pool_id").references(() => hostPools.resourceId, { onDelete: "set null" }), + ts: ts("ts").notNull(), + correlationId: text("correlation_id"), + code: text("code").notNull(), + source: text("source"), + message: text("message"), + serviceError: boolean("service_error").notNull().default(false), + }, + (t) => [index("error_facts_tenant_ts").on(t.tenantId, t.ts), index("error_facts_code").on(t.code)], +); + +export const hostHealth = pgTable( + "host_health", + { + ts: ts("ts").notNull(), + sessionHost: text("session_host").notNull(), + hostPoolId: text("host_pool_id") + .notNull() + .references(() => hostPools.resourceId, { onDelete: "cascade" }), + tenantId: uuid("tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + status: text("status").notNull(), + healthy: boolean("healthy").notNull(), + drain: boolean("drain").notNull().default(false), + sessions: integer("sessions").notNull().default(0), + }, + (t) => [ + primaryKey({ columns: [t.ts, t.hostPoolId, t.sessionHost] }), + index("host_health_tenant_ts").on(t.tenantId, t.ts), + ], +); + +export const perfHourly = pgTable( + "perf_hourly", + { + ts: ts("ts").notNull(), + sessionHost: text("session_host").notNull(), + hostPoolId: text("host_pool_id").references(() => hostPools.resourceId, { onDelete: "cascade" }), + tenantId: uuid("tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + cpuPct: doublePrecision("cpu_pct"), + memAvailableMb: doublePrecision("mem_available_mb"), + }, + (t) => [ + primaryKey({ columns: [t.ts, t.sessionHost] }), + index("perf_hourly_tenant_ts").on(t.tenantId, t.ts), + ], +); + +export const costDaily = pgTable( + "cost_daily", + { + key: text("key").primaryKey(), + ts: ts("ts").notNull(), + day: date("day").notNull(), + tenantId: uuid("tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + hostPoolId: text("host_pool_id").references(() => hostPools.resourceId, { onDelete: "set null" }), + meterCategory: text("meter_category").notNull().default("Other"), + cost: numeric("cost", { precision: 18, scale: 4 }).notNull(), + currency: text("currency").notNull().default("USD"), + estimatedSavings: numeric("estimated_savings", { precision: 18, scale: 4 }), + }, + (t) => [index("cost_daily_tenant_ts").on(t.tenantId, t.ts)], +); + +export const autoscaleEvents = pgTable( + "autoscale_events", + { + key: text("key").primaryKey(), + ts: ts("ts").notNull(), + tenantId: uuid("tenant_id") + .notNull() + .references(() => customerTenants.id, { onDelete: "cascade" }), + hostPoolId: text("host_pool_id").references(() => hostPools.resourceId, { onDelete: "cascade" }), + action: text("action").notNull(), + hostsBefore: integer("hosts_before"), + hostsAfter: integer("hosts_after"), + detail: text("detail"), + }, + (t) => [index("autoscale_events_tenant_ts").on(t.tenantId, t.ts)], +); + +// ─── dashboards ──────────────────────────────────────────────────────────────── + +export const dashboards = pgTable( + "dashboards", + { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + description: text("description").notNull().default(""), + goal: text("goal").$type().notNull().default("blank"), + widgets: jsonb("widgets").$type().notNull(), + defaultPreset: text("default_preset").notNull().default("7d"), + tenantScope: jsonb("tenant_scope").$type(), + visibility: text("visibility").$type<"private" | "shared">().notNull().default("private"), + sharedWithRole: text("shared_with_role").$type().notNull().default("viewer"), + ownerId: uuid("owner_id").references(() => users.id, { onDelete: "set null" }), + createdAt: now(), + updatedAt: ts("updated_at").notNull().defaultNow(), + }, + (t) => [index("dashboards_owner_idx").on(t.ownerId)], +); + +/** A per-user pin, so the dashboards someone actually uses float to the top of their list. */ +export const dashboardFavorites = pgTable( + "dashboard_favorites", + { + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + dashboardId: uuid("dashboard_id") + .notNull() + .references(() => dashboards.id, { onDelete: "cascade" }), + createdAt: now(), + }, + (t) => [primaryKey({ columns: [t.userId, t.dashboardId] })], +); + +export const connectionsRelations = relations(connections, ({ many }) => ({ + tenants: many(customerTenants), +})); + +export const customerTenantsRelations = relations(customerTenants, ({ one, many }) => ({ + connection: one(connections, { fields: [customerTenants.connectionId], references: [connections.id] }), + subscriptions: many(subscriptions), + hostPools: many(hostPools), +})); + +export const hostPoolsRelations = relations(hostPools, ({ one, many }) => ({ + tenant: one(customerTenants, { fields: [hostPools.customerTenantId], references: [customerTenants.id] }), + hosts: many(sessionHosts), +})); diff --git a/packages/demo/package.json b/packages/demo/package.json new file mode 100644 index 0000000..7aadf6a --- /dev/null +++ b/packages/demo/package.json @@ -0,0 +1,18 @@ +{ + "name": "@dashflow/demo", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./dataset": "./src/dataset.ts", + "./evaluator": "./src/evaluator.ts" + }, + "dependencies": { + "@dashflow/core": "workspace:*" + }, + "devDependencies": { + "@dashflow/db": "workspace:*", + "drizzle-orm": "0.45.2" + } +} diff --git a/packages/demo/src/dataset.ts b/packages/demo/src/dataset.ts new file mode 100644 index 0000000..0ec94da --- /dev/null +++ b/packages/demo/src/dataset.ts @@ -0,0 +1,87 @@ +import type { + ConnectionFactRecord, + CostRecord, + ErrorFactRecord, + HostHealthRecord, + HostPoolRecord, + PerfRecord, + SessionHostRecord, + SessionSnapshotRecord, +} from "@dashflow/core"; +import { + demoConnections, + demoCosts, + demoErrors, + demoHostHealth, + demoInventory, + demoPerf, + demoSnapshots, + demoTenants, +} from "./index"; + +/** + * A complete synthetic estate, generated once and held in memory. + * + * This is what the hosted demo runs on, and what the parity test feeds to both the JavaScript + * evaluator and the real SQL engine to prove they agree. + */ +export interface Dataset { + tenants: { id: string; displayName: string; domain: string }[]; + pools: (HostPoolRecord & { tenantName: string })[]; + hosts: SessionHostRecord[]; + snapshots: SessionSnapshotRecord[]; + hostHealth: HostHealthRecord[]; + connections: ConnectionFactRecord[]; + errors: ErrorFactRecord[]; + perf: PerfRecord[]; + costs: CostRecord[]; +} + +export interface DatasetOptions { + tenants?: number; + days?: number; + /** End of the window; defaults to now. */ + until?: Date; +} + +export function buildDataset(options: DatasetOptions = {}): Dataset { + const tenantCount = options.tenants ?? 4; + const days = options.days ?? 30; + const until = options.until ?? new Date(); + const from = new Date(until.getTime() - days * 86_400_000); + + const dataset: Dataset = { + tenants: [], + pools: [], + hosts: [], + snapshots: [], + hostHealth: [], + connections: [], + errors: [], + perf: [], + costs: [], + }; + + for (const tenant of demoTenants(tenantCount)) { + dataset.tenants.push({ id: tenant.tenantId, displayName: tenant.displayName, domain: tenant.domain }); + + const inventory = demoInventory(tenant); + for (const pool of inventory.hostPools ?? []) { + dataset.pools.push({ ...pool, tenantName: tenant.displayName }); + } + dataset.hosts.push(...(inventory.sessionHosts ?? [])); + + dataset.snapshots.push(...demoSnapshots(tenant, from, until)); + dataset.hostHealth.push(...demoHostHealth(tenant, from, until)); + dataset.connections.push(...demoConnections(tenant, from, until)); + dataset.errors.push(...demoErrors(tenant, from, until)); + dataset.perf.push(...demoPerf(tenant, from, until)); + dataset.costs.push(...demoCosts(tenant, from, until)); + } + + return dataset; +} + +export function poolLabel(pool: { friendlyName?: string | null; name: string }): string { + return pool.friendlyName ?? pool.name; +} diff --git a/packages/demo/src/evaluator.ts b/packages/demo/src/evaluator.ts new file mode 100644 index 0000000..1c97035 --- /dev/null +++ b/packages/demo/src/evaluator.ts @@ -0,0 +1,560 @@ +import type { DimensionId, QueryRequest, QueryResult, Series, TimeGrain } from "@dashflow/core"; +import { getMetric } from "@dashflow/core"; +import type { Dataset } from "./dataset"; + +/** + * The metric catalog, evaluated in JavaScript instead of SQL. + * + * The hosted demo has no database, so this computes the same numbers over the in-memory + * dataset. It is deliberately a second implementation rather than a translation: the parity + * test runs both this and the real SQL engine over identical rows and fails if they disagree, + * which is the only thing that keeps a demo honest over time. + */ + +type Row = Record; + +interface JsMetric { + rows: (data: Dataset) => Row[]; + ts: (row: Row) => Date; + where?: (row: Row) => boolean; + /** Aggregate a group of rows into one number. */ + agg: (rows: Row[]) => number | null; + /** Two-stage metrics aggregate per inner key first, then combine. */ + innerKey?: (row: Row) => string; + stage2?: "avg" | "max" | "sum"; + dims: Partial string>>; + ratioOf?: { numerator: string; denominator: string }; +} + +// ─── aggregate helpers ────────────────────────────────────────────────────────── + +const numbers = (rows: Row[], key: string): number[] => + rows + .map((row) => row[key]) + .filter((value): value is number => typeof value === "number" && !Number.isNaN(value)); + +const sum = (rows: Row[], key: string): number => + numbers(rows, key).reduce((total, value) => total + value, 0); + +const avg = (rows: Row[], key: string): number | null => { + const values = numbers(rows, key); + return values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length; +}; + +/** + * Postgres `percentile_cont`: linear interpolation between the two neighbouring values, which + * is not the same as picking the nearest rank. Matching it exactly is the point. + */ +const percentile = (rows: Row[], key: string, fraction: number): number | null => { + const values = numbers(rows, key).sort((a, b) => a - b); + if (values.length === 0) return null; + if (values.length === 1) return values[0]!; + const position = fraction * (values.length - 1); + const lower = Math.floor(position); + const upper = Math.ceil(position); + if (lower === upper) return values[lower]!; + return values[lower]! + (values[upper]! - values[lower]!) * (position - lower); +}; + +const distinct = (rows: Row[], key: string): number => new Set(rows.map((row) => String(row[key]))).size; + +// ─── dimension helpers ────────────────────────────────────────────────────────── + +const tenantName = (row: Row, data: Dataset): string => + data.tenants.find((tenant) => tenant.id === row.tenantId)?.displayName ?? "Unknown"; + +const poolName = (row: Row, data: Dataset): string => { + const pool = data.pools.find((candidate) => candidate.resourceId === row.hostPoolId); + return pool ? (pool.friendlyName ?? pool.name) : "Unknown"; +}; + +const poolField = + (field: "poolType" | "location") => + (row: Row, data: Dataset): string => { + const pool = data.pools.find((candidate) => candidate.resourceId === row.hostPoolId); + return pool ? String(pool[field]) : "Unknown"; + }; + +const text = + (key: string, fallback = "Unknown") => + (row: Row): string => { + const value = row[key]; + return value === null || value === undefined || value === "" ? fallback : String(value); + }; + +const POOL_DIMS = { + tenant: tenantName, + hostPool: poolName, + poolType: poolField("poolType"), + location: poolField("location"), +} satisfies JsMetric["dims"]; + +const CLIENT_DIMS = { + clientOs: text("clientOs"), + clientType: text("clientType"), + gatewayRegion: text("gatewayRegion"), + sessionHost: text("sessionHost"), +} satisfies JsMetric["dims"]; + +const ts = (key: string) => (row: Row) => new Date(String(row[key])); + +// ─── the catalog, mirrored ────────────────────────────────────────────────────── + +export const JS_METRICS: Record = { + "sessions.active": { + rows: (data) => data.snapshots as unknown as Row[], + ts: ts("ts"), + agg: (rows) => sum(rows, "activeSessions"), + innerKey: (row) => String(row.ts), + stage2: "avg", + dims: POOL_DIMS, + }, + "sessions.peak": { + rows: (data) => data.snapshots as unknown as Row[], + ts: ts("ts"), + agg: (rows) => sum(rows, "activeSessions"), + innerKey: (row) => String(row.ts), + stage2: "max", + dims: POOL_DIMS, + }, + "sessions.disconnected": { + rows: (data) => data.snapshots as unknown as Row[], + ts: ts("ts"), + agg: (rows) => sum(rows, "disconnectedSessions"), + innerKey: (row) => String(row.ts), + stage2: "avg", + dims: POOL_DIMS, + }, + "connections.count": { + rows: (data) => data.connections as unknown as Row[], + ts: ts("ts"), + agg: (rows) => rows.length, + dims: { ...POOL_DIMS, ...CLIENT_DIMS }, + }, + "users.unique": { + rows: (data) => data.connections as unknown as Row[], + ts: ts("ts"), + agg: (rows) => distinct(rows, "user"), + dims: { ...POOL_DIMS, clientOs: CLIENT_DIMS.clientOs }, + }, + "connect.p95": { + rows: (data) => data.connections as unknown as Row[], + ts: ts("ts"), + where: (row) => row.connectMs != null && row.state === "connected", + agg: (rows) => percentile(rows, "connectMs", 0.95), + dims: { ...POOL_DIMS, ...CLIENT_DIMS }, + }, + "connect.median": { + rows: (data) => data.connections as unknown as Row[], + ts: ts("ts"), + where: (row) => row.connectMs != null && row.state === "connected", + agg: (rows) => percentile(rows, "connectMs", 0.5), + dims: { ...POOL_DIMS, clientOs: CLIENT_DIMS.clientOs, gatewayRegion: CLIENT_DIMS.gatewayRegion }, + }, + "rtt.median": { + rows: (data) => data.connections as unknown as Row[], + ts: ts("ts"), + where: (row) => row.rttMs != null, + agg: (rows) => percentile(rows, "rttMs", 0.5), + dims: { + ...POOL_DIMS, + gatewayRegion: CLIENT_DIMS.gatewayRegion, + clientOs: CLIENT_DIMS.clientOs, + sessionHost: CLIENT_DIMS.sessionHost, + }, + }, + "rtt.p95": { + rows: (data) => data.connections as unknown as Row[], + ts: ts("ts"), + where: (row) => row.rttMs != null, + agg: (rows) => percentile(rows, "rttMs", 0.95), + dims: { ...POOL_DIMS, gatewayRegion: CLIENT_DIMS.gatewayRegion }, + }, + "session.duration.median": { + rows: (data) => data.connections as unknown as Row[], + ts: ts("ts"), + where: (row) => row.durationSec != null, + agg: (rows) => percentile(rows, "durationSec", 0.5), + dims: POOL_DIMS, + }, + "connections.failed": { + rows: (data) => data.connections as unknown as Row[], + ts: ts("ts"), + where: (row) => row.state === "failed", + agg: (rows) => rows.length, + dims: { ...POOL_DIMS, ...CLIENT_DIMS }, + }, + "connections.success_rate": { + rows: (data) => data.connections as unknown as Row[], + ts: ts("ts"), + agg: (rows) => + rows.length === 0 ? null : (100 * rows.filter((row) => row.state === "connected").length) / rows.length, + dims: { ...POOL_DIMS, clientOs: CLIENT_DIMS.clientOs, gatewayRegion: CLIENT_DIMS.gatewayRegion }, + }, + "errors.count": { + rows: (data) => data.errors as unknown as Row[], + ts: ts("ts"), + agg: (rows) => rows.length, + dims: { ...POOL_DIMS, errorCode: text("code"), errorSource: text("source") }, + }, + "errors.service": { + rows: (data) => data.errors as unknown as Row[], + ts: ts("ts"), + where: (row) => row.serviceError === true, + agg: (rows) => rows.length, + dims: { ...POOL_DIMS, errorCode: text("code"), errorSource: text("source") }, + }, + "hosts.unhealthy": { + rows: (data) => data.hostHealth as unknown as Row[], + ts: ts("ts"), + agg: (rows) => rows.filter((row) => row.healthy === false).length, + innerKey: (row) => String(row.ts), + stage2: "avg", + dims: { ...POOL_DIMS, hostStatus: text("status"), sessionHost: text("sessionHost") }, + }, + "hosts.drain": { + rows: (data) => data.hostHealth as unknown as Row[], + ts: ts("ts"), + agg: (rows) => rows.filter((row) => row.drain === true).length, + innerKey: (row) => String(row.ts), + stage2: "avg", + dims: { ...POOL_DIMS, sessionHost: text("sessionHost") }, + }, + "capacity.utilization": { + rows: (data) => data.snapshots as unknown as Row[], + ts: ts("ts"), + agg: (rows) => { + const capacity = sum(rows, "capacity"); + return capacity === 0 ? null : (100 * sum(rows, "activeSessions")) / capacity; + }, + innerKey: (row) => String(row.ts), + stage2: "avg", + dims: POOL_DIMS, + }, + "hosts.available": { + rows: (data) => data.snapshots as unknown as Row[], + ts: ts("ts"), + agg: (rows) => sum(rows, "availableHosts"), + innerKey: (row) => String(row.ts), + stage2: "avg", + dims: POOL_DIMS, + }, + "sessions.per_host": { + rows: (data) => data.snapshots as unknown as Row[], + ts: ts("ts"), + agg: (rows) => { + const hosts = sum(rows, "availableHosts"); + return hosts === 0 ? null : sum(rows, "activeSessions") / hosts; + }, + innerKey: (row) => String(row.ts), + stage2: "avg", + dims: POOL_DIMS, + }, + "cpu.avg": { + rows: (data) => data.perf as unknown as Row[], + ts: ts("ts"), + where: (row) => row.cpuPct != null, + agg: (rows) => avg(rows, "cpuPct"), + dims: { ...POOL_DIMS, sessionHost: text("sessionHost") }, + }, + "memory.available": { + rows: (data) => data.perf as unknown as Row[], + ts: ts("ts"), + where: (row) => row.memAvailableMb != null, + agg: (rows) => avg(rows, "memAvailableMb"), + dims: { ...POOL_DIMS, sessionHost: text("sessionHost") }, + }, + "cost.total": { + rows: (data) => data.costs as unknown as Row[], + ts: (row) => new Date(`${String(row.date)}T00:00:00Z`), + agg: (rows) => sum(rows, "cost"), + dims: { ...POOL_DIMS, meterCategory: text("meterCategory") }, + }, + "cost.savings": { + rows: (data) => data.costs as unknown as Row[], + ts: (row) => new Date(`${String(row.date)}T00:00:00Z`), + where: (row) => row.estimatedSavings != null, + agg: (rows) => sum(rows, "estimatedSavings"), + dims: POOL_DIMS, + }, + "cost.per_user": { + rows: (data) => data.costs as unknown as Row[], + ts: (row) => new Date(`${String(row.date)}T00:00:00Z`), + agg: (rows) => sum(rows, "cost"), + dims: POOL_DIMS, + ratioOf: { numerator: "cost.total", denominator: "users.unique" }, + }, + "autoscale.actions": { + rows: () => [], + ts: ts("ts"), + agg: (rows) => rows.length, + dims: POOL_DIMS, + }, +}; + +// ─── evaluation ───────────────────────────────────────────────────────────────── + +function bucketStart(date: Date, grain: TimeGrain): number { + const copy = new Date(date); + copy.setUTCMinutes(0, 0, 0); + if (grain === "day") copy.setUTCHours(0); + return copy.getTime(); +} + +function select(metric: JsMetric, data: Dataset, request: QueryRequest): Row[] { + const from = request.from.getTime(); + const to = request.to.getTime(); + const pools = request.filters.hostPools ?? []; + const dims = Object.entries(request.filters.dims ?? {}); + const tenants = request.tenantIds; + + return metric.rows(data).filter((row) => { + const at = metric.ts(row).getTime(); + if (at < from || at >= to) return false; + if (metric.where && !metric.where(row)) return false; + if (tenants && tenants.length > 0 && !tenants.includes(String(row.tenantId))) return false; + if (pools.length > 0 && !pools.includes(String(row.hostPoolId))) return false; + for (const [dim, values] of dims) { + if (!values || values.length === 0) continue; + const accessor = metric.dims[dim as DimensionId]; + if (!accessor) return false; + if (!values.includes(accessor(row, data))) return false; + } + return true; + }); +} + +function aggregate(metric: JsMetric, rows: Row[]): number | null { + if (!metric.innerKey || !metric.stage2) return metric.agg(rows); + + const byInner = new Map(); + for (const row of rows) { + const key = metric.innerKey(row); + const list = byInner.get(key) ?? []; + list.push(row); + byInner.set(key, list); + } + const inner = [...byInner.values()] + .map((group) => metric.agg(group)) + .filter((value): value is number => value !== null); + if (inner.length === 0) return null; + + switch (metric.stage2) { + case "max": + return Math.max(...inner); + case "sum": + return inner.reduce((total, value) => total + value, 0); + default: + return inner.reduce((total, value) => total + value, 0) / inner.length; + } +} + +function groupRows( + metric: JsMetric, + data: Dataset, + rows: Row[], + dim: DimensionId | undefined, +): Map { + const groups = new Map(); + for (const row of rows) { + const key = dim ? (metric.dims[dim]?.(row, data) ?? "Unknown") : "total"; + const list = groups.get(key) ?? []; + list.push(row); + groups.set(key, list); + } + return groups; +} + +function rankValue(metricId: string, values: (number | null)[]): number { + const definition = getMetric(metricId); + const clean = values.filter((value): value is number => value !== null); + if (clean.length === 0) return Number.NEGATIVE_INFINITY; + if (definition?.stage2 === "max") return Math.max(...clean); + if (definition && ["percent", "ms", "seconds"].includes(definition.unit)) { + return clean.reduce((total, value) => total + value, 0) / clean.length; + } + return clean.reduce((total, value) => total + value, 0); +} + +export function evaluate(data: Dataset, request: QueryRequest): QueryResult { + const definition = getMetric(request.metric); + const metric = JS_METRICS[request.metric]; + if (!definition || !metric) throw new Error(`Unknown metric ${request.metric}`); + + const unit = definition.unit; + const currency = unit === "currency" ? "USD" : undefined; + + if (metric.ratioOf) { + const numerator = evaluate(data, { ...request, metric: metric.ratioOf.numerator }); + const denominator = evaluate(data, { ...request, metric: metric.ratioOf.denominator }); + if (numerator.kind === "scalar" && denominator.kind === "scalar") { + const value = + numerator.value == null || !denominator.value ? null : numerator.value / denominator.value; + return { kind: "scalar", unit, value, currency }; + } + if (numerator.kind === "series" && denominator.kind === "series") { + const denom = new Map(); + for (const series of denominator.series) { + for (const point of series.points) denom.set(`${series.name}|${point.ts}`, point.value); + } + return { + kind: "series", + unit, + grain: request.grain, + currency, + series: numerator.series.map((series) => ({ + name: series.name, + points: series.points.map((point) => { + const bottom = denom.get(`${series.name}|${point.ts}`) ?? denom.get(`total|${point.ts}`) ?? null; + return { ts: point.ts, value: point.value == null || !bottom ? null : point.value / bottom }; + }), + })), + }; + } + } + + const rows = select(metric, data, request); + + switch (request.viz) { + case "kpi": { + const value = aggregate(metric, rows); + const span = request.to.getTime() - request.from.getTime(); + const previousRequest = { + ...request, + from: new Date(request.from.getTime() - span), + to: new Date(request.from.getTime()), + }; + const previous = request.compare ? aggregate(metric, select(metric, data, previousRequest)) : undefined; + const sparkGrain: TimeGrain = span > 10 * 86_400_000 ? "day" : "hour"; + const spark = buildSeries(metric, data, rows, { ...request, grain: sparkGrain, groupBy: undefined }, 1); + return { kind: "scalar", unit, value, previous, spark: spark[0]?.points, currency }; + } + case "line": + case "area": + case "stacked-bar": + return { + kind: "series", + unit, + grain: request.grain, + currency, + series: buildSeries(metric, data, rows, request, request.topN), + }; + case "bar": + case "donut": + return { kind: "breakdown", unit, currency, items: buildBreakdown(metric, data, rows, request) }; + case "table": { + const items = buildBreakdown(metric, data, rows, request); + return { + kind: "table", + unit, + currency, + columns: [ + { key: "name", label: request.groupBy ?? "All" }, + { key: "value", label: definition.label, numeric: true }, + ], + rows: items, + }; + } + case "heatmap": { + const cells: { day: number; hour: number; value: number }[] = []; + const byCell = new Map(); + for (const row of rows) { + const at = metric.ts(row); + const day = (at.getUTCDay() + 6) % 7; + const hour = at.getUTCHours(); + const key = `${day}|${hour}`; + const list = byCell.get(key) ?? []; + list.push(row); + byCell.set(key, list); + } + for (const [key, group] of byCell) { + const [day, hour] = key.split("|").map(Number); + const value = aggregate(metric, group); + if (value !== null) cells.push({ day: day!, hour: hour!, value }); + } + return { kind: "heatmap", unit, currency, cells }; + } + case "host-grid": { + const tenants = request.tenantIds; + const hosts = data.hosts + .filter((host) => !tenants || tenants.length === 0 || tenants.includes(host.tenantId)) + .filter( + (host) => + (request.filters.hostPools ?? []).length === 0 || + (request.filters.hostPools ?? []).includes(host.hostPoolId), + ) + .slice(0, 500) + .map((host) => { + const pool = data.pools.find((candidate) => candidate.resourceId === host.hostPoolId); + return { + name: host.name, + hostPool: pool ? (pool.friendlyName ?? pool.name) : "—", + tenant: data.tenants.find((tenant) => tenant.id === host.tenantId)?.displayName ?? "—", + status: host.status, + sessions: host.sessions, + allowNewSession: host.allowNewSession, + agentVersion: host.agentVersion ?? null, + lastHeartBeat: host.lastHeartBeat ?? null, + }; + }); + return { kind: "hosts", hosts }; + } + } +} + +function buildSeries( + metric: JsMetric, + data: Dataset, + rows: Row[], + request: QueryRequest, + topN: number, +): Series[] { + const groups = groupRows(metric, data, rows, request.groupBy); + const step = request.grain === "day" ? 86_400_000 : 3_600_000; + const first = bucketStart(request.from, request.grain); + const last = bucketStart(request.to, request.grain); + + const series: Series[] = []; + for (const [name, groupRowsList] of groups) { + const byBucket = new Map(); + for (const row of groupRowsList) { + const bucket = bucketStart(metric.ts(row), request.grain); + const list = byBucket.get(bucket) ?? []; + list.push(row); + byBucket.set(bucket, list); + } + const points = []; + for (let at = first; at <= last; at += step) { + const group = byBucket.get(at); + points.push({ ts: new Date(at).toISOString(), value: group ? aggregate(metric, group) : null }); + } + series.push({ name, points }); + } + + return series + .sort( + (a, b) => + rankValue( + request.metric, + b.points.map((point) => point.value), + ) - + rankValue( + request.metric, + a.points.map((point) => point.value), + ), + ) + .slice(0, topN); +} + +function buildBreakdown( + metric: JsMetric, + data: Dataset, + rows: Row[], + request: QueryRequest, +): { name: string; value: number }[] { + const groups = groupRows(metric, data, rows, request.groupBy); + return [...groups.entries()] + .map(([name, group]) => ({ name, value: aggregate(metric, group) ?? 0 })) + .sort((a, b) => b.value - a.value) + .slice(0, request.topN); +} diff --git a/packages/demo/src/index.ts b/packages/demo/src/index.ts new file mode 100644 index 0000000..3953be6 --- /dev/null +++ b/packages/demo/src/index.ts @@ -0,0 +1,450 @@ +import type { + ConnectionFactRecord, + CostRecord, + ErrorFactRecord, + HostHealthRecord, + HostPoolRecord, + PerfRecord, + SessionHostRecord, + SessionSnapshotRecord, + SyncBatch, +} from "@dashflow/core"; + +/** + * Synthetic AVD data for demo mode, screenshots and tests. Every name here is a Microsoft + * sample company, every GUID is a placeholder, and nothing in this file comes from a real + * tenant — that is deliberate, so the repo and its screenshots stay free of customer data. + */ + +const COMPANIES = [ + { name: "Contoso Ltd", domain: "contoso.onmicrosoft.com" }, + { name: "Fabrikam Inc", domain: "fabrikam.onmicrosoft.com" }, + { name: "Adatum Corporation", domain: "adatum.onmicrosoft.com" }, + { name: "Northwind Traders", domain: "northwind.onmicrosoft.com" }, + { name: "Tailspin Toys", domain: "tailspin.onmicrosoft.com" }, + { name: "Wide World Importers", domain: "wideworld.onmicrosoft.com" }, + { name: "Proseware Inc", domain: "proseware.onmicrosoft.com" }, + { name: "Woodgrove Bank", domain: "woodgrove.onmicrosoft.com" }, +]; + +const POOL_BLUEPRINTS = [ + { + suffix: "desktops-eus", + location: "eastus", + type: "Pooled", + hosts: 8, + maxSessions: 8, + gateway: "us-east", + }, + { suffix: "apps-eus", location: "eastus", type: "Pooled", hosts: 4, maxSessions: 12, gateway: "us-east" }, + { + suffix: "engineering-weu", + location: "westeurope", + type: "Personal", + hosts: 6, + maxSessions: 1, + gateway: "eu-west", + }, + { + suffix: "callcenter-cus", + location: "centralus", + type: "Pooled", + hosts: 10, + maxSessions: 10, + gateway: "us-central", + }, +]; + +const CLIENT_OS = ["Windows 11", "Windows 10", "macOS", "iOS", "Android", "Web"] as const; +const CLIENT_TYPE = ["Desktop", "Web", "Mobile"] as const; +const ERROR_CODES = [ + { code: "ConnectionFailedClientDisconnect", source: "Client", service: false }, + { code: "ConnectionFailedNoHealthyRdshAvailable", source: "RdGateway", service: true }, + { code: "AgentHealthDomainTrustRelationshipLost", source: "RDAgent", service: false }, + { code: "ConnectionFailedAvNotAvailable", source: "RdBroker", service: true }, + { code: "FslogixProfileLoadFailed", source: "RDAgent", service: false }, + { code: "SessionHostUnhealthyFslogixHealthCheckFailed", source: "RDAgent", service: false }, +]; +const METER_CATEGORIES: { name: string; share: number }[] = [ + { name: "Virtual Machines", share: 0.72 }, + { name: "Storage", share: 0.14 }, + { name: "Bandwidth", share: 0.05 }, + { name: "Virtual Network", share: 0.04 }, + { name: "Azure Monitor", share: 0.05 }, +]; + +/** Small deterministic PRNG so demo data is stable across restarts. */ +function mulberry32(seed: number) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function seedFrom(text: string): number { + let hash = 2166136261; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +export interface DemoTenant { + tenantId: string; + displayName: string; + domain: string; + subscriptionId: string; + pools: DemoPool[]; +} + +export interface DemoPool { + resourceId: string; + name: string; + location: string; + poolType: string; + maxSessions: number; + hostCount: number; + gateway: string; + /** Scales usage so tenants differ from each other. */ + scale: number; +} + +const GUID_PREFIX = "00000000-0000-4000-8000-0000000000"; + +function guid(index: number): string { + return `${GUID_PREFIX}${index.toString().padStart(2, "0")}`; +} + +export function demoTenants(count: number): DemoTenant[] { + return COMPANIES.slice(0, Math.min(count, COMPANIES.length)).map((company, index) => { + const random = mulberry32(seedFrom(company.name)); + const slug = company.name.toLowerCase().split(" ")[0]!; + const subscriptionId = guid(20 + index); + const poolCount = 2 + Math.floor(random() * 3); + const pools = POOL_BLUEPRINTS.slice(0, poolCount).map((blueprint) => { + const name = `hp-${slug}-${blueprint.suffix}`; + return { + resourceId: + `/subscriptions/${subscriptionId}/resourcegroups/rg-avd-${slug}/providers/` + + `microsoft.desktopvirtualization/hostpools/${name}`, + name, + location: blueprint.location, + poolType: blueprint.type, + maxSessions: blueprint.maxSessions, + hostCount: blueprint.hosts, + gateway: blueprint.gateway, + scale: 0.6 + random() * 0.9, + } satisfies DemoPool; + }); + return { + tenantId: guid(index + 1), + displayName: company.name, + domain: company.domain, + subscriptionId, + pools, + }; + }); +} + +/** Weekday business-hours shape, in the 0..1 range. */ +function usageShape(date: Date, random: () => number): number { + const day = date.getUTCDay(); + const hour = date.getUTCHours() + date.getUTCMinutes() / 60; + const weekend = day === 0 || day === 6 ? 0.12 : 1; + // Two humps: morning ramp and afternoon peak, with a lunch dip. + const morning = Math.exp(-((hour - 9.5) ** 2) / 8); + const afternoon = Math.exp(-((hour - 14.5) ** 2) / 10); + const base = Math.max(morning, afternoon * 0.95); + return Math.max(0, Math.min(1, base * weekend * (0.9 + random() * 0.2))); +} + +export function demoInventory(tenant: DemoTenant): SyncBatch { + const hostPools: HostPoolRecord[] = []; + const sessionHosts: SessionHostRecord[] = []; + const hostHealth: HostHealthRecord[] = []; + const ts = new Date().toISOString(); + + for (const pool of tenant.pools) { + const random = mulberry32(seedFrom(pool.name)); + hostPools.push({ + resourceId: pool.resourceId, + tenantId: tenant.tenantId, + subscriptionId: tenant.subscriptionId, + name: pool.name, + friendlyName: pool.name.replace(/^hp-/, "").replace(/-/g, " "), + location: pool.location, + poolType: pool.poolType, + loadBalancer: pool.poolType === "Pooled" ? "BreadthFirst" : null, + maxSessions: pool.maxSessions, + startVmOnConnect: pool.poolType === "Personal", + validationEnvironment: false, + preferredAppGroupType: pool.poolType === "Personal" ? "Desktop" : "RailApplications", + autoscaleEnabled: random() > 0.3, + source: "demo", + }); + + const hosts = demoHosts(tenant, pool, new Date()); + sessionHosts.push(...hosts); + hostHealth.push( + ...hosts.map((host) => ({ + ts, + tenantId: tenant.tenantId, + hostPoolId: pool.resourceId, + sessionHost: host.name, + status: host.status, + healthy: host.status === "Available" || host.status === "Shutdown", + drain: !host.allowNewSession, + sessions: host.sessions, + })), + ); + } + return { hostPools, sessionHosts, hostHealth }; +} + +function demoHosts(tenant: DemoTenant, pool: DemoPool, at: Date): SessionHostRecord[] { + const random = mulberry32(seedFrom(`${pool.name}|hosts`)); + const shape = usageShape(at, random); + const running = Math.max(1, Math.round(pool.hostCount * Math.max(0.25, shape))); + + return Array.from({ length: pool.hostCount }, (_, index) => { + const name = `${pool.name.replace("hp-", "vm-")}-${index}`; + const isRunning = index < running; + const roll = random(); + const status = !isRunning + ? "Shutdown" + : roll > 0.97 + ? "Unavailable" + : roll > 0.94 + ? "UpgradeFailed" + : "Available"; + return { + resourceId: `${pool.resourceId}/sessionhosts/${name}`, + hostPoolId: pool.resourceId, + tenantId: tenant.tenantId, + name, + vmResourceId: `/subscriptions/${tenant.subscriptionId}/resourcegroups/rg-avd-${ + tenant.displayName.toLowerCase().split(" ")[0] + }/providers/microsoft.compute/virtualmachines/${name}`, + status, + allowNewSession: !(roll > 0.9), + sessions: status === "Available" ? Math.round(shape * pool.maxSessions * pool.scale * random()) : 0, + agentVersion: "1.0.11742.1700", + osVersion: "10.0.22621", + lastHeartBeat: at.toISOString(), + updateState: "Succeeded", + source: "demo", + } satisfies SessionHostRecord; + }); +} + +const SNAPSHOT_INTERVAL_MS = 15 * 60_000; + +export function demoSnapshots(tenant: DemoTenant, from: Date, to: Date): SessionSnapshotRecord[] { + const out: SessionSnapshotRecord[] = []; + for (const pool of tenant.pools) { + const random = mulberry32(seedFrom(`${pool.name}|snap`)); + for (let t = alignTo(from, SNAPSHOT_INTERVAL_MS); t < to.getTime(); t += SNAPSHOT_INTERVAL_MS) { + const at = new Date(t); + const shape = usageShape(at, random); + const availableHosts = Math.max(1, Math.round(pool.hostCount * Math.max(0.25, shape))); + const active = Math.round(shape * pool.maxSessions * availableHosts * 0.55 * pool.scale); + out.push({ + ts: at.toISOString(), + hostPoolId: pool.resourceId, + tenantId: tenant.tenantId, + activeSessions: active, + disconnectedSessions: Math.round(active * 0.12 * random()), + capacity: availableHosts * pool.maxSessions, + availableHosts, + totalHosts: pool.hostCount, + }); + } + } + return out; +} + +/** Hourly host-health history, so the reliability dashboards have a trend to draw. */ +export function demoHostHealth(tenant: DemoTenant, from: Date, to: Date): HostHealthRecord[] { + const out: HostHealthRecord[] = []; + for (const pool of tenant.pools) { + const random = mulberry32(seedFrom(`${pool.name}|health`)); + for (let t = alignTo(from, 3600_000); t < to.getTime(); t += 3600_000) { + const at = new Date(t); + const shape = usageShape(at, random); + const running = Math.max(1, Math.round(pool.hostCount * Math.max(0.25, shape))); + for (let index = 0; index < pool.hostCount; index++) { + const roll = random(); + const status = + index >= running + ? "Shutdown" + : roll > 0.985 + ? "Unavailable" + : roll > 0.975 + ? "NeedsAssistance" + : "Available"; + out.push({ + ts: at.toISOString(), + tenantId: tenant.tenantId, + hostPoolId: pool.resourceId, + sessionHost: `${pool.name.replace("hp-", "vm-")}-${index}`, + status, + healthy: status === "Available" || status === "Shutdown", + drain: roll > 0.99, + sessions: status === "Available" ? Math.round(shape * pool.maxSessions * random()) : 0, + }); + } + } + } + return out; +} + +export function demoConnections(tenant: DemoTenant, from: Date, to: Date): ConnectionFactRecord[] { + const out: ConnectionFactRecord[] = []; + for (const pool of tenant.pools) { + const random = mulberry32(seedFrom(`${pool.name}|conn`)); + const userCount = Math.max(6, Math.round(pool.hostCount * pool.maxSessions * 0.8 * pool.scale)); + + for (let t = alignTo(from, 3600_000); t < to.getTime(); t += 3600_000) { + const at = new Date(t); + const shape = usageShape(at, random); + const attempts = Math.round(shape * userCount * 0.35); + // A rough patch every few days makes the reliability dashboards interesting. + const badHour = Math.floor(t / 3600_000) % 173 === 0; + + for (let i = 0; i < attempts; i++) { + const ts = new Date(t + Math.floor(random() * 3600_000)); + const failed = random() < (badHour ? 0.25 : 0.02); + const userIndex = Math.floor(random() * userCount); + const osIndex = Math.floor(random() ** 1.6 * CLIENT_OS.length); + out.push({ + correlationId: `demo-${pool.name}-${ts.getTime()}-${i}`, + tenantId: tenant.tenantId, + hostPoolId: pool.resourceId, + ts: ts.toISOString(), + user: `user${String(userIndex).padStart(3, "0")}@${tenant.domain}`, + sessionHost: `${pool.name.replace("hp-", "vm-")}-${Math.floor(random() * pool.hostCount)}`, + clientOs: CLIENT_OS[osIndex] ?? "Windows 11", + clientType: CLIENT_TYPE[Math.floor(random() ** 2 * CLIENT_TYPE.length)] ?? "Desktop", + clientVersion: "1.2.5405", + gatewayRegion: pool.gateway, + state: failed ? "failed" : "connected", + connectMs: failed ? null : Math.round(1200 + random() ** 2 * (badHour ? 18_000 : 6_000)), + rttMs: Math.round(25 + random() ** 2 * (pool.gateway.startsWith("eu") ? 120 : 70)), + bandwidthKbps: Math.round(2_000 + random() * 40_000), + durationSec: failed ? null : Math.round(600 + random() ** 1.5 * 28_000), + }); + } + } + } + return out; +} + +export function demoErrors(tenant: DemoTenant, from: Date, to: Date): ErrorFactRecord[] { + const out: ErrorFactRecord[] = []; + for (const pool of tenant.pools) { + const random = mulberry32(seedFrom(`${pool.name}|err`)); + for (let t = alignTo(from, 3600_000); t < to.getTime(); t += 3600_000) { + const at = new Date(t); + const shape = usageShape(at, random); + const badHour = Math.floor(t / 3600_000) % 173 === 0; + const count = Math.round(shape * (badHour ? 14 : 1.5) * random()); + for (let i = 0; i < count; i++) { + const error = ERROR_CODES[Math.floor(random() * ERROR_CODES.length)]!; + const ts = new Date(t + Math.floor(random() * 3600_000)); + out.push({ + key: `demo-${pool.name}-${ts.getTime()}-${i}`, + ts: ts.toISOString(), + tenantId: tenant.tenantId, + hostPoolId: pool.resourceId, + correlationId: null, + code: error.code, + source: error.source, + message: `${error.code} on ${pool.name}`, + serviceError: error.service, + }); + } + } + } + return out; +} + +export function demoPerf(tenant: DemoTenant, from: Date, to: Date): PerfRecord[] { + const out: PerfRecord[] = []; + for (const pool of tenant.pools) { + const random = mulberry32(seedFrom(`${pool.name}|perf`)); + for (let t = alignTo(from, 3600_000); t < to.getTime(); t += 3600_000) { + const at = new Date(t); + const shape = usageShape(at, random); + const running = Math.max(1, Math.round(pool.hostCount * Math.max(0.25, shape))); + for (let index = 0; index < running; index++) { + out.push({ + ts: at.toISOString(), + tenantId: tenant.tenantId, + hostPoolId: pool.resourceId, + sessionHost: `${pool.name.replace("hp-", "vm-")}-${index}`, + cpuPct: Math.min(99, 8 + shape * 60 + random() * 25), + memAvailableMb: Math.max(400, 16_000 - shape * 9_000 - random() * 2_000), + }); + } + } + } + return out; +} + +export function demoCosts(tenant: DemoTenant, from: Date, to: Date): CostRecord[] { + const out: CostRecord[] = []; + for (const pool of tenant.pools) { + const random = mulberry32(seedFrom(`${pool.name}|cost`)); + for (let t = alignTo(from, 86_400_000); t < to.getTime(); t += 86_400_000) { + const at = new Date(t); + const weekend = at.getUTCDay() === 0 || at.getUTCDay() === 6; + const daily = pool.hostCount * pool.scale * (weekend ? 18 : 46) * (0.9 + random() * 0.2); + for (const meter of METER_CATEGORIES) { + out.push({ + date: at.toISOString().slice(0, 10), + tenantId: tenant.tenantId, + hostPoolId: pool.resourceId, + meterCategory: meter.name, + cost: Number((daily * meter.share).toFixed(2)), + currency: "USD", + estimatedSavings: + meter.name === "Virtual Machines" + ? Number((daily * meter.share * (0.25 + random() * 0.2)).toFixed(2)) + : null, + }); + } + } + } + return out; +} + +function alignTo(date: Date, intervalMs: number): number { + return Math.ceil(date.getTime() / intervalMs) * intervalMs; +} + +export function demoBatch(tenant: DemoTenant, stream: string, from: Date, to: Date): SyncBatch { + switch (stream) { + case "inventory": + return demoInventory(tenant); + case "sessions": + return { + ...demoInventory(tenant), + sessionSnapshots: demoSnapshots(tenant, from, to), + hostHealth: demoHostHealth(tenant, from, to), + }; + case "logs": + return { + connections: demoConnections(tenant, from, to), + errors: demoErrors(tenant, from, to), + perf: demoPerf(tenant, from, to), + }; + case "cost": + return { costs: demoCosts(tenant, from, to) }; + default: + return {}; + } +} diff --git a/packages/demo/src/parity.test.ts b/packages/demo/src/parity.test.ts new file mode 100644 index 0000000..a85042a --- /dev/null +++ b/packages/demo/src/parity.test.ts @@ -0,0 +1,299 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import type { Access, QueryRequest } from "@dashflow/core"; +import { createDb, schema } from "@dashflow/db"; +import { runMigrations } from "@dashflow/db/migrate"; +import { sql } from "drizzle-orm"; +import { runQuery } from "../../../apps/server/src/query/engine"; +import { buildDataset, type Dataset } from "./dataset"; +import { evaluate } from "./evaluator"; + +/** + * The hosted demo computes its numbers in JavaScript because it has no database. This test + * loads one generated dataset into real Postgres, runs the same questions through the SQL + * engine and the JavaScript evaluator, and fails if they disagree. + * + * Without this, the demo drifts from the product and nobody notices until someone deploys. + * Run it with TEST_DATABASE_URL set. + */ +const url = process.env.TEST_DATABASE_URL; +const maybe = url ? describe : describe.skip; + +const UNTIL = new Date("2026-09-15T00:00:00Z"); +const FROM = new Date(UNTIL.getTime() - 7 * 86_400_000); +const CONNECTION_ID = "dddddddd-0000-4000-8000-00000000000d"; + +maybe("demo evaluator matches the SQL engine", () => { + const { db, client } = createDb({ url: url ?? "postgres://invalid", max: 2 }); + let data: Dataset; + /** tenantId (as generated) → customer_tenants.id (as stored). */ + const tenantRowId = new Map(); + + const access: Access = { role: "owner", tenantScope: null }; + + const request = (overrides: Partial): QueryRequest => ({ + metric: "connections.count", + viz: "kpi", + topN: 8, + grain: "hour", + from: FROM, + to: UNTIL, + tenantIds: null, + filters: {}, + compare: false, + ...overrides, + }); + + /** The SQL engine keys tenants by row id; the evaluator uses the generated tenant id. */ + const forSql = (input: QueryRequest): QueryRequest => ({ + ...input, + tenantIds: input.tenantIds ? input.tenantIds.map((id) => tenantRowId.get(id) ?? id) : null, + }); + + beforeAll(async () => { + data = buildDataset({ tenants: 2, days: 10, until: UNTIL }); + + await runMigrations(db, () => {}); + await db.execute(sql`truncate connection_facts, session_snapshots, host_health, perf_hourly, cost_daily, + error_facts, host_pools, session_hosts, customer_tenants, connections cascade`); + + await db.insert(schema.connections).values({ + id: CONNECTION_ID, + name: "parity", + type: "demo", + config: { type: "demo", tenants: 2 }, + schedule: { + inventoryMinutes: 60, + sessionsMinutes: 5, + logsMinutes: 15, + costHours: 24, + backfillDays: 30, + retentionDays: 180, + }, + }); + + for (const tenant of data.tenants) { + const [row] = await db + .insert(schema.customerTenants) + .values({ + connectionId: CONNECTION_ID, + tenantId: tenant.id, + displayName: tenant.displayName, + domain: tenant.domain, + }) + .returning({ id: schema.customerTenants.id }); + tenantRowId.set(tenant.id, row!.id); + } + + await db.insert(schema.hostPools).values( + data.pools.map((pool) => ({ + resourceId: pool.resourceId, + customerTenantId: tenantRowId.get(pool.tenantId)!, + connectionId: CONNECTION_ID, + subscriptionId: pool.subscriptionId, + name: pool.name, + friendlyName: pool.friendlyName ?? null, + location: pool.location, + poolType: pool.poolType, + maxSessions: pool.maxSessions ?? null, + })), + ); + + const chunked = async (rows: T[], insert: (batch: T[]) => Promise) => { + for (let i = 0; i < rows.length; i += 1000) await insert(rows.slice(i, i + 1000)); + }; + + await chunked(data.snapshots, (batch) => + db.insert(schema.sessionSnapshots).values( + batch.map((row) => ({ + ts: new Date(row.ts), + hostPoolId: row.hostPoolId, + tenantId: tenantRowId.get(row.tenantId)!, + activeSessions: row.activeSessions, + disconnectedSessions: row.disconnectedSessions, + capacity: row.capacity, + availableHosts: row.availableHosts, + totalHosts: row.totalHosts, + })), + ), + ); + + await chunked(data.hostHealth, (batch) => + db.insert(schema.hostHealth).values( + batch.map((row) => ({ + ts: new Date(row.ts), + sessionHost: row.sessionHost, + hostPoolId: row.hostPoolId, + tenantId: tenantRowId.get(row.tenantId)!, + status: row.status, + healthy: row.healthy, + drain: row.drain, + sessions: row.sessions, + })), + ), + ); + + await chunked(data.connections, (batch) => + db.insert(schema.connectionFacts).values( + batch.map((row) => ({ + correlationId: row.correlationId, + tenantId: tenantRowId.get(row.tenantId)!, + hostPoolId: row.hostPoolId, + ts: new Date(row.ts), + // The parity check is about aggregation, so the user key passes through unhashed. + userKey: row.user, + sessionHost: row.sessionHost ?? null, + clientOs: row.clientOs ?? null, + clientType: row.clientType ?? null, + clientVersion: row.clientVersion ?? null, + gatewayRegion: row.gatewayRegion ?? null, + state: row.state, + connectMs: row.connectMs ?? null, + rttMs: row.rttMs ?? null, + bandwidthKbps: row.bandwidthKbps ?? null, + durationSec: row.durationSec ?? null, + })), + ), + ); + + await chunked(data.errors, (batch) => + db.insert(schema.errorFacts).values( + batch.map((row) => ({ + key: row.key, + tenantId: tenantRowId.get(row.tenantId)!, + hostPoolId: row.hostPoolId, + ts: new Date(row.ts), + code: row.code, + source: row.source, + message: row.message, + serviceError: row.serviceError, + })), + ), + ); + + await chunked(data.perf, (batch) => + db.insert(schema.perfHourly).values( + batch.map((row) => ({ + ts: new Date(row.ts), + sessionHost: row.sessionHost, + hostPoolId: row.hostPoolId, + tenantId: tenantRowId.get(row.tenantId)!, + cpuPct: row.cpuPct ?? null, + memAvailableMb: row.memAvailableMb ?? null, + })), + ), + ); + + await chunked(data.costs, (batch) => + db.insert(schema.costDaily).values( + batch.map((row) => ({ + key: `${row.tenantId}|${row.date}|${row.hostPoolId ?? "-"}|${row.meterCategory}`, + ts: new Date(`${row.date}T00:00:00Z`), + day: row.date, + tenantId: tenantRowId.get(row.tenantId)!, + hostPoolId: row.hostPoolId, + meterCategory: row.meterCategory, + cost: row.cost.toFixed(4), + currency: row.currency, + estimatedSavings: row.estimatedSavings != null ? row.estimatedSavings.toFixed(4) : null, + })), + ), + ); + }); + + const close = (a: number | null | undefined, b: number | null | undefined, label: string) => { + if (a == null || b == null) { + expect(`${label}: ${a}`).toBe(`${label}: ${b}`); + return; + } + // Postgres numerics and JS floats differ in the last bits; anything larger is a real gap. + expect(Math.abs(a - b)).toBeLessThan(Math.max(1e-6, Math.abs(a) * 1e-9)); + }; + + const SCALAR_METRICS = [ + "connections.count", + "users.unique", + "connections.failed", + "connections.success_rate", + "connect.p95", + "rtt.median", + "errors.count", + "sessions.active", + "sessions.peak", + "capacity.utilization", + "cpu.avg", + "cost.total", + ]; + + for (const metric of SCALAR_METRICS) { + test(`scalar: ${metric}`, async () => { + const input = request({ metric, viz: "kpi" }); + const sqlResult = await runQuery(db, forSql(input), access, { tz: "UTC" }); + const jsResult = evaluate(data, input); + expect(sqlResult.kind).toBe("scalar"); + if (sqlResult.kind === "scalar" && jsResult.kind === "scalar") { + close(sqlResult.value, jsResult.value, metric); + } + }); + } + + test("series with a split match bucket for bucket", async () => { + const input = request({ metric: "connections.count", viz: "line", groupBy: "clientOs", topN: 3 }); + const sqlResult = await runQuery(db, forSql(input), access, { tz: "UTC" }); + const jsResult = evaluate(data, input); + if (sqlResult.kind !== "series" || jsResult.kind !== "series") throw new Error("expected series"); + + expect(jsResult.series.map((series) => series.name).sort()).toEqual( + sqlResult.series.map((series) => series.name).sort(), + ); + for (const series of sqlResult.series) { + const mirror = jsResult.series.find((candidate) => candidate.name === series.name)!; + expect(mirror.points.length).toBe(series.points.length); + for (const [index, point] of series.points.entries()) { + expect(mirror.points[index]!.ts).toBe(point.ts); + close(point.value, mirror.points[index]!.value, `${series.name}@${point.ts}`); + } + } + }); + + test("breakdowns agree on both order and value", async () => { + const input = request({ metric: "errors.count", viz: "bar", groupBy: "errorCode", topN: 5 }); + const sqlResult = await runQuery(db, forSql(input), access, { tz: "UTC" }); + const jsResult = evaluate(data, input); + if (sqlResult.kind !== "breakdown" || jsResult.kind !== "breakdown") + throw new Error("expected breakdown"); + expect(jsResult.items.map((item) => item.name)).toEqual(sqlResult.items.map((item) => item.name)); + for (const [index, item] of sqlResult.items.entries()) { + close(item.value, jsResult.items[index]!.value, item.name); + } + }); + + test("heatmaps agree cell for cell", async () => { + const input = request({ metric: "sessions.active", viz: "heatmap" }); + const sqlResult = await runQuery(db, forSql(input), access, { tz: "UTC" }); + const jsResult = evaluate(data, input); + if (sqlResult.kind !== "heatmap" || jsResult.kind !== "heatmap") throw new Error("expected heatmap"); + expect(jsResult.cells.length).toBe(sqlResult.cells.length); + for (const cell of sqlResult.cells) { + const mirror = jsResult.cells.find( + (candidate) => candidate.day === cell.day && candidate.hour === cell.hour, + ); + expect(mirror).toBeDefined(); + close(cell.value, mirror!.value, `${cell.day}@${cell.hour}`); + } + }); + + test("a dimension filter narrows both the same way", async () => { + const input = request({ + metric: "connections.count", + viz: "kpi", + filters: { dims: { clientOs: ["macOS"] } }, + }); + const sqlResult = await runQuery(db, forSql(input), access, { tz: "UTC" }); + const jsResult = evaluate(data, input); + if (sqlResult.kind === "scalar" && jsResult.kind === "scalar") { + close(sqlResult.value, jsResult.value, "clientOs filter"); + expect(sqlResult.value).toBeGreaterThan(0); + } + await client.end(); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0d87c63 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * The end-to-end suite runs against the demo build: the real UI, the mock API, no database. + * That keeps CI fast and makes the tests meaningful on a fork with no secrets. + */ +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : [["list"]], + timeout: 45_000, + use: { + baseURL: "http://127.0.0.1:4173", + trace: "on-first-retry", + screenshot: "only-on-failure", + colorScheme: "dark", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } }, + ], + webServer: { + command: + "bun run build:demo && bunx vite preview --host 127.0.0.1 --port 4173 --strictPort --outDir dist-demo", + cwd: "apps/web", + url: "http://127.0.0.1:4173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/scripts/gitleaks-precommit.sh b/scripts/gitleaks-precommit.sh new file mode 100755 index 0000000..3070626 --- /dev/null +++ b/scripts/gitleaks-precommit.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Block commits that contain secrets or tenant-identifying values. +set -euo pipefail + +if ! command -v gitleaks >/dev/null 2>&1; then + echo "gitleaks is not installed — install it so this repo stays clean:" + echo " macOS: brew install gitleaks" + echo " other: https://github.com/gitleaks/gitleaks#installing" + echo "Set ALLOW_MISSING_GITLEAKS=1 to skip this check (not recommended)." + [[ "${ALLOW_MISSING_GITLEAKS:-0}" == "1" ]] && exit 0 + exit 1 +fi + +gitleaks git --staged --config .gitleaks.toml --redact --verbose --no-banner diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..b28a5f5 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "types": ["bun"] + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3ac3061 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.base.json", + "include": ["apps/server/src", "packages/*/src"], + "exclude": ["node_modules", "dist"] +}