Simplify Integrations to a plain list + Add button

Flagged back when Proxmox/Synology got their own pages: once all six
integration types had moved to dedicated pages, the "browsing"
dropdown here only ever showed one of six near-identical "go manage
this on its own page" redirects, and its final fallback branch was
dead code (every IntegrationType was already covered). Now that all
the actual data views have moved out, drop that mode entirely.

The page is now just what "Manage integrations" already was: a
sortable, CSV-exportable table of configured integrations (name/type/
status), visible to every role since none of that is sensitive, with
an admin-only "Add integration" button and per-row edit/enable-disable/
delete actions. No more mode toggle, no provider-picker dropdown, no
per-type redirect cards -- the six dedicated pages (and the sidebar
nav that already points at them) are how you actually use each
integration now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-18 19:57:03 +02:00
1 parent 16d64c42d8
commit d5b6c55383
2 files changed
+115 -172

No files matched your search

+110 -170
View File
@@ -1,5 +1,4 @@
import { useEffect, useState, type CSSProperties } from "react";
import { Link } from "react-router-dom";
import { api, type CurrentUser, type IntegrationSummary, type IntegrationType } from "../api/client";
import IntegrationForm from "../components/IntegrationForm";
import IntegrationEditForm from "../components/IntegrationEditForm";
@@ -24,12 +23,10 @@ function typeBadgeStyle(colors: Record<string, string>, type: IntegrationType):
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 [adding, setAdding] = useState(false);
const [editingIntegration, setEditingIntegration] = useState<IntegrationSummary | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [integrationColors, setIntegrationColors] = useState<Record<string, string>>({});
@@ -40,17 +37,12 @@ export default function Integrations({ user }: { user: CurrentUser }) {
function loadIntegrations() {
api.integrations
.list()
.then((res) => {
setIntegrations(res.integrations);
if (!selectedId && res.integrations.length > 0) setSelectedId(res.integrations[0].id);
})
.then((res) => setIntegrations(res.integrations))
.catch((err) => setError(String(err)));
}
useEffect(loadIntegrations, []);
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
async function toggleEnabled(i: IntegrationSummary) {
try {
await api.integrations.update(i.id, { enabled: !i.enabled });
@@ -64,7 +56,6 @@ export default function Integrations({ user }: { user: CurrentUser }) {
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));
@@ -85,172 +76,121 @@ export default function Integrations({ user }: { user: CurrentUser }) {
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"}
<div>
<h2 className="page-title mb-0">Integrations</h2>
<div className="text-secondary small mt-1">
Tailscale, Proxmox, Synology, Semaphore, Gitea, and Dockhand each get their own page once
connected — add and manage credentials here.
</div>
</div>
{isAdmin && !adding && !editingIntegration && (
<button className="btn btn-primary ms-auto" onClick={() => setAdding(true)}>
Add integration
</button>
)}
</div>
{error && <div className="alert alert-danger">{error}</div>}
{managing ? (
<>
{editingIntegration ? (
<IntegrationEditForm
integration={editingIntegration}
onSaved={() => {
setEditingIntegration(null);
loadIntegrations();
}}
onCancel={() => setEditingIntegration(null)}
/>
) : (
<IntegrationForm onCreated={loadIntegrations} onCancel={() => setManaging(false)} />
)}
<div className="card">
<div className="card-header justify-content-end">
<button className="btn btn-outline-secondary btn-sm" onClick={exportCsv} disabled={!integrations || integrations.length === 0}>
Export CSV
</button>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<IntegrationSummary> label="Name" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<IntegrationSummary> label="Type" sortKeyName="type" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<IntegrationSummary>
label="Status"
sortKeyName="enabled"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<th className="w-1">Actions</th>
</tr>
</thead>
<tbody>
{sortedIntegrations?.map((i) => (
<tr key={i.id}>
<td>{i.name}</td>
<td>
<span className="badge" style={typeBadgeStyle(integrationColors, i.type)}>
{TYPE_LABELS[i.type]}
</span>
</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>
<div className="btn-list flex-nowrap">
<button className="btn btn-sm" onClick={() => setEditingIntegration(i)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => removeIntegration(i)}>
Delete
</button>
</div>
</td>
</tr>
))}
{sortedIntegrations?.length === 0 && (
<tr>
<td colSpan={4} className="text-secondary text-center">
No integrations configured yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
{editingIntegration ? (
<IntegrationEditForm
integration={editingIntegration}
onSaved={() => {
setEditingIntegration(null);
loadIntegrations();
}}
onCancel={() => setEditingIntegration(null)}
/>
) : (
<>
{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-body text-secondary">
Manage this integration's devices on the <Link to="/tailscale">Tailscale</Link> page.
</div>
</div>
) : selected?.type === "gitea" ? (
<div className="card">
<div className="card-body text-secondary">
Manage this integration's repositories on the <Link to="/gitea">Gitea</Link> page.
</div>
</div>
) : selected?.type === "semaphore" ? (
<div className="card">
<div className="card-body text-secondary">
Manage this integration's templates on the <Link to="/semaphore">Semaphore</Link> page.
</div>
</div>
) : selected?.type === "proxmox" ? (
<div className="card">
<div className="card-body text-secondary">
Manage this integration's VMs &amp; containers on the <Link to="/proxmox">Proxmox</Link> page.
</div>
</div>
) : selected?.type === "synology" ? (
<div className="card">
<div className="card-body text-secondary">
View this integration's storage on the <Link to="/synology">Synology</Link> page.
</div>
</div>
) : selected?.type === "dockhand" ? (
<div className="card">
<div className="card-body text-secondary">
Manage this integration's containers on the <Link to="/docker">Docker</Link> page.
</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>
)}
</>
)}
</>
adding && (
<IntegrationForm
onCreated={() => {
setAdding(false);
loadIntegrations();
}}
onCancel={() => setAdding(false)}
/>
)
)}
<div className="card">
<div className="card-header justify-content-end">
<button className="btn btn-outline-secondary btn-sm" onClick={exportCsv} disabled={!integrations || integrations.length === 0}>
Export CSV
</button>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<IntegrationSummary> label="Name" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<IntegrationSummary> label="Type" sortKeyName="type" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<IntegrationSummary>
label="Status"
sortKeyName="enabled"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
{isAdmin && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{sortedIntegrations?.map((i) => (
<tr key={i.id}>
<td>{i.name}</td>
<td>
<span className="badge" style={typeBadgeStyle(integrationColors, i.type)}>
{TYPE_LABELS[i.type]}
</span>
</td>
<td>
{isAdmin ? (
<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>
) : (
<span className={`badge ${i.enabled ? "bg-green-lt text-green" : "bg-secondary-lt text-secondary"}`}>
{i.enabled ? "Enabled" : "Disabled"}
</span>
)}
</td>
{isAdmin && (
<td>
<div className="btn-list flex-nowrap">
<button className="btn btn-sm" onClick={() => setEditingIntegration(i)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => removeIntegration(i)}>
Delete
</button>
</div>
</td>
)}
</tr>
))}
{sortedIntegrations?.length === 0 && (
<tr>
<td colSpan={isAdmin ? 4 : 3} className="text-secondary text-center">
No integrations configured yet.
{isAdmin && (
<>
{" "}
<button className="btn btn-link p-0 align-baseline" onClick={() => setAdding(true)}>
Add one
</button>
.
</>
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}