From 417e091dfe43ecfd90ccb455767f45012ecece41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 19:00:24 +0000 Subject: [PATCH] a nav down the left, whatever it selects beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two fixed panels could show two of NPM's seven sections and had nowhere to put the other five. now the left column is the section list — proxy hosts, redirections, streams, 404 hosts, access lists, certificates, connection — and the right renders whichever is chosen. NPM's own vocabulary, in NPM's own order. somebody who has used it should find the same words, and somebody who has not will end up in NPM's UI eventually anyway; better names would just mean learning both. "404 hosts" reads oddly and is theirs, so the hint under it says what it means. the two panels never speak. the nav writes ?section= and the detail pane reads it — navigation-audit's rule, and it is what makes a section linkable and survive a reload. changing section clears ?host=, which belonged to the list that was open. three things folded up while doing it. the unconfigured check moved to the detail pane and happens once, so all seven sections say "no connection yet" the same way instead of six saying it badly. the settings gear is gone: connection is a nav row now. and the four list-only sections are one component, differing only in what a row says — proxy hosts and certificates stay separate because they have real behaviour, and folding them in would be a props bag that is a worse version of both. the counts in the nav are one request each. affordable against a local NPM, and they answer "is this worth opening" without a click. a count that fails is absent rather than zero — zero is a fact about NPM, a failure is a fact about the request, and the detail pane reports that properly. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- web/DetailPanel.tsx | 75 +++++++++++++++++++++++ web/HostsPanel.tsx | 29 ++------- web/ListSection.tsx | 143 ++++++++++++++++++++++++++++++++++++++++++++ web/NavPanel.tsx | 103 +++++++++++++++++++++++++++++++ web/layout.ts | 12 ++-- web/panels.ts | 24 ++++---- web/sections.ts | 89 +++++++++++++++++++++++++++ 7 files changed, 433 insertions(+), 42 deletions(-) create mode 100644 web/DetailPanel.tsx create mode 100644 web/ListSection.tsx create mode 100644 web/NavPanel.tsx create mode 100644 web/sections.ts diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx new file mode 100644 index 0000000..5674fa2 --- /dev/null +++ b/web/DetailPanel.tsx @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { useClient } from 'hooks/useClient'; +import { DEFAULT_SECTION, isSectionKey, type SectionKey } from './sections'; +import { HostsPanel } from './HostsPanel'; +import { CertificatesPanel } from './CertificatesPanel'; +import { ConnectionPanel } from './ConnectionPanel'; +import { ListSection, REDIRECTIONS, STREAMS, DEAD_HOSTS, ACCESS_LISTS } from './ListSection'; +import { useConnectionChanged } from './channel'; + +/** + * The right column: whichever section the nav has selected. + * + * It reads `?section=` from the URL rather than being handed it — the two panels are siblings in a + * workspace and neither owns the other, and `docs/navigation-audit.md` puts addressable state in the URL + * for exactly this reason. A bogus or missing value falls back rather than rendering nothing. + * + * ── The unconfigured check lives HERE, once ── + * + * Every section needs a connection, so every section would otherwise have to handle its absence, and each + * one would say something slightly different. Asking `/status` once and rendering the connection form + * instead of any section is the same answer for all seven, and it means "no connection yet" reads as a + * setup step rather than as six broken lists. + */ + +type Status = { configured: boolean }; + +export const DetailPanel = () => { + const [params] = useSearchParams(); + const raw = params.get('section'); + const section: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; + + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [configured, setConfigured] = useState(null); + const [connectionSignal] = useConnectionChanged(); + + const check = useCallback(async () => { + try { + const status = await clientRef.current.get('/proxy-manager/status'); + setConfigured(status.configured); + } catch { + // Cannot tell. Render the section rather than the setup form — a transient failure must not look + // like "you have not set this up", which is a much more alarming thing to be told. + setConfigured(true); + } + }, []); + + useEffect(() => { + void check(); + }, [check, connectionSignal]); + + if (configured === null) { + return
Loading…
; + } + + if (!configured || section === 'connection') return ; + + switch (section) { + case 'proxy-hosts': + return ; + case 'certificates': + return ; + case 'redirections': + return ; + case 'streams': + return ; + case 'dead-hosts': + return ; + case 'access-lists': + return ; + } +}; diff --git a/web/HostsPanel.tsx b/web/HostsPanel.tsx index 83055a3..639f557 100644 --- a/web/HostsPanel.tsx +++ b/web/HostsPanel.tsx @@ -1,8 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { AlertTriangle, Globe, Lock, LockOpen, Power, RefreshCw, Settings, ShieldCheck, Users } from "lucide-react"; +import { AlertTriangle, Globe, Lock, LockOpen, Power, RefreshCw, ShieldCheck, Users } from "lucide-react"; import { useClient } from "hooks/useClient"; import { useSearchParams } from "react-router"; -import { ConnectionPanel } from "./ConnectionPanel"; import { readableError } from "./errors"; import { useConnectionChanged } from "./channel"; @@ -64,9 +63,6 @@ export const HostsPanel = () => { const [busy, setBusy] = useState(null); const [params, setParams] = useSearchParams(); const selected = params.get("host"); - // `?config=1` is the settings page; an unconfigured plugin shows it whether or not it was asked for. - // In the URL rather than in state, so it survives a refresh and can be linked to — `docs/navigation-audit.md`. - const configuring = params.get("config") === "1"; // Re-read when the connection is saved or removed. Named as an effect dependency below — that IS the // subscription; there is nothing to unsubscribe from. const [connectionSignal] = useConnectionChanged(); @@ -97,9 +93,8 @@ export const HostsPanel = () => { } }; - // Order matters: an explicit `?config=1` wins over everything, then "there is nothing to connect to" - // wins over the generic failure screen. Otherwise the first thing a new install shows is an error. - if (configuring || failure?.unconfigured) return ; + // No `unconfigured` branch here any more: `DetailPanel` asks `/status` once and renders the connection + // form instead of any section, so all seven say the same thing rather than six of them saying it badly. if (failure) return void load()} />; if (!hosts) return Loading hosts…; @@ -110,21 +105,9 @@ export const HostsPanel = () => { {hosts.length} host{hosts.length === 1 ? "" : "s"} - - - {/* Not a Link: this panel lives inside a workspace and the target is a parameter on the page it - is already on. A button that sets the parameter is the same navigation without a full route. */} - - + {hosts.length === 0 ? ( diff --git a/web/ListSection.tsx b/web/ListSection.tsx new file mode 100644 index 0000000..7494423 --- /dev/null +++ b/web/ListSection.tsx @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { RefreshCw } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { readableError } from './errors'; +import { useConnectionChanged } from './channel'; + +/** + * The four sections that are a list and nothing more — redirections, streams, 404 hosts, access lists. + * + * One component rather than four near-identical ones. Each differs only in what a row SAYS, so that is the + * one thing passed in; everything around it — loading, failure, empty, refresh — is the same problem four + * times over and was not worth writing four times. + * + * Proxy hosts and certificates are deliberately NOT here. They have real behaviour of their own: a + * selection with an enable/disable action, and expiry arithmetic that decides the colour of a row. Folding + * them in would mean a props bag that is a worse version of both. + */ + +type Row = { id: number } & Record; + +export type ListSectionProps = { + /** Plugin route, e.g. `/proxy-manager/streams`. */ + path: string; + /** What this is, shown when there are none. */ + emptyText: string; + /** The row's heading — usually a domain or a port. */ + primary: (row: Row) => string; + /** The line under it — where it goes, or what it holds. */ + secondary: (row: Row) => string; + /** Dimmed when false. NPM's `enabled` is 0/1, and some of these have no such field at all. */ + isEnabled?: (row: Row) => boolean; +}; + +const Centred = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const ListSection = ({ path, emptyText, primary, secondary, isEnabled }: ListSectionProps) => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + const [connectionSignal] = useConnectionChanged(); + + const load = useCallback(async () => { + try { + const next = await clientRef.current.get(path); + setRows(Array.isArray(next) ? next : []); + setError(null); + } catch (caught) { + setError(readableError(caught, 'Could not load this.')); + setRows(null); + } + }, [path]); + + useEffect(() => { + void load(); + }, [load, connectionSignal]); + + if (error) return {error}; + if (!rows) return Loading…; + + return ( +
+
+ + {rows.length} {rows.length === 1 ? 'entry' : 'entries'} + + +
+ + {rows.length === 0 ? ( + {emptyText} + ) : ( +
    + {rows.map((row) => ( +
  • + {primary(row)} + {secondary(row)} +
  • + ))} +
+ )} +
+ ); +}; + +/** NPM's `enabled` is an integer. Absent on the kinds that cannot be switched off. */ +const enabled = (row: Row) => row.enabled !== 0; + +const domains = (row: Row) => (Array.isArray(row.domain_names) ? (row.domain_names as string[]).join(', ') : '—'); + +/** + * The four, with only the parts that differ. + * + * Field names are NPM's, straight off the wire — `docs/npm-api.md` has the shapes. Nothing is renamed on + * the way through, so a value that looks wrong here is a value that looks the same in NPM's own UI. + */ +export const REDIRECTIONS: ListSectionProps = { + path: '/proxy-manager/redirections', + emptyText: 'No redirections. These send a domain somewhere else rather than serving it.', + primary: domains, + secondary: (r) => `→ ${String(r.forward_scheme ?? 'auto')}://${String(r.forward_domain_name ?? '?')}`, + isEnabled: enabled, +}; + +export const STREAMS: ListSectionProps = { + path: '/proxy-manager/streams', + emptyText: 'No streams. These forward a raw TCP or UDP port rather than HTTP.', + primary: (r) => `port ${String(r.incoming_port ?? '?')}`, + secondary: (r) => + `→ ${String(r.forwarding_host ?? '?')}:${String(r.forwarding_port ?? '?')}` + + `${r.tcp_forwarding ? ' · TCP' : ''}${r.udp_forwarding ? ' · UDP' : ''}`, + isEnabled: enabled, +}; + +export const DEAD_HOSTS: ListSectionProps = { + path: '/proxy-manager/dead-hosts', + emptyText: 'No 404 hosts. These serve a domain deliberately as nothing.', + primary: domains, + secondary: (r) => (r.certificate_id ? 'HTTPS' : 'HTTP only'), + isEnabled: enabled, +}; + +export const ACCESS_LISTS: ListSectionProps = { + path: '/proxy-manager/access-lists', + emptyText: 'No access lists. These decide who may reach a host.', + primary: (r) => String(r.name ?? '(unnamed)'), + secondary: (r) => { + const users = Array.isArray(r.items) ? r.items.length : 0; + const clients = Array.isArray(r.clients) ? r.clients.length : 0; + return `${users} user${users === 1 ? '' : 's'} · ${clients} client rule${clients === 1 ? '' : 's'}`; + }, +}; diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx new file mode 100644 index 0000000..9534333 --- /dev/null +++ b/web/NavPanel.tsx @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { useClient } from 'hooks/useClient'; +import { SECTIONS, DEFAULT_SECTION, isSectionKey, type SectionKey } from './sections'; +import { useConnectionChanged } from './channel'; + +/** + * The left column: which part of Nginx Proxy Manager the detail panel is showing. + * + * ── Selection is in the URL ── + * + * `?section=…`, per `docs/navigation-audit.md`: what is on screen is addressable, so it survives a reload, + * can be linked, and the detail panel reads it directly rather than being handed it through a channel. The + * two panels never talk to each other. + * + * ── The counts ── + * + * Each section fetches its own, which is one request per section on mount. That is affordable here — they + * are small JSON lists from a local NPM — and it buys the thing a bare nav cannot give you: whether a + * section is worth opening. "Streams 0" answers a question without a click. + * + * A count that fails is simply absent. A nav is not the place to report that NPM is unreachable; the + * detail panel says so properly, once, with the reason. + */ + +const rowClass = (active: boolean) => + `flex w-full items-start gap-2.5 border-b px-3 py-2.5 text-left transition-colors ${ + active ? 'bg-muted' : 'hover:bg-muted/50' + }`; + +const useSectionCounts = () => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [counts, setCounts] = useState>>({}); + const [connectionSignal] = useConnectionChanged(); + + const load = useCallback(async () => { + const results = await Promise.all( + SECTIONS.filter((s) => s.path).map(async (section) => { + try { + const rows = await clientRef.current.get(section.path!); + return [section.key, Array.isArray(rows) ? rows.length : undefined] as const; + } catch { + // Absent, not zero. Zero is a fact about NPM; a failure is a fact about the request. + return [section.key, undefined] as const; + } + }), + ); + setCounts(Object.fromEntries(results.filter(([, n]) => n !== undefined))); + }, []); + + useEffect(() => { + void load(); + }, [load, connectionSignal]); + + return counts; +}; + +export const NavPanel = () => { + const [params, setParams] = useSearchParams(); + const raw = params.get('section'); + const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; + const counts = useSectionCounts(); + + return ( + + ); +}; diff --git a/web/layout.ts b/web/layout.ts index b46c14b..2ec3343 100644 --- a/web/layout.ts +++ b/web/layout.ts @@ -1,6 +1,6 @@ import type { LayoutNode } from 'officerdev'; -// Hosts wide, certificates beside it, a chat across the bottom. +// A nav down the left, whatever it selects beside it, a chat across the bottom. // // ── The chat panel is ours; the chat inside it is the platform's ── // @@ -12,8 +12,8 @@ import type { LayoutNode } from 'officerdev'; // So `proxy-manager-chat` is our panel and `EmbeddableChat` inside it is the platform's component. No // second chat to maintain, and nothing for the allow-list to reject. // -// It gets a third of the height rather than a column, because a conversation reads badly in a narrow -// column and the two lists do not: they are scannable at any width, and a chat is not. +// The chat gets a third of the HEIGHT rather than a column, because a conversation reads badly narrow and +// a list does not. The nav is the narrow one: it holds seven rows of two short lines and nothing grows. // // ── What makes the chat useful HERE ── // @@ -29,11 +29,11 @@ export const defaultLayout: LayoutNode = { { node: { type: 'group', - id: 'proxy-manager-lists', + id: 'proxy-manager-top', direction: 'horizontal', children: [ - { node: { type: 'panel', id: 'proxy-manager-hosts', appType: 'proxy-manager-hosts' }, size: 62 }, - { node: { type: 'panel', id: 'proxy-manager-certificates', appType: 'proxy-manager-certificates' }, size: 38 }, + { node: { type: 'panel', id: 'proxy-manager-nav', appType: 'proxy-manager-nav' }, size: 30 }, + { node: { type: 'panel', id: 'proxy-manager-detail', appType: 'proxy-manager-detail' }, size: 70 }, ], }, size: 66, diff --git a/web/panels.ts b/web/panels.ts index ea888d0..4b3d4b7 100644 --- a/web/panels.ts +++ b/web/panels.ts @@ -1,11 +1,15 @@ -import { MessageSquare, Route, ShieldCheck } from 'lucide-react'; +import { List, MessageSquare, PanelRight } from 'lucide-react'; import type { AppRegistryMeta } from 'officerdev'; -import { HostsPanel } from './HostsPanel'; -import { CertificatesPanel } from './CertificatesPanel'; +import { NavPanel } from './NavPanel'; +import { DetailPanel } from './DetailPanel'; import { ChatPanel, ChatPanelHeader } from './ChatPanel'; -// Three panels. Hosts is what the plugin is for; certificates is the thing that quietly breaks and is -// worth seeing beside it rather than a click away; chat is where you ask about either of them. +// Three panels: a nav, a detail pane, and a chat. +// +// The nav and the detail pane are SIBLINGS that never speak to each other — the nav writes `?section=` and +// the detail pane reads it. That is `docs/navigation-audit.md`'s rule and it is why the sections are +// linkable and survive a reload. It replaced a pair of fixed panels (Hosts and Certificates side by side), +// which could show two of NPM's seven sections and had nowhere to put the other five. // // The chat panel has to be OURS even though the chat inside it is the platform's — `PluginScreen` pins the // layout to `allowed = panels.map(p => p.key)`, so naming `officerdev/chat` in layout.ts renders the @@ -13,14 +17,8 @@ import { ChatPanel, ChatPanelHeader } from './ChatPanel'; // // `availableOnPanel: false` — these belong to this plugin's screen, not to the generic panel picker. export const appRegistryMetas: AppRegistryMeta[] = [ - { key: 'proxy-manager-hosts', name: 'Hosts', icon: Route, component: HostsPanel, availableOnPanel: false }, - { - key: 'proxy-manager-certificates', - name: 'Certificates', - icon: ShieldCheck, - component: CertificatesPanel, - availableOnPanel: false, - }, + { key: 'proxy-manager-nav', name: 'Sections', icon: List, component: NavPanel, availableOnPanel: false }, + { key: 'proxy-manager-detail', name: 'Detail', icon: PanelRight, component: DetailPanel, availableOnPanel: false }, { key: 'proxy-manager-chat', name: 'Chat', diff --git a/web/sections.ts b/web/sections.ts new file mode 100644 index 0000000..4c4f8c9 --- /dev/null +++ b/web/sections.ts @@ -0,0 +1,89 @@ +import type { LucideIcon } from 'lucide-react'; +import { Globe, Cable, Radio, FileX2, Users, ShieldCheck, Plug } from 'lucide-react'; + +/** + * The sections, mirroring Nginx Proxy Manager's own tabs. + * + * Deliberately NPM's vocabulary rather than ours. Somebody who has used NPM should find the same words in + * the same order, and somebody who has not is going to end up in NPM's UI eventually anyway — inventing + * better names would only mean they have to learn both. + * + * `404 Hosts` is the one that reads oddly, and it is NPM's own name: a domain served deliberately as + * nothing. The description says so, because the label cannot. + */ + +export type SectionKey = + | 'proxy-hosts' + | 'redirections' + | 'streams' + | 'dead-hosts' + | 'access-lists' + | 'certificates' + | 'connection'; + +export type Section = { + key: SectionKey; + label: string; + icon: LucideIcon; + /** One line under the label in the nav. What this is, for somebody who does not already know. */ + hint: string; + /** The plugin route its count comes from, or null for the settings pane. */ + path: string | null; +}; + +export const SECTIONS: Section[] = [ + { + key: 'proxy-hosts', + label: 'Proxy Hosts', + icon: Globe, + hint: 'A domain, forwarded to something running here', + path: '/proxy-manager/hosts', + }, + { + key: 'redirections', + label: 'Redirection Hosts', + icon: Cable, + hint: 'A domain that sends visitors somewhere else', + path: '/proxy-manager/redirections', + }, + { + key: 'streams', + label: 'Streams', + icon: Radio, + hint: 'A raw TCP or UDP port, forwarded', + path: '/proxy-manager/streams', + }, + { + key: 'dead-hosts', + label: '404 Hosts', + icon: FileX2, + hint: 'A domain served deliberately as nothing', + path: '/proxy-manager/dead-hosts', + }, + { + key: 'access-lists', + label: 'Access Lists', + icon: Users, + hint: 'Who is allowed to reach a host', + path: '/proxy-manager/access-lists', + }, + { + key: 'certificates', + label: 'Certificates', + icon: ShieldCheck, + hint: 'TLS, and how long each one has left', + path: '/proxy-manager/certificates', + }, + { + key: 'connection', + label: 'Connection', + icon: Plug, + hint: "Where NPM is, and the credential — owner only", + path: null, + }, +]; + +export const DEFAULT_SECTION: SectionKey = 'proxy-hosts'; + +export const isSectionKey = (value: string | null): value is SectionKey => + !!value && SECTIONS.some((s) => s.key === value);