Add a Ports card to server pages: scan for open ports, find free ones, and keep notes
Each server's detail page now has a Ports card. "Scan…" runs a TCP connect scan of a chosen range from the app and shows what's open, along with the ranges that were actually confirmed free; clicking a free range starts a reservation. Any port can carry a service name and a comment, so the page also answers "what is this port for". A port with a note counts as taken even when nothing is listening, which is what makes a reservation work. Operators can scan and edit; everyone can read. Scans and note changes are audit-logged. Details that matter for correctness: - "Free" means the host actively refused the connection AND nobody has claimed the port. A port that never answers (firewall drop, host down) is reported as not answering, not as free. - A scan from elsewhere can't see services bound to localhost only, so the agent now also reports what is bound on the host (ss -tulnp) and those ports are treated as taken. They show as "local only". Existing agents keep working; re-run the install one-liner to add this. The field is validated leniently so one odd line can never cost an agent its whole report, tasks included. - If nothing answers at all during a scan, existing results are left alone instead of being marked all-closed. - Scan targets are limited to private addresses (RFC1918, Tailscale 100.64/10, link-local, IPv6 ULA/link-local); loopback and public addresses are refused. Ranges are capped at 20,000 ports, and only one scan runs per server at a time. - Rows exist only while they carry information: an open port, or one with a note. A closed port with no note disappears on the next scan; one with a note stays as "reserved". New table server_ports plus two columns on servers (migration 0009). Verified with 76 backend checks (scanner open/refused/filtered, address rules, agent report leniency, note/reserve/clear semantics, free-range calculation including the localhost-only case, roles, concurrency lock, no-response guard, audit entries, cascade delete) and by driving the real component against the real router in a browser. Real dev database mtime untouched. Not verified: the agent's ss/awk/jq pipeline on a real host — the awk step was checked against sample ss output and the script passes bash -n, but jq isn't available here to run the whole thing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aae4f0d74f
commit
4c11158e98
14 files changed
+2521
-1
No files matched your search
@@ -346,6 +346,44 @@ export interface ServerDetail {
|
||||
links: ServerLink[];
|
||||
}
|
||||
|
||||
export interface PortEntry {
|
||||
/** Null for a port only known from the agent that has no note yet. */
|
||||
id: number | null;
|
||||
port: number;
|
||||
protocol: "tcp" | "udp";
|
||||
label: string | null;
|
||||
comment: string | null;
|
||||
/** The last scan from this app connected to it. */
|
||||
scanOpen: boolean;
|
||||
lastSeenOpenAt: string | null;
|
||||
agent: { addresses: string[]; process: string | null; localOnly: boolean } | null;
|
||||
state: "open" | "reserved";
|
||||
}
|
||||
|
||||
export interface PortScanSummary {
|
||||
at: string;
|
||||
address: string;
|
||||
from: number;
|
||||
to: number;
|
||||
open: number;
|
||||
refused: number;
|
||||
filtered: number;
|
||||
responded: boolean;
|
||||
}
|
||||
|
||||
export interface PortList {
|
||||
ports: PortEntry[];
|
||||
agentReporting: boolean;
|
||||
agentReportedAt: string | null;
|
||||
lastScan: PortScanSummary | null;
|
||||
}
|
||||
|
||||
export interface PortScanResult extends Omit<PortList, "lastScan"> {
|
||||
scan: PortScanSummary;
|
||||
freeCount: number;
|
||||
freeRanges: [number, number][];
|
||||
}
|
||||
|
||||
export interface ServerUpdateInput {
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
@@ -803,6 +841,14 @@ export const api = {
|
||||
request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
removeLink: (id: number, linkId: number) =>
|
||||
request<void>(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }),
|
||||
ports: {
|
||||
list: (id: number) => request<PortList>(`/api/servers/${id}/ports`),
|
||||
scan: (id: number, data: { address: string; from: number; to: number }) =>
|
||||
request<PortScanResult>(`/api/servers/${id}/ports/scan`, { method: "POST", body: JSON.stringify(data) }),
|
||||
save: (id: number, data: { port: number; protocol: "tcp" | "udp"; label?: string; comment?: string }) =>
|
||||
request<{ ports: PortEntry[] }>(`/api/servers/${id}/ports`, { method: "PUT", body: JSON.stringify(data) }),
|
||||
remove: (id: number, portId: number) => request<void>(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }),
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
list: (
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
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";
|
||||
|
||||
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 <span className="badge bg-secondary-lt text-secondary">Reserved</span>;
|
||||
return (
|
||||
<div className="d-flex flex-wrap gap-1">
|
||||
{entry.scanOpen && (
|
||||
<span className="badge bg-green-lt text-green" title="A scan from this app connected to it">
|
||||
Reachable
|
||||
</span>
|
||||
)}
|
||||
{entry.agent && (
|
||||
<span className="badge bg-blue-lt text-blue" title={`Bound on: ${entry.agent.addresses.join(", ")}`}>
|
||||
Listening
|
||||
</span>
|
||||
)}
|
||||
{entry.agent?.localOnly && (
|
||||
<span className="badge bg-yellow-lt text-yellow" title="Bound to localhost only — other machines can't connect, but the port is still taken">
|
||||
local only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<PortList | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [sortKey, setSortKey] = useState<keyof PortEntry | undefined>("port");
|
||||
const [sortDir, setSortDir] = useState<SortDirection>("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<PortScanResult | null>(null);
|
||||
const [showAllRanges, setShowAllRanges] = useState(false);
|
||||
|
||||
const [note, setNote] = useState<NoteForm | null>(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<NoteForm> = {}) {
|
||||
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}?`;
|
||||
if (!confirm(what)) 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 (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Ports</h3>
|
||||
<div className="card-actions btn-list flex-nowrap">
|
||||
{data && data.ports.length > 0 && (
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={exportCsv}>
|
||||
Export CSV
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<>
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => openNote()}>
|
||||
Add note
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setShowScan((s) => !s)}>
|
||||
Scan…
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-body pb-2">
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{data && (
|
||||
<div className="text-secondary small mb-2">
|
||||
{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.</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && showScan && (
|
||||
<form onSubmit={runScan} className="row g-2 align-items-end border rounded p-3 mb-3">
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">Address to scan</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
list={`scan-addresses-${serverId}`}
|
||||
placeholder="e.g. 10.0.0.5"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
<datalist id={`scan-addresses-${serverId}`}>
|
||||
{addresses.map((a) => (
|
||||
<option key={a} value={a} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">Ports</label>
|
||||
<select className="form-select" value={presetKey} onChange={(e) => setPresetKey(e.target.value)}>
|
||||
{PRESETS.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{presetKey === "custom" && (
|
||||
<div className="col-md-2 d-flex gap-1 align-items-end">
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
min={1}
|
||||
max={65535}
|
||||
required
|
||||
aria-label="First port"
|
||||
value={customFrom}
|
||||
onChange={(e) => setCustomFrom(e.target.value)}
|
||||
/>
|
||||
<span className="pb-2">–</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
min={1}
|
||||
max={65535}
|
||||
required
|
||||
aria-label="Last port"
|
||||
value={customTo}
|
||||
onChange={(e) => setCustomTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-md-2">
|
||||
<button type="submit" className="btn btn-primary w-100" disabled={scanning || !address.trim()}>
|
||||
{scanning ? "Scanning…" : "Scan"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-12 text-secondary small">
|
||||
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.
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="border rounded p-3 mb-3">
|
||||
{!result.scan.responded ? (
|
||||
<div className="text-warning">
|
||||
Nothing answered on {result.scan.address} — the host may be down, or a firewall is dropping everything. Existing
|
||||
results were left as they were.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<strong>{result.scan.open}</strong> open in {result.scan.from}–{result.scan.to} on {result.scan.address}.{" "}
|
||||
<strong>{result.freeCount}</strong> available
|
||||
{result.scan.filtered > 0 && (
|
||||
<span className="text-secondary">
|
||||
{" "}
|
||||
({result.scan.filtered} didn't answer at all, so can't be called available)
|
||||
</span>
|
||||
)}
|
||||
.
|
||||
</div>
|
||||
{result.freeRanges.length > 0 ? (
|
||||
<div className="d-flex flex-wrap gap-1 align-items-center">
|
||||
{shownRanges.map((r) => (
|
||||
<button
|
||||
key={r[0]}
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
title="Reserve this port"
|
||||
disabled={!canEdit}
|
||||
onClick={() => openNote({ port: String(r[0]) })}
|
||||
>
|
||||
{rangeText(r)}
|
||||
</button>
|
||||
))}
|
||||
{result.freeRanges.length > MAX_SHOWN_RANGES && (
|
||||
<button type="button" className="btn btn-link btn-sm" onClick={() => setShowAllRanges((s) => !s)}>
|
||||
{showAllRanges ? "Show fewer" : `Show all ${result.freeRanges.length} ranges`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-secondary">No port in this range was confirmed free.</div>
|
||||
)}
|
||||
{result.freeRanges.length > 0 && canEdit && (
|
||||
<div className="text-secondary small mt-2">Click a range to reserve a port from it with a note.</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && note && (
|
||||
<form onSubmit={saveNote} className="row g-2 align-items-end border rounded p-3 mb-3">
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
min={1}
|
||||
max={65535}
|
||||
required
|
||||
disabled={editing}
|
||||
value={note.port}
|
||||
onChange={(e) => setNote({ ...note, port: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Protocol</label>
|
||||
<select
|
||||
className="form-select"
|
||||
disabled={editing}
|
||||
value={note.protocol}
|
||||
onChange={(e) => setNote({ ...note, protocol: e.target.value as "tcp" | "udp" })}
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Service</label>
|
||||
<input
|
||||
className="form-control"
|
||||
maxLength={100}
|
||||
placeholder="e.g. Grafana"
|
||||
value={note.label}
|
||||
onChange={(e) => setNote({ ...note, label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-5">
|
||||
<label className="form-label">Comment</label>
|
||||
<input
|
||||
className="form-control"
|
||||
maxLength={500}
|
||||
placeholder="e.g. proxied by nginx, or: reserved for the new monitoring stack"
|
||||
value={note.comment}
|
||||
onChange={(e) => setNote({ ...note, comment: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingNote}>
|
||||
{editing ? "Save" : "Add"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => setNote(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
{!editing && <span className="text-secondary small align-self-center">A port with a note counts as taken, even when nothing is listening.</span>}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{data && data.ports.length > 0 && (
|
||||
<input
|
||||
className="form-control form-control-sm mb-2"
|
||||
style={{ maxWidth: 280 }}
|
||||
placeholder="Filter by port, service, or comment"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && data.ports.length === 0 && (
|
||||
<div className="card-body pt-0 text-secondary">
|
||||
No ports recorded yet. {canEdit ? "Run a scan to find what's open, or add a note to reserve a port." : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pageItems && pageItems.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableTh<PortEntry> label="Port" sortKeyName="port" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<PortEntry> label="Proto" sortKeyName="protocol" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<PortEntry> label="Status" sortKeyName="state" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<PortEntry> label="Service" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<PortEntry> label="Comment" sortKeyName="comment" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((p) => (
|
||||
<tr key={`${p.protocol}:${p.port}`}>
|
||||
<td>
|
||||
<code>{p.port}</code>
|
||||
</td>
|
||||
<td className="text-secondary">{p.protocol}</td>
|
||||
<td>
|
||||
<StatusBadges entry={p} />
|
||||
</td>
|
||||
<td>
|
||||
{p.label ?? <span className="text-secondary">—</span>}
|
||||
{p.agent?.process && <div className="text-secondary small">{p.agent.process}</div>}
|
||||
</td>
|
||||
<td className="text-secondary">{p.comment ?? "—"}</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => editEntry(p)}>
|
||||
{p.label || p.comment ? "Edit" : "Add note"}
|
||||
</button>
|
||||
{(p.label || p.comment) && (
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => removeNote(p)}>
|
||||
{p.state === "reserved" ? "Remove" : "Clear"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{visible && visible.length === 0 && data && data.ports.length > 0 && (
|
||||
<div className="card-body pt-0 text-secondary">Nothing matches that filter.</div>
|
||||
)}
|
||||
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ServerLink,
|
||||
type TaskRecord,
|
||||
} from "../api/client";
|
||||
import ServerPorts from "../components/ServerPorts";
|
||||
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
|
||||
@@ -685,6 +686,12 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ServerPorts
|
||||
serverId={serverId}
|
||||
addresses={[...ipAddresses, ...(server.hostname && !ipAddresses.includes(server.hostname) ? [server.hostname] : [])]}
|
||||
canEdit={canEditTasks}
|
||||
/>
|
||||
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<h3 className="mb-0">Tasks</h3>
|
||||
{canEditTasks && (
|
||||
|
||||
Reference in new issue
Block a user