91 Commits

Author SHA1 Message Date
148766312e fix: use template literal for metadata.name -> ReactNode compat 2026-07-07 20:52:33 -04:00
97ec6ccbae fix: cast metadata.name to string for ReactNode compat 2026-07-07 20:49:54 -04:00
9d32d32d38 fix: TS errors in API queries and session list
- page_size -> size to match API schema
- reverse -> filters.reverse to match SessionGet body type
- session.name -> session.metadata?.name (Session type has no name field)
2026-07-07 20:42:45 -04:00
a8f0828727 "fix-sort-and-display-name" 2026-07-07 20:42:45 -04:00
github-actions[bot]
b250492925 chore(release): 0.16.0 [skip ci]
# [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](1c28cae3f2))
* **dashboard:** guard setMetricsById against same-value calls to end loop ([3b88a41](3b88a41afd))
* **dashboard:** remove lastSeen from metrics useMemo deps to break render loop ([f79cdaf](f79cdafba7))
* **dashboard:** use primitive deps in onMetrics effect to break render loop ([9cc8637](9cc8637dc7))
* **test:** remove unused within import from fleet.test.tsx ([36fb6ee](36fb6ee519))
* **web:** break metricsEqual lastSeen dep-loop; add staleTime to test ([173f096](173f096e33))
* **web:** reset serverFilter when the selected instance is removed ([699ec38](699ec38480))
* **web:** stop Sidebar re-render loop from cache-event subscriber ([7e529c8](7e529c8c44))

### Features

