Rozdeleni na sluzby a konektory, pristupove udaje do konektoru

Slovo "konektor" v kodu znamenalo katalog toho, co umime. Ted znamena napojeni
jedne firmy, tedy to, co tim mysli i uzivatel. Popis modelu je
v documentation/12-sluzby-a-konektory.md.

Tri vrstvy:
- Sluzba: ze iDoklad existuje, co umi a co potrebuje k napojeni. Nase.
- Skript: kod, ktery jednu operaci sluzby opravdu vykona. Nas.
- Konektor: ucet firmy vcetne jejich pristupovych udaju. Firemni.

Pristupove udaje se prestaly cist z environment variables. Cela instance by
mela jedny udaje spolecne a dve firmy by fakturovaly z jednoho uctu. Napojeni
je vlastnost firmy, ne prostredi. Z prostredi zustava jen SERVICES_BASE_URL.

Pridano:
- src/data/services.ts: sluzba nese general, appId, visibility, credentials
  a verifyPath. Kategorie "obecne" sdruzuje veci, ktere ma kazdy a nepotrebuji
  konektor: webhook, planovac, tickety, transformace dat, HTTP pozadavek,
  pauza, zapis do logu
- viditelnost sluzby: vsichni, jen uvedene firmy a lide, nebo jen spravce
  platformy. Neviditelna sluzba se z API nevraci vubec, ne se stavem 403 -
  firma nema poznat, ze takova sluzba existuje
- src/data/connectorStore.ts: konektory za firmu vcetne hodnot udaju. Hodnoty
  se z API nikdy nevraci, jen filled a missing. Prazdne pole hodnotu nemeni,
  takze ulozeni formularu bez tajnych hodnot nic nepresepe
- FlowStep.connectorId: krok rika, pod kterym napojenim volat. null = vychozi
  konektor firmy, diky tomu je vzorovy strom prenositelny mezi firmami
- overeni konektoru pres verifyPath, tedy cteci volani vyzadujici autorizaci.
  U sluzby bez nej se overi jen dostupnost a odpoved to rekne nahlas, jinak by
  zeleny vysledek uzivateli lhal
- stranky /dashboard/sluzby a /dashboard/konektory vcetne formularu udaju
- endpointy /api/dashboard/services a CRUD /api/dashboard/connectors ve Swaggeru
- predvyplnene prihlaseni spravcem platformy a prepinac demo uctu na login
  strance, kvuli testovani prototypu

Zmeneno:
- stav "napojeno" se prestal cist z katalogu a zacal pocitat z konektoru firmy.
  Sluzba ma jen available nebo planned
- validace stromu overuje i konektor. Cizi konektor je chyba, chybejici
  napojeni nedodelek - rozdelana prace se nezahazuje
