diff --git a/src/features/ports/ListenersPage.tsx b/src/features/ports/ListenersPage.tsx
index 398c17b..051aa1b 100644
--- a/src/features/ports/ListenersPage.tsx
+++ b/src/features/ports/ListenersPage.tsx
@@ -7,13 +7,15 @@
import { useState } from 'react';
import { DynamicList } from '@/components/lists/DynamicList';
import { PortsTable } from './PortsTable';
+import { ReachabilityCard } from './ReachabilityCard';
-/** Network › Listeners: the standard ports as a table, then every listener, refreshed on a change. */
+/** Network › Listeners: the standard ports as a table, whether they're reachable, then every listener, refreshed on a change. */
export function ListenersPage({ viewName }: { viewName: string }) {
const [version, setVersion] = useState(0);
return (
setVersion((v) => v + 1)} />
+
);
diff --git a/src/features/ports/ReachabilityCard.tsx b/src/features/ports/ReachabilityCard.tsx
new file mode 100644
index 0000000..53bc67c
--- /dev/null
+++ b/src/features/ports/ReachabilityCard.tsx
@@ -0,0 +1,157 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * inbuxa: under the ports table, whether each port can be reached from
+ * outside (settings-reorg, Ports). In a cluster, each node as the other
+ * nodes see it over the real network, refreshed by the server every ten
+ * minutes. On a single server, only whether each port is listening, and
+ * said plainly: a server can't see its own firewall from inside.
+ */
+
+import { useEffect, useState, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { CheckCircle2, CircleDashed, Globe, Loader2, RefreshCw, TriangleAlert, XCircle } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { apiFetch } from '@/services/api';
+import { cellFor, oldestCheck, type CellState, type ReachabilityAnswer } from './reachability';
+
+const ICON: Record = {
+ reachable: ,
+ partly: ,
+ blocked: ,
+ pending: ,
+};
+
+async function fetchAnswer(): Promise {
+ const res = await apiFetch('/api/ports/check');
+ if (!res.ok) {
+ const problem = (await res.json().catch(() => ({}))) as { detail?: string; title?: string };
+ throw new Error(problem.detail ?? problem.title ?? `The check failed (${res.status}).`);
+ }
+ return (await res.json()) as ReachabilityAnswer;
+}
+
+export function ReachabilityCard() {
+ const { t } = useTranslation();
+ const [answer, setAnswer] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const load = async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ setAnswer(await fetchAnswer());
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ useEffect(() => {
+ let live = true;
+ fetchAnswer()
+ .then((a) => live && setAnswer(a))
+ .catch((e: unknown) => live && setError(e instanceof Error ? e.message : String(e)));
+ return () => {
+ live = false;
+ };
+ }, []);
+
+ const oldest = answer ? oldestCheck(answer) : null;
+
+ return (
+
+
+
+
{t('reachability.title', 'Reachable from outside')}
+
+
+
+ {error &&
{error}
}
+
+ {answer?.mode === 'cluster' && (
+ <>
+
+ {t(
+ 'reachability.clusterBody',
+ 'Each server tries the others’ ports every {{minutes}} minutes, over the internet, by their public names.',
+ { minutes: Math.round(answer.intervalSeconds / 60) },
+ )}
+ {oldest !== null &&
+ ` ${t('reachability.since', 'Oldest result: {{time}}.', { time: new Date(oldest * 1000).toLocaleTimeString() })}`}
+
+ {t(
+ 'reachability.localBody',
+ 'A single server can’t see its own firewall from inside, so this only says whether each port is listening here. To check from outside, try from another network: nc -vz .',
+ )}
+
+ {answer.listening.length === 0 ? (
+
{t('reachability.none', 'No listeners to check.')}
+ ) : (
+
+ {answer.listening.map((l) => (
+
+ {l.listening ? ICON.reachable : ICON.blocked}
+ {l.port}
+
+ {l.listening
+ ? t('reachability.listening', 'listening on this server')
+ : t('reachability.notListening', 'not listening on this server')}
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/features/ports/reachability.test.ts b/src/features/ports/reachability.test.ts
new file mode 100644
index 0000000..24c9ce1
--- /dev/null
+++ b/src/features/ports/reachability.test.ts
@@ -0,0 +1,84 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { describe, it, expect } from 'vitest';
+import { cellFor, oldestCheck, type ProbeReport } from './reachability';
+
+const report = (probes: [number, string, boolean, string?][], at = 100): ProbeReport => ({
+ checked_at: at,
+ probes: probes.map(([port, address, ok, error]) => ({ port, address, ok, error })),
+});
+
+describe('cellFor', () => {
+ it('is reachable when every other node got through', () => {
+ const node = {
+ seenBy: [
+ { prober: 'mx2.inbuxa.com', report: report([[25, '192.0.2.1', true]]) },
+ { prober: 'mx3.inbuxa.com', report: report([[25, '192.0.2.1', true]]) },
+ ],
+ };
+ expect(cellFor(node, 25)).toEqual({ state: 'reachable', detail: 'Reached from mx2, mx3' });
+ });
+
+ it('names who could not get through, and why', () => {
+ const node = {
+ seenBy: [
+ { prober: 'mx2.inbuxa.com', report: report([[25, '192.0.2.1', true]]) },
+ {
+ prober: 'mx3.inbuxa.com',
+ report: report([
+ [25, '192.0.2.1', true],
+ [25, '2001:db8::1', false, 'no answer within 5 seconds'],
+ ]),
+ },
+ ],
+ };
+ expect(cellFor(node, 25)).toEqual({
+ state: 'partly',
+ detail: 'Reached from mx2. Not from mx3: 2001:db8::1 no answer within 5 seconds',
+ });
+ });
+
+ it('is blocked from everyone, pending before a first round, and says when a name does not resolve', () => {
+ expect(
+ cellFor({ seenBy: [{ prober: 'mx2.x', report: report([[587, '192.0.2.1', false, 'Connection refused']]) }] }, 587)
+ .state,
+ ).toBe('blocked');
+ expect(cellFor({ seenBy: [{ prober: 'mx2.x', report: null }] }, 25)).toEqual({
+ state: 'pending',
+ detail: 'Not tried yet',
+ });
+ expect(
+ cellFor(
+ { seenBy: [{ prober: 'mx2.x', report: { checked_at: 1, probes: [], error: 'mail.x doesn’t resolve' } }] },
+ 25,
+ ),
+ ).toEqual({ state: 'blocked', detail: 'Not from mx2: mail.x doesn’t resolve' });
+ });
+});
+
+describe('oldestCheck', () => {
+ it('finds the oldest round, and nothing for a single server', () => {
+ expect(
+ oldestCheck({
+ mode: 'cluster',
+ ports: [25],
+ intervalSeconds: 600,
+ nodes: [
+ { hostname: 'a', seenBy: [{ prober: 'b', report: report([], 300) }] },
+ {
+ hostname: 'b',
+ seenBy: [
+ { prober: 'a', report: report([], 200) },
+ { prober: 'c', report: null },
+ ],
+ },
+ ],
+ }),
+ ).toBe(200);
+ expect(oldestCheck({ mode: 'local', ports: [], listening: [] })).toBeNull();
+ });
+});
diff --git a/src/features/ports/reachability.ts b/src/features/ports/reachability.ts
new file mode 100644
index 0000000..b02f572
--- /dev/null
+++ b/src/features/ports/reachability.ts
@@ -0,0 +1,84 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * inbuxa: what GET /api/ports/check answers, turned into cells (settings-reorg,
+ * Ports). In a cluster each node is tried from the others, over the real
+ * network; a single server can only say whether a port is listening.
+ */
+
+export interface Probe {
+ port: number;
+ address: string;
+ ok: boolean;
+ error?: string;
+}
+
+export interface ProbeReport {
+ checked_at: number;
+ probes: Probe[];
+ error?: string;
+}
+
+export type ReachabilityAnswer =
+ | {
+ mode: 'cluster';
+ ports: number[];
+ intervalSeconds: number;
+ nodes: { hostname: string; seenBy: { prober: string; report: ProbeReport | null }[] }[];
+ }
+ | {
+ mode: 'local';
+ ports: number[];
+ listening: { port: number; address: string; listening: boolean }[];
+ };
+
+export type CellState = 'reachable' | 'partly' | 'blocked' | 'pending';
+
+export interface Cell {
+ state: CellState;
+ /** Who could connect, who couldn't and why, in words. */
+ detail: string;
+}
+
+function short(hostname: string): string {
+ return hostname.split('.')[0];
+}
+
+/** One node and port: reachable from every other node, from some, from none, or not tried yet. */
+export function cellFor(node: { seenBy: { prober: string; report: ProbeReport | null }[] }, port: number): Cell {
+ const ok: string[] = [];
+ const failed: string[] = [];
+ let tried = 0;
+ for (const { prober, report } of node.seenBy) {
+ if (!report) continue;
+ if (report.error) {
+ tried++;
+ failed.push(`${short(prober)}: ${report.error}`);
+ continue;
+ }
+ const probes = report.probes.filter((p) => p.port === port);
+ if (probes.length === 0) continue;
+ tried++;
+ // A port counts as reachable from a prober when every address it resolved to answered.
+ const bad = probes.filter((p) => !p.ok);
+ if (bad.length === 0) ok.push(short(prober));
+ else failed.push(`${short(prober)}: ${bad.map((p) => `${p.address} ${p.error ?? 'no answer'}`).join('; ')}`);
+ }
+ if (tried === 0) return { state: 'pending', detail: 'Not tried yet' };
+ const parts = [];
+ if (ok.length > 0) parts.push(`Reached from ${ok.join(', ')}`);
+ if (failed.length > 0) parts.push(`Not from ${failed.join('; ')}`);
+ const state: CellState = failed.length === 0 ? 'reachable' : ok.length === 0 ? 'blocked' : 'partly';
+ return { state, detail: parts.join('. ') };
+}
+
+/** The oldest check behind the answer, so the page can say how fresh it is. */
+export function oldestCheck(answer: ReachabilityAnswer): number | null {
+ if (answer.mode !== 'cluster') return null;
+ const times = answer.nodes.flatMap((n) => n.seenBy.flatMap((s) => (s.report ? [s.report.checked_at] : [])));
+ return times.length > 0 ? Math.min(...times) : null;
+}