* **web:** merge Fleet into a server-filterable Dashboard ([e66f927](e66f927f89)), closes [#54](https://github.com/offendingcommit/openconcho/issues/54)
* **web:** redirect /fleet to Dashboard; update fleet tests ([da126b2](da126b2e74))
2026-06-10 12:20:29 +00:00
Offending Commit
01371db6ae Merge pull request #61 from offendingcommit/feat/dashboard-fleet-merge 2026-06-10 07:19:26 -05:00
Offending Commit
7e529c8c44 fix(web): stop Sidebar re-render loop from cache-event subscriber
setNow(Date.now()) was called on every query-cache event, including
events dispatched synchronously during ServerWorkspaceRows' render.
On CI, consecutive Date.now() calls cross millisecond boundaries so
each call returns a new value — React always re-renders Sidebar, which
re-renders the layout, which re-renders ServerWorkspaceRows, which
fires more cache events. After ~25 cycles React throws "Maximum update
depth exceeded."

Fix: remove setNow from the cache subscriber. setNow now fires only
from the 30s interval timer, where it correctly refreshes the "X ago"
display text without triggering a render loop.

Also adds a deterministic CI-repro test that mocks Date.now to return
incrementing values and asserts the filter-change path completes without
looping.
2026-06-03 18:11:30 -05:00
Offending Commit
3b88a41afd fix(dashboard): guard setMetricsById against same-value calls to end loop
Even with primitive useEffect deps, TanStack Query or React concurrent
rendering can cause onMetrics to fire with identical values mid-render-cycle.
Add a ref-based equality check in Dashboard.onMetrics: if all five metric
values are unchanged, skip setMetricsById entirely — no state update, no
Dashboard re-render, loop terminates.

Also fixes vi.fn<TFunction>() typing in server-workspace-rows.test.tsx to
satisfy tsc (Vitest 4 single-type-arg signature).
2026-06-03 17:48:22 -05:00
Offending Commit
9cc8637dc7 fix(dashboard): use primitive deps in onMetrics effect to break render loop
Using a metrics object as a useEffect dep causes the loop:
  onMetrics → setMetricsById → Dashboard re-renders → ServerWorkspaceRows
  re-renders → useQueries runs → TanStack Query cache subscriber fires
  (Sidebar's setNow) → query result objects are new references → metrics
  useMemo returns new object → effect dep changed → onMetrics again → ∞

Fix: depend on the five primitive values (workspaceCount, conclusionCount,
queueActive, queuePending, health) directly. React compares primitives by
value, so the effect only fires when actual data changes, not on reference
churn from re-renders.

Also adds server-workspace-rows.test.tsx with three focused unit tests:
correct health:ok report, stability after load (no re-fire), and health
transition coverage.
2026-06-03 17:48:21 -05:00
Offending Commit
e59bfd908a Merge branch 'main' into feat/dashboard-fleet-merge 2026-06-03 17:28:52 -05:00
Offending Commit
3c99af7143 refactor(dashboard): drop unused lastSeen local var after metrics fix 2026-06-03 17:27:55 -05:00
Offending Commit
f79cdafba7 fix(dashboard): remove lastSeen from metrics useMemo deps to break render loop
lastSeen tracked workspacesQ.dataUpdatedAt which updates on every background
refetch, creating a new metrics object reference each cycle. The useEffect
dep on metrics then fired unconditionally, calling onMetrics → setMetricsById
→ Dashboard re-render → another refetch → infinite loop.

computeFleetAggregates never reads lastSeen, so reporting it upward was
pointless. Hardcode null and drop it from the dep array entirely.
2026-06-03 17:27:42 -05:00
Offending Commit
36fb6ee519 fix(test): remove unused within import from fleet.test.tsx 2026-06-03 17:10:32 -05:00
Offending Commit
da126b2e74 feat(web): redirect /fleet to Dashboard; update fleet tests
/fleet now redirects to / so bookmarks and muscle-memory links land on the
unified Dashboard rather than a 404-style dead route. Fleet tests updated
to assert the redirect and the per-instance rows the Dashboard renders.
2026-06-03 17:10:02 -05:00
Offending Commit
699ec38480 fix(web): reset serverFilter when the selected instance is removed
If the user filters to a specific server and that instance is then deleted
(e.g. from Settings), shownInstances becomes [] — empty table, no message.
A useEffect resets the filter to ALL_SERVERS whenever the selected ID
disappears from the instances list.
2026-06-03 17:03:10 -05:00
Offending Commit
173f096e33 fix(web): break metricsEqual lastSeen dep-loop; add staleTime to test
Remove lastSeen from metricsEqual in ServerWorkspaceRows: computeFleetAggregates
never reads lastSeen, so comparing it in metricsEqual causes background TanStack
Query refetches (dataUpdatedAt changes) to trigger spurious onMetrics callbacks,
cascading into a Dashboard → ServerWorkspaceRows render loop that hits React's
100-render limit in CI.

Add staleTime: Infinity to the test QueryClient to prevent background refetches
from interfering with test assertions.
2026-06-03 17:01:19 -05:00
Offending Commit
1c28cae3f2 fix(ci): replace userEvent.selectOptions with fireEvent.change; bump setup-node to v6
userEvent.selectOptions hangs in jsdom when firing pointer + change event
sequences — fireEvent.change fires the React-controlled onChange directly
and is deterministic. Removes userEvent import (no longer used).

Bump actions/setup-node from v4 to v6 to clear the Node.js 20 deprecation
warning on GitHub Actions runners.
2026-06-03 16:41:40 -05:00
github-actions[bot]
2a6ddb9f78 chore(release): 0.15.0 [skip ci]
# [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](d5a65d73b5))
* **helm:** pdb mutual exclusion, ingress null rules guard, hpa nil utilization guard ([b4939bd](b4939bd57f))
* **helm:** pin busybox:1.36, add -T 10 timeout, use --spider, add activeDeadlineSeconds ([8fac5d0](8fac5d060f))
* **helm:** use http://json-schema.org/draft-07/schema# for Helm compatibility ([8d41455](8d41455e39))

### Features

* **helm:** add _helpers.tpl with name, label, and imageTag partials ([0268275](02682750ab))
* **helm:** add Deployment template with read-only FS, tmpfs, probes ([514e1d4](514e1d46c0))
* **helm:** add NOTES.txt with access instructions and NetworkPolicy/Ingress warning ([ce211df](ce211df48c))
* **helm:** add optional HPA, PDB, and NetworkPolicy templates ([b0b648b](b0b648bdcf))
* **helm:** add optional Ingress template ([9aa106c](9aa106cede))
* **helm:** add Service and ServiceAccount templates ([ee916ea](ee916eabc4))
* **helm:** add test-healthz and test-spa-root helm test jobs ([ee4630e](ee4630e79c))
* **helm:** chart scaffold — Chart.yaml, values, schema ([4112270](411227046a))
2026-06-03 21:37:08 +00:00
Offending Commit
11eec585fa Merge pull request #62 from offendingcommit/worktree-feat+helm-chart
feat(helm): add Helm chart for self-hosted openconcho web UI
2026-06-03 16:35:58 -05:00
Offending Commit
d81e7f17ac docs(helm): annotate values.yaml, add chart README, ArgoCD example, update root README and AGENTS.md 2026-06-03 16:32:28 -05:00
Offending Commit
4ebd4cc211 ci(helm): publish chart to ghcr oci on release tags 2026-06-03 11:29:04 -05:00
Offending Commit
8fac5d060f fix(helm): pin busybox:1.36, add -T 10 timeout, use --spider, add activeDeadlineSeconds 2026-06-03 11:22:19 -05:00
Offending Commit
ee4630e79c feat(helm): add test-healthz and test-spa-root helm test jobs 2026-06-03 11:16:27 -05:00
Offending Commit
ce211df48c feat(helm): add NOTES.txt with access instructions and NetworkPolicy/Ingress warning 2026-06-03 11:16:23 -05:00
Offending Commit
b4939bd57f fix(helm): pdb mutual exclusion, ingress null rules guard, hpa nil utilization guard 2026-06-03 11:14:47 -05:00
Offending Commit
b0b648bdcf feat(helm): add optional HPA, PDB, and NetworkPolicy templates 2026-06-03 11:07:11 -05:00
Offending Commit
9aa106cede feat(helm): add optional Ingress template 2026-06-03 11:07:03 -05:00
Offending Commit
ee916eabc4 feat(helm): add Service and ServiceAccount templates 2026-06-03 11:06:53 -05:00
Offending Commit
d5a65d73b5 fix(helm): guard tmpfs blocks when empty, cap volume names at 63 chars 2026-06-03 11:04:15 -05:00
Offending Commit
514e1d46c0 feat(helm): add Deployment template with read-only FS, tmpfs, probes 2026-06-03 11:00:54 -05:00
Offending Commit
02682750ab feat(helm): add _helpers.tpl with name, label, and imageTag partials 2026-06-03 10:59:04 -05:00
Offending Commit
8d41455e39 fix(helm): use http://json-schema.org/draft-07/schema# for Helm compatibility 2026-06-03 10:56:37 -05:00
Offending Commit
411227046a feat(helm): chart scaffold — Chart.yaml, values, schema 2026-06-03 10:51:38 -05:00
Offending Commit
e66f927f89 feat(web): merge Fleet into a server-filterable Dashboard
Implements Phase 1 of the UI navigation rework (docs/superpowers/specs/
2026-06-02-ui-navigation-rework.md): the Dashboard now lists every workspace
across every configured server as <workspace> (<server>), filterable by server,
with cross-server aggregate cards (reusing computeFleetAggregates). Opening a
workspace activates its server then drills into the existing detail route.
Per-server fan-out lives in a ServerWorkspaceRows child (rules-of-hooks safe,
mirrors FleetRow). Fleet removed from the sidebar nav; the /fleet route is left
intact for now (full removal deferred to avoid churning fleet.test.tsx while #54
is open).
2026-06-02 16:41:51 -05:00
Offending Commit
3677575f65 Merge pull request #57 from offendingcommit/chore/deps-consolidated
chore(deps): consolidate Dependabot PRs #34-53 + widen grouping
2026-06-02 15:41:21 -05:00
Offending Commit
765c618a76 ci(dependabot): widen grouping to cut PR volume
Collapse minor+patch bumps into one PR per ecosystem (npm/cargo) with majors in
their own grouped PR; add a github-actions group so action bumps batch instead of
one PR each. Replaces the many narrow per-family groups.
2026-06-02 15:38:23 -05:00
Offending Commit
85b56ca0f8 chore(deps): consolidate dependency bumps (supersedes #34-53)
npm (lockfile + catalog): tanstack, @tauri-apps/*, turbo 2.9.x, biome, vite,
@vitejs/plugin-react, vitest+coverage-v8 4.1.8 (lockstep), @playwright/test,
and jsdom 26->29 (major). cargo: tauri 2.11.2, tauri-plugin-http 2.5.9,
tauri-build 2.6.2, serde_json. github-actions: docker setup-qemu v4,
setup-buildx v4, build-push v7. Validated: make ci-web, cargo-check, actionlint.
2026-06-02 15:38:22 -05:00
github-actions[bot]
c474767ba1 chore(release): 0.14.0 [skip ci]
# [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](66b299a28e))
* **docker:** drop dead HONCHO_UPSTREAM and same-origin default ([a2854ab](a2854ab8ea))
* **web:** enforce upstream allowlist in vite dev proxy ([b4fac95](b4fac95f37))
* **web:** raise connection-test timeout for cold upstreams ([409d7d8](409d7d8be7))
* **web:** strip content-encoding from vite dev proxy responses ([6b602c0](6b602c05bb))

### Features

* **docker:** header-driven /api reverse proxy in nginx ([753c978](753c978f56))
* **docker:** render SSRF allowlist map from env ([0af1ad9](0af1ad923c))
* **docker:** split compose into dev-forward build and prod pull ([c9bd2db](c9bd2db07d))
* **web:** add dispatchFor transport helper for same-origin proxy ([9945e4c](9945e4cf14))
* **web:** dev /api proxy middleware mirroring nginx ([ab8a1ba](ab8a1ba866))
* **web:** route checkConnection and discovery through the proxy ([9893230](9893230cde))
* **web:** route web build through same-origin /api proxy ([0935099](0935099bc2))
2026-06-02 20:20:40 +00:00
Offending Commit
5a2543592a Merge pull request #54 from offendingcommit/feat/web-api-proxy
feat(web): eliminate browser CORS via header-driven /api proxy
2026-06-02 15:19:32 -05:00
Offending Commit
4349864234 Merge pull request #56 from offendingcommit/chore/env-specific-hardening
chore: harden against environment-specific leaks
2026-06-02 15:19:05 -05:00
Offending Commit
1aa1c0456f chore(hooks): flag tailnet hostnames and CGNAT IPs in the secret scan
Extend the pre-commit secret-scan to catch environment-specific values
(*.ts.net MagicDNS names and 100.64.0.0/10 tailnet IPs) so live infra can't be
committed into code, docs, or examples. Verified: detects leaks, no false
positive on 192.0.2.x or non-CGNAT 100.x, and the script does not self-trip.
2026-06-02 15:12:33 -05:00
Offending Commit
239eb3327a test(web): use a documentation IP instead of a tailnet-range fixture
Swap the 100.x CGNAT example for 192.0.2.10 (RFC 5737 TEST-NET-1) in the
token-transport guard tests — keeps the non-loopback-HTTP assertion, drops an
environment-specific address.
2026-06-02 15:11:17 -05:00
Offending Commit
08b77839b1 docs: scrub environment-specific endpoint from proxy spec
Replace the real tailnet hostname and IP with honcho.example.net / a generic
tailnet reference — specs and PRs should carry examples, not live endpoints.
2026-06-02 15:05:25 -05:00
Offending Commit
3ea6a73833 test(web): cover instance store CRUD and legacy migration
Closes the multi-instance coverage gap: add (first becomes active, later adds
don't steal focus, insertion order), switch (incl. unknown-id no-op), delete
(active->first-remaining fallback, non-active unchanged, last clears active),
update (patch + unknown-id no-op), active config, and legacy-key migration.
2026-06-02 14:36:23 -05:00
Offending Commit
96dff8900e refactor(web): use top-level z.url() over deprecated z.string().url()
Zod v4 deprecates the chained .url() in favor of the top-level format validator.
2026-06-02 14:31:40 -05:00
Offending Commit
409d7d8be7 fix(web): raise connection-test timeout for cold upstreams
A cold/idle self-hosted Honcho can take ~5s on its first request (DB pool, tunnel
wake); the hardcoded 5s budget aborted just before the response and reported a
live instance as 'Connection timed out'. Extract CONNECTION_TIMEOUT_MS (15s),
make checkConnection's timeout injectable, and cover the budget behavior.
2026-06-02 14:18:19 -05:00
Offending Commit
4ccf8f2746 refactor(docker): use compose profiles instead of a prod override file
Collapse docker-compose.yml + docker-compose.prod.yml into one file with dev/prod
profiles sharing a YAML anchor: dev builds from source, prod pulls ghcr latest.
make up/prod select the profile; down passes both so it stops either. Drops the
separate prod file and the !reset hack.
2026-06-02 14:02:36 -05:00
Offending Commit
4e4843ce1a refactor(make): rename compose targets to up/prod/down/clean
Concise verbs: make up (dev build+run), make prod (pull published image),
make down (stop regardless of mode), make clean (down + drop local image).
Updates all docs and compose-file comment headers to match.
2026-06-02 13:59:04 -05:00
Offending Commit
e1285c73ad docs: document dev/prod compose modes and make targets
README, docs/docker.md, and AGENTS.md now cover make compose-up (dev-forward,
builds from source) vs make compose-up-prod (pulls ghcr latest) and compose-down.
2026-06-02 13:51:23 -05:00
Offending Commit
c9bd2db07d feat(docker): split compose into dev-forward build and prod pull
docker-compose.yml now builds from source (dev-forward: run your local changes);
docker-compose.prod.yml overrides it to pull ghcr latest (build reset via !reset).
Adds make compose-up / compose-up-prod / compose-down. Env, ports, and extra_hosts
stay defined once in the base file; the prod override only swaps build -> image.
2026-06-02 13:50:27 -05:00
Offending Commit
56b3d18f40 test(docker): add hermetic /api proxy smoke test
make smoke-docker builds the image, stands up a stub upstream + the container
on a shared network, and asserts forward+prefix-strip, upstream-header cleared,
421 on missing header, and 403 + reject sentinel on allowlist miss. Self-
contained (no tailnet), idempotent, local-only (Docker) like cargo-check.
2026-06-02 13:43:07 -05:00
Offending Commit
66b299a28e fix(docker): derive nginx resolver from container DNS
Hardcoded resolver 127.0.0.11 only exists on user-defined networks, so a plain
docker run on the default bridge 502'd (DNS connection refused). Render the
resolver from /etc/resolv.conf at start so per-request proxy_pass resolves on
both the default bridge (host DNS) and compose networks (embedded DNS).
2026-06-02 13:33:50 -05:00
Offending Commit
6b602c05bb fix(web): strip content-encoding from vite dev proxy responses
undici fetch auto-decompresses the body, so re-sending the upstream
content-encoding/length would cause ERR_CONTENT_DECODING_FAILED if Honcho
gzips. Drop those and hop-by-hop headers when relaying. nginx is unaffected.
2026-06-02 13:28:32 -05:00
Offending Commit
b4fac95f37 fix(web): enforce upstream allowlist in vite dev proxy
Mirrors the nginx allowlist (spec section D) so make dev-web matches prod: when
OPENCONCHO_UPSTREAM_ALLOWLIST is set, non-matching upstreams get 403 +
X-Honcho-Proxy-Reject; unset stays open.
2026-06-02 13:25:12 -05:00
Offending Commit
a2854ab8ea fix(docker): drop dead HONCHO_UPSTREAM and same-origin default
The published image defaulted OPENCONCHO_DEFAULT_HONCHO_URL=same-origin, but the
sentinel was removed — a bare run seeded an invalid "same-origin" base. Default
to empty (configure in Settings); HONCHO_UPSTREAM is unused by the new nginx.
2026-06-02 13:25:11 -05:00
Offending Commit
7357072b9e docs(docker): drop stale same-origin sentinel from entrypoint comment 2026-06-02 13:17:25 -05:00
Offending Commit
9a35be7b15 docs: document the /api proxy contract and env vars
Retire HONCHO_UPSTREAM and the same-origin sentinel; document the per-request
X-Honcho-Upstream header model, OPENCONCHO_DEFAULT_HONCHO_URL seeding, and the
optional OPENCONCHO_UPSTREAM_ALLOWLIST SSRF guard across compose, README,
AGENTS.md, and docs/docker.md.
2026-06-02 13:16:30 -05:00
Offending Commit
ab8a1ba866 feat(web): dev /api proxy middleware mirroring nginx 2026-06-02 13:13:48 -05:00
Offending Commit
0af1ad923c feat(docker): render SSRF allowlist map from env 2026-06-02 13:12:29 -05:00
Offending Commit
753c978f56 feat(docker): header-driven /api reverse proxy in nginx 2026-06-02 11:57:55 -05:00
Offending Commit
b29fa240a6 refactor(web): drop same-origin sentinel from runtime config 2026-06-02 11:53:42 -05:00
Offending Commit
9893230cde feat(web): route checkConnection and discovery through the proxy 2026-06-02 11:52:17 -05:00
Offending Commit
90823d12a6 docs: amend proxy plan with absolute-base fix and task 4 test mocking
Records the post-execution design correction (absolute same-origin base) and
rewrites the checkConnection test to mock @/lib/http rather than globalThis.fetch.
2026-06-02 11:49:34 -05:00
Offending Commit
0935099bc2 feat(web): route web build through same-origin /api proxy
client.current and createScopedClient resolve transport via dispatchFor:
web -> absolute origin + /api base with an X-Honcho-Upstream header; Tauri ->
absolute instance URL + reqwest. Absolute base (not bare "/api") so openapi-fetch
can construct a Request under node/undici and in the browser alike. Fleet fan-out
is unchanged; fleet.test.tsx now asserts the proxy contract.
2026-06-02 11:48:13 -05:00
Offending Commit
9945e4cf14 feat(web): add dispatchFor transport helper for same-origin proxy 2026-06-02 11:38:58 -05:00
Offending Commit
d4452abcea refactor(web): extract isTauri into a leaf platform module 2026-06-02 11:36:21 -05:00
Offending Commit
3bb1150773 docs: add header-driven /api proxy implementation plan
Ten TDD tasks: dispatchFor helper, client/checkConnection/discovery routing,
runtime-config simplification, nginx header proxy, allowlist map, vite dev
parity, env + docs. Gated by make ci-web after each task.
2026-06-02 11:33:26 -05:00
Offending Commit
ff9b298116 docs: add header-driven /api proxy design spec
Same-origin reverse proxy removes browser CORS for the web build; upstream
named per-request via X-Honcho-Upstream header (frontend stays source of
truth). Tauri keeps reqwest-absolute. Optional SSRF allowlist, open by
default. Preserves existing Fleet aggregation; new aggregation deferred.
2026-06-02 11:26:50 -05:00
github-actions[bot]
239d70f2b6 chore(release): 0.13.1 [skip ci]
## [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](fde483657f))
2026-05-29 16:49:55 +00:00
Offending Commit
9ab9d52cae Merge pull request #49 from offendingcommit/fix/docker-compose-standalone
fix(docker): make docker-compose runnable standalone
2026-05-29 11:48:51 -05:00
Offending Commit
fde483657f fix(docker): make docker-compose runnable standalone
The service declared depends_on the Honcho 'api' service, which doesn't
exist in this standalone compose — 'docker compose up' failed with
'depends on undefined service api'. Drop the dependency, default
HONCHO_UPSTREAM to the host's Honcho (host.docker.internal:8000,
overridable), and add an extra_hosts mapping so the default also
resolves on Linux. Combined-stack instructions moved to comments.
2026-05-29 11:47:19 -05:00
Offending Commit
af83be0a32 docs(claude): make CLAUDE.md a shim that imports AGENTS.md
CLAUDE.md was a full, drifting duplicate of AGENTS.md. Replace it with a
one-line @AGENTS.md import so AGENTS.md is the single source of truth for
agent context. Removed content was a stale subset (old openconcho:config
key, older rules description) — AGENTS.md already has the current version.

Type docs → no version bump.
2026-05-29 11:39:38 -05:00
Offending Commit
8f8a64c984 Merge pull request #48 from offendingcommit/docs/refresh-agents-readme
docs: refresh AGENTS.md + README for v0.13.0 accuracy
2026-05-29 11:38:25 -05:00
Offending Commit
ad2d131ae4 docs: refresh AGENTS.md + README for v0.13.0 accuracy
- Correct localStorage key (openconcho:instances, not :config) in both
- AGENTS.md: note local husky pre-commit/pre-push gates
- README: fix prerequisites (Node >=22, pnpm 10)
- README: add shipped features (fleet, seed kits, multi-instance, dream
  viewer, dialectic playground, peer display names, demo mode)
- README: add .rpm to downloads + a Docker/Compose quick-start section
2026-05-29 11:37:03 -05:00
Offending Commit
e3190c0bd7 ci(docker): simplify publish to match working repo pattern
Revert the over-engineered linking attempts (provenance: false,
index annotations, dispatch tag input, Dockerfile source LABEL). Our
working repos (e.g. offendingcommit/infra) link GHCR packages with the
plain metadata-action labels + GITHUB_TOKEN push — none of the extras
helped or were needed. Match that.

Package<->repo linking is handled by the one-time Connect Repository
step in package settings.

Type ci → no version bump.
2026-05-29 11:27:58 -05:00
Offending Commit
3a22f8e9c8 Merge pull request #46 from offendingcommit/build/dockerfile-source-label
build(docker): bake image source label for GHCR repo linking
2026-05-29 11:08:04 -05:00
Offending Commit
e5e930d381 build(docker): bake org.opencontainers.image.source into the image
The canonical, build-tool-independent way GHCR links a container package
to its repo is a Dockerfile LABEL baked into the image config — not
buildx/metadata-action annotations. Add it so freshly-created packages
auto-connect.

Note: GHCR evaluates the source at package CREATION, so this links new
packages; an already-orphaned package needs a one-time manual connect
(or delete + re-publish) regardless.

Type build → no version bump.
2026-05-29 11:05:30 -05:00
Offending Commit
7354879596 Merge pull request #44 from offendingcommit/ci/ghcr-repo-link
ci(docker): link GHCR package to repo via index annotation
2026-05-29 10:43:35 -05:00
Offending Commit
5bd2e8a1f0 Merge branch 'main' into ci/ghcr-repo-link 2026-05-29 10:43:25 -05:00
Offending Commit
526c45d9ce ci(docker): annotate image index so GHCR links the package to the repo
The publish step passed labels but not annotations, so
org.opencontainers.image.source landed only on per-platform configs, not
the multi-arch index. GHCR reads the source from the index annotation to
auto-link a package to its repo, so the package stayed orphaned.

- Set DOCKER_METADATA_ANNOTATIONS_LEVELS=index + pass annotations to the
  build, putting source/url/revision on the index.
- provenance: false keeps the pushed artifact a clean multi-arch index.
- workflow_dispatch now takes a 'tag' input to (re)publish a specific
  version; tags derived from the ref so dispatch and release both work.

Type ci → no version bump; republish v0.13.0 via dispatch.
2026-05-29 10:42:58 -05:00
Offending Commit
ec1a1b3665 Merge pull request #33 from offendingcommit/dependabot/github_actions/actions/checkout-6
chore(ci)(deps): bump actions/checkout from 4 to 6
2026-05-29 10:31:57 -05:00
github-actions[bot]
801555222e chore(release): 0.13.0 [skip ci]
# [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](282ba1b76c))
* **web:** add Fleet dashboard view for cross-instance observability ([12712bb](12712bb0b0))
* **web:** configurable peer display name ([3de6832](3de6832a5d))
2026-05-28 22:10:34 +00:00
Offending Commit
04104d1f92 Merge pull request #43 from offendingcommit/feat/docker-compose-support
feat(docker): full self-hosted Compose support
2026-05-28 17:09:23 -05:00
Offending Commit
ab69b0045f Merge pull request #42 from offendingcommit/feat/fleet-dashboard
feat(web): add Fleet dashboard for cross-instance observability
2026-05-28 17:08:35 -05:00
Offending Commit
472b1405a6 Merge pull request #41 from offendingcommit/feat/peer-display-name
feat(web): configurable peer display name
2026-05-28 17:07:50 -05:00
Offending Commit
282ba1b76c feat(docker): full self-hosted Compose support
Make the web image drop-in for a Honcho docker-compose stack:
- nginx reverse-proxies /v3 and /health to $HONCHO_UPSTREAM (variable +
  Docker resolver so it starts even before the upstream resolves), giving
  the SPA a same-origin path to Honcho with no browser CORS.
- Runtime config: an entrypoint writes config.js from
  OPENCONCHO_DEFAULT_HONCHO_URL, so one prebuilt image targets any backend
  ("same-origin" | absolute URL | empty). SPA seeds a first-run default
  instance from it (additive; no-op in dev/desktop).
- docker-compose.yml example service + GHCR multi-arch publish workflow on
  release.
- nginx.conf -> envsubst template; docs rewritten.

Closes #21. Closes #31.
2026-05-28 16:06:03 -05:00
Agents
37eb9bdf59 test(web): add E2E for Fleet route nav + per-instance row render
Two focused Playwright tests, mirroring the existing sidebar.spec
pattern (no backend dependency — instances point at unreachable ports
and we only assert on rendered names + row count, not health):

- Fleet link in the sidebar navigates to /fleet
- /fleet renders one row per configured instance under the table role

Also adds a Fleet link assertion to the existing sidebar.spec so the
nav entry is covered on the dashboard route alongside the other top
links. Both new tests run under the existing `pnpm test:e2e` (not
gated in CI by design — matches the current Compare/Dashboard E2E
posture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:57:17 -05:00
Agents
e90d20893d docs(fleet): add Fleet dashboard screenshots
Adds dark, light, and mixed-health screenshots used in the PR description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:57:17 -05:00
Agents
12712bb0b0 feat(web): add Fleet dashboard view for cross-instance observability
Adds a new /fleet route that shows a fleet-wide overview of all
configured Honcho instances. Each row renders per-instance metrics
(workspace count, total conclusions, queue activity, last seen, health)
by fanning out scoped fetches via createScopedClient, and aggregates
into top-level metric cards.

Reuses the Phase 2 scoped-client pattern. Extends compareQueries.ts
with useScopedQueueStatus and useScopedConclusionsCount, plus option
builders so useQueries can fan out per-workspace requests inside
FleetRow without duplicating query logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:57:17 -05:00
Offending Commit
3de6832a5d feat(web): configurable peer display name
Human peers surface as raw ids (WhatsApp `…-lid`, UUIDs). Read an
optional `display_name` from peer metadata and prefer it over the id in
the peer header and breadcrumb, with the raw id kept as a sub-caption.
An inline edit on the header writes `display_name` via useUpdatePeer
(merging existing metadata); blank clears it. Falls back to the id when
unset.

Closes #32.
2026-05-28 15:56:42 -05:00
dependabot[bot]
793ed230db chore(ci)(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 19:21:19 +00:00
81 changed files with 5319 additions and 1755 deletions

View File

@@ -6,7 +6,7 @@ runs:
steps:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm

View File

@@ -1,5 +1,9 @@
# Dependabot configuration
# 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
updates:
@@ -19,47 +23,17 @@ updates:
- "dependencies"
- "javascript"
groups:
# Keep TanStack libs in lockstep — they release as a family
tanstack:
npm-minor-patch:
patterns:
- "@tanstack/*"
# Tauri JS bindings
tauri:
- "*"
update-types:
- "minor"
- "patch"
npm-major:
patterns:
- "@tauri-apps/*"
# Test stack
testing:
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"
- "*"
update-types:
- "major"
# ─── Rust / Cargo (Tauri desktop shell) ───────────────────────────────────
- package-ecosystem: "cargo"
@@ -77,16 +51,17 @@ updates:
- "dependencies"
- "rust"
groups:
tauri-core:
cargo-minor-patch:
patterns:
- "tauri"
- "tauri-*"
tokio:
- "*"
update-types:
- "minor"
- "patch"
cargo-major:
patterns:
- "tokio"
- "tokio-*"
- "futures"
- "futures-*"
- "*"
update-types:
- "major"
# ─── GitHub Actions workflow pins ─────────────────────────────────────────
- package-ecosystem: "github-actions"
@@ -103,3 +78,7 @@ updates:
labels:
- "dependencies"
- "github-actions"
groups:
github-actions:
patterns:
- "*"

View File

@@ -11,7 +11,7 @@ jobs:
name: Lint, type-check, test & build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: ./.github/actions/setup
@@ -27,7 +27,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}

77
.github/workflows/docker-publish.yml vendored Normal file
View 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

View File

@@ -30,7 +30,7 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.tag || github.ref }}

View File

@@ -16,6 +16,11 @@ Frontend UI for self-hosted Honcho instances — browse memories, peers, session
| `make typecheck` | tsc --noEmit |
| `make test` | Vitest (unit + integration), excludes `e2e/` |
| `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 |
| `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` |
@@ -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/e2e/` | Playwright e2e specs |
| `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) |
| `docs/` | Architecture and references |
@@ -63,7 +69,9 @@ Before pushing any change under `packages/desktop/**` or `packages/desktop/src-t
## 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
- **`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

View File

@@ -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)

View File

@@ -1,62 +1 @@
# openconcho
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
@AGENTS.md

View File

@@ -31,8 +31,17 @@ RUN pnpm --filter @openconcho/web build
FROM nginxinc/nginx-unprivileged:alpine
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
# Base image CMD runs nginx in the foreground as UID 101.
# Base image entrypoint renders the template + runs config script, then nginx (UID 101).

View File

@@ -4,7 +4,8 @@
.PHONY: bootstrap dev dev-web dev-desktop \
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:
@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
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)
pnpm install

View File

@@ -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 |
| **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 |
| **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 |
| **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 |
| **Chat** | Conversational interface through Honcho's chat endpoint with memory context |
| **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 |
| **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 (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` |
## Quick Start
### Prerequisites
- [Node.js](https://nodejs.org/) ≥ 20
- [pnpm](https://pnpm.io/) ≥ 9
- [Node.js](https://nodejs.org/) ≥ 22
- [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)
### Web app
@@ -80,6 +87,61 @@ pnpm install
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
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
- 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`
- No telemetry, no analytics, no external requests beyond your configured Honcho instance

View 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
View 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) |

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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'"

View 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"

View 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" }
}
}
}
}

View 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
View 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

View 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

View File

@@ -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;
}

View 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
View 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

View File

@@ -1,65 +1,104 @@
# Running OpenConcho in Docker
The `@openconcho/web` SPA can be served from a container. The image is a
two-stage build: Node + pnpm builds the static bundle, then
`nginx-unprivileged` serves it on port `8080` as a non-root user.
The `@openconcho/web` SPA ships as a container: a two-stage build (Node + pnpm
builds the static bundle, then `nginx-unprivileged` serves it on port `8080` as
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
docker build -t openconcho-web .
docker run --rm -p 8080:8080 openconcho-web
# → http://localhost:8080
make up # build from source + run → http://localhost:8080
make prod # pull ghcr…:latest instead of building
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
docker run --rm -p 8080:8080 \
--read-only \
--tmpfs /tmp \
--tmpfs /var/cache/nginx \
--cap-drop ALL \
--security-opt no-new-privileges \
openconcho-web
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make prod
```
`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
to browser CORS. The **web build is**: it uses the browser's `fetch`, so every
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.
Drop the `openconcho` service into the project that runs your Honcho `api`,
pointing the seed at the api service (nginx resolves it on the compose network):
### Option 1 — configure Honcho's CORS (recommended)
Honcho is a FastAPI service. Allow the UI's origin via its
`CORSMiddleware` so preflight and actual requests succeed:
```python
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:8080"], # the OpenConcho origin
allow_methods=["*"],
allow_headers=["*"],
)
```yaml
services:
openconcho:
image: ghcr.io/offendingcommit/openconcho-web:latest
environment:
OPENCONCHO_DEFAULT_HONCHO_URL: http://api:8000
ports:
- "127.0.0.1:8080:8080"
depends_on:
api:
condition: service_healthy
restart: unless-stopped
```
This fits OpenConcho's model directly — the UI keeps using the absolute Honcho
URL you enter in Settings (stored in `localStorage`). Since you self-host
Honcho, you control this.
`OPENCONCHO_DEFAULT_HONCHO_URL` seeds the UI's first instance with an absolute
URL. The browser sends that URL in the `X-Honcho-Upstream` header; nginx (on the
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
sees same-origin requests and CORS never applies (the token also never crosses
origins). Uncomment the `location /honcho/` block in
[`docker/nginx.conf`](../docker/nginx.conf) and set `proxy_pass` to your Honcho
host.
```bash
docker run --rm -p 8080:8080 -e OPENCONCHO_DEFAULT_HONCHO_URL=http://host.docker.internal:8000 \
ghcr.io/offendingcommit/openconcho-web:latest
# → http://localhost:8080 · GET /healthz returns "ok"
```
Caveat: the Settings form currently validates the base URL as an **absolute**
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.
Runtime knobs (no rebuild needed):
| 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View 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 13 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.

View 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`.

View File

@@ -1,7 +1,7 @@
{
"name": "openconcho",
"private": true,
"version": "0.12.1",
"version": "0.16.0",
"packageManager": "pnpm@10.33.2",
"engines": {
"node": ">=22",
@@ -27,17 +27,17 @@
},
"devDependencies": {
"@biomejs/biome": "catalog:",
"@commitlint/cli": "~20.5.2",
"@commitlint/config-conventional": "~20.5.0",
"@semantic-release/changelog": "^6.0.0",
"@semantic-release/commit-analyzer": "^13.0.0",
"@commitlint/cli": "~20.5.3",
"@commitlint/config-conventional": "~20.5.3",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.0",
"@semantic-release/github": "^10.0.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^10.3.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",
"semantic-release": "catalog:",
"turbo": "^2"
"turbo": "^2.9.16"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "openconcho"
version = "0.12.1"
version = "0.16.0"
edition = "2021"
[lib]

View 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();
});
});

View File

@@ -17,6 +17,7 @@ test.describe("Sidebar", () => {
await page.goto("/");
await expect(page.getByRole("complementary")).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: /settings/i })).toBeVisible();
});

View File

@@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenConcho</title>
<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>
<body>
<div id="root"></div>

View 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__ = "";

View File

@@ -1,21 +1,12 @@
import createClient from "openapi-fetch";
import { loadConfig } from "@/lib/config";
import { httpFetch } from "@/lib/http";
import { dispatchFor } from "@/lib/dispatch";
import type { paths } from "./schema.d.ts";
export function createHonchoClient() {
const config = loadConfig();
const baseUrl = config?.baseUrl ?? "http://localhost:8000";
const token = config?.token ?? "";
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return createClient<paths>({ baseUrl, headers, fetch: httpFetch });
const config = loadConfig() ?? { baseUrl: "http://localhost:8000", token: "" };
const { baseUrl, headers, fetch } = dispatchFor(config);
return createClient<paths>({ baseUrl, headers, fetch });
}
export const client = {

View File

@@ -16,6 +16,9 @@ const CK = {
["compare", instId, "peer-representation", wsId, pId] as const,
peerCard: (instId: string, wsId: string, pId: string) =>
["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) {
@@ -83,3 +86,43 @@ export function useScopedPeerCard(instance: Instance, workspaceId: string, peerI
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));
}

View File

@@ -15,7 +15,7 @@ export function useWorkspaces(page = 1, pageSize = 20) {
queryKey: QK.workspaces(page, pageSize),
queryFn: async () => {
const { data, error } = await client.current.POST("/v3/workspaces/list", {
params: { query: { page, page_size: pageSize } },
params: { query: { page, size: pageSize } },
body: {},
});
return data ?? err(error);
@@ -139,7 +139,7 @@ export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
const { data, error } = await client.current.POST(
"/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: {},
},
);
@@ -253,7 +253,7 @@ export function usePeerSessions(workspaceId: string, peerId: string, page = 1, p
{
params: {
path: { workspace_id: workspaceId, peer_id: peerId },
query: { page, page_size: pageSize },
query: { page, size: pageSize },
},
body: {},
},
@@ -323,9 +323,9 @@ export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
{
params: {
path: { workspace_id: workspaceId },
query: { page, page_size: pageSize },
query: { page, size: pageSize },
},
body: {},
body: { filters: { reverse: true } },
},
);
return data ?? err(error);
@@ -412,7 +412,7 @@ export function useSessionMessages(
{
params: {
path: { workspace_id: workspaceId, session_id: sessionId },
query: { page, page_size: pageSize },
query: { page, size: pageSize },
},
body: {},
},
@@ -642,7 +642,7 @@ export function useConclusions(
{
params: {
path: { workspace_id: workspaceId },
query: { page, page_size: pageSize, reverse },
query: { page, size: pageSize, reverse },
},
body: filters,
},

View File

@@ -1,18 +1,16 @@
import createClient from "openapi-fetch";
import type { Instance } from "@/lib/config";
import { httpFetch } from "@/lib/http";
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 need to query non-active instances (e.g. side-by-side comparison).
* For single-instance access, prefer `client.current` which tracks the active
* instance via localStorage.
* 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 headers: Record<string, string> = { "Content-Type": "application/json" };
if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
return createClient<paths>({ baseUrl: instance.baseUrl, headers, fetch: httpFetch });
const { baseUrl, headers, fetch } = dispatchFor(instance);
return createClient<paths>({ baseUrl, headers, fetch });
}

View File

@@ -1,159 +1,101 @@
import { Link } from "@tanstack/react-router";
import { Link, useNavigate } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
import { useState } from "react";
import { useQueueStatus, useWorkspaces } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { Skeleton } from "@/components/shared/Skeleton";
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
import { Boxes, LayoutDashboard, Network, Settings as SettingsIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
computeFleetAggregates,
DEFAULT_ROW_METRICS,
type FleetRowMetrics,
} from "@/components/fleet/fleetAggregates";
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 { formatCount } from "@/lib/utils";
import { ServerWorkspaceRows } from "./ServerWorkspaceRows";
type QueueStatus = components["schemas"]["QueueStatus"];
// ─── Per-workspace queue row ─────────────────────────────────────────────────
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
const { mask } = useDemo();
const { data, isLoading } = useQueueStatus(workspaceId);
const pending = data?.pending_work_units ?? 0;
const active = data?.in_progress_work_units ?? 0;
const done = data?.completed_work_units ?? 0;
const total = data?.total_work_units ?? 0;
const isActive = active > 0 || pending > 0;
return (
<tr
style={{
borderTop: "1px solid var(--border)",
background: isActive ? COLOR.warningDim : undefined,
}}
>
<td className="py-2 px-4">
<Link
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>
</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>
);
}
// ─── Main dashboard ───────────────────────────────────────────────────────────
const ALL_SERVERS = "all";
/**
* 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 [page] = useState(1);
const { data, isLoading, error } = useWorkspaces(page, 50);
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>>({});
const workspaces =
(data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
const total = (data as { total?: number } | undefined)?.total ?? 0;
useEffect(() => {
if (serverFilter !== ALL_SERVERS && !instances.find((i) => i.id === serverFilter)) {
setServerFilter(ALL_SERVERS);
}
}, [instances, serverFilter]);
const onMetrics = useCallback((id: string, m: FleetRowMetrics) => {
const prev = lastMetrics.current[id];
if (
prev &&
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 (
<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={{
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">
@@ -165,163 +107,136 @@ export function Dashboard() {
strokeWidth={1.5}
/>
<PageTitle>Dashboard</PageTitle>
{total > 0 && (
<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}`,
}}
>
{total} workspace{total !== 1 ? "s" : ""}
</span>
)}
<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} server{agg.totalInstances !== 1 ? "s" : ""}
</span>
</div>
<Body className="leading-none">Overview of your Honcho instance</Body>
<Body className="leading-none">Workspaces across every configured server</Body>
</motion.div>
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <DashboardSkeleton />}
<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>
{!isLoading && workspaces.length > 0 && (
<div className="space-y-4">
{/* Aggregate stat row */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
>
<GlobalQueueBanner workspaces={workspaces} />
</motion.div>
{/* Per-workspace queue table */}
<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)" }}
>
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<SectionHeading className="mb-0">Queue Status</SectionHeading>
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
all workspaces · live polling
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr style={{ background: "var(--bg-3)" }}>
{["Workspace", "Status", "Total", "Done", "Active", "Pending"].map((h) => (
<th
key={h}
className={`py-2 px-4 font-medium text-left ${h !== "Workspace" && h !== "Status" ? "text-right" : ""}`}
style={{ color: "var(--text-3)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{workspaces.map((ws) => (
<WorkspaceQueueRow key={ws.id} workspaceId={ws.id} />
))}
</tbody>
</table>
</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>
);
}
function DashboardSkeleton() {
return (
<div className="space-y-4" aria-hidden="true">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-xl p-4 theme-card">
<Skeleton accent={index === 0} className="h-8 w-16 rounded-lg" />
<Skeleton className="mt-3 h-3 w-20 rounded" />
</div>
))}
</div>
<div className="rounded-xl theme-card overflow-hidden">
<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)" }}
>
<Skeleton accent className="h-4 w-4 rounded" />
<Skeleton className="h-4 w-28 rounded" />
<Skeleton className="ml-1 h-3 w-32 rounded" />
<Network className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<SectionHeading className="mb-0">Workspaces</SectionHeading>
{instances.length > 1 && (
<label className="ml-auto flex items-center gap-1.5 text-xs">
<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 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>
))}
<th className="py-2 px-4 font-medium text-left" style={{ color: "var(--text-3)" }}>
Workspace (server)
</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>
</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>
{shownInstances.map((inst) => (
<ServerWorkspaceRows
key={inst.id}
instance={inst}
onOpenWorkspace={onOpenWorkspace}
onMetrics={onMetrics}
/>
))}
</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>
);

