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:
JiriUhlir
2026-08-12 13:37:58 +02:00
co-authored by Claude Opus 5
parent bbc2236c0d
commit 6f6b287d7e
34 changed files with 5546 additions and 14 deletions
+2
View File
@@ -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
View File
@@ -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;
+23 -10
View File
@@ -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>
+668
View File
@@ -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>
);
}
+97
View File
@@ -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;
}