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:
co-authored by
Claude Opus 5
parent
99561c4682
commit
e4779caf82
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user