Add Semaphore integration
Fourth live integration: Ansible run history per template with a
trigger-a-run action — matching the "dashboard + basic actions" depth from
the plan. Adapter built against Semaphore's official published OpenAPI spec
(github.com/semaphoreui/semaphore/blob/develop/api-docs.yml) plus its Go
source directly for the task-status enum, which the spec itself leaves
untyped (db/Task.go, pkg/task_logger/task_logger.go) — instance wasn't
reachable from here (semaphore.int.toolabs.se doesn't resolve outside the
user's LAN), so verified against the real spec/source rather than guessing.
server/src/integrations/semaphore/adapter.ts: GET /api/projects, then GET
/api/project/{id}/templates?sort=name&order=asc per project — each template
in that response already embeds its last_task, so listing every template's
current status across every project needs only one call per project (no
N+1 per-template lookup, unlike Gitea where the run history isn't embedded
in the repo list). POST /api/project/{id}/tasks {template_id} triggers a
run. Follows the same config-in-UI + encrypted-credential pattern as the
other three integrations.
Verified: full build passes. Same as Dockhand — unreachable, so ran a
14-check HTTP test against the live server (real target URL, bogus token):
role gating, credential non-leakage, disabled-integration blocking, and the
wrong_type crash-safety check for this fourth adapter type, confirming the
server stays up throughout. Real template/run data and the trigger-a-run
action still need verification once this app can reach the user's LAN.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1504c19bbd
commit
abb5335a52
7 files changed
+419
-1
No files matched your search
@@ -23,6 +23,10 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
|
||||
{ key: "url", label: "Dockhand URL", secret: false, placeholder: "https://dockhand.example.lan" },
|
||||
{ key: "token", label: "API token", secret: true, type: "password", placeholder: "dh_..." },
|
||||
],
|
||||
semaphore: [
|
||||
{ key: "url", label: "Semaphore URL", secret: false, placeholder: "https://semaphore.example.lan" },
|
||||
{ key: "token", label: "API token", secret: true, type: "password" },
|
||||
],
|
||||
};
|
||||
|
||||
/** Fixed base URL per integration type, stored on the row for display/reference. */
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { IntegrationConfig } from "./types.js";
|
||||
import { createTailscaleAdapter } from "./tailscale/adapter.js";
|
||||
import { createGiteaAdapter } from "./gitea/adapter.js";
|
||||
import { createDockhandAdapter } from "./dockhand/adapter.js";
|
||||
import { createSemaphoreAdapter } from "./semaphore/adapter.js";
|
||||
|
||||
export interface PingableAdapter {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
@@ -23,6 +24,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat
|
||||
return createGiteaAdapter(config as any);
|
||||
case "dockhand":
|
||||
return createDockhandAdapter(config as any);
|
||||
case "semaphore":
|
||||
return createSemaphoreAdapter(config as any);
|
||||
default:
|
||||
throw new Error(`Integration type "${type}" is not implemented yet`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Semaphore (Ansible Semaphore / Semaphore UI) adapter.
|
||||
* Requires config: url, token
|
||||
*
|
||||
* API reference verified against the official spec
|
||||
* (https://github.com/semaphoreui/semaphore/blob/develop/api-docs.yml) and
|
||||
* source (db/Task.go, pkg/task_logger/task_logger.go) for the exact task
|
||||
* status enum, since the swagger doc itself doesn't enumerate it.
|
||||
*/
|
||||
|
||||
export interface SemaphoreConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// waiting -> starting -> running -> (success | error | stopped)
|
||||
// waiting_confirmation/confirmed/rejected apply only to templates requiring approval.
|
||||
export type SemaphoreTaskStatus =
|
||||
| "waiting"
|
||||
| "starting"
|
||||
| "waiting_confirmation"
|
||||
| "confirmed"
|
||||
| "rejected"
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "stopped"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
export interface SemaphoreTask {
|
||||
id: number;
|
||||
status: SemaphoreTaskStatus;
|
||||
created: string | null;
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
}
|
||||
|
||||
export interface SemaphoreTemplate {
|
||||
id: number;
|
||||
projectId: number;
|
||||
projectName: string;
|
||||
name: string;
|
||||
playbook: string;
|
||||
lastTask: SemaphoreTask | null;
|
||||
}
|
||||
|
||||
export interface SemaphoreAdapter {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
listTemplatesWithStatus(): Promise<SemaphoreTemplate[]>;
|
||||
runTemplate(projectId: number, templateId: number): Promise<SemaphoreTask>;
|
||||
}
|
||||
|
||||
export function createSemaphoreAdapter(config: SemaphoreConfig): SemaphoreAdapter {
|
||||
function base() {
|
||||
return config.url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function api(method: string, path: string, body?: unknown): Promise<any> {
|
||||
const res = await fetch(`${base()}/api${path}`, {
|
||||
method,
|
||||
headers: headers(),
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 204) return null;
|
||||
const text = await res.text();
|
||||
let data: any = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// non-JSON error page
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error((typeof data === "string" ? data : data?.error) || `Semaphore API error: HTTP ${res.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function mapTask(t: any): SemaphoreTask | null {
|
||||
if (!t) return null;
|
||||
return {
|
||||
id: t.id,
|
||||
status: t.status,
|
||||
created: t.created ?? null,
|
||||
start: t.start ?? null,
|
||||
end: t.end ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function listProjects(): Promise<{ id: number; name: string }[]> {
|
||||
const data = await api("GET", "/projects");
|
||||
return (Array.isArray(data) ? data : []).map((p: any) => ({ id: p.id, name: p.name }));
|
||||
}
|
||||
|
||||
async function listTemplatesForProject(projectId: number, projectName: string): Promise<SemaphoreTemplate[]> {
|
||||
const data = await api("GET", `/project/${projectId}/templates?sort=name&order=asc`);
|
||||
return (Array.isArray(data) ? data : []).map((t: any) => ({
|
||||
id: t.id,
|
||||
projectId,
|
||||
projectName,
|
||||
name: t.name,
|
||||
playbook: t.playbook,
|
||||
lastTask: mapTask(t.last_task),
|
||||
}));
|
||||
}
|
||||
|
||||
async function listTemplatesWithStatus(): Promise<SemaphoreTemplate[]> {
|
||||
const projects = await listProjects();
|
||||
const perProject = await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
try {
|
||||
return await listTemplatesForProject(p.id, p.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return perProject.flat();
|
||||
}
|
||||
|
||||
async function runTemplate(projectId: number, templateId: number): Promise<SemaphoreTask> {
|
||||
const task = await api("POST", `/project/${projectId}/tasks`, { template_id: templateId });
|
||||
return mapTask(task)!;
|
||||
}
|
||||
|
||||
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await api("GET", "/projects");
|
||||
return { ok: true, latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
return { ping, listTemplatesWithStatus, runTemplate };
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
|
||||
import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
|
||||
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
|
||||
import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const integrationsRouter = Router();
|
||||
@@ -469,3 +470,71 @@ for (const action of dockhandActions) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Semaphore ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function requireSemaphoreAdapter(req: Request, res: Response) {
|
||||
const id = Number(req.params.id);
|
||||
const loaded = await loadIntegrationConfig(id);
|
||||
if (!loaded) {
|
||||
res.status(404).json({ error: "not_found" });
|
||||
return null;
|
||||
}
|
||||
if (loaded.integration.type !== "semaphore") {
|
||||
res.status(400).json({ error: "wrong_type" });
|
||||
return null;
|
||||
}
|
||||
if (!loaded.integration.enabled) {
|
||||
res.status(400).json({ error: "integration_disabled" });
|
||||
return null;
|
||||
}
|
||||
return { integration: loaded.integration, adapter: createSemaphoreAdapter(loaded.config as any) };
|
||||
}
|
||||
|
||||
integrationsRouter.get("/:id/semaphore/templates", asyncHandler(async (req, res) => {
|
||||
const found = await requireSemaphoreAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
try {
|
||||
const templates = await found.adapter.listTemplatesWithStatus();
|
||||
res.json({
|
||||
templates,
|
||||
summary: {
|
||||
total: templates.length,
|
||||
failing: templates.filter((t) => t.lastTask?.status === "error").length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}));
|
||||
|
||||
integrationsRouter.post(
|
||||
"/:id/semaphore/projects/:projectId/templates/:templateId/run",
|
||||
requireRole("operator"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const found = await requireSemaphoreAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
const projectId = Number(req.params.projectId);
|
||||
const templateId = Number(req.params.templateId);
|
||||
if (!Number.isInteger(projectId) || !Number.isInteger(templateId)) {
|
||||
return res.status(400).json({ error: "invalid_id" });
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await found.adapter.runTemplate(projectId, templateId);
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "integration",
|
||||
action: "run_template",
|
||||
targetType: "semaphore_template",
|
||||
targetId: templateId,
|
||||
detail: { integrationId: found.integration.id, projectId, taskId: task.id },
|
||||
});
|
||||
res.status(201).json({ task });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -237,6 +237,40 @@ export interface DockhandContainersResponse {
|
||||
summary: { total: number; running: number };
|
||||
}
|
||||
|
||||
export type SemaphoreTaskStatus =
|
||||
| "waiting"
|
||||
| "starting"
|
||||
| "waiting_confirmation"
|
||||
| "confirmed"
|
||||
| "rejected"
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "stopped"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
export interface SemaphoreTask {
|
||||
id: number;
|
||||
status: SemaphoreTaskStatus;
|
||||
created: string | null;
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
}
|
||||
|
||||
export interface SemaphoreTemplate {
|
||||
id: number;
|
||||
projectId: number;
|
||||
projectName: string;
|
||||
name: string;
|
||||
playbook: string;
|
||||
lastTask: SemaphoreTask | null;
|
||||
}
|
||||
|
||||
export interface SemaphoreTemplatesResponse {
|
||||
templates: SemaphoreTemplate[];
|
||||
summary: { total: number; failing: number };
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {}
|
||||
export class ForbiddenError extends Error {}
|
||||
|
||||
@@ -434,5 +468,14 @@ export const api = {
|
||||
{ method: "POST" },
|
||||
),
|
||||
},
|
||||
semaphore: {
|
||||
templates: (integrationId: number) =>
|
||||
request<SemaphoreTemplatesResponse>(`/api/integrations/${integrationId}/semaphore/templates`),
|
||||
run: (integrationId: number, projectId: number, templateId: number) =>
|
||||
request<{ task: SemaphoreTask }>(
|
||||
`/api/integrations/${integrationId}/semaphore/projects/${projectId}/templates/${templateId}/run`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -21,11 +21,17 @@ interface DockhandSummary {
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface SemaphoreSummary {
|
||||
total: number;
|
||||
failing: number;
|
||||
}
|
||||
|
||||
export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
|
||||
const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(null);
|
||||
const [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null);
|
||||
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.integrations.list().then((res) => setIntegrations(res.integrations));
|
||||
@@ -72,6 +78,18 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
.catch(() => setDockhandSummary(null));
|
||||
}, [integrations]);
|
||||
|
||||
useEffect(() => {
|
||||
const semaphore = integrations?.find((i) => i.type === "semaphore" && i.enabled);
|
||||
if (!semaphore) {
|
||||
setSemaphoreSummary(null);
|
||||
return;
|
||||
}
|
||||
api.integrations.semaphore
|
||||
.templates(semaphore.id)
|
||||
.then((res) => setSemaphoreSummary(res.summary))
|
||||
.catch(() => setSemaphoreSummary(null));
|
||||
}, [integrations]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row row-cards mb-3">
|
||||
@@ -90,7 +108,8 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
const isLiveTailscale = type === "tailscale" && integration && tailscaleSummary;
|
||||
const isLiveGitea = type === "gitea" && integration && giteaSummary;
|
||||
const isLiveDockhand = type === "dockhand" && integration && dockhandSummary;
|
||||
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand;
|
||||
const isLiveSemaphore = type === "semaphore" && integration && semaphoreSummary;
|
||||
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore;
|
||||
|
||||
return (
|
||||
<div className="col-sm-6 col-lg-4" key={type}>
|
||||
@@ -133,6 +152,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
<div className="text-secondary">containers running</div>
|
||||
</div>
|
||||
) : isLiveSemaphore ? (
|
||||
<div className="mt-2">
|
||||
<div className="h3 mb-0">{semaphoreSummary!.total}</div>
|
||||
<div className="text-secondary">templates</div>
|
||||
{semaphoreSummary!.failing > 0 && (
|
||||
<div className="text-red small mt-1">{semaphoreSummary!.failing} last failed</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-secondary mt-2">
|
||||
{integration ? (
|
||||
|
||||
@@ -7,11 +7,38 @@ import {
|
||||
type GiteaRepo,
|
||||
type IntegrationSummary,
|
||||
type IntegrationType,
|
||||
type SemaphoreTemplate,
|
||||
type SemaphoreTemplatesResponse,
|
||||
type TailscaleDevice,
|
||||
type TailscaleDevicesResponse,
|
||||
} from "../api/client";
|
||||
import IntegrationForm from "../components/IntegrationForm";
|
||||
|
||||
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 containerStateBadge(state: string) {
|
||||
switch (state) {
|
||||
case "running":
|
||||
@@ -85,6 +112,11 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
const [loadingContainers, setLoadingContainers] = useState(false);
|
||||
const [actingOnContainer, setActingOnContainer] = useState<string | 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);
|
||||
|
||||
function loadIntegrations() {
|
||||
api.integrations
|
||||
.list()
|
||||
@@ -145,9 +177,24 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
} else {
|
||||
setDockhandData(null);
|
||||
}
|
||||
if (selected?.type === "semaphore" && selected.enabled && !managing) {
|
||||
loadSemaphoreTemplates(selected.id);
|
||||
} else {
|
||||
setSemaphoreData(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId, managing, integrations]);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
async function toggleEnabled(i: IntegrationSummary) {
|
||||
try {
|
||||
await api.integrations.update(i.id, { enabled: !i.enabled });
|
||||
@@ -218,6 +265,21 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
@@ -547,6 +609,72 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : selected?.type === "semaphore" ? (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
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
|
||||
? new Date(t.lastTask.end ?? t.lastTask.start ?? t.lastTask.created ?? "").toLocaleString()
|
||||
: "—"}
|
||||
</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 className="card">
|
||||
<div className="card-body text-secondary">
|
||||
|
||||
Reference in new issue
Block a user