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
@@ -34,20 +34,25 @@ All modules from the original plan are built:
|
|||||||
Proxmox for VM/LXC-backed servers, or from the agent's own hardware
|
Proxmox for VM/LXC-backed servers, or from the agent's own hardware
|
||||||
report for everything else. Proxmox-linked servers also get
|
report for everything else. Proxmox-linked servers also get
|
||||||
start/stop/restart buttons right on the detail page.
|
start/stop/restart buttons right on the detail page.
|
||||||
- **Integrations → Tailscale** — device list with online/authorized status,
|
- **Tailscale**, **Semaphore**, **Gitea**, and **Docker** each get their
|
||||||
and authorize/deauthorize/remove actions; a live device-count widget.
|
own top-level page (backed by the matching integration) instead of
|
||||||
- **Integrations → Gitea** — repo list with each repo's last CI run status,
|
living inside a shared Integrations browsing view:
|
||||||
and re-running just the failed jobs in a run; a live repo-count widget
|
- **Tailscale** — device list with online/authorized status, and
|
||||||
(with a failing-build warning).
|
authorize/deauthorize/remove actions; a live device-count widget.
|
||||||
- **Docker** (its own top-level page, backed by the Dockhand
|
- **Semaphore** — Ansible run status per template across every
|
||||||
integration) — container status across every Docker host Dockhand
|
project, with a "Run" action to trigger a template; a live
|
||||||
manages (one credential covers all of them), with start/stop/restart
|
template-count widget (with a last-failed warning).
|
||||||
actions and a live running/total widget. Add/edit/remove the
|
- **Gitea** — repo list with each repo's last CI run status, and
|
||||||
Dockhand credential itself under Integrations → Manage integrations
|
re-running just the failed jobs in a run; a live repo-count widget
|
||||||
as with every other integration.
|
(with a failing-build warning).
|
||||||
- **Integrations → Semaphore** — Ansible run status per template across
|
- **Docker** — container status across every Docker host Dockhand
|
||||||
every project, with a "Run" action to trigger a template; a live
|
manages (one credential covers all of them), with
|
||||||
template-count widget (with a last-failed warning).
|
start/stop/restart actions, a host filter, and a live
|
||||||
|
running/total widget.
|
||||||
|
|
||||||
|
Add/edit/enable/disable/remove each integration's credential itself
|
||||||
|
under Integrations → Manage integrations, same as Proxmox and
|
||||||
|
Synology (which stay on the Integrations page).
|
||||||
- **Integrations → Proxmox** — VM/LXC status across every node in the
|
- **Integrations → Proxmox** — VM/LXC status across every node in the
|
||||||
cluster, with start/stop/restart actions; a live running/total widget.
|
cluster, with start/stop/restart actions; a live running/total widget.
|
||||||
Supports self-signed certificates (common in homelab Proxmox setups).
|
Supports self-signed certificates (common in homelab Proxmox setups).
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import ServersTasks from "./pages/ServersTasks";
|
|||||||
import ServerDetail from "./pages/ServerDetail";
|
import ServerDetail from "./pages/ServerDetail";
|
||||||
import Integrations from "./pages/Integrations";
|
import Integrations from "./pages/Integrations";
|
||||||
import Docker from "./pages/Docker";
|
import Docker from "./pages/Docker";
|
||||||
|
import Tailscale from "./pages/Tailscale";
|
||||||
|
import Semaphore from "./pages/Semaphore";
|
||||||
|
import Gitea from "./pages/Gitea";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import NotificationSettings from "./pages/settings/NotificationSettings";
|
import NotificationSettings from "./pages/settings/NotificationSettings";
|
||||||
import BadgeSettings from "./pages/settings/BadgeSettings";
|
import BadgeSettings from "./pages/settings/BadgeSettings";
|
||||||
@@ -74,6 +77,9 @@ export default function App() {
|
|||||||
<Route path="/secrets" element={<Secrets user={user} />} />
|
<Route path="/secrets" element={<Secrets user={user} />} />
|
||||||
<Route path="/integrations" element={<Integrations user={user} />} />
|
<Route path="/integrations" element={<Integrations user={user} />} />
|
||||||
<Route path="/docker" element={<Docker user={user} />} />
|
<Route path="/docker" element={<Docker user={user} />} />
|
||||||
|
<Route path="/tailscale" element={<Tailscale user={user} />} />
|
||||||
|
<Route path="/semaphore" element={<Semaphore user={user} />} />
|
||||||
|
<Route path="/gitea" element={<Gitea user={user} />} />
|
||||||
<Route
|
<Route
|
||||||
path="/users"
|
path="/users"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
IconKey,
|
IconKey,
|
||||||
IconPlugConnected,
|
IconPlugConnected,
|
||||||
IconBrandDocker,
|
IconBrandDocker,
|
||||||
|
IconAffiliate,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconBrandGit,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@@ -30,6 +33,9 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ to: "/secrets", label: "Secrets", icon: <IconKey size={20} /> },
|
{ to: "/secrets", label: "Secrets", icon: <IconKey size={20} /> },
|
||||||
{ to: "/integrations", label: "Integrations", icon: <IconPlugConnected size={20} /> },
|
{ to: "/integrations", label: "Integrations", icon: <IconPlugConnected size={20} /> },
|
||||||
{ to: "/docker", label: "Docker", icon: <IconBrandDocker size={20} /> },
|
{ to: "/docker", label: "Docker", icon: <IconBrandDocker size={20} /> },
|
||||||
|
{ to: "/tailscale", label: "Tailscale", icon: <IconAffiliate size={20} /> },
|
||||||
|
{ to: "/semaphore", label: "Semaphore", icon: <IconPlayerPlay size={20} /> },
|
||||||
|
{ to: "/gitea", label: "Gitea", icon: <IconBrandGit size={20} /> },
|
||||||
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
|
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
|
||||||
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
|
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
|
||||||
{ to: "/settings", label: "Settings", icon: <IconSettings size={20} />, minRole: "admin" },
|
{ to: "/settings", label: "Settings", icon: <IconSettings size={20} />, minRole: "admin" },
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api, type CurrentUser, type GiteaRepo, type IntegrationSummary } from "../api/client";
|
||||||
|
|
||||||
|
function runStatusBadge(repo: GiteaRepo) {
|
||||||
|
const run = repo.latestRun;
|
||||||
|
if (!run) return <span className="badge bg-secondary-lt text-secondary">No runs</span>;
|
||||||
|
const state = run.conclusion || run.status;
|
||||||
|
switch (state) {
|
||||||
|
case "success":
|
||||||
|
return <span className="badge bg-green-lt text-green">Success</span>;
|
||||||
|
case "failure":
|
||||||
|
return <span className="badge bg-red-lt text-red">Failed</span>;
|
||||||
|
case "cancelled":
|
||||||
|
return <span className="badge bg-secondary-lt text-secondary">Cancelled</span>;
|
||||||
|
case "skipped":
|
||||||
|
return <span className="badge bg-secondary-lt text-secondary">Skipped</span>;
|
||||||
|
case "waiting":
|
||||||
|
case "queued":
|
||||||
|
case "pending":
|
||||||
|
return <span className="badge bg-yellow-lt text-yellow">Queued</span>;
|
||||||
|
case "running":
|
||||||
|
case "in_progress":
|
||||||
|
return <span className="badge bg-blue-lt text-blue">Running</span>;
|
||||||
|
default:
|
||||||
|
return <span className="badge bg-secondary-lt text-secondary">{state}</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Gitea({ 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 [repos, setRepos] = useState<GiteaRepo[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [rerunningRun, setRerunningRun] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.integrations
|
||||||
|
.list()
|
||||||
|
.then((res) => {
|
||||||
|
const giteaIntegrations = res.integrations.filter((i) => i.type === "gitea");
|
||||||
|
setIntegrations(giteaIntegrations);
|
||||||
|
if (!selectedId && giteaIntegrations.length > 0) setSelectedId(giteaIntegrations[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 loadRepos(id: number) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api.integrations.gitea
|
||||||
|
.repos(id)
|
||||||
|
.then((res) => setRepos(res.repos))
|
||||||
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected?.enabled) {
|
||||||
|
loadRepos(selected.id);
|
||||||
|
} else {
|
||||||
|
setRepos(null);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
async function rerunFailed(repo: GiteaRepo) {
|
||||||
|
if (!selectedId || !repo.latestRun) return;
|
||||||
|
setRerunningRun(repo.latestRun.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.integrations.gitea.rerunFailed(selectedId, repo.owner, repo.name, repo.latestRun.id);
|
||||||
|
loadRepos(selectedId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setRerunningRun(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="page-title mb-3">Gitea</h2>
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
|
||||||
|
{integrations?.length === 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-secondary">
|
||||||
|
No Gitea 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
|
||||||
|
repositories.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Repositories</h3>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => selected && loadRepos(selected.id)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Refreshing…" : "Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Repository</th>
|
||||||
|
<th>Last run</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Status</th>
|
||||||
|
{canEdit && <th className="w-1">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{repos?.map((r) => (
|
||||||
|
<tr key={r.fullName}>
|
||||||
|
<td>
|
||||||
|
<a href={r.htmlUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
{r.fullName}
|
||||||
|
</a>
|
||||||
|
{r.private && <span className="badge bg-secondary-lt ms-2">Private</span>}
|
||||||
|
</td>
|
||||||
|
<td className="text-secondary">
|
||||||
|
{r.latestRun ? (
|
||||||
|
<a href={r.latestRun.htmlUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
#{r.latestRun.runNumber} {r.latestRun.displayTitle}
|
||||||
|
</a>
|
||||||
|
) : r.hasActions ? (
|
||||||
|
"—"
|
||||||
|
) : (
|
||||||
|
"Actions disabled"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-secondary">{r.latestRun?.headBranch ?? "—"}</td>
|
||||||
|
<td>{runStatusBadge(r)}</td>
|
||||||
|
{canEdit && (
|
||||||
|
<td>
|
||||||
|
{r.latestRun && (r.latestRun.conclusion === "failure" || r.latestRun.status === "failure") && (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => rerunFailed(r)}
|
||||||
|
disabled={rerunningRun === r.latestRun.id}
|
||||||
|
>
|
||||||
|
{rerunningRun === r.latestRun.id ? "Re-running…" : "Re-run failed"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{repos?.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
|
||||||
|
No repositories visible to this token.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,16 +3,11 @@ import { Link } from "react-router-dom";
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type CurrentUser,
|
type CurrentUser,
|
||||||
type GiteaRepo,
|
|
||||||
type IntegrationSummary,
|
type IntegrationSummary,
|
||||||
type IntegrationType,
|
type IntegrationType,
|
||||||
type ProxmoxGuest,
|
type ProxmoxGuest,
|
||||||
type ProxmoxGuestsResponse,
|
type ProxmoxGuestsResponse,
|
||||||
type SemaphoreTemplate,
|
|
||||||
type SemaphoreTemplatesResponse,
|
|
||||||
type SynologyStorageResponse,
|
type SynologyStorageResponse,
|
||||||
type TailscaleDevice,
|
|
||||||
type TailscaleDevicesResponse,
|
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import IntegrationForm from "../components/IntegrationForm";
|
import IntegrationForm from "../components/IntegrationForm";
|
||||||
import IntegrationEditForm from "../components/IntegrationEditForm";
|
import IntegrationEditForm from "../components/IntegrationEditForm";
|
||||||
@@ -55,56 +50,6 @@ function formatUptime(seconds: number | null): string {
|
|||||||
return `${hours}h ${minutes}m`;
|
return `${hours}h ${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function semaphoreStatusBadge(template: SemaphoreTemplate) {
|
|
||||||
const status = template.lastTask?.status;
|
|
||||||
if (!status) return <span className="badge bg-secondary-lt text-secondary">Never run</span>;
|
|
||||||
switch (status) {
|
|
||||||
case "success":
|
|
||||||
return <span className="badge bg-green-lt text-green">Success</span>;
|
|
||||||
case "error":
|
|
||||||
return <span className="badge bg-red-lt text-red">Failed</span>;
|
|
||||||
case "stopped":
|
|
||||||
return <span className="badge bg-secondary-lt text-secondary">Stopped</span>;
|
|
||||||
case "rejected":
|
|
||||||
return <span className="badge bg-secondary-lt text-secondary">Rejected</span>;
|
|
||||||
case "waiting":
|
|
||||||
case "starting":
|
|
||||||
case "waiting_confirmation":
|
|
||||||
case "confirmed":
|
|
||||||
return <span className="badge bg-yellow-lt text-yellow">Queued</span>;
|
|
||||||
case "running":
|
|
||||||
case "stopping":
|
|
||||||
return <span className="badge bg-blue-lt text-blue">Running</span>;
|
|
||||||
default:
|
|
||||||
return <span className="badge bg-secondary-lt text-secondary">{status}</span>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runStatusBadge(repo: GiteaRepo) {
|
|
||||||
const run = repo.latestRun;
|
|
||||||
if (!run) return <span className="badge bg-secondary-lt text-secondary">No runs</span>;
|
|
||||||
const state = run.conclusion || run.status;
|
|
||||||
switch (state) {
|
|
||||||
case "success":
|
|
||||||
return <span className="badge bg-green-lt text-green">Success</span>;
|
|
||||||
case "failure":
|
|
||||||
return <span className="badge bg-red-lt text-red">Failed</span>;
|
|
||||||
case "cancelled":
|
|
||||||
return <span className="badge bg-secondary-lt text-secondary">Cancelled</span>;
|
|
||||||
case "skipped":
|
|
||||||
return <span className="badge bg-secondary-lt text-secondary">Skipped</span>;
|
|
||||||
case "waiting":
|
|
||||||
case "queued":
|
|
||||||
case "pending":
|
|
||||||
return <span className="badge bg-yellow-lt text-yellow">Queued</span>;
|
|
||||||
case "running":
|
|
||||||
case "in_progress":
|
|
||||||
return <span className="badge bg-blue-lt text-blue">Running</span>;
|
|
||||||
default:
|
|
||||||
return <span className="badge bg-secondary-lt text-secondary">{state}</span>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_LABELS: Record<IntegrationType, string> = {
|
const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||||
tailscale: "Tailscale",
|
tailscale: "Tailscale",
|
||||||
proxmox: "Proxmox",
|
proxmox: "Proxmox",
|
||||||
@@ -135,20 +80,6 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {});
|
api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [tailscaleData, setTailscaleData] = useState<TailscaleDevicesResponse | null>(null);
|
|
||||||
const [tailscaleError, setTailscaleError] = useState<string | null>(null);
|
|
||||||
const [loadingDevices, setLoadingDevices] = useState(false);
|
|
||||||
|
|
||||||
const [giteaRepos, setGiteaRepos] = useState<GiteaRepo[] | null>(null);
|
|
||||||
const [giteaError, setGiteaError] = useState<string | null>(null);
|
|
||||||
const [loadingRepos, setLoadingRepos] = useState(false);
|
|
||||||
const [rerunningRun, setRerunningRun] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const [semaphoreData, setSemaphoreData] = useState<SemaphoreTemplatesResponse | null>(null);
|
|
||||||
const [semaphoreError, setSemaphoreError] = useState<string | null>(null);
|
|
||||||
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
|
||||||
const [runningTemplate, setRunningTemplate] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const [proxmoxData, setProxmoxData] = useState<ProxmoxGuestsResponse | null>(null);
|
const [proxmoxData, setProxmoxData] = useState<ProxmoxGuestsResponse | null>(null);
|
||||||
const [proxmoxError, setProxmoxError] = useState<string | null>(null);
|
const [proxmoxError, setProxmoxError] = useState<string | null>(null);
|
||||||
const [loadingGuests, setLoadingGuests] = useState(false);
|
const [loadingGuests, setLoadingGuests] = useState(false);
|
||||||
@@ -172,42 +103,7 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
|
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
|
||||||
|
|
||||||
function loadTailscaleDevices(id: number) {
|
|
||||||
setLoadingDevices(true);
|
|
||||||
setTailscaleError(null);
|
|
||||||
api.integrations.tailscale
|
|
||||||
.devices(id)
|
|
||||||
.then((res) => setTailscaleData(res))
|
|
||||||
.catch((err) => setTailscaleError(err instanceof Error ? err.message : String(err)))
|
|
||||||
.finally(() => setLoadingDevices(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadGiteaRepos(id: number) {
|
|
||||||
setLoadingRepos(true);
|
|
||||||
setGiteaError(null);
|
|
||||||
api.integrations.gitea
|
|
||||||
.repos(id)
|
|
||||||
.then((res) => setGiteaRepos(res.repos))
|
|
||||||
.catch((err) => setGiteaError(err instanceof Error ? err.message : String(err)))
|
|
||||||
.finally(() => setLoadingRepos(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selected?.type === "tailscale" && selected.enabled && !managing) {
|
|
||||||
loadTailscaleDevices(selected.id);
|
|
||||||
} else {
|
|
||||||
setTailscaleData(null);
|
|
||||||
}
|
|
||||||
if (selected?.type === "gitea" && selected.enabled && !managing) {
|
|
||||||
loadGiteaRepos(selected.id);
|
|
||||||
} else {
|
|
||||||
setGiteaRepos(null);
|
|
||||||
}
|
|
||||||
if (selected?.type === "semaphore" && selected.enabled && !managing) {
|
|
||||||
loadSemaphoreTemplates(selected.id);
|
|
||||||
} else {
|
|
||||||
setSemaphoreData(null);
|
|
||||||
}
|
|
||||||
if (selected?.type === "proxmox" && selected.enabled && !managing) {
|
if (selected?.type === "proxmox" && selected.enabled && !managing) {
|
||||||
loadProxmoxGuests(selected.id);
|
loadProxmoxGuests(selected.id);
|
||||||
} else {
|
} else {
|
||||||
@@ -231,16 +127,6 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
.finally(() => setLoadingStorage(false));
|
.finally(() => setLoadingStorage(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSemaphoreTemplates(id: number) {
|
|
||||||
setLoadingTemplates(true);
|
|
||||||
setSemaphoreError(null);
|
|
||||||
api.integrations.semaphore
|
|
||||||
.templates(id)
|
|
||||||
.then((res) => setSemaphoreData(res))
|
|
||||||
.catch((err) => setSemaphoreError(err instanceof Error ? err.message : String(err)))
|
|
||||||
.finally(() => setLoadingTemplates(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadProxmoxGuests(id: number) {
|
function loadProxmoxGuests(id: number) {
|
||||||
setLoadingGuests(true);
|
setLoadingGuests(true);
|
||||||
setProxmoxError(null);
|
setProxmoxError(null);
|
||||||
@@ -271,56 +157,6 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setDeviceAuthorized(device: TailscaleDevice, authorized: boolean) {
|
|
||||||
if (!selectedId) return;
|
|
||||||
try {
|
|
||||||
await api.integrations.tailscale.setAuthorized(selectedId, device.id, authorized);
|
|
||||||
loadTailscaleDevices(selectedId);
|
|
||||||
} catch (err) {
|
|
||||||
setTailscaleError(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);
|
|
||||||
loadTailscaleDevices(selectedId);
|
|
||||||
} catch (err) {
|
|
||||||
setTailscaleError(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rerunFailed(repo: GiteaRepo) {
|
|
||||||
if (!selectedId || !repo.latestRun) return;
|
|
||||||
setRerunningRun(repo.latestRun.id);
|
|
||||||
setGiteaError(null);
|
|
||||||
try {
|
|
||||||
await api.integrations.gitea.rerunFailed(selectedId, repo.owner, repo.name, repo.latestRun.id);
|
|
||||||
loadGiteaRepos(selectedId);
|
|
||||||
} catch (err) {
|
|
||||||
setGiteaError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
setRerunningRun(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTemplate(t: SemaphoreTemplate) {
|
|
||||||
if (!selectedId) return;
|
|
||||||
if (!confirm(`Run "${t.name}" now?`)) return;
|
|
||||||
setRunningTemplate(t.id);
|
|
||||||
setSemaphoreError(null);
|
|
||||||
try {
|
|
||||||
await api.integrations.semaphore.run(selectedId, t.projectId, t.id);
|
|
||||||
loadSemaphoreTemplates(selectedId);
|
|
||||||
} catch (err) {
|
|
||||||
setSemaphoreError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
setRunningTemplate(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") {
|
async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`))
|
if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`))
|
||||||
@@ -450,218 +286,20 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
{selected?.type === "tailscale" ? (
|
{selected?.type === "tailscale" ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-body text-secondary">
|
||||||
<h3 className="card-title">
|
Manage this integration's devices on the <Link to="/tailscale">Tailscale</Link> page.
|
||||||
Devices
|
|
||||||
{tailscaleData && (
|
|
||||||
<span className="text-secondary fw-normal ms-2">
|
|
||||||
{tailscaleData.summary.online}/{tailscaleData.summary.total} online
|
|
||||||
{tailscaleData.summary.unauthorized > 0 && `, ${tailscaleData.summary.unauthorized} awaiting authorization`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<div className="card-actions">
|
|
||||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => loadTailscaleDevices(selected.id)} disabled={loadingDevices}>
|
|
||||||
{loadingDevices ? "Refreshing…" : "Refresh"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{tailscaleError && <div className="alert alert-danger m-3 mb-0">{tailscaleError}</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>
|
|
||||||
{tailscaleData?.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>
|
|
||||||
))}
|
|
||||||
{tailscaleData?.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>
|
||||||
</div>
|
</div>
|
||||||
) : selected?.type === "gitea" ? (
|
) : selected?.type === "gitea" ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-body text-secondary">
|
||||||
<h3 className="card-title">Repositories</h3>
|
Manage this integration's repositories on the <Link to="/gitea">Gitea</Link> page.
|
||||||
<div className="card-actions">
|
|
||||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => loadGiteaRepos(selected.id)} disabled={loadingRepos}>
|
|
||||||
{loadingRepos ? "Refreshing…" : "Refresh"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{giteaError && <div className="alert alert-danger m-3 mb-0">{giteaError}</div>}
|
|
||||||
<div className="table-responsive">
|
|
||||||
<table className="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Repository</th>
|
|
||||||
<th>Last run</th>
|
|
||||||
<th>Branch</th>
|
|
||||||
<th>Status</th>
|
|
||||||
{canEdit && <th className="w-1">Actions</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{giteaRepos?.map((r) => (
|
|
||||||
<tr key={r.fullName}>
|
|
||||||
<td>
|
|
||||||
<a href={r.htmlUrl} target="_blank" rel="noopener noreferrer">
|
|
||||||
{r.fullName}
|
|
||||||
</a>
|
|
||||||
{r.private && <span className="badge bg-secondary-lt ms-2">Private</span>}
|
|
||||||
</td>
|
|
||||||
<td className="text-secondary">
|
|
||||||
{r.latestRun ? (
|
|
||||||
<a href={r.latestRun.htmlUrl} target="_blank" rel="noopener noreferrer">
|
|
||||||
#{r.latestRun.runNumber} {r.latestRun.displayTitle}
|
|
||||||
</a>
|
|
||||||
) : r.hasActions ? (
|
|
||||||
"—"
|
|
||||||
) : (
|
|
||||||
"Actions disabled"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="text-secondary">{r.latestRun?.headBranch ?? "—"}</td>
|
|
||||||
<td>{runStatusBadge(r)}</td>
|
|
||||||
{canEdit && (
|
|
||||||
<td>
|
|
||||||
{r.latestRun && (r.latestRun.conclusion === "failure" || r.latestRun.status === "failure") && (
|
|
||||||
<button
|
|
||||||
className="btn btn-sm"
|
|
||||||
onClick={() => rerunFailed(r)}
|
|
||||||
disabled={rerunningRun === r.latestRun.id}
|
|
||||||
>
|
|
||||||
{rerunningRun === r.latestRun.id ? "Re-running…" : "Re-run failed"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{giteaRepos?.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
|
|
||||||
No repositories visible to this token.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : selected?.type === "semaphore" ? (
|
) : selected?.type === "semaphore" ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-body text-secondary">
|
||||||
<h3 className="card-title">
|
Manage this integration's templates on the <Link to="/semaphore">Semaphore</Link> page.
|
||||||
Templates
|
|
||||||
{semaphoreData && semaphoreData.summary.failing > 0 && (
|
|
||||||
<span className="text-red fw-normal ms-2">{semaphoreData.summary.failing} failing</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<div className="card-actions">
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
onClick={() => loadSemaphoreTemplates(selected.id)}
|
|
||||||
disabled={loadingTemplates}
|
|
||||||
>
|
|
||||||
{loadingTemplates ? "Refreshing…" : "Refresh"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{semaphoreError && <div className="alert alert-danger m-3 mb-0">{semaphoreError}</div>}
|
|
||||||
<div className="table-responsive">
|
|
||||||
<table className="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Template</th>
|
|
||||||
<th>Project</th>
|
|
||||||
<th>Last run</th>
|
|
||||||
<th>Status</th>
|
|
||||||
{canEdit && <th className="w-1">Actions</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{semaphoreData?.templates.map((t) => (
|
|
||||||
<tr key={`${t.projectId}:${t.id}`}>
|
|
||||||
<td>{t.name}</td>
|
|
||||||
<td className="text-secondary">{t.projectName}</td>
|
|
||||||
<td className="text-secondary">
|
|
||||||
{t.lastTask
|
|
||||||
? formatDateTime(new Date(t.lastTask.end ?? t.lastTask.start ?? t.lastTask.created ?? ""))
|
|
||||||
: "—"}
|
|
||||||
</td>
|
|
||||||
<td>{semaphoreStatusBadge(t)}</td>
|
|
||||||
{canEdit && (
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-primary"
|
|
||||||
onClick={() => runTemplate(t)}
|
|
||||||
disabled={runningTemplate === t.id}
|
|
||||||
>
|
|
||||||
{runningTemplate === t.id ? "Starting…" : "Run"}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{semaphoreData?.templates.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
|
|
||||||
No templates found across any project.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : selected?.type === "proxmox" ? (
|
) : selected?.type === "proxmox" ? (
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type CurrentUser,
|
||||||
|
type IntegrationSummary,
|
||||||
|
type SemaphoreTemplate,
|
||||||
|
type SemaphoreTemplatesResponse,
|
||||||
|
} from "../api/client";
|
||||||
|
import { formatDateTime } from "../utils/date";
|
||||||
|
|
||||||
|
function semaphoreStatusBadge(template: SemaphoreTemplate) {
|
||||||
|
const status = template.lastTask?.status;
|
||||||
|
if (!status) return <span className="badge bg-secondary-lt text-secondary">Never run</span>;
|
||||||
|
switch (status) {
|
||||||
|
case "success":
|
||||||
|
return <span className="badge bg-green-lt text-green">Success</span>;
|
||||||
|
case "error":
|
||||||
|
return <span className="badge bg-red-lt text-red">Failed</span>;
|
||||||
|
case "stopped":
|
||||||
|
return <span className="badge bg-secondary-lt text-secondary">Stopped</span>;
|
||||||
|
case "rejected":
|
||||||
|
return <span className="badge bg-secondary-lt text-secondary">Rejected</span>;
|
||||||
|
case "waiting":
|
||||||
|
case "starting":
|
||||||
|
case "waiting_confirmation":
|
||||||
|
case "confirmed":
|
||||||
|
return <span className="badge bg-yellow-lt text-yellow">Queued</span>;
|
||||||
|
case "running":
|
||||||
|
case "stopping":
|
||||||
|
return <span className="badge bg-blue-lt text-blue">Running</span>;
|
||||||
|
default:
|
||||||
|
return <span className="badge bg-secondary-lt text-secondary">{status}</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Semaphore({ 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<SemaphoreTemplatesResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [runningTemplate, setRunningTemplate] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.integrations
|
||||||
|
.list()
|
||||||
|
.then((res) => {
|
||||||
|
const semaphoreIntegrations = res.integrations.filter((i) => i.type === "semaphore");
|
||||||
|
setIntegrations(semaphoreIntegrations);
|
||||||
|
if (!selectedId && semaphoreIntegrations.length > 0) setSelectedId(semaphoreIntegrations[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 loadTemplates(id: number) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api.integrations.semaphore
|
||||||
|
.templates(id)
|
||||||
|
.then((res) => setData(res))
|
||||||
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected?.enabled) {
|
||||||
|
loadTemplates(selected.id);
|
||||||
|
} else {
|
||||||
|
setData(null);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
async function runTemplate(t: SemaphoreTemplate) {
|
||||||
|
if (!selectedId) return;
|
||||||
|
if (!confirm(`Run "${t.name}" now?`)) return;
|
||||||
|
setRunningTemplate(t.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.integrations.semaphore.run(selectedId, t.projectId, t.id);
|
||||||
|
loadTemplates(selectedId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setRunningTemplate(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="page-title mb-3">Semaphore</h2>
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
|
||||||
|
{integrations?.length === 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-secondary">
|
||||||
|
No Semaphore 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
|
||||||
|
templates.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">
|
||||||
|
Templates
|
||||||
|
{data && data.summary.failing > 0 && (
|
||||||
|
<span className="text-red fw-normal ms-2">{data.summary.failing} failing</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => selected && loadTemplates(selected.id)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Refreshing…" : "Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Template</th>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Last run</th>
|
||||||
|
<th>Status</th>
|
||||||
|
{canEdit && <th className="w-1">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data?.templates.map((t) => (
|
||||||
|
<tr key={`${t.projectId}:${t.id}`}>
|
||||||
|
<td>{t.name}</td>
|
||||||
|
<td className="text-secondary">{t.projectName}</td>
|
||||||
|
<td className="text-secondary">
|
||||||
|
{t.lastTask
|
||||||
|
? formatDateTime(new Date(t.lastTask.end ?? t.lastTask.start ?? t.lastTask.created ?? ""))
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td>{semaphoreStatusBadge(t)}</td>
|
||||||
|
{canEdit && (
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => runTemplate(t)}
|
||||||
|
disabled={runningTemplate === t.id}
|
||||||
|
>
|
||||||
|
{runningTemplate === t.id ? "Starting…" : "Run"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{data?.templates.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
|
||||||
|
No templates found across any project.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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