Second live integration: repo list with last CI run status, and re-running
failed jobs on a workflow run — matching the "dashboard + basic actions"
depth from the plan. Adapter built directly against the real Gitea 1.27
swagger spec (fetched from the user's own instance) rather than guessing at
the API shape: GET /user/repos for the repo list, GET
/repos/{owner}/{repo}/actions/runs?limit=1 for the latest run per repo (only
for repos with Actions enabled), and POST .../rerun-failed-jobs for retrying
just the failed jobs in a run. Follows the same config-in-UI +
encrypted-credential pattern as Tailscale and DNS providers.
Since Gitea collects its own base URL as a config field (unlike Tailscale,
which always talks to a fixed api.tailscale.com), generalized the
"integrations.baseUrl" bookkeeping into resolveBaseUrl() instead of the
one-fixed-URL-per-type map used previously.
Also fixed a real gap found while setting this up: server/src/env.ts reads
process.env directly, but nothing in the app ever loaded .env into
process.env for plain `node dist/index.js` / `tsx src/index.ts` runs — only
Docker's `env_file` config populated it, by injecting vars before Node even
starts. Every local (non-Docker) run silently had every setting at its
insecure default. Added server/src/loadEnv.ts (dotenv, pointed at the
repo-root .env) as the first import in both server/src/index.ts and
server/src/db/migrate.ts's standalone entrypoint.
Verified against the user's real, reachable services — not mocks:
- Authentik (auth.labsconnect.se): full OIDC login completed by the user
through the real UI; confirmed their account landed as admin (first user).
- Gitea (gitea.labsconnect.se): the compiled adapter run directly against a
real API token correctly listed all 11 real repos; a full HTTP-layer test
against the live server (8 checks) additionally covered a real
test-connection ping, credential non-leakage in list responses, and role
gating (403) on the rerun-failed-jobs action even with a valid token
behind it. None of the real repos have any workflow run history yet, so
the success/failure status badge and the rerun action itself are
implemented per the swagger spec but not yet exercised against a real run
— worth checking once one of those repos has actual CI activity.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
423 lines
17 KiB
TypeScript
423 lines
17 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import {
|
|
api,
|
|
type CurrentUser,
|
|
type GiteaRepo,
|
|
type IntegrationSummary,
|
|
type IntegrationType,
|
|
type TailscaleDevice,
|
|
type TailscaleDevicesResponse,
|
|
} from "../api/client";
|
|
import IntegrationForm from "../components/IntegrationForm";
|
|
|
|
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> = {
|
|
tailscale: "Tailscale",
|
|
proxmox: "Proxmox",
|
|
synology: "Synology",
|
|
semaphore: "Semaphore",
|
|
gitea: "Gitea",
|
|
dockhand: "Dockhand",
|
|
};
|
|
|
|
export default function Integrations({ user }: { user: CurrentUser }) {
|
|
const isAdmin = user.role === "admin";
|
|
const canEdit = user.role === "admin" || user.role === "operator";
|
|
|
|
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
|
const [managing, setManaging] = useState(false);
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
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);
|
|
|
|
function loadIntegrations() {
|
|
api.integrations
|
|
.list()
|
|
.then((res) => {
|
|
setIntegrations(res.integrations);
|
|
if (!selectedId && res.integrations.length > 0) setSelectedId(res.integrations[0].id);
|
|
})
|
|
.catch((err) => setError(String(err)));
|
|
}
|
|
|
|
useEffect(loadIntegrations, []);
|
|
|
|
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(() => {
|
|
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);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedId, managing, integrations]);
|
|
|
|
async function toggleEnabled(i: IntegrationSummary) {
|
|
try {
|
|
await api.integrations.update(i.id, { enabled: !i.enabled });
|
|
loadIntegrations();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function removeIntegration(i: IntegrationSummary) {
|
|
if (!confirm(`Delete integration "${i.name}"?`)) return;
|
|
try {
|
|
await api.integrations.remove(i.id);
|
|
if (selectedId === i.id) setSelectedId(null);
|
|
loadIntegrations();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="d-flex align-items-center mb-3">
|
|
<h2 className="page-title mb-0">Integrations</h2>
|
|
{isAdmin && (
|
|
<button className="btn btn-outline-secondary ms-auto" onClick={() => setManaging((v) => !v)}>
|
|
{managing ? "Back to browsing" : "Manage integrations"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{error && <div className="alert alert-danger">{error}</div>}
|
|
|
|
{managing ? (
|
|
<>
|
|
<IntegrationForm onCreated={loadIntegrations} onCancel={() => setManaging(false)} />
|
|
<div className="card">
|
|
<div className="table-responsive">
|
|
<table className="table table-vcenter card-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>Status</th>
|
|
<th className="w-1">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{integrations?.map((i) => (
|
|
<tr key={i.id}>
|
|
<td>{i.name}</td>
|
|
<td>{TYPE_LABELS[i.type]}</td>
|
|
<td>
|
|
<button
|
|
className={`badge ${i.enabled ? "bg-green-lt text-green" : "bg-secondary-lt text-secondary"} border-0`}
|
|
onClick={() => toggleEnabled(i)}
|
|
>
|
|
{i.enabled ? "Enabled" : "Disabled"}
|
|
</button>
|
|
</td>
|
|
<td>
|
|
<button className="btn btn-sm btn-outline-danger" onClick={() => removeIntegration(i)}>
|
|
Delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{integrations?.length === 0 && (
|
|
<tr>
|
|
<td colSpan={4} className="text-secondary text-center">
|
|
No integrations configured yet.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
{integrations?.length === 0 ? (
|
|
<div className="card">
|
|
<div className="card-body text-secondary">
|
|
No integrations configured yet.
|
|
{isAdmin && (
|
|
<>
|
|
{" "}
|
|
<button className="btn btn-link p-0 align-baseline" onClick={() => setManaging(true)}>
|
|
Add one
|
|
</button>
|
|
.
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<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} ({TYPE_LABELS[i.type]}){!i.enabled ? " — disabled" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{selected?.type === "tailscale" ? (
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h3 className="card-title">
|
|
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 ? new Date(d.lastSeen).toLocaleString() : "—"}</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>
|
|
) : selected?.type === "gitea" ? (
|
|
<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={() => 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 className="card">
|
|
<div className="card-body text-secondary">
|
|
{selected ? `${TYPE_LABELS[selected.type]} isn't wired up in the UI yet.` : "Select an integration."}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|