Skripty konektoru: vykonna cast s manifestem a kontrolou parametru
Konektory dostaly vykonnou cast. Jeden skript je jeden soubor, ktery nese manifest (vstupni a vystupni parametry) i kod. Diky manifestu s nim umi pracovat strom automatizace, aniz by o kodu cokoliv vedel. Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se musel prelozit a to je presne to otaceni, ktere tady nema byt. Registr sleduje cas zmeny souboru, takze uprava v portalu, rucni uprava souboru i novy soubor ve slozce funguji stejne a bez restartu. Pridano: - scripts/ se skripty konektoru, nazev souboru je zaroven ID operace - kontrola vstupu i vystupu proti manifestu, jedna funkce pro obe strany. Chybejici povinny vystup je chyba skriptu, ne uzivatele - jinak by strom veril parametru, ktery nikdy nedosel - ctx predavany skriptu: http nad adresou napojeni, util, log, config, idempotencyKey, fail a retry. Skript nedostane pristupove udaje - rozliseni opakovatelne a koncove chyby. Runner nikdy nevyhodi vyjimku, vzdy vraci vysledek vcetne retryable - redakce tajnych hodnot pred zapisem do logu. Cizi API rado vraci prijaty token v chybove zprave a log ticketu vidi klient - napojeni z environment variables vcetne iDokladu - sest ukazkovych skriptu pro iDoklad proti skutecnemu API sluzby services.csbot.cz/apps/idoklad, kazdy na jiny vzor - stranka /dashboard/skripty: seznam, manifest, editor, zkusebni spusteni. Formular testu se sklada z manifestu, nepise se pro kazdy skript - endpointy /api/dashboard/scripts vcetne Swaggeru Zmeneno: - katalog konektoru uz neni jen staticky seznam. Akce ze skriptu se domeruji prekryvem v src/data/connectors.ts, takze se naraz objevi ve validaci stromu, ve vypoctu scope i v sablonach. Pri stejnem ID vyhrava skript - ConnectorOperation ma implementation a scriptId - ApiError na klientovi nese cele telo odpovedi a umi z nej vytahnout issues - Dockerfile kopiruje scripts/ do vysledneho image Ukladani nemuze rozbit fungujici skript: kod se nejdriv zapise do docasneho souboru, ten se nacte a overi, a az pak prepise puvodni. K tomu tri dokumenty navrhu dalsich kroku: 09 datove modely a prava, 10 runtime a rozpocet na 150 klientu, 11 popis skriptu konektoru. Overeno: npm run typecheck prochazi na serveru i webu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bbc2236c0d
commit
6f6b287d7e
@@ -17,6 +17,7 @@ const Overview = lazy(() => import('@/pages/dashboard/Overview'));
|
||||
const Automations = lazy(() => import('@/pages/dashboard/Automations'));
|
||||
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
|
||||
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
|
||||
const Scripts = lazy(() => import('@/pages/dashboard/Scripts'));
|
||||
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
|
||||
const TicketDetail = lazy(() => import('@/pages/dashboard/TicketDetail'));
|
||||
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
|
||||
@@ -61,6 +62,7 @@ export default function App() {
|
||||
<Route path="automatizace" element={<Automations />} />
|
||||
<Route path="automatizace/:id" element={<AutomationDetail />} />
|
||||
<Route path="konektory" element={<Connectors />} />
|
||||
<Route path="skripty" element={<Scripts />} />
|
||||
<Route path="tickety" element={<Tickets />} />
|
||||
<Route path="tickety/:id" element={<TicketDetail />} />
|
||||
<Route path="incidenty" element={<Incidents />} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LogOut,
|
||||
Menu,
|
||||
Plug,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Workflow,
|
||||
X,
|
||||
@@ -27,6 +28,7 @@ const nav = [
|
||||
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
|
||||
{ to: '/dashboard/automatizace', label: 'Automatizace', icon: Workflow, end: false },
|
||||
{ to: '/dashboard/konektory', label: 'Konektory', icon: Plug, end: false },
|
||||
{ to: '/dashboard/skripty', label: 'Skripty', icon: ScrollText, end: false },
|
||||
{ to: '/dashboard/tickety', label: 'Tickety', icon: LifeBuoy, end: false },
|
||||
{ to: '/dashboard/incidenty', label: 'Incidenty', icon: AlarmClock, end: false },
|
||||
{ to: '/dashboard/nastaveni', label: 'Nastavení', icon: Settings, end: false },
|
||||
|
||||
+20
-1
@@ -25,10 +25,29 @@ export class ApiError extends Error {
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code?: string,
|
||||
/** Cele telo odpovedi. Nektere endpointy vraci vedle zpravy i podrobnosti. */
|
||||
readonly payload?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dilci problemy z odpovedi, napriklad co presne v manifestu skriptu nesedi.
|
||||
* Prazdne pole, kdyz je odpoved neposila - volajici pak nemusi nic hlidat.
|
||||
*/
|
||||
issues(): Array<{ field: string; message: string }> {
|
||||
if (this.payload === null || typeof this.payload !== 'object') return [];
|
||||
const value = (this.payload as { issues?: unknown }).issues;
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(item): item is { field: string; message: string } =>
|
||||
item !== null &&
|
||||
typeof item === 'object' &&
|
||||
typeof (item as { field?: unknown }).field === 'string' &&
|
||||
typeof (item as { message?: unknown }).message === 'string',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
@@ -85,7 +104,7 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
|
||||
? String((payload as { error: unknown }).error)
|
||||
: undefined;
|
||||
console.error(`[api] ${path} -> ${response.status} ${code ?? ''} ${message}`);
|
||||
throw new ApiError(message, response.status, code);
|
||||
throw new ApiError(message, response.status, code, isJson ? payload : undefined);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheckCircle2, Clock, Plug, Search, Zap } from 'lucide-react';
|
||||
import { CheckCircle2, Clock, Plug, Search, Terminal, Zap } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
@@ -8,7 +8,13 @@ import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type { Connector, ConnectorCatalog, ConnectorCategory, ConnectorStatus } from '@/types/dashboard';
|
||||
import type {
|
||||
Connector,
|
||||
ConnectorCatalog,
|
||||
ConnectorCategory,
|
||||
ConnectorOperation,
|
||||
ConnectorStatus,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
const statusMeta: Record<ConnectorStatus, { label: string; tone: 'ok' | 'neutral' | 'warn' }> = {
|
||||
connected: { label: 'Napojeno', tone: 'ok' },
|
||||
@@ -223,13 +229,13 @@ function ConnectorCard({ connector }: { connector: Connector }) {
|
||||
<OperationList
|
||||
title="Spouštěče"
|
||||
icon={<Zap className="size-3 text-brand-300" />}
|
||||
names={connector.triggers.map((t) => t.name)}
|
||||
operations={connector.triggers}
|
||||
emptyLabel="Nelze použít jako spouštěč"
|
||||
/>
|
||||
<OperationList
|
||||
title="Akce"
|
||||
icon={<Plug className="size-3 text-accent-300" />}
|
||||
names={connector.actions.map((a) => a.name)}
|
||||
operations={connector.actions}
|
||||
emptyLabel="Žádné akce"
|
||||
/>
|
||||
</div>
|
||||
@@ -240,12 +246,12 @@ function ConnectorCard({ connector }: { connector: Connector }) {
|
||||
function OperationList({
|
||||
title,
|
||||
icon,
|
||||
names,
|
||||
operations,
|
||||
emptyLabel,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
names: string[];
|
||||
operations: ConnectorOperation[];
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -254,13 +260,20 @@ function OperationList({
|
||||
{icon}
|
||||
{title}
|
||||
</p>
|
||||
{names.length === 0 ? (
|
||||
{operations.length === 0 ? (
|
||||
<p className="text-xs text-white/25">{emptyLabel}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{names.map((name) => (
|
||||
<li key={name} className="text-sm text-white/60">
|
||||
{name}
|
||||
{operations.map((operation) => (
|
||||
<li key={operation.id} className="flex items-start gap-1.5 text-sm text-white/60">
|
||||
<span>{operation.name}</span>
|
||||
{/* Operace se skriptem se opravdu vykona, ostatni jsou zatim popis. */}
|
||||
{operation.implementation === 'script' && (
|
||||
<Terminal
|
||||
className="mt-1 size-3 shrink-0 text-ok-400"
|
||||
aria-label="Obsluhuje skript, opravdu se vykoná"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Code2,
|
||||
FileWarning,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { apiFetch, ApiError } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
ScriptCatalog,
|
||||
ScriptDetail,
|
||||
ScriptField,
|
||||
ScriptRunResult,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Vykonna cast konektoru. Jeden skript = jeden soubor, ktery nese manifest
|
||||
* (vstupy a vystupy) a kod. Upravit ho jde tady i rucne v souboru, server si
|
||||
* zmenu vsimne sam - nic se nerestartuje.
|
||||
*
|
||||
* Popis modelu je v documentation/11-skripty-konektoru.md.
|
||||
*/
|
||||
export default function Scripts() {
|
||||
usePageMeta({ title: 'Skripty konektorů - portál Automia' });
|
||||
|
||||
const { user } = useAuth();
|
||||
const canEdit = user?.platformAdmin === true;
|
||||
|
||||
const catalog = useApiQuery<ScriptCatalog>('/api/dashboard/scripts');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const items = catalog.data?.items ?? [];
|
||||
|
||||
// Prvni skript se vybere sam, jinak by stranka po nacteni vypadala prazdne.
|
||||
useEffect(() => {
|
||||
if (selectedId === null && items.length > 0) setSelectedId(items[0].id);
|
||||
}, [items, selectedId]);
|
||||
|
||||
const connectionsById = useMemo(() => {
|
||||
const map = new Map<string, ConnectionStatus>();
|
||||
for (const connection of catalog.data?.connections ?? []) {
|
||||
map.set(connection.connectorId, connection);
|
||||
}
|
||||
return map;
|
||||
}, [catalog.data]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, ScriptCatalog['items']>();
|
||||
for (const item of items) {
|
||||
const list = map.get(item.connectorId) ?? [];
|
||||
list.push(item);
|
||||
map.set(item.connectorId, list);
|
||||
}
|
||||
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Skripty konektorů</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Výkonná část konektorů. Každý skript nese vstupní i výstupní parametry, takže
|
||||
s ním umí pracovat strom automatizace. Upravit ho jde tady nebo přímo v souboru,
|
||||
server si změny všimne sám.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DataState
|
||||
loading={catalog.loading}
|
||||
error={catalog.error}
|
||||
onRetry={catalog.reload}
|
||||
empty={items.length === 0 && (catalog.data?.problems.length ?? 0) === 0}
|
||||
emptyLabel="Ve složce skriptů nic není."
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{catalog.data && <Problems problems={catalog.data.problems} />}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)]">
|
||||
<aside className="space-y-5">
|
||||
{grouped.map(([connectorId, scripts]) => (
|
||||
<ConnectorGroup
|
||||
key={connectorId}
|
||||
connectorId={connectorId}
|
||||
connection={connectionsById.get(connectorId)}
|
||||
scripts={scripts}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{catalog.data && (
|
||||
<p className="px-1 text-xs break-all text-white/30">
|
||||
Složka: <code>{catalog.data.directory}</code>
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{selectedId ? (
|
||||
<ScriptPanel
|
||||
key={selectedId}
|
||||
scriptId={selectedId}
|
||||
canEdit={canEdit}
|
||||
onSaved={catalog.reload}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-10 text-center text-sm text-white/40">Vyberte skript vlevo.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- seznam
|
||||
|
||||
function ConnectorGroup({
|
||||
connectorId,
|
||||
connection,
|
||||
scripts,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
connectorId: string;
|
||||
connection: ConnectionStatus | undefined;
|
||||
scripts: ScriptCatalog['items'];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass rounded-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<p className="font-semibold text-white">{connectorId}</p>
|
||||
{connection && (
|
||||
<Badge tone={connection.ready ? 'ok' : 'warn'}>
|
||||
{connection.ready ? 'Napojeno' : 'Chybí údaje'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{scripts.map((script) => (
|
||||
<li key={script.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(script.id)}
|
||||
className={cn(
|
||||
'w-full rounded-lg px-3 py-2 text-left text-sm transition-colors',
|
||||
script.id === selectedId
|
||||
? 'bg-brand-500/12 text-brand-200'
|
||||
: 'text-white/60 hover:bg-white/5 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{script.name}
|
||||
<span className="mt-0.5 block font-mono text-xs text-white/30">
|
||||
{script.operationId}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Problems({ problems }: { problems: ScriptCatalog['problems'] }) {
|
||||
if (problems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-danger-500/40 bg-danger-500/8 p-4">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold text-danger-400">
|
||||
<FileWarning className="size-4 shrink-0" />
|
||||
{problems.length === 1 ? 'Jeden skript se nenačetl' : `${problems.length} skriptů se nenačetlo`}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-2 text-sm text-white/70">
|
||||
{problems.map((problem) => (
|
||||
<li key={problem.file}>
|
||||
<code className="text-white/90">{problem.file}</code>: {problem.message}
|
||||
{problem.issues && problem.issues.length > 0 && (
|
||||
<ul className="mt-1 ml-4 list-disc text-xs text-white/50">
|
||||
{problem.issues.map((issue) => (
|
||||
<li key={`${issue.field}-${issue.message}`}>
|
||||
{issue.field}: {issue.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- detail
|
||||
|
||||
function ScriptPanel({
|
||||
scriptId,
|
||||
canEdit,
|
||||
onSaved,
|
||||
}: {
|
||||
scriptId: string;
|
||||
canEdit: boolean;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [detail, setDetail] = useState<ScriptDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
apiFetch<ScriptDetail>(`/api/dashboard/scripts/${scriptId}`)
|
||||
.then(setDetail)
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'Skript se nepodařilo načíst.');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [scriptId]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
return (
|
||||
<DataState loading={loading} error={error} onRetry={load}>
|
||||
{detail && (
|
||||
<div className="space-y-5">
|
||||
<div className="glass rounded-card p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-white">{detail.manifest?.name ?? detail.id}</h2>
|
||||
<p className="mt-1 font-mono text-xs text-white/35">{detail.id}.js</p>
|
||||
</div>
|
||||
<ConnectionBadge connection={detail.connection} />
|
||||
</div>
|
||||
|
||||
{detail.manifest && (
|
||||
<p className="mt-3 text-sm text-white/55">{detail.manifest.description}</p>
|
||||
)}
|
||||
|
||||
{detail.problem && (
|
||||
<p className="mt-3 flex items-start gap-2 rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm text-danger-400">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
{detail.problem.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!detail.connection.ready && (
|
||||
<p className="mt-3 text-sm text-warn-400">
|
||||
Napojení není nastavené, skript nepůjde spustit. Chybí:{' '}
|
||||
<code>{detail.connection.missing.join(', ')}</code>. Nastavte je jako
|
||||
proměnné aplikace v AppFactory.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail.manifest && (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<FieldTable title="Vstupní parametry" fields={detail.manifest.inputs} />
|
||||
<FieldTable title="Výstupní parametry" fields={detail.manifest.outputs} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.manifest && canEdit && (
|
||||
<TestPanel
|
||||
scriptId={detail.id}
|
||||
fields={detail.manifest.inputs}
|
||||
ready={detail.connection.ready}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CodeEditor
|
||||
scriptId={detail.id}
|
||||
code={detail.code}
|
||||
canEdit={canEdit}
|
||||
onSaved={() => {
|
||||
load();
|
||||
onSaved();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DataState>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionBadge({ connection }: { connection: ConnectionStatus }) {
|
||||
return (
|
||||
<div className="text-right">
|
||||
<Badge tone={connection.ready ? 'ok' : 'warn'}>
|
||||
{connection.ready ? 'Napojení připravené' : 'Napojení nenastavené'}
|
||||
</Badge>
|
||||
<p className="mt-1 font-mono text-xs break-all text-white/30">{connection.baseUrl}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabulka parametru. Zamerne jedna komponenta pro vstupy i vystupy - je to
|
||||
* tentyz tvar dat a dve skoro stejne tabulky by se rozesly.
|
||||
*/
|
||||
function FieldTable({ title, fields }: { title: string; fields: ScriptField[] }) {
|
||||
return (
|
||||
<div className="glass rounded-card p-5">
|
||||
<p className="mb-3 text-xs font-semibold tracking-wide text-white/45 uppercase">{title}</p>
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<p className="text-sm text-white/30">Žádné.</p>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{fields.map((field) => (
|
||||
<li key={field.id} className="border-b border-ink-600/40 pb-2.5 last:border-0 last:pb-0">
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<code className="text-white">{field.id}</code>
|
||||
<span className="text-xs text-white/35">{field.type}</span>
|
||||
{field.required && <Badge tone="warn">povinné</Badge>}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-white/55">{field.label}</p>
|
||||
{field.hint && <p className="mt-0.5 text-xs text-white/35">{field.hint}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- test
|
||||
|
||||
/**
|
||||
* Jedno vstupni pole podle deklarovaneho typu. Diky tomu neni testovaci
|
||||
* formular rucne psany pro kazdy skript, ale vznika z manifestu.
|
||||
*/
|
||||
function FieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: ScriptField;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const className =
|
||||
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
|
||||
|
||||
const control = (): ReactNode => {
|
||||
if (field.options) {
|
||||
return (
|
||||
<select className={className} value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">Nevyplněno</option>
|
||||
{field.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<select className={className} value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">Nevyplněno</option>
|
||||
<option value="true">Ano</option>
|
||||
<option value="false">Ne</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.multiline) {
|
||||
return (
|
||||
<textarea
|
||||
rows={3}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-white/70">
|
||||
{field.label}
|
||||
{field.required && <span className="text-warn-400"> *</span>}
|
||||
</span>
|
||||
{control()}
|
||||
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TestPanel({
|
||||
scriptId,
|
||||
fields,
|
||||
ready,
|
||||
}: {
|
||||
scriptId: string;
|
||||
fields: ScriptField[];
|
||||
ready: boolean;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [result, setResult] = useState<ScriptRunResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
async function handleRun() {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
// Prazdne hodnoty se neposilaji, aby se uplatnily vychozi hodnoty z manifestu.
|
||||
const inputs = Object.fromEntries(
|
||||
Object.entries(values).filter(([, value]) => value !== ''),
|
||||
);
|
||||
setResult(await apiFetch<ScriptRunResult>(`/api/dashboard/scripts/${scriptId}/test`, {
|
||||
method: 'POST',
|
||||
body: { inputs },
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : 'Zkušební spuštění se nepodařilo odeslat.';
|
||||
setError(message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass rounded-card p-5">
|
||||
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
Zkušební spuštění
|
||||
</p>
|
||||
<p className="mt-1 mb-4 text-sm text-warn-400">
|
||||
Volá se opravdová služba. Co skript založí, opravdu vznikne.
|
||||
</p>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{fields.map((field) => (
|
||||
<FieldInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={values[field.id] ?? ''}
|
||||
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<Button size="sm" onClick={handleRun} disabled={running || !ready}>
|
||||
<Play className="size-4" />
|
||||
{running ? 'Spouštím...' : 'Spustit'}
|
||||
</Button>
|
||||
{!ready && <span className="text-sm text-white/40">Nejdřív nastavte napojení.</span>}
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
|
||||
{result && <RunResult result={result} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunResult({ result }: { result: ScriptRunResult }) {
|
||||
return (
|
||||
<div className="mt-4 space-y-3 border-t border-ink-600/50 pt-4">
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm font-semibold',
|
||||
result.ok ? 'text-ok-400' : 'text-danger-400',
|
||||
)}
|
||||
>
|
||||
{result.ok ? (
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
)}
|
||||
{result.ok ? 'Proběhlo' : 'Selhalo'}
|
||||
<span className="font-normal text-white/40">
|
||||
{result.durationMs} ms, volání služby: {result.httpCalls}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{result.error && (
|
||||
<div className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
|
||||
<p className="text-danger-400">{result.error.message}</p>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
Druh: {result.error.kind}
|
||||
{result.error.status ? `, HTTP ${result.error.status}` : ''},{' '}
|
||||
{result.error.retryable ? 'má smysl zkusit znovu' : 'opakování nepomůže'}
|
||||
</p>
|
||||
{result.error.issues && result.error.issues.length > 0 && (
|
||||
<ul className="mt-2 ml-4 list-disc text-xs text-white/60">
|
||||
{result.error.issues.map((issue) => (
|
||||
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{result.error.detail && (
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-ink-950/60 p-2 text-xs text-white/50">
|
||||
{result.error.detail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.ok && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
Výstupy
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(result.outputs).map(([key, value]) => (
|
||||
<li key={key} className="flex flex-wrap gap-2">
|
||||
<code className="text-brand-200">{key}</code>
|
||||
<span className="text-white/70">{value === null ? '-' : String(value)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.logs.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
Průběh
|
||||
</p>
|
||||
<ul className="space-y-1 font-mono text-xs text-white/50">
|
||||
{result.logs.map((entry, index) => (
|
||||
<li key={`${entry.at}-${index}`}>
|
||||
{entry.message}
|
||||
{entry.detail && <span className="text-white/30"> {entry.detail}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- editor
|
||||
|
||||
function CodeEditor({
|
||||
scriptId,
|
||||
code,
|
||||
canEdit,
|
||||
onSaved,
|
||||
}: {
|
||||
scriptId: string;
|
||||
code: string;
|
||||
canEdit: boolean;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(code);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [issues, setIssues] = useState<Array<{ field: string; message: string }>>([]);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Po nacteni jineho skriptu nebo po ulozeni se koncept srovna se serverem.
|
||||
useEffect(() => {
|
||||
setDraft(code);
|
||||
setIssues([]);
|
||||
setError(null);
|
||||
}, [code]);
|
||||
|
||||
const dirty = draft !== code;
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setIssues([]);
|
||||
setSaved(false);
|
||||
try {
|
||||
await apiFetch(`/api/dashboard/scripts/${scriptId}`, { method: 'PUT', body: { code: draft } });
|
||||
setSaved(true);
|
||||
onSaved();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
|
||||
// Server posila v `issues`, co presne v manifestu nesedi.
|
||||
if (err instanceof ApiError) setIssues(err.issues());
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass rounded-card p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="flex items-center gap-2 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
<Code2 className="size-3.5" />
|
||||
Kód skriptu
|
||||
</p>
|
||||
|
||||
{canEdit ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{dirty && <span className="text-xs text-warn-400">Neuložené změny</span>}
|
||||
{saved && !dirty && <span className="text-xs text-ok-400">Uloženo</span>}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setDraft(code)}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Zahodit
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
|
||||
<Save className="size-4" />
|
||||
{saving ? 'Ukládám...' : 'Uložit'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-white/35">Upravovat smí jen správce platformy.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
spellCheck={false}
|
||||
readOnly={!canEdit}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
className="h-96 w-full resize-y rounded-xl border border-ink-600/70 bg-ink-950/70 p-4 font-mono text-xs leading-relaxed text-white/85 focus:border-brand-400/70 focus:outline-none"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
|
||||
<p className="text-danger-400">{error}</p>
|
||||
{issues.length > 0 && (
|
||||
<ul className="mt-2 ml-4 list-disc text-xs text-white/60">
|
||||
{issues.map((issue) => (
|
||||
<li key={`${issue.field}-${issue.message}`}>
|
||||
{issue.field}: {issue.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
Nic se neuložilo, původní skript funguje dál.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,6 +190,12 @@ export interface ConnectorOperation {
|
||||
inputs?: OperationField[];
|
||||
/** Jen u akci: co krok vrati dalsim krokum. Podminka se na to muze ptat. */
|
||||
outputFields?: TriggerField[];
|
||||
/**
|
||||
* `script` = operaci obsluhuje skript, tedy se opravdu vykona.
|
||||
* Kdyz chybi, je to zatim jen zapis v katalogu.
|
||||
*/
|
||||
implementation?: 'script';
|
||||
scriptId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,3 +359,94 @@ export interface LayoutResponse {
|
||||
/** false = uzivatel kouka na vychozi rozlozeni, nic si neulozil. */
|
||||
custom: boolean;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- skripty konektoru
|
||||
|
||||
/**
|
||||
* Parametr skriptu. Stejny tvar pro vstup i vystup.
|
||||
* Zdroj pravdy je manifest na serveru, viz src/scripts/types.ts.
|
||||
*/
|
||||
export interface ScriptField {
|
||||
id: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
hint?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
pattern?: string;
|
||||
multiline?: boolean;
|
||||
default?: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export interface ScriptManifest {
|
||||
id: string;
|
||||
connectorId: string;
|
||||
operationId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
inputs: ScriptField[];
|
||||
outputs: ScriptField[];
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ScriptProblem {
|
||||
file: string;
|
||||
scriptId: string | null;
|
||||
message: string;
|
||||
issues?: Array<{ field: string; message: string }>;
|
||||
}
|
||||
|
||||
/** Stav napojeni. Hodnoty pristupovych udaju se ze serveru nikdy nevraci. */
|
||||
export interface ConnectionStatus {
|
||||
connectorId: string;
|
||||
baseUrl: string;
|
||||
ready: boolean;
|
||||
/** Jmena chybejicich environment variables. */
|
||||
missing: string[];
|
||||
headers: string[];
|
||||
}
|
||||
|
||||
export interface ScriptCatalog {
|
||||
items: ScriptManifest[];
|
||||
problems: ScriptProblem[];
|
||||
connections: ConnectionStatus[];
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface ScriptDetail {
|
||||
id: string;
|
||||
connectorId: string;
|
||||
operationId: string;
|
||||
/** null = soubor je rozbity, manifest se nepodarilo precist. */
|
||||
manifest: ScriptManifest | null;
|
||||
code: string;
|
||||
problem: ScriptProblem | null;
|
||||
connection: ConnectionStatus;
|
||||
}
|
||||
|
||||
export type ScriptErrorKind =
|
||||
| 'not_found'
|
||||
| 'config'
|
||||
| 'validation'
|
||||
| 'output'
|
||||
| 'retryable'
|
||||
| 'terminal'
|
||||
| 'timeout'
|
||||
| 'internal';
|
||||
|
||||
export interface ScriptRunResult {
|
||||
ok: boolean;
|
||||
scriptId: string;
|
||||
outputs: Record<string, string | number | boolean | null>;
|
||||
logs: Array<{ at: string; message: string; detail?: string }>;
|
||||
durationMs: number;
|
||||
httpCalls: number;
|
||||
error: {
|
||||
kind: ScriptErrorKind;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
status?: number;
|
||||
detail?: string;
|
||||
issues?: Array<{ field: string; message: string }>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user