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:
1 parent
16d64c42d8
commit
d5b6c55383
2 files changed
+115
-172
No files matched your search
+110
-170
@@ -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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user