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