Move Tailscale, Semaphore, and Gitea to their own top-level pages
Same move as Docker: each was buried inside the generic Integrations
browsing view, sharing one "pick any integration" dropdown across six
unrelated types. Give them dedicated pages instead.
New web/src/pages/{Tailscale,Semaphore,Gitea}.tsx each reuse the
existing, unchanged API routes and adapters (no server changes) with
their own integration picker scoped to just that type. Added matching
nav items (Tailscale, Semaphore, Gitea, right after Docker) and routes.
Removed the corresponding state/handlers/tables from Integrations.tsx
entirely (374 lines). Adding, editing, enabling/disabling, and
deleting each credential itself still happens under Integrations ->
Manage integrations, same as Proxmox and Synology, which stay on the
Integrations page. Selecting one of these three types in Integrations'
generic browsing dropdown now points to its new page instead of
rendering a table there too.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
35afcc4a37
commit
8fef68c27a
7 files changed
+646
-382
No files matched your search
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
api,
|
||||
type CurrentUser,
|
||||
type IntegrationSummary,
|
||||
type TailscaleDevice,
|
||||
type TailscaleDevicesResponse,
|
||||
} from "../api/client";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
|
||||
export default function Tailscale({ user }: { user: CurrentUser }) {
|
||||
const canEdit = user.role === "admin" || user.role === "operator";
|
||||
|
||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [data, setData] = useState<TailscaleDevicesResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.integrations
|
||||
.list()
|
||||
.then((res) => {
|
||||
const tailscaleIntegrations = res.integrations.filter((i) => i.type === "tailscale");
|
||||
setIntegrations(tailscaleIntegrations);
|
||||
if (!selectedId && tailscaleIntegrations.length > 0) setSelectedId(tailscaleIntegrations[0].id);
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
function loadDevices(id: number) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.integrations.tailscale
|
||||
.devices(id)
|
||||
.then((res) => setData(res))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selected?.enabled) {
|
||||
loadDevices(selected.id);
|
||||
} else {
|
||||
setData(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId]);
|
||||
|
||||
async function setDeviceAuthorized(device: TailscaleDevice, authorized: boolean) {
|
||||
if (!selectedId) return;
|
||||
try {
|
||||
await api.integrations.tailscale.setAuthorized(selectedId, device.id, authorized);
|
||||
loadDevices(selectedId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDevice(device: TailscaleDevice) {
|
||||
if (!selectedId) return;
|
||||
if (!confirm(`Remove device "${device.label || device.hostname}" from the tailnet?`)) return;
|
||||
try {
|
||||
await api.integrations.tailscale.remove(selectedId, device.id);
|
||||
loadDevices(selectedId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">Tailscale</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{integrations?.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
No Tailscale integration configured yet.{" "}
|
||||
<Link to="/integrations">Add one under Integrations</Link>.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{integrations && integrations.length > 1 && (
|
||||
<div className="mb-3" style={{ maxWidth: 320 }}>
|
||||
<select
|
||||
className="form-select"
|
||||
value={selectedId ?? ""}
|
||||
onChange={(e) => setSelectedId(Number(e.target.value))}
|
||||
>
|
||||
{integrations.map((i) => (
|
||||
<option key={i.id} value={i.id} disabled={!i.enabled}>
|
||||
{i.name}
|
||||
{!i.enabled ? " — disabled" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && !selected.enabled ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
"{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its
|
||||
devices.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
Devices
|
||||
{data && (
|
||||
<span className="text-secondary fw-normal ms-2">
|
||||
{data.summary.online}/{data.summary.total} online
|
||||
{data.summary.unauthorized > 0 && `, ${data.summary.unauthorized} awaiting authorization`}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => selected && loadDevices(selected.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Address</th>
|
||||
<th>OS</th>
|
||||
<th>Status</th>
|
||||
<th>Last seen</th>
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.devices.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td>
|
||||
{d.label || d.hostname}
|
||||
{d.isExitNode && <span className="badge bg-purple-lt ms-2">Exit node</span>}
|
||||
</td>
|
||||
<td className="text-secondary">{d.primaryAddress}</td>
|
||||
<td>{d.os || "—"}</td>
|
||||
<td>
|
||||
{d.online ? (
|
||||
<span className="badge bg-green-lt text-green me-1">Online</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary-lt text-secondary me-1">Offline</span>
|
||||
)}
|
||||
{!d.authorized && <span className="badge bg-yellow-lt text-yellow">Unauthorized</span>}
|
||||
</td>
|
||||
<td className="text-secondary">{d.lastSeen ? formatDateTime(new Date(d.lastSeen)) : "—"}</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
{d.authorized ? (
|
||||
<button className="btn btn-sm" onClick={() => setDeviceAuthorized(d, false)}>
|
||||
Deauthorize
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-sm btn-primary" onClick={() => setDeviceAuthorized(d, true)}>
|
||||
Authorize
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => removeDevice(d)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{data?.devices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
|
||||
No devices in this tailnet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user