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:
@@ -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}>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user