Compare commits
91 Commits
hermes-bac
...
feat/fix-o
| Author | SHA1 | Date | |
|---|---|---|---|
| 148766312e | |||
| 97ec6ccbae | |||
| 9d32d32d38 | |||
| a8f0828727 | |||
|
|
b250492925 | ||
|
|
01371db6ae | ||
|
|
7e529c8c44 | ||
|
|
3b88a41afd | ||
|
|
9cc8637dc7 | ||
|
|
e59bfd908a | ||
|
|
3c99af7143 | ||
|
|
f79cdafba7 | ||
|
|
36fb6ee519 | ||
|
|
da126b2e74 | ||
|
|
699ec38480 | ||
|
|
173f096e33 | ||
|
|
1c28cae3f2 | ||
|
|
2a6ddb9f78 | ||
|
|
11eec585fa | ||
|
|
d81e7f17ac | ||
|
|
4ebd4cc211 | ||
|
|
8fac5d060f | ||
|
|
ee4630e79c | ||
|
|
ce211df48c | ||
|
|
b4939bd57f | ||
|
|
b0b648bdcf | ||
|
|
9aa106cede | ||
|
|
ee916eabc4 | ||
|
|
d5a65d73b5 | ||
|
|
514e1d46c0 | ||
|
|
02682750ab | ||
|
|
8d41455e39 | ||
|
|
411227046a | ||
|
|
e66f927f89 | ||
|
|
3677575f65 | ||
|
|
765c618a76 | ||
|
|
85b56ca0f8 | ||
|
|
c474767ba1 | ||
|
|
5a2543592a | ||
|
|
4349864234 | ||
|
|
1aa1c0456f | ||
|
|
239eb3327a | ||
|
|
08b77839b1 | ||
|
|
3ea6a73833 | ||
|
|
96dff8900e | ||
|
|
409d7d8be7 | ||
|
|
4ccf8f2746 | ||
|
|
4e4843ce1a | ||
|
|
e1285c73ad | ||
|
|
c9bd2db07d | ||
|
|
56b3d18f40 | ||
|
|
66b299a28e | ||
|
|
6b602c05bb | ||
|
|
b4fac95f37 | ||
|
|
a2854ab8ea | ||
|
|
7357072b9e | ||
|
|
9a35be7b15 | ||
|
|
ab8a1ba866 | ||
|
|
0af1ad923c | ||
|
|
753c978f56 | ||
|
|
b29fa240a6 | ||
|
|
9893230cde | ||
|
|
90823d12a6 | ||
|
|
0935099bc2 | ||
|
|
9945e4cf14 | ||
|
|
d4452abcea | ||
|
|
3bb1150773 | ||
|
|
ff9b298116 | ||
|
|
239d70f2b6 | ||
|
|
9ab9d52cae | ||
|
|
fde483657f | ||
|
|
af83be0a32 | ||
|
|
8f8a64c984 | ||
|
|
ad2d131ae4 | ||
|
|
e3190c0bd7 | ||
|
|
3a22f8e9c8 | ||
|
|
e5e930d381 | ||
|
|
7354879596 | ||
|
|
5bd2e8a1f0 | ||
|
|
526c45d9ce | ||
|
|
ec1a1b3665 | ||
|
|
801555222e | ||
|
|
04104d1f92 | ||
|
|
ab69b0045f | ||
|
|
472b1405a6 | ||
|
|
282ba1b76c | ||
|
|
37eb9bdf59 | ||
|
|
e90d20893d | ||
|
|
12712bb0b0 | ||
|
|
3de6832a5d | ||
|
|
793ed230db |
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@@ -6,7 +6,7 @@ runs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|||||||
73
.github/dependabot.yml
vendored
73
.github/dependabot.yml
vendored
@@ -1,5 +1,9 @@
|
|||||||
# Dependabot configuration
|
# Dependabot configuration
|
||||||
# Docs: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
# Docs: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
#
|
||||||
|
# Grouping policy: collapse all minor+patch bumps into ONE PR per ecosystem so
|
||||||
|
# the review queue stays small; majors get their own grouped PR per ecosystem so
|
||||||
|
# breaking changes still get individual scrutiny.
|
||||||
|
|
||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
@@ -19,47 +23,17 @@ updates:
|
|||||||
- "dependencies"
|
- "dependencies"
|
||||||
- "javascript"
|
- "javascript"
|
||||||
groups:
|
groups:
|
||||||
# Keep TanStack libs in lockstep — they release as a family
|
npm-minor-patch:
|
||||||
tanstack:
|
|
||||||
patterns:
|
patterns:
|
||||||
- "@tanstack/*"
|
- "*"
|
||||||
# Tauri JS bindings
|
update-types:
|
||||||
tauri:
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
npm-major:
|
||||||
patterns:
|
patterns:
|
||||||
- "@tauri-apps/*"
|
- "*"
|
||||||
# Test stack
|
update-types:
|
||||||
testing:
|
- "major"
|
||||||
patterns:
|
|
||||||
- "vitest"
|
|
||||||
- "@vitest/*"
|
|
||||||
- "@testing-library/*"
|
|
||||||
- "jsdom"
|
|
||||||
- "@playwright/*"
|
|
||||||
# Build/lint tooling
|
|
||||||
tooling:
|
|
||||||
patterns:
|
|
||||||
- "@biomejs/*"
|
|
||||||
- "turbo"
|
|
||||||
- "vite"
|
|
||||||
- "@vitejs/*"
|
|
||||||
- "typescript"
|
|
||||||
# React core
|
|
||||||
react:
|
|
||||||
patterns:
|
|
||||||
- "react"
|
|
||||||
- "react-dom"
|
|
||||||
- "@types/react"
|
|
||||||
- "@types/react-dom"
|
|
||||||
# Semantic-release ecosystem
|
|
||||||
semantic-release:
|
|
||||||
patterns:
|
|
||||||
- "semantic-release"
|
|
||||||
- "@semantic-release/*"
|
|
||||||
# Commitlint + husky
|
|
||||||
commit-tooling:
|
|
||||||
patterns:
|
|
||||||
- "@commitlint/*"
|
|
||||||
- "husky"
|
|
||||||
|
|
||||||
# ─── Rust / Cargo (Tauri desktop shell) ───────────────────────────────────
|
# ─── Rust / Cargo (Tauri desktop shell) ───────────────────────────────────
|
||||||
- package-ecosystem: "cargo"
|
- package-ecosystem: "cargo"
|
||||||
@@ -77,16 +51,17 @@ updates:
|
|||||||
- "dependencies"
|
- "dependencies"
|
||||||
- "rust"
|
- "rust"
|
||||||
groups:
|
groups:
|
||||||
tauri-core:
|
cargo-minor-patch:
|
||||||
patterns:
|
patterns:
|
||||||
- "tauri"
|
- "*"
|
||||||
- "tauri-*"
|
update-types:
|
||||||
tokio:
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
cargo-major:
|
||||||
patterns:
|
patterns:
|
||||||
- "tokio"
|
- "*"
|
||||||
- "tokio-*"
|
update-types:
|
||||||
- "futures"
|
- "major"
|
||||||
- "futures-*"
|
|
||||||
|
|
||||||
# ─── GitHub Actions workflow pins ─────────────────────────────────────────
|
# ─── GitHub Actions workflow pins ─────────────────────────────────────────
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
@@ -103,3 +78,7 @@ updates:
|
|||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
- "github-actions"
|
- "github-actions"
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
name: Lint, type-check, test & build
|
name: Lint, type-check, test & build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: ./.github/actions/setup
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.RELEASE_TOKEN }}
|
token: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
|||||||
77
.github/workflows/docker-publish.yml
vendored
Normal file
77
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
name: Publish web image
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Build & push web image to GHCR
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-qemu-action@v4
|
||||||
|
- uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository_owner }}/openconcho-web
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha,format=short
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
publish-chart:
|
||||||
|
name: Package & push Helm chart to GHCR
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [publish]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: azure/setup-helm@v4
|
||||||
|
|
||||||
|
- name: Derive chart version
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to GHCR (Helm OCI)
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io \
|
||||||
|
--username "${{ github.actor }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Package chart
|
||||||
|
run: |
|
||||||
|
helm package charts/openconcho \
|
||||||
|
--version "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--app-version "${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
- name: Push chart
|
||||||
|
run: |
|
||||||
|
helm push "openconcho-${{ steps.version.outputs.VERSION }}.tgz" \
|
||||||
|
oci://ghcr.io/${{ github.repository_owner }}/charts
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
|||||||
10
AGENTS.md
10
AGENTS.md
@@ -16,6 +16,11 @@ Frontend UI for self-hosted Honcho instances — browse memories, peers, session
|
|||||||
| `make typecheck` | tsc --noEmit |
|
| `make typecheck` | tsc --noEmit |
|
||||||
| `make test` | Vitest (unit + integration), excludes `e2e/` |
|
| `make test` | Vitest (unit + integration), excludes `e2e/` |
|
||||||
| `make test-e2e` | Playwright e2e (uncached) |
|
| `make test-e2e` | Playwright e2e (uncached) |
|
||||||
|
| `make smoke-docker` | Local: build image + hermetic smoke test of the `/api` proxy (Docker required) |
|
||||||
|
| `make up` | Run the web container from source (dev-forward, builds) at :8080 |
|
||||||
|
| `make prod` | Run the web container from the published image (pulls `ghcr…:latest`) |
|
||||||
|
| `make down` | Stop + remove the web container (dev or prod) |
|
||||||
|
| `make clean` | `down` + remove the locally built image |
|
||||||
| `make check` | lint + typecheck + test |
|
| `make check` | lint + typecheck + test |
|
||||||
| `pnpm --filter @openconcho/desktop cargo-check` | Local Rust/Tauri compile check before pushing desktop changes |
|
| `pnpm --filter @openconcho/desktop cargo-check` | Local Rust/Tauri compile check before pushing desktop changes |
|
||||||
| `pnpm --filter @openconcho/web generate:api` | Regen `src/api/schema.d.ts` from `openapi.json` |
|
| `pnpm --filter @openconcho/web generate:api` | Regen `src/api/schema.d.ts` from `openapi.json` |
|
||||||
@@ -33,6 +38,7 @@ Frontend UI for self-hosted Honcho instances — browse memories, peers, session
|
|||||||
| `packages/web/src/test/` | Vitest unit/integration tests + setup |
|
| `packages/web/src/test/` | Vitest unit/integration tests + setup |
|
||||||
| `packages/web/e2e/` | Playwright e2e specs |
|
| `packages/web/e2e/` | Playwright e2e specs |
|
||||||
| `packages/desktop/` | Tauri shell that bundles the built web app |
|
| `packages/desktop/` | Tauri shell that bundles the built web app |
|
||||||
|
| `charts/openconcho/` | Helm 3 chart for self-hosting on Kubernetes (OCI artifact on GHCR) |
|
||||||
| `.claude/rules/` | Coding conventions (auto-loaded; stack-agnostic, applies to all agents) |
|
| `.claude/rules/` | Coding conventions (auto-loaded; stack-agnostic, applies to all agents) |
|
||||||
| `docs/` | Architecture and references |
|
| `docs/` | Architecture and references |
|
||||||
|
|
||||||
@@ -63,7 +69,9 @@ Before pushing any change under `packages/desktop/**` or `packages/desktop/src-t
|
|||||||
|
|
||||||
## Key Constraints
|
## Key Constraints
|
||||||
|
|
||||||
- **No hardcoded URLs** — all connection config lives in `localStorage` under `openconcho:config`
|
- **No hardcoded URLs** — connection config lives in `localStorage` under `openconcho:instances` (multi-instance store; legacy `openconcho:config` is auto-migrated)
|
||||||
|
- **Web CORS via a same-origin `/api` proxy** — the web build issues all Honcho calls to `/api/*` with an `X-Honcho-Upstream` header (the active instance's URL); nginx (docker) and a Vite middleware (dev) forward server-side. Transport is resolved by `dispatchFor` in `src/lib/dispatch.ts`: web → relative `/api` + header; Tauri → absolute URL + reqwest. Optional `OPENCONCHO_UPSTREAM_ALLOWLIST` guards the proxy when exposed.
|
||||||
|
- **Local git hooks** — `.husky/pre-commit` runs a secret scan + Biome on staged files; `.husky/pre-push` runs `pnpm check`. Your commits and pushes trigger these.
|
||||||
- **TanStack Router flat-route params** — always cast `params` as `as never` at `navigate()` and `<Link>` callsites
|
- **TanStack Router flat-route params** — always cast `params` as `as never` at `navigate()` and `<Link>` callsites
|
||||||
- **`framer-motion` Variants typing** — import `type Variants` and annotate objects; never use `as const` on variant objects
|
- **`framer-motion` Variants typing** — import `type Variants` and annotate objects; never use `as const` on variant objects
|
||||||
- **Auth is optional** — token header only sent when non-empty; `checkConnection()` detects if auth is required
|
- **Auth is optional** — token header only sent when non-empty; `checkConnection()` detects if auth is required
|
||||||
|
|||||||
80
CHANGELOG.md
80
CHANGELOG.md
@@ -1,3 +1,83 @@
|
|||||||
|
# [0.16.0](https://github.com/offendingcommit/openconcho/compare/v0.15.0...v0.16.0) (2026-06-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** replace userEvent.selectOptions with fireEvent.change; bump setup-node to v6 ([1c28cae](https://github.com/offendingcommit/openconcho/commit/1c28cae3f2aed84e9c2deff7c7fcb622bb86df2e))
|
||||||
|
* **dashboard:** guard setMetricsById against same-value calls to end loop ([3b88a41](https://github.com/offendingcommit/openconcho/commit/3b88a41afda842eb3d493b7d77a825595164cb94))
|
||||||
|
* **dashboard:** remove lastSeen from metrics useMemo deps to break render loop ([f79cdaf](https://github.com/offendingcommit/openconcho/commit/f79cdafba7aa16004c75d2684d7dedd6279f3265))
|
||||||
|
* **dashboard:** use primitive deps in onMetrics effect to break render loop ([9cc8637](https://github.com/offendingcommit/openconcho/commit/9cc8637dc7ebf62b2002a0a3d8e4b3781660e770))
|
||||||
|
* **test:** remove unused within import from fleet.test.tsx ([36fb6ee](https://github.com/offendingcommit/openconcho/commit/36fb6ee51923c8eb3261d851a1754f7f1c6ec0b9))
|
||||||
|
* **web:** break metricsEqual lastSeen dep-loop; add staleTime to test ([173f096](https://github.com/offendingcommit/openconcho/commit/173f096e33157a1744ffa2ef9d09a170216ac036))
|
||||||
|
* **web:** reset serverFilter when the selected instance is removed ([699ec38](https://github.com/offendingcommit/openconcho/commit/699ec3848016aad31db33eecc0079645f24f83b6))
|
||||||
|
* **web:** stop Sidebar re-render loop from cache-event subscriber ([7e529c8](https://github.com/offendingcommit/openconcho/commit/7e529c8c44b358d3f437f1f1888c0e7fa5664d52))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **web:** merge Fleet into a server-filterable Dashboard ([e66f927](https://github.com/offendingcommit/openconcho/commit/e66f927f89cbfda6e900080369599fe0d4eeb5bf)), closes [#54](https://github.com/offendingcommit/openconcho/issues/54)
|
||||||
|
* **web:** redirect /fleet to Dashboard; update fleet tests ([da126b2](https://github.com/offendingcommit/openconcho/commit/da126b2e74f89cdeca323e00232a4f49764fb992))
|
||||||
|
|
||||||
|
# [0.15.0](https://github.com/offendingcommit/openconcho/compare/v0.14.0...v0.15.0) (2026-06-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **helm:** guard tmpfs blocks when empty, cap volume names at 63 chars ([d5a65d7](https://github.com/offendingcommit/openconcho/commit/d5a65d73b59378f5ce39bf76e0572da478cecbda))
|
||||||
|
* **helm:** pdb mutual exclusion, ingress null rules guard, hpa nil utilization guard ([b4939bd](https://github.com/offendingcommit/openconcho/commit/b4939bd57f2dba5ebca9efcd42901457512e70e4))
|
||||||
|
* **helm:** pin busybox:1.36, add -T 10 timeout, use --spider, add activeDeadlineSeconds ([8fac5d0](https://github.com/offendingcommit/openconcho/commit/8fac5d060f45b68141917efad4afe499ca2fda56))
|
||||||
|
* **helm:** use http://json-schema.org/draft-07/schema# for Helm compatibility ([8d41455](https://github.com/offendingcommit/openconcho/commit/8d41455e39db51617d7476e5cc48577eb7fff158))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **helm:** add _helpers.tpl with name, label, and imageTag partials ([0268275](https://github.com/offendingcommit/openconcho/commit/02682750ab766851570eae58eb0b92761b98724f))
|
||||||
|
* **helm:** add Deployment template with read-only FS, tmpfs, probes ([514e1d4](https://github.com/offendingcommit/openconcho/commit/514e1d46c0248bfae5da1f2ceb12ca8799a81468))
|
||||||
|
* **helm:** add NOTES.txt with access instructions and NetworkPolicy/Ingress warning ([ce211df](https://github.com/offendingcommit/openconcho/commit/ce211df48cc59dfe933eb7a1b1415591b0e9f7fa))
|
||||||
|
* **helm:** add optional HPA, PDB, and NetworkPolicy templates ([b0b648b](https://github.com/offendingcommit/openconcho/commit/b0b648bdcf64732c0a713bd8e45077c5f1b39ba6))
|
||||||
|
* **helm:** add optional Ingress template ([9aa106c](https://github.com/offendingcommit/openconcho/commit/9aa106cede7d5719ee2cbc48c1c677491deea568))
|
||||||
|
* **helm:** add Service and ServiceAccount templates ([ee916ea](https://github.com/offendingcommit/openconcho/commit/ee916eabc485f37cdc56ffbdd8d9004f33f3a7b7))
|
||||||
|
* **helm:** add test-healthz and test-spa-root helm test jobs ([ee4630e](https://github.com/offendingcommit/openconcho/commit/ee4630e79ca588ee0f9cb167ac0f58ae4b8223cc))
|
||||||
|
* **helm:** chart scaffold — Chart.yaml, values, schema ([4112270](https://github.com/offendingcommit/openconcho/commit/411227046a3dee125a555a0d1a426afed0e74ec3))
|
||||||
|
|
||||||
|
# [0.14.0](https://github.com/offendingcommit/openconcho/compare/v0.13.1...v0.14.0) (2026-06-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **docker:** derive nginx resolver from container DNS ([66b299a](https://github.com/offendingcommit/openconcho/commit/66b299a28e912bc2f8c2922b40292696c4f7d81a))
|
||||||
|
* **docker:** drop dead HONCHO_UPSTREAM and same-origin default ([a2854ab](https://github.com/offendingcommit/openconcho/commit/a2854ab8ea0a9eec2a06838fb394a0264f7dd80d))
|
||||||
|
* **web:** enforce upstream allowlist in vite dev proxy ([b4fac95](https://github.com/offendingcommit/openconcho/commit/b4fac95f37da3985dbc4fbf64d04dd509ec86c2c))
|
||||||
|
* **web:** raise connection-test timeout for cold upstreams ([409d7d8](https://github.com/offendingcommit/openconcho/commit/409d7d8be7f5cc94421dce32a54105ea48bfd44b))
|
||||||
|
* **web:** strip content-encoding from vite dev proxy responses ([6b602c0](https://github.com/offendingcommit/openconcho/commit/6b602c05bb81721dfc102b3f97112b2cf58d4d60))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **docker:** header-driven /api reverse proxy in nginx ([753c978](https://github.com/offendingcommit/openconcho/commit/753c978f56dab61d0c15b25b56ecf438cdc5ae88))
|
||||||
|
* **docker:** render SSRF allowlist map from env ([0af1ad9](https://github.com/offendingcommit/openconcho/commit/0af1ad923cd2aa61a201d65ce4f19acb13858790))
|
||||||
|
* **docker:** split compose into dev-forward build and prod pull ([c9bd2db](https://github.com/offendingcommit/openconcho/commit/c9bd2db07d84e0eedffeadcc6f2bc15c628eb251))
|
||||||
|
* **web:** add dispatchFor transport helper for same-origin proxy ([9945e4c](https://github.com/offendingcommit/openconcho/commit/9945e4cf148aec6fc47bb853e8661c339c52ff32))
|
||||||
|
* **web:** dev /api proxy middleware mirroring nginx ([ab8a1ba](https://github.com/offendingcommit/openconcho/commit/ab8a1ba866728ff972544c1d912fed59ba03a4a7))
|
||||||
|
* **web:** route checkConnection and discovery through the proxy ([9893230](https://github.com/offendingcommit/openconcho/commit/9893230cde3d11ce73350bd12fffae236ee9adff))
|
||||||
|
* **web:** route web build through same-origin /api proxy ([0935099](https://github.com/offendingcommit/openconcho/commit/0935099bc28468a21183f5f03105645f4ac8aa8a))
|
||||||
|
|
||||||
|
## [0.13.1](https://github.com/offendingcommit/openconcho/compare/v0.13.0...v0.13.1) (2026-05-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **docker:** make docker-compose runnable standalone ([fde4836](https://github.com/offendingcommit/openconcho/commit/fde483657fabde5b0f39578ec354c2e5c8b02daa))
|
||||||
|
|
||||||
|
# [0.13.0](https://github.com/offendingcommit/openconcho/compare/v0.12.1...v0.13.0) (2026-05-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **docker:** full self-hosted Compose support ([282ba1b](https://github.com/offendingcommit/openconcho/commit/282ba1b76c43b51aae5bf4d173fd2c76bfbd8eac))
|
||||||
|
* **web:** add Fleet dashboard view for cross-instance observability ([12712bb](https://github.com/offendingcommit/openconcho/commit/12712bb0b087f3413173328cad96c9b04bb35ca5))
|
||||||
|
* **web:** configurable peer display name ([3de6832](https://github.com/offendingcommit/openconcho/commit/3de6832a5dc7aad971ac87e356674d834fe9e715))
|
||||||
|
|
||||||
## [0.12.1](https://github.com/offendingcommit/openconcho/compare/v0.12.0...v0.12.1) (2026-05-28)
|
## [0.12.1](https://github.com/offendingcommit/openconcho/compare/v0.12.0...v0.12.1) (2026-05-28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
63
CLAUDE.md
63
CLAUDE.md
@@ -1,62 +1 @@
|
|||||||
# openconcho
|
@AGENTS.md
|
||||||
|
|
||||||
Frontend UI for self-hosted Honcho instances — browse memories, peers, sessions, conclusions, and chat with memory context. Ships as a web app (`@openconcho/web`) and a Tauri desktop wrapper (`@openconcho/desktop`).
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
`make` is the canonical interface; it shells out to pnpm scripts which shell out to turborepo. CI calls the same targets — `make help` lists everything.
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| `make bootstrap` | Install deps + Playwright Chromium (run once after clone) |
|
|
||||||
| `make dev-web` | Vite dev server on http://localhost:5173 |
|
|
||||||
| `make dev-desktop` (or `make dev`) | Tauri desktop app |
|
|
||||||
| `make build` | Turbo: build web + desktop |
|
|
||||||
| `make lint` | Biome check |
|
|
||||||
| `make typecheck` | tsc --noEmit |
|
|
||||||
| `make test` | Vitest (unit + integration), excludes `e2e/` |
|
|
||||||
| `make test-e2e` | Playwright e2e (uncached) |
|
|
||||||
| `make check` | lint + typecheck + test |
|
|
||||||
| `pnpm --filter @openconcho/desktop cargo-check` | Local Rust/Tauri compile check before pushing desktop changes |
|
|
||||||
| `pnpm --filter @openconcho/web generate:api` | Regen `src/api/schema.d.ts` from `openapi.json` |
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
| Path | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `packages/web/` | Vite + React 19 + TanStack Router/Query SPA |
|
|
||||||
| `packages/web/src/routes/` | TanStack Router file-based routes (flat-route syntax) |
|
|
||||||
| `packages/web/src/components/` | Feature components grouped by domain |
|
|
||||||
| `packages/web/src/api/` | openapi-fetch client + TanStack Query hooks |
|
|
||||||
| `packages/web/src/lib/` | Config (localStorage) + theme utilities |
|
|
||||||
| `packages/web/src/hooks/` | Custom React hooks |
|
|
||||||
| `packages/web/src/test/` | Vitest unit/integration tests + setup |
|
|
||||||
| `packages/web/e2e/` | Playwright e2e specs |
|
|
||||||
| `packages/desktop/` | Tauri shell that bundles the built web app |
|
|
||||||
| `.claude/rules/` | Coding conventions (auto-loaded) |
|
|
||||||
| `docs/` | Architecture and references |
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
Read `.claude/rules/coding-standards.md` when writing or reviewing any code file.
|
|
||||||
|
|
||||||
## Workflows
|
|
||||||
|
|
||||||
Read `.claude/rules/workflows.md` for recurring task patterns.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Read `docs/architecture.md` for component overview, data flow, and design decisions.
|
|
||||||
|
|
||||||
## Key Constraints
|
|
||||||
|
|
||||||
- **No hardcoded URLs** — all connection config lives in `localStorage` under `openconcho:config`
|
|
||||||
- **TanStack Router flat-route params** — always cast `params` as `as never` at `navigate()` and `<Link>` callsites
|
|
||||||
- **`framer-motion` Variants typing** — import `type Variants` and annotate objects; never use `as const` on variant objects
|
|
||||||
- **Auth is optional** — token header only sent when non-empty; `checkConnection()` detects if auth is required
|
|
||||||
- **CSS variables only** — no Tailwind color utilities for theme-aware colors; use `var(--text-1)` etc.
|
|
||||||
- **Shared deps via pnpm catalog** — version-pinned in `pnpm-workspace.yaml`; reference as `"catalog:"` in package.json
|
|
||||||
- **Conventional commits enforced** — commitlint runs in husky `commit-msg`; body lines must be ≤100 chars
|
|
||||||
- **Releases via semantic-release** — `.releaserc.json`; commits land on `main`, no manual version bumps
|
|
||||||
- **GitHub account** — push under `offendingcommit` (`gh auth switch` if needed)
|
|
||||||
- **Desktop preflight is local** — Rust/Tauri compile-check no longer runs in PR CI; run `pnpm --filter @openconcho/desktop cargo-check` before pushing any `packages/desktop/**` or `packages/desktop/src-tauri/**` change
|
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -31,8 +31,17 @@ RUN pnpm --filter @openconcho/web build
|
|||||||
FROM nginxinc/nginx-unprivileged:alpine
|
FROM nginxinc/nginx-unprivileged:alpine
|
||||||
|
|
||||||
COPY --chown=101:101 --from=builder /app/packages/web/dist /usr/share/nginx/html
|
COPY --chown=101:101 --from=builder /app/packages/web/dist /usr/share/nginx/html
|
||||||
COPY --chown=101:101 docker/nginx.conf /etc/nginx/conf.d/default.conf
|
# Rendered to /etc/nginx/conf.d/default.conf by the image's envsubst entrypoint.
|
||||||
|
COPY --chown=101:101 docker/nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
# Writes /usr/share/nginx/html/config.js from OPENCONCHO_DEFAULT_HONCHO_URL.
|
||||||
|
# --chmod=0755 so nginx's docker-entrypoint.d actually executes it.
|
||||||
|
COPY --chown=101:101 --chmod=0755 docker/40-openconcho-config.sh /docker-entrypoint.d/40-openconcho-config.sh
|
||||||
|
|
||||||
|
# Empty default → clean first run (configure the instance in Settings). Override per
|
||||||
|
# deploy to seed the first instance; the browser routes via /api with an
|
||||||
|
# X-Honcho-Upstream header. Optional OPENCONCHO_UPSTREAM_ALLOWLIST guards the proxy.
|
||||||
|
ENV OPENCONCHO_DEFAULT_HONCHO_URL=""
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Base image CMD runs nginx in the foreground as UID 101.
|
# Base image entrypoint renders the template + runs config script, then nginx (UID 101).
|
||||||
|
|||||||
18
Makefile
18
Makefile
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
.PHONY: bootstrap dev dev-web dev-desktop \
|
.PHONY: bootstrap dev dev-web dev-desktop \
|
||||||
build test test-e2e lint lint-fix typecheck check \
|
build test test-e2e lint lint-fix typecheck check \
|
||||||
ci-web ci-desktop install help
|
ci-web ci-desktop smoke-docker \
|
||||||
|
up prod down clean install help
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS=":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS=":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
||||||
@@ -47,5 +48,20 @@ ci-web: ## CI: lint + typecheck + test + build for @openconcho/web
|
|||||||
ci-desktop: ## CI: cargo-check for @openconcho/desktop
|
ci-desktop: ## CI: cargo-check for @openconcho/desktop
|
||||||
pnpm ci:desktop
|
pnpm ci:desktop
|
||||||
|
|
||||||
|
smoke-docker: ## Local: build the image + smoke-test the /api proxy (Docker required)
|
||||||
|
bash docker/smoke-test.sh
|
||||||
|
|
||||||
|
up: ## Run the web container from source (dev profile, builds) at :8080
|
||||||
|
docker compose --profile dev up -d --build
|
||||||
|
|
||||||
|
prod: ## Run the web container from the published image (prod profile, pulls latest)
|
||||||
|
docker compose --profile prod up -d
|
||||||
|
|
||||||
|
down: ## Stop + remove the web container (either profile)
|
||||||
|
docker compose --profile dev --profile prod down --remove-orphans
|
||||||
|
|
||||||
|
clean: down ## down + remove the locally built image
|
||||||
|
-docker image rm openconcho-web:local
|
||||||
|
|
||||||
install: ## pnpm install (no playwright)
|
install: ## pnpm install (no playwright)
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -29,13 +29,20 @@ Browse memories, peers, sessions, and conclusions — or chat with full memory c
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dashboard** | Workspace count and queue status, auto-refreshes every 10 s |
|
| **Dashboard** | Workspace count and queue status, auto-refreshes every 10 s |
|
||||||
|
| **Multiple instances** | Add and switch between several Honcho connections |
|
||||||
|
| **Fleet dashboard** | Cross-instance observability — workspaces/sessions/queue side-by-side with per-instance badges |
|
||||||
| **Workspaces** | Paginated list with per-workspace navigation |
|
| **Workspaces** | Paginated list with per-workspace navigation |
|
||||||
| **Peers** | Browse peers, view representations, context, and peer cards |
|
| **Peers** | Browse peers, view representations, context, and peer cards |
|
||||||
|
| **Peer display names** | Set a friendly `display_name` (metadata) to replace raw peer ids |
|
||||||
|
| **Peer Card Seed Kits** | Author reusable peer-card kits and apply them across instances |
|
||||||
| **Sessions** | Paginated message history with summaries and context |
|
| **Sessions** | Paginated message history with summaries and context |
|
||||||
| **Conclusions** | Semantic search across conclusions with observer/subject display |
|
| **Conclusions** | Semantic search across conclusions with observer/subject display |
|
||||||
|
| **Dream viewer** | Browse dream/consolidation bursts with a recursive premise tree |
|
||||||
|
| **Dialectic playground** | Fan one query across all reasoning levels side-by-side |
|
||||||
| **Webhooks** | Manage and trigger webhooks per workspace |
|
| **Webhooks** | Manage and trigger webhooks per workspace |
|
||||||
| **Chat** | Conversational interface through Honcho's chat endpoint with memory context |
|
| **Chat** | Conversational interface through Honcho's chat endpoint with memory context |
|
||||||
| **Schedule Dream** | Trigger Honcho's dream/consolidation pass on demand |
|
| **Schedule Dream** | Trigger Honcho's dream/consolidation pass on demand |
|
||||||
|
| **Demo mode** | Mask identifiers/content for screenshots and screen-sharing |
|
||||||
| **Dark / light mode** | Persisted per device, instant toggle |
|
| **Dark / light mode** | Persisted per device, instant toggle |
|
||||||
| **Optional auth** | Token field is optional; connection health check auto-detects auth requirement |
|
| **Optional auth** | Token field is optional; connection health check auto-detects auth requirement |
|
||||||
|
|
||||||
@@ -47,15 +54,15 @@ Pre-built binaries are attached to every [GitHub Release](https://github.com/off
|
|||||||
|---|---|
|
|---|---|
|
||||||
| macOS (Apple Silicon) | `OpenConcho_*_aarch64.dmg` |
|
| macOS (Apple Silicon) | `OpenConcho_*_aarch64.dmg` |
|
||||||
| macOS (Intel) | `OpenConcho_*_x64.dmg` |
|
| macOS (Intel) | `OpenConcho_*_x64.dmg` |
|
||||||
| Linux | `openconcho_*_amd64.deb` / `openconcho_*_amd64.AppImage` |
|
| Linux | `openconcho_*_amd64.deb` / `openconcho_*_amd64.AppImage` / `OpenConcho-*.x86_64.rpm` |
|
||||||
| Windows | `OpenConcho_*_x64-setup.exe` / `OpenConcho_*_x64_en-US.msi` |
|
| Windows | `OpenConcho_*_x64-setup.exe` / `OpenConcho_*_x64_en-US.msi` |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/) ≥ 20
|
- [Node.js](https://nodejs.org/) ≥ 22
|
||||||
- [pnpm](https://pnpm.io/) ≥ 9
|
- [pnpm](https://pnpm.io/) 10 (pinned via `packageManager`; `corepack enable` picks it up)
|
||||||
- A running [Honcho](https://github.com/plastic-labs/honcho) instance (local or remote)
|
- A running [Honcho](https://github.com/plastic-labs/honcho) instance (local or remote)
|
||||||
|
|
||||||
### Web app
|
### Web app
|
||||||
@@ -80,6 +87,61 @@ pnpm install
|
|||||||
pnpm --filter @openconcho/desktop dev
|
pnpm --filter @openconcho/desktop dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker (web app)
|
||||||
|
|
||||||
|
The container serves the SPA and reverse-proxies the Honcho API under its own
|
||||||
|
origin: the browser calls `/api` same-origin and names the upstream in an
|
||||||
|
`X-Honcho-Upstream` header, so there's no browser CORS to configure.
|
||||||
|
|
||||||
|
Two Compose modes (the published image is `ghcr.io/offendingcommit/openconcho-web`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev-forward — build from this repo and run your local changes:
|
||||||
|
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make up
|
||||||
|
|
||||||
|
# Production — pull the latest published image instead of building:
|
||||||
|
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make prod
|
||||||
|
|
||||||
|
make down # stop + remove (dev or prod)
|
||||||
|
make clean # down + drop the locally built image
|
||||||
|
# → http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Both modes live in one [`docker-compose.yml`](docker-compose.yml) as Compose
|
||||||
|
profiles: `make up` runs the `dev` profile (`build: .`), `make prod` runs the
|
||||||
|
`prod` profile (pulls `ghcr…:latest`). `OPENCONCHO_DEFAULT_HONCHO_URL` seeds the first instance
|
||||||
|
(absolute URL); `OPENCONCHO_UPSTREAM_ALLOWLIST` is an optional SSRF guard
|
||||||
|
(comma-separated host globs) for when you expose the proxy. Full details and env
|
||||||
|
vars are in [`docs/docker.md`](docs/docker.md).
|
||||||
|
|
||||||
|
### Kubernetes (Helm)
|
||||||
|
|
||||||
|
The chart is published as an OCI artifact to GHCR on every tagged release.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install openconcho oci://ghcr.io/offendingcommit/charts/openconcho \
|
||||||
|
--version 0.14.0 \
|
||||||
|
--create-namespace --namespace openconcho \
|
||||||
|
--set honcho.defaultUrl=https://honcho.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable an Ingress and TLS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install openconcho oci://ghcr.io/offendingcommit/charts/openconcho \
|
||||||
|
--version 0.14.0 \
|
||||||
|
--create-namespace --namespace openconcho \
|
||||||
|
--set honcho.defaultUrl=https://honcho.example.com \
|
||||||
|
--set ingress.enabled=true \
|
||||||
|
--set ingress.className=nginx \
|
||||||
|
--set 'ingress.hosts[0].host=openconcho.example.com' \
|
||||||
|
--set 'ingress.hosts[0].paths[0].path=/' \
|
||||||
|
--set 'ingress.tls[0].secretName=openconcho-tls' \
|
||||||
|
--set 'ingress.tls[0].hosts[0]=openconcho.example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
Full chart documentation, configuration reference, and an ArgoCD Application example are in [`charts/openconcho/README.md`](charts/openconcho/README.md).
|
||||||
|
|
||||||
### Connecting to your instance
|
### Connecting to your instance
|
||||||
|
|
||||||
1. Enter the base URL of your Honcho instance (e.g. `http://localhost:8000`)
|
1. Enter the base URL of your Honcho instance (e.g. `http://localhost:8000`)
|
||||||
@@ -131,7 +193,7 @@ pnpm --filter @openconcho/web generate:api
|
|||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
- Base URL and token stored in `localStorage` under `openconcho:config`
|
- Connection details (base URL + token, one or more instances) stored in `localStorage` under `openconcho:instances`
|
||||||
- Theme preference stored in `localStorage` under `openconcho:theme`
|
- Theme preference stored in `localStorage` under `openconcho:theme`
|
||||||
- No telemetry, no analytics, no external requests beyond your configured Honcho instance
|
- No telemetry, no analytics, no external requests beyond your configured Honcho instance
|
||||||
|
|
||||||
|
|||||||
16
charts/openconcho/Chart.yaml
Normal file
16
charts/openconcho/Chart.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: openconcho
|
||||||
|
description: Self-hosted UI for Honcho — browse memories, peers, sessions, conclusions, and chat with memory context.
|
||||||
|
type: application
|
||||||
|
version: 0.14.0
|
||||||
|
appVersion: "0.14.0"
|
||||||
|
keywords:
|
||||||
|
- honcho
|
||||||
|
- memory
|
||||||
|
- ai
|
||||||
|
home: https://github.com/offendingcommit/openconcho
|
||||||
|
sources:
|
||||||
|
- https://github.com/offendingcommit/openconcho
|
||||||
|
maintainers:
|
||||||
|
- name: offendingcommit
|
||||||
|
url: https://github.com/offendingcommit
|
||||||
229
charts/openconcho/README.md
Normal file
229
charts/openconcho/README.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# openconcho Helm Chart
|
||||||
|
|
||||||
|
Helm 3 chart for self-hosting the [openconcho](https://github.com/offendingcommit/openconcho) web UI on Kubernetes.
|
||||||
|
|
||||||
|
The chart deploys a single nginx-unprivileged container (port 8080, UID 101) that serves the React SPA and reverse-proxies Honcho API calls under `/api` to avoid browser CORS issues.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Kubernetes 1.25+
|
||||||
|
- Helm 3.10+
|
||||||
|
- A running [Honcho](https://github.com/plastic-labs/honcho) instance reachable from within the cluster (or via a configured ingress)
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Add the chart repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm registry login ghcr.io --username <github-username> --password <github-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the chart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install openconcho oci://ghcr.io/offendingcommit/charts/openconcho \
|
||||||
|
--version 0.14.0 \
|
||||||
|
--set honcho.defaultUrl=https://honcho.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with a values file (recommended):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install openconcho oci://ghcr.io/offendingcommit/charts/openconcho \
|
||||||
|
--version 0.14.0 \
|
||||||
|
-f my-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm upgrade openconcho oci://ghcr.io/offendingcommit/charts/openconcho \
|
||||||
|
--version <new-version> \
|
||||||
|
-f my-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm uninstall openconcho
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All values with their defaults are documented in [`values.yaml`](values.yaml). Key options:
|
||||||
|
|
||||||
|
| Value | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `replicaCount` | `1` | Number of pod replicas |
|
||||||
|
| `image.repository` | `ghcr.io/offendingcommit/openconcho-web` | Container image |
|
||||||
|
| `image.tag` | `""` | Tag; defaults to chart `appVersion` |
|
||||||
|
| `image.pullPolicy` | `IfNotPresent` | Image pull policy |
|
||||||
|
| `honcho.defaultUrl` | `""` | Honcho URL pre-seeded in the UI |
|
||||||
|
| `honcho.upstreamAllowlist` | `""` | SSRF guard (comma-separated host globs) |
|
||||||
|
| `service.type` | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` |
|
||||||
|
| `service.port` | `80` | Service port |
|
||||||
|
| `ingress.enabled` | `false` | Enable Ingress resource |
|
||||||
|
| `ingress.className` | `""` | IngressClass name |
|
||||||
|
| `autoscaling.enabled` | `false` | Enable HorizontalPodAutoscaler |
|
||||||
|
| `podDisruptionBudget.enabled` | `false` | Enable PodDisruptionBudget |
|
||||||
|
| `networkPolicy.enabled` | `false` | Enable NetworkPolicy (same-namespace only) |
|
||||||
|
| `resources.requests.memory` | `32Mi` | Memory request |
|
||||||
|
| `resources.limits.memory` | `128Mi` | Memory limit |
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Minimal (ClusterIP, no ingress)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
honcho:
|
||||||
|
defaultUrl: http://honcho.honcho.svc.cluster.local:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Ingress and TLS (cert-manager)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
honcho:
|
||||||
|
defaultUrl: https://honcho.example.com
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
hosts:
|
||||||
|
- host: openconcho.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: openconcho-tls
|
||||||
|
hosts:
|
||||||
|
- openconcho.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### With autoscaling and disruption budget
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
replicaCount: 2
|
||||||
|
|
||||||
|
autoscaling:
|
||||||
|
enabled: true
|
||||||
|
minReplicas: 2
|
||||||
|
maxReplicas: 10
|
||||||
|
targetCPUUtilizationPercentage: 70
|
||||||
|
|
||||||
|
podDisruptionBudget:
|
||||||
|
enabled: true
|
||||||
|
minAvailable: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### With NetworkPolicy
|
||||||
|
|
||||||
|
> **Note:** When `networkPolicy.enabled=true` and `ingress.enabled=true`, you must add
|
||||||
|
> a policy that allows traffic from the ingress-controller namespace. Run
|
||||||
|
> `helm status <release>` for the exact `kubectl edit` command after install.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
networkPolicy:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
hosts:
|
||||||
|
- host: openconcho.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Private registry
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
image:
|
||||||
|
repository: registry.example.com/myorg/openconcho-web
|
||||||
|
tag: "0.14.0"
|
||||||
|
pullPolicy: Always
|
||||||
|
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
## ArgoCD Application
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: openconcho
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
source:
|
||||||
|
repoURL: ghcr.io/offendingcommit/charts
|
||||||
|
chart: openconcho
|
||||||
|
targetRevision: 0.14.0
|
||||||
|
helm:
|
||||||
|
valuesObject:
|
||||||
|
honcho:
|
||||||
|
defaultUrl: https://honcho.example.com
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
hosts:
|
||||||
|
- host: openconcho.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: openconcho-tls
|
||||||
|
hosts:
|
||||||
|
- openconcho.example.com
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: openconcho
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
|
```
|
||||||
|
|
||||||
|
> OCI chart sources require ArgoCD 2.10+ (OCI Helm support GA).
|
||||||
|
|
||||||
|
## Helm tests
|
||||||
|
|
||||||
|
After install, run the bundled tests to verify the deployment is healthy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm test openconcho
|
||||||
|
```
|
||||||
|
|
||||||
|
Two test pods run and exit 0 on success:
|
||||||
|
|
||||||
|
| Test | What it checks |
|
||||||
|
|---|---|
|
||||||
|
| `test-healthz` | `GET /healthz` body equals `ok` |
|
||||||
|
| `test-spa-root` | `GET /` returns HTTP 200 |
|
||||||
|
|
||||||
|
Pass `--logs` to see output from failing pods:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm test openconcho --logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security posture
|
||||||
|
|
||||||
|
| Control | Value |
|
||||||
|
|---|---|
|
||||||
|
| Run as UID/GID | 101 (nginx-unprivileged) |
|
||||||
|
| `runAsNonRoot` | `true` |
|
||||||
|
| `readOnlyRootFilesystem` | `true` |
|
||||||
|
| Linux capabilities | all dropped |
|
||||||
|
| `seccompProfile` | `RuntimeDefault` |
|
||||||
|
| `allowPrivilegeEscalation` | `false` |
|
||||||
|
| `automountServiceAccountToken` | `false` |
|
||||||
|
| Writable paths | `/var/cache/nginx`, `/var/run`, `/tmp` (tmpfs) |
|
||||||
40
charts/openconcho/templates/NOTES.txt
Normal file
40
charts/openconcho/templates/NOTES.txt
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
OpenConcho {{ .Chart.AppVersion }} deployed to namespace {{ .Release.Namespace }}.
|
||||||
|
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
Access:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
|
||||||
|
{{- end }}
|
||||||
|
{{- else if eq .Values.service.type "NodePort" }}
|
||||||
|
Access (NodePort):
|
||||||
|
export NODE_PORT=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "openconcho.fullname" . }} -o jsonpath="{.spec.ports[0].nodePort}")
|
||||||
|
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||||
|
echo "http://$NODE_IP:$NODE_PORT"
|
||||||
|
{{- else if eq .Values.service.type "LoadBalancer" }}
|
||||||
|
Access (LoadBalancer — IP may take a few minutes):
|
||||||
|
export LB_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "openconcho.fullname" . }} --template '{{ "{{" }}range (index .status.loadBalancer.ingress 0){{ "}}" }}{{ "{{" }}.{{ "}}" }}{{ "{{" }}end{{ "}}" }}')
|
||||||
|
echo "http://$LB_IP:{{ .Values.service.port }}"
|
||||||
|
{{- else }}
|
||||||
|
Access (port-forward):
|
||||||
|
kubectl port-forward --namespace {{ .Release.Namespace }} svc/{{ include "openconcho.fullname" . }} 8080:{{ .Values.service.port }}
|
||||||
|
Then open http://localhost:8080
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
Run Helm tests to verify the deployment:
|
||||||
|
helm test {{ .Release.Name }}
|
||||||
|
|
||||||
|
{{- if and .Values.networkPolicy.enabled .Values.ingress.enabled }}
|
||||||
|
|
||||||
|
WARNING: NetworkPolicy + Ingress are both enabled.
|
||||||
|
The default NetworkPolicy allows port {{ .Values.service.containerPort }} only from pods within
|
||||||
|
namespace {{ .Release.Namespace }}. Ingress controllers typically run in a separate
|
||||||
|
namespace (ingress-nginx, kube-system, etc.) and will be blocked.
|
||||||
|
To allow ingress-controller traffic, add a namespaceSelector rule:
|
||||||
|
|
||||||
|
kubectl edit networkpolicy --namespace {{ .Release.Namespace }} {{ include "openconcho.fullname" . }}
|
||||||
|
|
||||||
|
# Under spec.ingress[0].from, add:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: <ingress-controller-namespace>
|
||||||
|
{{- end }}
|
||||||
46
charts/openconcho/templates/_helpers.tpl
Normal file
46
charts/openconcho/templates/_helpers.tpl
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{{- define "openconcho.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "openconcho.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride }}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
|
{{- if contains $name .Release.Name }}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "openconcho.chart" -}}
|
||||||
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "openconcho.labels" -}}
|
||||||
|
helm.sh/chart: {{ include "openconcho.chart" . }}
|
||||||
|
{{ include "openconcho.selectorLabels" . }}
|
||||||
|
{{- if .Chart.AppVersion }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
{{- end }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "openconcho.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "openconcho.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "openconcho.serviceAccountName" -}}
|
||||||
|
{{- if .Values.serviceAccount.create }}
|
||||||
|
{{- default (include "openconcho.fullname" .) .Values.serviceAccount.name }}
|
||||||
|
{{- else }}
|
||||||
|
{{- default "default" .Values.serviceAccount.name }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "openconcho.imageTag" -}}
|
||||||
|
{{- .Values.image.tag | default .Chart.AppVersion }}
|
||||||
|
{{- end }}
|
||||||
84
charts/openconcho/templates/deployment.yaml
Normal file
84
charts/openconcho/templates/deployment.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "openconcho.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "openconcho.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
{{- if not .Values.autoscaling.enabled }}
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
{{- end }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "openconcho.selectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
{{- with .Values.podAnnotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
labels:
|
||||||
|
{{- include "openconcho.selectorLabels" . | nindent 8 }}
|
||||||
|
{{- with .Values.podLabels }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
serviceAccountName: {{ include "openconcho.serviceAccountName" . }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||||
|
containers:
|
||||||
|
- name: {{ .Chart.Name }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||||
|
image: "{{ .Values.image.repository }}:{{ include "openconcho.imageTag" . }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ .Values.service.containerPort }}
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: OPENCONCHO_DEFAULT_HONCHO_URL
|
||||||
|
value: {{ .Values.honcho.defaultUrl | quote }}
|
||||||
|
- name: OPENCONCHO_UPSTREAM_ALLOWLIST
|
||||||
|
value: {{ .Values.honcho.upstreamAllowlist | quote }}
|
||||||
|
livenessProbe:
|
||||||
|
{{- toYaml .Values.livenessProbe | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- toYaml .Values.readinessProbe | nindent 12 }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
{{- if .Values.tmpfsMounts }}
|
||||||
|
volumeMounts:
|
||||||
|
{{- range .Values.tmpfsMounts }}
|
||||||
|
- name: {{ .mountPath | trimPrefix "/" | replace "/" "-" | trunc 63 | trimSuffix "-" }}
|
||||||
|
mountPath: {{ .mountPath }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.tmpfsMounts }}
|
||||||
|
volumes:
|
||||||
|
{{- range .Values.tmpfsMounts }}
|
||||||
|
- name: {{ .mountPath | trimPrefix "/" | replace "/" "-" | trunc 63 | trimSuffix "-" }}
|
||||||
|
emptyDir:
|
||||||
|
medium: Memory
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.topologySpreadConstraints }}
|
||||||
|
topologySpreadConstraints:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
24
charts/openconcho/templates/hpa.yaml
Normal file
24
charts/openconcho/templates/hpa.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{{- if .Values.autoscaling.enabled }}
|
||||||
|
apiVersion: autoscaling/v2
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: {{ include "openconcho.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "openconcho.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: {{ include "openconcho.fullname" . }}
|
||||||
|
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||||
|
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||||
|
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
39
charts/openconcho/templates/ingress.yaml
Normal file
39
charts/openconcho/templates/ingress.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{{- if .Values.ingress.enabled -}}
|
||||||
|
{{- $fullName := include "openconcho.fullname" . -}}
|
||||||
|
{{- $svcPort := .Values.service.port -}}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
labels:
|
||||||
|
{{- include "openconcho.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if .Values.ingress.className }}
|
||||||
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.hosts }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
pathType: {{ .pathType | default "Prefix" }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
port:
|
||||||
|
number: {{ $svcPort }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
20
charts/openconcho/templates/networkpolicy.yaml
Normal file
20
charts/openconcho/templates/networkpolicy.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{{- if .Values.networkPolicy.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: {{ include "openconcho.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "openconcho.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "openconcho.selectorLabels" . | nindent 6 }}
|
||||||
|
policyTypes:
|
||||||
|
- Ingress
|
||||||
|
ingress:
|
||||||
|
- ports:
|
||||||
|
- port: {{ .Values.service.containerPort }}
|
||||||
|
protocol: TCP
|
||||||
|
from:
|
||||||
|
- podSelector: {}
|
||||||
|
{{- end }}
|
||||||
17
charts/openconcho/templates/pdb.yaml
Normal file
17
charts/openconcho/templates/pdb.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{{- if .Values.podDisruptionBudget.enabled }}
|
||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: {{ include "openconcho.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "openconcho.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
{{- if not (kindIs "invalid" .Values.podDisruptionBudget.maxUnavailable) }}
|
||||||
|
maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
|
||||||
|
{{- else }}
|
||||||
|
minAvailable: {{ .Values.podDisruptionBudget.minAvailable | default 1 }}
|
||||||
|
{{- end }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "openconcho.selectorLabels" . | nindent 6 }}
|
||||||
|
{{- end }}
|
||||||
15
charts/openconcho/templates/service.yaml
Normal file
15
charts/openconcho/templates/service.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "openconcho.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "openconcho.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
{{- include "openconcho.selectorLabels" . | nindent 4 }}
|
||||||
13
charts/openconcho/templates/serviceaccount.yaml
Normal file
13
charts/openconcho/templates/serviceaccount.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{{- if .Values.serviceAccount.create -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ include "openconcho.serviceAccountName" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "openconcho.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.serviceAccount.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||||
|
{{- end }}
|
||||||
25
charts/openconcho/templates/tests/test-healthz.yaml
Normal file
25
charts/openconcho/templates/tests/test-healthz.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: "{{ include "openconcho.fullname" . }}-test-healthz"
|
||||||
|
labels:
|
||||||
|
{{- include "openconcho.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/hook: test
|
||||||
|
helm.sh/hook-delete-policy: before-hook-creation
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
activeDeadlineSeconds: 60
|
||||||
|
containers:
|
||||||
|
- name: healthz
|
||||||
|
image: busybox:1.36
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
RESPONSE=$(wget -T 10 -qO- http://{{ include "openconcho.fullname" . }}:{{ .Values.service.port }}/healthz)
|
||||||
|
if [ "$RESPONSE" != "ok" ]; then
|
||||||
|
echo "FAIL: expected 'ok', got '$RESPONSE'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PASS: /healthz returned 'ok'"
|
||||||
24
charts/openconcho/templates/tests/test-spa-root.yaml
Normal file
24
charts/openconcho/templates/tests/test-spa-root.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: "{{ include "openconcho.fullname" . }}-test-spa-root"
|
||||||
|
labels:
|
||||||
|
{{- include "openconcho.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/hook: test
|
||||||
|
helm.sh/hook-delete-policy: before-hook-creation
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
activeDeadlineSeconds: 60
|
||||||
|
containers:
|
||||||
|
- name: spa-root
|
||||||
|
image: busybox:1.36
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
if ! wget -T 10 -q --spider http://{{ include "openconcho.fullname" . }}:{{ .Values.service.port }}/; then
|
||||||
|
echo "FAIL: GET / did not return HTTP 200"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PASS: / returned HTTP 200"
|
||||||
67
charts/openconcho/values.schema.json
Normal file
67
charts/openconcho/values.schema.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"replicaCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["repository", "pullPolicy"],
|
||||||
|
"properties": {
|
||||||
|
"repository": { "type": "string", "minLength": 1 },
|
||||||
|
"tag": { "type": "string" },
|
||||||
|
"pullPolicy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["Always", "IfNotPresent", "Never"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "port", "containerPort"],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["ClusterIP", "NodePort", "LoadBalancer"]
|
||||||
|
},
|
||||||
|
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
|
||||||
|
"containerPort": { "type": "integer", "minimum": 1, "maximum": 65535 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"honcho": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"defaultUrl": { "type": "string" },
|
||||||
|
"upstreamAllowlist": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoscaling": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": { "type": "boolean" },
|
||||||
|
"minReplicas": { "type": "integer", "minimum": 1 },
|
||||||
|
"maxReplicas": { "type": "integer", "minimum": 1 },
|
||||||
|
"targetCPUUtilizationPercentage": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"podDisruptionBudget": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": { "type": "boolean" },
|
||||||
|
"minAvailable": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"networkPolicy": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
charts/openconcho/values.yaml
Normal file
178
charts/openconcho/values.yaml
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Number of pod replicas. Increase for high availability or use autoscaling instead.
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
# Container image repository. Override to use a custom registry or fork.
|
||||||
|
repository: ghcr.io/offendingcommit/openconcho-web
|
||||||
|
# Image tag. Defaults to the chart appVersion when left empty.
|
||||||
|
tag: ""
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# Secrets for pulling images from private registries.
|
||||||
|
# Example: [{ name: my-registry-secret }]
|
||||||
|
imagePullSecrets: []
|
||||||
|
|
||||||
|
# Override the name portion used in resource names and labels.
|
||||||
|
nameOverride: ""
|
||||||
|
# Override the full resource name (normally release-name + chart-name).
|
||||||
|
fullnameOverride: ""
|
||||||
|
|
||||||
|
serviceAccount:
|
||||||
|
# Create a dedicated ServiceAccount for the pod.
|
||||||
|
create: true
|
||||||
|
# Disable automatic ServiceAccount token mounting — the app never calls the Kubernetes API.
|
||||||
|
automount: false
|
||||||
|
# Annotations to add to the ServiceAccount (e.g. for IRSA, Workload Identity, Vault).
|
||||||
|
annotations: {}
|
||||||
|
# Use a pre-existing ServiceAccount instead of creating one. Ignored when create is true.
|
||||||
|
name: ""
|
||||||
|
|
||||||
|
# Annotations applied to every pod (not the Deployment). Useful for Prometheus scraping,
|
||||||
|
# Vault agent injection, Datadog unified service tagging, etc.
|
||||||
|
podAnnotations: {}
|
||||||
|
# Extra labels applied to every pod.
|
||||||
|
podLabels: {}
|
||||||
|
|
||||||
|
# Pod-level security context shared by all containers.
|
||||||
|
# UID/GID 101 matches the nginx-unprivileged base image — do not change without rebuilding.
|
||||||
|
podSecurityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 101
|
||||||
|
runAsGroup: 101
|
||||||
|
fsGroup: 101
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
|
||||||
|
# Container-level security context.
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
|
||||||
|
# Directories mounted as ephemeral tmpfs (in-memory) to satisfy nginx's write requirements
|
||||||
|
# when the root filesystem is read-only. Add entries for any additional writable paths.
|
||||||
|
tmpfsMounts:
|
||||||
|
- mountPath: /var/cache/nginx
|
||||||
|
- mountPath: /var/run
|
||||||
|
- mountPath: /tmp
|
||||||
|
|
||||||
|
service:
|
||||||
|
# Kubernetes Service type. Options: ClusterIP | NodePort | LoadBalancer
|
||||||
|
type: ClusterIP
|
||||||
|
# Port exposed by the Service (what the Ingress or other pods target).
|
||||||
|
port: 80
|
||||||
|
# Port the container actually listens on (nginx-unprivileged default).
|
||||||
|
containerPort: 8080
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
# IngressClass name. Leave empty to accept the cluster default.
|
||||||
|
# Examples: nginx | traefik | alb | kong
|
||||||
|
className: ""
|
||||||
|
# Annotations forwarded verbatim to the Ingress resource.
|
||||||
|
# Example (cert-manager + nginx-ingress):
|
||||||
|
# kubernetes.io/ingress.class: nginx
|
||||||
|
# cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
annotations: {}
|
||||||
|
hosts:
|
||||||
|
- host: openconcho.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
# TLS configuration. Provide a Secret containing the certificate.
|
||||||
|
# Example:
|
||||||
|
# - secretName: openconcho-tls
|
||||||
|
# hosts:
|
||||||
|
# - openconcho.example.com
|
||||||
|
tls: []
|
||||||
|
|
||||||
|
honcho:
|
||||||
|
# Default Honcho instance URL pre-populated in the UI on first load.
|
||||||
|
# Users can change or add instances at runtime; this only seeds the initial value.
|
||||||
|
# Example: https://honcho.example.com
|
||||||
|
defaultUrl: ""
|
||||||
|
# Optional SSRF guard: comma-separated host globs the nginx proxy is allowed to forward to.
|
||||||
|
# Leave empty to allow any upstream. Applies only when the proxy is publicly reachable.
|
||||||
|
# Example: honcho.example.com,*.internal.example.com
|
||||||
|
upstreamAllowlist: ""
|
||||||
|
|
||||||
|
# CPU and memory requests / limits for the web container.
|
||||||
|
# The SPA is static HTML/JS, so memory is the primary concern and CPU is negligible at rest.
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
|
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
|
||||||
|
autoscaling:
|
||||||
|
enabled: false
|
||||||
|
minReplicas: 1
|
||||||
|
maxReplicas: 5
|
||||||
|
# Scale up when average CPU utilization across pods exceeds this percentage.
|
||||||
|
targetCPUUtilizationPercentage: 80
|
||||||
|
|
||||||
|
podDisruptionBudget:
|
||||||
|
enabled: false
|
||||||
|
# Minimum number of pods that must remain available during voluntary disruptions
|
||||||
|
# (node drains, rolling upgrades). Set maxUnavailable instead to flip the direction.
|
||||||
|
minAvailable: 1
|
||||||
|
# Uncomment to use maxUnavailable instead — cannot set both simultaneously.
|
||||||
|
# maxUnavailable: 1
|
||||||
|
|
||||||
|
# Restrict pod-to-pod traffic at the network layer. When enabled, only pods in the
|
||||||
|
# same namespace may reach the web container.
|
||||||
|
# WARNING: if ingress is also enabled, you must add a policy that allows traffic from
|
||||||
|
# the ingress-controller namespace — run `helm status <release>` to see the reminder.
|
||||||
|
networkPolicy:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Spread pods across failure domains (availability zones, nodes, etc.) to reduce
|
||||||
|
# the blast radius of a single-node or single-zone failure.
|
||||||
|
# Example (zone spread):
|
||||||
|
# - maxSkew: 1
|
||||||
|
# topologyKey: topology.kubernetes.io/zone
|
||||||
|
# whenUnsatisfiable: DoNotSchedule
|
||||||
|
# labelSelector:
|
||||||
|
# matchLabels:
|
||||||
|
# app.kubernetes.io/name: openconcho
|
||||||
|
topologySpreadConstraints: []
|
||||||
|
|
||||||
|
# Constrain pods to nodes whose labels match these key/value pairs.
|
||||||
|
# Example: kubernetes.io/arch: amd64
|
||||||
|
nodeSelector: {}
|
||||||
|
|
||||||
|
# Allow pods to be scheduled on tainted nodes.
|
||||||
|
# Example:
|
||||||
|
# - key: dedicated
|
||||||
|
# operator: Equal
|
||||||
|
# value: web
|
||||||
|
# effect: NoSchedule
|
||||||
|
tolerations: []
|
||||||
|
|
||||||
|
# Advanced pod affinity / anti-affinity rules.
|
||||||
|
# Example (soft anti-affinity — prefer spreading across different nodes):
|
||||||
|
# podAntiAffinity:
|
||||||
|
# preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
# - weight: 100
|
||||||
|
# podAffinityTerm:
|
||||||
|
# labelSelector:
|
||||||
|
# matchLabels:
|
||||||
|
# app.kubernetes.io/name: openconcho
|
||||||
|
# topologyKey: kubernetes.io/hostname
|
||||||
|
affinity: {}
|
||||||
48
docker-compose.yml
Normal file
48
docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# OpenConcho web UI — one file, two Compose profiles (dev builds, prod pulls).
|
||||||
|
#
|
||||||
|
# make up # profile dev: build from THIS repo + run → http://localhost:8080
|
||||||
|
# make prod # profile prod: pull ghcr…:latest instead of building
|
||||||
|
# make down # stop + remove (either profile)
|
||||||
|
# make clean # down + drop the locally built image
|
||||||
|
#
|
||||||
|
# The SPA issues all Honcho calls same-origin to /api; nginx forwards each to the
|
||||||
|
# URL named in the per-request X-Honcho-Upstream header (no browser CORS). Seed the
|
||||||
|
# first instance with OPENCONCHO_DEFAULT_HONCHO_URL:
|
||||||
|
#
|
||||||
|
# OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make up
|
||||||
|
#
|
||||||
|
# To fold into an existing Honcho Compose stack, point the seed at the api service
|
||||||
|
# (e.g. http://api:8000 — nginx resolves it on the compose network).
|
||||||
|
|
||||||
|
# Shared config (defined once); both profiles reference it via a YAML merge.
|
||||||
|
x-openconcho: &openconcho
|
||||||
|
environment:
|
||||||
|
# Absolute URL seeding the first instance; the browser sends it as the
|
||||||
|
# X-Honcho-Upstream header and nginx forwards there (no browser CORS).
|
||||||
|
OPENCONCHO_DEFAULT_HONCHO_URL: ${OPENCONCHO_DEFAULT_HONCHO_URL:-http://host.docker.internal:8000}
|
||||||
|
# Optional SSRF guard. Unset = forward anywhere (safe for the localhost-only
|
||||||
|
# binding below). Set comma-separated host globs before exposing the proxy:
|
||||||
|
# OPENCONCHO_UPSTREAM_ALLOWLIST: honcho.example.net,*.honcho.dev
|
||||||
|
OPENCONCHO_UPSTREAM_ALLOWLIST: ${OPENCONCHO_UPSTREAM_ALLOWLIST:-}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
# Lets the default host.docker.internal upstream resolve on Linux too
|
||||||
|
# (Docker Desktop / Colima provide it automatically).
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Dev-forward — builds from source so you run your local changes (`make up`).
|
||||||
|
openconcho:
|
||||||
|
<<: *openconcho
|
||||||
|
profiles: ["dev"]
|
||||||
|
build: .
|
||||||
|
image: openconcho-web:local
|
||||||
|
|
||||||
|
# Production — pulls the published image instead of building (`make prod`).
|
||||||
|
openconcho-prod:
|
||||||
|
<<: *openconcho
|
||||||
|
profiles: ["prod"]
|
||||||
|
image: ghcr.io/offendingcommit/openconcho-web:latest
|
||||||
|
pull_policy: always
|
||||||
42
docker/40-openconcho-config.sh
Normal file
42
docker/40-openconcho-config.sh
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Regenerate the SPA's runtime config from the environment at container start.
|
||||||
|
# Lets one prebuilt image target any Honcho backend without a rebuild.
|
||||||
|
# OPENCONCHO_DEFAULT_HONCHO_URL — absolute URL seeding the first instance, or empty.
|
||||||
|
# OPENCONCHO_UPSTREAM_ALLOWLIST — optional comma-separated host globs (SSRF guard).
|
||||||
|
# Runs from /docker-entrypoint.d before nginx starts. Requires the html dir to
|
||||||
|
# be writable (default); skip or bind-mount config.js when running --read-only.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cat > /usr/share/nginx/html/config.js <<EOF
|
||||||
|
window.__OPENCONCHO_DEFAULT_HONCHO_URL__ = "${OPENCONCHO_DEFAULT_HONCHO_URL:-}";
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Derive nginx's resolver from the container's own DNS so the runtime-variable
|
||||||
|
# proxy_pass resolves on BOTH user-defined networks (Docker embedded DNS at
|
||||||
|
# 127.0.0.11) and the default bridge (host nameservers from /etc/resolv.conf).
|
||||||
|
# Hardcoding 127.0.0.11 breaks `docker run` on the default bridge (no embedded DNS).
|
||||||
|
RESOLVERS=$(awk '/^nameserver/ { print $2 }' /etc/resolv.conf | tr '\n' ' ' | sed 's/ *$//')
|
||||||
|
[ -z "$RESOLVERS" ] && RESOLVERS=127.0.0.11
|
||||||
|
printf 'resolver %s ipv6=off valid=10s;\n' "$RESOLVERS" > /etc/nginx/conf.d/00-resolver.conf
|
||||||
|
|
||||||
|
# Render the SSRF allowlist into an nginx map for $allow_upstream.
|
||||||
|
# Unset/empty OPENCONCHO_UPSTREAM_ALLOWLIST → open (default 1), fine for the
|
||||||
|
# localhost-bound default. Set it (comma-separated host globs) before exposing
|
||||||
|
# the proxy (e.g. behind a tunnel) to reject non-matching upstreams.
|
||||||
|
ALLOWLIST_CONF=/etc/nginx/conf.d/allowlist_map.conf
|
||||||
|
if [ -z "${OPENCONCHO_UPSTREAM_ALLOWLIST:-}" ]; then
|
||||||
|
printf 'map $http_x_honcho_upstream $allow_upstream { default 1; }\n' > "$ALLOWLIST_CONF"
|
||||||
|
else
|
||||||
|
{
|
||||||
|
printf 'map $http_x_honcho_upstream $allow_upstream {\n'
|
||||||
|
printf ' default 0;\n'
|
||||||
|
IFS=','
|
||||||
|
for host in $OPENCONCHO_UPSTREAM_ALLOWLIST; do
|
||||||
|
host=$(printf '%s' "$host" | tr -d ' ')
|
||||||
|
[ -z "$host" ] && continue
|
||||||
|
esc=$(printf '%s' "$host" | sed -e 's/[.]/\\./g' -e 's#[*]#[^/]*#g')
|
||||||
|
printf ' "~^https?://%s(:[0-9]+)?(/.*)?$" 1;\n' "$esc"
|
||||||
|
done
|
||||||
|
printf '}\n'
|
||||||
|
} > "$ALLOWLIST_CONF"
|
||||||
|
fi
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# OpenConcho — nginx site config for the runtime container.
|
|
||||||
# Serves the React SPA from /usr/share/nginx/html with client-side routing.
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 8080;
|
|
||||||
listen [::]:8080;
|
|
||||||
server_name _;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Don't leak the nginx version.
|
|
||||||
server_tokens off;
|
|
||||||
|
|
||||||
# Long-cache static assets — Vite hashes filenames so they're safely immutable.
|
|
||||||
location ~* \.(?:js|mjs|css|woff2?|ttf|otf|eot|svg|png|jpg|jpeg|gif|ico|webp|avif|wasm)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
access_log off;
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Healthcheck (no logging spam).
|
|
||||||
location = /healthz {
|
|
||||||
access_log off;
|
|
||||||
default_type text/plain;
|
|
||||||
return 200 "ok\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Optional: same-origin Honcho reverse proxy (eliminates browser CORS) ---
|
|
||||||
# The SPA's fetches are subject to browser CORS only on the web build (the
|
|
||||||
# desktop app routes through Rust and bypasses CORS). To avoid configuring
|
|
||||||
# CORS on Honcho itself, proxy the API under this origin and point the UI's
|
|
||||||
# base URL at it. See docs/docker.md for the full tradeoff — note the UI
|
|
||||||
# currently requires an absolute base URL, so this block is opt-in.
|
|
||||||
#
|
|
||||||
# location /honcho/ {
|
|
||||||
# proxy_pass http://your-honcho-host:8000/;
|
|
||||||
# proxy_set_header Host $host;
|
|
||||||
# proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
# }
|
|
||||||
|
|
||||||
# SPA fallback: any unknown path returns index.html so the router resolves it.
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
}
|
|
||||||
|
|
||||||
# gzip text responses (Vite pre-compresses CSS/JS, but HTML still benefits).
|
|
||||||
gzip on;
|
|
||||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
|
||||||
gzip_min_length 1024;
|
|
||||||
}
|
|
||||||
69
docker/nginx.conf.template
Normal file
69
docker/nginx.conf.template
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# OpenConcho — nginx site config.
|
||||||
|
# Serves the React SPA and header-driven same-origin /api proxy to Honcho.
|
||||||
|
# The browser sends X-Honcho-Upstream per request; nginx forwards server-side (no browser CORS).
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
listen [::]:8080;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Don't leak the nginx version.
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
# The resolver (required for per-request DNS with a runtime-variable proxy_pass)
|
||||||
|
# is rendered by the entrypoint into conf.d from the container's own DNS, so it
|
||||||
|
# works on both user-defined networks (127.0.0.11) and the default bridge.
|
||||||
|
|
||||||
|
# Header-driven same-origin proxy: the browser names the Honcho upstream per
|
||||||
|
# request via X-Honcho-Upstream, so the browser never makes a cross-origin call.
|
||||||
|
# $allow_upstream is provided by the allowlist map in conf.d (entrypoint-rendered).
|
||||||
|
location ^~ /api/ {
|
||||||
|
set $upstream $http_x_honcho_upstream;
|
||||||
|
if ($upstream = "") {
|
||||||
|
add_header X-Honcho-Proxy-Reject "no-upstream" always;
|
||||||
|
return 421;
|
||||||
|
}
|
||||||
|
if ($allow_upstream = 0) {
|
||||||
|
add_header X-Honcho-Proxy-Reject "allowlist" always;
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
rewrite ^/api/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header Host $proxy_host;
|
||||||
|
proxy_set_header X-Honcho-Upstream "";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runtime config — regenerated per container start, must never be cached.
|
||||||
|
location = /config.js {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Long-cache static assets — Vite hashes filenames so they're immutable.
|
||||||
|
location ~* \.(?:js|mjs|css|woff2?|ttf|otf|eot|svg|png|jpg|jpeg|gif|ico|webp|avif|wasm)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Container healthcheck (local; distinct from Honcho's /health).
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
default_type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback: unknown paths return index.html so the router resolves them.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
}
|
||||||
82
docker/smoke-test.sh
Executable file
82
docker/smoke-test.sh
Executable file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Hermetic container smoke test for the same-origin /api proxy.
|
||||||
|
#
|
||||||
|
# Builds the image, then stands up a stub upstream + the openconcho container on
|
||||||
|
# a shared Docker network and asserts the proxy forwards correctly. Fully
|
||||||
|
# self-contained — no external Honcho or tailnet needed. Local-only (requires a
|
||||||
|
# Docker daemon); not part of PR CI, like the desktop cargo-check preflight.
|
||||||
|
#
|
||||||
|
# Idempotent: removes its own containers/network on entry and exit. Exits non-zero
|
||||||
|
# on any failed assertion.
|
||||||
|
#
|
||||||
|
# Usage: make smoke-docker (or: bash docker/smoke-test.sh)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
IMAGE="openconcho-web:smoke"
|
||||||
|
NET="oc-smoke-net"
|
||||||
|
UPSTREAM="oc-smoke-upstream"
|
||||||
|
APP="oc-smoke-app"
|
||||||
|
PORT="${SMOKE_PORT:-18080}"
|
||||||
|
# Echo server: returns request method/path/headers as JSON for any verb.
|
||||||
|
STUB_IMAGE="mendhak/http-https-echo:31"
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
docker rm -f "$APP" "$UPSTREAM" >/dev/null 2>&1 || true
|
||||||
|
docker network rm "$NET" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
cleanup
|
||||||
|
|
||||||
|
wait_ready() { # url
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
curl -fsS "$1" >/dev/null 2>&1 && return 0
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
echo " FAIL: container did not become ready at $1"
|
||||||
|
FAIL=1
|
||||||
|
}
|
||||||
|
|
||||||
|
check() { # label expected actual
|
||||||
|
if [ "$2" = "$3" ]; then echo " PASS: $1 ($3)"; else echo " FAIL: $1 — expected $2, got $3"; FAIL=1; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> build image"
|
||||||
|
docker build -t "$IMAGE" . >/dev/null
|
||||||
|
|
||||||
|
echo "==> create network + stub upstream"
|
||||||
|
docker network create "$NET" >/dev/null
|
||||||
|
docker run -d --name "$UPSTREAM" --network "$NET" -e HTTP_PORT=8080 "$STUB_IMAGE" >/dev/null
|
||||||
|
|
||||||
|
echo "==> start openconcho (default-open allowlist)"
|
||||||
|
docker run -d --name "$APP" --network "$NET" -p "$PORT:8080" \
|
||||||
|
-e "OPENCONCHO_DEFAULT_HONCHO_URL=http://$UPSTREAM:8080" "$IMAGE" >/dev/null
|
||||||
|
wait_ready "http://localhost:$PORT/healthz"
|
||||||
|
|
||||||
|
echo "==> assertions"
|
||||||
|
check "healthz 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/healthz")"
|
||||||
|
check "SPA served 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/")"
|
||||||
|
check "config.js injected" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/config.js")"
|
||||||
|
|
||||||
|
# Proxy forwards POST /api/v3/test -> stub, stripping the /api prefix.
|
||||||
|
body=$(curl -s "http://localhost:$PORT/api/v3/test" \
|
||||||
|
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -H 'content-type: application/json' -X POST -d '{}')
|
||||||
|
if echo "$body" | grep -q '/v3/test'; then echo " PASS: /api forwards + strips prefix"; else echo " FAIL: forward/strip — body: $body"; FAIL=1; fi
|
||||||
|
# Routing header must NOT leak to the upstream.
|
||||||
|
if echo "$body" | grep -qi 'x-honcho-upstream'; then echo " FAIL: X-Honcho-Upstream leaked upstream"; FAIL=1; else echo " PASS: X-Honcho-Upstream cleared upstream"; fi
|
||||||
|
# Missing routing header -> 421.
|
||||||
|
check "missing header 421" 421 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/v3/test" -X POST -d '{}')"
|
||||||
|
|
||||||
|
echo "==> restart with a non-matching allowlist"
|
||||||
|
docker rm -f "$APP" >/dev/null
|
||||||
|
docker run -d --name "$APP" --network "$NET" -p "$PORT:8080" \
|
||||||
|
-e "OPENCONCHO_UPSTREAM_ALLOWLIST=*.honcho.dev" "$IMAGE" >/dev/null
|
||||||
|
wait_ready "http://localhost:$PORT/healthz"
|
||||||
|
check "allowlist reject 403" 403 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/v3/test" \
|
||||||
|
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -X POST -d '{}')"
|
||||||
|
reject=$(curl -s -D- -o /dev/null "http://localhost:$PORT/api/v3/test" \
|
||||||
|
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -X POST -d '{}' | grep -i 'X-Honcho-Proxy-Reject' | tr -d '\r')
|
||||||
|
if echo "$reject" | grep -qi 'allowlist'; then echo " PASS: reject sentinel header present"; else echo " FAIL: missing reject sentinel — got: $reject"; FAIL=1; fi
|
||||||
|
|
||||||
|
if [ "$FAIL" = 0 ]; then echo "==> SMOKE TEST PASSED"; else echo "==> SMOKE TEST FAILED"; exit 1; fi
|
||||||
131
docs/docker.md
131
docs/docker.md
@@ -1,65 +1,104 @@
|
|||||||
# Running OpenConcho in Docker
|
# Running OpenConcho in Docker
|
||||||
|
|
||||||
The `@openconcho/web` SPA can be served from a container. The image is a
|
The `@openconcho/web` SPA ships as a container: a two-stage build (Node + pnpm
|
||||||
two-stage build: Node + pnpm builds the static bundle, then
|
builds the static bundle, then `nginx-unprivileged` serves it on port `8080` as
|
||||||
`nginx-unprivileged` serves it on port `8080` as a non-root user.
|
a non-root user) that also **reverse-proxies the Honcho API under its own
|
||||||
|
origin**, so the browser never makes a cross-origin request.
|
||||||
|
|
||||||
## Build and run
|
## How the proxy works
|
||||||
|
|
||||||
|
The browser issues every Honcho call same-origin to `/api/*` and names the real
|
||||||
|
upstream per request in an `X-Honcho-Upstream` header (sourced from the active
|
||||||
|
instance's base URL). nginx strips `/api`, forwards to that upstream server-side,
|
||||||
|
and returns the response. Because the browser→nginx hop is same-origin, **no CORS
|
||||||
|
applies**; the nginx→Honcho hop is server-side, where CORS is irrelevant. The
|
||||||
|
frontend stays the source of truth for which instance to talk to, so the
|
||||||
|
multi-instance switcher and the Fleet view keep working.
|
||||||
|
|
||||||
|
## Compose: dev vs prod profiles
|
||||||
|
|
||||||
|
One [`docker-compose.yml`](../docker-compose.yml) with two profiles; the shared
|
||||||
|
config (env, ports, extra_hosts) is defined once via a YAML anchor:
|
||||||
|
|
||||||
|
- **`dev` profile** — `build: .`, runs **your local source** (`make up`).
|
||||||
|
- **`prod` profile** — pulls the **published image**
|
||||||
|
(`ghcr.io/offendingcommit/openconcho-web:latest`, `pull_policy: always`) (`make prod`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t openconcho-web .
|
make up # build from source + run → http://localhost:8080
|
||||||
docker run --rm -p 8080:8080 openconcho-web
|
make prod # pull ghcr…:latest instead of building
|
||||||
# → http://localhost:8080
|
make down # stop + remove (either profile)
|
||||||
|
make clean # down + drop the locally built image
|
||||||
```
|
```
|
||||||
|
|
||||||
Hardened run (read-only filesystem, no added capabilities):
|
`make up` expands to `docker compose --profile dev up -d --build` and `make prod`
|
||||||
|
to `docker compose --profile prod up -d`. A bare `docker compose up` (no profile)
|
||||||
|
starts nothing — use the make targets or pass `--profile`. Set env inline or via a
|
||||||
|
`.env` file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -p 8080:8080 \
|
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make prod
|
||||||
--read-only \
|
|
||||||
--tmpfs /tmp \
|
|
||||||
--tmpfs /var/cache/nginx \
|
|
||||||
--cap-drop ALL \
|
|
||||||
--security-opt no-new-privileges \
|
|
||||||
openconcho-web
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /healthz` returns `200 ok` for container health checks.
|
The published image is multi-arch (amd64 + arm64); the first publish creates a
|
||||||
|
private GHCR package — make it public for unauthenticated pulls.
|
||||||
|
|
||||||
## CORS
|
## Add it to an existing Honcho Compose stack
|
||||||
|
|
||||||
The desktop app routes HTTP through Rust (`reqwest`), so it is **not** subject
|
Drop the `openconcho` service into the project that runs your Honcho `api`,
|
||||||
to browser CORS. The **web build is**: it uses the browser's `fetch`, so every
|
pointing the seed at the api service (nginx resolves it on the compose network):
|
||||||
request to your Honcho API is cross-origin. Honcho calls are `POST` +
|
|
||||||
`application/json` + `Authorization: Bearer`, which the browser always
|
|
||||||
**preflights** (`OPTIONS`). You must handle this one of two ways.
|
|
||||||
|
|
||||||
### Option 1 — configure Honcho's CORS (recommended)
|
```yaml
|
||||||
|
services:
|
||||||
Honcho is a FastAPI service. Allow the UI's origin via its
|
openconcho:
|
||||||
`CORSMiddleware` so preflight and actual requests succeed:
|
image: ghcr.io/offendingcommit/openconcho-web:latest
|
||||||
|
environment:
|
||||||
```python
|
OPENCONCHO_DEFAULT_HONCHO_URL: http://api:8000
|
||||||
app.add_middleware(
|
ports:
|
||||||
CORSMiddleware,
|
- "127.0.0.1:8080:8080"
|
||||||
allow_origins=["http://localhost:8080"], # the OpenConcho origin
|
depends_on:
|
||||||
allow_methods=["*"],
|
api:
|
||||||
allow_headers=["*"],
|
condition: service_healthy
|
||||||
)
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
This fits OpenConcho's model directly — the UI keeps using the absolute Honcho
|
`OPENCONCHO_DEFAULT_HONCHO_URL` seeds the UI's first instance with an absolute
|
||||||
URL you enter in Settings (stored in `localStorage`). Since you self-host
|
URL. The browser sends that URL in the `X-Honcho-Upstream` header; nginx (on the
|
||||||
Honcho, you control this.
|
compose network) forwards to it — **no browser CORS, and the API token never
|
||||||
|
leaves the origin.**
|
||||||
|
|
||||||
### Option 2 — same-origin reverse proxy (advanced)
|
## Standalone (no compose)
|
||||||
|
|
||||||
Proxy the Honcho API under the same origin that serves the SPA, so the browser
|
```bash
|
||||||
sees same-origin requests and CORS never applies (the token also never crosses
|
docker run --rm -p 8080:8080 -e OPENCONCHO_DEFAULT_HONCHO_URL=http://host.docker.internal:8000 \
|
||||||
origins). Uncomment the `location /honcho/` block in
|
ghcr.io/offendingcommit/openconcho-web:latest
|
||||||
[`docker/nginx.conf`](../docker/nginx.conf) and set `proxy_pass` to your Honcho
|
# → http://localhost:8080 · GET /healthz returns "ok"
|
||||||
host.
|
```
|
||||||
|
|
||||||
Caveat: the Settings form currently validates the base URL as an **absolute**
|
Runtime knobs (no rebuild needed):
|
||||||
URL (`z.string().url()`), so pointing the UI at a relative same-origin path
|
|
||||||
(`/honcho`) isn't wired yet. Until that lands, Option 1 is the supported path.
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `OPENCONCHO_DEFAULT_HONCHO_URL` | _(empty)_ | Absolute URL seeding the first instance; empty = configure in Settings |
|
||||||
|
| `OPENCONCHO_UPSTREAM_ALLOWLIST` | _(empty)_ | Optional SSRF guard: comma-separated host globs (e.g. `honcho.example.net,*.honcho.dev`). Empty = forward anywhere |
|
||||||
|
|
||||||
|
Hardened run adds `--read-only --cap-drop ALL --security-opt no-new-privileges`
|
||||||
|
with `--tmpfs /tmp --tmpfs /var/cache/nginx`. Note: the entrypoint writes
|
||||||
|
`config.js` and the allowlist map at start, which a read-only root blocks — under
|
||||||
|
`--read-only` either bind-mount those paths or leave the env empty and configure
|
||||||
|
the URL in Settings.
|
||||||
|
|
||||||
|
## SSRF: when to set the allowlist
|
||||||
|
|
||||||
|
The header-driven proxy forwards to whatever upstream the client names. With the
|
||||||
|
default `127.0.0.1:8080` binding only your own machine can reach nginx, so leaving
|
||||||
|
the allowlist open is fine. **Before exposing the proxy** (e.g. behind a tunnel),
|
||||||
|
set `OPENCONCHO_UPSTREAM_ALLOWLIST` to the host globs you trust — non-matching
|
||||||
|
upstreams are rejected with `403` and an `X-Honcho-Proxy-Reject: allowlist` header.
|
||||||
|
|
||||||
|
## CORS, the short version
|
||||||
|
|
||||||
|
The desktop app routes HTTP through Rust (reqwest) and bypasses browser CORS; the
|
||||||
|
web build solves it with the same-origin `/api` proxy above — **nothing to
|
||||||
|
configure on Honcho.** The proxy makes a Honcho-side `CORSMiddleware` unnecessary
|
||||||
|
regardless of which instance you point at.
|
||||||
|
|||||||
BIN
docs/screenshots/fleet-dashboard-dark.png
Normal file
BIN
docs/screenshots/fleet-dashboard-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
BIN
docs/screenshots/fleet-dashboard-light.png
Normal file
BIN
docs/screenshots/fleet-dashboard-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
BIN
docs/screenshots/fleet-dashboard-mixed-health.png
Normal file
BIN
docs/screenshots/fleet-dashboard-mixed-health.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
782
docs/superpowers/plans/2026-06-02-web-api-proxy.md
Normal file
782
docs/superpowers/plans/2026-06-02-web-api-proxy.md
Normal file
@@ -0,0 +1,782 @@
|
|||||||
|
# Header-Driven `/api` Proxy — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Eliminate browser CORS for the web build by routing Honcho API calls through a same-origin, header-driven reverse proxy, while leaving the Tauri desktop path untouched and preserving Fleet aggregation.
|
||||||
|
|
||||||
|
**Architecture:** A single `dispatchFor(instance)` helper decides transport at runtime — web mode returns `baseUrl="/api"` plus an `X-Honcho-Upstream` header (the real Honcho URL); Tauri mode returns the absolute URL and reqwest. nginx (docker) and a Vite middleware (dev) read the header and forward server-side, so the browser never makes a cross-origin request. Instances are still stored as absolute URLs — only dispatch changes.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, openapi-fetch, TanStack Query, Vitest, Biome, nginx (envsubst template), Vite dev server, Tauri v2.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-02-honcho-api-proxy-design.md`
|
||||||
|
|
||||||
|
**Baseline gate (run before starting AND after every task):**
|
||||||
|
`make ci-web` (lint + typecheck + test + build). Targeted test during a task: `pnpm --filter @openconcho/web exec vitest run <path>`.
|
||||||
|
|
||||||
|
**Commit discipline:** conventional commits, one logical change per commit, body lines ≤100 chars, no AI attribution. Branch `feat/web-api-proxy` (already created; spec already committed there).
|
||||||
|
|
||||||
|
> **Amendment (2026-06-02, post-execution):** Tasks 1–3 are implemented and committed
|
||||||
|
> (`d4452ab`, `9945e4c`, `0935099`). During execution a design correction was made:
|
||||||
|
> the web-mode base is **absolute same-origin** (`${location.origin}/api`), not the bare
|
||||||
|
> relative `"/api"`. Reason: a relative base makes `openapi-fetch` call
|
||||||
|
> `new Request("/api/...")`, which throws `ERR_INVALID_URL` under node/undici and is
|
||||||
|
> fragile in the browser. `dispatchFor` now returns `${location.origin}${API_PREFIX}`
|
||||||
|
> in web mode. Consequently `fleet.test.tsx` **was** updated (the original claim that it
|
||||||
|
> "stays green untouched" was wrong — the transport contract genuinely changed) and any
|
||||||
|
> test that inspects the dispatched URL asserts the absolute same-origin form. Tests mock
|
||||||
|
> `@/lib/http` (not `globalThis.fetch`), because `httpFetch` captures the fetch reference
|
||||||
|
> at module load, so `vi.stubGlobal("fetch", …)` would not intercept it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | Action |
|
||||||
|
|------|----------------|--------|
|
||||||
|
| `packages/web/src/lib/platform.ts` | Single `isTauri()` predicate (leaf module, no app imports) | Create |
|
||||||
|
| `packages/web/src/lib/dispatch.ts` | `dispatchFor()` + proxy constants — the one transport decision | Create |
|
||||||
|
| `packages/web/src/lib/http.ts` | Keep `httpFetch`; consume `isTauri` from platform | Modify |
|
||||||
|
| `packages/web/src/lib/discovery.ts` | Re-export `isTauri` from platform; route probe via `dispatchFor` | Modify |
|
||||||
|
| `packages/web/src/api/client.ts` | Active-instance client via `dispatchFor` | Modify |
|
||||||
|
| `packages/web/src/api/scopedClient.ts` | Scoped client (Fleet/seed-kits) via `dispatchFor` | Modify |
|
||||||
|
| `packages/web/src/lib/config.ts` | `checkConnection` via `dispatchFor` + proxy-reject handling | Modify |
|
||||||
|
| `packages/web/src/lib/runtimeConfig.ts` | Drop `same-origin` sentinel | Modify |
|
||||||
|
| `packages/web/vite.config.ts` | Dev `/api` proxy middleware mirroring nginx | Modify |
|
||||||
|
| `docker/nginx.conf.template` | Header-driven `^~ /api/` block (replaces `/v3` + `/health`) | Modify |
|
||||||
|
| `docker/40-openconcho-config.sh` | Render allowlist `map` for `$allow_upstream` | Modify |
|
||||||
|
| `docker-compose.yml` | Retire `HONCHO_UPSTREAM`; document allowlist env | Modify |
|
||||||
|
| `AGENTS.md`, `README.md` | Proxy contract + env vars | Modify |
|
||||||
|
| `packages/web/src/test/dispatch.test.ts` | Unit tests for `dispatchFor` | Create |
|
||||||
|
| `packages/web/src/test/check-connection.test.ts` | Unit tests for `checkConnection` proxy behavior | Create |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extract `isTauri()` into a leaf module
|
||||||
|
|
||||||
|
Prevents an import cycle: `discovery.ts` will later import `dispatchFor`, and `dispatch.ts` needs `isTauri`. A leaf `platform.ts` breaks the cycle and gives one canonical predicate (WIOCHE).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/web/src/lib/platform.ts`
|
||||||
|
- Test: `packages/web/src/test/platform.test.ts`
|
||||||
|
- Modify: `packages/web/src/lib/http.ts`, `packages/web/src/lib/discovery.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`packages/web/src/test/platform.test.ts`:
|
||||||
|
```ts
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { isTauri } from "@/lib/platform";
|
||||||
|
|
||||||
|
describe("isTauri", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false in a plain browser/jsdom environment", () => {
|
||||||
|
expect(isTauri()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when the Tauri internals global is present", () => {
|
||||||
|
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
|
||||||
|
expect(isTauri()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @openconcho/web exec vitest run src/test/platform.test.ts`
|
||||||
|
Expected: FAIL — cannot resolve `@/lib/platform`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the module**
|
||||||
|
|
||||||
|
`packages/web/src/lib/platform.ts`:
|
||||||
|
```ts
|
||||||
|
/** True when running inside the Tauri desktop shell (WebView with injected internals). */
|
||||||
|
export function isTauri(): boolean {
|
||||||
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Point existing consumers at the canonical predicate**
|
||||||
|
|
||||||
|
In `packages/web/src/lib/http.ts`, replace the inline const with the shared predicate (call it at module load to preserve current behavior):
|
||||||
|
```ts
|
||||||
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||||
|
import { isTauri } from "@/lib/platform";
|
||||||
|
|
||||||
|
// Route fetch through Rust (reqwest) when running in Tauri — bypasses WebView CORS enforcement.
|
||||||
|
// Falls back to native browser fetch during plain web dev.
|
||||||
|
export const httpFetch: typeof globalThis.fetch = isTauri()
|
||||||
|
? (tauriFetch as typeof globalThis.fetch)
|
||||||
|
: globalThis.fetch;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `packages/web/src/lib/discovery.ts`, delete the local `isTauri` function (lines 8-10) and re-export the canonical one so existing importers keep working. Add at the top, after the `httpFetch` import:
|
||||||
|
```ts
|
||||||
|
export { isTauri } from "@/lib/platform";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests + lint + typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @openconcho/web exec vitest run src/test/platform.test.ts && make lint && make typecheck`
|
||||||
|
Expected: PASS; no type errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/web/src/lib/platform.ts packages/web/src/lib/http.ts \
|
||||||
|
packages/web/src/lib/discovery.ts packages/web/src/test/platform.test.ts
|
||||||
|
git commit -m "refactor(web): extract isTauri into a leaf platform module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `dispatchFor` helper + proxy constants
|
||||||
|
|
||||||
|
The heart of the design. Decides transport per instance.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/web/src/lib/dispatch.ts`
|
||||||
|
- Test: `packages/web/src/test/dispatch.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`packages/web/src/test/dispatch.test.ts`:
|
||||||
|
```ts
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockIsTauri = vi.fn();
|
||||||
|
vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
|
||||||
|
|
||||||
|
import {
|
||||||
|
API_PREFIX,
|
||||||
|
dispatchFor,
|
||||||
|
PROXY_REJECT_HEADER,
|
||||||
|
UPSTREAM_HEADER,
|
||||||
|
} from "@/lib/dispatch";
|
||||||
|
|
||||||
|
afterEach(() => mockIsTauri.mockReset());
|
||||||
|
|
||||||
|
describe("dispatchFor — web mode", () => {
|
||||||
|
it("targets the /api prefix and carries the upstream header", () => {
|
||||||
|
mockIsTauri.mockReturnValue(false);
|
||||||
|
const d = dispatchFor({ baseUrl: "https://honcho.example.net/", token: "" });
|
||||||
|
expect(d.baseUrl).toBe(API_PREFIX);
|
||||||
|
expect(d.headers[UPSTREAM_HEADER]).toBe("https://honcho.example.net");
|
||||||
|
expect(d.headers.Authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds Authorization only when a token is present", () => {
|
||||||
|
mockIsTauri.mockReturnValue(false);
|
||||||
|
const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
|
||||||
|
expect(d.headers.Authorization).toBe("Bearer sk-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dispatchFor — tauri mode", () => {
|
||||||
|
it("targets the absolute URL with no upstream header", () => {
|
||||||
|
mockIsTauri.mockReturnValue(true);
|
||||||
|
const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
|
||||||
|
expect(d.baseUrl).toBe("https://honcho.example.net");
|
||||||
|
expect(d.headers[UPSTREAM_HEADER]).toBeUndefined();
|
||||||
|
expect(d.headers.Authorization).toBe("Bearer sk-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("proxy reject header constant", () => {
|
||||||
|
it("is the agreed sentinel name", () => {
|
||||||
|
expect(PROXY_REJECT_HEADER).toBe("X-Honcho-Proxy-Reject");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @openconcho/web exec vitest run src/test/dispatch.test.ts`
|
||||||
|
Expected: FAIL — cannot resolve `@/lib/dispatch`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the helper**
|
||||||
|
|
||||||
|
`packages/web/src/lib/dispatch.ts`:
|
||||||
|
```ts
|
||||||
|
import { httpFetch } from "@/lib/http";
|
||||||
|
import { isTauri } from "@/lib/platform";
|
||||||
|
|
||||||
|
/** Same-origin path prefix the web build issues all Honcho calls through. */
|
||||||
|
export const API_PREFIX = "/api";
|
||||||
|
/** Request header naming the real Honcho upstream for the proxy to forward to. */
|
||||||
|
export const UPSTREAM_HEADER = "X-Honcho-Upstream";
|
||||||
|
/** Response header the proxy sets on its OWN refusals (so they aren't read as upstream auth). */
|
||||||
|
export const PROXY_REJECT_HEADER = "X-Honcho-Proxy-Reject";
|
||||||
|
|
||||||
|
export interface Dispatch {
|
||||||
|
baseUrl: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
fetch: typeof globalThis.fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUpstream(url: string): string {
|
||||||
|
return url.trim().replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve how to issue a request for an instance.
|
||||||
|
* - Web: same-origin `/api` + `X-Honcho-Upstream` header (proxy forwards server-side, no CORS).
|
||||||
|
* - Tauri: the absolute instance URL via reqwest (no browser same-origin policy).
|
||||||
|
*/
|
||||||
|
export function dispatchFor(instance: { baseUrl: string; token?: string }): Dispatch {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
|
||||||
|
|
||||||
|
if (isTauri()) {
|
||||||
|
return { baseUrl: instance.baseUrl, headers, fetch: httpFetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
headers[UPSTREAM_HEADER] = normalizeUpstream(instance.baseUrl);
|
||||||
|
return { baseUrl: API_PREFIX, headers, fetch: httpFetch };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @openconcho/web exec vitest run src/test/dispatch.test.ts`
|
||||||
|
Expected: PASS (5 assertions across 4 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/web/src/lib/dispatch.ts packages/web/src/test/dispatch.test.ts
|
||||||
|
git commit -m "feat(web): add dispatchFor transport helper for same-origin proxy"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Route the API clients through `dispatchFor`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/web/src/api/client.ts`, `packages/web/src/api/scopedClient.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite `client.ts`**
|
||||||
|
|
||||||
|
`packages/web/src/api/client.ts`:
|
||||||
|
```ts
|
||||||
|
import createClient from "openapi-fetch";
|
||||||
|
import { loadConfig } from "@/lib/config";
|
||||||
|
import { dispatchFor } from "@/lib/dispatch";
|
||||||
|
import type { paths } from "./schema.d.ts";
|
||||||
|
|
||||||
|
export function createHonchoClient() {
|
||||||
|
const config = loadConfig() ?? { baseUrl: "http://localhost:8000", token: "" };
|
||||||
|
const { baseUrl, headers, fetch } = dispatchFor(config);
|
||||||
|
return createClient<paths>({ baseUrl, headers, fetch });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const client = {
|
||||||
|
get current() {
|
||||||
|
return createHonchoClient();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite `scopedClient.ts`**
|
||||||
|
|
||||||
|
`packages/web/src/api/scopedClient.ts`:
|
||||||
|
```ts
|
||||||
|
import createClient from "openapi-fetch";
|
||||||
|
import type { Instance } from "@/lib/config";
|
||||||
|
import { dispatchFor } from "@/lib/dispatch";
|
||||||
|
import type { paths } from "./schema.d.ts";
|
||||||
|
|
||||||
|
export type ScopedClient = ReturnType<typeof createClient<paths>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an openapi-fetch client bound to a specific instance. Use for views that
|
||||||
|
* query non-active instances (e.g. the Fleet side-by-side comparison). Each scoped
|
||||||
|
* client self-routes via its own X-Honcho-Upstream header in web mode.
|
||||||
|
*/
|
||||||
|
export function createScopedClient(instance: Instance): ScopedClient {
|
||||||
|
const { baseUrl, headers, fetch } = dispatchFor(instance);
|
||||||
|
return createClient<paths>({ baseUrl, headers, fetch });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the full web suite stays green**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @openconcho/web exec vitest run && make typecheck && make lint`
|
||||||
|
Expected: PASS — existing `fleet.test.tsx`, `seed-kits.test.ts`, `app.test.tsx` still pass (transport swap is invisible to them).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/web/src/api/client.ts packages/web/src/api/scopedClient.ts
|
||||||
|
git commit -m "feat(web): route api clients through dispatchFor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `checkConnection` + discovery via the proxy, with reject handling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/web/src/lib/config.ts` (`checkConnection`), `packages/web/src/lib/discovery.ts` (`suggestNameForInstance`)
|
||||||
|
- Test: `packages/web/src/test/check-connection.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`packages/web/src/test/check-connection.test.ts`:
|
||||||
|
```ts
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock the platform predicate (web mode) and the fetch boundary. We mock
|
||||||
|
// @/lib/http — NOT globalThis.fetch — because httpFetch captures the fetch
|
||||||
|
// reference at module load, so vi.stubGlobal would not be observed by dispatchFor.
|
||||||
|
const { mockIsTauri, httpFetchMock } = vi.hoisted(() => ({
|
||||||
|
mockIsTauri: vi.fn(() => false),
|
||||||
|
httpFetchMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
|
||||||
|
vi.mock("@/lib/http", () => ({ httpFetch: httpFetchMock }));
|
||||||
|
|
||||||
|
import { checkConnection } from "@/lib/config";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpFetchMock.mockReset();
|
||||||
|
mockIsTauri.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkConnection — web proxy mode", () => {
|
||||||
|
it("calls the absolute same-origin /api path with the upstream header", async () => {
|
||||||
|
httpFetchMock.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||||
|
const res = await checkConnection("https://honcho.example.net", "sk-1");
|
||||||
|
expect(res.status).toBe("ok");
|
||||||
|
const [url, init] = httpFetchMock.mock.calls[0];
|
||||||
|
expect(String(url)).toBe(`${location.origin}/api/v3/workspaces/list`);
|
||||||
|
expect((init.headers as Record<string, string>)["X-Honcho-Upstream"]).toBe(
|
||||||
|
"https://honcho.example.net",
|
||||||
|
);
|
||||||
|
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer sk-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an upstream 401 to auth-required", async () => {
|
||||||
|
httpFetchMock.mockResolvedValue(new Response("{}", { status: 401 }));
|
||||||
|
const res = await checkConnection("https://honcho.example.net");
|
||||||
|
expect(res.status).toBe("auth-required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a proxy reject as unreachable, not auth-required", async () => {
|
||||||
|
httpFetchMock.mockResolvedValue(
|
||||||
|
new Response("", { status: 403, headers: { "X-Honcho-Proxy-Reject": "allowlist" } }),
|
||||||
|
);
|
||||||
|
const res = await checkConnection("https://blocked.example.net");
|
||||||
|
expect(res.status).toBe("unreachable");
|
||||||
|
expect(res.message).toMatch(/allowlist/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @openconcho/web exec vitest run src/test/check-connection.test.ts`
|
||||||
|
Expected: FAIL — current `checkConnection` fetches `${baseUrl}/v3/...` directly (no `/api`, no upstream header, no reject handling).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite `checkConnection`**
|
||||||
|
|
||||||
|
In `packages/web/src/lib/config.ts`, replace the `checkConnection` body (keep the signature). Add the import `import { dispatchFor, PROXY_REJECT_HEADER } from "@/lib/dispatch";` at the top (and remove the now-unused `httpFetch` import if nothing else in the file uses it):
|
||||||
|
```ts
|
||||||
|
export async function checkConnection(
|
||||||
|
baseUrl: string,
|
||||||
|
token?: string,
|
||||||
|
): Promise<{ status: HealthStatus; message: string }> {
|
||||||
|
try {
|
||||||
|
const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl, token });
|
||||||
|
const res = await fetch(`${base}/v3/workspaces/list`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reject = res.headers.get(PROXY_REJECT_HEADER);
|
||||||
|
if (reject) {
|
||||||
|
return { status: "unreachable", message: `Proxy refused upstream (${reject})` };
|
||||||
|
}
|
||||||
|
if (res.ok) return { status: "ok", message: "Connected successfully" };
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
return { status: "auth-required", message: "Authentication required — provide an API token" };
|
||||||
|
}
|
||||||
|
return { status: "unreachable", message: `Server returned ${res.status}` };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
if (msg.includes("AbortError") || msg.includes("timeout")) {
|
||||||
|
return { status: "unreachable", message: "Connection timed out" };
|
||||||
|
}
|
||||||
|
return { status: "unreachable", message: `Cannot reach server: ${msg}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Route the discovery probe through `dispatchFor` too**
|
||||||
|
|
||||||
|
In `packages/web/src/lib/discovery.ts`, rewrite `suggestNameForInstance` to dispatch consistently. Add `import { dispatchFor } from "@/lib/dispatch";` and replace the fetch line:
|
||||||
|
```ts
|
||||||
|
export async function suggestNameForInstance(baseUrl: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl });
|
||||||
|
const res = await fetch(`${base}/v3/workspaces/list?page=1&page_size=1`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as { items?: Array<{ id?: string }> };
|
||||||
|
const wsId = data.items?.[0]?.id;
|
||||||
|
if (typeof wsId === "string" && wsId.length > 0) {
|
||||||
|
return deriveNameFromWorkspaceId(wsId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Leave the top-of-file `httpFetch` import only if still referenced; otherwise remove it. `discovery.ts` no longer needs `httpFetch` after this change — remove the import.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests + gate**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @openconcho/web exec vitest run src/test/check-connection.test.ts && pnpm --filter @openconcho/web exec vitest run && make typecheck && make lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/web/src/lib/config.ts packages/web/src/lib/discovery.ts \
|
||||||
|
packages/web/src/test/check-connection.test.ts
|
||||||
|
git commit -m "feat(web): route checkConnection and discovery through the proxy"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Drop the `same-origin` sentinel from runtime config
|
||||||
|
|
||||||
|
In header mode the default instance needs a real absolute URL (it becomes the header value); `same-origin` was glue for the retired `/v3` proxy.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/web/src/lib/runtimeConfig.ts`
|
||||||
|
- Test: `packages/web/src/test/runtime-config.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`packages/web/src/test/runtime-config.test.ts`:
|
||||||
|
```ts
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
|
||||||
|
|
||||||
|
const KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (globalThis as Record<string, unknown>)[KEY];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runtimeDefaultBaseUrl", () => {
|
||||||
|
it("returns an injected absolute URL verbatim", () => {
|
||||||
|
(globalThis as Record<string, unknown>)[KEY] = "https://honcho.example.net";
|
||||||
|
expect(runtimeDefaultBaseUrl()).toBe("https://honcho.example.net");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when unset or empty", () => {
|
||||||
|
expect(runtimeDefaultBaseUrl()).toBeNull();
|
||||||
|
(globalThis as Record<string, unknown>)[KEY] = " ";
|
||||||
|
expect(runtimeDefaultBaseUrl()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify current behavior is covered/fails appropriately**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @openconcho/web exec vitest run src/test/runtime-config.test.ts`
|
||||||
|
Expected: PASS for absolute/empty (existing behavior), but the goal is to simplify; proceed to remove the sentinel branch.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Simplify the module**
|
||||||
|
|
||||||
|
`packages/web/src/lib/runtimeConfig.ts`:
|
||||||
|
```ts
|
||||||
|
const GLOBAL_KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-injected default Honcho base URL for container deployments.
|
||||||
|
*
|
||||||
|
* The Docker image writes `/config.js` from `OPENCONCHO_DEFAULT_HONCHO_URL` at
|
||||||
|
* container start, so one prebuilt image can target any backend without a rebuild.
|
||||||
|
* The web build proxies this URL via the same-origin `/api` reverse proxy (no CORS).
|
||||||
|
*
|
||||||
|
* - an absolute URL → that URL (seeds the first instance)
|
||||||
|
* - empty / unset → null (no default; the user configures in Settings)
|
||||||
|
*/
|
||||||
|
export function runtimeDefaultBaseUrl(): string | null {
|
||||||
|
const raw = (globalThis as Record<string, unknown>)[GLOBAL_KEY];
|
||||||
|
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||||
|
return raw.trim();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test + gate**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @openconcho/web exec vitest run src/test/runtime-config.test.ts && make typecheck && make lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/web/src/lib/runtimeConfig.ts packages/web/src/test/runtime-config.test.ts
|
||||||
|
git commit -m "refactor(web): drop same-origin sentinel from runtime config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: nginx header-driven `/api` proxy
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docker/nginx.conf.template`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the `/v3` + `/health` blocks with the `/api` block**
|
||||||
|
|
||||||
|
In `docker/nginx.conf.template`, delete the `location ^~ /v3/ { ... }` and `location = /health { ... }` blocks (lines 21-32) and the `set $honcho_upstream ...` line. Keep the `resolver` line. Insert:
|
||||||
|
```nginx
|
||||||
|
# Header-driven same-origin proxy: the browser names the Honcho upstream per
|
||||||
|
# request via X-Honcho-Upstream, so the browser never makes a cross-origin call.
|
||||||
|
# $allow_upstream is provided by the allowlist map in conf.d (entrypoint-rendered).
|
||||||
|
location ^~ /api/ {
|
||||||
|
set $upstream $http_x_honcho_upstream;
|
||||||
|
if ($upstream = "") {
|
||||||
|
add_header X-Honcho-Proxy-Reject "no-upstream" always;
|
||||||
|
return 421;
|
||||||
|
}
|
||||||
|
if ($allow_upstream = 0) {
|
||||||
|
add_header X-Honcho-Proxy-Reject "allowlist" always;
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
rewrite ^/api/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header Host $proxy_host;
|
||||||
|
proxy_set_header X-Honcho-Upstream "";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate template renders to valid nginx syntax**
|
||||||
|
|
||||||
|
Run (renders the template with a dummy upstream and an open allowlist map, then `nginx -t`):
|
||||||
|
```bash
|
||||||
|
docker run --rm -e HONCHO_UPSTREAM=http://x:8000 -v "$PWD/docker":/d nginxinc/nginx-unprivileged:stable sh -c '
|
||||||
|
mkdir -p /etc/nginx/conf.d
|
||||||
|
echo "map \$http_x_honcho_upstream \$allow_upstream { default 1; }" > /etc/nginx/conf.d/allowlist_map.conf
|
||||||
|
envsubst "\$HONCHO_UPSTREAM" < /d/nginx.conf.template > /etc/nginx/conf.d/default.conf
|
||||||
|
nginx -t'
|
||||||
|
```
|
||||||
|
Expected: `nginx: configuration file /etc/nginx/nginx.conf test is successful`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker/nginx.conf.template
|
||||||
|
git commit -m "feat(docker): header-driven /api reverse proxy in nginx"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Entrypoint renders the allowlist map
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docker/40-openconcho-config.sh`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append allowlist-map rendering**
|
||||||
|
|
||||||
|
In `docker/40-openconcho-config.sh`, after the existing `config.js` heredoc, append:
|
||||||
|
```sh
|
||||||
|
# Render the SSRF allowlist into an nginx map for $allow_upstream.
|
||||||
|
# Unset/empty OPENCONCHO_UPSTREAM_ALLOWLIST → open (default 1), fine for the
|
||||||
|
# localhost-bound default. Set it (comma-separated host globs) before exposing
|
||||||
|
# the proxy (e.g. behind a tunnel) to reject non-matching upstreams.
|
||||||
|
ALLOWLIST_CONF=/etc/nginx/conf.d/allowlist_map.conf
|
||||||
|
if [ -z "${OPENCONCHO_UPSTREAM_ALLOWLIST:-}" ]; then
|
||||||
|
printf 'map $http_x_honcho_upstream $allow_upstream { default 1; }\n' > "$ALLOWLIST_CONF"
|
||||||
|
else
|
||||||
|
{
|
||||||
|
printf 'map $http_x_honcho_upstream $allow_upstream {\n'
|
||||||
|
printf ' default 0;\n'
|
||||||
|
IFS=','
|
||||||
|
for host in $OPENCONCHO_UPSTREAM_ALLOWLIST; do
|
||||||
|
host=$(printf '%s' "$host" | tr -d ' ')
|
||||||
|
[ -z "$host" ] && continue
|
||||||
|
esc=$(printf '%s' "$host" | sed -e 's/[.]/\\./g' -e 's/[*]/[^/]*/g')
|
||||||
|
printf ' "~^https?://%s(:[0-9]+)?(/.*)?$" 1;\n' "$esc"
|
||||||
|
done
|
||||||
|
printf '}\n'
|
||||||
|
} > "$ALLOWLIST_CONF"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate generated map syntax (allowlist set)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker run --rm -e OPENCONCHO_UPSTREAM_ALLOWLIST="honcho.example.net,*.honcho.dev" \
|
||||||
|
-e HONCHO_UPSTREAM=http://x:8000 -v "$PWD/docker":/d nginxinc/nginx-unprivileged:stable sh -c '
|
||||||
|
mkdir -p /etc/nginx/conf.d
|
||||||
|
export OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net
|
||||||
|
sh /d/40-openconcho-config.sh || true
|
||||||
|
envsubst "\$HONCHO_UPSTREAM" < /d/nginx.conf.template > /etc/nginx/conf.d/default.conf
|
||||||
|
nginx -t && cat /etc/nginx/conf.d/allowlist_map.conf'
|
||||||
|
```
|
||||||
|
Expected: `nginx -t` success; printed map contains regex lines for both hosts.
|
||||||
|
Note: the script writes `config.js` to `/usr/share/nginx/html`; if that dir is absent in this bare check, the heredoc line may error — that is fine for syntax validation (the `|| true` guards it). The allowlist block still runs.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker/40-openconcho-config.sh
|
||||||
|
git commit -m "feat(docker): render SSRF allowlist map from env"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Vite dev proxy middleware (dev/CI parity)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/web/vite.config.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a `configureServer` plugin mirroring nginx**
|
||||||
|
|
||||||
|
In `packages/web/vite.config.ts`, add this plugin factory above `defineConfig` and include `honchoApiProxy()` in the `plugins` array (after `react()`):
|
||||||
|
```ts
|
||||||
|
import type { Plugin } from "vite";
|
||||||
|
|
||||||
|
function honchoApiProxy(): Plugin {
|
||||||
|
const HEADER = "x-honcho-upstream";
|
||||||
|
return {
|
||||||
|
name: "honcho-api-proxy",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use("/api", async (req, res) => {
|
||||||
|
const upstream = req.headers[HEADER];
|
||||||
|
if (typeof upstream !== "string" || upstream.trim() === "") {
|
||||||
|
res.statusCode = 421;
|
||||||
|
res.setHeader("X-Honcho-Proxy-Reject", "no-upstream");
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = upstream.replace(/\/+$/, "") + (req.url ?? "");
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const c of req) chunks.push(c as Buffer);
|
||||||
|
try {
|
||||||
|
const upstreamRes = await fetch(target, {
|
||||||
|
method: req.method,
|
||||||
|
headers: {
|
||||||
|
"content-type": req.headers["content-type"] ?? "application/json",
|
||||||
|
...(req.headers.authorization
|
||||||
|
? { authorization: req.headers.authorization }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
body: ["GET", "HEAD"].includes(req.method ?? "") ? undefined : Buffer.concat(chunks),
|
||||||
|
});
|
||||||
|
res.statusCode = upstreamRes.status;
|
||||||
|
upstreamRes.headers.forEach((v, k) => res.setHeader(k, v));
|
||||||
|
res.end(Buffer.from(await upstreamRes.arrayBuffer()));
|
||||||
|
} catch (e) {
|
||||||
|
res.statusCode = 502;
|
||||||
|
res.end(`proxy error: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Update the plugins line to:
|
||||||
|
```ts
|
||||||
|
plugins: [tanstackRouter({ autoCodeSplitting: true }), react(), honchoApiProxy(), tailwindcss()],
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck + lint (the config is type-checked by the build)**
|
||||||
|
|
||||||
|
Run: `make typecheck && make lint`
|
||||||
|
Expected: PASS (no `any`, `Plugin` typed).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/web/vite.config.ts
|
||||||
|
git commit -m "feat(web): dev /api proxy middleware mirroring nginx"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Compose env + docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docker-compose.yml`, `AGENTS.md`, `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `docker-compose.yml`**
|
||||||
|
|
||||||
|
Replace the `environment:` block and its comments so the upstream is no longer a single env var. New `environment:` section:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
# The SPA seeds its first instance from this absolute URL; the browser then
|
||||||
|
# routes all calls same-origin through /api, and nginx forwards them to the
|
||||||
|
# URL named per-request in the X-Honcho-Upstream header (no browser CORS).
|
||||||
|
OPENCONCHO_DEFAULT_HONCHO_URL: ${OPENCONCHO_DEFAULT_HONCHO_URL:-http://host.docker.internal:8000}
|
||||||
|
# Optional SSRF guard. Unset = forward anywhere (safe for the localhost-only
|
||||||
|
# binding below). Set comma-separated host globs before exposing the proxy:
|
||||||
|
# OPENCONCHO_UPSTREAM_ALLOWLIST: honcho.example.net,*.honcho.dev
|
||||||
|
OPENCONCHO_UPSTREAM_ALLOWLIST: ${OPENCONCHO_UPSTREAM_ALLOWLIST:-}
|
||||||
|
```
|
||||||
|
Update the top-of-file comment block: replace the `HONCHO_UPSTREAM=...` example with `OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net docker compose up` and note the per-request header model.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `AGENTS.md` Key Constraints**
|
||||||
|
|
||||||
|
Replace the CORS-relevant bullet(s) with:
|
||||||
|
```markdown
|
||||||
|
- **Web CORS is handled by a same-origin `/api` proxy** — the browser issues all
|
||||||
|
Honcho calls to `/api/*` with an `X-Honcho-Upstream` header; nginx (docker) and a
|
||||||
|
Vite middleware (dev) forward server-side. Tauri bypasses CORS via reqwest and uses
|
||||||
|
absolute URLs. Optional `OPENCONCHO_UPSTREAM_ALLOWLIST` guards the proxy when exposed.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `README.md`**
|
||||||
|
|
||||||
|
In the Docker/run section, document `OPENCONCHO_DEFAULT_HONCHO_URL` (absolute URL seed) and `OPENCONCHO_UPSTREAM_ALLOWLIST` (optional, comma-separated host globs), and remove any `HONCHO_UPSTREAM` references.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker-compose.yml AGENTS.md README.md
|
||||||
|
git commit -m "docs: document the /api proxy contract and env vars"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Final CI-parity gate + branch readiness
|
||||||
|
|
||||||
|
**Files:** none (verification only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the full web gate**
|
||||||
|
|
||||||
|
Run: `make ci-web`
|
||||||
|
Expected: lint, typecheck, test, and build all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm no `HONCHO_UPSTREAM` or `same-origin` references remain**
|
||||||
|
|
||||||
|
Run: `grep -rn "HONCHO_UPSTREAM\|same-origin" docker docker-compose.yml packages/web/src README.md AGENTS.md`
|
||||||
|
Expected: no matches in code/config (only the spec/plan under `docs/` may mention them historically).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm clean tree**
|
||||||
|
|
||||||
|
Run: `git status --short`
|
||||||
|
Expected: empty (all work committed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed by author)
|
||||||
|
|
||||||
|
- **Spec coverage:** dispatch helper (T2/T3), checkConnection+discovery (T4), nginx header proxy + SNI + reject header (T6), allowlist map (T7), vite parity (T8), env migration + docs (T9), Fleet preserved (T3 — scopedClient routed; existing fleet.test.tsx asserted green), runtime sentinel drop (T5). All spec sections mapped.
|
||||||
|
- **Placeholder scan:** none — every code step shows full content.
|
||||||
|
- **Type consistency:** `dispatchFor`, `Dispatch`, `API_PREFIX`, `UPSTREAM_HEADER`, `PROXY_REJECT_HEADER` are defined in T2 and consumed verbatim in T3/T4. `isTauri()` defined T1, consumed T2.
|
||||||
243
docs/superpowers/specs/2026-06-02-honcho-api-proxy-design.md
Normal file
243
docs/superpowers/specs/2026-06-02-honcho-api-proxy-design.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# Header-Driven `/api` Proxy for Web CORS — Design
|
||||||
|
|
||||||
|
- **Date:** 2026-06-02
|
||||||
|
- **Status:** Approved (design) — pending spec review
|
||||||
|
- **Scope:** One concern — eliminate browser CORS for the web build by routing
|
||||||
|
Honcho API calls through a same-origin, header-driven reverse proxy. Preserve
|
||||||
|
existing Fleet aggregation. No new aggregation features (deferred).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The web build (`@openconcho/web`) talks to Honcho directly from the browser. When
|
||||||
|
the configured instance URL is a different origin than the page (e.g. a self-hosted
|
||||||
|
Honcho at `https://honcho.example.net` while the UI runs on
|
||||||
|
`http://localhost:8080`), the browser issues a CORS preflight on the `Authorization`
|
||||||
|
header and the request fails — Honcho ships no `CORSMiddleware`.
|
||||||
|
|
||||||
|
The desktop (Tauri) build is unaffected: it routes fetch through Rust/`reqwest`
|
||||||
|
(`packages/web/src/lib/http.ts`), which has no browser same-origin policy.
|
||||||
|
|
||||||
|
The repo already had a partial mitigation (a static `^~ /v3/` nginx proxy keyed to a
|
||||||
|
single `HONCHO_UPSTREAM`), but it (a) supported only one backend and (b) was bypassed
|
||||||
|
the moment a user typed an absolute URL into Settings — which is the bug that surfaced.
|
||||||
|
|
||||||
|
### Evidence gathered
|
||||||
|
|
||||||
|
- Browser → `honcho.example.net` is reachable; the CORS error proves the
|
||||||
|
request reached Honcho and only the browser policy check failed.
|
||||||
|
- A Docker container under Colima **also** reaches the tailnet: `docker run ...
|
||||||
|
curl https://honcho.example.net/health` returned **HTTP 200**, connected over the
|
||||||
|
tailnet on `:443` with TLS verified. So a container-side proxy is viable on this
|
||||||
|
host (Colima forwards container egress through the host's tailnet routing).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Coexist by runtime mode.** Tauri keeps absolute-URL + `reqwest`. The web build
|
||||||
|
(docker **and** `make dev-web`) routes through a same-origin `/api` proxy. One
|
||||||
|
build; behavior chosen at runtime by `isTauri()`.
|
||||||
|
2. **Header-driven routing.** The browser names the target upstream per request via
|
||||||
|
an `X-Honcho-Upstream` header (sourced from the active/scoped instance's
|
||||||
|
`baseUrl`). The proxy is a stateless forwarder; the frontend stays the single
|
||||||
|
source of truth for instances. No server-side slug→upstream map.
|
||||||
|
3. **SSRF posture: optional allowlist, open by default.** Unset
|
||||||
|
`OPENCONCHO_UPSTREAM_ALLOWLIST` ⇒ forward anywhere (safe for the default
|
||||||
|
`127.0.0.1:8080` binding). Set it (host globs) before exposing the proxy (e.g.
|
||||||
|
behind `cloudflared`) to reject non-matching upstreams.
|
||||||
|
4. **Aggregation: preserve, don't extend.** The existing Fleet dashboard
|
||||||
|
(`compareQueries.ts`, `fleetAggregates.ts`, `FleetDashboard`/`FleetRow`) must keep
|
||||||
|
working identically. New cross-instance merge/dedup/search is explicitly a
|
||||||
|
**non-goal** of this PR.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
WEB (docker + dev):
|
||||||
|
browser ──same-origin──▶ /api/v3/... (openapi-fetch base = "/api")
|
||||||
|
X-Honcho-Upstream: https://honcho.example.net (from instance.baseUrl)
|
||||||
|
Authorization: Bearer … (unchanged, when set)
|
||||||
|
│ proxy: validate header, allowlist-check, strip "/api",
|
||||||
|
│ proxy_pass $upstream, set SNI/Host, drop routing header
|
||||||
|
▼
|
||||||
|
https://honcho.example.net/v3/... (server-side hop — no CORS)
|
||||||
|
|
||||||
|
TAURI:
|
||||||
|
webview ──reqwest──▶ https://honcho.example.net/v3/... (unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why a custom header is free here:** `X-Honcho-Upstream` rides a *same-origin*
|
||||||
|
request (browser → `/api`), so it triggers no CORS preflight. Preflight only fires
|
||||||
|
cross-origin — the exact condition this design removes.
|
||||||
|
|
||||||
|
**Why the instance store is unchanged:** instances still persist an absolute
|
||||||
|
`baseUrl` (`z.string().url()` stays valid). We change only *how a request is
|
||||||
|
dispatched*, not what is stored. In web mode the instance URL stops being the fetch
|
||||||
|
target and becomes the header value.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### A. Centralized dispatch helper (new) — `src/lib/dispatch.ts`
|
||||||
|
|
||||||
|
Single source of truth for "how to issue a request for an instance," replacing four
|
||||||
|
ad-hoc constructions.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const API_PREFIX = "/api";
|
||||||
|
export const UPSTREAM_HEADER = "X-Honcho-Upstream";
|
||||||
|
|
||||||
|
export interface Dispatch {
|
||||||
|
baseUrl: string; // "/api" (web) | instance.baseUrl (tauri)
|
||||||
|
headers: Record<string, string>; // Content-Type, Authorization?, X-Honcho-Upstream?
|
||||||
|
fetch: typeof globalThis.fetch; // globalThis.fetch (web) | tauriFetch (tauri)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchFor(
|
||||||
|
instance: Pick<Instance, "baseUrl" | "token">,
|
||||||
|
): Dispatch;
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Web:** `baseUrl = API_PREFIX`; headers include `UPSTREAM_HEADER =
|
||||||
|
normalizedUpstream(instance.baseUrl)` (trailing slash stripped) and `Authorization`
|
||||||
|
when `token` is non-empty; `fetch = globalThis.fetch`.
|
||||||
|
- **Tauri:** `baseUrl = instance.baseUrl`; no upstream header; `fetch = tauriFetch`.
|
||||||
|
|
||||||
|
`API_PREFIX` and `UPSTREAM_HEADER` are named constants (WIOCHE) referenced by both
|
||||||
|
the frontend and documented for the proxy.
|
||||||
|
|
||||||
|
### B. Consumers of the helper (the four dispatch sites)
|
||||||
|
|
||||||
|
| Site | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| Active-instance client | `src/api/client.ts` | build client from `dispatchFor(loadConfig())` |
|
||||||
|
| Scoped client (Fleet/compare, seed-kits) | `src/api/scopedClient.ts` | build client from `dispatchFor(instance)` |
|
||||||
|
| Connection health check | `src/lib/config.ts` `checkConnection` | fetch `${baseUrl}/v3/workspaces/list` via `dispatchFor` (hits `/api/...` in web) |
|
||||||
|
| Discovery name probe | `src/lib/discovery.ts` `suggestNameForInstance` | same, via `dispatchFor` |
|
||||||
|
|
||||||
|
`compareQueries.ts` and `fleetAggregates.ts` need **no changes** — they go through
|
||||||
|
`createScopedClient`, so the transport swap is invisible to them. `FleetRow.tsx:114`
|
||||||
|
uses `instance.baseUrl` only to render a hostname label (cosmetic) — untouched.
|
||||||
|
|
||||||
|
### C. nginx proxy — `docker/nginx.conf.template`
|
||||||
|
|
||||||
|
Replace the `^~ /v3/` and `= /health` upstream blocks with one header-driven block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
resolver 127.0.0.11 ipv6=off valid=10s;
|
||||||
|
|
||||||
|
# Rendered by the entrypoint from OPENCONCHO_UPSTREAM_ALLOWLIST.
|
||||||
|
# Unset → default 1 (open). Set → 1 only for matching hosts, else 0.
|
||||||
|
# (map block injected here)
|
||||||
|
|
||||||
|
location ^~ /api/ {
|
||||||
|
set $upstream $http_x_honcho_upstream;
|
||||||
|
if ($upstream = "") { return 421; } # misdirected: no target named
|
||||||
|
if ($allow_upstream = 0) { return 403; } # allowlist reject
|
||||||
|
rewrite ^/api/(.*)$ /$1 break; # strip /api, keep /v3/...
|
||||||
|
proxy_pass $upstream;
|
||||||
|
proxy_ssl_server_name on; # SNI for HTTPS upstreams
|
||||||
|
proxy_set_header Host $proxy_host; # upstream host, not localhost:8080
|
||||||
|
proxy_set_header X-Honcho-Upstream ""; # never leak routing header upstream
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Authorization` and other client headers pass through by nginx default. The
|
||||||
|
container's own `/healthz` liveness endpoint is unchanged.
|
||||||
|
|
||||||
|
### D. Vite dev parity — `vite.config.ts`
|
||||||
|
|
||||||
|
A `configureServer` middleware mirrors nginx for `make dev-web`: read
|
||||||
|
`X-Honcho-Upstream`, forward `/api/*` (prefix stripped) to it, drop the routing
|
||||||
|
header, apply the same allowlist if configured. Result: dev behaves identically to
|
||||||
|
the docker image (local/CI parity).
|
||||||
|
|
||||||
|
### E. Config / env — `docker/40-openconcho-config.sh`, `docker-compose.yml`
|
||||||
|
|
||||||
|
- `OPENCONCHO_DEFAULT_HONCHO_URL` keeps seeding the first instance, now an **absolute
|
||||||
|
URL only**. Drop the `same-origin` sentinel (glue for the retired `/v3` proxy) from
|
||||||
|
`src/lib/runtimeConfig.ts`.
|
||||||
|
- **Retire `HONCHO_UPSTREAM`** from compose — the upstream now comes from the header.
|
||||||
|
- New optional `OPENCONCHO_UPSTREAM_ALLOWLIST` (comma-separated host globs, e.g.
|
||||||
|
`honcho.example.net,*.honcho.dev`). The entrypoint renders it into the
|
||||||
|
nginx `map` for `$allow_upstream`; unset ⇒ map default 1.
|
||||||
|
|
||||||
|
## Data flow (Fleet aggregation, web mode)
|
||||||
|
|
||||||
|
```
|
||||||
|
FleetDashboard → useScoped*(instanceA) → createScopedClient(A) → dispatchFor(A)
|
||||||
|
POST /api/v3/... X-Honcho-Upstream: A ─┐
|
||||||
|
→ useScoped*(instanceB) → createScopedClient(B) → dispatchFor(B) ├▶ nginx → A,B
|
||||||
|
POST /api/v3/... X-Honcho-Upstream: B ─┘ (concurrent)
|
||||||
|
fleetAggregates.ts merges results (transport-agnostic). B down ⇒ only B's column errors.
|
||||||
|
```
|
||||||
|
|
||||||
|
Query keys in `compareQueries.ts` are already scoped by `instance.id`, so caches
|
||||||
|
never collide across columns. Stateless per-request routing isolates partial
|
||||||
|
failures per instance.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
The proxy must not let its own refusals masquerade as upstream responses. Both
|
||||||
|
proxy-origin refusals carry a sentinel response header **`X-Honcho-Proxy-Reject`**
|
||||||
|
(value: `no-upstream` | `allowlist`). `checkConnection` treats any response bearing
|
||||||
|
that header as `unreachable` with the reject reason — **regardless of status code** —
|
||||||
|
so an allowlist `403` is never mis-mapped to the upstream's auth `403`.
|
||||||
|
|
||||||
|
- **No `X-Honcho-Upstream`** (misconfigured web request) → proxy `421` +
|
||||||
|
`X-Honcho-Proxy-Reject: no-upstream`. Fail loud, not silent.
|
||||||
|
- **Allowlist reject** → proxy `403` + `X-Honcho-Proxy-Reject: allowlist`. The
|
||||||
|
sentinel header is what disambiguates it from the upstream's own `401/403` (which
|
||||||
|
arrive without the header and still map to `auth-required`).
|
||||||
|
- **Upstream unreachable / TLS failure** → nginx `502`; UI shows "Cannot reach
|
||||||
|
server." This is the symptom if a future host's container is *not* on the tailnet —
|
||||||
|
a network problem, not CORS (documented caveat: proxy portability depends on the
|
||||||
|
container host being able to route to the upstream).
|
||||||
|
|
||||||
|
## Testing (TDD)
|
||||||
|
|
||||||
|
Unit (mock only `isTauri` + the fetch boundary, per project test rules):
|
||||||
|
|
||||||
|
1. `dispatchFor` (web): `baseUrl === "/api"`, sets `X-Honcho-Upstream` to the
|
||||||
|
normalized instance URL, sets `Authorization` only when token non-empty, uses
|
||||||
|
`globalThis.fetch`.
|
||||||
|
2. `dispatchFor` (tauri): `baseUrl === instance.baseUrl`, no upstream header, uses
|
||||||
|
`tauriFetch`.
|
||||||
|
3. `checkConnection` (web): issues to `/api/v3/workspaces/list` with the upstream
|
||||||
|
header; maps `ok` / `401|403` / other correctly.
|
||||||
|
4. **Aggregation regression:** two scoped clients for distinct instances emit two
|
||||||
|
distinct `X-Honcho-Upstream` values; existing `src/test/fleet.test.tsx` stays
|
||||||
|
green.
|
||||||
|
|
||||||
|
Integration (nginx): compose up against a stub upstream — assert (a) header →
|
||||||
|
forward with `/api` stripped, (b) missing header → 421, (c) allowlist miss → 403,
|
||||||
|
(d) `X-Honcho-Upstream` absent on the upstream-side request.
|
||||||
|
|
||||||
|
## Non-goals (deferred to separate PRs)
|
||||||
|
|
||||||
|
- New cross-instance aggregation intelligence (unified merged lists, dedup, conflict
|
||||||
|
resolution, cross-instance search).
|
||||||
|
- Running Tailscale inside the Colima VM / any host-networking change (not needed —
|
||||||
|
reachability confirmed on the target host).
|
||||||
|
- Adding `CORSMiddleware` to Honcho (the proxy keeps openconcho self-contained and
|
||||||
|
backend-agnostic; this is the deliberate alternative *not* taken).
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
- `packages/web/src/lib/dispatch.ts` (new)
|
||||||
|
- `packages/web/src/api/client.ts`
|
||||||
|
- `packages/web/src/api/scopedClient.ts`
|
||||||
|
- `packages/web/src/lib/config.ts`
|
||||||
|
- `packages/web/src/lib/discovery.ts`
|
||||||
|
- `packages/web/src/lib/runtimeConfig.ts`
|
||||||
|
- `packages/web/vite.config.ts`
|
||||||
|
- `docker/nginx.conf.template`
|
||||||
|
- `docker/40-openconcho-config.sh`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- tests: `packages/web/src/test/` (new dispatch + checkConnection cases; keep
|
||||||
|
`fleet.test.tsx`, `settings-form.test.tsx` green)
|
||||||
|
- docs: `AGENTS.md`, `README` (env vars + the proxy contract)
|
||||||
|
|
||||||
|
## Constraints honored
|
||||||
|
|
||||||
|
- Web-only change → PR CI (web checks) covers it; no desktop `cargo-check` needed
|
||||||
|
(Tauri path unchanged).
|
||||||
|
- No hardcoded URLs (upstream comes from instance config / runtime env).
|
||||||
|
- One concern per PR (proxy only); conventional commits; push under `offendingcommit`.
|
||||||
18
package.json
18
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "openconcho",
|
"name": "openconcho",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.1",
|
"version": "0.16.0",
|
||||||
"packageManager": "pnpm@10.33.2",
|
"packageManager": "pnpm@10.33.2",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22",
|
"node": ">=22",
|
||||||
@@ -27,17 +27,17 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "catalog:",
|
"@biomejs/biome": "catalog:",
|
||||||
"@commitlint/cli": "~20.5.2",
|
"@commitlint/cli": "~20.5.3",
|
||||||
"@commitlint/config-conventional": "~20.5.0",
|
"@commitlint/config-conventional": "~20.5.3",
|
||||||
"@semantic-release/changelog": "^6.0.0",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
"@semantic-release/commit-analyzer": "^13.0.0",
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||||
"@semantic-release/exec": "^7.1.0",
|
"@semantic-release/exec": "^7.1.0",
|
||||||
"@semantic-release/git": "^10.0.0",
|
"@semantic-release/git": "^10.0.1",
|
||||||
"@semantic-release/github": "^10.0.0",
|
"@semantic-release/github": "^10.3.5",
|
||||||
"@semantic-release/npm": "^13.1.5",
|
"@semantic-release/npm": "^13.1.5",
|
||||||
"@semantic-release/release-notes-generator": "^14.0.0",
|
"@semantic-release/release-notes-generator": "^14.1.1",
|
||||||
"husky": "~9.1.7",
|
"husky": "~9.1.7",
|
||||||
"semantic-release": "catalog:",
|
"semantic-release": "catalog:",
|
||||||
"turbo": "^2"
|
"turbo": "^2.9.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1008
packages/desktop/src-tauri/Cargo.lock
generated
1008
packages/desktop/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "openconcho"
|
name = "openconcho"
|
||||||
version = "0.12.1"
|
version = "0.16.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
51
packages/web/e2e/fleet.spec.ts
Normal file
51
packages/web/e2e/fleet.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const STORE_KEY = "openconcho:instances";
|
||||||
|
|
||||||
|
// Two unreachable instances — the rows still render with their configured
|
||||||
|
// names; only the health column flips to "unreachable" once the workspaces
|
||||||
|
// query errors. We only assert on the rendered names + row count, so the
|
||||||
|
// test doesn't depend on a live backend.
|
||||||
|
const FLEET_STORE = JSON.stringify({
|
||||||
|
instances: [
|
||||||
|
{ id: "a", name: "Neo", baseUrl: "http://localhost:9001", token: "" },
|
||||||
|
{ id: "b", name: "Iris", baseUrl: "http://localhost:9002", token: "" },
|
||||||
|
{ id: "c", name: "Lexi", baseUrl: "http://localhost:9003", token: "" },
|
||||||
|
],
|
||||||
|
activeId: "a",
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Fleet route", () => {
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await context.addInitScript(
|
||||||
|
([key, value]) => {
|
||||||
|
window.localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
[STORE_KEY, FLEET_STORE],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders one row per configured instance and the Fleet heading", async ({ page }) => {
|
||||||
|
await page.goto("/fleet");
|
||||||
|
|
||||||
|
// Page header
|
||||||
|
await expect(page.getByRole("heading", { name: /^Fleet$/ })).toBeVisible();
|
||||||
|
|
||||||
|
// One row per instance, asserted via the table not the sidebar (the
|
||||||
|
// active instance's name also appears in the sidebar switcher).
|
||||||
|
const table = page.getByRole("table");
|
||||||
|
await expect(table.getByText("Neo", { exact: true })).toBeVisible();
|
||||||
|
await expect(table.getByText("Iris", { exact: true })).toBeVisible();
|
||||||
|
await expect(table.getByText("Lexi", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// 1 header row + 3 instance rows
|
||||||
|
await expect(table.getByRole("row")).toHaveCount(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fleet link in the sidebar navigates to /fleet", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("link", { name: /fleet/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/fleet$/);
|
||||||
|
await expect(page.getByRole("heading", { name: /^Fleet$/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ test.describe("Sidebar", () => {
|
|||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByRole("complementary")).toBeVisible();
|
await expect(page.getByRole("complementary")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: /dashboard/i })).toBeVisible();
|
await expect(page.getByRole("link", { name: /dashboard/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: /fleet/i })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: /workspaces/i })).toBeVisible();
|
await expect(page.getByRole("link", { name: /workspaces/i })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: /settings/i })).toBeVisible();
|
await expect(page.getByRole("link", { name: /settings/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>OpenConcho</title>
|
<title>OpenConcho</title>
|
||||||
<meta name="description" content="Frontend for self-hosted Honcho instances — browse memories, chat with memory context" />
|
<meta name="description" content="Frontend for self-hosted Honcho instances — browse memories, chat with memory context" />
|
||||||
|
<!-- Runtime config (regenerated by the Docker image at start; no-op otherwise) -->
|
||||||
|
<script src="/config.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4
packages/web/public/config.js
Normal file
4
packages/web/public/config.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Runtime configuration placeholder. In the Docker image this file is
|
||||||
|
// regenerated at container start from the OPENCONCHO_DEFAULT_HONCHO_URL env.
|
||||||
|
// In dev and the desktop build it stays a no-op.
|
||||||
|
window.__OPENCONCHO_DEFAULT_HONCHO_URL__ = "";
|
||||||
@@ -1,21 +1,12 @@
|
|||||||
import createClient from "openapi-fetch";
|
import createClient from "openapi-fetch";
|
||||||
import { loadConfig } from "@/lib/config";
|
import { loadConfig } from "@/lib/config";
|
||||||
import { httpFetch } from "@/lib/http";
|
import { dispatchFor } from "@/lib/dispatch";
|
||||||
import type { paths } from "./schema.d.ts";
|
import type { paths } from "./schema.d.ts";
|
||||||
|
|
||||||
export function createHonchoClient() {
|
export function createHonchoClient() {
|
||||||
const config = loadConfig();
|
const config = loadConfig() ?? { baseUrl: "http://localhost:8000", token: "" };
|
||||||
const baseUrl = config?.baseUrl ?? "http://localhost:8000";
|
const { baseUrl, headers, fetch } = dispatchFor(config);
|
||||||
const token = config?.token ?? "";
|
return createClient<paths>({ baseUrl, headers, fetch });
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return createClient<paths>({ baseUrl, headers, fetch: httpFetch });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const client = {
|
export const client = {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const CK = {
|
|||||||
["compare", instId, "peer-representation", wsId, pId] as const,
|
["compare", instId, "peer-representation", wsId, pId] as const,
|
||||||
peerCard: (instId: string, wsId: string, pId: string) =>
|
peerCard: (instId: string, wsId: string, pId: string) =>
|
||||||
["compare", instId, "peer-card", wsId, pId] as const,
|
["compare", instId, "peer-card", wsId, pId] as const,
|
||||||
|
queueStatus: (instId: string, wsId: string) => ["compare", instId, "queue-status", wsId] as const,
|
||||||
|
conclusionsCount: (instId: string, wsId: string) =>
|
||||||
|
["compare", instId, "conclusions-count", wsId] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useScopedWorkspaces(instance: Instance, page = 1, pageSize = 20) {
|
export function useScopedWorkspaces(instance: Instance, page = 1, pageSize = 20) {
|
||||||
@@ -83,3 +86,43 @@ export function useScopedPeerCard(instance: Instance, workspaceId: string, peerI
|
|||||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Option builders — used by both single-fetch hooks and useQueries fan-out (e.g. Fleet view).
|
||||||
|
|
||||||
|
export function scopedQueueStatusOptions(instance: Instance, workspaceId: string) {
|
||||||
|
return {
|
||||||
|
queryKey: CK.queueStatus(instance.id, workspaceId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const client = createScopedClient(instance);
|
||||||
|
const { data, error } = await client.GET("/v3/workspaces/{workspace_id}/queue/status", {
|
||||||
|
params: { path: { workspace_id: workspaceId } },
|
||||||
|
});
|
||||||
|
return data ?? err(error);
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scopedConclusionsCountOptions(instance: Instance, workspaceId: string) {
|
||||||
|
return {
|
||||||
|
queryKey: CK.conclusionsCount(instance.id, workspaceId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const client = createScopedClient(instance);
|
||||||
|
const { data, error } = await client.POST("/v3/workspaces/{workspace_id}/conclusions/list", {
|
||||||
|
params: { path: { workspace_id: workspaceId }, query: { page: 1, size: 1 } },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
return data ?? err(error);
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId),
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScopedQueueStatus(instance: Instance, workspaceId: string) {
|
||||||
|
return useQuery(scopedQueueStatusOptions(instance, workspaceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScopedConclusionsCount(instance: Instance, workspaceId: string) {
|
||||||
|
return useQuery(scopedConclusionsCountOptions(instance, workspaceId));
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function useWorkspaces(page = 1, pageSize = 20) {
|
|||||||
queryKey: QK.workspaces(page, pageSize),
|
queryKey: QK.workspaces(page, pageSize),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST("/v3/workspaces/list", {
|
const { data, error } = await client.current.POST("/v3/workspaces/list", {
|
||||||
params: { query: { page, page_size: pageSize } },
|
params: { query: { page, size: pageSize } },
|
||||||
body: {},
|
body: {},
|
||||||
});
|
});
|
||||||
return data ?? err(error);
|
return data ?? err(error);
|
||||||
@@ -139,7 +139,7 @@ export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
|
|||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/peers/list",
|
"/v3/workspaces/{workspace_id}/peers/list",
|
||||||
{
|
{
|
||||||
params: { path: { workspace_id: workspaceId }, query: { page, page_size: pageSize } },
|
params: { path: { workspace_id: workspaceId }, query: { page, size: pageSize } },
|
||||||
body: {},
|
body: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -253,7 +253,7 @@ export function usePeerSessions(workspaceId: string, peerId: string, page = 1, p
|
|||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
path: { workspace_id: workspaceId, peer_id: peerId },
|
path: { workspace_id: workspaceId, peer_id: peerId },
|
||||||
query: { page, page_size: pageSize },
|
query: { page, size: pageSize },
|
||||||
},
|
},
|
||||||
body: {},
|
body: {},
|
||||||
},
|
},
|
||||||
@@ -323,9 +323,9 @@ export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
|
|||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
path: { workspace_id: workspaceId },
|
path: { workspace_id: workspaceId },
|
||||||
query: { page, page_size: pageSize },
|
query: { page, size: pageSize },
|
||||||
},
|
},
|
||||||
body: {},
|
body: { filters: { reverse: true } },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return data ?? err(error);
|
return data ?? err(error);
|
||||||
@@ -412,7 +412,7 @@ export function useSessionMessages(
|
|||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
path: { workspace_id: workspaceId, session_id: sessionId },
|
path: { workspace_id: workspaceId, session_id: sessionId },
|
||||||
query: { page, page_size: pageSize },
|
query: { page, size: pageSize },
|
||||||
},
|
},
|
||||||
body: {},
|
body: {},
|
||||||
},
|
},
|
||||||
@@ -642,7 +642,7 @@ export function useConclusions(
|
|||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
path: { workspace_id: workspaceId },
|
path: { workspace_id: workspaceId },
|
||||||
query: { page, page_size: pageSize, reverse },
|
query: { page, size: pageSize, reverse },
|
||||||
},
|
},
|
||||||
body: filters,
|
body: filters,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import createClient from "openapi-fetch";
|
import createClient from "openapi-fetch";
|
||||||
import type { Instance } from "@/lib/config";
|
import type { Instance } from "@/lib/config";
|
||||||
import { httpFetch } from "@/lib/http";
|
import { dispatchFor } from "@/lib/dispatch";
|
||||||
import type { paths } from "./schema.d.ts";
|
import type { paths } from "./schema.d.ts";
|
||||||
|
|
||||||
export type ScopedClient = ReturnType<typeof createClient<paths>>;
|
export type ScopedClient = ReturnType<typeof createClient<paths>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an openapi-fetch client bound to a specific instance. Use for views
|
* Create an openapi-fetch client bound to a specific instance. Use for views that
|
||||||
* that need to query non-active instances (e.g. side-by-side comparison).
|
* query non-active instances (e.g. the Fleet side-by-side comparison). Each scoped
|
||||||
* For single-instance access, prefer `client.current` which tracks the active
|
* client self-routes via its own X-Honcho-Upstream header in web mode.
|
||||||
* instance via localStorage.
|
|
||||||
*/
|
*/
|
||||||
export function createScopedClient(instance: Instance): ScopedClient {
|
export function createScopedClient(instance: Instance): ScopedClient {
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const { baseUrl, headers, fetch } = dispatchFor(instance);
|
||||||
if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
|
return createClient<paths>({ baseUrl, headers, fetch });
|
||||||
return createClient<paths>({ baseUrl: instance.baseUrl, headers, fetch: httpFetch });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,159 +1,101 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
|
import { Boxes, LayoutDashboard, Network, Settings as SettingsIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQueueStatus, useWorkspaces } from "@/api/queries";
|
import {
|
||||||
import type { components } from "@/api/schema.d.ts";
|
computeFleetAggregates,
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
DEFAULT_ROW_METRICS,
|
||||||
import { Skeleton } from "@/components/shared/Skeleton";
|
type FleetRowMetrics,
|
||||||
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
} from "@/components/fleet/fleetAggregates";
|
||||||
import { useDemo } from "@/hooks/useDemo";
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { Body, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||||
|
import { useInstances } from "@/hooks/useInstances";
|
||||||
|
import type { Instance } from "@/lib/config";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
import { formatCount } from "@/lib/utils";
|
import { formatCount } from "@/lib/utils";
|
||||||
|
import { ServerWorkspaceRows } from "./ServerWorkspaceRows";
|
||||||
|
|
||||||
type QueueStatus = components["schemas"]["QueueStatus"];
|
const ALL_SERVERS = "all";
|
||||||
|
|
||||||
// ─── Per-workspace queue row ─────────────────────────────────────────────────
|
/**
|
||||||
|
* Unified, server-aware dashboard: every workspace across every configured server,
|
||||||
|
* labelled `<workspace> (<server>)` and filterable by server. Aggregates fold in the
|
||||||
|
* cross-server totals (formerly the standalone Fleet view). Opening a workspace
|
||||||
|
* activates its server, then drills into the existing workspace detail route.
|
||||||
|
*/
|
||||||
|
export function Dashboard() {
|
||||||
|
const { instances, activeId, activate } = useInstances();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [serverFilter, setServerFilter] = useState<string>(ALL_SERVERS);
|
||||||
|
const [metricsById, setMetricsById] = useState<Record<string, FleetRowMetrics>>({});
|
||||||
|
const lastMetrics = useRef<Record<string, FleetRowMetrics>>({});
|
||||||
|
|
||||||
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
|
useEffect(() => {
|
||||||
const { mask } = useDemo();
|
if (serverFilter !== ALL_SERVERS && !instances.find((i) => i.id === serverFilter)) {
|
||||||
const { data, isLoading } = useQueueStatus(workspaceId);
|
setServerFilter(ALL_SERVERS);
|
||||||
|
}
|
||||||
|
}, [instances, serverFilter]);
|
||||||
|
|
||||||
const pending = data?.pending_work_units ?? 0;
|
const onMetrics = useCallback((id: string, m: FleetRowMetrics) => {
|
||||||
const active = data?.in_progress_work_units ?? 0;
|
const prev = lastMetrics.current[id];
|
||||||
const done = data?.completed_work_units ?? 0;
|
if (
|
||||||
const total = data?.total_work_units ?? 0;
|
prev &&
|
||||||
const isActive = active > 0 || pending > 0;
|
prev.workspaceCount === m.workspaceCount &&
|
||||||
|
prev.conclusionCount === m.conclusionCount &&
|
||||||
|
prev.queueActive === m.queueActive &&
|
||||||
|
prev.queuePending === m.queuePending &&
|
||||||
|
prev.health === m.health
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
lastMetrics.current = { ...lastMetrics.current, [id]: m };
|
||||||
|
setMetricsById((prev) => ({ ...prev, [id]: m }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onOpenWorkspace = useCallback(
|
||||||
|
(instance: Instance, workspaceId: string) => {
|
||||||
|
if (instance.id !== activeId) activate(instance.id);
|
||||||
|
navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } as never });
|
||||||
|
},
|
||||||
|
[activeId, activate, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const shownInstances = useMemo(
|
||||||
|
() =>
|
||||||
|
serverFilter === ALL_SERVERS ? instances : instances.filter((i) => i.id === serverFilter),
|
||||||
|
[instances, serverFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const agg = useMemo(
|
||||||
|
() =>
|
||||||
|
computeFleetAggregates(shownInstances.map((i) => metricsById[i.id] ?? DEFAULT_ROW_METRICS)),
|
||||||
|
[shownInstances, metricsById],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (instances.length === 0) {
|
||||||
return (
|
return (
|
||||||
<tr
|
<div className="page-container page-container--xl">
|
||||||
|
<EmptyState
|
||||||
|
icon={Boxes}
|
||||||
|
title="No servers configured"
|
||||||
|
description="Add at least one Honcho server in Settings to see your workspaces."
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md"
|
||||||
style={{
|
style={{
|
||||||
borderTop: "1px solid var(--border)",
|
background: "var(--accent-dim)",
|
||||||
background: isActive ? COLOR.warningDim : undefined,
|
border: "1px solid var(--accent-border)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td className="py-2 px-4">
|
<SettingsIcon className="w-4 h-4" strokeWidth={1.5} />
|
||||||
<Link
|
Go to Settings
|
||||||
to="/workspaces/$workspaceId"
|
|
||||||
params={{ workspaceId } as never}
|
|
||||||
className="flex items-center gap-2 group"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
|
|
||||||
style={{ color: "var(--accent-text)" }}
|
|
||||||
>
|
|
||||||
{mask(workspaceId)}
|
|
||||||
</span>
|
|
||||||
<ChevronRight
|
|
||||||
className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
}
|
||||||
|
/>
|
||||||
<td className="py-2 px-4 text-right">
|
|
||||||
{isLoading ? (
|
|
||||||
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
|
||||||
…
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
|
||||||
{isActive ? (
|
|
||||||
<motion.div
|
|
||||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
|
||||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
|
|
||||||
>
|
|
||||||
<CircleDot className="w-3 h-3" style={{ color: COLOR.warning }} strokeWidth={2} />
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<CircleDot className="w-3 h-3" style={{ color: COLOR.success }} strokeWidth={2} />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="text-xs font-medium"
|
|
||||||
style={{ color: isActive ? COLOR.warning : COLOR.success }}
|
|
||||||
>
|
|
||||||
{isActive ? `${formatCount(pending + active)} pending` : "Idle"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
{ key: "total", val: total, color: "var(--text-2)" },
|
|
||||||
{ key: "done", val: done, color: COLOR.success },
|
|
||||||
{ key: "active", val: active, color: COLOR.warning },
|
|
||||||
{ key: "pending", val: pending, color: "var(--text-3)" },
|
|
||||||
] satisfies Array<{ key: string; val: number; color: string }>
|
|
||||||
).map(({ key, val, color }) => (
|
|
||||||
<td
|
|
||||||
key={key}
|
|
||||||
className="py-2 px-4 text-right font-mono text-xs"
|
|
||||||
style={{ color: isLoading ? "var(--text-4)" : color }}
|
|
||||||
>
|
|
||||||
{isLoading ? "—" : formatCount(val)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Aggregate banner ─────────────────────────────────────────────────────────
|
|
||||||
// Each workspace row already called useQueueStatus — TanStack Query deduplicates
|
|
||||||
// the fetches so calling the same hooks here just reads from cache.
|
|
||||||
|
|
||||||
function GlobalQueueBanner({ workspaces }: { workspaces: Array<{ id: string }> }) {
|
|
||||||
const statuses = workspaces.map((ws) => {
|
|
||||||
const { data } = useQueueStatus(ws.id);
|
|
||||||
return data as QueueStatus | undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPending = statuses.reduce((s, d) => s + (d?.pending_work_units ?? 0), 0);
|
|
||||||
const totalActive = statuses.reduce((s, d) => s + (d?.in_progress_work_units ?? 0), 0);
|
|
||||||
const totalDone = statuses.reduce((s, d) => s + (d?.completed_work_units ?? 0), 0);
|
|
||||||
const allLoaded = statuses.every((d) => d !== undefined);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
{ label: "Workspaces", value: workspaces.length, color: "var(--text-1)", always: true },
|
|
||||||
{ label: "Total done", value: totalDone, color: COLOR.success, always: false },
|
|
||||||
{ label: "Active", value: totalActive, color: COLOR.warning, always: false },
|
|
||||||
{
|
|
||||||
label: "Pending",
|
|
||||||
value: totalPending,
|
|
||||||
color: totalPending > 0 ? COLOR.warning : "var(--text-3)",
|
|
||||||
always: false,
|
|
||||||
},
|
|
||||||
] as Array<{ label: string; value: number; color: string; always: boolean }>
|
|
||||||
).map(({ label, value, color, always }) => (
|
|
||||||
<div key={label} className="rounded-xl p-4 theme-card">
|
|
||||||
<div
|
|
||||||
className="text-2xl font-semibold font-mono"
|
|
||||||
style={{ color: allLoaded || always ? color : "var(--text-4)" }}
|
|
||||||
>
|
|
||||||
{allLoaded || always ? formatCount(value) : "—"}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main dashboard ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function Dashboard() {
|
|
||||||
const [page] = useState(1);
|
|
||||||
const { data, isLoading, error } = useWorkspaces(page, 50);
|
|
||||||
|
|
||||||
const workspaces =
|
|
||||||
(data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
|
|
||||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container page-container--xl">
|
<div className="page-container page-container--xl">
|
||||||
@@ -165,7 +107,6 @@ export function Dashboard() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
<PageTitle>Dashboard</PageTitle>
|
<PageTitle>Dashboard</PageTitle>
|
||||||
{total > 0 && (
|
|
||||||
<span
|
<span
|
||||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
@@ -174,28 +115,33 @@ export function Dashboard() {
|
|||||||
border: `1px solid ${COLOR.accentBorder}`,
|
border: `1px solid ${COLOR.accentBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{total} workspace{total !== 1 ? "s" : ""}
|
{agg.totalInstances} server{agg.totalInstances !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Body className="leading-none">Overview of your Honcho instance</Body>
|
<Body className="leading-none">Workspaces across every configured server</Body>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
|
||||||
{isLoading && <DashboardSkeleton />}
|
|
||||||
|
|
||||||
{!isLoading && workspaces.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Aggregate stat row */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.05 }}
|
transition={{ delay: 0.05 }}
|
||||||
|
className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4"
|
||||||
>
|
>
|
||||||
<GlobalQueueBanner workspaces={workspaces} />
|
<MetricCard label="Workspaces" value={agg.totalWorkspaces} />
|
||||||
|
<MetricCard label="Conclusions" value={agg.totalConclusions} accent />
|
||||||
|
<MetricCard
|
||||||
|
label="Healthy"
|
||||||
|
value={agg.healthyCount}
|
||||||
|
total={agg.totalInstances}
|
||||||
|
color={agg.healthyCount === agg.totalInstances ? COLOR.success : COLOR.warning}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Unreachable"
|
||||||
|
value={agg.unreachableCount}
|
||||||
|
color={agg.unreachableCount > 0 ? COLOR.destructive : "var(--text-3)"}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Per-workspace queue table */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -206,122 +152,91 @@ export function Dashboard() {
|
|||||||
className="flex items-center gap-2 px-4 py-3"
|
className="flex items-center gap-2 px-4 py-3"
|
||||||
style={{ borderBottom: "1px solid var(--border)" }}
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
>
|
>
|
||||||
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<Network className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
<SectionHeading className="mb-0">Queue Status</SectionHeading>
|
<SectionHeading className="mb-0">Workspaces</SectionHeading>
|
||||||
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
|
{instances.length > 1 && (
|
||||||
all workspaces · live polling
|
<label className="ml-auto flex items-center gap-1.5 text-xs">
|
||||||
</span>
|
<span style={{ color: "var(--text-4)" }}>Server</span>
|
||||||
|
<select
|
||||||
|
aria-label="Filter by server"
|
||||||
|
value={serverFilter}
|
||||||
|
onChange={(e) => setServerFilter(e.target.value)}
|
||||||
|
className="rounded-md px-2 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-3)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={ALL_SERVERS}>All servers</option>
|
||||||
|
{instances.map((i) => (
|
||||||
|
<option key={i.id} value={i.id}>
|
||||||
|
{i.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: "var(--bg-3)" }}>
|
<tr style={{ background: "var(--bg-3)" }}>
|
||||||
{["Workspace", "Status", "Total", "Done", "Active", "Pending"].map((h) => (
|
<th className="py-2 px-4 font-medium text-left" style={{ color: "var(--text-3)" }}>
|
||||||
<th
|
Workspace (server)
|
||||||
key={h}
|
</th>
|
||||||
className={`py-2 px-4 font-medium text-left ${h !== "Workspace" && h !== "Status" ? "text-right" : ""}`}
|
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
|
||||||
style={{ color: "var(--text-3)" }}
|
Conclusions
|
||||||
>
|
</th>
|
||||||
{h}
|
<th
|
||||||
|
className="py-2 px-4 font-medium text-right"
|
||||||
|
style={{ color: "var(--text-3)" }}
|
||||||
|
title="Active / Pending queue work units"
|
||||||
|
>
|
||||||
|
Queue (a/p)
|
||||||
</th>
|
</th>
|
||||||
))}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{workspaces.map((ws) => (
|
{shownInstances.map((inst) => (
|
||||||
<WorkspaceQueueRow key={ws.id} workspaceId={ws.id} />
|
<ServerWorkspaceRows
|
||||||
|
key={inst.id}
|
||||||
|
instance={inst}
|
||||||
|
onOpenWorkspace={onOpenWorkspace}
|
||||||
|
onMetrics={onMetrics}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{total > workspaces.length && (
|
|
||||||
<p className="text-xs text-center" style={{ color: "var(--text-4)" }}>
|
|
||||||
Showing {workspaces.length} of {total} workspaces.{" "}
|
|
||||||
<Link
|
|
||||||
to="/workspaces"
|
|
||||||
className="hover:underline"
|
|
||||||
style={{ color: "var(--accent-text)" }}
|
|
||||||
>
|
|
||||||
View all →
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && workspaces.length === 0 && (
|
|
||||||
<div className="rounded-xl p-10 text-center theme-card">
|
|
||||||
<Boxes
|
|
||||||
className="w-8 h-8 mx-auto mb-3"
|
|
||||||
style={{ color: "var(--text-4)" }}
|
|
||||||
strokeWidth={1}
|
|
||||||
/>
|
|
||||||
<Muted>No workspaces found.</Muted>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardSkeleton() {
|
interface MetricCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
total?: number;
|
||||||
|
color?: string;
|
||||||
|
accent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ label, value, total, color, accent }: MetricCardProps) {
|
||||||
|
const valueColor = color ?? (accent ? COLOR.accentText : "var(--text-1)");
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4" aria-hidden="true">
|
<div className="rounded-xl p-4 theme-card">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="text-2xl font-semibold font-mono" style={{ color: valueColor }}>
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
{formatCount(value)}
|
||||||
<div key={index} className="rounded-xl p-4 theme-card">
|
{total !== undefined && (
|
||||||
<Skeleton accent={index === 0} className="h-8 w-16 rounded-lg" />
|
<span className="text-base ml-1" style={{ color: "var(--text-4)" }}>
|
||||||
<Skeleton className="mt-3 h-3 w-20 rounded" />
|
/ {formatCount(total)}
|
||||||
</div>
|
</span>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl theme-card overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-4 py-3"
|
|
||||||
style={{ borderBottom: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
<Skeleton accent className="h-4 w-4 rounded" />
|
|
||||||
<Skeleton className="h-4 w-28 rounded" />
|
|
||||||
<Skeleton className="ml-1 h-3 w-32 rounded" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr style={{ background: "var(--bg-3)" }}>
|
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
|
||||||
<th key={index} className="px-4 py-2 text-left">
|
|
||||||
<Skeleton className="h-3 w-14 rounded" />
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{Array.from({ length: 5 }).map((_, rowIndex) => (
|
|
||||||
<tr key={rowIndex} style={{ borderTop: "1px solid var(--border)" }}>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Skeleton accent className="h-3 w-28 rounded" />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Skeleton className="h-3 w-20 rounded" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{Array.from({ length: 4 }).map((__, cellIndex) => (
|
|
||||||
<td key={cellIndex} className="px-4 py-3">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Skeleton className="h-3 w-8 rounded" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||||
|
{label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
191
packages/web/src/components/dashboard/ServerWorkspaceRows.tsx
Normal file
191
packages/web/src/components/dashboard/ServerWorkspaceRows.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useQueries } from "@tanstack/react-query";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ChevronRight, CircleDot } from "lucide-react";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
scopedConclusionsCountOptions,
|
||||||
|
scopedQueueStatusOptions,
|
||||||
|
useScopedWorkspaces,
|
||||||
|
} from "@/api/compareQueries";
|
||||||
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import type { FleetRowMetrics } from "@/components/fleet/fleetAggregates";
|
||||||
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
|
import type { Instance } from "@/lib/config";
|
||||||
|
import { COLOR } from "@/lib/constants";
|
||||||
|
import { formatCount } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Workspace = components["schemas"]["Workspace"];
|
||||||
|
type QueueStatus = components["schemas"]["QueueStatus"];
|
||||||
|
type ConclusionPage = components["schemas"]["Page_Conclusion_"];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instance: Instance;
|
||||||
|
/** Open a workspace's drill-down (activates the instance first if needed). */
|
||||||
|
onOpenWorkspace: (instance: Instance, workspaceId: string) => void;
|
||||||
|
/** Report this server's summed metrics up for the aggregate header. */
|
||||||
|
onMetrics: (id: string, metrics: FleetRowMetrics) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one `<tr>` per workspace on a single server (instance), labelled
|
||||||
|
* `<workspace> (<server>)`, and reports the server's summed metrics up so the
|
||||||
|
* Dashboard header can aggregate across servers. Per-instance data fetching lives
|
||||||
|
* here (not in a parent loop) to satisfy the rules of hooks — one child per server.
|
||||||
|
*/
|
||||||
|
export function ServerWorkspaceRows({ instance, onOpenWorkspace, onMetrics }: Props) {
|
||||||
|
const { mask } = useDemo();
|
||||||
|
const workspacesQ = useScopedWorkspaces(instance, 1, WORKSPACE_PAGE_SIZE);
|
||||||
|
|
||||||
|
const workspaces: Workspace[] = useMemo(
|
||||||
|
() => (workspacesQ.data as { items?: Workspace[] } | undefined)?.items ?? [],
|
||||||
|
[workspacesQ.data],
|
||||||
|
);
|
||||||
|
const totalWorkspaces =
|
||||||
|
(workspacesQ.data as { total?: number } | undefined)?.total ?? workspaces.length;
|
||||||
|
|
||||||
|
const queueResults = useQueries({
|
||||||
|
queries: workspaces.map((ws) => scopedQueueStatusOptions(instance, ws.id)),
|
||||||
|
});
|
||||||
|
const conclusionsResults = useQueries({
|
||||||
|
queries: workspaces.map((ws) => scopedConclusionsCountOptions(instance, ws.id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const queueActive = queueResults.reduce(
|
||||||
|
(s, q) => s + ((q.data as QueueStatus | undefined)?.in_progress_work_units ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const queuePending = queueResults.reduce(
|
||||||
|
(s, q) => s + ((q.data as QueueStatus | undefined)?.pending_work_units ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const conclusionCount = conclusionsResults.reduce(
|
||||||
|
(s, c) => s + ((c.data as ConclusionPage | undefined)?.total ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const health: FleetRowMetrics["health"] = workspacesQ.isError
|
||||||
|
? "unreachable"
|
||||||
|
: workspacesQ.isSuccess
|
||||||
|
? "ok"
|
||||||
|
: "loading";
|
||||||
|
// Dep array uses primitives only — an object dep (e.g. the metrics shape) would
|
||||||
|
// create a new reference on each render even when values are unchanged, causing
|
||||||
|
// onMetrics → setMetricsById → re-render → new object → onMetrics … loop.
|
||||||
|
useEffect(() => {
|
||||||
|
onMetrics(instance.id, {
|
||||||
|
workspaceCount: totalWorkspaces,
|
||||||
|
conclusionCount,
|
||||||
|
queueActive,
|
||||||
|
queuePending,
|
||||||
|
lastSeen: null,
|
||||||
|
health,
|
||||||
|
});
|
||||||
|
}, [instance.id, totalWorkspaces, conclusionCount, queueActive, queuePending, health, onMetrics]);
|
||||||
|
|
||||||
|
if (workspacesQ.isError) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
style={{ borderTop: "1px solid var(--border)" }}
|
||||||
|
data-testid={`server-error-${instance.id}`}
|
||||||
|
>
|
||||||
|
<td className="py-2.5 px-4" colSpan={3}>
|
||||||
|
<span className="text-xs" style={{ color: COLOR.destructive }}>
|
||||||
|
{instance.name} — unreachable
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspaces.length === 0) {
|
||||||
|
return (
|
||||||
|
<tr style={{ borderTop: "1px solid var(--border)" }}>
|
||||||
|
<td className="py-2.5 px-4" colSpan={3}>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||||
|
{instance.name} — {workspacesQ.isLoading ? "loading…" : "no workspaces"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{workspaces.map((ws, i) => {
|
||||||
|
const queue = queueResults[i]?.data as QueueStatus | undefined;
|
||||||
|
const active = queue?.in_progress_work_units ?? 0;
|
||||||
|
const pending = queue?.pending_work_units ?? 0;
|
||||||
|
const isActive = active > 0 || pending > 0;
|
||||||
|
const conclusions = (conclusionsResults[i]?.data as ConclusionPage | undefined)?.total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${instance.id}:${ws.id}`}
|
||||||
|
data-testid={`ws-row-${instance.id}-${ws.id}`}
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
background: isActive ? COLOR.warningDim : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="py-2 px-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenWorkspace(instance, ws.id)}
|
||||||
|
className="flex items-center gap-2 group text-left"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
|
||||||
|
style={{ color: "var(--accent-text)" }}
|
||||||
|
>
|
||||||
|
{mask(ws.id)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||||
|
({instance.name})
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td
|
||||||
|
className="py-2 px-4 text-right font-mono text-xs"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
|
{conclusions === undefined ? "—" : formatCount(conclusions)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="py-2 px-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{isActive ? (
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||||
|
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
|
||||||
|
>
|
||||||
|
<CircleDot
|
||||||
|
className="w-3 h-3"
|
||||||
|
style={{ color: COLOR.warning }}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<CircleDot className="w-3 h-3" style={{ color: COLOR.success }} strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium font-mono"
|
||||||
|
style={{ color: isActive ? COLOR.warning : "var(--text-3)" }}
|
||||||
|
>
|
||||||
|
{isActive ? `${formatCount(active)}/${formatCount(pending)}` : "idle"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
packages/web/src/components/fleet/FleetDashboard.tsx
Normal file
177
packages/web/src/components/fleet/FleetDashboard.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Network, Server, Settings as SettingsIcon } from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { Body, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||||
|
import { useInstances } from "@/hooks/useInstances";
|
||||||
|
import { COLOR } from "@/lib/constants";
|
||||||
|
import { formatCount } from "@/lib/utils";
|
||||||
|
import { FleetRow } from "./FleetRow";
|
||||||
|
import {
|
||||||
|
computeFleetAggregates,
|
||||||
|
DEFAULT_ROW_METRICS,
|
||||||
|
type FleetRowMetrics,
|
||||||
|
} from "./fleetAggregates";
|
||||||
|
|
||||||
|
export function FleetDashboard() {
|
||||||
|
const { instances } = useInstances();
|
||||||
|
const [metricsById, setMetricsById] = useState<Record<string, FleetRowMetrics>>({});
|
||||||
|
|
||||||
|
const setMetrics = useCallback((id: string, m: FleetRowMetrics) => {
|
||||||
|
setMetricsById((prev) => ({ ...prev, [id]: m }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() => instances.map((i) => metricsById[i.id] ?? DEFAULT_ROW_METRICS),
|
||||||
|
[instances, metricsById],
|
||||||
|
);
|
||||||
|
const agg = useMemo(() => computeFleetAggregates(rows), [rows]);
|
||||||
|
|
||||||
|
if (instances.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="page-container page-container--xl">
|
||||||
|
<EmptyState
|
||||||
|
icon={Network}
|
||||||
|
title="No instances configured"
|
||||||
|
description="Add at least one Honcho instance in Settings to use the Fleet view."
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md"
|
||||||
|
style={{
|
||||||
|
background: "var(--accent-dim)",
|
||||||
|
border: "1px solid var(--accent-border)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="w-4 h-4" strokeWidth={1.5} />
|
||||||
|
Go to Settings
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container page-container--xl">
|
||||||
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Network className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
<PageTitle>Fleet</PageTitle>
|
||||||
|
<span
|
||||||
|
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: COLOR.accentSubtle,
|
||||||
|
color: COLOR.accentText,
|
||||||
|
border: `1px solid ${COLOR.accentBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agg.totalInstances} agent{agg.totalInstances !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Body className="leading-none">Cross-instance overview of all configured agents</Body>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.05 }}
|
||||||
|
className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4"
|
||||||
|
>
|
||||||
|
<MetricCard label="Workspaces" value={agg.totalWorkspaces} />
|
||||||
|
<MetricCard label="Conclusions" value={agg.totalConclusions} accent />
|
||||||
|
<MetricCard
|
||||||
|
label="Healthy"
|
||||||
|
value={agg.healthyCount}
|
||||||
|
total={agg.totalInstances}
|
||||||
|
color={agg.healthyCount === agg.totalInstances ? COLOR.success : COLOR.warning}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Unreachable"
|
||||||
|
value={agg.unreachableCount}
|
||||||
|
color={agg.unreachableCount > 0 ? COLOR.destructive : "var(--text-3)"}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.12 }}
|
||||||
|
className="rounded-xl theme-card overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-3"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<Server className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
<SectionHeading className="mb-0">Agents</SectionHeading>
|
||||||
|
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
|
||||||
|
all configured instances · queue updates every 10s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: "var(--bg-3)" }}>
|
||||||
|
<th className="py-2 px-4 font-medium text-left" style={{ color: "var(--text-3)" }}>
|
||||||
|
Agent
|
||||||
|
</th>
|
||||||
|
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
|
||||||
|
Workspaces
|
||||||
|
</th>
|
||||||
|
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
|
||||||
|
Conclusions
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="py-2 px-4 font-medium text-right"
|
||||||
|
style={{ color: "var(--text-3)" }}
|
||||||
|
title="Active / Pending queue work units"
|
||||||
|
>
|
||||||
|
Queue (a/p)
|
||||||
|
</th>
|
||||||
|
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
|
||||||
|
Last seen
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{instances.map((inst) => (
|
||||||
|
<FleetRow key={inst.id} instance={inst} onMetrics={setMetrics} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
total?: number;
|
||||||
|
color?: string;
|
||||||
|
accent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ label, value, total, color, accent }: MetricCardProps) {
|
||||||
|
const valueColor = color ?? (accent ? COLOR.accentText : "var(--text-1)");
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl p-4 theme-card">
|
||||||
|
<div className="text-2xl font-semibold font-mono" style={{ color: valueColor }}>
|
||||||
|
{formatCount(value)}
|
||||||
|
{total !== undefined && (
|
||||||
|
<span className="text-base ml-1" style={{ color: "var(--text-4)" }}>
|
||||||
|
/ {formatCount(total)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
packages/web/src/components/fleet/FleetRow.tsx
Normal file
192
packages/web/src/components/fleet/FleetRow.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useQueries } from "@tanstack/react-query";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { CircleDot } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import {
|
||||||
|
scopedConclusionsCountOptions,
|
||||||
|
scopedQueueStatusOptions,
|
||||||
|
useScopedWorkspaces,
|
||||||
|
} from "@/api/compareQueries";
|
||||||
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import { HealthDot } from "@/components/shared/HealthDot";
|
||||||
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
|
import type { Instance } from "@/lib/config";
|
||||||
|
import { COLOR } from "@/lib/constants";
|
||||||
|
import { formatCount } from "@/lib/utils";
|
||||||
|
import type { FleetRowMetrics } from "./fleetAggregates";
|
||||||
|
|
||||||
|
type Workspace = components["schemas"]["Workspace"];
|
||||||
|
type QueueStatus = components["schemas"]["QueueStatus"];
|
||||||
|
type ConclusionPage = components["schemas"]["Page_Conclusion_"];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instance: Instance;
|
||||||
|
onMetrics: (id: string, metrics: FleetRowMetrics) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shallowEqualMetrics(a: FleetRowMetrics, b: FleetRowMetrics): boolean {
|
||||||
|
return (
|
||||||
|
a.workspaceCount === b.workspaceCount &&
|
||||||
|
a.conclusionCount === b.conclusionCount &&
|
||||||
|
a.queueActive === b.queueActive &&
|
||||||
|
a.queuePending === b.queuePending &&
|
||||||
|
a.lastSeen === b.lastSeen &&
|
||||||
|
a.health === b.health
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(ts: number | null): string {
|
||||||
|
if (!ts) return "—";
|
||||||
|
const seconds = Math.round((Date.now() - ts) / 1000);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FleetRow({ instance, onMetrics }: Props) {
|
||||||
|
const { mask } = useDemo();
|
||||||
|
const workspacesQ = useScopedWorkspaces(instance, 1, 100);
|
||||||
|
|
||||||
|
const workspaces: Workspace[] = useMemo(
|
||||||
|
() => (workspacesQ.data as { items?: Workspace[] } | undefined)?.items ?? [],
|
||||||
|
[workspacesQ.data],
|
||||||
|
);
|
||||||
|
const totalWorkspaces =
|
||||||
|
(workspacesQ.data as { total?: number } | undefined)?.total ?? workspaces.length;
|
||||||
|
|
||||||
|
const queueResults = useQueries({
|
||||||
|
queries: workspaces.map((ws) => scopedQueueStatusOptions(instance, ws.id)),
|
||||||
|
});
|
||||||
|
const conclusionsResults = useQueries({
|
||||||
|
queries: workspaces.map((ws) => scopedConclusionsCountOptions(instance, ws.id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const queueActive = queueResults.reduce(
|
||||||
|
(s, q) => s + ((q.data as QueueStatus | undefined)?.in_progress_work_units ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const queuePending = queueResults.reduce(
|
||||||
|
(s, q) => s + ((q.data as QueueStatus | undefined)?.pending_work_units ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const conclusionCount = conclusionsResults.reduce(
|
||||||
|
(s, c) => s + ((c.data as ConclusionPage | undefined)?.total ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const health: FleetRowMetrics["health"] = workspacesQ.isError
|
||||||
|
? "unreachable"
|
||||||
|
: workspacesQ.isSuccess
|
||||||
|
? "ok"
|
||||||
|
: "loading";
|
||||||
|
const lastSeen = workspacesQ.dataUpdatedAt > 0 ? workspacesQ.dataUpdatedAt : null;
|
||||||
|
|
||||||
|
const isActive = queueActive > 0 || queuePending > 0;
|
||||||
|
const isLoading =
|
||||||
|
workspacesQ.isLoading ||
|
||||||
|
queueResults.some((q) => q.isLoading) ||
|
||||||
|
conclusionsResults.some((c) => c.isLoading);
|
||||||
|
|
||||||
|
const metrics: FleetRowMetrics = useMemo(
|
||||||
|
() => ({
|
||||||
|
workspaceCount: totalWorkspaces,
|
||||||
|
conclusionCount,
|
||||||
|
queueActive,
|
||||||
|
queuePending,
|
||||||
|
lastSeen,
|
||||||
|
health,
|
||||||
|
}),
|
||||||
|
[totalWorkspaces, conclusionCount, queueActive, queuePending, lastSeen, health],
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastReported = useRef<FleetRowMetrics | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastReported.current && shallowEqualMetrics(lastReported.current, metrics)) return;
|
||||||
|
lastReported.current = metrics;
|
||||||
|
onMetrics(instance.id, metrics);
|
||||||
|
}, [instance.id, metrics, onMetrics]);
|
||||||
|
|
||||||
|
const hostname = instance.baseUrl.replace(/^https?:\/\//, "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-testid={`fleet-row-${instance.id}`}
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
background: isActive ? COLOR.warningDim : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="py-2.5 px-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<HealthDot
|
||||||
|
status={health === "ok" ? "ok" : health === "unreachable" ? "unreachable" : "checking"}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate" style={{ color: "var(--text-1)" }}>
|
||||||
|
{instance.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xs font-mono truncate max-w-[16rem]"
|
||||||
|
style={{ color: "var(--text-4)" }}
|
||||||
|
title={mask(hostname)}
|
||||||
|
>
|
||||||
|
{mask(hostname)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="py-2.5 px-4 text-right font-mono text-xs" style={{ color: "var(--text-2)" }}>
|
||||||
|
{workspacesQ.isLoading ? "—" : formatCount(totalWorkspaces)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="py-2.5 px-4 text-right font-mono text-xs" style={{ color: "var(--text-2)" }}>
|
||||||
|
{isLoading ? "—" : formatCount(conclusionCount)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="py-2.5 px-4 text-right">
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{isActive ? (
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||||
|
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
|
||||||
|
>
|
||||||
|
<CircleDot className="w-3 h-3" style={{ color: COLOR.warning }} strokeWidth={2} />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<CircleDot
|
||||||
|
className="w-3 h-3"
|
||||||
|
style={{ color: health === "ok" ? COLOR.success : "var(--text-4)" }}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium font-mono"
|
||||||
|
style={{ color: isActive ? COLOR.warning : "var(--text-3)" }}
|
||||||
|
>
|
||||||
|
{isActive ? `${formatCount(queueActive)}/${formatCount(queuePending)}` : "idle"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td
|
||||||
|
className="py-2.5 px-4 text-right text-xs font-mono"
|
||||||
|
style={{ color: "var(--text-4)" }}
|
||||||
|
title={lastSeen ? new Date(lastSeen).toLocaleString() : undefined}
|
||||||
|
>
|
||||||
|
{health === "unreachable" ? "unreachable" : formatRelative(lastSeen)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
packages/web/src/components/fleet/fleetAggregates.ts
Normal file
43
packages/web/src/components/fleet/fleetAggregates.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export type FleetHealth = "ok" | "unreachable" | "loading";
|
||||||
|
|
||||||
|
export interface FleetRowMetrics {
|
||||||
|
workspaceCount: number;
|
||||||
|
conclusionCount: number;
|
||||||
|
queueActive: number;
|
||||||
|
queuePending: number;
|
||||||
|
lastSeen: number | null;
|
||||||
|
health: FleetHealth;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetAggregates {
|
||||||
|
totalInstances: number;
|
||||||
|
totalWorkspaces: number;
|
||||||
|
totalConclusions: number;
|
||||||
|
totalQueueActive: number;
|
||||||
|
totalQueuePending: number;
|
||||||
|
healthyCount: number;
|
||||||
|
unreachableCount: number;
|
||||||
|
loadingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ROW_METRICS: FleetRowMetrics = {
|
||||||
|
workspaceCount: 0,
|
||||||
|
conclusionCount: 0,
|
||||||
|
queueActive: 0,
|
||||||
|
queuePending: 0,
|
||||||
|
lastSeen: null,
|
||||||
|
health: "loading",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function computeFleetAggregates(rows: FleetRowMetrics[]): FleetAggregates {
|
||||||
|
return {
|
||||||
|
totalInstances: rows.length,
|
||||||
|
totalWorkspaces: rows.reduce((s, r) => s + r.workspaceCount, 0),
|
||||||
|
totalConclusions: rows.reduce((s, r) => s + r.conclusionCount, 0),
|
||||||
|
totalQueueActive: rows.reduce((s, r) => s + r.queueActive, 0),
|
||||||
|
totalQueuePending: rows.reduce((s, r) => s + r.queuePending, 0),
|
||||||
|
healthyCount: rows.filter((r) => r.health === "ok").length,
|
||||||
|
unreachableCount: rows.filter((r) => r.health === "unreachable").length,
|
||||||
|
loadingCount: rows.filter((r) => r.health === "loading").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,7 +15,11 @@ const KNOWN_SECTIONS = new Set(Object.keys(SECTION_LABELS));
|
|||||||
|
|
||||||
type Segment = { label: string; href: string | null; mono?: boolean };
|
type Segment = { label: string; href: string | null; mono?: boolean };
|
||||||
|
|
||||||
function buildSegments(pathname: string, mask: (v: string) => string): Segment[] {
|
function buildSegments(
|
||||||
|
pathname: string,
|
||||||
|
mask: (v: string) => string,
|
||||||
|
labels: Record<string, string>,
|
||||||
|
): Segment[] {
|
||||||
if (!pathname.startsWith("/workspaces")) return [];
|
if (!pathname.startsWith("/workspaces")) return [];
|
||||||
|
|
||||||
const rest = pathname.slice("/workspaces".length); // "" | "/wid" | "/wid/peers" | ...
|
const rest = pathname.slice("/workspaces".length); // "" | "/wid" | "/wid/peers" | ...
|
||||||
@@ -46,14 +50,20 @@ function buildSegments(pathname: string, mask: (v: string) => string): Segment[]
|
|||||||
const subId = parts[2];
|
const subId = parts[2];
|
||||||
if (!subId) return segments;
|
if (!subId) return segments;
|
||||||
|
|
||||||
|
// A friendly label override (e.g. a peer's display_name) renders in place of
|
||||||
|
// the raw id and drops the mono styling reserved for ids.
|
||||||
|
const override = labels[subId];
|
||||||
|
const subLabel = mask(override ?? subId);
|
||||||
|
const subMono = override === undefined;
|
||||||
|
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
segments.push({ label: mask(subId), href: null, mono: true });
|
segments.push({ label: subLabel, href: null, mono: subMono });
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
segments.push({
|
segments.push({
|
||||||
label: mask(subId),
|
label: subLabel,
|
||||||
href: `/workspaces/${wid}/${section}/${subId}`,
|
href: `/workspaces/${wid}/${section}/${subId}`,
|
||||||
mono: true,
|
mono: subMono,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subSection = parts[3];
|
const subSection = parts[3];
|
||||||
@@ -64,10 +74,10 @@ function buildSegments(pathname: string, mask: (v: string) => string): Segment[]
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Breadcrumb() {
|
export function Breadcrumb({ labels = {} }: { labels?: Record<string, string> } = {}) {
|
||||||
const { state } = useRouter();
|
const { state } = useRouter();
|
||||||
const { mask } = useDemo();
|
const { mask } = useDemo();
|
||||||
const segments = buildSegments(state.location.pathname, mask);
|
const segments = buildSegments(state.location.pathname, mask, labels);
|
||||||
|
|
||||||
if (segments.length <= 1) return null;
|
if (segments.length <= 1) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ function useLastDataUpdate(): string {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function refresh() {
|
function refresh() {
|
||||||
setNow(Date.now());
|
// No setNow here — calling setNow on every cache event causes a render loop on
|
||||||
|
// CI (each Date.now() call crosses a ms boundary → new value → React re-renders
|
||||||
|
// Sidebar → cache events fire again → loop). setNow belongs only in the interval.
|
||||||
const latest = queryClient
|
const latest = queryClient
|
||||||
.getQueryCache()
|
.getQueryCache()
|
||||||
.getAll()
|
.getAll()
|
||||||
@@ -70,7 +72,10 @@ function useLastDataUpdate(): string {
|
|||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
const unsubscribe = queryClient.getQueryCache().subscribe(refresh);
|
const unsubscribe = queryClient.getQueryCache().subscribe(refresh);
|
||||||
const interval = window.setInterval(refresh, 30_000);
|
const interval = window.setInterval(() => {
|
||||||
|
setNow(Date.now()); // refresh relative-time display ("X ago") every 30s
|
||||||
|
refresh();
|
||||||
|
}, 30_000);
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
Pencil,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
User,
|
User,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
usePeerRepresentation,
|
usePeerRepresentation,
|
||||||
useSearchPeer,
|
useSearchPeer,
|
||||||
useSetPeerCard,
|
useSetPeerCard,
|
||||||
|
useUpdatePeer,
|
||||||
} from "@/api/queries";
|
} from "@/api/queries";
|
||||||
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
import { Badge } from "@/components/shared/Badge";
|
import { Badge } from "@/components/shared/Badge";
|
||||||
@@ -41,6 +44,7 @@ import {
|
|||||||
import { useDemo } from "@/hooks/useDemo";
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
import { useMetadata } from "@/hooks/useMetadata";
|
import { useMetadata } from "@/hooks/useMetadata";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
|
import { DISPLAY_NAME_KEY, hasDisplayName, peerDisplayName } from "@/lib/peerDisplay";
|
||||||
|
|
||||||
export function PeerDetail() {
|
export function PeerDetail() {
|
||||||
const { mask } = useDemo();
|
const { mask } = useDemo();
|
||||||
@@ -69,6 +73,20 @@ export function PeerDetail() {
|
|||||||
const [cardDraft, setCardDraft] = useState<string | null>(null);
|
const [cardDraft, setCardDraft] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const peerMeta = (peer as { metadata?: Record<string, unknown> } | undefined)?.metadata;
|
||||||
|
const displayName = peerDisplayName(peerMeta, peerId);
|
||||||
|
const showsDisplayName = hasDisplayName(peerMeta, peerId);
|
||||||
|
const updatePeer = useUpdatePeer(workspaceId, peerId);
|
||||||
|
const [nameDraft, setNameDraft] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function saveDisplayName() {
|
||||||
|
const next = (nameDraft ?? "").trim();
|
||||||
|
const merged: Record<string, unknown> = { ...(peerMeta ?? {}) };
|
||||||
|
if (next) merged[DISPLAY_NAME_KEY] = next;
|
||||||
|
else delete merged[DISPLAY_NAME_KEY];
|
||||||
|
updatePeer.mutate({ metadata: merged }, { onSuccess: () => setNameDraft(null) });
|
||||||
|
}
|
||||||
|
|
||||||
const observeMe = (peer as { configuration?: { observe_me?: boolean } } | undefined)
|
const observeMe = (peer as { configuration?: { observe_me?: boolean } } | undefined)
|
||||||
?.configuration?.observe_me;
|
?.configuration?.observe_me;
|
||||||
|
|
||||||
@@ -81,14 +99,54 @@ export function PeerDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="page-container page-container--xl">
|
<div className="page-container page-container--xl">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
<Breadcrumb />
|
<Breadcrumb labels={{ [peerId]: displayName }} />
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<User className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<User className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
<PageTitle className="font-mono break-all">{mask(peerId)}</PageTitle>
|
{nameDraft === null ? (
|
||||||
{observeMe !== undefined && (
|
<>
|
||||||
|
<PageTitle className={showsDisplayName ? "break-all" : "font-mono break-all"}>
|
||||||
|
{mask(displayName)}
|
||||||
|
</PageTitle>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNameDraft(showsDisplayName ? displayName : "")}
|
||||||
|
className="shrink-0 p-1 rounded-md transition-colors hover:bg-[color:var(--surface)]"
|
||||||
|
style={{ color: "var(--text-4)" }}
|
||||||
|
title="Edit display name"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
value={nameDraft}
|
||||||
|
onChange={(e) => setNameDraft(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") saveDisplayName();
|
||||||
|
if (e.key === "Escape") setNameDraft(null);
|
||||||
|
}}
|
||||||
|
placeholder="Display name"
|
||||||
|
autoFocus
|
||||||
|
className="w-56"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="surface"
|
||||||
|
onClick={saveDisplayName}
|
||||||
|
disabled={updatePeer.isPending}
|
||||||
|
title="Save display name"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="surface" onClick={() => setNameDraft(null)} title="Cancel">
|
||||||
|
<X className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nameDraft === null && observeMe !== undefined && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono"
|
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono"
|
||||||
style={{
|
style={{
|
||||||
@@ -106,6 +164,9 @@ export function PeerDetail() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showsDisplayName && nameDraft === null && (
|
||||||
|
<MonoCaption className="break-all">{mask(peerId)}</MonoCaption>
|
||||||
|
)}
|
||||||
<Body className="leading-none">Peer identity & memory</Body>
|
<Body className="leading-none">Peer identity & memory</Body>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function SessionList() {
|
|||||||
className="font-mono text-sm font-medium truncate"
|
className="font-mono text-sm font-medium truncate"
|
||||||
style={{ color: COLOR.accentSoft }}
|
style={{ color: COLOR.accentSoft }}
|
||||||
>
|
>
|
||||||
{mask(session.id)}
|
{session.metadata?.name ? `${session.metadata.name}` : mask(session.id)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||||
{session.is_active && (
|
{session.is_active && (
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { httpFetch } from "@/lib/http";
|
import { dispatchFor, PROXY_REJECT_HEADER } from "@/lib/dispatch";
|
||||||
|
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
|
||||||
|
|
||||||
const LEGACY_KEY = "openconcho:config";
|
const LEGACY_KEY = "openconcho:config";
|
||||||
const STORE_KEY = "openconcho:instances";
|
const STORE_KEY = "openconcho:instances";
|
||||||
|
|
||||||
export const HONCHO_CLOUD_URL = "https://api.honcho.dev";
|
export const HONCHO_CLOUD_URL = "https://api.honcho.dev";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection-test timeout. Generous because a cold/idle self-hosted Honcho (DB
|
||||||
|
* pool spin-up, tunnel wake) can take several seconds on its first request — a
|
||||||
|
* tight 5s budget reported live-and-reachable instances as "Connection timed out".
|
||||||
|
*/
|
||||||
|
export const CONNECTION_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
function normalizeBaseUrl(url: string): string {
|
function normalizeBaseUrl(url: string): string {
|
||||||
return url.trim().replace(/\/+$/, "").toLowerCase();
|
return url.trim().replace(/\/+$/, "").toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -15,7 +23,7 @@ export function isCloudInstance(instance: Pick<Instance, "baseUrl">): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const configSchema = z.object({
|
export const configSchema = z.object({
|
||||||
baseUrl: z.string().url({ message: "Must be a valid URL" }),
|
baseUrl: z.url({ message: "Must be a valid URL" }),
|
||||||
token: z.string().optional().default(""),
|
token: z.string().optional().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,7 +32,7 @@ export type Config = z.infer<typeof configSchema>;
|
|||||||
export const instanceSchema = z.object({
|
export const instanceSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1, { message: "Name is required" }),
|
name: z.string().min(1, { message: "Name is required" }),
|
||||||
baseUrl: z.string().url({ message: "Must be a valid URL" }),
|
baseUrl: z.url({ message: "Must be a valid URL" }),
|
||||||
token: z.string().optional().default(""),
|
token: z.string().optional().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,6 +81,18 @@ export function loadStore(): InstanceStore {
|
|||||||
}
|
}
|
||||||
const migrated = migrateLegacy();
|
const migrated = migrateLegacy();
|
||||||
if (migrated) return migrated;
|
if (migrated) return migrated;
|
||||||
|
|
||||||
|
// First-run default from a container's runtime config (no-op outside Docker).
|
||||||
|
const runtimeUrl = runtimeDefaultBaseUrl();
|
||||||
|
if (runtimeUrl) {
|
||||||
|
const inst: Instance = {
|
||||||
|
id: "runtime-default",
|
||||||
|
name: "Honcho",
|
||||||
|
baseUrl: runtimeUrl,
|
||||||
|
token: "",
|
||||||
|
};
|
||||||
|
return { instances: [inst], activeId: inst.id };
|
||||||
|
}
|
||||||
return { instances: [], activeId: null };
|
return { instances: [], activeId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,21 +169,21 @@ export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking";
|
|||||||
export async function checkConnection(
|
export async function checkConnection(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
token?: string,
|
token?: string,
|
||||||
): Promise<{
|
timeoutMs: number = CONNECTION_TIMEOUT_MS,
|
||||||
status: HealthStatus;
|
): Promise<{ status: HealthStatus; message: string }> {
|
||||||
message: string;
|
|
||||||
}> {
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl, token });
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
const res = await fetch(`${base}/v3/workspaces/list`, {
|
||||||
|
|
||||||
const res = await httpFetch(`${baseUrl}/v3/workspaces/list`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reject = res.headers.get(PROXY_REJECT_HEADER);
|
||||||
|
if (reject) {
|
||||||
|
return { status: "unreachable", message: `Proxy refused upstream (${reject})` };
|
||||||
|
}
|
||||||
if (res.ok) return { status: "ok", message: "Connected successfully" };
|
if (res.ok) return { status: "ok", message: "Connected successfully" };
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
return { status: "auth-required", message: "Authentication required — provide an API token" };
|
return { status: "auth-required", message: "Authentication required — provide an API token" };
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { httpFetch } from "@/lib/http";
|
import { dispatchFor } from "@/lib/dispatch";
|
||||||
|
import { isTauri } from "@/lib/platform";
|
||||||
|
|
||||||
|
export { isTauri } from "@/lib/platform";
|
||||||
|
|
||||||
export interface DiscoveredInstance {
|
export interface DiscoveredInstance {
|
||||||
port: number;
|
port: number;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTauri(): boolean {
|
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe localhost ports for running Honcho instances. Desktop-only — the web
|
* Probe localhost ports for running Honcho instances. Desktop-only — the web
|
||||||
* build can't port-scan due to CORS, so this returns an empty list when not
|
* build can't port-scan due to CORS, so this returns an empty list when not
|
||||||
@@ -38,9 +37,10 @@ export function deriveNameFromWorkspaceId(workspaceId: string): string {
|
|||||||
*/
|
*/
|
||||||
export async function suggestNameForInstance(baseUrl: string): Promise<string | null> {
|
export async function suggestNameForInstance(baseUrl: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const res = await httpFetch(`${baseUrl}/v3/workspaces/list?page=1&page_size=1`, {
|
const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl });
|
||||||
|
const res = await fetch(`${base}/v3/workspaces/list?page=1&page_size=1`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
signal: AbortSignal.timeout(2000),
|
signal: AbortSignal.timeout(2000),
|
||||||
});
|
});
|
||||||
|
|||||||
47
packages/web/src/lib/dispatch.ts
Normal file
47
packages/web/src/lib/dispatch.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { httpFetch } from "@/lib/http";
|
||||||
|
import { isTauri } from "@/lib/platform";
|
||||||
|
|
||||||
|
/** Same-origin path prefix the web build issues all Honcho calls through. */
|
||||||
|
export const API_PREFIX = "/api";
|
||||||
|
/** Request header naming the real Honcho upstream for the proxy to forward to. */
|
||||||
|
export const UPSTREAM_HEADER = "X-Honcho-Upstream";
|
||||||
|
/** Response header the proxy sets on its OWN refusals (so they aren't read as upstream auth). */
|
||||||
|
export const PROXY_REJECT_HEADER = "X-Honcho-Proxy-Reject";
|
||||||
|
|
||||||
|
export interface Dispatch {
|
||||||
|
baseUrl: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
fetch: typeof globalThis.fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUpstream(url: string): string {
|
||||||
|
return url.trim().replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute same-origin base for the web proxy. Absolute (origin + `/api`), not the
|
||||||
|
* bare relative `/api`, so openapi-fetch and node/undici can construct a Request
|
||||||
|
* without an ambient document base — and so it resolves identically in the browser,
|
||||||
|
* behind a tunnel, and under jsdom.
|
||||||
|
*/
|
||||||
|
function webApiBase(): string {
|
||||||
|
const origin = typeof location !== "undefined" ? location.origin : "";
|
||||||
|
return `${origin}${API_PREFIX}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve how to issue a request for an instance.
|
||||||
|
* - Web: same-origin `/api` + `X-Honcho-Upstream` header (proxy forwards server-side, no CORS).
|
||||||
|
* - Tauri: the absolute instance URL via reqwest (no browser same-origin policy).
|
||||||
|
*/
|
||||||
|
export function dispatchFor(instance: { baseUrl: string; token?: string }): Dispatch {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
|
||||||
|
|
||||||
|
if (isTauri()) {
|
||||||
|
return { baseUrl: instance.baseUrl, headers, fetch: httpFetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
headers[UPSTREAM_HEADER] = normalizeUpstream(instance.baseUrl);
|
||||||
|
return { baseUrl: webApiBase(), headers, fetch: httpFetch };
|
||||||
|
}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||||
|
import { isTauri } from "@/lib/platform";
|
||||||
|
|
||||||
// Route fetch through Rust (reqwest) when running in Tauri — bypasses WebView CORS enforcement.
|
// Route fetch through Rust (reqwest) when running in Tauri — bypasses WebView CORS enforcement.
|
||||||
// Falls back to native browser fetch during plain web dev (`pnpm dev:web`).
|
// Falls back to native browser fetch during plain web dev.
|
||||||
const isTauri = Boolean(
|
export const httpFetch: typeof globalThis.fetch = isTauri()
|
||||||
typeof window !== "undefined" &&
|
|
||||||
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const httpFetch: typeof globalThis.fetch = isTauri
|
|
||||||
? (tauriFetch as typeof globalThis.fetch)
|
? (tauriFetch as typeof globalThis.fetch)
|
||||||
: globalThis.fetch;
|
: globalThis.fetch;
|
||||||
|
|||||||
25
packages/web/src/lib/peerDisplay.ts
Normal file
25
packages/web/src/lib/peerDisplay.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Resolve a peer's friendly display name from its metadata, falling back to the
|
||||||
|
* raw peer id. Honcho peers are keyed by opaque ids (WhatsApp `…-lid`, UUIDs);
|
||||||
|
* a `display_name` metadata key lets the UI show something human-readable.
|
||||||
|
*/
|
||||||
|
export const DISPLAY_NAME_KEY = "display_name";
|
||||||
|
|
||||||
|
export function peerDisplayName(
|
||||||
|
metadata: Record<string, unknown> | null | undefined,
|
||||||
|
fallbackId: string,
|
||||||
|
): string {
|
||||||
|
const value = metadata?.[DISPLAY_NAME_KEY];
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
return fallbackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a peer has an explicit display name distinct from its id. */
|
||||||
|
export function hasDisplayName(
|
||||||
|
metadata: Record<string, unknown> | null | undefined,
|
||||||
|
peerId: string,
|
||||||
|
): boolean {
|
||||||
|
return peerDisplayName(metadata, peerId) !== peerId;
|
||||||
|
}
|
||||||
4
packages/web/src/lib/platform.ts
Normal file
4
packages/web/src/lib/platform.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** True when running inside the Tauri desktop shell (WebView with injected internals). */
|
||||||
|
export function isTauri(): boolean {
|
||||||
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
|
}
|
||||||
17
packages/web/src/lib/runtimeConfig.ts
Normal file
17
packages/web/src/lib/runtimeConfig.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const GLOBAL_KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-injected default Honcho base URL for container deployments.
|
||||||
|
*
|
||||||
|
* The Docker image writes `/config.js` from `OPENCONCHO_DEFAULT_HONCHO_URL` at
|
||||||
|
* container start, so one prebuilt image can target any backend without a rebuild.
|
||||||
|
* The web build proxies this URL via the same-origin `/api` reverse proxy (no CORS).
|
||||||
|
*
|
||||||
|
* - an absolute URL → that URL (seeds the first instance)
|
||||||
|
* - empty / unset → null (no default; the user configures in Settings)
|
||||||
|
*/
|
||||||
|
export function runtimeDefaultBaseUrl(): string | null {
|
||||||
|
const raw = (globalThis as Record<string, unknown>)[GLOBAL_KEY];
|
||||||
|
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||||
|
return raw.trim();
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as WorkspacesRouteImport } from './routes/workspaces'
|
import { Route as WorkspacesRouteImport } from './routes/workspaces'
|
||||||
import { Route as SettingsRouteImport } from './routes/settings'
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
import { Route as SeedKitsRouteImport } from './routes/seed-kits'
|
import { Route as SeedKitsRouteImport } from './routes/seed-kits'
|
||||||
|
import { Route as FleetRouteImport } from './routes/fleet'
|
||||||
import { Route as ExploreRouteImport } from './routes/explore'
|
import { Route as ExploreRouteImport } from './routes/explore'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
|
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
|
||||||
@@ -42,6 +43,11 @@ const SeedKitsRoute = SeedKitsRouteImport.update({
|
|||||||
path: '/seed-kits',
|
path: '/seed-kits',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const FleetRoute = FleetRouteImport.update({
|
||||||
|
id: '/fleet',
|
||||||
|
path: '/fleet',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ExploreRoute = ExploreRouteImport.update({
|
const ExploreRoute = ExploreRouteImport.update({
|
||||||
id: '/explore',
|
id: '/explore',
|
||||||
path: '/explore',
|
path: '/explore',
|
||||||
@@ -126,6 +132,7 @@ const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/explore': typeof ExploreRoute
|
'/explore': typeof ExploreRoute
|
||||||
|
'/fleet': typeof FleetRoute
|
||||||
'/seed-kits': typeof SeedKitsRoute
|
'/seed-kits': typeof SeedKitsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/workspaces': typeof WorkspacesRoute
|
'/workspaces': typeof WorkspacesRoute
|
||||||
@@ -145,6 +152,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/explore': typeof ExploreRoute
|
'/explore': typeof ExploreRoute
|
||||||
|
'/fleet': typeof FleetRoute
|
||||||
'/seed-kits': typeof SeedKitsRoute
|
'/seed-kits': typeof SeedKitsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/workspaces': typeof WorkspacesRoute
|
'/workspaces': typeof WorkspacesRoute
|
||||||
@@ -165,6 +173,7 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/explore': typeof ExploreRoute
|
'/explore': typeof ExploreRoute
|
||||||
|
'/fleet': typeof FleetRoute
|
||||||
'/seed-kits': typeof SeedKitsRoute
|
'/seed-kits': typeof SeedKitsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/workspaces': typeof WorkspacesRoute
|
'/workspaces': typeof WorkspacesRoute
|
||||||
@@ -186,6 +195,7 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/explore'
|
| '/explore'
|
||||||
|
| '/fleet'
|
||||||
| '/seed-kits'
|
| '/seed-kits'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/workspaces'
|
| '/workspaces'
|
||||||
@@ -205,6 +215,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/explore'
|
| '/explore'
|
||||||
|
| '/fleet'
|
||||||
| '/seed-kits'
|
| '/seed-kits'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/workspaces'
|
| '/workspaces'
|
||||||
@@ -224,6 +235,7 @@ export interface FileRouteTypes {
|
|||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/explore'
|
| '/explore'
|
||||||
|
| '/fleet'
|
||||||
| '/seed-kits'
|
| '/seed-kits'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/workspaces'
|
| '/workspaces'
|
||||||
@@ -244,6 +256,7 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
ExploreRoute: typeof ExploreRoute
|
ExploreRoute: typeof ExploreRoute
|
||||||
|
FleetRoute: typeof FleetRoute
|
||||||
SeedKitsRoute: typeof SeedKitsRoute
|
SeedKitsRoute: typeof SeedKitsRoute
|
||||||
SettingsRoute: typeof SettingsRoute
|
SettingsRoute: typeof SettingsRoute
|
||||||
WorkspacesRoute: typeof WorkspacesRoute
|
WorkspacesRoute: typeof WorkspacesRoute
|
||||||
@@ -284,6 +297,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SeedKitsRouteImport
|
preLoaderRoute: typeof SeedKitsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/fleet': {
|
||||||
|
id: '/fleet'
|
||||||
|
path: '/fleet'
|
||||||
|
fullPath: '/fleet'
|
||||||
|
preLoaderRoute: typeof FleetRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/explore': {
|
'/explore': {
|
||||||
id: '/explore'
|
id: '/explore'
|
||||||
path: '/explore'
|
path: '/explore'
|
||||||
@@ -388,6 +408,7 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
ExploreRoute: ExploreRoute,
|
ExploreRoute: ExploreRoute,
|
||||||
|
FleetRoute: FleetRoute,
|
||||||
SeedKitsRoute: SeedKitsRoute,
|
SeedKitsRoute: SeedKitsRoute,
|
||||||
SettingsRoute: SettingsRoute,
|
SettingsRoute: SettingsRoute,
|
||||||
WorkspacesRoute: WorkspacesRoute,
|
WorkspacesRoute: WorkspacesRoute,
|
||||||
|
|||||||
7
packages/web/src/routes/fleet.tsx
Normal file
7
packages/web/src/routes/fleet.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/fleet")({
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: "/" });
|
||||||
|
},
|
||||||
|
});
|
||||||
74
packages/web/src/test/check-connection.test.ts
Normal file
74
packages/web/src/test/check-connection.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock the platform predicate (web mode) and the fetch boundary. We mock
|
||||||
|
// @/lib/http — NOT globalThis.fetch — because httpFetch captures the fetch
|
||||||
|
// reference at module load, so vi.stubGlobal would not be observed by dispatchFor.
|
||||||
|
const { mockIsTauri, httpFetchMock } = vi.hoisted(() => ({
|
||||||
|
mockIsTauri: vi.fn(() => false),
|
||||||
|
httpFetchMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
|
||||||
|
vi.mock("@/lib/http", () => ({ httpFetch: httpFetchMock }));
|
||||||
|
|
||||||
|
import { checkConnection } from "@/lib/config";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpFetchMock.mockReset();
|
||||||
|
mockIsTauri.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkConnection — web proxy mode", () => {
|
||||||
|
it("calls the absolute same-origin /api path with the upstream header", async () => {
|
||||||
|
httpFetchMock.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||||
|
const res = await checkConnection("https://honcho.example.net", "sk-1");
|
||||||
|
expect(res.status).toBe("ok");
|
||||||
|
const [url, init] = httpFetchMock.mock.calls[0];
|
||||||
|
expect(String(url)).toBe(`${location.origin}/api/v3/workspaces/list`);
|
||||||
|
expect((init.headers as Record<string, string>)["X-Honcho-Upstream"]).toBe(
|
||||||
|
"https://honcho.example.net",
|
||||||
|
);
|
||||||
|
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer sk-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an upstream 401 to auth-required", async () => {
|
||||||
|
httpFetchMock.mockResolvedValue(new Response("{}", { status: 401 }));
|
||||||
|
const res = await checkConnection("https://honcho.example.net");
|
||||||
|
expect(res.status).toBe("auth-required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a proxy reject as unreachable, not auth-required", async () => {
|
||||||
|
httpFetchMock.mockResolvedValue(
|
||||||
|
new Response("", { status: 403, headers: { "X-Honcho-Proxy-Reject": "allowlist" } }),
|
||||||
|
);
|
||||||
|
const res = await checkConnection("https://blocked.example.net");
|
||||||
|
expect(res.status).toBe("unreachable");
|
||||||
|
expect(res.message).toMatch(/allowlist/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkConnection — timeout budget", () => {
|
||||||
|
// A fetch that resolves after `ms`, but rejects early if the abort signal fires —
|
||||||
|
// mirrors how a real slow upstream interacts with AbortSignal.timeout.
|
||||||
|
function delayedFetch(ms: number) {
|
||||||
|
return (_url: string, init: { signal?: AbortSignal }) =>
|
||||||
|
new Promise<Response>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => resolve(new Response("{}", { status: 200 })), ms);
|
||||||
|
init.signal?.addEventListener("abort", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException("The operation timed out", "TimeoutError"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports unreachable when the upstream is slower than the timeout budget", async () => {
|
||||||
|
httpFetchMock.mockImplementation(delayedFetch(80));
|
||||||
|
const res = await checkConnection("https://slow.example.net", undefined, 20);
|
||||||
|
expect(res.status).toBe("unreachable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("succeeds when a slow upstream responds within the (cold-start) budget", async () => {
|
||||||
|
httpFetchMock.mockImplementation(delayedFetch(20));
|
||||||
|
const res = await checkConnection("https://slow.example.net", undefined, 200);
|
||||||
|
expect(res.status).toBe("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
106
packages/web/src/test/dashboard.test.tsx
Normal file
106
packages/web/src/test/dashboard.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DemoProvider } from "@/context/DemoContext";
|
||||||
|
import { MetadataProvider } from "@/context/MetadataContext";
|
||||||
|
import type { Instance } from "@/lib/config";
|
||||||
|
import { saveStore } from "@/lib/config";
|
||||||
|
import { routeTree } from "@/routeTree.gen";
|
||||||
|
|
||||||
|
// One mocked transport for every scoped call; branch by URL so the per-workspace
|
||||||
|
// fan-out (workspaces list → queue status + conclusions count) all resolve.
|
||||||
|
vi.mock("@/lib/http", () => ({
|
||||||
|
httpFetch: vi.fn(async (input: Request | string) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const json = (body: unknown) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (url.includes("/queue/status")) {
|
||||||
|
return json({
|
||||||
|
in_progress_work_units: 0,
|
||||||
|
pending_work_units: 0,
|
||||||
|
completed_work_units: 0,
|
||||||
|
total_work_units: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/conclusions/list")) {
|
||||||
|
return json({ items: [], total: 5, page: 1, size: 1, pages: 1 });
|
||||||
|
}
|
||||||
|
return json({ items: [{ id: "ws-1" }], total: 1, page: 1, size: 100, pages: 1 });
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const neo: Instance = { id: "neo", name: "Neo", baseUrl: "https://neo.example.net", token: "" };
|
||||||
|
const iris: Instance = { id: "iris", name: "Iris", baseUrl: "https://iris.example.net", token: "" };
|
||||||
|
|
||||||
|
function renderDashboard() {
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||||
|
});
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<DemoProvider>
|
||||||
|
<MetadataProvider>
|
||||||
|
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
|
||||||
|
<RouterProvider router={router as any} />
|
||||||
|
</MetadataProvider>
|
||||||
|
</DemoProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard — unified server-aware view", () => {
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it("does not loop when Date.now advances on each call (CI render-loop repro)", async () => {
|
||||||
|
// On CI, consecutive Date.now() calls cross millisecond boundaries, so setNow(Date.now())
|
||||||
|
// in the cache-event subscriber always produces a new value → React keeps re-rendering
|
||||||
|
// Sidebar → hits the 25-cycle "Maximum update depth exceeded" limit.
|
||||||
|
// This test forces that CI condition locally to catch regressions.
|
||||||
|
let t = 1_000_000;
|
||||||
|
const spy = vi.spyOn(Date, "now").mockImplementation(() => t++);
|
||||||
|
saveStore({ instances: [neo, iris], activeId: "neo" });
|
||||||
|
renderDashboard();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("(Neo)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(Iris)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists each server's workspaces labelled with the server name", async () => {
|
||||||
|
saveStore({ instances: [neo, iris], activeId: "neo" });
|
||||||
|
renderDashboard();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("(Neo)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(Iris)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a server filter listing every server", async () => {
|
||||||
|
saveStore({ instances: [neo, iris], activeId: "neo" });
|
||||||
|
renderDashboard();
|
||||||
|
const select = await screen.findByLabelText("Filter by server");
|
||||||
|
expect(within(select).getByRole("option", { name: "Neo" })).toBeInTheDocument();
|
||||||
|
expect(within(select).getByRole("option", { name: "Iris" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("narrows to a single server when filtered", async () => {
|
||||||
|
saveStore({ instances: [neo, iris], activeId: "neo" });
|
||||||
|
renderDashboard();
|
||||||
|
await screen.findByText("(Iris)");
|
||||||
|
const select = await screen.findByLabelText("Filter by server");
|
||||||
|
fireEvent.change(select, { target: { value: "iris" } });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("(Neo)")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(Iris)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
40
packages/web/src/test/dispatch.test.ts
Normal file
40
packages/web/src/test/dispatch.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { mockIsTauri } = vi.hoisted(() => ({ mockIsTauri: vi.fn() }));
|
||||||
|
vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
|
||||||
|
|
||||||
|
import { API_PREFIX, dispatchFor, PROXY_REJECT_HEADER, UPSTREAM_HEADER } from "@/lib/dispatch";
|
||||||
|
|
||||||
|
afterEach(() => mockIsTauri.mockReset());
|
||||||
|
|
||||||
|
describe("dispatchFor — web mode", () => {
|
||||||
|
it("targets the absolute same-origin /api base and carries the upstream header", () => {
|
||||||
|
mockIsTauri.mockReturnValue(false);
|
||||||
|
const d = dispatchFor({ baseUrl: "https://honcho.example.net/", token: "" });
|
||||||
|
expect(d.baseUrl).toBe(`${location.origin}${API_PREFIX}`);
|
||||||
|
expect(d.headers[UPSTREAM_HEADER]).toBe("https://honcho.example.net");
|
||||||
|
expect(d.headers.Authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds Authorization only when a token is present", () => {
|
||||||
|
mockIsTauri.mockReturnValue(false);
|
||||||
|
const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
|
||||||
|
expect(d.headers.Authorization).toBe("Bearer sk-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dispatchFor — tauri mode", () => {
|
||||||
|
it("targets the absolute URL with no upstream header", () => {
|
||||||
|
mockIsTauri.mockReturnValue(true);
|
||||||
|
const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
|
||||||
|
expect(d.baseUrl).toBe("https://honcho.example.net");
|
||||||
|
expect(d.headers[UPSTREAM_HEADER]).toBeUndefined();
|
||||||
|
expect(d.headers.Authorization).toBe("Bearer sk-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("proxy reject header constant", () => {
|
||||||
|
it("is the agreed sentinel name", () => {
|
||||||
|
expect(PROXY_REJECT_HEADER).toBe("X-Honcho-Proxy-Reject");
|
||||||
|
});
|
||||||
|
});
|
||||||
174
packages/web/src/test/fleet.test.tsx
Normal file
174
packages/web/src/test/fleet.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { scopedConclusionsCountOptions, scopedQueueStatusOptions } from "@/api/compareQueries";
|
||||||
|
import {
|
||||||
|
computeFleetAggregates,
|
||||||
|
DEFAULT_ROW_METRICS,
|
||||||
|
type FleetRowMetrics,
|
||||||
|
} from "@/components/fleet/fleetAggregates";
|
||||||
|
import { DemoProvider } from "@/context/DemoContext";
|
||||||
|
import { MetadataProvider } from "@/context/MetadataContext";
|
||||||
|
import type { Instance } from "@/lib/config";
|
||||||
|
import { saveStore } from "@/lib/config";
|
||||||
|
import { routeTree } from "@/routeTree.gen";
|
||||||
|
|
||||||
|
vi.mock("@/lib/http", () => ({
|
||||||
|
httpFetch: vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(JSON.stringify({ items: [], total: 0, page: 1, size: 1, pages: 0 }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const neo: Instance = {
|
||||||
|
id: "neo",
|
||||||
|
name: "Neo",
|
||||||
|
baseUrl: "http://localhost:8001",
|
||||||
|
token: "neo-token",
|
||||||
|
};
|
||||||
|
const iris: Instance = {
|
||||||
|
id: "iris",
|
||||||
|
name: "Iris",
|
||||||
|
baseUrl: "http://localhost:8002",
|
||||||
|
token: "iris-token",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("computeFleetAggregates", () => {
|
||||||
|
it("matches snapshot for an empty fleet", () => {
|
||||||
|
expect(computeFleetAggregates([])).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"healthyCount": 0,
|
||||||
|
"loadingCount": 0,
|
||||||
|
"totalConclusions": 0,
|
||||||
|
"totalInstances": 0,
|
||||||
|
"totalQueueActive": 0,
|
||||||
|
"totalQueuePending": 0,
|
||||||
|
"totalWorkspaces": 0,
|
||||||
|
"unreachableCount": 0,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches snapshot for a mixed fleet (healthy, loading, unreachable)", () => {
|
||||||
|
const rows: FleetRowMetrics[] = [
|
||||||
|
{
|
||||||
|
workspaceCount: 3,
|
||||||
|
conclusionCount: 142,
|
||||||
|
queueActive: 2,
|
||||||
|
queuePending: 5,
|
||||||
|
lastSeen: 1_700_000_000_000,
|
||||||
|
health: "ok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workspaceCount: 1,
|
||||||
|
conclusionCount: 87,
|
||||||
|
queueActive: 0,
|
||||||
|
queuePending: 0,
|
||||||
|
lastSeen: 1_700_000_001_000,
|
||||||
|
health: "ok",
|
||||||
|
},
|
||||||
|
{ ...DEFAULT_ROW_METRICS },
|
||||||
|
{
|
||||||
|
workspaceCount: 0,
|
||||||
|
conclusionCount: 0,
|
||||||
|
queueActive: 0,
|
||||||
|
queuePending: 0,
|
||||||
|
lastSeen: null,
|
||||||
|
health: "unreachable",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(computeFleetAggregates(rows)).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"healthyCount": 2,
|
||||||
|
"loadingCount": 1,
|
||||||
|
"totalConclusions": 229,
|
||||||
|
"totalInstances": 4,
|
||||||
|
"totalQueueActive": 2,
|
||||||
|
"totalQueuePending": 5,
|
||||||
|
"totalWorkspaces": 4,
|
||||||
|
"unreachableCount": 1,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scoped option builders", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { httpFetch } = await import("@/lib/http");
|
||||||
|
(httpFetch as ReturnType<typeof vi.fn>).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes queue status requests via the same-origin proxy, upstream header, and token", async () => {
|
||||||
|
const { httpFetch } = await import("@/lib/http");
|
||||||
|
const opts = scopedQueueStatusOptions(neo, "ws-1");
|
||||||
|
await opts.queryFn();
|
||||||
|
const req = (httpFetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as Request;
|
||||||
|
expect(req.url).toBe(`${location.origin}/api/v3/workspaces/ws-1/queue/status`);
|
||||||
|
expect(req.headers.get("X-Honcho-Upstream")).toBe("http://localhost:8001");
|
||||||
|
expect(req.headers.get("Authorization")).toBe("Bearer neo-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes conclusions-count requests via the same-origin proxy, upstream header, and token", async () => {
|
||||||
|
const { httpFetch } = await import("@/lib/http");
|
||||||
|
const opts = scopedConclusionsCountOptions(iris, "ws-9");
|
||||||
|
await opts.queryFn();
|
||||||
|
const req = (httpFetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as Request;
|
||||||
|
expect(req.url.startsWith(`${location.origin}/api/v3/workspaces/ws-9/conclusions/list`)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(req.headers.get("X-Honcho-Upstream")).toBe("http://localhost:8002");
|
||||||
|
expect(req.headers.get("Authorization")).toBe("Bearer iris-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces distinct query keys per instance to prevent cache collisions", () => {
|
||||||
|
const neoKey = scopedQueueStatusOptions(neo, "ws-shared").queryKey;
|
||||||
|
const irisKey = scopedQueueStatusOptions(iris, "ws-shared").queryKey;
|
||||||
|
expect(neoKey).not.toEqual(irisKey);
|
||||||
|
expect(neoKey).toContain("neo");
|
||||||
|
expect(irisKey).toContain("iris");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderRouteAt(initialPath: string) {
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
history: createMemoryHistory({ initialEntries: [initialPath] }),
|
||||||
|
});
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<DemoProvider>
|
||||||
|
<MetadataProvider>
|
||||||
|
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
|
||||||
|
<RouterProvider router={router as any} />
|
||||||
|
</MetadataProvider>
|
||||||
|
</DemoProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Fleet route", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects /fleet to the Dashboard", async () => {
|
||||||
|
saveStore({ instances: [neo], activeId: "neo" });
|
||||||
|
renderRouteAt("/fleet");
|
||||||
|
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows each instance after /fleet redirect", async () => {
|
||||||
|
saveStore({ instances: [neo, iris], activeId: "neo" });
|
||||||
|
renderRouteAt("/fleet");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Neo — no workspaces")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Iris — no workspaces")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
125
packages/web/src/test/instances.test.ts
Normal file
125
packages/web/src/test/instances.test.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
addInstance,
|
||||||
|
deleteInstance,
|
||||||
|
getActiveInstance,
|
||||||
|
loadConfig,
|
||||||
|
loadStore,
|
||||||
|
setActiveInstance,
|
||||||
|
updateInstance,
|
||||||
|
} from "@/lib/config";
|
||||||
|
|
||||||
|
const STORE_KEY = "openconcho:instances";
|
||||||
|
const LEGACY_KEY = "openconcho:config";
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
describe("instance store — add + active selection", () => {
|
||||||
|
it("makes the first added instance active", () => {
|
||||||
|
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||||
|
expect(loadStore().activeId).toBe(a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not steal active focus when adding more instances", () => {
|
||||||
|
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||||
|
addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
|
||||||
|
expect(loadStore().activeId).toBe(a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends instances in insertion order", () => {
|
||||||
|
addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||||
|
addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
|
||||||
|
expect(loadStore().instances.map((i) => i.name)).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("instance store — switching active", () => {
|
||||||
|
it("switches the active instance", () => {
|
||||||
|
addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||||
|
const b = addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
|
||||||
|
setActiveInstance(b.id);
|
||||||
|
expect(getActiveInstance()?.id).toBe(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unknown id", () => {
|
||||||
|
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||||
|
setActiveInstance("does-not-exist");
|
||||||
|
expect(getActiveInstance()?.id).toBe(a.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("instance store — deletion", () => {
|
||||||
|
it("falls back to the first remaining when the active instance is deleted", () => {
|
||||||
|
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||||
|
const b = addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
|
||||||
|
setActiveInstance(b.id);
|
||||||
|
deleteInstance(b.id);
|
||||||
|
expect(loadStore().activeId).toBe(a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the active id unchanged when a non-active instance is deleted", () => {
|
||||||
|
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||||
|
const b = addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
|
||||||
|
deleteInstance(b.id);
|
||||||
|
expect(getActiveInstance()?.id).toBe(a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the active id when the last instance is removed", () => {
|
||||||
|
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||||
|
deleteInstance(a.id);
|
||||||
|
expect(loadStore().activeId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null config once every instance is gone", () => {
|
||||||
|
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||||
|
deleteInstance(a.id);
|
||||||
|
expect(loadConfig()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("instance store — update", () => {
|
||||||
|
it("patches the named fields", () => {
|
||||||
|
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||||
|
updateInstance(a.id, { name: "Renamed", token: "sk-1" });
|
||||||
|
expect(loadStore().instances[0]).toMatchObject({ name: "Renamed", token: "sk-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops on an unknown id", () => {
|
||||||
|
addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||||
|
updateInstance("nope", { name: "X" });
|
||||||
|
expect(loadStore().instances[0].name).toBe("A");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("instance store — active config", () => {
|
||||||
|
it("reflects the active instance's url and token", () => {
|
||||||
|
addInstance({ name: "A", baseUrl: "https://a.example.net", token: "sk-a" });
|
||||||
|
expect(loadConfig()).toEqual({ baseUrl: "https://a.example.net", token: "sk-a" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("instance store — legacy migration", () => {
|
||||||
|
it("migrates the legacy single-config key into the instances store", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
LEGACY_KEY,
|
||||||
|
JSON.stringify({ baseUrl: "https://legacy.example.net", token: "sk-legacy" }),
|
||||||
|
);
|
||||||
|
const store = loadStore();
|
||||||
|
expect(store.instances[0]).toMatchObject({
|
||||||
|
name: "Default",
|
||||||
|
baseUrl: "https://legacy.example.net",
|
||||||
|
token: "sk-legacy",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the legacy key after migrating", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
LEGACY_KEY,
|
||||||
|
JSON.stringify({ baseUrl: "https://legacy.example.net", token: "" }),
|
||||||
|
);
|
||||||
|
loadStore();
|
||||||
|
expect(localStorage.getItem(LEGACY_KEY)).toBeNull();
|
||||||
|
expect(localStorage.getItem(STORE_KEY)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
packages/web/src/test/peer-display.test.ts
Normal file
40
packages/web/src/test/peer-display.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hasDisplayName, peerDisplayName } from "@/lib/peerDisplay";
|
||||||
|
|
||||||
|
const PEER_ID = "22335577991-lid";
|
||||||
|
|
||||||
|
describe("peerDisplayName", () => {
|
||||||
|
it("returns the display_name when set", () => {
|
||||||
|
expect(peerDisplayName({ display_name: "Alice" }, PEER_ID)).toBe("Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims surrounding whitespace", () => {
|
||||||
|
expect(peerDisplayName({ display_name: " Bob " }, PEER_ID)).toBe("Bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the peer id when display_name is absent", () => {
|
||||||
|
expect(peerDisplayName({}, PEER_ID)).toBe(PEER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the peer id when display_name is blank", () => {
|
||||||
|
expect(peerDisplayName({ display_name: " " }, PEER_ID)).toBe(PEER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the peer id when display_name is not a string", () => {
|
||||||
|
expect(peerDisplayName({ display_name: 42 }, PEER_ID)).toBe(PEER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the peer id when metadata is null", () => {
|
||||||
|
expect(peerDisplayName(null, PEER_ID)).toBe(PEER_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasDisplayName", () => {
|
||||||
|
it("is true when a distinct display name is set", () => {
|
||||||
|
expect(hasDisplayName({ display_name: "Alice" }, PEER_ID)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when no display name is set", () => {
|
||||||
|
expect(hasDisplayName({}, PEER_ID)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
17
packages/web/src/test/platform.test.ts
Normal file
17
packages/web/src/test/platform.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { isTauri } from "@/lib/platform";
|
||||||
|
|
||||||
|
describe("isTauri", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false in a plain browser/jsdom environment", () => {
|
||||||
|
expect(isTauri()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when the Tauri internals global is present", () => {
|
||||||
|
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
|
||||||
|
expect(isTauri()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
packages/web/src/test/runtime-config.test.ts
Normal file
21
packages/web/src/test/runtime-config.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
|
||||||
|
|
||||||
|
const KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (globalThis as Record<string, unknown>)[KEY];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runtimeDefaultBaseUrl", () => {
|
||||||
|
it("returns an injected absolute URL verbatim", () => {
|
||||||
|
(globalThis as Record<string, unknown>)[KEY] = "https://honcho.example.net";
|
||||||
|
expect(runtimeDefaultBaseUrl()).toBe("https://honcho.example.net");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when unset or empty", () => {
|
||||||
|
expect(runtimeDefaultBaseUrl()).toBeNull();
|
||||||
|
(globalThis as Record<string, unknown>)[KEY] = " ";
|
||||||
|
expect(runtimeDefaultBaseUrl()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,11 +27,11 @@ describe("security URL helpers", () => {
|
|||||||
expect(isSecureTokenTransport("http://localhost:8000")).toBe(true);
|
expect(isSecureTokenTransport("http://localhost:8000")).toBe(true);
|
||||||
expect(isSecureTokenTransport("http://127.0.0.1:8000")).toBe(true);
|
expect(isSecureTokenTransport("http://127.0.0.1:8000")).toBe(true);
|
||||||
expect(isSecureTokenTransport("http://192.168.1.50:8000")).toBe(false);
|
expect(isSecureTokenTransport("http://192.168.1.50:8000")).toBe(false);
|
||||||
expect(isSecureTokenTransport("http://100.67.206.76:8000")).toBe(false);
|
expect(isSecureTokenTransport("http://192.0.2.10:8000")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a user-facing error for insecure token transport", () => {
|
it("returns a user-facing error for insecure token transport", () => {
|
||||||
expect(tokenTransportError("http://100.67.206.76:8000")).toMatch(/HTTPS/);
|
expect(tokenTransportError("http://192.0.2.10:8000")).toMatch(/HTTPS/);
|
||||||
expect(tokenTransportError("https://honcho.example.com")).toBeNull();
|
expect(tokenTransportError("https://honcho.example.com")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
95
packages/web/src/test/server-workspace-rows.test.tsx
Normal file
95
packages/web/src/test/server-workspace-rows.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { act, render, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ServerWorkspaceRows } from "@/components/dashboard/ServerWorkspaceRows";
|
||||||
|
import type { FleetRowMetrics } from "@/components/fleet/fleetAggregates";
|
||||||
|
import { DemoProvider } from "@/context/DemoContext";
|
||||||
|
import type { Instance } from "@/lib/config";
|
||||||
|
|
||||||
|
vi.mock("@/lib/http", () => ({
|
||||||
|
httpFetch: vi.fn(async (input: Request | string) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const json = (body: unknown) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (url.includes("/queue/status")) {
|
||||||
|
return json({ in_progress_work_units: 0, pending_work_units: 0 });
|
||||||
|
}
|
||||||
|
if (url.includes("/conclusions/list")) {
|
||||||
|
return json({ items: [], total: 3, page: 1, size: 1, pages: 1 });
|
||||||
|
}
|
||||||
|
return json({ items: [{ id: "ws-1" }], total: 1, page: 1, size: 100, pages: 1 });
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const neo: Instance = { id: "neo", name: "Neo", baseUrl: "https://neo.example.net", token: "" };
|
||||||
|
|
||||||
|
function makeQc() {
|
||||||
|
return new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows(instance: Instance, onMetrics: (id: string, metrics: FleetRowMetrics) => void) {
|
||||||
|
const qc = makeQc();
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<DemoProvider>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<ServerWorkspaceRows
|
||||||
|
instance={instance}
|
||||||
|
onOpenWorkspace={vi.fn()}
|
||||||
|
onMetrics={onMetrics}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</DemoProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ServerWorkspaceRows — onMetrics stability", () => {
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it("calls onMetrics with health:ok after data loads", async () => {
|
||||||
|
const onMetrics = vi.fn<(id: string, m: FleetRowMetrics) => void>();
|
||||||
|
renderRows(neo, onMetrics);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onMetrics).toHaveBeenCalledWith(
|
||||||
|
"neo",
|
||||||
|
expect.objectContaining({ health: "ok", workspaceCount: 1, conclusionCount: 3 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call onMetrics again when values have not changed", async () => {
|
||||||
|
const onMetrics = vi.fn<(id: string, m: FleetRowMetrics) => void>();
|
||||||
|
renderRows(neo, onMetrics);
|
||||||
|
|
||||||
|
// Wait until we have at least one call with stable state
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onMetrics).toHaveBeenCalledWith("neo", expect.objectContaining({ health: "ok" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const callsBefore = onMetrics.mock.calls.length;
|
||||||
|
|
||||||
|
// Flush any pending micro-tasks / React batched updates
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
// onMetrics must not have been called again — no render loop
|
||||||
|
expect(onMetrics).toHaveBeenCalledTimes(callsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onMetrics when health transitions from loading to ok", async () => {
|
||||||
|
const onMetrics = vi.fn<(id: string, m: FleetRowMetrics) => void>();
|
||||||
|
renderRows(neo, onMetrics);
|
||||||
|
|
||||||
|
// Must eventually report ok (not just loading)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onMetrics).toHaveBeenCalledWith("neo", expect.objectContaining({ health: "ok" })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ describe("SettingsForm — self-hosted preset", () => {
|
|||||||
renderForm(<SettingsForm instance={null} preset="self-hosted" />);
|
renderForm(<SettingsForm instance={null} preset="self-hosted" />);
|
||||||
const baseUrl = screen.getByPlaceholderText("http://localhost:8000");
|
const baseUrl = screen.getByPlaceholderText("http://localhost:8000");
|
||||||
await user.clear(baseUrl);
|
await user.clear(baseUrl);
|
||||||
await user.type(baseUrl, "http://100.67.206.76:8000");
|
await user.type(baseUrl, "http://192.0.2.10:8000");
|
||||||
await user.type(
|
await user.type(
|
||||||
screen.getByPlaceholderText(/required only if your instance has auth enabled/i),
|
screen.getByPlaceholderText(/required only if your instance has auth enabled/i),
|
||||||
"secret-token",
|
"secret-token",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import path from "path";
|
import { defineConfig, type Plugin } from "vite";
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
@@ -12,9 +12,80 @@ const { version } = JSON.parse(
|
|||||||
readFileSync(path.resolve(__dirname, "../../package.json"), "utf-8"),
|
readFileSync(path.resolve(__dirname, "../../package.json"), "utf-8"),
|
||||||
) as { version: string };
|
) as { version: string };
|
||||||
|
|
||||||
|
// Dev-mode mirror of the nginx /api reverse proxy: read X-Honcho-Upstream and
|
||||||
|
// forward /api/* there, so `make dev-web` behaves identically to the docker image
|
||||||
|
// (same-origin requests, no browser CORS). Connect strips the /api mount prefix,
|
||||||
|
// so req.url is already the upstream path (e.g. /v3/workspaces/list).
|
||||||
|
function honchoApiProxy(): Plugin {
|
||||||
|
const HEADER = "x-honcho-upstream";
|
||||||
|
// Mirror nginx's allowlist (spec §D): unset/empty => open; otherwise only
|
||||||
|
// matching upstream hosts forward. Glob `*` -> any non-slash run, like nginx.
|
||||||
|
const raw = process.env.OPENCONCHO_UPSTREAM_ALLOWLIST?.trim();
|
||||||
|
const allowlist: RegExp[] | null = raw
|
||||||
|
? raw
|
||||||
|
.split(",")
|
||||||
|
.map((h) => h.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((host) => {
|
||||||
|
const esc = host.replace(/[.]/g, "\\.").replace(/[*]/g, "[^/]*");
|
||||||
|
return new RegExp(`^https?://${esc}(:[0-9]+)?(/.*)?$`);
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
name: "honcho-api-proxy",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use("/api", async (req, res) => {
|
||||||
|
const upstream = req.headers[HEADER];
|
||||||
|
if (typeof upstream !== "string" || upstream.trim() === "") {
|
||||||
|
res.statusCode = 421;
|
||||||
|
res.setHeader("X-Honcho-Proxy-Reject", "no-upstream");
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allowlist && !allowlist.some((re) => re.test(upstream))) {
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.setHeader("X-Honcho-Proxy-Reject", "allowlist");
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = upstream.replace(/\/+$/, "") + (req.url ?? "");
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const c of req) chunks.push(c as Buffer);
|
||||||
|
try {
|
||||||
|
const upstreamRes = await fetch(target, {
|
||||||
|
method: req.method,
|
||||||
|
headers: {
|
||||||
|
"content-type": req.headers["content-type"] ?? "application/json",
|
||||||
|
...(req.headers.authorization ? { authorization: req.headers.authorization } : {}),
|
||||||
|
},
|
||||||
|
body: ["GET", "HEAD"].includes(req.method ?? "") ? undefined : Buffer.concat(chunks),
|
||||||
|
});
|
||||||
|
res.statusCode = upstreamRes.status;
|
||||||
|
// undici's fetch auto-decompresses the body, so the original
|
||||||
|
// content-encoding/length no longer describe what we re-send —
|
||||||
|
// drop those (and hop-by-hop headers) to avoid ERR_CONTENT_DECODING_FAILED.
|
||||||
|
const SKIP = new Set([
|
||||||
|
"content-encoding",
|
||||||
|
"content-length",
|
||||||
|
"transfer-encoding",
|
||||||
|
"connection",
|
||||||
|
]);
|
||||||
|
upstreamRes.headers.forEach((v, k) => {
|
||||||
|
if (!SKIP.has(k.toLowerCase())) res.setHeader(k, v);
|
||||||
|
});
|
||||||
|
res.end(Buffer.from(await upstreamRes.arrayBuffer()));
|
||||||
|
} catch (e) {
|
||||||
|
res.statusCode = 502;
|
||||||
|
res.end(`proxy error: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
plugins: [tanstackRouter({ autoCodeSplitting: true }), react(), tailwindcss()],
|
plugins: [tanstackRouter({ autoCodeSplitting: true }), react(), honchoApiProxy(), tailwindcss()],
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(version),
|
__APP_VERSION__: JSON.stringify(version),
|
||||||
},
|
},
|
||||||
|
|||||||
1027
pnpm-lock.yaml
generated
1027
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ allowBuilds:
|
|||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
# Standard tooling
|
# Standard tooling
|
||||||
"@biomejs/biome": "^2.4.0"
|
"@biomejs/biome": "^2.4.16"
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
"@playwright/test": "^1.59.1"
|
"@playwright/test": "^1.59.1"
|
||||||
@@ -15,16 +15,16 @@ catalog:
|
|||||||
"@types/react": "^19.2.14"
|
"@types/react": "^19.2.14"
|
||||||
"@types/react-dom": "^19.2.3"
|
"@types/react-dom": "^19.2.3"
|
||||||
"@vitejs/plugin-react": "^6.0.1"
|
"@vitejs/plugin-react": "^6.0.1"
|
||||||
"@vitest/coverage-v8": "^4.0.0"
|
"@vitest/coverage-v8": "^4.1.8"
|
||||||
jsdom: "^26.1.0"
|
jsdom: "^29.1.1"
|
||||||
|
|
||||||
# React
|
# React
|
||||||
react: "^19.2.5"
|
react: "^19.2.5"
|
||||||
react-dom: "^19.2.5"
|
react-dom: "^19.2.5"
|
||||||
semantic-release: "^25.0.0"
|
semantic-release: "^25.0.3"
|
||||||
typescript: "~6.0.2"
|
typescript: "~6.0.2"
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite: "^8.0.10"
|
vite: "^8.0.10"
|
||||||
vitest: "^4.0.0"
|
vitest: "^4.1.8"
|
||||||
zod: "^4.0.0"
|
zod: "^4.0.0"
|
||||||
|
|||||||
@@ -52,12 +52,17 @@ check_pattern "Honcho-style JWT (likely)" 'eyJ[A-Za-z0-9_-]{20,}\.eyJ[A-Za-z0-9_
|
|||||||
check_pattern "RSA/EC/DSA/OpenSSH private key block" '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'
|
check_pattern "RSA/EC/DSA/OpenSSH private key block" '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'
|
||||||
check_pattern "Generic hardcoded password" '(password|passwd|pwd)[[:space:]]*[:=][[:space:]]*["'\'']\w{8,}["'\'']'
|
check_pattern "Generic hardcoded password" '(password|passwd|pwd)[[:space:]]*[:=][[:space:]]*["'\'']\w{8,}["'\'']'
|
||||||
|
|
||||||
|
# Environment-specific values — keep live infra out of committed code/docs/PRs.
|
||||||
|
# Use examples instead (honcho.example.net; 192.0.2.x per RFC 5737 TEST-NET).
|
||||||
|
check_pattern "Tailnet hostname (env-specific; use example.net)" '[A-Za-z0-9-]+\.ts\.net'
|
||||||
|
check_pattern "Tailnet/CGNAT IP (env-specific; use 192.0.2.x)" '100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.[0-9]{1,3}\.[0-9]{1,3}'
|
||||||
|
|
||||||
if [ $FOUND -eq 1 ]; then
|
if [ $FOUND -eq 1 ]; then
|
||||||
printf '\n\033[31m✗ Secret scan: potential secrets in staged changes\033[0m\n' >&2
|
printf '\n\033[31m✗ Secret scan: potential secrets or environment-specific values in staged changes\033[0m\n' >&2
|
||||||
printf '%b' "$FINDINGS" >&2
|
printf '%b' "$FINDINGS" >&2
|
||||||
printf '\n' >&2
|
printf '\n' >&2
|
||||||
printf 'If this is a false positive, bypass with: \033[33mgit commit --no-verify\033[0m\n' >&2
|
printf 'If this is a false positive, bypass with: \033[33mgit commit --no-verify\033[0m\n' >&2
|
||||||
printf 'Otherwise: remove the secret, rotate the credential, and re-stage.\n\n' >&2
|
printf 'Otherwise: remove the secret/value (use an example), rotate if a credential, and re-stage.\n\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user