Fronta a worker: webhook odpovi hned, praci udelaji workeri
Webhook uz nic nevykonava v requestu. Zapise udalost do fronty a odpovi 202 do jednotek milisekund; strom vykona worker na pozadi. Za konektory nerucime, takze cekat na cizi sluzbu v requestu znamena ztracet udalosti pri timeoutu. Fronta ma opakovani s rostouci prodlevou (30 s, 2 min, 10 min, hodina), spravedlive poradi po firmach (jedna firma s tisicem udalosti nezablokuje ostatni), navrat zaseknutych behu po restartu a uklid hotovych. Marna chyba se neopakuje - chybejici skript za minutu existovat nezacne. Tri druhy spoustecu: push (webhook), vnitrni udalost (vznik a zmena ticketu) a pull, tedy pravidelne dotazovani u sluzeb bez webhooku (posta, zpravy). Planovac jen rekne "je cas", samotny dotaz je prvni krok stromu, takze ma zaznam v logu a opakuje se pri chybe jako cokoliv jineho. Kontrakt tela webhooku: kazdy parametr ma cestu (data.order.id, errors.0.message), takze jde napojit i odesilatel s vnorenym modelem. U adresy je metoda, ukazka tela a kopiruje se cela adresa vcetne domeny. Vnitrni kroky, ktere sahaji do naseho uloziste: ticket/upsert (zaloz nebo dopln podle externiho ID), assign-least-busy, assign-by-external, set-type, set-stage, add-tags, set-status, incident/create, flow/pause a flow/log. Faze ticketu jako treti osa vedle stavu a stitku. Stav je zivotni cyklus a pocitaji se z nej statistiky, faze je workflow daneho typu a muze byt jen jedna, takze se na ni da spolehnout v podmince. ID z cizich aplikaci u resitele: voicebot posle voicebotId a ticket skonci u toho, komu patri. Vazba je na jednom miste, ne v kazde automatizaci. Kazda chyba zaklada incident se dvema urovnemi: impact cte klient a je srozumitelny, detail cte admin a je v nem cely beh, ktery krok selhal, cele hlaseni a data na vstupu. Detail vidi jen spravce platformy. Ochrana proti smycce: automatizace navazana na zmenu ticketu ticket meni, cimz se spousti znovu - pri vyvoji to server polozilo. Resi to oznaceni behu pres AsyncLocalStorage a strop peti behu na jeden ticket za minutu. Upozorneni pri prideleni prace vcetne cisla u zalozky Tickety. Zivy dashboard: dlazdice nad nasimi daty na udalost, data z konektoru podle ttlSec s moznosti vynutit nacteni znovu. Opraveno: path a intervalSec u spoustece se pri ulozeni zahazovaly; nad seznamem neslo pouzit contains, takze na stitky neslo postavit podminku; novejsi vystup kroku ted prekryje starsi misto hlaseni konfliktu. Overeno dvema scenari proti bezicimu serveru, 34 kontrol: firma se skladem, expedici a IT, a hovory z voicebota (callSid do externiho ID, status do faze, prirazeni podle voicebotId, tri zpravy = jeden ticket se tremi udalostmi). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5d186dcd2e
commit
a57eca123e
@@ -74,6 +74,15 @@ function accessLabel(user: AuthUser | null, access: Access | null): string {
|
||||
function DashboardShell() {
|
||||
const { user, logout } = useAuth();
|
||||
const access = useApiQuery<Access>('/api/dashboard/access');
|
||||
|
||||
/*
|
||||
* Cislo u zalozky Tickety a upozorneni. Obnovuje se na udalost, ne casovacem -
|
||||
* kdyz nekomu prijde ticket, ma to videt hned, ne za minutu.
|
||||
*/
|
||||
const notifications = useApiQuery<{ unread: number; mine: number }>(
|
||||
'/api/dashboard/notifications',
|
||||
{ refetchOn: ['notification.created', 'ticket.assigned', 'ticket.resolved'] },
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
@@ -128,7 +137,19 @@ function DashboardShell() {
|
||||
}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{entry.label}
|
||||
<span className="flex-1">{entry.label}</span>
|
||||
{/*
|
||||
Kolik ticketu ma prihlaseny u sebe. Nula se nekresli - prazdny
|
||||
odznak je jen sum.
|
||||
*/}
|
||||
{entry.key === 'tickets' && (notifications.data?.mine ?? 0) > 0 && (
|
||||
<span
|
||||
className="grid min-w-5 place-items-center rounded-full bg-brand-500/20 px-1.5 text-xs font-semibold text-brand-200"
|
||||
title="Kolik ticketů máte u sebe"
|
||||
>
|
||||
{notifications.data?.mine}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlarmClock, CheckCircle2, LifeBuoy, UserCheck, Webhook, Workflow, X } from 'lucide-react';
|
||||
import { AlarmClock, Bell, CheckCircle2, LifeBuoy, UserCheck, Webhook, Workflow, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
|
||||
@@ -22,6 +22,7 @@ const style: Record<DashboardEventType, { icon: LucideIcon; tone: string }> = {
|
||||
'automation.deleted': { icon: Workflow, tone: 'text-white/50 bg-white/5' },
|
||||
'automation.run': { icon: Workflow, tone: 'text-ok-400 bg-ok-500/12' },
|
||||
'webhook.received': { icon: Webhook, tone: 'text-accent-300 bg-accent-500/12' },
|
||||
'notification.created': { icon: Bell, tone: 'text-brand-300 bg-brand-500/12' },
|
||||
};
|
||||
|
||||
/** Bubliny o tom, co se prave stalo. Bez nich by zive zmeny nebyly poznat. */
|
||||
|
||||
@@ -42,6 +42,7 @@ export function TriggerConfig({
|
||||
<WebhookAddress
|
||||
token={trigger.webhookToken}
|
||||
baseUrl={webhookBaseUrl}
|
||||
fields={trigger.fields}
|
||||
onRegenerate={onRegenerateToken}
|
||||
regenerating={regenerating}
|
||||
/>
|
||||
@@ -151,6 +152,21 @@ function CustomFields({
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/*
|
||||
Cesta v tele. Prazdna = hodnota lezi primo pod nazvem.
|
||||
Bez toho by slo napojit jen ploche telo a odesilatel
|
||||
s vnorenym modelem by se musel prizpusobovat nam.
|
||||
*/}
|
||||
<input
|
||||
value={field.path ?? ''}
|
||||
onChange={(event) =>
|
||||
updateField(field.id, { path: event.target.value.trim() || undefined })
|
||||
}
|
||||
placeholder="cesta v těle, např. data.order.id"
|
||||
title="Kde hodnota v přijatém JSONu je. Prázdné = přímo pod názvem."
|
||||
className="min-w-40 flex-1 rounded-lg border border-ink-600/70 bg-ink-900/70 px-2.5 py-1.5 font-mono text-xs text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none"
|
||||
/>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-white/55">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -226,14 +242,63 @@ function ProvidedFields({
|
||||
}
|
||||
|
||||
/** Registrovana adresa webhooku - token generuje server, tady se jen ukazuje. */
|
||||
/**
|
||||
* Ukazkove telo podle deklarovanych parametru.
|
||||
*
|
||||
* Respektuje cestu: kdyz je parametr `orderId` na `data.order.id`, ukazka ho
|
||||
* ukaze zanoreny presne tam. Jinak by odesilatel poslal plocho a nic by nesedelo.
|
||||
*/
|
||||
function exampleBody(fields: TriggerField[]): string {
|
||||
if (fields.length === 0) return '{\n "cokoliv": "co pošle odesílatel"\n}';
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const path = (field.path ?? field.name).split('.');
|
||||
let target = body;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
const next = path[i + 1];
|
||||
// Cislo v ceste znamena pole, ne objekt s klicem "0".
|
||||
if (target[key] === undefined) target[key] = /^\d+$/.test(next) ? [] : {};
|
||||
target = target[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
target[path[path.length - 1]] = exampleValue(field.type);
|
||||
}
|
||||
|
||||
return JSON.stringify(body, null, 2);
|
||||
}
|
||||
|
||||
function exampleValue(type: TriggerField['type']): unknown {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return 42;
|
||||
case 'boolean':
|
||||
return true;
|
||||
case 'date':
|
||||
return '2026-01-31';
|
||||
case 'object':
|
||||
return { klic: 'hodnota' };
|
||||
case 'list':
|
||||
return ['první', 'druhá'];
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
function WebhookAddress({
|
||||
token,
|
||||
baseUrl,
|
||||
fields,
|
||||
onRegenerate,
|
||||
regenerating,
|
||||
}: {
|
||||
token: string | undefined;
|
||||
baseUrl: string;
|
||||
/** Deklarovane parametry. Podle nich se sklada ukazka tela. */
|
||||
fields: TriggerField[];
|
||||
onRegenerate: () => void;
|
||||
regenerating: boolean;
|
||||
}) {
|
||||
@@ -251,7 +316,14 @@ function WebhookAddress({
|
||||
);
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/${token}`;
|
||||
/*
|
||||
* Cela adresa vcetne domeny. Server zna `PUBLIC_ORIGIN` jen kdyz je nastavena,
|
||||
* jinak posle cestu bez domeny - a takovou adresu nejde vlozit do cizi
|
||||
* aplikace. Domenu proto doplni prohlizec, ktery ji zna vzdycky.
|
||||
*/
|
||||
const url = baseUrl.startsWith('http')
|
||||
? `${baseUrl}/${token}`
|
||||
: `${window.location.origin}${baseUrl}/${token}`;
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
@@ -293,6 +365,28 @@ function WebhookAddress({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Ukazka tela podle deklarovanych parametru. Bez ni musi ten, kdo webhook
|
||||
zapojuje, hadat, jak ma JSON vypadat - a hadat se bude spatne.
|
||||
*/}
|
||||
<div className="mt-3">
|
||||
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
Ukázka požadavku
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-lg border border-ink-600/70 bg-ink-900 p-3 font-mono text-xs leading-relaxed text-white/70">
|
||||
{`POST ${url}
|
||||
Content-Type: application/json
|
||||
|
||||
${exampleBody(fields)}`}
|
||||
</pre>
|
||||
{fields.length === 0 && (
|
||||
<p className="mt-1.5 text-xs text-white/35">
|
||||
Zatím nejsou nadeklarované parametry, takže se přijme jakékoliv tělo.
|
||||
Doplňte je výš, ať je jasné, co má odesílatel poslat.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-2.5 text-xs leading-relaxed text-white/45">
|
||||
Token v adrese je jediná ochrana - kdo ji zná, může automatizaci spustit.
|
||||
Nesdílejte ji veřejně a neposílejte ji v odkazech.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertTriangle, ArrowDownRight, ArrowUpRight, Clock, RotateCcw } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowDownRight, ArrowUpRight, Clock, RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
@@ -21,11 +21,14 @@ export function CustomWidgetCard({
|
||||
value,
|
||||
error,
|
||||
loading,
|
||||
onRefresh,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
value: WidgetValue | undefined;
|
||||
error: string | undefined;
|
||||
loading: boolean;
|
||||
/** Vyzada cerstva data z konektoru. U dlazdic nad nasimi daty nema smysl. */
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
if (loading && !value && !error) {
|
||||
return (
|
||||
@@ -60,18 +63,20 @@ export function CustomWidgetCard({
|
||||
}
|
||||
|
||||
if (value.kind === 'external') {
|
||||
const live = { at: value.at, stale: value.stale, ttlSec: value.ttlSec, onRefresh };
|
||||
|
||||
if (value.value !== null && value.rows.length === 0) {
|
||||
return <Number definition={definition} value={value.value} at={value.at} stale={value.stale} />;
|
||||
return <Number definition={definition} value={value.value} {...live} />;
|
||||
}
|
||||
if (value.rows.length > 0) {
|
||||
return (
|
||||
<Shell definition={definition} at={value.at} stale={value.stale}>
|
||||
<Shell definition={definition} {...live}>
|
||||
<Bars rows={value.rows} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Shell definition={definition} at={value.at} stale={value.stale}>
|
||||
<Shell definition={definition} {...live}>
|
||||
<p className="text-sm break-words text-white/70">{value.text ?? 'Bez dat.'}</p>
|
||||
</Shell>
|
||||
);
|
||||
@@ -294,12 +299,16 @@ function Number({
|
||||
value,
|
||||
at,
|
||||
stale,
|
||||
ttlSec,
|
||||
onRefresh,
|
||||
href,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
value: number | null;
|
||||
at?: string;
|
||||
stale?: boolean;
|
||||
ttlSec?: number;
|
||||
onRefresh?: () => void;
|
||||
href?: string;
|
||||
}) {
|
||||
const target = definition.target;
|
||||
@@ -317,7 +326,14 @@ function Number({
|
||||
);
|
||||
|
||||
return (
|
||||
<Shell definition={definition} at={at} stale={stale} href={href}>
|
||||
<Shell
|
||||
definition={definition}
|
||||
at={at}
|
||||
stale={stale}
|
||||
ttlSec={ttlSec}
|
||||
onRefresh={onRefresh}
|
||||
href={href}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
{href ? (
|
||||
<Link to={href} className="group">
|
||||
@@ -388,12 +404,16 @@ function Shell({
|
||||
definition,
|
||||
at,
|
||||
stale,
|
||||
ttlSec,
|
||||
onRefresh,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
at?: string;
|
||||
stale?: boolean;
|
||||
ttlSec?: number;
|
||||
onRefresh?: () => void;
|
||||
/** Kam vede nadpis. Bez nej je to jen text. */
|
||||
href?: string;
|
||||
children: React.ReactNode;
|
||||
@@ -418,12 +438,29 @@ function Shell({
|
||||
</div>
|
||||
{/* U dat z cizi sluzby je stari podstatne - jinak se ctou cisla z vcerejska. */}
|
||||
{at && (
|
||||
<span
|
||||
className="shrink-0 text-xs text-white/30"
|
||||
title={stale ? 'Z mezipaměti, aby se cizí služba nevolala při každém otevření.' : undefined}
|
||||
>
|
||||
{stale ? 'z mezipaměti ' : ''}
|
||||
{formatRelative(at)}
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<span
|
||||
className="text-xs text-white/30"
|
||||
title={
|
||||
ttlSec
|
||||
? `Data z konektoru se drží ${ttlSec} s, aby se cizí služba nevolala při každém otevření.`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{stale ? 'z mezipaměti ' : ''}
|
||||
{formatRelative(at)}
|
||||
</span>
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
aria-label="Načíst znovu z konektoru"
|
||||
title="Načíst znovu z konektoru"
|
||||
className="grid size-6 place-items-center rounded-md text-white/30 transition-colors hover:bg-white/8 hover:text-brand-200"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -173,7 +173,13 @@ export function collectScopes(
|
||||
}
|
||||
|
||||
const outputs = outputsOf(step);
|
||||
if (outputs.length > 0) available = [...available, ...outputs];
|
||||
if (outputs.length > 0) {
|
||||
// Novejsi vystup prekryje starsi se stejnym jmenem, stejne jako to
|
||||
// dela runtime pri dosazovani do sablon. Musi to sedet se serverem,
|
||||
// viz src/data/flowScope.ts.
|
||||
const names = new Set(outputs.map((output) => output.name));
|
||||
available = [...available.filter((field) => !names.has(field.name)), ...outputs];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -120,15 +120,67 @@ export default function Overview() {
|
||||
{
|
||||
body: { widgetIds: computedIds },
|
||||
enabled: computedIds.length > 0,
|
||||
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
|
||||
/*
|
||||
* Data z nasich dat se prekresli hned po udalosti. Data z konektoru
|
||||
* ne - ta drzi server v mezipameti podle `ttlSec` u widgetu, aby se
|
||||
* cizi sluzba nevolala pri kazdem hnuti.
|
||||
*/
|
||||
refetchOn: [
|
||||
'ticket.created',
|
||||
'ticket.updated',
|
||||
'ticket.assigned',
|
||||
'ticket.resolved',
|
||||
'automation.run',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
/*
|
||||
* Tikot pro dlazdice nad konektory.
|
||||
*
|
||||
* Server drzi vysledek `ttlSec` a rekne, kdy bude novy (`nextAt`). Prehled
|
||||
* se po te dobe zeptá znovu - dokud je mezipamet cerstva, dostane ji zpatky
|
||||
* bez volani cizi sluzby, takze to nic nestoji.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const times = (widgetData.data?.items ?? [])
|
||||
.map((item) => (item.ok && item.value?.kind === 'external' ? item.value.nextAt : null))
|
||||
.filter((value): value is string => value !== null)
|
||||
.map((value) => new Date(value).getTime() - Date.now());
|
||||
|
||||
if (times.length === 0) return;
|
||||
// Nejblizsi obnova rozhoduje, ostatni dostanou svou pri dalsim kole.
|
||||
const wait = Math.max(Math.min(...times), 5_000);
|
||||
|
||||
const timer = setTimeout(() => widgetData.reload(), wait);
|
||||
return () => clearTimeout(timer);
|
||||
}, [widgetData.data, widgetData.reload]);
|
||||
|
||||
const computed = useMemo(
|
||||
() => new Map((widgetData.data?.items ?? []).map((item) => [item.id, item])),
|
||||
[widgetData.data],
|
||||
);
|
||||
|
||||
/**
|
||||
* Rucni obnova dat z konektoru.
|
||||
*
|
||||
* `force` obejde mezipamet serveru. Zamerne to neni vychozi chovani -
|
||||
* kazde otevreni prehledu by pak znamenalo volani cizi sluzby za kazdou
|
||||
* dlazdici a to ma limity.
|
||||
*/
|
||||
async function refreshExternal() {
|
||||
try {
|
||||
await apiFetch(`/api/dashboard/widget-data${scopeQuery}`, {
|
||||
method: 'POST',
|
||||
body: { widgetIds: computedIds, force: true },
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[prehled] obnova z konektoru selhala:', err);
|
||||
} finally {
|
||||
widgetData.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- rezim uprav
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -384,6 +436,7 @@ export default function Overview() {
|
||||
value={result?.ok ? result.value : undefined}
|
||||
error={result && !result.ok ? result.error : (widgetData.error ?? undefined)}
|
||||
loading={widgetData.loading}
|
||||
onRefresh={() => void refreshExternal()}
|
||||
/>
|
||||
) : (
|
||||
<WidgetCard definition={definition} data={data} />
|
||||
|
||||
@@ -145,6 +145,12 @@ export default function Settings() {
|
||||
initial: 8,
|
||||
hint: 'Kolik nevyřízených ticketů je pro něj zdravá zátěž. Není to limit.',
|
||||
},
|
||||
{
|
||||
name: 'externalIds',
|
||||
label: 'ID z cizích aplikací',
|
||||
kind: 'tags',
|
||||
hint: 'Oddělená čárkou. Podle nich umí automatizace předat ticket právě jemu, například voicebotId.',
|
||||
},
|
||||
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -104,6 +104,14 @@ export interface Ticket {
|
||||
typeId: string | null;
|
||||
/** Hodnoty vlastnich poli daneho typu. */
|
||||
fields: Record<string, string | number | boolean | null>;
|
||||
/**
|
||||
* Faze ve workflow typu, napr. "ceka na zabaleni".
|
||||
*
|
||||
* Tri osy: `status` je zivotni cyklus (podle nej se pocitaji statistiky),
|
||||
* `stage` je postup uvnitr typu a definuje si ho firma, `tags` jsou volne
|
||||
* stitky. Faze muze byt jen jedna, proto se na ni da spolehnout v podmince.
|
||||
*/
|
||||
stage: string | null;
|
||||
/** Volne tagy. Akce se muze vazat na typ **nebo** na tag. */
|
||||
tags: string[];
|
||||
/** Kdy se ticketu prvne nekdo venoval. */
|
||||
@@ -409,6 +417,11 @@ export interface TriggerField {
|
||||
name: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
/**
|
||||
* Kde ta hodnota v tele je, kdyz to neni primo `name`.
|
||||
* Napriklad `data.order.id` nebo `errors.0.message`.
|
||||
*/
|
||||
path?: string;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- strom automatizace
|
||||
@@ -529,6 +542,10 @@ export type WidgetValue =
|
||||
rows: Array<{ key: string; label: string; value: number }>;
|
||||
at: string;
|
||||
stale: boolean;
|
||||
/** Kdy se data z konektoru natáhnou znovu. */
|
||||
nextAt: string;
|
||||
/** Jak dlouho se drzi, v sekundach. Nastavuje se u widgetu. */
|
||||
ttlSec: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,9 @@ export type DashboardEventType =
|
||||
| 'automation.updated'
|
||||
| 'automation.deleted'
|
||||
| 'automation.run'
|
||||
| 'webhook.received';
|
||||
| 'webhook.received'
|
||||
/** Nekomu prislo upozorneni. Portal podle toho prekresli cislo u zalozky. */
|
||||
| 'notification.created';
|
||||
|
||||
export interface DashboardEvent {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user