import { useEffect, useMemo, useState } from "react"; import { api, type PortEntry, type PortList, type PortScanResult } from "../api/client"; import { sortItems, type SortDirection } from "../hooks/useSortable"; import { usePagination } from "../hooks/usePagination"; import { formatDateTime } from "../utils/date"; import { downloadCsv } from "../utils/csv"; import { readableError } from "../utils/errors"; import Pagination from "./Pagination"; import SortableTh from "./SortableTh"; import { confirmDialog } from "../utils/dialogs"; const PRESETS: { key: string; label: string; from: number; to: number }[] = [ { key: "well-known", label: "1–1024 (well-known)", from: 1, to: 1024 }, { key: "common", label: "1–10000 (most services)", from: 1, to: 10000 }, { key: "high1", label: "10001–30000", from: 10001, to: 30000 }, { key: "high2", label: "30001–50000", from: 30001, to: 50000 }, { key: "high3", label: "50001–65535", from: 50001, to: 65535 }, { key: "custom", label: "Custom range…", from: 8000, to: 8999 }, ]; const MAX_SHOWN_RANGES = 24; interface NoteForm { editingId: number | null; port: string; protocol: "tcp" | "udp"; label: string; comment: string; } function rangeText([from, to]: [number, number]) { return from === to ? String(from) : `${from}–${to}`; } function StatusBadges({ entry }: { entry: PortEntry }) { if (entry.state === "reserved") return Reserved; return (
{entry.scanOpen && ( Reachable )} {entry.agent && ( Listening )} {entry.agent?.localOnly && ( local only )}
); } export default function ServerPorts({ serverId, addresses, canEdit, }: { serverId: number; /** Addresses this server is known by, offered as scan targets. */ addresses: string[]; canEdit: boolean; }) { const [data, setData] = useState(null); const [error, setError] = useState(null); const [filter, setFilter] = useState(""); const [sortKey, setSortKey] = useState("port"); const [sortDir, setSortDir] = useState("asc"); const [showScan, setShowScan] = useState(false); const [address, setAddress] = useState(""); const [presetKey, setPresetKey] = useState("common"); const [customFrom, setCustomFrom] = useState("8000"); const [customTo, setCustomTo] = useState("8999"); const [scanning, setScanning] = useState(false); const [result, setResult] = useState(null); const [showAllRanges, setShowAllRanges] = useState(false); const [note, setNote] = useState(null); const [savingNote, setSavingNote] = useState(false); useEffect(() => { api.servers.ports .list(serverId) .then(setData) .catch((err) => setError(readableError(err))); }, [serverId]); // Default the scan target to the server's first known address once we have one. useEffect(() => { if (!address && addresses.length > 0) setAddress(addresses[0]); }, [addresses, address]); const visible = useMemo(() => { if (!data) return null; const q = filter.trim().toLowerCase(); const rows = q ? data.ports.filter((p) => [String(p.port), p.protocol, p.label, p.comment, p.agent?.process].some((v) => v?.toLowerCase().includes(q)), ) : data.ports; return sortItems(rows, sortKey, sortDir); }, [data, filter, sortKey, sortDir]); const { pageItems, page, setPage, pageCount, totalCount } = usePagination(visible); function requestSort(key: keyof PortEntry) { if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc")); else { setSortKey(key); setSortDir("asc"); } } const preset = PRESETS.find((p) => p.key === presetKey)!; const from = presetKey === "custom" ? Number(customFrom) : preset.from; const to = presetKey === "custom" ? Number(customTo) : preset.to; async function runScan(e: React.FormEvent) { e.preventDefault(); setError(null); setScanning(true); setResult(null); setShowAllRanges(false); try { const res = await api.servers.ports.scan(serverId, { address: address.trim(), from, to }); setResult(res); setData({ ports: res.ports, agentReporting: res.agentReporting, agentReportedAt: res.agentReportedAt, lastScan: res.scan }); } catch (err) { setError(readableError(err)); } finally { setScanning(false); } } function openNote(initial: Partial = {}) { setError(null); setNote({ editingId: null, port: "", protocol: "tcp", label: "", comment: "", ...initial }); } function editEntry(entry: PortEntry) { openNote({ editingId: entry.id ?? -1, // -1: a port with no row yet — a note creates one port: String(entry.port), protocol: entry.protocol, label: entry.label ?? "", comment: entry.comment ?? "", }); } async function saveNote(e: React.FormEvent) { e.preventDefault(); if (!note) return; setSavingNote(true); setError(null); try { const res = await api.servers.ports.save(serverId, { port: Number(note.port), protocol: note.protocol, label: note.label.trim(), comment: note.comment.trim(), }); setData((prev) => (prev ? { ...prev, ports: res.ports } : prev)); // A port that was just reserved is no longer "available" in the last scan's result. setResult((prev) => { if (!prev) return prev; const port = Number(note.port); const ranges = prev.freeRanges.flatMap(([a, b]): [number, number][] => { if (port < a || port > b) return [[a, b]]; const out: [number, number][] = []; if (port > a) out.push([a, port - 1]); if (port < b) out.push([port + 1, b]); return out; }); const removed = prev.freeRanges.some(([a, b]) => port >= a && port <= b); return { ...prev, freeRanges: ranges, freeCount: prev.freeCount - (removed && note.protocol === "tcp" ? 1 : 0) }; }); setNote(null); } catch (err) { setError(readableError(err)); } finally { setSavingNote(false); } } async function removeNote(entry: PortEntry) { if (entry.id === null) return; const what = entry.state === "reserved" ? `Remove the reservation for ${entry.protocol}/${entry.port}?` : `Clear the note on ${entry.protocol}/${entry.port}?`; const reserved = entry.state === "reserved"; if (!(await confirmDialog({ title: reserved ? "Remove reservation" : "Clear note", message: what, confirmLabel: reserved ? "Remove" : "Clear", danger: true }))) return; try { await api.servers.ports.remove(serverId, entry.id); setData(await api.servers.ports.list(serverId)); } catch (err) { setError(readableError(err)); } } function exportCsv() { if (!visible) return; downloadCsv( "ports.csv", ["Port", "Protocol", "Status", "Service", "Process", "Comment"], visible.map((p) => [p.port, p.protocol, p.state, p.label ?? "", p.agent?.process ?? "", p.comment ?? ""]), ); } const shownRanges = result ? (showAllRanges ? result.freeRanges : result.freeRanges.slice(0, MAX_SHOWN_RANGES)) : []; const editing = note !== null && note.editingId !== null; return (

Ports

{data && data.ports.length > 0 && ( )} {canEdit && ( <> )}
{error &&
{error}
} {data && (
{data.agentReporting ? ( <>Listening ports reported by the agent{data.agentReportedAt ? ` as of ${formatDateTime(new Date(data.agentReportedAt))}` : ""}. ) : ( <> The agent hasn't reported listening ports (update it to see services bound to localhost only, which a scan can't detect).{" "} )} {data.lastScan ? ( <> Last scan {formatDateTime(new Date(data.lastScan.at))}: {data.lastScan.address}, ports {data.lastScan.from}– {data.lastScan.to}. ) : ( <>Not scanned yet. )}
)} {canEdit && showScan && (
setAddress(e.target.value)} /> {addresses.map((a) => (
{presetKey === "custom" && (
setCustomFrom(e.target.value)} /> – setCustomTo(e.target.value)} />
)}
A TCP scan from this app: it can see what's reachable from here, not services bound to localhost or blocked by a firewall. Only private addresses can be scanned, up to 20,000 ports at a time.
)} {result && (
{!result.scan.responded ? (
Nothing answered on {result.scan.address} — the host may be down, or a firewall is dropping everything. Existing results were left as they were.
) : ( <>
{result.scan.open} open in {result.scan.from}–{result.scan.to} on {result.scan.address}.{" "} {result.freeCount} available {result.scan.filtered > 0 && ( {" "} ({result.scan.filtered} didn't answer at all, so can't be called available) )} .
{result.freeRanges.length > 0 ? (
{shownRanges.map((r) => ( ))} {result.freeRanges.length > MAX_SHOWN_RANGES && ( )}
) : (
No port in this range was confirmed free.
)} {result.freeRanges.length > 0 && canEdit && (
Click a range to reserve a port from it with a note.
)} )}
)} {canEdit && note && (
setNote({ ...note, port: e.target.value })} />
setNote({ ...note, label: e.target.value })} />
setNote({ ...note, comment: e.target.value })} />
{!editing && A port with a note counts as taken, even when nothing is listening.}
)} {data && data.ports.length > 0 && ( setFilter(e.target.value)} /> )}
{data && data.ports.length === 0 && (
No ports recorded yet. {canEdit ? "Run a scan to find what's open, or add a note to reserve a port." : ""}
)} {pageItems && pageItems.length > 0 && (
label="Port" sortKeyName="port" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Proto" sortKeyName="protocol" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Status" sortKeyName="state" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Service" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Comment" sortKeyName="comment" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> {canEdit && } {pageItems.map((p) => ( {canEdit && ( )} ))}
Actions
{p.port} {p.protocol} {p.label ?? —} {p.agent?.process &&
{p.agent.process}
}
{p.comment ?? "—"}
{(p.label || p.comment) && ( )}
)} {visible && visible.length === 0 && data && data.ports.length > 0 && (
Nothing matches that filter.
)}
); }