Fill an admin link's URL from a server's own known ports

Both places you add an admin link - a server's own Admin Links section
and the Operations > Admin Links summary page - now offer a "fill from
a known port" dropdown once a server is picked, listing its
agent-reported and manually-noted ports (the same data the Ports
page/card shows). Picking one fills in the URL as
http(s)://<hostname-or-IP>:<port>, guessing https for a handful of
common admin-panel ports, and fills the label too if it's still empty.
The URL field stays fully editable either way.

Shared the address/label logic in a small adminLinkUrl.ts helper used
by both pages. No server-side changes needed - built entirely on the
existing per-server ports and detail endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-30 00:23:52 +02:00
1 parent 1ff1c6550c
commit 21054aaa8c
3 files changed
+135 -1

No files matched your search

+69 -1
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
import { api, type AdminLink, type CurrentUser, type ServerRecord } from "../api/client";
import { api, type AdminLink, type CurrentUser, type PortEntry, type ServerRecord } from "../api/client";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv";
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
interface LinkForm {
serverId: number | "";
@@ -26,6 +27,11 @@ export default function AdminLinks({ user }: { user: CurrentUser }) {
const [form, setForm] = useState<LinkForm>(emptyForm);
const [saving, setSaving] = useState(false);
const [portOptions, setPortOptions] = useState<PortEntry[] | null>(null);
const [portAddress, setPortAddress] = useState<string | null>(null);
const [loadingPorts, setLoadingPorts] = useState(false);
const [portPick, setPortPick] = useState("");
function load() {
api.servers.allLinks()
.then((res) => setLinks(res.links))
@@ -40,6 +46,37 @@ export default function AdminLinks({ user }: { user: CurrentUser }) {
.catch(() => {});
}, []);
// Once a server is picked in the add/edit form, offer to fill the URL from one of its own known
// ports (agent-reported or already noted on its Ports card) instead of typing it out by hand.
useEffect(() => {
setPortPick("");
if (form.serverId === "") {
setPortOptions(null);
setPortAddress(null);
return;
}
const serverId = form.serverId;
setLoadingPorts(true);
Promise.all([api.servers.detail(serverId), api.servers.ports.list(serverId)])
.then(([detail, portList]) => {
setPortAddress(detail.server.hostname || detail.ipAddresses[0] || null);
setPortOptions(portList.ports);
})
.catch(() => {
setPortOptions(null);
setPortAddress(null);
})
.finally(() => setLoadingPorts(false));
}, [form.serverId]);
function pickPort(key: string) {
setPortPick(key);
const entry = portOptions?.find((p) => `${p.protocol}:${p.port}` === key);
if (!entry) return;
const url = guessAdminUrl(portAddress, entry);
setForm((f) => ({ ...f, url: url ?? f.url, label: f.label || entry.label || entry.agent?.process || "" }));
}
const filtered = useMemo(() => {
if (!links) return [];
const q = search.trim().toLowerCase();
@@ -166,6 +203,37 @@ export default function AdminLinks({ user }: { user: CurrentUser }) {
onChange={(e) => setForm({ ...form, url: e.target.value })}
/>
</div>
{form.serverId !== "" && (
<div className="col-12">
<label className="form-label">Fill from a known port (optional)</label>
<select
className="form-select"
style={{ maxWidth: 460 }}
value={portPick}
disabled={loadingPorts || !portOptions?.length}
onChange={(e) => pickPort(e.target.value)}
>
<option value="">
{loadingPorts
? "Loading this server's ports…"
: portOptions?.length
? "— pick a port to fill in the URL above —"
: "No known ports for this server yet"}
</option>
{portOptions?.map((p) => (
<option key={`${p.protocol}:${p.port}`} value={`${p.protocol}:${p.port}`}>
{portOptionLabel(p)}
</option>
))}
</select>
{portOptions && portOptions.length > 0 && !portAddress && (
<div className="form-hint">
This server has no known hostname or IP yet, so picking a port won't fill in an address —
see its Ports card under Infrastructure → Servers.
</div>
)}
</div>
)}
</div>
<div className="card-footer d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
+46
View File
@@ -5,6 +5,7 @@ import {
type CurrentUser,
type IntegrationSummary,
type ManualTaskInput,
type PortEntry,
type ProxmoxGuest,
type ScheduleType,
type ServerDetail as ServerDetailResponse,
@@ -15,6 +16,7 @@ import ServerPorts from "../components/ServerPorts";
import ServerTags from "../components/ServerTags";
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
import { formatDateTime } from "../utils/date";
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = Object.entries(SCHEDULE_TYPE_LABELS).map(
([value, label]) => ({ value: value as ScheduleType, label }),
@@ -111,6 +113,8 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
const [editingAdminLinkId, setEditingAdminLinkId] = useState<number | null>(null);
const [savingAdminLink, setSavingAdminLink] = useState(false);
const [adminLinkError, setAdminLinkError] = useState<string | null>(null);
const [adminLinkPorts, setAdminLinkPorts] = useState<PortEntry[] | null>(null);
const [adminLinkPortPick, setAdminLinkPortPick] = useState("");
const loadDetail = useCallback(() => {
setRefreshing(true);
@@ -263,22 +267,46 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
}
}
function loadAdminLinkPorts() {
setAdminLinkPortPick("");
api.servers.ports
.list(serverId)
.then((res) => setAdminLinkPorts(res.ports))
.catch(() => setAdminLinkPorts(null));
}
function openAddAdminLink() {
setEditingAdminLinkId(null);
setAdminLinkError(null);
setAdminLinkForm({ label: "", url: "" });
loadAdminLinkPorts();
}
function startEditAdminLink(link: ServerLink) {
setEditingAdminLinkId(link.id);
setAdminLinkError(null);
setAdminLinkForm({ label: link.label, url: link.url });
loadAdminLinkPorts();
}
function pickAdminLinkPort(key: string) {
setAdminLinkPortPick(key);
const entry = adminLinkPorts?.find((p) => `${p.protocol}:${p.port}` === key);
if (!entry || !adminLinkForm) return;
const address = detail?.server.hostname || detail?.ipAddresses[0] || null;
const url = guessAdminUrl(address, entry);
setAdminLinkForm({
label: adminLinkForm.label || entry.label || entry.agent?.process || "",
url: url ?? adminLinkForm.url,
});
}
function cancelAdminLinkForm() {
setAdminLinkForm(null);
setEditingAdminLinkId(null);
setAdminLinkError(null);
setAdminLinkPorts(null);
setAdminLinkPortPick("");
}
async function submitAdminLink(e: React.FormEvent) {
@@ -400,6 +428,24 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
onChange={(e) => setAdminLinkForm({ ...adminLinkForm, url: e.target.value })}
/>
</div>
{!!adminLinkPorts?.length && (
<div className="col-12">
<label className="form-label">Fill from a known port (optional)</label>
<select
className="form-select"
style={{ maxWidth: 460 }}
value={adminLinkPortPick}
onChange={(e) => pickAdminLinkPort(e.target.value)}
>
<option value="">— pick a port to fill in the URL above —</option>
{adminLinkPorts.map((p) => (
<option key={`${p.protocol}:${p.port}`} value={`${p.protocol}:${p.port}`}>
{portOptionLabel(p)}
</option>
))}
</select>
</div>
)}
<div className="col-md-3 d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={savingAdminLink}>
{editingAdminLinkId ? "Save" : "Add"}
+20
View File
@@ -0,0 +1,20 @@
import type { PortEntry } from "../api/client";
// Common admin-panel ports that are HTTPS-only (often with a self-signed cert) even though nothing
// on the wire says so — a small, honest guess, not a detection. Anything else defaults to http://,
// and the URL field stays a normal editable text input either way.
const HTTPS_PORTS = new Set([443, 5001, 8006, 8007, 8443, 9090, 9443, 10000]);
/** What to show for one port in a "fill from a known port" picker. */
export function portOptionLabel(entry: PortEntry): string {
const bits = [`${entry.port}/${entry.protocol}`];
if (entry.agent?.process) bits.push(entry.agent.process);
if (entry.label) bits.push(entry.label);
return bits.join(" — ");
}
/** A best-effort URL for a server's port, or null if no address is known for it yet. */
export function guessAdminUrl(address: string | null, entry: PortEntry): string | null {
if (!address) return null;
return `${HTTPS_PORTS.has(entry.port) ? "https" : "http"}://${address}:${entry.port}`;
}