Files
sic/apps/web/src/WebhookFormTab.tsx
rikrdo 62728b2200 Initial commit: SIC harness (backend, web, pi-adapter, configs, docs)
- pnpm monorepo: apps/api (Fastify + SQLite + SSE), apps/web (React+Vite), packages/shared, packages/pi-adapter
- Local auth (admin/webhook-runner roles) + Keycloak JWT ready
- Multi-session chat with reliable history (user persisted before LLM, assistant persisted after stream)
- Markdown knowledge base with /api/docs/search + /api/docs/:id
- YAML webhook catalog with backend-only execution, retry/backoff, audit (webhook_runs), and per-user rate limit
- Skills config (sre-on-call, blameless-postmortem, security-incident) injected into LLM system prompt
- LLM provider failover chain (config/models.yml fallback + LLM_FALLBACK_CHAIN override)
- Context-aware webhooks panel + backend id-mention safety net
- Per-message stats (time/duration/tokens/model), Markdown+GFM render, code & table copy/download buttons
- Vitest suite, end-to-end smoke test (scripts/smoke.mjs), per-session system prompt override
- /metrics Prometheus endpoint + /api/metrics JSON, request-id correlation
- dotenv with explicit repo-root path; envString/envNumber helpers (handles empty-string env)
- Runbooks + SOPs under knowledge/ in English; README, docs, and INDEX.md in English
2026-06-29 16:20:53 +02:00

232 lines
6.7 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { authorizedHeaders, authTokenFromStorage, jsonHeaders, api } from "./api";
type PublicWebhook = {
id: string;
label: string;
description?: string;
method: string;
required_roles: string[];
confirmation_required: boolean;
};
type RunResult =
| { kind: "idle" }
| { kind: "running" }
| { kind: "success"; responseStatus: number | null; runId: string }
| { kind: "error"; message: string };
type WebhookFormTabProps = {
webhookId: string;
sessionId: string;
onBack: () => void;
};
const labelsEn = {
title: "Run webhook",
description: "Description",
requiredRoles: "Required roles",
confirmation: "Requires confirmation",
method: "Method",
payload: "Payload (optional JSON)",
payloadHelp: "These fields are merged with the backend template. Available variables: {user}, {session}, {message}.",
run: "Run",
running: "Running...",
resultOk: "Webhook executed",
resultErr: "Failed to execute",
httpStatus: "HTTP",
runId: "Audit ID",
back: "Back to chat",
notFound: "Webhook not found or insufficient permissions",
loading: "Loading webhook...",
user: "User",
session: "Session",
};
const detectLanguage = (): "en" => "en";
const WebhookFormTabInner = ({ webhookId, sessionId, onBack }: WebhookFormTabProps) => {
const [labels] = useState(() => labelsEn);
const [webhook, setWebhook] = useState<PublicWebhook | null>(null);
const [payload, setPayload] = useState("{}");
const [result, setResult] = useState<RunResult>({ kind: "idle" });
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
const data = await api<{ items: PublicWebhook[] }>("/api/webhooks");
const found = data.items.find((item) => item.id === webhookId);
if (!found) {
setError(labels.notFound);
return;
}
setWebhook(found);
} catch (err) {
console.error(err);
setError(labels.notFound);
}
})();
}, [webhookId, labels.notFound]);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!webhook) return;
if (webhook.confirmation_required) {
const ok = window.confirm(`Run ${webhook.label}?`);
if (!ok) return;
}
let parsed: Record<string, unknown> = {};
if (payload.trim().length > 0) {
try {
const value = JSON.parse(payload);
if (value && typeof value === "object" && !Array.isArray(value)) {
parsed = value as Record<string, unknown>;
}
} catch {
setResult({ kind: "error", message: "Payload is not valid JSON" });
return;
}
}
setResult({ kind: "running" });
try {
const response = await fetch(`/api/webhooks/${webhook.id}/run`, {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({
sessionId,
confirmed: true,
lastUserMessage: undefined,
payload: parsed,
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`http_${response.status}: ${detail.slice(0, 200)}`);
}
const body = (await response.json()) as { id: string; response_status: number | null };
setResult({ kind: "success", responseStatus: body.response_status, runId: body.id });
} catch (err) {
console.error(err);
const message = err instanceof Error ? err.message : "error";
setResult({ kind: "error", message });
}
};
const tokenInfo = useMemo(() => {
const t = authTokenFromStorage();
return t ? `${t.slice(0, 12)}` : labels.notFound;
}, [labels.notFound]);
if (error) {
return (
<main className="formTab error">
<h1>{labels.title}</h1>
<p className="muted">{error}</p>
<button type="button" onClick={onBack}>{labels.back}</button>
</main>
);
}
if (!webhook) {
return (
<main className="formTab loading">
<h1>{labels.title}</h1>
<p className="muted">{labels.loading}</p>
</main>
);
}
return (
<main className="formTab">
<header className="formTabHeader">
<div>
<small>SIC</small>
<h1>{webhook.label}</h1>
</div>
<button type="button" onClick={onBack}>{labels.back}</button>
</header>
<dl className="formTabMeta">
{webhook.description ? (
<div>
<dt>{labels.description}</dt>
<dd>{webhook.description}</dd>
</div>
) : null}
<div>
<dt>{labels.method}</dt>
<dd><code>{webhook.method}</code></dd>
</div>
<div>
<dt>{labels.requiredRoles}</dt>
<dd>{webhook.required_roles.join(", ")}</dd>
</div>
<div>
<dt>{labels.confirmation}</dt>
<dd>{webhook.confirmation_required ? "Yes" : "No"}</dd>
</div>
<div>
<dt>{labels.session}</dt>
<dd><code>{sessionId.slice(0, 8)}</code></dd>
</div>
<div>
<dt>{labels.user}</dt>
<dd><code>{tokenInfo}</code></dd>
</div>
</dl>
<form onSubmit={submit} className="formTabForm">
<label>
<span>{labels.payload}</span>
<textarea
onChange={(e) => setPayload(e.target.value)}
rows={8}
spellCheck={false}
value={payload}
/>
</label>
<small className="muted">{labels.payloadHelp}</small>
<button
className="formTabRun"
disabled={result.kind === "running"}
type="submit"
>
{result.kind === "running" ? labels.running : labels.run}
</button>
</form>
{result.kind === "success" ? (
<section className="formTabResult success">
<strong>{labels.resultOk}</strong>
<small>{labels.httpStatus}: {result.responseStatus ?? "—"}</small>
<small>{labels.runId}: <code>{result.runId}</code></small>
</section>
) : null}
{result.kind === "error" ? (
<section className="formTabResult error">
<strong>{labels.resultErr}</strong>
<small>{result.message}</small>
</section>
) : null}
</main>
);
};
// Read query params helper for the main App.
export const getWebhookFormTabParams = () => {
const params = new URLSearchParams(window.location.search);
const webhook = params.get("webhook");
const session = params.get("session");
if (!webhook || !session) return null;
return { webhookId: webhook, sessionId: session };
};
const WebhookFormTab = WebhookFormTabInner;
export default WebhookFormTab;
export type { WebhookFormTabProps };