Keep SSH terminal sessions connected across page navigation (#30)
The Terminal page held all session state (xterm instances and their
WebSockets) in component-local React state. Because it renders as a
`<Route element={<Terminal />}>`, navigating away unmounted it and ran
the xterm cleanup (`term.dispose()` + `ws.close()`), tearing down every
SSH session. Returning to the page reconnected from scratch, losing
scrollback and any running work.
Lift terminal sessions into a `TerminalSessionProvider` mounted above the
router (in `main.tsx`, inside `AuthProvider`). The provider owns each
pane's xterm instance, fit addon, WebSocket, and a persistent wrapper DOM
node. Wrappers live in a hidden container at the app root; the Terminal
page re-parents them into its grid on mount and moves them back to the
hidden root on unmount instead of disposing — so the xterm + WebSocket
keep running in the background across route changes.
Disconnect semantics: closing a tab/pane (or shrinking the 1/2/4 grid)
destroys those sessions; logout tears down all sessions. A full browser
reload still drops connections (the WebSocket dies with the page) — this
persists across in-app navigation only.
Shared terminal constants/types/prefs are split into a non-component
module (`src/lib/terminalPrefs.ts`) so the context file stays a clean
component module.
Also document the terminal window grid-view tiering in ROADMAP.md
(self-hosted = 4-window cap, current; paid = as many as fit on screen,
planned for the AWS deployment), and realign HANDOFF/README/design-docs
to reflect that auth Phase 3 (multi-user) shipped and Phase 4 (SSO) is
deferred to a paid AWS add-on.
Verified with a clean `tsc -b && vite build` (frontend) and
`tsc --noEmit -p .` (backend).
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
This commit is contained in:
parent
7f354e54ab
commit
b836ac1a02
8 changed files with 624 additions and 267 deletions
48
HANDOFF.md
48
HANDOFF.md
|
|
@ -6,7 +6,7 @@ Status snapshot as of **2026-06-20**, branch `claude/dazzling-mendel-rzyxos`. Wr
|
|||
|
||||
ArchNest is **live and deployed** at `archnest.snsnetlabs.com`, auto-deploying via GitHub Actions (`.github/workflows/deploy.yml`) on every merge to `main` — push triggers a build + SCP + `docker compose up -d --build` on `racknerd1`, with a health-check gate (`/api/health`). Deployment is no longer the open task; it's working infrastructure now.
|
||||
|
||||
The current focus is **auth/account features**: the top-right user menu (Profile/Appearance/Security) was fixed from being dead links (Phase 1), then **password management, sessions, and login audit logging shipped (Phase 2)**. The remaining unbuilt scope is **multi-user accounts (Phase 3, in progress) and Authentik SSO (Phase 4)**. See the phase breakdown below.
|
||||
The current focus is **auth/account features**: the top-right user menu (Profile/Appearance/Security) was fixed from being dead links (Phase 1), then **password management, sessions, and login audit logging shipped (Phase 2)**, then **multi-user accounts with admin/member roles shipped (Phase 3)**. **Phase 4 (Authentik SSO) is deferred to a paid add-on for the future AWS deployment** — see `ROADMAP.md`. With Phases 1-3 done, there is no active auth task in the current self-hosted build.
|
||||
|
||||
## Standing rules (read before doing anything)
|
||||
|
||||
|
|
@ -31,10 +31,10 @@ The current focus is **auth/account features**: the top-right user menu (Profile
|
|||
|
||||
### Backend (`/backend`)
|
||||
- Fastify 5, TypeScript, ESM (`type: "module"` — `tsx` in dev, entrypoint `src/server.ts`).
|
||||
- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log, plus `sessions` and `login_events` tables (Phase 2). **Multi-user is in progress (Phase 3)**: a `role`/`active` column is being added to `users`.
|
||||
- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log, plus `sessions` and `login_events` tables (Phase 2). **Multi-user shipped (Phase 3)**: `users` has `role` (`admin`/`member`) and `active` columns, added via idempotent boot-time migrations.
|
||||
- `backend/src/db/crypto.ts` — AES-256-GCM `encryptSecret`/`decryptSecret`, keyed by `ARCHNEST_SECRET_KEY`.
|
||||
- `backend/src/routes/` — one file per route group (`auth`, `bookmarks`, `integrations`, `events`, `terminal`, `tunnels`, `files`, `docker`, `guacamole`, `metrics`, `transfer`, `data`).
|
||||
- `backend/src/routes/auth.ts` — `/api/setup` (first-run, creates the first admin user), `/api/auth/login`, `/api/auth/me` (GET/PUT), `/api/auth/password`, `/api/auth/sessions`, `/api/auth/logout`, `/api/auth/login-events` (Phase 2). User-management endpoints land here in Phase 3.
|
||||
- `backend/src/routes/auth.ts` — `/api/setup` (first-run, creates the first admin user), `/api/auth/login`, `/api/auth/me` (GET/PUT), `/api/auth/password`, `/api/auth/sessions`, `/api/auth/logout`, `/api/auth/login-events` (Phase 2), plus user-management endpoints `/api/users` (GET/POST) and `/api/users/:id` (PUT/DELETE) gated by `requireAdmin` (Phase 3).
|
||||
- `backend/src/integrations/` — the 8 integration adapters (Proxmox, Docker, NetBird, Cloudflare, AWS, Uptime Kuma, Weather, SSH).
|
||||
- `backend/src/ssh/` — SSH-backed feature engines: terminal sessions, tunnels, file ops, host metrics collectors, host-to-host transfer.
|
||||
- Docker images run on Alpine; **OpenSSL legacy provider is enabled** in `backend/Dockerfile` (`OPENSSL_CONF=/etc/ssl/openssl-legacy.cnf`) so old-format encrypted PEM keys (`BEGIN RSA PRIVATE KEY` + `DEK-Info`) still decrypt under OpenSSL 3 — don't remove this without understanding why it's there.
|
||||
|
|
@ -56,9 +56,9 @@ See `TERMIX_MIGRATION.md` for the phase-by-phase record of the original feature
|
|||
10. **TopBar global search** — across nav pages, integrations, bookmarks.
|
||||
11. **Settings UX fixes** — secret fields show a "· saved" indicator instead of appearing blank/deleted after reload (`secretKeys: string[]` on the integration serializer); SSH host cards default-collapsed if already configured; SSH private-key/cert fields support file upload to avoid paste corruption.
|
||||
|
||||
## Current initiative: User menu → full auth system (in progress)
|
||||
## Auth system — Phases 1-3 complete
|
||||
|
||||
The user menu (`TopBar.tsx`, avatar dropdown) had `Profile`/`Appearance`/`Security` as dead `href="#"` links. Root-caused and scoped into 4 phases; **Phases 1 and 2 shipped, Phase 3 is in progress**.
|
||||
The user menu (`TopBar.tsx`, avatar dropdown) had `Profile`/`Appearance`/`Security` as dead `href="#"` links. Root-caused and scoped into 4 phases; **Phases 1, 2, and 3 shipped. Phase 4 (SSO) is deferred to a paid AWS add-on — see `ROADMAP.md`.**
|
||||
|
||||
### Phase 1 — DONE (merged, deployed)
|
||||
- Added `?tab=` deep-linking to `Settings.tsx` (`useSearchParams`) so menu items can jump to a specific section instead of always landing on Profile.
|
||||
|
|
@ -73,27 +73,25 @@ Password change + sessions + login audit log, still single-user. Shipped in PR #
|
|||
- Endpoints in `auth.ts`: `PUT /api/auth/password` (verify current via bcrypt, hash new at cost 12, revoke all *other* sessions), `GET /api/auth/sessions`, `DELETE /api/auth/sessions/:id` (can't revoke current), `POST /api/auth/logout` (revokes current), `GET /api/auth/login-events?limit`.
|
||||
- `SecuritySection` in `Settings.tsx` is fully built: change-password form, active-sessions list with per-session "Sign out", recent login-activity feed. `AuthContext.logout()` calls `POST /api/auth/logout` so signing out revokes the server session.
|
||||
|
||||
### Phase 3 — IN PROGRESS. Multi-user (cap: 10 seats)
|
||||
- **Decision already made by the user**: dashboard data (integrations, bookmarks, tunnels, etc.) is **shared across all users**, not private per-user — this is a household/self-hosted dashboard, not a multi-tenant app. Don't build per-user data isolation.
|
||||
- Add a `role` column to `users` (`admin` / `member`) and an `active` column (for deactivate-without-delete). First user (`/api/setup`) is `admin`; existing single user is backfilled to `admin`.
|
||||
- Add an admin-only "User Management" section in Settings: create user (admin sets temp password — **no public signup**), list users, change role, deactivate/delete, enforce the **10-user cap** server-side.
|
||||
- **Permission model (decided with the user, see below) — gate via a `requireAdmin` hook:**
|
||||
- **Admin-only (mutating shared config):** integrations create/update/delete/test, tunnels create/delete, user management, and data export/import (`/api/data/*` — round-trips decrypted secrets).
|
||||
- **All authenticated users (admin + member):** view everything (Glance/Infrastructure/BookNest/Host Metrics), use ALL the SSH/Docker tooling (Terminal, Files, Containers, Remote Desktop, connect/disconnect existing tunnels — the user explicitly OK'd members having shell/root access; trusted household/team), bookmarks CRUD (shared link hub everyone contributes to), and their own profile/password/sessions.
|
||||
- A deactivated user (`active = 0`) is rejected at login and their existing sessions stop validating.
|
||||
### Phase 3 — DONE (merged, deployed). Multi-user (cap: 10 seats)
|
||||
Shipped in PR #28 (with a build-fix follow-up in PR #29). Both frontend and backend type-check cleanly.
|
||||
- **Decision (made by the user):** dashboard data (integrations, bookmarks, tunnels, etc.) is **shared across all users**, not private per-user — household/self-hosted dashboard, not multi-tenant. No per-user data isolation was built.
|
||||
- `users` gained a `role` column (`admin`/`member`, defaults to `'admin'` so the pre-existing single user keeps full access) and an `active` column (deactivate-without-delete), added via idempotent boot-time `ALTER TABLE` migrations in `backend/src/db/index.ts`. First user (`/api/setup`) is `admin`; new users are created as `member` unless promoted.
|
||||
- Admin-only "User Management" section in Settings (`UsersSection` in `Settings.tsx`): create user (admin sets temp password — **no public signup**), list users, toggle role, deactivate/delete. The **10-user cap** is enforced server-side in `POST /api/users`.
|
||||
- Endpoints in `auth.ts`, all behind `app.requireAdmin`: `GET /api/users`, `POST /api/users`, `PUT /api/users/:id` (role/active), `DELETE /api/users/:id`. Last-active-admin guardrails: can't demote, deactivate, or delete the final active admin; can't delete your own account. Deactivating a user deletes their sessions immediately.
|
||||
- **Permission model (gated via hooks in `server.ts`):**
|
||||
- `requireAdmin` (authenticates, then enforces `role === 'admin'`) and `adminOnly` (role-only, for routes already behind a plugin-level `authenticate` hook).
|
||||
- `authenticate` re-reads `role`/`active` fresh from the DB on every request rather than trusting the JWT claim, so a demoted/deactivated user loses elevated access immediately even with an older token; a deactivated user is rejected (401/at login 403) and their sessions stop validating.
|
||||
- **Admin-only (mutating shared config):** integrations create/update/delete/test (`adminOnly` in `integrations.ts`), tunnels create/delete (`tunnels.ts`), data export/import (`data.ts`), and user management.
|
||||
- **All authenticated users (admin + member):** view everything, use ALL the SSH/Docker tooling (Terminal, Files, Containers, Remote Desktop, connect/disconnect existing tunnels), bookmarks CRUD, and their own profile/password/sessions.
|
||||
- Frontend wiring: `listUsers`/`createUser`/`updateUser`/`deleteUser` + `ManagedUser` type in `src/lib/api.ts`.
|
||||
|
||||
### Phase 4 — NOT STARTED. Authentik SSO (OIDC)
|
||||
- Add instance-level SSO config (issuer URL, client ID/secret, redirect URI) — likely an integration-like settings entry, or dedicated config table/env vars.
|
||||
- `GET /api/auth/sso/login` → redirect to Authentik; `GET /api/auth/sso/callback` → exchange code, look up/create local user by SSO subject claim (respecting the 10-user cap from Phase 3), issue the same JWT format as today.
|
||||
- Add a "Sign in with SSO" button on `Login.tsx` alongside username/password (local accounts remain as an admin recovery path — don't remove password auth entirely).
|
||||
### Phase 4 — DEFERRED to paid add-on (AWS deployment). Authentik SSO (OIDC)
|
||||
Moved out of the core build. Planned as a **paid add-on shipped when ArchNest is deployed on AWS**, not on the current `racknerd1` deployment. Full intended scope and the open scope questions now live in **`ROADMAP.md`**. Local username/password auth (Phases 1-3) stays as the free path and admin recovery path.
|
||||
|
||||
## Known non-blocking stubs (cosmetic, not flagged as work to do unless asked)
|
||||
## Known non-blocking stubs
|
||||
|
||||
- `Infrastructure.tsx`'s "Network" sub-tab is **intentionally** disabled (`title="Coming soon"`) — leave alone unless explicitly asked.
|
||||
- `Settings.tsx`'s Appearance section (theme/accent/fontSize/radius/sidebarExpanded/animations) is local-state-only — doesn't persist or apply anywhere. Recommended fix if picked up: mirror the Terminal page's `localStorage`-backed prefs pattern and apply via CSS variables on `:root`.
|
||||
- `Settings.tsx`'s Notifications section (email/push/sound toggles) has no backing delivery mechanism — recommend removing or clearly labeling as not-yet-functional rather than persisting settings that do nothing.
|
||||
|
||||
Neither has been actioned because the user hasn't asked — check the latest conversation/commits before assuming a direction.
|
||||
Moved to **`ROADMAP.md`** ("Known non-blocking stubs"). Summary: the Infrastructure "Network" sub-tab is intentionally disabled, and the Settings Appearance and Notifications sections are non-functional placeholders. None are flagged as work to do unless explicitly asked — check the latest conversation/commits before assuming a direction.
|
||||
|
||||
## Deployment (already working — reference only)
|
||||
|
||||
|
|
@ -103,6 +101,6 @@ Neither has been actioned because the user hasn't asked — check the latest con
|
|||
|
||||
1. Read this file, then `TERMIX_MIGRATION.md` for feature-level history, then skim recent `git log --oneline -30` for the latest concrete changes (commit messages are deliberately descriptive).
|
||||
2. Frontend type-checks with `npx tsc --noEmit -p .` from repo root; backend the same from `backend/`. Both should pass cleanly before any commit.
|
||||
3. If continuing the auth work, **Phase 2 is done** (password change + sessions + login log). **Phase 3 (multi-user) is in progress** — see its section above for the agreed permission model.
|
||||
3. The auth roadmap's **Phases 1-3 are done** (user menu wiring; password change + sessions + login log; multi-user accounts with admin/member roles). **Phase 4 (Authentik SSO) is deferred to a paid AWS add-on — see `ROADMAP.md`.** There is no active auth task in the current self-hosted build.
|
||||
4. If asked to add a feature unrelated to auth, follow existing patterns: integration adapters in `backend/src/integrations/`, SSH-backed engines in `backend/src/ssh/`, one route file per feature in `backend/src/routes/`, one `api.ts` entry + page component per frontend feature.
|
||||
5. For anything ambiguous in scope (especially Phase 3's permission model or Phase 4's SSO provider assumptions), use `AskUserQuestion` rather than guessing — that's how Phases 2–4 above got scoped in the first place.
|
||||
5. For anything ambiguous in scope (especially the permission model, or Phase 4's SSO scope questions in `ROADMAP.md` if that add-on gets picked up), use `AskUserQuestion` rather than guessing — that's how Phases 2–4 above got scoped in the first place.
|
||||
|
|
|
|||
34
README.md
34
README.md
|
|
@ -27,16 +27,20 @@ managed host.
|
|||
merge to `main` via `.github/workflows/deploy.yml`. All 11 pages and their
|
||||
backend routes are built and working — there is no pending/on-hold page.
|
||||
The active area of work is **the auth system**: the user menu's
|
||||
Profile/Appearance/Security links were fixed in Phase 1; Phases 2-4
|
||||
(password change + sessions + login audit log, multi-user accounts,
|
||||
Authentik SSO) are planned but not started — see `HANDOFF.md` for the full
|
||||
phase breakdown and exactly where to resume.
|
||||
Profile/Appearance/Security links were fixed in Phase 1; Phase 2
|
||||
(password change + sessions + login audit log) and Phase 3 (multi-user
|
||||
accounts with admin/member roles, 10-seat cap) have shipped. Phase 4
|
||||
(Authentik SSO) is **deferred to a paid add-on for the future AWS
|
||||
deployment** — see `ROADMAP.md`. With Phases 1-3 done there is no active
|
||||
auth task in the current self-hosted build; see `HANDOFF.md` for the full
|
||||
phase breakdown.
|
||||
|
||||
If you're a fresh AI session: read this file, then `HANDOFF.md` (current
|
||||
task state + standing workflow rules), then `design-decisions.md` (visual
|
||||
conventions + accurate per-page implementation notes), then
|
||||
`TERMIX_MIGRATION.md` (history of how the SSH/Docker/Guacamole feature set
|
||||
was built) if you need historical context on a specific feature.
|
||||
conventions + accurate per-page implementation notes), then `ROADMAP.md`
|
||||
(deferred/planned work, incl. the paid SSO add-on) and `TERMIX_MIGRATION.md`
|
||||
(history of how the SSH/Docker/Guacamole feature set was built) if you need
|
||||
that context.
|
||||
|
||||
## Pages
|
||||
|
||||
|
|
@ -67,8 +71,9 @@ with the actual code, not a spec written before the page existed.
|
|||
- `src/lib/api.ts` — typed fetch wrapper (`apiFetch`) + one function per
|
||||
backend endpoint + matching TS interfaces. This is the contract between
|
||||
frontend and backend; any new backend route needs a matching entry here.
|
||||
- `src/lib/AuthContext.tsx` — auth state backed by `localStorage` (single JWT,
|
||||
no session tracking yet — Phase 2 of the auth roadmap).
|
||||
- `src/lib/AuthContext.tsx` — auth state backed by `localStorage` (JWT
|
||||
carrying a server-tracked session id; signing out revokes the session
|
||||
server-side).
|
||||
- `src/pages/` — one file per route (see table above), plus `Login.tsx` /
|
||||
`Enrollment.tsx` for the unauthenticated/first-run flows.
|
||||
- `src/components/` — `TopBar.tsx` (title, global search across pages/
|
||||
|
|
@ -81,14 +86,15 @@ with the actual code, not a spec written before the page existed.
|
|||
### Backend (`/backend`)
|
||||
- Fastify 5, TypeScript, ESM (`tsx` for dev, `tsc -b` for build), entrypoint
|
||||
`src/server.ts`.
|
||||
- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log.
|
||||
Single-user schema today (no `role` column, no multi-account concept —
|
||||
see `HANDOFF.md` Phase 3).
|
||||
- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log,
|
||||
plus `sessions`/`login_events` tables and a multi-user `users` schema
|
||||
(`role` admin/member + `active` columns).
|
||||
- `backend/src/db/crypto.ts` — AES-256-GCM `encryptSecret`/`decryptSecret`,
|
||||
keyed by `ARCHNEST_SECRET_KEY`.
|
||||
- `backend/src/routes/` — one file per feature area:
|
||||
- `auth.ts` — setup, login, profile (`/api/setup`, `/api/auth/login`,
|
||||
`/api/auth/me`)
|
||||
- `auth.ts` — setup, login, profile, password change, sessions,
|
||||
login audit log, and admin-only user management (`/api/setup`,
|
||||
`/api/auth/*`, `/api/users`)
|
||||
- `integrations.ts` — integration CRUD + connection testing
|
||||
- `bookmarks.ts` — bookmarks + categories CRUD
|
||||
- `events.ts` — activity log retrieval
|
||||
|
|
|
|||
85
ROADMAP.md
Normal file
85
ROADMAP.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# ArchNest — Roadmap
|
||||
|
||||
Forward-looking work that is **planned but not currently being built**. For
|
||||
the state of shipped work and the active task, see `HANDOFF.md`. For
|
||||
historical feature build-out, see `TERMIX_MIGRATION.md`.
|
||||
|
||||
---
|
||||
|
||||
## Shipped (for context)
|
||||
|
||||
The auth roadmap so far — full detail in `HANDOFF.md`:
|
||||
|
||||
- **Phase 1** — User menu Profile/Appearance/Security wired up; `?tab=`
|
||||
deep-linking in Settings.
|
||||
- **Phase 2** — Password change, server-tracked sessions, login audit log.
|
||||
- **Phase 3** — Multi-user accounts: admin/member roles, `active` flag,
|
||||
10-seat cap, admin-only user management, `requireAdmin`/`adminOnly` gating.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Authentik SSO (OIDC) — PAID ADD-ON (AWS deployment)
|
||||
|
||||
**Status:** deferred. This is intentionally **not** part of the
|
||||
self-hosted core build. It is planned as a **paid add-on, shipped when
|
||||
ArchNest is deployed on AWS** — not on the current `racknerd1` deployment.
|
||||
|
||||
Local username/password auth (Phases 1-3) remains the free, always-available
|
||||
path and the admin recovery path; SSO layers on top of it rather than
|
||||
replacing it.
|
||||
|
||||
### Intended scope (when built)
|
||||
- Instance-level SSO config (issuer URL, client ID/secret, redirect URI) —
|
||||
likely an integration-like settings entry, or a dedicated config
|
||||
table / env vars.
|
||||
- `GET /api/auth/sso/login` → redirect to Authentik.
|
||||
- `GET /api/auth/sso/callback` → exchange code, look up/create local user by
|
||||
SSO subject claim (respecting the 10-user cap from Phase 3), issue the same
|
||||
JWT format as today.
|
||||
- "Sign in with SSO" button on `Login.tsx` alongside username/password
|
||||
(local accounts remain — do **not** remove password auth entirely).
|
||||
|
||||
### Open scope questions (decide before any code)
|
||||
1. **Where does SSO config live?** env vars (simplest, redeploy to change) vs.
|
||||
a dedicated config table vs. an integration-like settings entry (editable
|
||||
in-UI, more work).
|
||||
2. **First-login provisioning** — auto-create a local `member` for an
|
||||
unknown-but-valid SSO user (subject to the 10-seat cap), or require an
|
||||
admin to pre-create the account and only *link* it on SSO login?
|
||||
3. **Role mapping** — do Authentik groups/claims map to admin/member, or do
|
||||
all SSO users default to `member` with roles managed locally?
|
||||
|
||||
---
|
||||
|
||||
## Terminal — window grid view (tiered: self-hosted vs. paid)
|
||||
|
||||
**Status:** self-hosted behavior is current; the paid tier is planned.
|
||||
|
||||
The Terminal page (`src/pages/Terminal.tsx`) supports a split-pane grid view
|
||||
within a tab.
|
||||
|
||||
- **Self-hosted (current):** capped at a **4-window grid** (1 / 2 / 4 pane
|
||||
layouts via the toolbar buttons). This is the free, always-available tier.
|
||||
- **Paid (planned, AWS deployment):** **as many windows as fit on the
|
||||
screen** — dynamic grid sizing beyond the 4-pane cap, laid out responsively
|
||||
to the viewport rather than a fixed 1/2/4 choice.
|
||||
|
||||
When the paid tier is built, the 4-pane cap becomes a licensing/feature gate
|
||||
rather than a hard UI limit; the grid layout logic generalizes to an
|
||||
arbitrary pane count.
|
||||
|
||||
---
|
||||
|
||||
## Known non-blocking stubs (cosmetic, not scheduled)
|
||||
|
||||
Not flagged as work to do unless explicitly asked:
|
||||
|
||||
- `Infrastructure.tsx`'s "Network" sub-tab is **intentionally** disabled
|
||||
(`title="Coming soon"`) — leave alone unless explicitly asked.
|
||||
- `Settings.tsx`'s Appearance section (theme/accent/fontSize/radius/
|
||||
sidebarExpanded/animations) is local-state-only — doesn't persist or apply
|
||||
anywhere. Recommended fix if picked up: mirror the Terminal page's
|
||||
`localStorage`-backed prefs pattern and apply via CSS variables on `:root`.
|
||||
- `Settings.tsx`'s Notifications section (email/push/sound toggles) has no
|
||||
backing delivery mechanism — recommend removing or clearly labeling as
|
||||
not-yet-functional rather than persisting settings that do nothing.
|
||||
|
|
@ -257,8 +257,9 @@ an actual SQL-backed or live-polled endpoint, not a config file or static array.
|
|||
- `backend/` — Fastify 5 + TypeScript (ESM, `tsx` dev / `tsc -b` build),
|
||||
`better-sqlite3` for storage, deployed as its own Docker container alongside
|
||||
the frontend and a `guacd` sidecar.
|
||||
- Auth: single-user-schema JWT (`@fastify/jwt`) today — see `HANDOFF.md` for
|
||||
the multi-user/SSO roadmap (Phases 2-4, not yet built).
|
||||
- Auth: JWT (`@fastify/jwt`) with server-tracked sessions and a multi-user
|
||||
schema (admin/member roles, 10-seat cap). Authentik SSO is deferred to a
|
||||
paid AWS add-on — see `ROADMAP.md`.
|
||||
- Integration credentials split across `integrations` (non-secret config) and
|
||||
`secrets` (AES-256-GCM-encrypted, keyed by `ARCHNEST_SECRET_KEY`) tables —
|
||||
secrets never appear in a generic "list integrations" response by
|
||||
|
|
|
|||
389
src/lib/TerminalSessionContext.tsx
Normal file
389
src/lib/TerminalSessionContext.tsx
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { getToken } from './api'
|
||||
import { useAuth } from './AuthContext'
|
||||
import {
|
||||
TERM_THEMES,
|
||||
loadPrefs,
|
||||
savePrefs,
|
||||
type TabState,
|
||||
type TerminalPrefs,
|
||||
} from './terminalPrefs'
|
||||
|
||||
/**
|
||||
* One live terminal pane: its xterm instance, fit addon, WebSocket, and the
|
||||
* persistent wrapper DOM node that xterm is attached to. The wrapper lives in
|
||||
* a hidden container at the app root and is re-parented into the Terminal page
|
||||
* grid when that page is mounted, so the xterm instance and its WebSocket
|
||||
* survive route changes (the page unmounting no longer disposes them).
|
||||
*/
|
||||
interface PaneSession {
|
||||
paneId: number
|
||||
wrapper: HTMLDivElement
|
||||
term: XTerm
|
||||
fit: FitAddon
|
||||
ws: WebSocket | null
|
||||
hostId: number | null
|
||||
connected: boolean
|
||||
/** Last tmux session list fetched for the current host. */
|
||||
tmuxSessions: string[]
|
||||
/** Currently-selected tmux option in the pane header (raw select value). */
|
||||
selectedTmux: string
|
||||
/** Disposes the term's onData/onResize listeners before re-wiring on reconnect. */
|
||||
disposeListeners: (() => void) | null
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
const genId = () => nextId++
|
||||
|
||||
function newTab(): TabState {
|
||||
return { id: genId(), name: 'Terminal', panes: [{ id: genId(), hostId: null }] }
|
||||
}
|
||||
|
||||
interface TerminalSessionContextValue {
|
||||
tabs: TabState[]
|
||||
activeTabId: number
|
||||
activePaneId: number | null
|
||||
prefs: TerminalPrefs
|
||||
setActiveTabId: (id: number) => void
|
||||
setActivePaneId: (id: number | null) => void
|
||||
setPrefs: (update: (p: TerminalPrefs) => TerminalPrefs) => void
|
||||
addTab: () => void
|
||||
closeTab: (id: number) => void
|
||||
setPaneCount: (count: number) => void
|
||||
setPaneHost: (paneId: number, hostId: number) => void
|
||||
/** Mount a pane's persistent DOM node into a grid cell; returns a detach fn. */
|
||||
attachPane: (paneId: number, cell: HTMLElement) => () => void
|
||||
/** Read-only snapshot of a pane's live connection state (for header UI). */
|
||||
getPaneInfo: (paneId: number) => { connected: boolean; tmuxSessions: string[]; selectedTmux: string }
|
||||
/** Reconnect a pane to a tmux session (or plain shell) via the header select. */
|
||||
reconnectTmux: (paneId: number, rawSelectValue: string) => void
|
||||
/** Version counter that bumps whenever live pane state changes, to trigger re-render. */
|
||||
version: number
|
||||
}
|
||||
|
||||
const TerminalSessionContext = createContext<TerminalSessionContextValue | null>(null)
|
||||
|
||||
export function TerminalSessionProvider({ children }: { children: ReactNode }) {
|
||||
const { status } = useAuth()
|
||||
|
||||
const [tabs, setTabs] = useState<TabState[]>(() => [newTab()])
|
||||
const [activeTabId, setActiveTabId] = useState(() => tabs[0].id)
|
||||
const [activePaneId, setActivePaneId] = useState<number | null>(null)
|
||||
const [prefs, setPrefsState] = useState<TerminalPrefs>(loadPrefs)
|
||||
const [version, setVersion] = useState(0)
|
||||
|
||||
// Hidden host for offscreen pane wrappers; created lazily on first use (never
|
||||
// during render — only from effects/handlers — to satisfy the rules of refs).
|
||||
const hiddenRootRef = useRef<HTMLDivElement | null>(null)
|
||||
function getHiddenRoot(): HTMLDivElement {
|
||||
let el = hiddenRootRef.current
|
||||
if (!el) {
|
||||
el = document.createElement('div')
|
||||
el.style.position = 'absolute'
|
||||
el.style.width = '0'
|
||||
el.style.height = '0'
|
||||
el.style.overflow = 'hidden'
|
||||
el.style.left = '-99999px'
|
||||
el.style.top = '0'
|
||||
hiddenRootRef.current = el
|
||||
}
|
||||
if (!el.isConnected) document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
const sessionsRef = useRef<Map<number, PaneSession>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
const hidden = getHiddenRoot()
|
||||
return () => {
|
||||
if (hidden.isConnected) hidden.remove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => savePrefs(prefs), [prefs])
|
||||
|
||||
const bump = () => setVersion((v) => v + 1)
|
||||
|
||||
// --- Session lifecycle ----------------------------------------------------
|
||||
|
||||
function ensureSession(paneId: number): PaneSession {
|
||||
const existing = sessionsRef.current.get(paneId)
|
||||
if (existing) return existing
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.style.width = '100%'
|
||||
wrapper.style.height = '100%'
|
||||
wrapper.style.minHeight = '0'
|
||||
getHiddenRoot().appendChild(wrapper)
|
||||
|
||||
const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0]
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: prefs.fontSize,
|
||||
fontFamily: prefs.fontFamily,
|
||||
theme: { background: theme.background, foreground: theme.foreground, cursor: theme.cursor },
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(wrapper)
|
||||
|
||||
const session: PaneSession = {
|
||||
paneId,
|
||||
wrapper,
|
||||
term,
|
||||
fit,
|
||||
ws: null,
|
||||
hostId: null,
|
||||
connected: false,
|
||||
tmuxSessions: [],
|
||||
selectedTmux: '',
|
||||
disposeListeners: null,
|
||||
}
|
||||
sessionsRef.current.set(paneId, session)
|
||||
return session
|
||||
}
|
||||
|
||||
function applyPrefsToSession(s: PaneSession) {
|
||||
const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0]
|
||||
s.term.options.fontSize = prefs.fontSize
|
||||
s.term.options.fontFamily = prefs.fontFamily
|
||||
s.term.options.theme = { background: theme.background, foreground: theme.foreground, cursor: theme.cursor }
|
||||
try {
|
||||
s.fit.fit()
|
||||
} catch {
|
||||
/* container may be detached */
|
||||
}
|
||||
}
|
||||
|
||||
function fetchTmuxSessions(s: PaneSession, id: number) {
|
||||
s.tmuxSessions = []
|
||||
bump()
|
||||
const token = getToken()
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
|
||||
ws.onopen = () => ws.send(JSON.stringify({ type: 'list_tmux', integrationId: id }))
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'tmux_sessions') {
|
||||
s.tmuxSessions = msg.sessions ?? []
|
||||
bump()
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connect(s: PaneSession, id: number, tmuxSession?: string) {
|
||||
s.disposeListeners?.()
|
||||
s.ws?.close()
|
||||
s.connected = false
|
||||
bump()
|
||||
|
||||
const term = s.term
|
||||
term.reset()
|
||||
term.writeln('Connecting…')
|
||||
|
||||
const token = getToken()
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
|
||||
s.ws = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows, tmuxSession }))
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'connected') {
|
||||
s.connected = true
|
||||
bump()
|
||||
term.reset()
|
||||
} else if (msg.type === 'data') {
|
||||
term.write(msg.data)
|
||||
} else if (msg.type === 'error') {
|
||||
term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`)
|
||||
s.connected = false
|
||||
bump()
|
||||
} else if (msg.type === 'closed') {
|
||||
term.writeln('\r\n\x1b[33mConnection closed.\x1b[0m')
|
||||
s.connected = false
|
||||
bump()
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
s.connected = false
|
||||
bump()
|
||||
}
|
||||
|
||||
const dataDisp = term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }))
|
||||
})
|
||||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||||
})
|
||||
s.disposeListeners = () => {
|
||||
dataDisp.dispose()
|
||||
resizeDisp.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function destroySession(paneId: number) {
|
||||
const s = sessionsRef.current.get(paneId)
|
||||
if (!s) return
|
||||
s.disposeListeners?.()
|
||||
s.ws?.close()
|
||||
s.term.dispose()
|
||||
s.wrapper.remove()
|
||||
sessionsRef.current.delete(paneId)
|
||||
}
|
||||
|
||||
function destroyAllSessions() {
|
||||
for (const paneId of [...sessionsRef.current.keys()]) destroySession(paneId)
|
||||
}
|
||||
|
||||
// Tear everything down on logout (sessions must not outlive the auth session).
|
||||
useEffect(() => {
|
||||
if (status === 'logged-out' || status === 'needs-setup') {
|
||||
destroyAllSessions()
|
||||
// Defer the state reset so we don't call setState synchronously inside the
|
||||
// effect body (which would trigger a cascading render).
|
||||
queueMicrotask(() => {
|
||||
const fresh = newTab()
|
||||
setTabs([fresh])
|
||||
setActiveTabId(fresh.id)
|
||||
setActivePaneId(null)
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status])
|
||||
|
||||
// --- Public actions -------------------------------------------------------
|
||||
|
||||
function setPrefs(update: (p: TerminalPrefs) => TerminalPrefs) {
|
||||
setPrefsState((prev) => {
|
||||
const next = update(prev)
|
||||
// Re-style any live sessions in place rather than recreating them.
|
||||
queueMicrotask(() => {
|
||||
for (const s of sessionsRef.current.values()) applyPrefsToSession(s)
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function addTab() {
|
||||
const tab = newTab()
|
||||
setTabs((prev) => [...prev, tab])
|
||||
setActiveTabId(tab.id)
|
||||
}
|
||||
|
||||
function closeTab(id: number) {
|
||||
setTabs((prev) => {
|
||||
const closing = prev.find((t) => t.id === id)
|
||||
// Closing a tab disconnects its panes' sessions.
|
||||
closing?.panes.forEach((p) => destroySession(p.id))
|
||||
const next = prev.filter((t) => t.id !== id)
|
||||
if (next.length === 0) {
|
||||
const t = newTab()
|
||||
setActiveTabId(t.id)
|
||||
return [t]
|
||||
}
|
||||
if (id === activeTabId) setActiveTabId(next[0].id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function updateActiveTab(fn: (tab: TabState) => TabState) {
|
||||
setTabs((prev) => prev.map((t) => (t.id === activeTabId ? fn(t) : t)))
|
||||
}
|
||||
|
||||
function setPaneCount(count: number) {
|
||||
updateActiveTab((tab) => {
|
||||
const panes = [...tab.panes]
|
||||
while (panes.length < count) panes.push({ id: genId(), hostId: null })
|
||||
while (panes.length > count) {
|
||||
const removed = panes.pop()
|
||||
// Reducing the grid disconnects the dropped pane's session.
|
||||
if (removed) destroySession(removed.id)
|
||||
}
|
||||
return { ...tab, panes }
|
||||
})
|
||||
}
|
||||
|
||||
function setPaneHost(paneId: number, hostId: number) {
|
||||
updateActiveTab((tab) => ({
|
||||
...tab,
|
||||
panes: tab.panes.map((p) => (p.id === paneId ? { ...p, hostId } : p)),
|
||||
}))
|
||||
setActivePaneId(paneId)
|
||||
|
||||
const s = ensureSession(paneId)
|
||||
if (s.hostId === hostId) return
|
||||
s.hostId = hostId
|
||||
s.selectedTmux = ''
|
||||
fetchTmuxSessions(s, hostId)
|
||||
connect(s, hostId)
|
||||
}
|
||||
|
||||
function attachPane(paneId: number, cell: HTMLElement): () => void {
|
||||
const s = ensureSession(paneId)
|
||||
cell.appendChild(s.wrapper)
|
||||
// Defer the fit until layout has settled in the new parent.
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
s.fit.fit()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
// On page unmount, move the wrapper back to the hidden root instead of
|
||||
// disposing it, so the xterm + WebSocket keep running in the background.
|
||||
const hidden = getHiddenRoot()
|
||||
if (s.wrapper.parentElement !== hidden) hidden.appendChild(s.wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
function getPaneInfo(paneId: number) {
|
||||
const s = sessionsRef.current.get(paneId)
|
||||
return {
|
||||
connected: s?.connected ?? false,
|
||||
tmuxSessions: s?.tmuxSessions ?? [],
|
||||
selectedTmux: s?.selectedTmux ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function reconnectTmux(paneId: number, rawSelectValue: string) {
|
||||
const s = sessionsRef.current.get(paneId)
|
||||
if (!s || s.hostId === null) return
|
||||
s.selectedTmux = rawSelectValue
|
||||
const value = rawSelectValue === '__new__' ? `archnest-${Date.now().toString(36)}` : rawSelectValue
|
||||
connect(s, s.hostId, value || undefined)
|
||||
}
|
||||
|
||||
const value: TerminalSessionContextValue = {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activePaneId,
|
||||
prefs,
|
||||
setActiveTabId,
|
||||
setActivePaneId,
|
||||
setPrefs,
|
||||
addTab,
|
||||
closeTab,
|
||||
setPaneCount,
|
||||
setPaneHost,
|
||||
attachPane,
|
||||
getPaneInfo,
|
||||
reconnectTmux,
|
||||
version,
|
||||
}
|
||||
|
||||
return <TerminalSessionContext.Provider value={value}>{children}</TerminalSessionContext.Provider>
|
||||
}
|
||||
|
||||
export function useTerminalSessions() {
|
||||
const ctx = useContext(TerminalSessionContext)
|
||||
if (!ctx) throw new Error('useTerminalSessions must be used within TerminalSessionProvider')
|
||||
return ctx
|
||||
}
|
||||
56
src/lib/terminalPrefs.ts
Normal file
56
src/lib/terminalPrefs.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Shared terminal constants, types, and prefs persistence. Kept in a non-component
|
||||
// module so the context file can export only its provider/hook (React Fast Refresh
|
||||
// requires component files to export components exclusively).
|
||||
|
||||
const GOLD = '#C8A434'
|
||||
|
||||
export interface TermTheme {
|
||||
name: string
|
||||
background: string
|
||||
foreground: string
|
||||
cursor: string
|
||||
}
|
||||
|
||||
export const TERM_THEMES: TermTheme[] = [
|
||||
{ name: 'ArchNest Dark', background: '#15161A', foreground: '#E8E6E0', cursor: GOLD },
|
||||
{ name: 'Matrix', background: '#000000', foreground: '#33FF66', cursor: '#33FF66' },
|
||||
{ name: 'Solarized', background: '#002B36', foreground: '#93A1A1', cursor: '#CB4B16' },
|
||||
{ name: 'Midnight Blue', background: '#0B1021', foreground: '#C7D3F2', cursor: '#5FA8FF' },
|
||||
]
|
||||
|
||||
export interface TerminalPrefs {
|
||||
themeName: string
|
||||
fontSize: number
|
||||
fontFamily: string
|
||||
}
|
||||
|
||||
export interface PaneState {
|
||||
id: number
|
||||
hostId: number | null
|
||||
}
|
||||
|
||||
export interface TabState {
|
||||
id: number
|
||||
name: string
|
||||
panes: PaneState[]
|
||||
}
|
||||
|
||||
const PREFS_KEY = 'archnest-terminal-prefs'
|
||||
|
||||
export function defaultPrefs(): TerminalPrefs {
|
||||
return { themeName: TERM_THEMES[0].name, fontSize: 13, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }
|
||||
}
|
||||
|
||||
export function loadPrefs(): TerminalPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_KEY)
|
||||
if (raw) return { ...defaultPrefs(), ...JSON.parse(raw) }
|
||||
} catch {
|
||||
/* ignore malformed local storage */
|
||||
}
|
||||
return defaultPrefs()
|
||||
}
|
||||
|
||||
export function savePrefs(prefs: TerminalPrefs) {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs))
|
||||
}
|
||||
|
|
@ -4,12 +4,15 @@ import { BrowserRouter } from 'react-router-dom'
|
|||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './lib/AuthContext'
|
||||
import { TerminalSessionProvider } from './lib/TerminalSessionContext'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<TerminalSessionProvider>
|
||||
<App />
|
||||
</TerminalSessionProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,12 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react'
|
||||
import { api, getToken, type Integration } from '../lib/api'
|
||||
import { api, type Integration } from '../lib/api'
|
||||
import { useTerminalSessions } from '../lib/TerminalSessionContext'
|
||||
import { TERM_THEMES } from '../lib/terminalPrefs'
|
||||
|
||||
const GOLD = '#C8A434'
|
||||
const TEXT_SECONDARY = '#7A7D85'
|
||||
|
||||
interface TermTheme {
|
||||
name: string
|
||||
background: string
|
||||
foreground: string
|
||||
cursor: string
|
||||
}
|
||||
|
||||
const TERM_THEMES: TermTheme[] = [
|
||||
{ name: 'ArchNest Dark', background: '#15161A', foreground: '#E8E6E0', cursor: GOLD },
|
||||
{ name: 'Matrix', background: '#000000', foreground: '#33FF66', cursor: '#33FF66' },
|
||||
{ name: 'Solarized', background: '#002B36', foreground: '#93A1A1', cursor: '#CB4B16' },
|
||||
{ name: 'Midnight Blue', background: '#0B1021', foreground: '#C7D3F2', cursor: '#5FA8FF' },
|
||||
]
|
||||
|
||||
const FONT_SIZES = [11, 12, 13, 14, 15, 16]
|
||||
const FONT_FAMILIES = [
|
||||
{ name: 'Monospace', value: 'ui-monospace, SFMono-Regular, Menlo, monospace' },
|
||||
|
|
@ -29,108 +14,32 @@ const FONT_FAMILIES = [
|
|||
{ name: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace' },
|
||||
]
|
||||
|
||||
interface TerminalPrefs {
|
||||
themeName: string
|
||||
fontSize: number
|
||||
fontFamily: string
|
||||
}
|
||||
|
||||
const PREFS_KEY = 'archnest-terminal-prefs'
|
||||
|
||||
function loadPrefs(): TerminalPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_KEY)
|
||||
if (raw) return { ...defaultPrefs(), ...JSON.parse(raw) }
|
||||
} catch {
|
||||
/* ignore malformed local storage */
|
||||
}
|
||||
return defaultPrefs()
|
||||
}
|
||||
|
||||
function defaultPrefs(): TerminalPrefs {
|
||||
return { themeName: TERM_THEMES[0].name, fontSize: 13, fontFamily: FONT_FAMILIES[0].value }
|
||||
}
|
||||
|
||||
function savePrefs(prefs: TerminalPrefs) {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs))
|
||||
}
|
||||
|
||||
interface PaneState {
|
||||
id: number
|
||||
hostId: number | null
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
id: number
|
||||
name: string
|
||||
panes: PaneState[]
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
const genId = () => nextId++
|
||||
|
||||
function newTab(): TabState {
|
||||
return { id: genId(), name: 'Terminal', panes: [{ id: genId(), hostId: null }] }
|
||||
}
|
||||
|
||||
export default function Terminal() {
|
||||
const [hosts, setHosts] = useState<Integration[]>([])
|
||||
const [tabs, setTabs] = useState<TabState[]>(() => [newTab()])
|
||||
const [activeTabId, setActiveTabId] = useState(() => tabs[0].id)
|
||||
const [activePaneId, setActivePaneId] = useState<number | null>(null)
|
||||
const [prefs, setPrefs] = useState<TerminalPrefs>(loadPrefs)
|
||||
const [showPrefs, setShowPrefs] = useState(false)
|
||||
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activePaneId,
|
||||
prefs,
|
||||
setActiveTabId,
|
||||
setActivePaneId,
|
||||
setPrefs,
|
||||
addTab,
|
||||
closeTab,
|
||||
setPaneCount,
|
||||
setPaneHost,
|
||||
} = useTerminalSessions()
|
||||
|
||||
useEffect(() => {
|
||||
api.listIntegrations().then(({ integrations }) => {
|
||||
setHosts(integrations.filter((i) => i.type === 'ssh'))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => savePrefs(prefs), [prefs])
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]
|
||||
|
||||
function addTab() {
|
||||
const tab = newTab()
|
||||
setTabs((prev) => [...prev, tab])
|
||||
setActiveTabId(tab.id)
|
||||
}
|
||||
|
||||
function closeTab(id: number) {
|
||||
setTabs((prev) => {
|
||||
const next = prev.filter((t) => t.id !== id)
|
||||
if (next.length === 0) {
|
||||
const t = newTab()
|
||||
setActiveTabId(t.id)
|
||||
return [t]
|
||||
}
|
||||
if (id === activeTabId) setActiveTabId(next[0].id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function updateActiveTab(fn: (tab: TabState) => TabState) {
|
||||
setTabs((prev) => prev.map((t) => (t.id === activeTabId ? fn(t) : t)))
|
||||
}
|
||||
|
||||
function setPaneCount(count: number) {
|
||||
updateActiveTab((tab) => {
|
||||
const panes = [...tab.panes]
|
||||
while (panes.length < count) panes.push({ id: genId(), hostId: null })
|
||||
while (panes.length > count) panes.pop()
|
||||
return { ...tab, panes }
|
||||
})
|
||||
}
|
||||
|
||||
function setPaneHost(paneId: number, hostId: number) {
|
||||
updateActiveTab((tab) => ({
|
||||
...tab,
|
||||
panes: tab.panes.map((p) => (p.id === paneId ? { ...p, hostId } : p)),
|
||||
}))
|
||||
setActivePaneId(paneId)
|
||||
}
|
||||
|
||||
const paneGridClass =
|
||||
activeTab.panes.length === 1
|
||||
? 'grid-cols-1 grid-rows-1'
|
||||
|
|
@ -138,6 +47,8 @@ export default function Terminal() {
|
|||
? 'grid-cols-2 grid-rows-1'
|
||||
: 'grid-cols-2 grid-rows-2'
|
||||
|
||||
const activePaneHostId = activeTab.panes.find((p) => p.id === activePaneId)?.hostId ?? null
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-4">
|
||||
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-white/10 bg-white/5 p-3">
|
||||
|
|
@ -156,8 +67,8 @@ export default function Terminal() {
|
|||
onClick={() => activePaneId !== null && setPaneHost(activePaneId, h.id)}
|
||||
className="rounded-md px-2 py-1.5 text-left text-sm transition-colors"
|
||||
style={{
|
||||
background: activeTab.panes.find((p) => p.id === activePaneId)?.hostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
|
||||
color: activeTab.panes.find((p) => p.id === activePaneId)?.hostId === h.id ? GOLD : '#E8E6E0',
|
||||
background: activePaneHostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
|
||||
color: activePaneHostId === h.id ? GOLD : '#E8E6E0',
|
||||
}}
|
||||
>
|
||||
{h.name}
|
||||
|
|
@ -165,7 +76,8 @@ export default function Terminal() {
|
|||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11px]" style={{ color: TEXT_SECONDARY }}>
|
||||
Click a pane, then a host to connect it.
|
||||
Click a pane, then a host to connect it. Sessions stay connected when
|
||||
you switch pages — close a tab or pane to disconnect.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -264,9 +176,9 @@ export default function Terminal() {
|
|||
{activeTab.panes.map((pane) => (
|
||||
<TerminalPane
|
||||
key={pane.id}
|
||||
paneId={pane.id}
|
||||
hostId={pane.hostId}
|
||||
hosts={hosts}
|
||||
prefs={prefs}
|
||||
active={pane.id === activePaneId}
|
||||
onFocus={() => setActivePaneId(pane.id)}
|
||||
/>
|
||||
|
|
@ -278,122 +190,34 @@ export default function Terminal() {
|
|||
}
|
||||
|
||||
function TerminalPane({
|
||||
paneId,
|
||||
hostId,
|
||||
hosts,
|
||||
prefs,
|
||||
active,
|
||||
onFocus,
|
||||
}: {
|
||||
paneId: number
|
||||
hostId: number | null
|
||||
hosts: Integration[]
|
||||
prefs: TerminalPrefs
|
||||
active: boolean
|
||||
onFocus: () => void
|
||||
}) {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [tmuxSessions, setTmuxSessions] = useState<string[]>([])
|
||||
const [selectedTmux, setSelectedTmux] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const termRef = useRef<XTerm | null>(null)
|
||||
const fitRef = useRef<FitAddon | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const lastHostIdRef = useRef<number | null>(null)
|
||||
const { attachPane, getPaneInfo, reconnectTmux, version } = useTerminalSessions()
|
||||
const cellRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Mount the persistent xterm DOM node owned by the provider into this cell;
|
||||
// on unmount (route change or layout change) the wrapper is moved back to the
|
||||
// hidden root rather than disposed, so the session keeps running.
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0]
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: prefs.fontSize,
|
||||
fontFamily: prefs.fontFamily,
|
||||
theme: { background: theme.background, foreground: theme.foreground, cursor: theme.cursor },
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(containerRef.current)
|
||||
fit.fit()
|
||||
termRef.current = term
|
||||
fitRef.current = fit
|
||||
|
||||
const onResize = () => fit.fit()
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
term.dispose()
|
||||
wsRef.current?.close()
|
||||
}
|
||||
if (!cellRef.current) return
|
||||
const detach = attachPane(paneId, cellRef.current)
|
||||
return detach
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [prefs.themeName, prefs.fontSize, prefs.fontFamily])
|
||||
|
||||
useEffect(() => {
|
||||
fitRef.current?.fit()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (hostId === null || hostId === lastHostIdRef.current) return
|
||||
lastHostIdRef.current = hostId
|
||||
setSelectedTmux('')
|
||||
fetchTmuxSessions(hostId)
|
||||
connect(hostId)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostId])
|
||||
|
||||
function fetchTmuxSessions(id: number) {
|
||||
setTmuxSessions([])
|
||||
const token = getToken()
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
|
||||
ws.onopen = () => ws.send(JSON.stringify({ type: 'list_tmux', integrationId: id }))
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'tmux_sessions') {
|
||||
setTmuxSessions(msg.sessions ?? [])
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connect(id: number, tmuxSession?: string) {
|
||||
wsRef.current?.close()
|
||||
setConnected(false)
|
||||
const term = termRef.current
|
||||
if (!term) return
|
||||
term.reset()
|
||||
term.writeln('Connecting…')
|
||||
|
||||
const token = getToken()
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows, tmuxSession }))
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'connected') {
|
||||
setConnected(true)
|
||||
term.reset()
|
||||
} else if (msg.type === 'data') {
|
||||
term.write(msg.data)
|
||||
} else if (msg.type === 'error') {
|
||||
term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`)
|
||||
setConnected(false)
|
||||
} else if (msg.type === 'closed') {
|
||||
term.writeln('\r\n\x1b[33mConnection closed.\x1b[0m')
|
||||
setConnected(false)
|
||||
}
|
||||
}
|
||||
ws.onclose = () => setConnected(false)
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }))
|
||||
})
|
||||
term.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||||
})
|
||||
}
|
||||
}, [paneId])
|
||||
|
||||
// `version` is read so the header re-renders when live pane state changes.
|
||||
void version
|
||||
const info = getPaneInfo(paneId)
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
|
||||
return (
|
||||
|
|
@ -406,25 +230,20 @@ function TerminalPane({
|
|||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 px-1 text-xs" style={{ color: TEXT_SECONDARY }}>
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ background: connected ? '#2ECC71' : '#7A7D85' }} />
|
||||
{host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'}
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ background: info.connected ? '#2ECC71' : '#7A7D85' }} />
|
||||
{host ? (info.connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'}
|
||||
{host && (
|
||||
<select
|
||||
value={selectedTmux}
|
||||
value={info.selectedTmux}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
const value = raw === '__new__' ? `archnest-${Date.now().toString(36)}` : raw
|
||||
setSelectedTmux(raw)
|
||||
connect(host.id, value || undefined)
|
||||
}}
|
||||
onChange={(e) => reconnectTmux(paneId, e.target.value)}
|
||||
className="ml-auto rounded-md border border-white/10 bg-transparent px-1.5 py-0.5"
|
||||
style={{ color: TEXT_SECONDARY, fontSize: '11px' }}
|
||||
title="Attach to a tmux session on this host"
|
||||
>
|
||||
<option value="">Plain shell</option>
|
||||
<option value="__new__">New tmux session</option>
|
||||
{tmuxSessions.map((s) => (
|
||||
{info.tmuxSessions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
tmux: {s}
|
||||
</option>
|
||||
|
|
@ -432,7 +251,7 @@ function TerminalPane({
|
|||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div ref={containerRef} className="min-h-0 flex-1" />
|
||||
<div ref={cellRef} className="min-h-0 flex-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue