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 parent
1ff1c6550c
commit
21054aaa8c
3 files changed
+135
-1
No files matched your search
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
import { Link } from "react-router-dom";
|
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 { useSortable } from "../hooks/useSortable";
|
||||||
import SortableTh from "../components/SortableTh";
|
import SortableTh from "../components/SortableTh";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
|
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
|
||||||
|
|
||||||
interface LinkForm {
|
interface LinkForm {
|
||||||
serverId: number | "";
|
serverId: number | "";
|
||||||
@@ -26,6 +27,11 @@ export default function AdminLinks({ user }: { user: CurrentUser }) {
|
|||||||
const [form, setForm] = useState<LinkForm>(emptyForm);
|
const [form, setForm] = useState<LinkForm>(emptyForm);
|
||||||
const [saving, setSaving] = useState(false);
|
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() {
|
function load() {
|
||||||
api.servers.allLinks()
|
api.servers.allLinks()
|
||||||
.then((res) => setLinks(res.links))
|
.then((res) => setLinks(res.links))
|
||||||
@@ -40,6 +46,37 @@ export default function AdminLinks({ user }: { user: CurrentUser }) {
|
|||||||
.catch(() => {});
|
.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(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!links) return [];
|
if (!links) return [];
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
@@ -166,6 +203,37 @@ export default function AdminLinks({ user }: { user: CurrentUser }) {
|
|||||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
<div className="card-footer d-flex gap-2">
|
<div className="card-footer d-flex gap-2">
|
||||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type CurrentUser,
|
type CurrentUser,
|
||||||
type IntegrationSummary,
|
type IntegrationSummary,
|
||||||
type ManualTaskInput,
|
type ManualTaskInput,
|
||||||
|
type PortEntry,
|
||||||
type ProxmoxGuest,
|
type ProxmoxGuest,
|
||||||
type ScheduleType,
|
type ScheduleType,
|
||||||
type ServerDetail as ServerDetailResponse,
|
type ServerDetail as ServerDetailResponse,
|
||||||
@@ -15,6 +16,7 @@ import ServerPorts from "../components/ServerPorts";
|
|||||||
import ServerTags from "../components/ServerTags";
|
import ServerTags from "../components/ServerTags";
|
||||||
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
|
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
|
||||||
import { formatDateTime } from "../utils/date";
|
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(
|
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = Object.entries(SCHEDULE_TYPE_LABELS).map(
|
||||||
([value, label]) => ({ value: value as ScheduleType, label }),
|
([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 [editingAdminLinkId, setEditingAdminLinkId] = useState<number | null>(null);
|
||||||
const [savingAdminLink, setSavingAdminLink] = useState(false);
|
const [savingAdminLink, setSavingAdminLink] = useState(false);
|
||||||
const [adminLinkError, setAdminLinkError] = useState<string | null>(null);
|
const [adminLinkError, setAdminLinkError] = useState<string | null>(null);
|
||||||
|
const [adminLinkPorts, setAdminLinkPorts] = useState<PortEntry[] | null>(null);
|
||||||
|
const [adminLinkPortPick, setAdminLinkPortPick] = useState("");
|
||||||
|
|
||||||
const loadDetail = useCallback(() => {
|
const loadDetail = useCallback(() => {
|
||||||
setRefreshing(true);
|
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() {
|
function openAddAdminLink() {
|
||||||
setEditingAdminLinkId(null);
|
setEditingAdminLinkId(null);
|
||||||
setAdminLinkError(null);
|
setAdminLinkError(null);
|
||||||
setAdminLinkForm({ label: "", url: "" });
|
setAdminLinkForm({ label: "", url: "" });
|
||||||
|
loadAdminLinkPorts();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditAdminLink(link: ServerLink) {
|
function startEditAdminLink(link: ServerLink) {
|
||||||
setEditingAdminLinkId(link.id);
|
setEditingAdminLinkId(link.id);
|
||||||
setAdminLinkError(null);
|
setAdminLinkError(null);
|
||||||
setAdminLinkForm({ label: link.label, url: link.url });
|
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() {
|
function cancelAdminLinkForm() {
|
||||||
setAdminLinkForm(null);
|
setAdminLinkForm(null);
|
||||||
setEditingAdminLinkId(null);
|
setEditingAdminLinkId(null);
|
||||||
setAdminLinkError(null);
|
setAdminLinkError(null);
|
||||||
|
setAdminLinkPorts(null);
|
||||||
|
setAdminLinkPortPick("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAdminLink(e: React.FormEvent) {
|
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 })}
|
onChange={(e) => setAdminLinkForm({ ...adminLinkForm, url: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="col-md-3 d-flex gap-2">
|
||||||
<button type="submit" className="btn btn-primary" disabled={savingAdminLink}>
|
<button type="submit" className="btn btn-primary" disabled={savingAdminLink}>
|
||||||
{editingAdminLinkId ? "Save" : "Add"}
|
{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