- 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
232 lines
6.7 KiB
TypeScript
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 };
|