View 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>
);
})}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View File

@@ -15,7 +15,11 @@ const KNOWN_SECTIONS = new Set(Object.keys(SECTION_LABELS));
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 [];
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];
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) {
segments.push({ label: mask(subId), href: null, mono: true });
segments.push({ label: subLabel, href: null, mono: subMono });
return segments;
}
segments.push({
label: mask(subId),
label: subLabel,
href: `/workspaces/${wid}/${section}/${subId}`,
mono: true,
mono: subMono,
});
const subSection = parts[3];
@@ -64,10 +74,10 @@ function buildSegments(pathname: string, mask: (v: string) => string): Segment[]
return segments;
}
export function Breadcrumb() {
export function Breadcrumb({ labels = {} }: { labels?: Record<string, string> } = {}) {
const { state } = useRouter();
const { mask } = useDemo();
const segments = buildSegments(state.location.pathname, mask);
const segments = buildSegments(state.location.pathname, mask, labels);
if (segments.length <= 1) return null;

View File

@@ -60,7 +60,9 @@ function useLastDataUpdate(): string {
useEffect(() => {
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
.getQueryCache()
.getAll()
@@ -70,7 +72,10 @@ function useLastDataUpdate(): string {
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 () => {
unsubscribe();
window.clearInterval(interval);

View File

@@ -1,10 +1,12 @@
import { useNavigate, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import {
Check,
Eye,
EyeOff,
FlaskConical,
MessageCircle,
Pencil,
Save,
Search,
User,
@@ -19,6 +21,7 @@ import {
usePeerRepresentation,
useSearchPeer,
useSetPeerCard,
useUpdatePeer,
} from "@/api/queries";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { Badge } from "@/components/shared/Badge";
@@ -41,6 +44,7 @@ import {
import { useDemo } from "@/hooks/useDemo";
import { useMetadata } from "@/hooks/useMetadata";
import { COLOR } from "@/lib/constants";
import { DISPLAY_NAME_KEY, hasDisplayName, peerDisplayName } from "@/lib/peerDisplay";
export function PeerDetail() {
const { mask } = useDemo();
@@ -69,6 +73,20 @@ export function PeerDetail() {
const [cardDraft, setCardDraft] = useState<string | null>(null);
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)
?.configuration?.observe_me;
@@ -81,14 +99,54 @@ export function PeerDetail() {
return (
<div className="page-container page-container--xl">
<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>
<div className="flex items-center gap-2 mb-1">
<User className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<PageTitle className="font-mono break-all">{mask(peerId)}</PageTitle>
{observeMe !== undefined && (
{nameDraft === null ? (
<>
<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
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono"
style={{
@@ -106,6 +164,9 @@ export function PeerDetail() {
</span>
)}
</div>
{showsDisplayName && nameDraft === null && (
<MonoCaption className="break-all">{mask(peerId)}</MonoCaption>
)}
<Body className="leading-none">Peer identity &amp; memory</Body>
</div>
<div className="flex items-center gap-2 shrink-0">

View File

@@ -137,7 +137,7 @@ export function SessionList() {
className="font-mono text-sm font-medium truncate"
style={{ color: COLOR.accentSoft }}
>
{mask(session.id)}
{session.metadata?.name ? `${session.metadata.name}` : mask(session.id)}
</span>
<div className="flex items-center gap-2 shrink-0 ml-2">
{session.is_active && (

View File

@@ -1,11 +1,19 @@
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 STORE_KEY = "openconcho:instances";
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 {
return url.trim().replace(/\/+$/, "").toLowerCase();
}
@@ -15,7 +23,7 @@ export function isCloudInstance(instance: Pick<Instance, "baseUrl">): boolean {
}
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(""),
});
@@ -24,7 +32,7 @@ export type Config = z.infer<typeof configSchema>;
export const instanceSchema = z.object({
id: z.string().min(1),
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(""),
});
@@ -73,6 +81,18 @@ export function loadStore(): InstanceStore {
}
const migrated = migrateLegacy();
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 };
}
@@ -149,21 +169,21 @@ export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking";
export async function checkConnection(
baseUrl: string,
token?: string,
): Promise<{
status: HealthStatus;
message: string;
}> {
timeoutMs: number = CONNECTION_TIMEOUT_MS,
): Promise<{ status: HealthStatus; message: string }> {
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
const res = await httpFetch(`${baseUrl}/v3/workspaces/list`, {
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),
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.status === 401 || res.status === 403) {
return { status: "auth-required", message: "Authentication required — provide an API token" };

View File

@@ -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 {
port: number;
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
* 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> {
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",
headers: { "Content-Type": "application/json" },
headers,
body: JSON.stringify({}),
signal: AbortSignal.timeout(2000),
});

View 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 };
}

View File

@@ -1,12 +1,8 @@
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 (`pnpm dev:web`).
const isTauri = Boolean(
typeof window !== "undefined" &&
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__,
);
export const httpFetch: typeof globalThis.fetch = isTauri
// Falls back to native browser fetch during plain web dev.
export const httpFetch: typeof globalThis.fetch = isTauri()
? (tauriFetch as typeof globalThis.fetch)
: globalThis.fetch;

View 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;
}

View 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;
}

View 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();
}

View File

@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as WorkspacesRouteImport } from './routes/workspaces'
import { Route as SettingsRouteImport } from './routes/settings'
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 IndexRouteImport } from './routes/index'
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
@@ -42,6 +43,11 @@ const SeedKitsRoute = SeedKitsRouteImport.update({
path: '/seed-kits',
getParentRoute: () => rootRouteImport,
} as any)
const FleetRoute = FleetRouteImport.update({
id: '/fleet',
path: '/fleet',
getParentRoute: () => rootRouteImport,
} as any)
const ExploreRoute = ExploreRouteImport.update({
id: '/explore',
path: '/explore',
@@ -126,6 +132,7 @@ const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/explore': typeof ExploreRoute
'/fleet': typeof FleetRoute
'/seed-kits': typeof SeedKitsRoute
'/settings': typeof SettingsRoute
'/workspaces': typeof WorkspacesRoute
@@ -145,6 +152,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/explore': typeof ExploreRoute
'/fleet': typeof FleetRoute
'/seed-kits': typeof SeedKitsRoute
'/settings': typeof SettingsRoute
'/workspaces': typeof WorkspacesRoute
@@ -165,6 +173,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/explore': typeof ExploreRoute
'/fleet': typeof FleetRoute
'/seed-kits': typeof SeedKitsRoute
'/settings': typeof SettingsRoute
'/workspaces': typeof WorkspacesRoute
@@ -186,6 +195,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/explore'
| '/fleet'
| '/seed-kits'
| '/settings'
| '/workspaces'
@@ -205,6 +215,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/explore'
| '/fleet'
| '/seed-kits'
| '/settings'
| '/workspaces'
@@ -224,6 +235,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/explore'
| '/fleet'
| '/seed-kits'
| '/settings'
| '/workspaces'
@@ -244,6 +256,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ExploreRoute: typeof ExploreRoute
FleetRoute: typeof FleetRoute
SeedKitsRoute: typeof SeedKitsRoute
SettingsRoute: typeof SettingsRoute
WorkspacesRoute: typeof WorkspacesRoute
@@ -284,6 +297,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SeedKitsRouteImport
parentRoute: typeof rootRouteImport
}
'/fleet': {
id: '/fleet'
path: '/fleet'
fullPath: '/fleet'
preLoaderRoute: typeof FleetRouteImport
parentRoute: typeof rootRouteImport
}
'/explore': {
id: '/explore'
path: '/explore'
@@ -388,6 +408,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ExploreRoute: ExploreRoute,
FleetRoute: FleetRoute,
SeedKitsRoute: SeedKitsRoute,
SettingsRoute: SettingsRoute,
WorkspacesRoute: WorkspacesRoute,

View File

@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/fleet")({
beforeLoad: () => {
throw redirect({ to: "/" });
},
});

View 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");
});
});

View 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();
});
});
});

View 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");
});
});

