Compare commits
2
Commits
de5c39dddf
...
236b1da0dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
236b1da0dc | ||
|
|
4e48377348 |
@@ -0,0 +1,112 @@
|
||||
# Notifications
|
||||
|
||||
Every notification in Homelab Manager is sent through the same pipe
|
||||
(`server/src/services/notify.ts`) to whichever channels are enabled under
|
||||
**Settings → Notifications**: Gotify, ntfy, SMTP (email), and a generic
|
||||
JSON webhook. All four fire for every notification below — there's no
|
||||
per-event channel routing, only a per-event on/off toggle (the "Notify
|
||||
on" list on that same page) and, for the daily ones, one shared
|
||||
time/timezone.
|
||||
|
||||
Two settings apply to *every* notification regardless of what triggered
|
||||
it:
|
||||
|
||||
- **Quiet hours** (Settings → Notifications → Quiet hours): while
|
||||
enabled and inside the configured window, a notification is held
|
||||
instead of sent immediately, then delivered as a single digest at the
|
||||
window's end time. This matters most for the real-time alerts below —
|
||||
the daily reminders already fire once at a time you pick, usually
|
||||
outside the window anyway.
|
||||
- **Maintenance windows** (the Maintenance page): opening one for a
|
||||
server, integration, or DNS provider silences the alerts that target
|
||||
it specifically (offline/disk-full for a server; storage, backup, and
|
||||
automation-failure alerts for an integration; "integration down" for
|
||||
that whole service type) — see the Maintenance page's own "What gets
|
||||
silenced" panel for the exact list.
|
||||
|
||||
Where a trigger has a configurable threshold, that's noted in its row
|
||||
below; several are fixed and can't be changed from the UI.
|
||||
|
||||
## Daily reminders
|
||||
|
||||
These six checks share one schedule: **Settings → Notifications → Daily
|
||||
reminder time** (default 08:00) and **Timezone** (default UTC). Unlike
|
||||
the state-based alerts further down, these **re-send every day the
|
||||
condition is still true** — there's no "only once" de-duplication, so an
|
||||
expired secret you haven't renewed yet will be mentioned again at the
|
||||
next day's check, and the day after that.
|
||||
|
||||
Each one also runs once at server startup if it hasn't already run
|
||||
today (e.g. after an upgrade or a period offline), so you're not waiting
|
||||
until the next scheduled time to catch up.
|
||||
|
||||
| Check | "Notify on" toggle | Fires when | Configured at |
|
||||
|---|---|---|---|
|
||||
| Secret expiry | *Secret expiry reminder* | Any tracked secret (API token, SSL cert, password, generic) is expired or within its own configured warning window. SSL certificates with a host:port are re-checked live first, so this reflects the real current expiry, not a stale saved date. A certificate that couldn't be re-checked live is mentioned separately, so the expiry shown may be stale. | Per-secret warning threshold, set when adding/editing that secret |
|
||||
| Domain expiry | *Domain registration expiring or expired (daily reminder)* | Any tracked domain registration is expired or within the warning window; every domain is re-looked-up against its registry first. A domain whose registry lookup has been failing for 3+ days is also mentioned, separately, as "may be stale." Registries that don't publish an expiry (e.g. `.de`, `.eu`) are tracked but never trigger this. | Settings → Notifications → Health checks → **Domain expiry warning** (default 30 days) |
|
||||
| Tailscale key expiry | *Tailscale key expiry reminder* | Any device's node key (across every enabled Tailscale integration) is within 30 days of expiring, or already expired. Devices with key expiry disabled are skipped. | Fixed at 30 days |
|
||||
| Docker image updates | *Docker image update available* | Any container (across every enabled Dockhand integration) has an image update available, per Dockhand's own cached update-check results — this doesn't trigger a fresh registry lookup, just reads what the Docker page itself would show. | Not configurable (reflects Dockhand's own check interval) |
|
||||
| Proxmox backups | *Proxmox backup failed or a guest has no coverage* | Two independent conditions, both under this one toggle: (1) a node's most recent `vzdump` backup task (across every enabled Proxmox integration) didn't succeed; (2) a VM/LXC isn't covered by any enabled backup job at all. | Not configurable |
|
||||
| Proxmox Backup Server verification | *Proxmox Backup Server snapshot failed verification* | Across every enabled PBS integration: any datastore has at least one stored snapshot whose verification state is "failed", or a datastore couldn't be read at all (e.g. a permissions problem). This is distinct from the Proxmox check above — PVE only knows a backup *ran*, PBS is the only place that knows whether the stored data still verifies. | Not configurable |
|
||||
|
||||
## State-based alerts
|
||||
|
||||
These two run on a fixed **15-minute interval** (matching the agent's
|
||||
own default report interval) — not the daily reminder time above, and
|
||||
not user-configurable. Unlike the daily reminders, these are
|
||||
**state-based**: a problem is announced once when it first appears, and
|
||||
once more when it clears — not repeated on every 15-minute pass while it
|
||||
continues. A condition that can't currently be read (an integration
|
||||
that's down, a server whose agent hasn't reported) is held exactly as it
|
||||
was rather than cleared or re-announced, so a temporary read failure
|
||||
can't fake a recovery. On first-ever run (or right after upgrading to
|
||||
this feature), whatever's already failing is recorded silently as the
|
||||
starting baseline rather than announced all at once.
|
||||
|
||||
| Check | "Notify on" toggle | Fires when | Configured at |
|
||||
|---|---|---|---|
|
||||
| Health — server offline | *Server offline, disk nearly full, or Synology volume/disk problem* | A server's agent hasn't reported for longer than the offline threshold. Held for the first 20 minutes after this app restarts, since agents haven't had a chance to report yet. | Settings → Notifications → Health checks → **Server offline after** (default 60 min) |
|
||||
| Health — disk usage | *(same toggle)* | A server disk, a Proxmox node's root filesystem or any of its storages, or a Synology volume, is at or above the usage threshold. A server that's currently offline isn't also judged on disk usage (its figures are stale). | Settings → Notifications → Health checks → **Disk usage alert at** (default 90%) |
|
||||
| Health — Synology status | *(same toggle)* | A Synology volume's status isn't "normal", or a disk's status/SMART result isn't normal, or it's over the bad-sector or under the remaining-life threshold. | Not configurable |
|
||||
| Automation — failed run | *Semaphore template or Gitea workflow run failed* | A Semaphore template's, or a Gitea repo's, most recent run status is a clear failure. A run that's still going, was cancelled, or was stopped by hand doesn't count as failed or as a recovery — it's left exactly as it was. For Gitea this follows the repo's most recent run on any workflow or branch. | Not configurable |
|
||||
|
||||
## Real-time alerts
|
||||
|
||||
These fire immediately, as the triggering action happens — not on any
|
||||
schedule.
|
||||
|
||||
| Event | "Notify on" toggle | Fires when |
|
||||
|---|---|---|
|
||||
| DNS record added | *DNS record added* | A DNS record is created against any DNS provider through this app (manually, or via any automated action that writes one). |
|
||||
| DNS record updated | *DNS record updated* | An existing DNS record is edited. |
|
||||
| DNS record deleted | *DNS record deleted* | A DNS record is deleted. |
|
||||
| Integration/DNS provider down | *Integration/DNS provider failing repeatedly* | Any integration or DNS provider adapter's calls fail a configurable number of times **in a row**. Tracked per adapter *type* (e.g. "proxmox"), not per individual integration row — with two Proxmox integrations, a streak of failures on either one counts toward the same total. Only fires once per failure streak (not on every failure past the threshold), and is skipped entirely if that type is currently in a maintenance window. | Settings → Notifications → **Alert after** (default 3 consecutive failures) |
|
||||
| Integration/DNS provider recovered | *(same toggle)* | The next call for that source succeeds, after a "down" alert was already sent for the current streak. |
|
||||
|
||||
## Digest
|
||||
|
||||
| Notification | Fires when |
|
||||
|---|---|
|
||||
| "Notifications from quiet hours" | Once, at quiet hours' configured end time, **only if enabled and only if at least one notification was held** during the window. Bundles every held notification's title and message into one message, then clears the queue. |
|
||||
|
||||
## What does *not* send a notification
|
||||
|
||||
Worth calling out explicitly, since it's easy to assume everything in
|
||||
the app alerts on something:
|
||||
|
||||
- **osTicket** — the integration lists open tickets, but there is no
|
||||
scheduled check or alert wired up for it (e.g. nothing pings you about
|
||||
a newly-overdue ticket). It's a read-only dashboard/page today.
|
||||
- **phpIPAM sync**, **Uptime Kuma monitor/maintenance import**, and any
|
||||
other manual "Sync from X" action — these run when you click the
|
||||
button and report their result on the page itself, not via a
|
||||
notification.
|
||||
- **Test buttons** (Settings → Notifications → each channel's "Send
|
||||
test") send a one-off message through that one channel only, to
|
||||
confirm it's wired up correctly — not a real event and not affected by
|
||||
quiet hours.
|
||||
- **Consistency reports**, **Diagnostic Log**, and **Audit Log** are all
|
||||
read-only views you check yourself; none of them push a notification
|
||||
on their own (the Diagnostic Log's failures are what feed the
|
||||
integration-down alert above, but reading the log itself never
|
||||
triggers anything).
|
||||
@@ -268,6 +268,20 @@ host (`ss`), which catches services listening on localhost only — a scan from
|
||||
elsewhere can't see those, so they'd otherwise look free. Re-run the agent
|
||||
install one-liner on a host to pick that up.
|
||||
|
||||
**Network → Ports** is the cross-server counterpart: one page listing every
|
||||
port every agent currently reports as listening, across all servers at once
|
||||
(protocol, address, process, last report time), each linking back to its
|
||||
server. Below that, a separate, manually-maintained table is for the ports
|
||||
this app can't see on its own — a router's port forward, an edge firewall
|
||||
rule, a cloud security group — the same reason people keep a spreadsheet of
|
||||
"what did I open and why." Each entry has a label, the external port/protocol,
|
||||
an optional link to a tracked server (plus its own internal port, when NAT
|
||||
changes it) or a freeform destination, a free-text "source" (which
|
||||
router/firewall/service it's actually configured on — this app doesn't talk
|
||||
to any firewall, so it can't manage or verify the rule, only record it), and
|
||||
a comment. Viewers can see both tables; adding, editing, or deleting a manual
|
||||
entry needs operator or admin.
|
||||
|
||||
The app is installable as a PWA — "Install app" / "Add to Home Screen" from the
|
||||
browser gives it its own icon and a standalone window on phone or desktop. This
|
||||
needs the site to be served over HTTPS (browsers only offer install on secure
|
||||
|
||||
@@ -30,6 +30,7 @@ page's own top-level link.
|
||||
| DNS | `/dns` | ✅ | ✅ | ✅ |
|
||||
| Domains | `/domains` | ✅ | ✅ | ✅ |
|
||||
| IP Addresses | `/ipam` | ✅ | ✅ | ✅ |
|
||||
| Ports | `/ports` | ✅ | ✅ | ✅ |
|
||||
| Consistency | `/consistency` | ✅ | ✅ | ✅ |
|
||||
| **Automation** | | | | |
|
||||
| Semaphore | `/semaphore` | ✅ | ✅ | ✅ |
|
||||
@@ -71,6 +72,8 @@ even further, to admin only:
|
||||
linking/unlinking it to a Proxmox guest just need operator.
|
||||
- **Tags**: creating, renaming, recoloring, or deleting a tag is
|
||||
admin-only (applying an existing tag to a server needs operator).
|
||||
- **Ports**: everyone can see both the agent-reported and manual tables;
|
||||
adding, editing, or deleting a manual port opening needs operator.
|
||||
- Everything under **Settings** (notification channels, badge colors,
|
||||
display prefs, log retention, backup/restore) is admin-only, matching
|
||||
the page itself being admin-only.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `port_forwards` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`label` text NOT NULL,
|
||||
`external_port` integer NOT NULL,
|
||||
`protocol` text DEFAULT 'tcp' NOT NULL,
|
||||
`server_id` integer,
|
||||
`destination` text,
|
||||
`internal_port` integer,
|
||||
`source` text,
|
||||
`comment` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
||||
"when": 1790449897833,
|
||||
"tag": "0013_sturdy_bloodstorm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1790718056783,
|
||||
"tag": "0014_empty_gabe_jones",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -331,6 +331,33 @@ export const serverPorts = sqliteTable(
|
||||
(t) => [uniqueIndex("server_ports_unique").on(t.serverId, t.port, t.protocol)],
|
||||
);
|
||||
|
||||
// A manually-recorded port opening on something this app doesn't monitor directly — a router's port forward, an
|
||||
// edge firewall rule, a cloud provider's security group, etc. Distinct from serverPorts (which is what a server
|
||||
// itself, or a scan of it, reports): this is what someone tells the app is open further out on the network path,
|
||||
// for the same reason people keep a spreadsheet of "what did I open on the router and why."
|
||||
export const portForwards = sqliteTable("port_forwards", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
label: text("label").notNull(),
|
||||
externalPort: integer("external_port").notNull(),
|
||||
protocol: text("protocol").$type<"tcp" | "udp">().notNull().default("tcp"),
|
||||
// Optional link to a tracked server this forward points at; "destination" covers anything else (a bare IP,
|
||||
// an untracked device) or extra detail alongside a linked server.
|
||||
serverId: integer("server_id").references(() => servers.id, { onDelete: "set null" }),
|
||||
destination: text("destination"),
|
||||
// The port it's actually forwarded to, when NAT changes it (a router forwarding external 8443 to internal 443).
|
||||
internalPort: integer("internal_port"),
|
||||
// Free text: where this rule actually lives ("Home router", "OPNsense WAN rule", "Cloudflare Tunnel") — this
|
||||
// app has no integration with any firewall/router, so it can't verify or manage the rule, only record it.
|
||||
source: text("source"),
|
||||
comment: text("comment"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// ─── Domain registrations ───────────────────────────────────────────────────
|
||||
|
||||
export const domainOrigins = ["manual", "zone"] as const;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { domainsRouter } from "./routes/domains.js";
|
||||
import { consistencyRouter } from "./routes/consistency.js";
|
||||
import { privacyRouter } from "./routes/privacy.js";
|
||||
import { tagsRouter } from "./routes/tags.js";
|
||||
import { portsRouter } from "./routes/ports.js";
|
||||
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
||||
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
||||
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
|
||||
@@ -102,6 +103,7 @@ app.use("/api/domains", domainsRouter);
|
||||
app.use("/api/consistency", consistencyRouter);
|
||||
app.use("/api/privacy", privacyRouter);
|
||||
app.use("/api/tags", tagsRouter);
|
||||
app.use("/api/ports", portsRouter);
|
||||
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist));
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { servers, portForwards } from "../db/schema.js";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { type AgentPort, groupAgentPorts, parseJson, splitPortKey } from "../services/agentPorts.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const portsRouter = Router();
|
||||
|
||||
portsRouter.use(requireAuth);
|
||||
|
||||
// ─── Ports reported by agents, across every server ──────────────────────────
|
||||
|
||||
export interface AgentPortRow {
|
||||
serverId: number;
|
||||
serverName: string;
|
||||
serverHostname: string | null;
|
||||
protocol: "tcp" | "udp";
|
||||
port: number;
|
||||
addresses: string[];
|
||||
process: string | null;
|
||||
localOnly: boolean;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
portsRouter.get("/agent", asyncHandler(async (_req, res) => {
|
||||
const rows = await db
|
||||
.select({ id: servers.id, name: servers.name, hostname: servers.hostname, listeningPorts: servers.listeningPorts, lastSeenAt: servers.lastSeenAt })
|
||||
.from(servers);
|
||||
|
||||
const out: AgentPortRow[] = [];
|
||||
for (const s of rows) {
|
||||
const raw = parseJson<AgentPort[] | null>(s.listeningPorts, null);
|
||||
if (!raw) continue;
|
||||
for (const [key, grouped] of groupAgentPorts(raw)) {
|
||||
const { protocol, port } = splitPortKey(key);
|
||||
out.push({
|
||||
serverId: s.id,
|
||||
serverName: s.name,
|
||||
serverHostname: s.hostname,
|
||||
protocol,
|
||||
port,
|
||||
addresses: grouped.addresses,
|
||||
process: grouped.process,
|
||||
localOnly: grouped.localOnly,
|
||||
lastSeenAt: s.lastSeenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.serverName.localeCompare(b.serverName) || a.port - b.port || a.protocol.localeCompare(b.protocol));
|
||||
|
||||
res.json({ ports: out, reportingServers: rows.filter((s) => s.listeningPorts !== null).length, totalServers: rows.length });
|
||||
}));
|
||||
|
||||
// ─── Manually-recorded port openings (router/firewall/cloud security group, etc.) ──
|
||||
|
||||
// Blank/omitted optional fields normalize to null right here, so every downstream handler can
|
||||
// just use parsed.data as-is (matching the DB columns, which store NULL, not empty strings).
|
||||
const optionalText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.nullish()
|
||||
.transform((v) => v || null);
|
||||
|
||||
const forwardInput = z.object({
|
||||
label: z.string().trim().min(1).max(200),
|
||||
externalPort: z.number().int().min(1).max(65535),
|
||||
protocol: z.enum(["tcp", "udp"]).default("tcp"),
|
||||
serverId: z
|
||||
.number()
|
||||
.int()
|
||||
.nullish()
|
||||
.transform((v) => v ?? null),
|
||||
destination: optionalText(255),
|
||||
internalPort: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.nullish()
|
||||
.transform((v) => v ?? null),
|
||||
source: optionalText(200),
|
||||
comment: optionalText(2000),
|
||||
});
|
||||
|
||||
const updateForwardInput = forwardInput.partial();
|
||||
|
||||
portsRouter.get("/forwards", asyncHandler(async (_req, res) => {
|
||||
const rows = await db.query.portForwards.findMany({ orderBy: (p, { asc }) => [asc(p.externalPort)] });
|
||||
const serverRows = await db.select({ id: servers.id, name: servers.name }).from(servers);
|
||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name]));
|
||||
|
||||
res.json({
|
||||
forwards: rows.map((r) => ({ ...r, serverName: r.serverId !== null ? nameById.get(r.serverId) ?? null : null })),
|
||||
});
|
||||
}));
|
||||
|
||||
portsRouter.post("/forwards", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||
const parsed = forwardInput.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
if (data.serverId) {
|
||||
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, data.serverId)).limit(1);
|
||||
if (!server) return res.status(400).json({ error: "invalid_server" });
|
||||
}
|
||||
|
||||
const [created] = await db.insert(portForwards).values(data).returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "network",
|
||||
action: "create_port_forward",
|
||||
targetType: "port_forward",
|
||||
targetId: created.id,
|
||||
detail: { label: created.label, externalPort: created.externalPort, protocol: created.protocol },
|
||||
});
|
||||
|
||||
res.status(201).json({ forward: created });
|
||||
}));
|
||||
|
||||
portsRouter.patch("/forwards/:id", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const parsed = updateForwardInput.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
const [existing] = await db.select().from(portForwards).where(eq(portForwards.id, id)).limit(1);
|
||||
if (!existing) return res.status(404).json({ error: "not_found" });
|
||||
|
||||
if (data.serverId) {
|
||||
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, data.serverId)).limit(1);
|
||||
if (!server) return res.status(400).json({ error: "invalid_server" });
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(portForwards)
|
||||
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(portForwards.id, id))
|
||||
.returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "network",
|
||||
action: "update_port_forward",
|
||||
targetType: "port_forward",
|
||||
targetId: id,
|
||||
detail: { label: updated.label, externalPort: updated.externalPort, protocol: updated.protocol },
|
||||
});
|
||||
|
||||
res.json({ forward: updated });
|
||||
}));
|
||||
|
||||
portsRouter.delete("/forwards/:id", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const [existing] = await db.select().from(portForwards).where(eq(portForwards.id, id)).limit(1);
|
||||
if (!existing) return res.status(404).json({ error: "not_found" });
|
||||
|
||||
await db.delete(portForwards).where(eq(portForwards.id, id));
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "network",
|
||||
action: "delete_port_forward",
|
||||
targetType: "port_forward",
|
||||
targetId: id,
|
||||
detail: { label: existing.label, externalPort: existing.externalPort, protocol: existing.protocol },
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
}));
|
||||
@@ -6,18 +6,12 @@ import { servers, serverPorts } from "../db/schema.js";
|
||||
import { requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { beginScan, endScan, MAX_SCAN_SPAN, resolveScanTarget, scanPorts, toRanges } from "../services/portScan.js";
|
||||
import { type AgentPort, groupAgentPorts, parseJson } from "../services/agentPorts.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
// Mounted under /api/servers/:id/ports by the servers router, which has already required a signed-in user.
|
||||
export const serverPortsRouter = Router({ mergeParams: true });
|
||||
|
||||
interface AgentPort {
|
||||
protocol: "tcp" | "udp";
|
||||
port: number;
|
||||
address: string;
|
||||
process?: string;
|
||||
}
|
||||
|
||||
interface StoredScan {
|
||||
at: string;
|
||||
address: string;
|
||||
@@ -45,38 +39,6 @@ export interface PortEntry {
|
||||
state: "open" | "reserved";
|
||||
}
|
||||
|
||||
function parseJson<T>(text: string | null | undefined, fallback: T): T {
|
||||
if (!text) return fallback;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopback(address: string): boolean {
|
||||
const bare = address.replace(/%.*$/, "").replace(/^\[|\]$/g, "");
|
||||
return bare.startsWith("127.") || bare === "::1";
|
||||
}
|
||||
|
||||
/** Groups the agent's raw one-row-per-socket report into one entry per protocol+port. */
|
||||
function groupAgentPorts(raw: AgentPort[]): Map<string, { addresses: string[]; process: string | null; localOnly: boolean }> {
|
||||
const grouped = new Map<string, { addresses: Set<string>; process: string | null }>();
|
||||
for (const p of raw) {
|
||||
const key = `${p.protocol}:${p.port}`;
|
||||
const entry = grouped.get(key) ?? { addresses: new Set<string>(), process: null };
|
||||
entry.addresses.add(p.address);
|
||||
if (!entry.process && p.process) entry.process = p.process;
|
||||
grouped.set(key, entry);
|
||||
}
|
||||
const out = new Map<string, { addresses: string[]; process: string | null; localOnly: boolean }>();
|
||||
for (const [key, entry] of grouped) {
|
||||
const addresses = [...entry.addresses];
|
||||
out.set(key, { addresses, process: entry.process, localOnly: addresses.every(isLoopback) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildPortList(serverId: number) {
|
||||
const [server] = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1);
|
||||
if (!server) return null;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Shared between the per-server Ports card (routes/serverPorts.ts) and the cross-server
|
||||
// Network > Ports page (routes/ports.ts) — both read the same agent-reported "listeningPorts"
|
||||
// JSON column and need the same one-row-per-socket -> one-row-per-protocol+port grouping.
|
||||
|
||||
export interface AgentPort {
|
||||
protocol: "tcp" | "udp";
|
||||
port: number;
|
||||
address: string;
|
||||
process?: string;
|
||||
}
|
||||
|
||||
export interface GroupedAgentPort {
|
||||
addresses: string[];
|
||||
process: string | null;
|
||||
localOnly: boolean;
|
||||
}
|
||||
|
||||
export function parseJson<T>(text: string | null | undefined, fallback: T): T {
|
||||
if (!text) return fallback;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoopback(address: string): boolean {
|
||||
const bare = address.replace(/%.*$/, "").replace(/^\[|\]$/g, "");
|
||||
return bare.startsWith("127.") || bare === "::1";
|
||||
}
|
||||
|
||||
/** Groups the agent's raw one-row-per-socket report into one entry per protocol+port, keyed "tcp:443". */
|
||||
export function groupAgentPorts(raw: AgentPort[]): Map<string, GroupedAgentPort> {
|
||||
const grouped = new Map<string, { addresses: Set<string>; process: string | null }>();
|
||||
for (const p of raw) {
|
||||
const key = `${p.protocol}:${p.port}`;
|
||||
const entry = grouped.get(key) ?? { addresses: new Set<string>(), process: null };
|
||||
entry.addresses.add(p.address);
|
||||
if (!entry.process && p.process) entry.process = p.process;
|
||||
grouped.set(key, entry);
|
||||
}
|
||||
const out = new Map<string, GroupedAgentPort>();
|
||||
for (const [key, entry] of grouped) {
|
||||
const addresses = [...entry.addresses];
|
||||
out.set(key, { addresses, process: entry.process, localOnly: addresses.every(isLoopback) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function splitPortKey(key: string): { protocol: "tcp" | "udp"; port: number } {
|
||||
const [protocol, port] = key.split(":");
|
||||
return { protocol: protocol as "tcp" | "udp", port: Number(port) };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import AuditLog from "./pages/AuditLog";
|
||||
import DiagLog from "./pages/DiagLog";
|
||||
import Secrets from "./pages/Secrets";
|
||||
import Ipam from "./pages/Ipam";
|
||||
import Ports from "./pages/Ports";
|
||||
import Dns from "./pages/Dns";
|
||||
import Servers from "./pages/Servers";
|
||||
import ServerDetail from "./pages/ServerDetail";
|
||||
@@ -93,6 +94,7 @@ export default function App() {
|
||||
<Route path="/servers/:id" element={<ServerDetail user={user} />} />
|
||||
<Route path="/dns" element={<Dns user={user} />} />
|
||||
<Route path="/ipam" element={<Ipam user={user} />} />
|
||||
<Route path="/ports" element={<Ports user={user} />} />
|
||||
<Route path="/secrets" element={<Secrets user={user} />} />
|
||||
<Route path="/integrations" element={<Integrations user={user} />} />
|
||||
<Route path="/generator" element={<Generator />} />
|
||||
|
||||
@@ -140,6 +140,50 @@ export interface IpamInput {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface AgentPortRow {
|
||||
serverId: number;
|
||||
serverName: string;
|
||||
serverHostname: string | null;
|
||||
protocol: "tcp" | "udp";
|
||||
port: number;
|
||||
addresses: string[];
|
||||
process: string | null;
|
||||
localOnly: boolean;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
export interface AgentPortsResponse {
|
||||
ports: AgentPortRow[];
|
||||
reportingServers: number;
|
||||
totalServers: number;
|
||||
}
|
||||
|
||||
export interface PortForward {
|
||||
id: number;
|
||||
label: string;
|
||||
externalPort: number;
|
||||
protocol: "tcp" | "udp";
|
||||
serverId: number | null;
|
||||
serverName: string | null;
|
||||
destination: string | null;
|
||||
internalPort: number | null;
|
||||
source: string | null;
|
||||
comment: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PortForwardInput {
|
||||
label: string;
|
||||
externalPort: number;
|
||||
protocol: "tcp" | "udp";
|
||||
serverId?: number | null;
|
||||
destination?: string | null;
|
||||
internalPort?: number | null;
|
||||
source?: string | null;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium";
|
||||
|
||||
export interface GotifySettings {
|
||||
@@ -951,6 +995,17 @@ export const api = {
|
||||
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
|
||||
syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }),
|
||||
},
|
||||
ports: {
|
||||
agent: () => request<AgentPortsResponse>("/api/ports/agent"),
|
||||
forwards: {
|
||||
list: () => request<{ forwards: PortForward[] }>("/api/ports/forwards"),
|
||||
create: (data: PortForwardInput) =>
|
||||
request<{ forward: PortForward }>("/api/ports/forwards", { method: "POST", body: JSON.stringify(data) }),
|
||||
update: (id: number, data: Partial<PortForwardInput>) =>
|
||||
request<{ forward: PortForward }>(`/api/ports/forwards/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
remove: (id: number) => request<void>(`/api/ports/forwards/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
},
|
||||
dns: {
|
||||
providerFields: () =>
|
||||
request<{ fields: Record<DnsProviderType, DnsProviderField[]> }>("/api/dns/provider-fields"),
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
IconActivityHeartbeat,
|
||||
IconShieldCheck,
|
||||
IconTicket,
|
||||
IconPlug,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
|
||||
import { formatRemaining } from "../utils/duration";
|
||||
@@ -80,6 +81,7 @@ const NAV: NavEntry[] = [
|
||||
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
|
||||
{ to: "/domains", label: "Domains", icon: <IconWorldWww size={20} /> },
|
||||
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
|
||||
{ to: "/ports", label: "Ports", icon: <IconPlug size={20} /> },
|
||||
{ to: "/consistency", label: "Consistency", icon: <IconListCheck size={20} /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
api,
|
||||
type AgentPortRow,
|
||||
type CurrentUser,
|
||||
type PortForward,
|
||||
type PortForwardInput,
|
||||
type ServerRecord,
|
||||
} from "../api/client";
|
||||
import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { formatAgo } from "../utils/duration";
|
||||
|
||||
const emptyForm: PortForwardInput = {
|
||||
label: "",
|
||||
externalPort: 0,
|
||||
protocol: "tcp",
|
||||
serverId: null,
|
||||
destination: "",
|
||||
internalPort: null,
|
||||
source: "",
|
||||
comment: "",
|
||||
};
|
||||
|
||||
export default function Ports({ user }: { user: CurrentUser }) {
|
||||
const canEdit = user.role === "admin" || user.role === "operator";
|
||||
|
||||
const [agentPorts, setAgentPorts] = useState<AgentPortRow[] | null>(null);
|
||||
const [reportingServers, setReportingServers] = useState(0);
|
||||
const [totalServers, setTotalServers] = useState(0);
|
||||
const [agentError, setAgentError] = useState<string | null>(null);
|
||||
|
||||
const [servers, setServers] = useState<ServerRecord[] | null>(null);
|
||||
const [forwards, setForwards] = useState<PortForward[] | null>(null);
|
||||
const [forwardError, setForwardError] = useState<string | null>(null);
|
||||
|
||||
const [editing, setEditing] = useState<PortForward | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [form, setForm] = useState<PortForwardInput>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function loadAgentPorts() {
|
||||
api.ports
|
||||
.agent()
|
||||
.then((res) => {
|
||||
setAgentPorts(res.ports);
|
||||
setReportingServers(res.reportingServers);
|
||||
setTotalServers(res.totalServers);
|
||||
})
|
||||
.catch((err) => setAgentError(err instanceof Error ? err.message : String(err)));
|
||||
}
|
||||
|
||||
function loadForwards() {
|
||||
api.ports.forwards
|
||||
.list()
|
||||
.then((res) => setForwards(res.forwards))
|
||||
.catch((err) => setForwardError(err instanceof Error ? err.message : String(err)));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAgentPorts();
|
||||
loadForwards();
|
||||
api.servers
|
||||
.list()
|
||||
.then((res) => setServers(res.servers))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const { sorted: sortedAgentPorts, sortKey: agentSortKey, sortDir: agentSortDir, requestSort: requestAgentSort } = useSortable(
|
||||
agentPorts,
|
||||
"serverName",
|
||||
);
|
||||
const { pageItems: agentPageItems, page: agentPage, setPage: setAgentPage, pageCount: agentPageCount, totalCount: agentTotalCount } = usePagination(
|
||||
sortedAgentPorts,
|
||||
);
|
||||
|
||||
const { sorted: sortedForwards, sortKey: forwardSortKey, sortDir: forwardSortDir, requestSort: requestForwardSort } = useSortable(
|
||||
forwards,
|
||||
"externalPort",
|
||||
);
|
||||
|
||||
function exportAgentCsv() {
|
||||
downloadCsv(
|
||||
"agent-reported-ports.csv",
|
||||
["Server", "Hostname", "Protocol", "Port", "Addresses", "Process", "Local only"],
|
||||
(sortedAgentPorts ?? []).map((p) => [
|
||||
p.serverName,
|
||||
p.serverHostname ?? "",
|
||||
p.protocol,
|
||||
p.port,
|
||||
p.addresses.join(", "),
|
||||
p.process ?? "",
|
||||
p.localOnly ? "yes" : "no",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function exportForwardsCsv() {
|
||||
downloadCsv(
|
||||
"port-forwards.csv",
|
||||
["Label", "External port", "Protocol", "Server", "Destination", "Internal port", "Source", "Comment"],
|
||||
(sortedForwards ?? []).map((f) => [
|
||||
f.label,
|
||||
f.externalPort,
|
||||
f.protocol,
|
||||
f.serverName ?? "",
|
||||
f.destination ?? "",
|
||||
f.internalPort ?? "",
|
||||
f.source ?? "",
|
||||
f.comment ?? "",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function startAdd() {
|
||||
setAdding(true);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
function startEdit(fwd: PortForward) {
|
||||
setEditing(fwd);
|
||||
setAdding(false);
|
||||
setForm({
|
||||
label: fwd.label,
|
||||
externalPort: fwd.externalPort,
|
||||
protocol: fwd.protocol,
|
||||
serverId: fwd.serverId,
|
||||
destination: fwd.destination ?? "",
|
||||
internalPort: fwd.internalPort,
|
||||
source: fwd.source ?? "",
|
||||
comment: fwd.comment ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
setAdding(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setForwardError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api.ports.forwards.update(editing.id, form);
|
||||
} else {
|
||||
await api.ports.forwards.create(form);
|
||||
}
|
||||
cancelForm();
|
||||
loadForwards();
|
||||
} catch (err) {
|
||||
setForwardError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(fwd: PortForward) {
|
||||
if (!confirm(`Delete port opening "${fwd.label}"?`)) return;
|
||||
setForwardError(null);
|
||||
try {
|
||||
await api.ports.forwards.remove(fwd.id);
|
||||
loadForwards();
|
||||
} catch (err) {
|
||||
setForwardError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const showForm = adding || editing;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">Ports</h2>
|
||||
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Ports reported by agents</h3>
|
||||
<div className="card-actions">
|
||||
<span className="text-secondary me-3">
|
||||
{reportingServers} of {totalServers} server{totalServers !== 1 ? "s" : ""} reporting
|
||||
</span>
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={exportAgentCsv} disabled={!sortedAgentPorts?.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={loadAgentPorts}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{agentError && <div className="alert alert-danger m-3">{agentError}</div>}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableTh<AgentPortRow> label="Server" sortKeyName="serverName" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
|
||||
<SortableTh<AgentPortRow> label="Protocol" sortKeyName="protocol" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
|
||||
<SortableTh<AgentPortRow> label="Port" sortKeyName="port" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
|
||||
<th>Address</th>
|
||||
<th>Process</th>
|
||||
<SortableTh<AgentPortRow> label="Last report" sortKeyName="lastSeenAt" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(agentPageItems ?? []).map((p) => (
|
||||
<tr key={`${p.serverId}-${p.protocol}-${p.port}`}>
|
||||
<td>
|
||||
<Link to={`/servers/${p.serverId}`}>{p.serverName}</Link>
|
||||
{p.serverHostname && <div className="text-secondary small">{p.serverHostname}</div>}
|
||||
</td>
|
||||
<td className="text-uppercase text-secondary">{p.protocol}</td>
|
||||
<td>{p.port}</td>
|
||||
<td className="text-secondary">
|
||||
{p.addresses.join(", ")}
|
||||
{p.localOnly && <span className="badge bg-secondary-lt text-secondary ms-2">local only</span>}
|
||||
</td>
|
||||
<td className="text-secondary">{p.process ?? "—"}</td>
|
||||
<td className="text-secondary">{p.lastSeenAt ? formatAgo(p.lastSeenAt) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{agentPorts !== null && agentPorts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-secondary text-center">
|
||||
No servers have reported listening ports yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={agentPage} pageCount={agentPageCount} totalCount={agentTotalCount} onPageChange={setAgentPage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && showForm && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{editing ? `Edit "${editing.label}"` : "Add a manual port opening"}</h3>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<div className="card-body row g-3">
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Label</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="Plex remote access"
|
||||
value={form.label}
|
||||
onChange={(e) => setForm({ ...form, label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">External port</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
required
|
||||
min={1}
|
||||
max={65535}
|
||||
value={form.externalPort || ""}
|
||||
onChange={(e) => setForm({ ...form, externalPort: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Protocol</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.protocol}
|
||||
onChange={(e) => setForm({ ...form, protocol: e.target.value as "tcp" | "udp" })}
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Internal port</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder="if different"
|
||||
value={form.internalPort ?? ""}
|
||||
onChange={(e) => setForm({ ...form, internalPort: e.target.value ? Number(e.target.value) : null })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Server (optional)</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.serverId ?? ""}
|
||||
onChange={(e) => setForm({ ...form, serverId: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
{(servers ?? []).map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Destination</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="IP/host, if not a tracked server"
|
||||
value={form.destination ?? ""}
|
||||
onChange={(e) => setForm({ ...form, destination: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Source</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Home router, OPNsense, Cloudflare Tunnel…"
|
||||
value={form.source ?? ""}
|
||||
onChange={(e) => setForm({ ...form, source: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Comment</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.comment ?? ""}
|
||||
onChange={(e) => setForm({ ...form, comment: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{editing ? "Save changes" : "Add port opening"}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={cancelForm}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Manual port openings</h3>
|
||||
<p className="text-secondary mb-0 me-auto">
|
||||
Router port forwards, edge firewall rules, cloud security groups — anything opened further out on the
|
||||
network that this app can't see on its own.
|
||||
</p>
|
||||
<div className="card-actions">
|
||||
{canEdit && !showForm && (
|
||||
<button className="btn btn-primary btn-sm" onClick={startAdd}>
|
||||
Add port opening
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={exportForwardsCsv} disabled={!sortedForwards?.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{forwardError && <div className="alert alert-danger m-3">{forwardError}</div>}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableTh<PortForward> label="Label" sortKeyName="label" activeKey={forwardSortKey} direction={forwardSortDir} onSort={requestForwardSort} />
|
||||
<SortableTh<PortForward>
|
||||
label="External port"
|
||||
sortKeyName="externalPort"
|
||||
activeKey={forwardSortKey}
|
||||
direction={forwardSortDir}
|
||||
onSort={requestForwardSort}
|
||||
/>
|
||||
<th>Destination</th>
|
||||
<SortableTh<PortForward> label="Source" sortKeyName="source" activeKey={forwardSortKey} direction={forwardSortDir} onSort={requestForwardSort} />
|
||||
<th>Comment</th>
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(sortedForwards ?? []).map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td>{f.label}</td>
|
||||
<td>
|
||||
<span className="text-uppercase text-secondary me-1">{f.protocol}</span>
|
||||
{f.externalPort}
|
||||
{f.internalPort && f.internalPort !== f.externalPort && (
|
||||
<span className="text-secondary"> → {f.internalPort}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-secondary">
|
||||
{f.serverId ? <Link to={`/servers/${f.serverId}`}>{f.serverName}</Link> : f.destination || "—"}
|
||||
{f.serverId && f.destination && <div className="small">{f.destination}</div>}
|
||||
</td>
|
||||
<td className="text-secondary">{f.source ?? "—"}</td>
|
||||
<td className="text-secondary">{f.comment ?? "—"}</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
<button className="btn btn-sm" onClick={() => startEdit(f)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(f)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{forwards !== null && forwards.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
|
||||
No manual port openings recorded yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -633,7 +633,10 @@ export default function NotificationSettings() {
|
||||
disabled={!dailyChecksEnabled}
|
||||
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
|
||||
/>
|
||||
<div className="form-hint">Shared by the secret, Tailscale key, Docker update, and Proxmox backup reminders above.</div>
|
||||
<div className="form-hint">
|
||||
Shared by the secret, Tailscale key, Docker update, Proxmox backup, Proxmox Backup Server, and
|
||||
domain expiry reminders above.
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<label className="form-label">Timezone</label>
|
||||
|
||||
Reference in New Issue
Block a user