Vlastni skripty firmy: prevod dat v JS, v logu vstup i vystup

Klikaci pravidla jsou u peti poli rychlejsi, ale u modelu objednavky je jich
dvacet a v tom se necte. Vedle nich proto skript firmy: prevod z A do B
napsany v JS, jeden na zakaznika.

- Skripty se ukladaji do uloziste, ne na disk. Disk je uvnitr kontejneru
  a redeploy ho vymaze.
- Krok Transformace dat - Vlastni skript. Vysledek jde dal jako krok.result.
- V logu ticketu je u kroku vstup i vystup. Prave to byl duvod, proc skript
  nad pravidly vyhral.
- Zkouska bez ulozeni: v portalu se vlepi skutecne telo a hned je videt, co
  z toho leze.
- Skripty jsou v zalozce Akce, vedle definic akci. Obojí je popis toho, co
  aplikace ve firme umi, a spravuje to tentyz clovek.

Skript je ciste prevod hodnot: dostane input, vrati objekt. Nema require,
import, process, fetch ani console, bezi nejvys 2 s a vysledek se vejde do
256 kB. node:vm neni bezpecnostni hranice proti nekomu, kdo se chce dostat
ven - je to izolace proti nehode a proti zacykleni.

Pri zkousce se ukazalo, ze casovy limit nepokryval samotny beh: runInContext
jen vyrobil funkci a zavolat ji zvenku znamenalo, ze while (true) uvnitr
zablokovalo proces navzdy. Kod se ted vola uvnitr runInContext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-20 08:08:09 +02:00
co-authored by Claude Opus 5
parent 99561c4682
commit e4779caf82
12 changed files with 1063 additions and 6 deletions
@@ -0,0 +1,339 @@
import { AlertTriangle, CheckCircle2, Code2, Play, Plus, Save, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
/**
* Skripty firmy: prevod dat z A do B napsany v JS.
*
* Proc vedle klikacich pravidel: u slozitejsiho prevodu je kod citelnejsi nez
* dvacet pravidel, a hlavne ho jde prectе i za pul roku. U peti poli je naopak
* vyber ze seznamu rychlejsi.
*
* Skript **nic nevola ven**. Dostane objekt, vrati objekt. Proto se da zkusit
* hned tady na skutecnem tele, bez toho, aby se neco odeslalo.
*/
interface ScriptRecord {
id: string;
name: string;
description: string;
code: string;
enabled: boolean;
lastRunAt: string | null;
lastRunOk: boolean | null;
updatedAt: string;
}
interface ScriptsResponse {
items: ScriptRecord[];
template: string;
maxLength: number;
}
interface TestResult {
ok: boolean;
output: unknown;
error: string | null;
durationMs: number;
}
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
export function TenantScripts() {
const scripts = useApiQuery<ScriptsResponse>('/api/dashboard/tenant-scripts');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState<ScriptRecord | null>(null);
const [sample, setSample] = useState('{\n "data": {}\n}');
const [test, setTest] = useState<TestResult | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const items = useMemo(() => scripts.data?.items ?? [], [scripts.data]);
// Prvni skript se vybere sam, jinak stranka po nacteni vypada prazdne.
useEffect(() => {
if (selectedId === null && items.length > 0) setSelectedId(items[0].id);
}, [items, selectedId]);
useEffect(() => {
const found = items.find((item) => item.id === selectedId);
setDraft(found ? { ...found } : null);
setTest(null);
}, [items, selectedId]);
const dirty =
draft !== null &&
JSON.stringify(draft) !== JSON.stringify(items.find((item) => item.id === draft.id));
async function create() {
setBusy(true);
setError(null);
try {
const created = await apiFetch<ScriptRecord>('/api/dashboard/tenant-scripts', {
method: 'POST',
body: {
name: `Převod ${items.length + 1}`,
description: '',
code: scripts.data?.template ?? 'return input;',
},
});
scripts.reload();
setSelectedId(created.id);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Skript se nepodařilo založit.');
} finally {
setBusy(false);
}
}
async function save() {
if (!draft) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/api/dashboard/tenant-scripts/${draft.id}`, {
method: 'PUT',
body: {
name: draft.name,
description: draft.description,
code: draft.code,
enabled: draft.enabled,
},
});
scripts.reload();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Skript se nepodařilo uložit.');
} finally {
setBusy(false);
}
}
async function remove() {
if (!draft) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/api/dashboard/tenant-scripts/${draft.id}`, { method: 'DELETE' });
setSelectedId(null);
scripts.reload();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Skript se nepodařilo smazat.');
} finally {
setBusy(false);
}
}
/**
* Zkousi se **rozepsany kod**, ne ulozeny.
*
* Tohle je duvod, proc se skript pise tady a ne do souboru: napisu prevod,
* vlepim skutecne telo a hned vidim, co z toho leze.
*/
async function runTest() {
if (!draft) return;
setBusy(true);
setError(null);
try {
const parsed = JSON.parse(sample.trim() === '' ? '{}' : sample);
const result = await apiFetch<TestResult>('/api/dashboard/tenant-scripts/test', {
method: 'POST',
body: { code: draft.code, input: parsed },
});
setTest(result);
} catch (err: unknown) {
setTest(null);
setError(
err instanceof SyntaxError
? `Ukázka není platný JSON: ${err.message}`
: err instanceof Error
? err.message
: 'Zkoušku se nepodařilo spustit.',
);
} finally {
setBusy(false);
}
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<p className="max-w-2xl text-sm text-white/50">
Převod dat napsaný v JavaScriptu. Skript dostane objekt v proměnné{' '}
<code className="text-brand-300/80">input</code> a vrátí objekt přes{' '}
<code className="text-brand-300/80">return</code>. Ve stromu se pak použije krokem
Transformace dat - Vlastní skript. V logu ticketu je u něj vidět vstup i výstup.
</p>
<Button onClick={() => void create()} disabled={busy} size="sm">
<Plus className="size-3.5" />
Nový skript
</Button>
</div>
{error && (
<p className="flex items-start gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{error}
</p>
)}
<DataState
loading={scripts.loading}
error={scripts.error}
onRetry={scripts.reload}
empty={items.length === 0}
emptyLabel="Zatím žádný skript. Hodí se, když je pravidel tolik, že je kód čitelnější."
>
<div className="grid gap-4 lg:grid-cols-[16rem_1fr]">
<ul className="space-y-1.5">
{items.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => setSelectedId(item.id)}
className={cn(
'w-full rounded-xl border px-3 py-2.5 text-left transition-colors',
item.id === selectedId
? 'border-brand-400/50 bg-brand-500/10'
: 'border-ink-600/60 hover:border-brand-400/30',
)}
>
<span className="flex flex-wrap items-center gap-2">
<Code2 className="size-3.5 shrink-0 text-brand-300" />
<span className="min-w-0 truncate text-sm font-medium text-white">
{item.name}
</span>
{!item.enabled && <Badge tone="warn">vypnutý</Badge>}
</span>
{item.lastRunAt && (
<span className="mt-1 block text-xs text-white/35">
naposledy {formatDateTime(item.lastRunAt)}
{item.lastRunOk === false ? ', chybou' : ''}
</span>
)}
</button>
</li>
))}
</ul>
{draft && (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs text-white/55">Název</span>
<input
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
className={inputClass}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-white/55">K čemu to je</span>
<input
value={draft.description}
onChange={(event) => setDraft({ ...draft, description: event.target.value })}
placeholder="Objednávka ze Shoptetu na doklad pro iDoklad"
className={inputClass}
/>
</label>
</div>
<label className="block">
<span className="mb-1 block text-xs text-white/55">
Kód
<span className="ml-2 text-white/30">
k dispozici je input a util.get, util.num, util.day
</span>
</span>
<textarea
value={draft.code}
onChange={(event) => setDraft({ ...draft, code: event.target.value })}
rows={16}
spellCheck={false}
className={cn(inputClass, 'resize-y font-mono text-xs leading-relaxed')}
/>
</label>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void save()} disabled={busy || !dirty} size="sm">
<Save className="size-3.5" />
{dirty ? 'Uložit' : 'Uloženo'}
</Button>
<label className="flex items-center gap-2 text-sm text-white/60">
<input
type="checkbox"
checked={draft.enabled}
onChange={(event) => setDraft({ ...draft, enabled: event.target.checked })}
className="size-4 accent-brand-400"
/>
Zapnutý
</label>
<button
type="button"
onClick={() => void remove()}
disabled={busy}
className="ml-auto flex items-center gap-1.5 text-xs text-white/35 transition-colors hover:text-danger-400"
>
<Trash2 className="size-3.5" />
Smazat
</button>
</div>
<div className="rounded-xl border border-ink-600/50 bg-ink-900/40 p-3">
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">
Zkouška
</p>
<p className="mt-1 text-xs text-white/35">
Vlepte skutečné tělo. Zkouší se rozepsaný kód, nic se neodesílá ven.
</p>
<textarea
value={sample}
onChange={(event) => setSample(event.target.value)}
rows={5}
spellCheck={false}
className={cn(inputClass, 'mt-2 resize-y font-mono text-xs')}
/>
<Button onClick={() => void runTest()} disabled={busy} size="sm" className="mt-2">
<Play className="size-3.5" />
Zkusit
</Button>
{test && (
<div className="mt-3">
<p
className={cn(
'flex items-center gap-1.5 text-xs',
test.ok ? 'text-ok-400' : 'text-danger-400',
)}
>
{test.ok ? (
<CheckCircle2 className="size-3.5" />
) : (
<AlertTriangle className="size-3.5" />
)}
{test.ok ? `Hotovo za ${test.durationMs} ms` : 'Skript spadl'}
</p>
<pre className="mt-1.5 max-h-72 overflow-auto rounded-lg bg-ink-950/70 p-2.5 font-mono text-xs whitespace-pre-wrap text-white/70">
{test.ok ? JSON.stringify(test.output, null, 2) : test.error}
</pre>
</div>
)}
</div>
</div>
)}
</div>
</DataState>
</div>
);
}
+36 -2
View File
@@ -2,6 +2,7 @@ import { GitBranch, Info } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin';
import { TenantScripts } from '@/components/dashboard/TenantScripts';
import { Badge } from '@/components/ui/Badge';
import { apiFetch } from '@/lib/api';
import { usePageMeta } from '@/lib/usePageMeta';
@@ -37,6 +38,12 @@ export default function Actions() {
usePageMeta({ title: 'Akce na ticketu - portál Automia' });
const [types, setTypes] = useState<Array<{ id: string; name: string }>>([]);
/*
* Skripty jsou tu vedle akci zamerne. Obojí je **definice toho, co aplikace
* ve firme umi**, obojí spravuje tentyz clovek pod pravem `action.manage`
* a skript je casto to, co akce nebo automatizace uvnitr pouziva.
*/
const [tab, setTab] = useState<'akce' | 'skripty'>('akce');
useEffect(() => {
apiFetch<{ items: Array<{ id: string; name: string }> }>(
@@ -100,12 +107,37 @@ export default function Actions() {
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Akce na ticketu</h1>
<h1 className="text-2xl font-bold text-white">
{tab === 'akce' ? 'Akce na ticketu' : 'Skripty firmy'}
</h1>
<p className="mt-1 text-sm text-white/50">
Tlačítko, které řešitel zmáčkne na ticketu. Váže se na typ nebo tag.
{tab === 'akce'
? 'Tlačítko, které řešitel zmáčkne na ticketu. Váže se na typ nebo tag.'
: 'Převod dat z jednoho tvaru do druhého, napsaný v JavaScriptu.'}
</p>
</header>
<div className="flex flex-wrap gap-2">
{(['akce', 'skripty'] as const).map((key) => (
<button
key={key}
type="button"
onClick={() => setTab(key)}
className={
tab === key
? 'rounded-lg bg-brand-500/15 px-3 py-1.5 text-sm font-medium text-brand-100'
: 'rounded-lg px-3 py-1.5 text-sm font-medium text-white/50 transition-colors hover:text-white'
}
>
{key === 'akce' ? 'Akce' : 'Skripty'}
</button>
))}
</div>
{tab === 'skripty' && <TenantScripts />}
{tab === 'akce' && (
<>
<p className="flex gap-2 rounded-xl border border-ink-600/60 px-4 py-3 text-sm leading-relaxed text-white/45">
<Info className="mt-0.5 size-4 shrink-0 text-brand-300" />
<span>
@@ -202,6 +234,8 @@ export default function Actions() {
body: undefined,
})}
/>
</>
)}
</div>
);
}