View 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();
});
});
});

View 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();
});
});

View 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);
});
});

View 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);
});
});

View 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();
});
});

View File

@@ -27,11 +27,11 @@ describe("security URL helpers", () => {
expect(isSecureTokenTransport("http://localhost: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://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", () => {
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();
});
});

View 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" })),
);
});
});

View File

@@ -58,7 +58,7 @@ describe("SettingsForm — self-hosted preset", () => {
renderForm(<SettingsForm instance={null} preset="self-hosted" />);
const baseUrl = screen.getByPlaceholderText("http://localhost:8000");
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(
screen.getByPlaceholderText(/required only if your instance has auth enabled/i),
"secret-token",

View File

@@ -1,10 +1,10 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vite";
import { defineConfig, type Plugin } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const host = process.env.TAURI_DEV_HOST;
@@ -12,9 +12,80 @@ const { version } = JSON.parse(
readFileSync(path.resolve(__dirname, "../../package.json"), "utf-8"),
) 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({
clearScreen: false,
plugins: [tanstackRouter({ autoCodeSplitting: true }), react(), tailwindcss()],
plugins: [tanstackRouter({ autoCodeSplitting: true }), react(), honchoApiProxy(), tailwindcss()],
define: {
__APP_VERSION__: JSON.stringify(version),
},

1027
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ allowBuilds:
catalog:
# Standard tooling
"@biomejs/biome": "^2.4.0"
"@biomejs/biome": "^2.4.16"
# Testing
"@playwright/test": "^1.59.1"
@@ -15,16 +15,16 @@ catalog:
"@types/react": "^19.2.14"
"@types/react-dom": "^19.2.3"
"@vitejs/plugin-react": "^6.0.1"
"@vitest/coverage-v8": "^4.0.0"
jsdom: "^26.1.0"
"@vitest/coverage-v8": "^4.1.8"
jsdom: "^29.1.1"
# React
react: "^19.2.5"
react-dom: "^19.2.5"
semantic-release: "^25.0.0"
semantic-release: "^25.0.3"
typescript: "~6.0.2"
# Vite
vite: "^8.0.10"
vitest: "^4.0.0"
vitest: "^4.1.8"
zod: "^4.0.0"

View File

@@ -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 "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
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 '\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
fi