[eric] workflows: event-trigger panel in detail view (folder/web watch, predicate, activity feed)

This commit is contained in:
ciregenz
2026-07-28 12:01:22 -07:00
parent e0c6f796ff
commit d1ef12b2c7
4 changed files with 259 additions and 0 deletions
+10
View File
@@ -771,6 +771,16 @@ async def get_workflow_audit(workflow_id: str, limit: int = 50):
return {"entries": audit.read_tail(workflow_id, limit=limit)}
@workflows.router.get("/{workflow_id}/events")
async def get_workflow_events(workflow_id: str):
"""Event-trigger activity log, newest first ("saw X, skipped because Y, fired run Z")."""
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
from backend.apps.events.stores import read_log
return {"events": [e.model_dump(mode="json") for e in reversed(read_log(workflow_id))]}
@workflows.router.patch("/{workflow_id}")
async def update_workflow(
workflow_id: str,
@@ -12,6 +12,7 @@ import { isRunning, runContextChip } from './model';
import { useEditAgentSession } from './useEditAgentSession';
import { useWorkflowPatch } from './useWorkflowPatch';
import ScheduleCard from './ScheduleCard';
import EventTriggersCard from './EventTriggersCard';
import StepsCard from './StepsCard';
import HistoryCard from './HistoryCard';
import ColorSwatch from './ColorSwatch';
@@ -107,6 +108,7 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId
<div style={{ width: 344, height: '100%', borderLeft: `1px solid ${WC.line}`, display: 'flex', flexDirection: 'column', minHeight: 0, opacity: paneOpen ? 1 : 0, transition: 'opacity .4s ease' }}>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '18px 18px 22px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<ScheduleCard workflow={workflow} />
<EventTriggersCard workflow={workflow} />
<StepsCard workflow={workflow} />
<HistoryCard workflowId={workflow.id} title={workflow.title} />
</div>
@@ -0,0 +1,211 @@
// "When something happens" panel beside the Schedule card: watch a folder/file
// or a web page, optionally filter with a plain-English condition, and see the
// trigger's recent activity ("saw X, skipped because Y") so a quiet trigger is
// debuggable instead of mysterious.
import React, { useCallback, useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { API_BASE } from '@/shared/config';
import type { EventTriggerConfig, Workflow, WorkflowEventLogEntry } from '@/shared/state/workflowsSlice';
import { useWC, FONT_SERIF, track, knob } from './uiKit';
import { useWorkflowPatch } from './useWorkflowPatch';
const WEB_POLL_CHOICES: Array<[number, string]> = [[60, 'every minute'], [300, 'every 5 min'], [900, 'every 15 min'], [3600, 'hourly']];
function newTrigger(kind: 'file' | 'web'): EventTriggerConfig {
return {
id: crypto.randomUUID().replace(/-/g, ''),
enabled: true,
source: kind === 'file'
? { kind: 'file', path: '', poll_seconds: 15 }
: { kind: 'web', url: '', watch_for: '', poll_seconds: 300 },
predicate: '',
coalesce_seconds: kind === 'file' ? 30 : 0,
max_fires_per_hour: 6,
};
}
const EventTriggersCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
const WC = useWC();
const patch = useWorkflowPatch();
const triggers = workflow.event_triggers ?? [];
const [log, setLog] = useState<WorkflowEventLogEntry[]>([]);
const patchTriggers = useCallback((next: EventTriggerConfig[]) => {
patch(workflow, { event_triggers: next });
}, [patch, workflow]);
const updateTrigger = useCallback((id: string, mut: (t: EventTriggerConfig) => EventTriggerConfig) => {
patchTriggers(triggers.map((t) => (t.id === id ? mut(t) : t)));
}, [patchTriggers, triggers]);
// Activity poll while the panel is mounted; local endpoint, cheap.
useEffect(() => {
if (triggers.length === 0) return;
let alive = true;
const load = async () => {
try {
const r = await fetch(`${API_BASE}/workflows/${workflow.id}/events`);
const data = (await r.json()) as { events: WorkflowEventLogEntry[] };
if (alive) setLog(data.events ?? []);
} catch { /* activity is best-effort */ }
};
void load();
const iv = setInterval(load, 15_000);
return () => { alive = false; clearInterval(iv); };
}, [workflow.id, triggers.length]);
const ghostBtn: CSSProperties = {
height: 26, padding: '0 10px', borderRadius: 7, border: `1px solid rgba(${WC.inkRGB},0.12)`,
cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: WC.raised, color: WC.ink3,
};
const fieldStyle: CSSProperties = {
width: '100%', boxSizing: 'border-box', height: 30, background: WC.raised,
border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, padding: '0 9px',
fontSize: 12.5, color: WC.ink,
};
const labelStyle: CSSProperties = { fontSize: 11.5, color: WC.muted, marginBottom: 4, display: 'block' };
const dotColor = (kind: WorkflowEventLogEntry['kind']): string => {
if (kind === 'fired') return WC.accent;
if (kind === 'emitted') return WC.muted;
return WC.warn;
};
return (
<div style={{ background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.lg, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink }}>Event triggers</span>
<div style={{ display: 'flex', gap: 6 }}>
<button style={ghostBtn} onClick={() => patchTriggers([...triggers, newTrigger('file')])}>+ Folder</button>
<button style={ghostBtn} onClick={() => patchTriggers([...triggers, newTrigger('web')])}>+ Web page</button>
</div>
</div>
{triggers.length === 0 && (
<span style={{ fontSize: 12.5, color: WC.ink4, lineHeight: 1.5, display: 'block' }}>
Run this workflow when something happens: a file lands in a folder, or a page you care about changes.
</span>
)}
{triggers.map((t) => {
const src = t.source;
return (
<div key={t.id} style={{ border: `1px solid ${WC.line}`, borderRadius: 10, padding: '10px 11px', marginBottom: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 9 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: WC.ink3 }}>
{src.kind === 'file' ? 'Folder / file watch' : 'Web page watch'}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
onClick={() => updateTrigger(t.id, (x) => ({ ...x, enabled: !x.enabled }))}
style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
>
<span style={{ fontSize: 11.5, fontWeight: 600, color: t.enabled ? WC.accent : WC.muted }}>{t.enabled ? 'On' : 'Off'}</span>
<div style={track(t.enabled, WC)}><div style={knob(t.enabled)} /></div>
</div>
<button
aria-label="Remove trigger"
onClick={() => patchTriggers(triggers.filter((x) => x.id !== t.id))}
style={{ ...ghostBtn, padding: '0 8px' }}
>
</button>
</div>
</div>
{src.kind === 'file' ? (
<div style={{ marginBottom: 8 }}>
<span style={labelStyle}>Watch this folder or file</span>
<input
style={fieldStyle}
defaultValue={src.path}
placeholder="~/Downloads"
onBlur={(e) => {
const path = e.target.value.trim();
if (path !== src.path) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, path } }));
}}
/>
</div>
) : (
<>
<div style={{ marginBottom: 8 }}>
<span style={labelStyle}>Page URL</span>
<input
style={fieldStyle}
defaultValue={src.url}
placeholder="https://example.com/reservations"
onBlur={(e) => {
const url = e.target.value.trim();
if (url !== src.url) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, url } }));
}}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<span style={labelStyle}>Watching for</span>
<input
style={fieldStyle}
defaultValue={src.watch_for}
placeholder="a reservation slot opening"
onBlur={(e) => {
const watchFor = e.target.value.trim();
if (watchFor !== src.watch_for) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, watch_for: watchFor } }));
}}
/>
</div>
<div style={{ width: 118, flex: 'none' }}>
<span style={labelStyle}>Check</span>
<select
value={src.poll_seconds}
onChange={(e) => {
const pollSeconds = parseInt(e.target.value, 10);
updateTrigger(t.id, (x) => ({ ...x, source: { ...src, poll_seconds: pollSeconds } }));
}}
style={{ ...fieldStyle, cursor: 'pointer', padding: '0 6px' }}
>
{WEB_POLL_CHOICES.map(([v, label]) => <option key={v} value={v}>{label}</option>)}
</select>
</div>
</div>
</>
)}
<div>
<span style={labelStyle}>Only when (optional, plain English)</span>
<input
style={fieldStyle}
defaultValue={t.predicate}
placeholder={src.kind === 'file' ? 'a new CSV export shows up' : 'the change mentions Friday or Saturday'}
onBlur={(e) => {
const predicate = e.target.value.trim();
if (predicate !== t.predicate) updateTrigger(t.id, (x) => ({ ...x, predicate }));
}}
/>
</div>
</div>
);
})}
{triggers.length > 0 && log.length > 0 && (
<div style={{ marginTop: 4, paddingTop: 11, borderTop: `1px solid ${WC.line}` }}>
<span style={{ ...labelStyle, marginBottom: 7 }}>Recent activity</span>
{log.slice(0, 6).map((e, i) => (
<div key={`${e.ts}-${i}`} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 6 }}>
<div style={{ width: 14, display: 'flex', justifyContent: 'center', flex: 'none', paddingTop: 5 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: dotColor(e.kind) }} />
</div>
<span style={{ fontSize: 11.5, color: WC.ink4, lineHeight: 1.45 }}>
{new Date(e.ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
{' '}
{e.summary}
</span>
</div>
))}
</div>
)}
</div>
);
};
export default EventTriggersCard;
@@ -28,6 +28,40 @@ export interface ScheduleConfig {
runs_count: number;
}
export interface FileWatchSource {
kind: 'file';
path: string;
poll_seconds: number;
}
export interface WebWatchSource {
kind: 'web';
url: string;
/** What change actually matters, in the user's words ("a reservation slot opens"). */
watch_for: string;
poll_seconds: number;
}
export type EventSourceConfig = FileWatchSource | WebWatchSource;
export interface EventTriggerConfig {
id: string;
enabled: boolean;
source: EventSourceConfig;
/** Natural-language filter judged per event batch; empty = every batch fires. */
predicate: string;
coalesce_seconds: number;
max_fires_per_hour: number;
}
export interface WorkflowEventLogEntry {
ts: string;
trigger_id: string;
kind: 'emitted' | 'fired' | 'skipped' | 'error';
summary: string;
run_id?: string | null;
}
export interface CostEstimate {
monthly_usd: number;
last_run_usd: number;
@@ -74,6 +108,8 @@ export interface Workflow {
steps: WorkflowStep[];
actions: ActionsConfig;
schedule: ScheduleConfig;
/** Event triggers live beside the schedule; "every Monday AND when this file changes" is legitimate. */
event_triggers?: EventTriggerConfig[];
permissions: PermissionTier[];
source_session_id?: string | null;
dashboard_id?: string | null;