- prejmenovani napric kodem: Connector na Service, FlowStep.connectorId na
  serviceId, GET /connectors na GET /services, connectorIcons na serviceIcons,
  stranka Konektory (katalog) na Sluzby. Prevodni tabulka je v dokumentu 12

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 14:11:50 +02:00
co-authored by Claude Opus 5
parent 6f6b287d7e
commit 8ad91a6c28
37 changed files with 3006 additions and 776 deletions
+2
View File
@@ -16,6 +16,7 @@ import Services from '@/pages/Services';
const Overview = lazy(() => import('@/pages/dashboard/Overview'));
const Automations = lazy(() => import('@/pages/dashboard/Automations'));
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
const DashboardServices = lazy(() => import('@/pages/dashboard/Services'));
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
const Scripts = lazy(() => import('@/pages/dashboard/Scripts'));
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
@@ -61,6 +62,7 @@ export default function App() {
<Route index element={<Overview />} />
<Route path="automatizace" element={<Automations />} />
<Route path="automatizace/:id" element={<AutomationDetail />} />
<Route path="sluzby" element={<DashboardServices />} />
<Route path="konektory" element={<Connectors />} />
<Route path="skripty" element={<Scripts />} />
<Route path="tickety" element={<Tickets />} />
@@ -7,6 +7,7 @@ import {
LogOut,
Menu,
Plug,
Blocks,
ScrollText,
Settings,
Workflow,
@@ -27,6 +28,7 @@ import type { Access } from '@/types/dashboard';
const nav = [
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
{ to: '/dashboard/automatizace', label: 'Automatizace', icon: Workflow, end: false },
{ to: '/dashboard/sluzby', label: 'Služby', icon: Blocks, 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 },
+17 -17
View File
@@ -2,9 +2,9 @@ import { AlertCircle, CheckCircle2, GitBranch, Info, MinusCircle, Zap } from 'lu
import type { LucideIcon } from 'lucide-react';
import { useMemo } from 'react';
import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import { serviceIcon } from '@/lib/serviceIcons';
import { formatDateTime, formatDuration } from '@/lib/format';
import type { Connector, TicketTraceEntry, TraceStatus } from '@/types/dashboard';
import type { Service, TicketTraceEntry, TraceStatus } from '@/types/dashboard';
/**
* Log ticketu jako strom. Vetev podminky je zanorena pod ni,
@@ -55,10 +55,10 @@ function buildTree(entries: TicketTraceEntry[]): TraceNode[] {
export function TicketTrace({
entries,
connectors,
services,
}: {
entries: TicketTraceEntry[];
connectors: Connector[];
services: Service[];
}) {
const tree = useMemo(() => buildTree(entries), [entries]);
@@ -73,16 +73,16 @@ export function TicketTrace({
return (
<ol className="space-y-2.5">
{tree.map((node) => (
<TraceRow key={node.id} node={node} connectors={connectors} />
<TraceRow key={node.id} node={node} services={services} />
))}
</ol>
);
}
function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector[] }) {
function TraceRow({ node, services }: { node: TraceNode; services: Service[] }) {
const meta = statusMeta[node.status];
const Icon = node.kind === 'condition' ? GitBranch : node.kind === 'trigger' ? Zap : meta.icon;
const service = describeService(node, connectors);
const service = describeService(node, services);
return (
<li>
@@ -124,7 +124,7 @@ function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector
{node.children.length > 0 && (
<ol className={cn('mt-2.5 space-y-2.5 border-l-2 pl-4 sm:pl-6', meta.line)}>
{node.children.map((child) => (
<TraceRow key={child.id} node={child} connectors={connectors} />
<TraceRow key={child.id} node={child} services={services} />
))}
</ol>
)}
@@ -133,11 +133,11 @@ function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector
}
/** "RAYNET CRM / Dohledani kontaktu" - kdyz konektor v katalogu neni, aspon jeho ID. */
function describeService(node: TraceNode, connectors: Connector[]): string | null {
if (!node.connectorId) return null;
function describeService(node: TraceNode, services: Service[]): string | null {
if (!node.serviceId) return null;
const connector = connectors.find((c) => c.id === node.connectorId);
if (!connector) return `${node.connectorId}${node.operationId ? ` / ${node.operationId}` : ''}`;
const connector = services.find((c) => c.id === node.serviceId);
if (!connector) return `${node.serviceId}${node.operationId ? ` / ${node.operationId}` : ''}`;
if (!node.operationId) return connector.name;
@@ -150,7 +150,7 @@ function describeService(node: TraceNode, connectors: Connector[]): string | nul
export function TraceSummary({ entries }: { entries: TicketTraceEntry[] }) {
const failed = entries.filter((e) => e.status === 'error').length;
const skipped = entries.filter((e) => e.status === 'skipped').length;
const services = new Set(entries.map((e) => e.connectorId).filter(Boolean)).size;
const services = new Set(entries.map((e) => e.serviceId).filter(Boolean)).size;
return (
<p className="text-xs text-white/40">
@@ -162,8 +162,8 @@ export function TraceSummary({ entries }: { entries: TicketTraceEntry[] }) {
);
}
/** Ikona konektoru pro pouziti nad logem (napr. u kanalu ticketu). */
export function serviceIcon(connectorId: string, connectors: Connector[]): LucideIcon | null {
const connector = connectors.find((c) => c.id === connectorId);
return connector ? connectorIcon(connector.icon) : null;
/** Ikona sluzby pro pouziti nad logem (napr. u kanalu ticketu). */
export function traceServiceIcon(serviceId: string, services: Service[]): LucideIcon | null {
const service = services.find((item) => item.id === serviceId);
return service ? serviceIcon(service.icon) : null;
}
@@ -3,7 +3,7 @@ import { StepInputs } from '@/components/dashboard/flow/StepInputs';
import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import { serviceIcon } from '@/lib/serviceIcons';
import {
collectScopes,
defaultOperatorFor,
@@ -17,7 +17,7 @@ import {
import type {
AutomationFlow,
ConditionOperator,
Connector,
Service,
FlowStep,
TriggerField,
} from '@/types/dashboard';
@@ -51,7 +51,7 @@ export function FlowCanvas({
...callbacks
}: CanvasCallbacks & {
flow: AutomationFlow;
connectors: Connector[];
connectors: Service[];
webhookBaseUrl: string;
regenerating: boolean;
onPickTrigger: () => void;
@@ -95,7 +95,7 @@ function TriggerCard({
onRegenerateToken,
}: {
flow: AutomationFlow;
connectors: Connector[];
connectors: Service[];
webhookBaseUrl: string;
regenerating: boolean;
onPick: () => void;
@@ -124,7 +124,7 @@ function TriggerCard({
const resolved = resolveOperation(
connectors,
flow.trigger.connectorId,
flow.trigger.serviceId,
flow.trigger.operationId,
'trigger',
);
@@ -133,13 +133,13 @@ function TriggerCard({
return (
<BrokenCard
label="Spouštěč odkazuje na neznámý konektor"
detail={`${flow.trigger.connectorId} / ${flow.trigger.operationId}`}
detail={`${flow.trigger.serviceId} / ${flow.trigger.operationId}`}
onFix={onPick}
/>
);
}
const Icon = connectorIcon(resolved.connector.icon);
const Icon = serviceIcon(resolved.connector.icon);
return (
<div className="rounded-card border border-brand-400/35 bg-gradient-to-br from-brand-500/12 to-transparent p-5">
@@ -191,7 +191,7 @@ function StepSequence({
}: CanvasCallbacks & {
steps: FlowStep[];
path: FlowPath;
connectors: Connector[];
connectors: Service[];
/** Co je videt v kterem kroku. Klic je ID kroku. */
scopes: Map<string, TriggerField[]>;
/** Pouzije se, kdyz krok ve scope jeste neni (prave pridany). */
@@ -279,7 +279,7 @@ function ActionCard({
onUpdateInputs,
}: {
step: Extract<FlowStep, { kind: 'action' }>;
connectors: Connector[];
connectors: Service[];
/** Parametry viditelne prave v tomto miste stromu. */
available: TriggerField[];
canMoveUp: boolean;
@@ -288,19 +288,19 @@ function ActionCard({
onMove: (offset: number) => void;
onUpdateInputs: (inputs: Record<string, string>) => void;
}) {
const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action');
const resolved = resolveOperation(connectors, step.serviceId, step.operationId, 'action');
if (!resolved) {
return (
<BrokenCard
label="Krok odkazuje na neznámou akci"
detail={`${step.connectorId} / ${step.operationId}`}
detail={`${step.serviceId} / ${step.operationId}`}
onRemove={onRemove}
/>
);
}
const Icon = connectorIcon(resolved.connector.icon);
const Icon = serviceIcon(resolved.connector.icon);
const inputs = resolved.operation.inputs;
return (
@@ -359,7 +359,7 @@ function ConditionCard({
}: CanvasCallbacks & {
step: Extract<FlowStep, { kind: 'condition' }>;
path: FlowPath;
connectors: Connector[];
connectors: Service[];
scopes: Map<string, TriggerField[]>;
fallbackFields: TriggerField[];
available: TriggerField[];
@@ -4,8 +4,8 @@ import type { ReactNode } from 'react';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import type { Connector, ConnectorCategory, ConnectorOperation } from '@/types/dashboard';
import { serviceIcon } from '@/lib/serviceIcons';
import type { Service, ServiceCategory, ServiceOperation } from '@/types/dashboard';
/**
* Vyber toho, co se ma pridat do stromu.
@@ -28,19 +28,19 @@ export function StepPicker({
}: {
open: boolean;
mode: 'trigger' | 'action';
connectors: Connector[];
categories: Array<{ id: ConnectorCategory; label: string }>;
connectors: Service[];
categories: Array<{ id: ServiceCategory; label: string }>;
onClose: () => void;
onPickOperation: (connectorId: string, operationId: string) => void;
onPickOperation: (serviceId: string, operationId: string) => void;
onPickCondition?: () => void;
/** false = spoustec nema zadne parametry, nebylo by podle ceho se rozhodovat */
canAddCondition?: boolean;
}) {
const [selected, setSelected] = useState<Connector | null>(null);
const [selected, setSelected] = useState<Service | null>(null);
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ConnectorCategory | 'all'>('all');
const [category, setCategory] = useState<ServiceCategory | 'all'>('all');
const operationsOf = (connector: Connector) =>
const operationsOf = (connector: Service) =>
mode === 'trigger' ? connector.triggers : connector.actions;
// Konektory, ktere v tomhle rezimu vubec maji co nabidnout.
@@ -65,7 +65,7 @@ export function StepPicker({
// Ploche vysledky hledani - hleda v nazvu konektoru i v nazvech operaci.
const searchResults = useMemo(() => {
if (!searching) return [];
const results: Array<{ connector: Connector; operation: ConnectorOperation }> = [];
const results: Array<{ connector: Service; operation: ServiceOperation }> = [];
for (const connector of usable) {
for (const operation of operationsOf(connector)) {
const haystack = `${connector.name} ${operation.name} ${operation.description}`.toLowerCase();
@@ -82,7 +82,7 @@ export function StepPicker({
onClose();
}
function pick(connector: Connector, operation: ConnectorOperation) {
function pick(connector: Service, operation: ServiceOperation) {
if (connector.status === 'planned') {
console.warn(`[picker] konektor ${connector.id} je na roadmape, nelze pouzit`);
return;
@@ -267,8 +267,8 @@ function CategoryChip({
);
}
function ConnectorGlyph({ connector }: { connector: Connector }) {
const Icon = connectorIcon(connector.icon);
function ConnectorGlyph({ connector }: { connector: Service }) {
const Icon = serviceIcon(connector.icon);
return (
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
@@ -281,7 +281,7 @@ function ConnectorCard({
operationCount,
onClick,
}: {
connector: Connector;
connector: Service;
operationCount: number;
onClick: () => void;
}) {
@@ -324,8 +324,8 @@ function OperationRow({
showConnectorName = false,
onClick,
}: {
connector: Connector;
operation: ConnectorOperation;
connector: Service;
operation: ServiceOperation;
showConnectorName?: boolean;
onClick: () => void;
}) {
@@ -3,7 +3,7 @@ import { useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { fieldTypeLabels, newFieldId } from '@/lib/flow';
import type { Connector, FieldType, FlowTrigger, TriggerField } from '@/types/dashboard';
import type { Service, FieldType, FlowTrigger, TriggerField } from '@/types/dashboard';
const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
@@ -20,13 +20,13 @@ export function TriggerConfig({
regenerating,
}: {
trigger: FlowTrigger;
connector: Connector | undefined;
connector: Service | undefined;
webhookBaseUrl: string;
onChangeFields: (fields: TriggerField[]) => void;
onRegenerateToken: () => void;
regenerating: boolean;
}) {
const isWebhook = trigger.connectorId === 'webhook';
const isWebhook = trigger.serviceId === 'webhook';
const operation = connector?.triggers.find((t) => t.id === trigger.operationId);
const editable = operation?.customPayload === true;
/**
+13 -13
View File
@@ -1,8 +1,8 @@
import type {
AutomationFlow,
ConditionOperator,
Connector,
ConnectorOperation,
Service,
ServiceOperation,
FieldType,
FlowStep,
TriggerField,
@@ -30,8 +30,8 @@ export function newStepId(): string {
return `st_${Date.now().toString(36)}_${idCounter}`;
}
export function createActionStep(connectorId: string, operationId: string): FlowStep {
return { id: newStepId(), kind: 'action', connectorId, operationId };
export function createActionStep(serviceId: string, operationId: string): FlowStep {
return { id: newStepId(), kind: 'action', serviceId, operationId };
}
export function createConditionStep(field: TriggerField): FlowStep {
@@ -139,13 +139,13 @@ export function describeCondition(
*/
export function collectScopes(
flow: AutomationFlow,
connectors: Connector[],
connectors: Service[],
): Map<string, TriggerField[]> {
const byStep = new Map<string, TriggerField[]>();
const triggerFields = flow.trigger?.fields ?? [];
const outputsOf = (step: Extract<FlowStep, { kind: 'action' }>): TriggerField[] => {
const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action');
const resolved = resolveOperation(connectors, step.serviceId, step.operationId, 'action');
return (resolved?.operation.outputFields ?? []).map((output) => ({
// Prefix ID krokem - dva stejne kroky ve strome se nesmi prekryt.
id: `${step.id}.${output.id}`,
@@ -305,8 +305,8 @@ export function countSteps(steps: FlowStep[]): number {
}
interface ResolvedOperation {
connector: Connector;
operation: ConnectorOperation;
connector: Service;
operation: ServiceOperation;
}
/**
@@ -314,21 +314,21 @@ interface ResolvedOperation {
* (napr. konektor byl z katalogu odebran) - volajici to musi zobrazit.
*/
export function resolveOperation(
connectors: Connector[],
connectorId: string,
connectors: Service[],
serviceId: string,
operationId: string,
type: 'trigger' | 'action',
): ResolvedOperation | null {
const connector = connectors.find((c) => c.id === connectorId);
const connector = connectors.find((c) => c.id === serviceId);
if (!connector) {
console.warn(`[flow] neznamy konektor ${connectorId}`);
console.warn(`[flow] neznamy konektor ${serviceId}`);
return null;
}
const pool = type === 'trigger' ? connector.triggers : connector.actions;
const operation = pool.find((op) => op.id === operationId);
if (!operation) {
console.warn(`[flow] konektor ${connectorId} nema ${type} ${operationId}`);
console.warn(`[flow] konektor ${serviceId} nema ${type} ${operationId}`);
return null;
}
@@ -1,5 +1,6 @@
import {
BarChart3,
Boxes,
Building2,
Clock,
Facebook,
@@ -32,12 +33,13 @@ import {
} from 'lucide-react';
/**
* API posila u konektoru jen klic ikony (string) - React komponentu poslat nemuze.
* API posila u sluzby jen klic ikony (string) - React komponentu poslat nemuze.
* Mapovani je tady. Neznamy klic se zaloguje a dostane obecnou ikonu,
* aby chybejici zapis nerozbil celou stranku.
*/
const icons: Record<string, LucideIcon> = {
BarChart3,
Boxes,
Building2,
Clock,
Facebook,
@@ -67,10 +69,10 @@ const icons: Record<string, LucideIcon> = {
Webhook,
};
export function connectorIcon(key: string): LucideIcon {
export function serviceIcon(key: string): LucideIcon {
const icon = icons[key];
if (!icon) {
console.warn(`[connectorIcons] neznamy klic ikony "${key}", pouzivam zastupnou`);
console.warn(`[serviceIcons] neznamy klic ikony "${key}", pouzivam zastupnou`);
return Plug;
}
return icon;
+51 -3
View File
@@ -8,6 +8,24 @@ import { Button } from '@/components/ui/Button';
import { brand } from '@/config/brand';
import { usePageMeta } from '@/lib/usePageMeta';
/**
* Demo ucty prototypu.
*
* Prvni je predvyplneny, aby se pri testovani nemuselo porad psat prihlaseni.
* Pred ostrym pouzitim cely tenhle blok i `defaultValue` odebrat.
*/
interface DemoAccount {
email: string;
password: string;
note: string;
}
const demoAccounts: DemoAccount[] = [
{ email: 'admin@automia.cz', password: 'demo1234', note: 'správce platformy, vidí všechny firmy' },
{ email: 'karel.vomacka@automia.cz', password: 'demo1234', note: 'dvě firmy, jiná role v každé' },
{ email: 'martin.kriz@automia.cz', password: 'demo1234', note: 'řešitel jedné firmy' },
];
const inputClass =
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-4 py-3 text-sm text-white placeholder:text-white/30 transition-colors focus:border-brand-400/70 focus:outline-none';
@@ -22,6 +40,20 @@ export default function Login() {
const navigate = useNavigate();
const location = useLocation();
const [error, setError] = useState<string | null>(null);
/**
* Predvyplni formular vybranym demo uctem.
* Sahame na DOM misto stavu, protoze formular je nerizeny - jinak by se
* musela prepsat obe pole na controlled a nic dalsiho z toho nezískáme.
*/
function fillAccount(account: DemoAccount) {
const form = document.querySelector<HTMLFormElement>('form');
if (!form) return;
const email = form.elements.namedItem('email');
const password = form.elements.namedItem('password');
if (email instanceof HTMLInputElement) email.value = account.email;
if (password instanceof HTMLInputElement) password.value = account.password;
}
const [submitting, setSubmitting] = useState(false);
// Kam po prihlaseni - bud puvodni cil z RequireAuth, nebo dashboard.
@@ -86,6 +118,8 @@ export default function Login() {
required
autoComplete="email"
autoFocus
// Predvyplneno kvuli testovani prototypu. Pred ostrym pouzitim odebrat.
defaultValue={demoAccounts[0].email}
className={inputClass}
placeholder="vas@email.cz"
/>
@@ -104,6 +138,7 @@ export default function Login() {
type="password"
required
autoComplete="current-password"
defaultValue={demoAccounts[0].password}
className={inputClass}
placeholder="••••••••"
/>
@@ -127,9 +162,22 @@ export default function Login() {
<ShieldCheck className="size-3.5" />
Demo přístupy (prototyp)
</p>
<ul className="mt-2.5 space-y-1 font-mono text-xs text-white/50">
<li>admin@automia.cz / demo1234</li>
<li>klient@firma.cz / demo1234</li>
<p className="mt-1 text-xs text-white/35">
Formulář je předvyplněný správcem platformy. Kliknutím se přepne účet.
</p>
<ul className="mt-2.5 space-y-1">
{demoAccounts.map((account) => (
<li key={account.email}>
<button
type="button"
onClick={() => fillAccount(account)}
className="w-full rounded-lg px-2 py-1 text-left font-mono text-xs text-white/50 transition-colors hover:bg-white/5 hover:text-white"
>
{account.email}
<span className="ml-2 font-sans text-white/30">{account.note}</span>
</button>
</li>
))}
</ul>
</div>
</div>
+11 -11
View File
@@ -24,7 +24,7 @@ import { usePageMeta } from '@/lib/usePageMeta';
import type {
AutomationDetail as Detail,
AutomationFlow,
ConnectorCatalog,
ServiceCatalog,
TriggerField,
} from '@/types/dashboard';
@@ -40,7 +40,7 @@ export default function AutomationDetail() {
const navigate = useNavigate();
const automation = useApiQuery<Detail>(`/api/dashboard/automations/${id}`);
const catalog = useApiQuery<ConnectorCatalog>('/api/dashboard/connectors');
const catalog = useApiQuery<ServiceCatalog>('/api/dashboard/services');
const [name, setName] = useState('');
const [flow, setFlow] = useState<AutomationFlow>({ trigger: null, steps: [] });
@@ -79,14 +79,14 @@ export default function AutomationDetail() {
}, []);
/** Parametry, ktere ma mit spoustec po jeho zmene. */
function fieldsForTrigger(connectorId: string, operationId: string): TriggerField[] {
const provided = findTrigger(connectorId, operationId)?.providedFields;
function fieldsForTrigger(serviceId: string, operationId: string): TriggerField[] {
const provided = findTrigger(serviceId, operationId)?.providedFields;
// Sluzba si data urcuje sama. Server je pri ulozeni stejne dosadi z katalogu,
// tady je nastavime hned, aby slo rovnou stavet podminky.
if (provided) return provided;
const previous = flow.trigger
? findTrigger(flow.trigger.connectorId, flow.trigger.operationId)
? findTrigger(flow.trigger.serviceId, flow.trigger.operationId)
: undefined;
// Prechod z katalogoveho spoustece na vlastni - cizi parametry by tu neplatily.
if (previous?.providedFields) return [];
@@ -95,13 +95,13 @@ export default function AutomationDetail() {
return flow.trigger?.fields ?? [];
}
function findTrigger(connectorId: string, operationId: string) {
function findTrigger(serviceId: string, operationId: string) {
return connectors
.find((connector) => connector.id === connectorId)
.find((connector) => connector.id === serviceId)
?.triggers.find((operation) => operation.id === operationId);
}
function handlePick(connectorId: string, operationId: string) {
function handlePick(serviceId: string, operationId: string) {
if (!picker) {
console.warn('[builder] vyber potvrzen bez otevreneho cile');
return;
@@ -111,9 +111,9 @@ export default function AutomationDetail() {
changeFlow({
...flow,
trigger: {
connectorId,
serviceId,
operationId,
fields: fieldsForTrigger(connectorId, operationId),
fields: fieldsForTrigger(serviceId, operationId),
webhookToken: flow.trigger?.webhookToken,
},
});
@@ -124,7 +124,7 @@ export default function AutomationDetail() {
flow.steps,
picker.path,
picker.index,
createActionStep(connectorId, operationId),
createActionStep(serviceId, operationId),
),
});
}
+451 -218
View File
@@ -1,147 +1,130 @@
import { CheckCircle2, Clock, Plug, Search, Terminal, Zap } from 'lucide-react';
import { useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Plus, Star, Trash2, Wifi } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { brand } from '@/config/brand';
import { Button } from '@/components/ui/Button';
import { apiFetch, ApiError } from '@/lib/api';
import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import { useApiQuery } from '@/lib/useApiQuery';
import { serviceIcon } from '@/lib/serviceIcons';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
Connector,
ConnectorCatalog,
ConnectorCategory,
ConnectorOperation,
ConnectorStatus,
ConnectorTestResult,
Service,
ServiceCredentialField,
ServiceOverview,
} from '@/types/dashboard';
const statusMeta: Record<ConnectorStatus, { label: string; tone: 'ok' | 'neutral' | 'warn' }> = {
connected: { label: 'Napojeno', tone: 'ok' },
available: { label: 'Umíme napojit', tone: 'neutral' },
planned: { label: 'Na roadmapě', tone: 'warn' },
};
/**
* Konektory: napojeni teto firmy na sluzby.
*
* Pristupove udaje se zadavaji tady, ne v prostredi aplikace - kazda firma ma
* svoje. Tajne hodnoty server nikdy nevraci, takze u vyplneneho pole je videt
* jen to, ze vyplnene je. Viz documentation/12-sluzby-a-konektory.md.
*/
export default function Connectors() {
usePageMeta({ title: 'Konektory — portál Automia' });
usePageMeta({ title: 'Konektory - portál Automia' });
const { data, loading, error, reload } = useApiQuery<ConnectorCatalog>(
'/api/dashboard/connectors',
const [params, setParams] = useSearchParams();
const preselected = params.get('service');
const [services, setServices] = useState<ServiceOverview | null>(null);
const [connectors, setConnectors] = useState<Connector[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [creatingFor, setCreatingFor] = useState<string | null>(preselected);
const load = useCallback(() => {
setLoading(true);
setError(null);
Promise.all([
apiFetch<ServiceOverview>('/api/dashboard/connectors/services'),
apiFetch<{ items: Connector[] }>('/api/dashboard/connectors'),
])
.then(([overview, list]) => {
setServices(overview);
setConnectors(list.items);
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Konektory se nepodařilo načíst.');
})
.finally(() => setLoading(false));
}, []);
useEffect(load, [load]);
const servicesById = useMemo(() => {
const map = new Map<string, Service>();
for (const service of services?.items ?? []) map.set(service.id, service);
return map;
}, [services]);
/** Sluzby, na ktere jde konektor zalozit. Obecne se nenabizeji, nepotrebuji ho. */
const connectable = useMemo(
() =>
(services?.items ?? [])
.filter((service) => !service.general && service.status === 'available')
.sort((a, b) => a.name.localeCompare(b.name, 'cs')),
[services],
);
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ConnectorCategory | 'all'>('all');
const [status, setStatus] = useState<ConnectorStatus | 'all'>('all');
const items = data?.items ?? [];
const categories = data?.categories ?? [];
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
return items.filter((connector) => {
if (category !== 'all' && connector.category !== category) return false;
if (status !== 'all' && connector.status !== status) return false;
if (needle.length === 0) return true;
const haystack = [
connector.name,
connector.description,
...connector.triggers.map((t) => t.name),
...connector.actions.map((a) => a.name),
]
.join(' ')
.toLowerCase();
return haystack.includes(needle);
});
}, [items, query, category, status]);
const counts = useMemo(
() => ({
connected: items.filter((c) => c.status === 'connected').length,
available: items.filter((c) => c.status === 'available').length,
planned: items.filter((c) => c.status === 'planned').length,
}),
[items],
);
function closeForm() {
setCreatingFor(null);
if (preselected) setParams({});
}
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Konektory</h1>
<p className="mt-1 text-sm text-white/50">
Služby, které jdou použít v automatizacích. Každý konektor nabízí spouštěče
(čím běh začne) a akce (co se má stát).
</p>
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Konektory</h1>
<p className="mt-1 max-w-2xl text-sm text-white/50">
Napojení této firmy na jednotlivé služby. Přístupové údaje zadáváte tady a
zůstávají na serveru, prohlížeč je nikdy nedostane zpátky.
</p>
</div>
{connectable.length > 0 && (
<Button
size="sm"
onClick={() => setCreatingFor(connectable[0].id)}
disabled={creatingFor !== null}
>
<Plus className="size-4" />
Nový konektor
</Button>
)}
</header>
<DataState loading={loading} error={error} onRetry={reload} empty={items.length === 0}>
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<SummaryTile
icon={CheckCircle2}
value={counts.connected}
label="napojeno a připraveno k použití"
tone="ok"
<DataState loading={loading} error={error} onRetry={load}>
<div className="space-y-5">
{creatingFor && (
<CreateForm
services={connectable}
initialServiceId={creatingFor}
onCancel={closeForm}
onCreated={() => {
closeForm();
load();
}}
/>
<SummaryTile
icon={Plug}
value={counts.available}
label="umíme napojit na požádání"
tone="brand"
/>
<SummaryTile icon={Clock} value={counts.planned} label="na roadmapě" tone="warn" />
</div>
)}
<div className="glass rounded-card p-4 sm:p-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Hledat službu, spouštěč nebo akci…"
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Chip active={category === 'all'} onClick={() => setCategory('all')}>
Všechny kategorie
</Chip>
{categories.map((cat) => (
<Chip
key={cat.id}
active={category === cat.id}
onClick={() => setCategory(cat.id)}
>
{cat.label}
</Chip>
))}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Chip active={status === 'all'} onClick={() => setStatus('all')}>
Vše
</Chip>
{(['connected', 'available', 'planned'] as const).map((value) => (
<Chip key={value} active={status === value} onClick={() => setStatus(value)}>
{statusMeta[value].label}
</Chip>
))}
</div>
</div>
{filtered.length === 0 ? (
{(connectors?.length ?? 0) === 0 && !creatingFor ? (
<p className="py-10 text-center text-sm text-white/45">
Nic neodpovídá filtru. Chybí vám konektor?{' '}
<a href={`mailto:${brand.email}`} className="text-brand-300 hover:text-brand-200">
Napište nám
</a>
, většinu služeb umíme napojit přes HTTP požadavek.
Zatím žádný konektor. Vyberte službu na stránce Služby, nebo použijte
tlačítko Nový konektor.
</p>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{filtered.map((connector) => (
<ConnectorCard key={connector.id} connector={connector} />
<div className="space-y-4">
{(connectors ?? []).map((connector) => (
<ConnectorCard
key={connector.id}
connector={connector}
service={servicesById.get(connector.serviceId)}
onChanged={load}
/>
))}
</div>
)}
@@ -151,133 +134,383 @@ export default function Connectors() {
);
}
function SummaryTile({
icon: Icon,
value,
label,
tone,
// ------------------------------------------------------------------ zakladani
function CreateForm({
services,
initialServiceId,
onCancel,
onCreated,
}: {
icon: typeof Plug;
value: number;
label: string;
tone: 'ok' | 'brand' | 'warn';
services: Service[];
initialServiceId: string;
onCancel: () => void;
onCreated: () => void;
}) {
const tones = {
ok: 'bg-ok-500/12 text-ok-400',
brand: 'bg-brand-500/12 text-brand-300',
warn: 'bg-warn-500/12 text-warn-400',
} as const;
const [serviceId, setServiceId] = useState(
services.some((service) => service.id === initialServiceId)
? initialServiceId
: (services[0]?.id ?? ''),
);
const service = services.find((item) => item.id === serviceId);
const [name, setName] = useState('');
const [values, setValues] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Nazev se predvyplni podle sluzby, at ho nikdo nemusi vymyslet.
useEffect(() => {
setName(service ? service.name : '');
setValues({});
}, [service]);
async function handleCreate() {
if (!service) return;
setSaving(true);
setError(null);
try {
await apiFetch('/api/dashboard/connectors', {
method: 'POST',
body: { serviceId: service.id, name: name.trim() || service.name, values },
});
onCreated();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Konektor se nepodařilo založit.');
} finally {
setSaving(false);
}
}
return (
<div className="glass flex items-center gap-4 rounded-card p-5">
<span className={cn('grid size-10 shrink-0 place-items-center rounded-xl', tones[tone])}>
<Icon className="size-5" />
</span>
<div>
<p className="text-2xl font-extrabold text-white tabular-nums">{value}</p>
<p className="text-xs text-white/45">{label}</p>
<div className="glass rounded-card border border-brand-400/30 p-5">
<p className="mb-4 text-xs font-semibold tracking-wide text-white/45 uppercase">
Nový konektor
</p>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Služba">
<select
value={serviceId}
onChange={(event) => setServiceId(event.target.value)}
className={inputClass}
>
{services.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</Field>
<Field label="Název konektoru" hint="Firma může mít dva účty téže služby.">
<input
value={name}
onChange={(event) => setName(event.target.value)}
className={inputClass}
/>
</Field>
</div>
{service && service.credentials.length > 0 && (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{service.credentials.map((field) => (
<CredentialInput
key={field.id}
field={field}
value={values[field.id] ?? ''}
filled={false}
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
/>
))}
</div>
)}
{service && service.credentials.length === 0 && (
<p className="mt-4 text-sm text-white/45">
U služby {service.name} ještě nejsou popsané přístupové údaje. Konektor půjde
založit, ale volat se bude umět až spolu se službou.
</p>
)}
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
<div className="mt-5 flex gap-3">
<Button size="sm" onClick={handleCreate} disabled={saving || !service}>
{saving ? 'Zakládám...' : 'Založit'}
</Button>
<Button size="sm" variant="secondary" onClick={onCancel} disabled={saving}>
Zrušit
</Button>
</div>
</div>
);
}
function Chip({
active,
onClick,
children,
// --------------------------------------------------------------------- detail
function ConnectorCard({
connector,
service,
onChanged,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
connector: Connector;
service: Service | undefined;
onChanged: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
>
{children}
</button>
);
}
const Icon = serviceIcon(service?.icon ?? 'Plug');
const [values, setValues] = useState<Record<string, string>>({});
const [name, setName] = useState(connector.name);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [test, setTest] = useState<ConnectorTestResult | null>(null);
const [error, setError] = useState<string | null>(null);
function ConnectorCard({ connector }: { connector: Connector }) {
const Icon = connectorIcon(connector.icon);
const meta = statusMeta[connector.status];
const dirty = Object.keys(values).length > 0 || name !== connector.name;
async function patch(body: Record<string, unknown>) {
setSaving(true);
setError(null);
try {
await apiFetch(`/api/dashboard/connectors/${connector.id}`, { method: 'PATCH', body });
setValues({});
setTest(null);
onChanged();
} catch (err: unknown) {
setError(err instanceof ApiError ? err.message : 'Uložení selhalo.');
} finally {
setSaving(false);
}
}
async function handleTest() {
setTesting(true);
setError(null);
try {
setTest(
await apiFetch<ConnectorTestResult>(`/api/dashboard/connectors/${connector.id}/test`, {
method: 'POST',
}),
);
onChanged();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ověření se nepodařilo odeslat.');
} finally {
setTesting(false);
}
}
async function handleDelete() {
setSaving(true);
try {
await apiFetch(`/api/dashboard/connectors/${connector.id}`, { method: 'DELETE' });
onChanged();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Smazání selhalo.');
setSaving(false);
}
}
return (
<article className="glass flex h-full flex-col rounded-card p-5">
<div className="flex items-start justify-between gap-3">
<article className="glass rounded-card p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
<div className="min-w-0">
<h2 className="font-semibold text-white">{connector.name}</h2>
<p className="mt-0.5 text-sm text-white/55">{connector.description}</p>
<h2 className="flex flex-wrap items-center gap-2 font-semibold text-white">
{connector.name}
{connector.isDefault && (
<Star className="size-3.5 text-brand-300" aria-label="Výchozí konektor" />
)}
{!connector.enabled && <Badge tone="warn">vypnutý</Badge>}
</h2>
<p className="mt-0.5 text-sm text-white/45">
{service?.name ?? connector.serviceId}
{connector.baseUrl ? ` - ${connector.baseUrl}` : ''}
</p>
</div>
</div>
<Badge tone={meta.tone}>{meta.label}</Badge>
<StatusBadge connector={connector} />
</div>
<div className="mt-5 grid flex-1 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
<OperationList
title="Spouštěče"
icon={<Zap className="size-3 text-brand-300" />}
operations={connector.triggers}
emptyLabel="Nelze použít jako spouštěč"
/>
<OperationList
title="Akce"
icon={<Plug className="size-3 text-accent-300" />}
operations={connector.actions}
emptyLabel="Žádné akce"
/>
{connector.missing.length > 0 && service && (
<p className="mt-3 text-sm text-warn-400">
Chybí vyplnit:{' '}
{connector.missing
.map((id) => service.credentials.find((field) => field.id === id)?.label ?? id)
.join(', ')}
. Dokud to nevyplníte, kroky s touhle službou nepoběží.
</p>
)}
{connector.lastError && (
<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" />
{connector.lastError}
</p>
)}
<div className="mt-4 grid gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
<Field label="Název">
<input
value={name}
onChange={(event) => setName(event.target.value)}
className={inputClass}
/>
</Field>
{(service?.credentials ?? []).map((field) => (
<CredentialInput
key={field.id}
field={field}
value={values[field.id] ?? ''}
filled={connector.filled.includes(field.id)}
existing={connector.config[field.id]}
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
/>
))}
</div>
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
{test && (
<p
className={cn(
'mt-3 flex items-start gap-2 rounded-lg p-3 text-sm',
test.ok
? 'border border-ok-500/40 bg-ok-500/8 text-ok-400'
: 'border border-danger-500/40 bg-danger-500/8 text-danger-400',
)}
>
{test.ok ? (
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
) : (
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
)}
<span>
{test.message}
<span className="mt-0.5 block text-xs text-white/40">Ověřeno: {test.checked}</span>
</span>
</p>
)}
<div className="mt-5 flex flex-wrap items-center gap-3">
<Button
size="sm"
onClick={() => patch({ name: name.trim(), values })}
disabled={!dirty || saving}
>
{saving ? 'Ukládám...' : 'Uložit'}
</Button>
<Button size="sm" variant="secondary" onClick={handleTest} disabled={testing || saving}>
<Wifi className="size-4" />
{testing ? 'Ověřuji...' : 'Ověřit napojení'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => patch({ enabled: !connector.enabled })}
disabled={saving}
>
{connector.enabled ? 'Vypnout' : 'Zapnout'}
</Button>
{!connector.isDefault && (
<Button
size="sm"
variant="ghost"
onClick={() => patch({ isDefault: true })}
disabled={saving}
>
<Star className="size-4" />
Nastavit jako výchozí
</Button>
)}
<Button size="sm" variant="ghost" onClick={handleDelete} disabled={saving}>
<Trash2 className="size-4" />
Smazat
</Button>
</div>
</article>
);
}
function OperationList({
title,
icon,
operations,
emptyLabel,
function StatusBadge({ connector }: { connector: Connector }) {
if (!connector.ready) return <Badge tone="warn">Chybí údaje</Badge>;
if (connector.status === 'ok') return <Badge tone="ok">Ověřeno</Badge>;
if (connector.status === 'error') return <Badge tone="danger">Ověření selhalo</Badge>;
return <Badge tone="neutral">Neověřeno</Badge>;
}
// ------------------------------------------------------------------ formulare
const inputClass =
'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';
function Field({
label,
hint,
children,
}: {
title: string;
icon: ReactNode;
operations: ConnectorOperation[];
emptyLabel: string;
label: string;
hint?: string;
children: ReactNode;
}) {
return (
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
{icon}
{title}
</p>
{operations.length === 0 ? (
<p className="text-xs text-white/25">{emptyLabel}</p>
) : (
<ul className="space-y-1">
{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>
)}
</div>
<label className="block">
<span className="mb-1 block text-sm text-white/70">{label}</span>
{children}
{hint && <span className="mt-1 block text-xs text-white/35">{hint}</span>}
</label>
);
}
/**
* Pole pristupoveho udaje.
*
* U tajneho pole server hodnotu nevraci, takze se ukazuje jen to, ze vyplnene
* je. Prazdne pole hodnotu nemeni - ulozeni formulare tedy nic nepresepe.
*/
function CredentialInput({
field,
value,
filled,
existing,
onChange,
}: {
field: ServiceCredentialField;
value: string;
filled: boolean;
existing?: string;
onChange: (value: string) => void;
}) {
const placeholder = field.secret
? filled
? 'Vyplněno, prázdné pole hodnotu nezmění'
: 'Nevyplněno'
: (existing ?? '');
return (
<label className="block">
<span className="mb-1 flex flex-wrap items-center gap-2 text-sm text-white/70">
{field.label}
{field.required && <span className="text-warn-400">*</span>}
{field.secret && <Badge tone="neutral">tajné</Badge>}
{filled && <Badge tone="ok">vyplněno</Badge>}
</span>
<input
type={field.secret ? 'password' : 'text'}
autoComplete="off"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className={inputClass}
/>
<span className="mt-1 block text-xs text-white/35">
{field.hint ??
`Posílá se jako ${field.target === 'header' ? 'hlavička' : 'nastavení'} ${field.name}.`}
</span>
</label>
);
}
+31 -28
View File
@@ -18,7 +18,7 @@ import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
ConnectionStatus,
Connector,
ScriptCatalog,
ScriptDetail,
ScriptField,
@@ -48,20 +48,14 @@ export default function Scripts() {
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 connectorsByService = catalog.data?.connectors ?? {};
const grouped = useMemo(() => {
const map = new Map<string, ScriptCatalog['items']>();
for (const item of items) {
const list = map.get(item.connectorId) ?? [];
const list = map.get(item.serviceId) ?? [];
list.push(item);
map.set(item.connectorId, list);
map.set(item.serviceId, list);
}
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [items]);
@@ -89,11 +83,11 @@ export default function Scripts() {
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="space-y-5">
{grouped.map(([connectorId, scripts]) => (
{grouped.map(([serviceId, scripts]) => (
<ConnectorGroup
key={connectorId}
connectorId={connectorId}
connection={connectionsById.get(connectorId)}
key={serviceId}
serviceId={serviceId}
connection={connectorsByService[serviceId]}
scripts={scripts}
selectedId={selectedId}
onSelect={setSelectedId}
@@ -127,14 +121,14 @@ export default function Scripts() {
// ------------------------------------------------------------------- seznam
function ConnectorGroup({
connectorId,
serviceId,
connection,
scripts,
selectedId,
onSelect,
}: {
connectorId: string;
connection: ConnectionStatus | undefined;
serviceId: string;
connection: Connector | undefined;
scripts: ScriptCatalog['items'];
selectedId: string | null;
onSelect: (id: string) => void;
@@ -142,7 +136,7 @@ function ConnectorGroup({
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>
<p className="font-semibold text-white">{serviceId}</p>
{connection && (
<Badge tone={connection.ready ? 'ok' : 'warn'}>
{connection.ready ? 'Napojeno' : 'Chybí údaje'}
@@ -242,7 +236,7 @@ function ScriptPanel({
<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} />
<ConnectorBadge connector={detail.connector} />
</div>
{detail.manifest && (
@@ -256,12 +250,19 @@ function ScriptPanel({
</p>
)}
{!detail.connection.ready && (
{detail.connector === null ? (
<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.
Služba {detail.serviceName} nemá v této firmě konektor, skript nepůjde
spustit. Vytvořte ho na stránce Konektory.
</p>
) : (
!detail.connector.ready && (
<p className="mt-3 text-sm text-warn-400">
Konektor {detail.connector.name} nemá vyplněné všechny přístupové údaje,
chybí <code>{detail.connector.missing.join(', ')}</code>. Doplňte je na
stránce Konektory.
</p>
)
)}
</div>
@@ -276,7 +277,7 @@ function ScriptPanel({
<TestPanel
scriptId={detail.id}
fields={detail.manifest.inputs}
ready={detail.connection.ready}
ready={detail.connector?.ready === true}
/>
)}
@@ -295,13 +296,15 @@ function ScriptPanel({
);
}
function ConnectionBadge({ connection }: { connection: ConnectionStatus }) {
function ConnectorBadge({ connector }: { connector: Connector | null }) {
if (!connector) return <Badge tone="warn">Bez konektoru</Badge>;
return (
<div className="text-right">
<Badge tone={connection.ready ? 'ok' : 'warn'}>
{connection.ready ? 'Napojení připravené' : 'Napojení nenastavené'}
<Badge tone={connector.ready ? 'ok' : 'warn'}>
{connector.ready ? 'Konektor připravený' : 'Konektor bez údajů'}
</Badge>
<p className="mt-1 font-mono text-xs break-all text-white/30">{connection.baseUrl}</p>
<p className="mt-1 text-xs text-white/30">{connector.name}</p>
</div>
);
}
+330
View File
@@ -0,0 +1,330 @@
import { CheckCircle2, Clock, Lock, Plug, Search, Terminal, Zap } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { brand } from '@/config/brand';
import { cn } from '@/lib/cn';
import { serviceIcon } from '@/lib/serviceIcons';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
ServiceCategory,
ServiceOperation,
ServiceOverview,
ServiceWithUsage,
} from '@/types/dashboard';
/**
* Katalog sluzeb: co umime.
*
* Sluzba neni to same jako konektor. Sluzba je to, co umime; konektor je
* napojeni teto firmy na ni vcetne jejich pristupovych udaju. Proto se stav
* "napojeno" pocita z toho, jestli firma konektor ma, a neni to vlastnost
* sluzby - viz documentation/12-sluzby-a-konektory.md.
*
* Sluzba, kterou uzivatel nema videt, se ze serveru vubec nevraci.
*/
/** Stav sluzby ocima firmy. Pocita se z konektoru, necte se z katalogu. */
type ServiceView = 'connected' | 'available' | 'planned';
const viewMeta: Record<ServiceView, { label: string; tone: 'ok' | 'neutral' | 'warn' }> = {
connected: { label: 'Napojeno', tone: 'ok' },
available: { label: 'Můžete napojit', tone: 'neutral' },
planned: { label: 'Na roadmapě', tone: 'warn' },
};
function viewOf(service: ServiceWithUsage): ServiceView {
if (service.status === 'planned') return 'planned';
// Obecna sluzba konektor nepotrebuje, je pripravena rovnou.
if (service.general || service.connectorCount > 0) return 'connected';
return 'available';
}
export default function Services() {
usePageMeta({ title: 'Služby - portál Automia' });
const { data, loading, error, reload } = useApiQuery<ServiceOverview>(
'/api/dashboard/connectors/services',
);
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ServiceCategory | 'all'>('all');
const [view, setView] = useState<ServiceView | 'all'>('all');
const items = data?.items ?? [];
const categories = data?.categories ?? [];
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
return items.filter((service) => {
if (category !== 'all' && service.category !== category) return false;
if (view !== 'all' && viewOf(service) !== view) return false;
if (needle.length === 0) return true;
const haystack = [
service.name,
service.description,
...service.triggers.map((t) => t.name),
...service.actions.map((a) => a.name),
]
.join(' ')
.toLowerCase();
return haystack.includes(needle);
});
}, [items, query, category, view]);
const counts = useMemo(
() => ({
connected: items.filter((s) => viewOf(s) === 'connected').length,
available: items.filter((s) => viewOf(s) === 'available').length,
planned: items.filter((s) => viewOf(s) === 'planned').length,
}),
[items],
);
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Služby</h1>
<p className="mt-1 text-sm text-white/50">
Co umíme napojit. Každá služba nabízí spouštěče (čím běh začne) a akce (co se
má stát). Obecné služby fungují rovnou, u ostatních si vytvoříte{' '}
<Link to="/dashboard/konektory" className="text-brand-300 hover:text-brand-200">
konektor
</Link>{' '}
se svými přístupovými údaji.
</p>
</header>
<DataState loading={loading} error={error} onRetry={reload} empty={items.length === 0}>
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<SummaryTile
icon={CheckCircle2}
value={counts.connected}
label="připraveno k použití"
tone="ok"
/>
<SummaryTile
icon={Plug}
value={counts.available}
label="můžete si napojit"
tone="brand"
/>
<SummaryTile icon={Clock} value={counts.planned} label="na roadmapě" tone="warn" />
</div>
<div className="glass rounded-card p-4 sm:p-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Hledat službu, spouštěč nebo akci"
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Chip active={category === 'all'} onClick={() => setCategory('all')}>
Všechny kategorie
</Chip>
{categories.map((cat) => (
<Chip
key={cat.id}
active={category === cat.id}
onClick={() => setCategory(cat.id)}
>
{cat.label}
</Chip>
))}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Chip active={view === 'all'} onClick={() => setView('all')}>
Vše
</Chip>
{(['connected', 'available', 'planned'] as const).map((value) => (
<Chip key={value} active={view === value} onClick={() => setView(value)}>
{viewMeta[value].label}
</Chip>
))}
</div>
</div>
{filtered.length === 0 ? (
<p className="py-10 text-center text-sm text-white/45">
Nic neodpovídá filtru. Chybí vám služba?{' '}
<a href={`mailto:${brand.email}`} className="text-brand-300 hover:text-brand-200">
Napište nám
</a>
, většinu API umíme napojit přes HTTP požadavek.
</p>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{filtered.map((service) => (
<ServiceCard key={service.id} service={service} />
))}
</div>
)}
</div>
</DataState>
</div>
);
}
function SummaryTile({
icon: Icon,
value,
label,
tone,
}: {
icon: typeof Plug;
value: number;
label: string;
tone: 'ok' | 'brand' | 'warn';
}) {
const tones = {
ok: 'bg-ok-500/12 text-ok-400',
brand: 'bg-brand-500/12 text-brand-300',
warn: 'bg-warn-500/12 text-warn-400',
} as const;
return (
<div className="glass flex items-center gap-4 rounded-card p-5">
<span className={cn('grid size-10 shrink-0 place-items-center rounded-xl', tones[tone])}>
<Icon className="size-5" />
</span>
<div>
<p className="text-2xl font-extrabold text-white tabular-nums">{value}</p>
<p className="text-xs text-white/45">{label}</p>
</div>
</div>
);
}
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
>
{children}
</button>
);
}
function ServiceCard({ service }: { service: ServiceWithUsage }) {
const Icon = serviceIcon(service.icon);
const meta = viewMeta[viewOf(service)];
return (
<article className="glass flex h-full flex-col rounded-card p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
<div className="min-w-0">
<h2 className="flex flex-wrap items-center gap-2 font-semibold text-white">
{service.name}
{service.general && <Badge tone="neutral">obecná</Badge>}
{service.visibility.mode === 'restricted' && (
<Lock className="size-3.5 text-warn-400" aria-label="Omezená viditelnost" />
)}
</h2>
<p className="mt-0.5 text-sm text-white/55">{service.description}</p>
</div>
</div>
<Badge tone={meta.tone}>{meta.label}</Badge>
</div>
<div className="mt-5 grid flex-1 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
<OperationList
title="Spouštěče"
icon={<Zap className="size-3 text-brand-300" />}
operations={service.triggers}
emptyLabel="Nelze použít jako spouštěč"
/>
<OperationList
title="Akce"
icon={<Plug className="size-3 text-accent-300" />}
operations={service.actions}
emptyLabel="Žádné akce"
/>
</div>
{!service.general && (
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-ink-600/50 pt-3 text-xs">
<span className="text-white/40">
{service.connectorCount === 0
? 'Bez konektoru službu ve stromu použít nelze.'
: `${service.connectorCount} ${service.connectorCount === 1 ? 'konektor' : 'konektory'}`}
</span>
<Link
to={`/dashboard/konektory?service=${service.id}`}
className="text-brand-300 hover:text-brand-200"
>
{service.connectorCount === 0 ? 'Vytvořit konektor' : 'Spravovat konektory'}
</Link>
</footer>
)}
</article>
);
}
function OperationList({
title,
icon,
operations,
emptyLabel,
}: {
title: string;
icon: ReactNode;
operations: ServiceOperation[];
emptyLabel: string;
}) {
return (
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
{icon}
{title}
</p>
{operations.length === 0 ? (
<p className="text-xs text-white/25">{emptyLabel}</p>
) : (
<ul className="space-y-1">
{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>
)}
</div>
);
}
+3 -3
View File
@@ -25,7 +25,7 @@ import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
ConnectorCatalog,
ServiceCatalog,
PeopleResponse,
TicketDetail as Detail,
TicketStatus,
@@ -48,7 +48,7 @@ export default function TicketDetail() {
refetchOn: ['ticket.updated', 'ticket.assigned', 'ticket.resolved'],
});
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
const catalog = useApiQuery<ConnectorCatalog>('/api/dashboard/connectors');
const catalog = useApiQuery<ServiceCatalog>('/api/dashboard/services');
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
@@ -168,7 +168,7 @@ export default function TicketDetail() {
<TicketTrace
entries={ticket.data.trace}
connectors={catalog.data?.items ?? []}
services={catalog.data?.items ?? []}
/>
<form
+101 -33
View File
@@ -82,7 +82,7 @@ export interface TicketTraceEntry {
id: string;
parentId: string | null;
kind: TraceKind;
connectorId: string | null;
serviceId: string | null;
operationId: string | null;
label: string;
status: TraceStatus;
@@ -158,20 +158,38 @@ export interface Automation {
// ----------------------------------------------------------------- konektory
export type ConnectorCategory =
| 'spoustece'
| 'servicedesk'
export type ServiceCategory =
/** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */
| 'obecne'
| 'crm'
| 'ekonomika'
| 'logistika'
| 'komunikace'
| 'analytika'
| 'ai'
| 'nastroje';
| 'ai';
export type ConnectorStatus = 'connected' | 'available' | 'planned';
/** available = sluzbu umime, planned = je na roadmape. */
export type ServiceStatus = 'available' | 'planned';
export interface ConnectorOperation {
export interface ServiceVisibility {
mode: 'everyone' | 'restricted' | 'admin';
tenantIds: string[];
userIds: string[];
}
/** Jeden udaj, ktery je potreba vyplnit pri zakladani konektoru. */
export interface ServiceCredentialField {
id: string;
label: string;
target: 'header' | 'config';
name: string;
required: boolean;
/** true = hodnota se ze serveru nikdy nevraci. */
secret: boolean;
hint?: string;
}
export interface ServiceOperation {
id: string;
name: string;
description: string;
@@ -211,21 +229,76 @@ export interface OperationField {
hint?: string;
}
export interface Connector {
export interface Service {
id: string;
name: string;
category: ConnectorCategory;
category: ServiceCategory;
description: string;
/** Klic ikony - mapuje se v lib/connectorIcons.ts */
/** Klic ikony - mapuje se v lib/serviceIcons.ts */
icon: string;
status: ConnectorStatus;
triggers: ConnectorOperation[];
actions: ConnectorOperation[];
status: ServiceStatus;
/** true = funguje bez konektoru (webhook, pauza, transformace dat). */
general: boolean;
appId: string | null;
visibility: ServiceVisibility;
credentials: ServiceCredentialField[];
verifyPath?: string;
triggers: ServiceOperation[];
actions: ServiceOperation[];
}
export interface ConnectorCatalog {
categories: Array<{ id: ConnectorCategory; label: string }>;
items: Connector[];
/** Sluzba ocima jedne firmy, tedy uz vcetne toho, co ma napojene. */
export interface ServiceWithUsage extends Service {
baseUrl: string;
/** Kolik konektoru na ni firma ma. 0 = muze si napojit. */
connectorCount: number;
/** Kolik akci uz opravdu neco dela, tedy ma skript. */
implementedActions: number;
}
export interface ServiceOverview {
categories: Array<{ id: ServiceCategory; label: string }>;
items: ServiceWithUsage[];
tenantId: string | null;
}
/**
* Konektor = napojeni firmy na jednu sluzbu.
* Hodnoty pristupovych udaju server nikdy nevraci, jen `filled` a `missing`.
*/
export interface Connector {
id: string;
tenantId: string;
serviceId: string;
name: string;
baseUrl: string | null;
enabled: boolean;
status: 'untested' | 'ok' | 'error';
lastCheckAt: string | null;
lastError: string | null;
isDefault: boolean;
createdAt: string;
updatedAt: string;
/** ID poli, ktera jsou vyplnena. */
filled: string[];
/** ID povinnych poli, ktera chybi. */
missing: string[];
/** Necitliva nastaveni. Tajna pole tu nejsou vubec. */
config: Record<string, string>;
ready: boolean;
}
export interface ConnectorTestResult {
ok: boolean;
/** Co se vlastne overilo: pristupove udaje, nebo jen dostupnost sluzby. */
checked: string;
status?: number;
message: string;
}
export interface ServiceCatalog {
categories: Array<{ id: ServiceCategory; label: string }>;
items: Service[];
/** Ktere operatory server povoli pro ktery typ parametru. */
operatorsByType: Record<FieldType, ConditionOperator[]>;
/** Zaklad adresy webhooku, napr. "https://api.automia.cz/webhook". */
@@ -262,7 +335,7 @@ export interface TriggerField {
// ----------------------------------------------------------- strom automatizace
export interface FlowTrigger {
connectorId: string;
serviceId: string;
operationId: string;
fields: TriggerField[];
/** Generuje vyhradne server, klient ho jen zobrazuje. */
@@ -274,7 +347,7 @@ export type FlowStep =
| {
id: string;
kind: 'action';
connectorId: string;
serviceId: string;
operationId: string;
/** Klic je `OperationField.id`, hodnota sablona s `{{parametry}}`. */
inputs?: Record<string, string>;
@@ -380,7 +453,8 @@ export interface ScriptField {
export interface ScriptManifest {
id: string;
connectorId: string;
serviceId: string;
serviceName: string;
operationId: string;
name: string;
description: string;
@@ -396,32 +470,26 @@ export interface ScriptProblem {
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[];
/** Vychozi konektor firmy podle ID sluzby. Chybi = sluzba neni napojena. */
connectors: Record<string, Connector>;
tenantId: string | null;
directory: string;
}
export interface ScriptDetail {
id: string;
connectorId: string;
serviceId: string;
operationId: string;
serviceName: string;
/** null = soubor je rozbity, manifest se nepodarilo precist. */
manifest: ScriptManifest | null;
code: string;
problem: ScriptProblem | null;
connection: ConnectionStatus;
/** Vychozi konektor firmy, pod kterym se skript da vyzkouset. */
connector: Connector | null;
}
export type ScriptErrorKind =