mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(flows): Workflows B3b — Run Inspector drawer (#4450)
This commit is contained in:
@@ -569,7 +569,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn marker_is_fresh_bounds_on_age() {
|
||||
assert!(marker_is_fresh(Some(Duration::from_secs(0)), MARKER_MAX_AGE));
|
||||
assert!(marker_is_fresh(
|
||||
Some(Duration::from_secs(0)),
|
||||
MARKER_MAX_AGE
|
||||
));
|
||||
assert!(marker_is_fresh(Some(MARKER_MAX_AGE), MARKER_MAX_AGE));
|
||||
assert!(!marker_is_fresh(
|
||||
Some(MARKER_MAX_AGE + Duration::from_secs(1)),
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* FlowRunInspectorDrawer (issue B3b)
|
||||
* ----------------------------------
|
||||
*
|
||||
* Right-side drawer showing a single durable `tinyflows` run's status + step
|
||||
* timeline, opened from the "View run" action on {@link FlowApprovalCard}.
|
||||
* Drawer chrome mirrors `pages/conversations/components/SubagentDrawer.tsx`
|
||||
* (fixed overlay + backdrop-click-to-close + Escape-to-close) so it renders
|
||||
* as a fixed overlay regardless of where the parent mounts it in the DOM.
|
||||
*
|
||||
* Data comes from {@link useFlowRunPoller}, which polls
|
||||
* `openhuman.flows_get_run` every 2s until the run reaches a terminal status
|
||||
* (`completed`/`failed`) — `pending_approval` keeps polling since the run can
|
||||
* still be resumed elsewhere.
|
||||
*
|
||||
* `FlowRunStep` is lean by design (`node_id` + `output` + optional `port`
|
||||
* only — no per-step status/timing), so each step renders as a plain label
|
||||
* + collapsible output, not a graduated status timeline. Status-dot/pill
|
||||
* visual language borrows from `components/intelligence/WorkflowRunDetail.tsx`
|
||||
* (`RUN_STATUS_ACCENT`/`PHASE_STATUS_DOT`) and
|
||||
* `pages/conversations/components/ToolTimelineBlock.tsx` (`StatusTag`) —
|
||||
* dots, not progress bars (project rule).
|
||||
*/
|
||||
import debug from 'debug';
|
||||
|
||||
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||
import { useFlowRunPoller } from '../../hooks/useFlowRunPoller';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { FlowRunStatus, FlowRunStep } from '../../services/api/flowsApi';
|
||||
|
||||
const log = debug('flows:run-inspector-drawer');
|
||||
|
||||
/** Accent classes per run status (semantic palette from tailwind.config.js). */
|
||||
const FLOW_RUN_STATUS_ACCENT: Record<FlowRunStatus, string> = {
|
||||
running:
|
||||
'border-ocean-200 bg-ocean-50 text-ocean-700 dark:border-ocean-500/30 dark:bg-ocean-500/10 dark:text-ocean-300',
|
||||
completed:
|
||||
'border-sage-200 bg-sage-50 text-sage-700 dark:border-sage-500/30 dark:bg-sage-500/10 dark:text-sage-300',
|
||||
pending_approval:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300',
|
||||
failed:
|
||||
'border-coral-200 bg-coral-50 text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300',
|
||||
};
|
||||
|
||||
/** Header status dot per run status — mirrors `PHASE_STATUS_DOT`. */
|
||||
const FLOW_RUN_STATUS_DOT: Record<FlowRunStatus, string> = {
|
||||
running: 'bg-ocean-500 animate-pulse',
|
||||
completed: 'bg-sage-500',
|
||||
pending_approval: 'bg-amber-500 animate-pulse',
|
||||
failed: 'bg-coral-500',
|
||||
};
|
||||
|
||||
const FLOW_RUN_STATUS_KEY: Record<FlowRunStatus, string> = {
|
||||
running: 'flowRuns.status.running',
|
||||
completed: 'flowRuns.status.completed',
|
||||
pending_approval: 'flowRuns.status.pending_approval',
|
||||
failed: 'flowRuns.status.failed',
|
||||
};
|
||||
|
||||
function formatTimestamp(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(new Date(parsed));
|
||||
}
|
||||
|
||||
/** Render a step's `output` — pretty-printed JSON for objects/arrays, verbatim for strings. */
|
||||
function formatStepOutput(output: unknown): string {
|
||||
if (output == null) return '';
|
||||
if (typeof output === 'string') return output;
|
||||
try {
|
||||
return JSON.stringify(output, null, 2);
|
||||
} catch {
|
||||
return String(output);
|
||||
}
|
||||
}
|
||||
|
||||
function StepRow({ step, index }: { step: FlowRunStep; index: number }) {
|
||||
const { t } = useT();
|
||||
const outputText = formatStepOutput(step.output);
|
||||
|
||||
return (
|
||||
<li
|
||||
data-testid={`flow-run-step-${index}`}
|
||||
className="rounded-lg border border-line bg-surface-muted p-2.5 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 flex-none rounded-full bg-content-faint" aria-hidden />
|
||||
<span className="truncate font-mono font-medium text-content-secondary">
|
||||
{step.node_id}
|
||||
</span>
|
||||
{step.port !== undefined && (
|
||||
<span
|
||||
data-testid={`flow-run-step-port-${index}`}
|
||||
className="rounded-md border border-line px-1.5 py-0.5 text-[10px] font-medium text-content-muted">
|
||||
{t('flowRuns.inspector.port')}: {step.port}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{outputText.length > 0 && (
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-[11px] font-medium text-content-faint hover:text-content-secondary">
|
||||
{t('flowRuns.inspector.output')}
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words rounded bg-surface px-2 py-1.5 font-mono text-[11px] leading-relaxed text-content-secondary">
|
||||
{outputText}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Run id (== thread_id) to inspect. Renders `null` (nothing) when absent. */
|
||||
runId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `null` when `runId` is `null` so the parent can mount this
|
||||
* unconditionally and just flip `runId` (same convention as
|
||||
* `SubagentDrawer`).
|
||||
*/
|
||||
export function FlowRunInspectorDrawer({ runId, onClose }: Props) {
|
||||
const { t } = useT();
|
||||
const { run, loading, error } = useFlowRunPoller(runId);
|
||||
|
||||
useEscapeKey(() => {
|
||||
log('escape: closing runId=%s', runId);
|
||||
onClose();
|
||||
}, runId !== null);
|
||||
|
||||
if (!runId) return null;
|
||||
|
||||
const startedAt = formatTimestamp(run?.started_at);
|
||||
const finishedAt = formatTimestamp(run?.finished_at);
|
||||
const pendingCount = run?.pending_approvals.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end" data-testid="flow-run-inspector-drawer">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('conversations.subagent.close')}
|
||||
data-testid="flow-run-inspector-backdrop"
|
||||
className="absolute inset-0 bg-stone-900/30 dark:bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside className="relative flex h-full w-full max-w-md flex-col bg-surface shadow-xl">
|
||||
{/* Header */}
|
||||
<header className="flex items-start gap-2.5 border-b border-line px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-semibold text-content">
|
||||
{t('flowRuns.inspector.title')}
|
||||
</span>
|
||||
{run && (
|
||||
<span
|
||||
data-testid="flow-run-status-dot"
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[run.status]}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
|
||||
{run && (
|
||||
<span
|
||||
data-testid="flow-run-status-pill"
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 font-medium ${FLOW_RUN_STATUS_ACCENT[run.status]}`}>
|
||||
{t(FLOW_RUN_STATUS_KEY[run.status])}
|
||||
</span>
|
||||
)}
|
||||
{run && <span className="truncate font-mono">{run.flow_id}</span>}
|
||||
{run && <span className="truncate font-mono">{run.thread_id}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="flow-run-inspector-close"
|
||||
onClick={onClose}
|
||||
aria-label={t('conversations.subagent.close')}
|
||||
className="shrink-0 rounded-full p-1.5 text-content-faint hover:bg-surface-hover hover:text-content-secondary">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||
{loading && !run && (
|
||||
<div
|
||||
className="flex items-center gap-2 py-8 text-content-faint"
|
||||
data-testid="flow-run-inspector-loading">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
|
||||
<span className="text-sm">{t('flowRuns.inspector.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="flow-run-inspector-error"
|
||||
className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('flowRuns.inspector.loadError')}: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run && (
|
||||
<>
|
||||
{/* Timing */}
|
||||
<div className="text-xs text-content-muted" data-testid="flow-run-timing">
|
||||
{startedAt && (
|
||||
<div>
|
||||
{t('flowRuns.inspector.startedAt')}: {startedAt}
|
||||
</div>
|
||||
)}
|
||||
{finishedAt ? (
|
||||
<div>
|
||||
{t('flowRuns.inspector.finishedAt')}: {finishedAt}
|
||||
</div>
|
||||
) : run.status === 'running' || run.status === 'pending_approval' ? (
|
||||
<div className="animate-pulse">{t('flowRuns.inspector.running')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{run.error && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="flow-run-error-banner"
|
||||
className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('flowRuns.inspector.error')}: {run.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending approvals banner */}
|
||||
{run.status === 'pending_approval' && pendingCount > 0 && (
|
||||
<div
|
||||
data-testid="flow-run-pending-approvals-banner"
|
||||
className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300">
|
||||
{t('flowRuns.inspector.pendingApprovalsCount').replace(
|
||||
'{count}',
|
||||
String(pendingCount)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps timeline */}
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('flowRuns.inspector.steps')}
|
||||
</h3>
|
||||
{run.steps.length === 0 ? (
|
||||
<p className="text-xs italic text-content-faint">
|
||||
{t('flowRuns.inspector.noSteps')}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-2" data-testid="flow-run-steps">
|
||||
{run.steps.map((step, idx) => (
|
||||
<StepRow key={`${step.node_id}-${idx}`} step={step} index={idx} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowRunInspectorDrawer;
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* FlowRunInspectorDrawer (issue B3b) — rendering contract.
|
||||
*
|
||||
* Asserts: renders null when `runId` is null; loading state; renders fetched
|
||||
* run data (status pill, steps, expandable output, port pill); error state;
|
||||
* pending-approvals banner when `status === 'pending_approval'`; run.error
|
||||
* banner; Escape and backdrop both close; close button calls `onClose`.
|
||||
*
|
||||
* Mocks `useFlowRunPoller` directly rather than the underlying RPC client —
|
||||
* its own poll-until-terminal contract is covered by
|
||||
* `hooks/__tests__/useFlowRunPoller.test.ts`.
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { FlowRun } from '../../../services/api/flowsApi';
|
||||
import { store } from '../../../store';
|
||||
import { FlowRunInspectorDrawer } from '../FlowRunInspectorDrawer';
|
||||
|
||||
const useFlowRunPoller = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../../hooks/useFlowRunPoller', () => ({ useFlowRunPoller }));
|
||||
|
||||
function makeRun(overrides: Partial<FlowRun> = {}): FlowRun {
|
||||
return {
|
||||
id: 'thread-1',
|
||||
flow_id: 'flow-1',
|
||||
thread_id: 'thread-1',
|
||||
status: 'running',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
steps: [
|
||||
{ node_id: 'fetch-data', output: { rows: 3 } },
|
||||
{ node_id: 'branch', output: 'ok', port: 'true' },
|
||||
],
|
||||
pending_approvals: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDrawer(runId: string | null, onClose: () => void) {
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<FlowRunInspectorDrawer runId={runId} onClose={onClose} />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('FlowRunInspectorDrawer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders null when runId is null', () => {
|
||||
useFlowRunPoller.mockReturnValue({ run: null, loading: false, error: null });
|
||||
const { container } = renderDrawer(null, vi.fn());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(useFlowRunPoller).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('shows a loading state before data resolves', () => {
|
||||
useFlowRunPoller.mockReturnValue({ run: null, loading: true, error: null });
|
||||
renderDrawer('thread-1', vi.fn());
|
||||
expect(screen.getByTestId('flow-run-inspector-loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the run status pill and step list once data resolves', () => {
|
||||
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
|
||||
renderDrawer('thread-1', vi.fn());
|
||||
|
||||
expect(screen.getByTestId('flow-run-status-pill')).toHaveTextContent('Running');
|
||||
expect(screen.getByTestId('flow-run-steps')).toBeInTheDocument();
|
||||
expect(screen.getByText('fetch-data')).toBeInTheDocument();
|
||||
expect(screen.getByText('branch')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-run-step-port-1')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
it('expands a step to reveal its output', () => {
|
||||
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
|
||||
renderDrawer('thread-1', vi.fn());
|
||||
|
||||
const step = screen.getByTestId('flow-run-step-0');
|
||||
expect(step.querySelector('pre')).not.toBeVisible();
|
||||
fireEvent.click(screen.getAllByText('Output')[0]);
|
||||
expect(step.querySelector('pre')).toBeVisible();
|
||||
expect(step.querySelector('pre')?.textContent).toContain('"rows": 3');
|
||||
});
|
||||
|
||||
it('shows an error state when the poller reports an error', () => {
|
||||
useFlowRunPoller.mockReturnValue({ run: null, loading: false, error: 'network down' });
|
||||
renderDrawer('thread-1', vi.fn());
|
||||
expect(screen.getByTestId('flow-run-inspector-error')).toHaveTextContent('network down');
|
||||
});
|
||||
|
||||
it('shows the pending-approvals banner when status is pending_approval', () => {
|
||||
useFlowRunPoller.mockReturnValue({
|
||||
run: makeRun({ status: 'pending_approval', pending_approvals: ['node-a', 'node-b'] }),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
renderDrawer('thread-1', vi.fn());
|
||||
expect(screen.getByTestId('flow-run-pending-approvals-banner')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('does not show the pending-approvals banner for a running run', () => {
|
||||
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
|
||||
renderDrawer('thread-1', vi.fn());
|
||||
expect(screen.queryByTestId('flow-run-pending-approvals-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the run.error banner when present', () => {
|
||||
useFlowRunPoller.mockReturnValue({
|
||||
run: makeRun({ status: 'failed', error: 'node crashed' }),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
renderDrawer('thread-1', vi.fn());
|
||||
expect(screen.getByTestId('flow-run-error-banner')).toHaveTextContent('node crashed');
|
||||
});
|
||||
|
||||
it('calls onClose when the close button is clicked', () => {
|
||||
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
|
||||
const onClose = vi.fn();
|
||||
renderDrawer('thread-1', onClose);
|
||||
fireEvent.click(screen.getByTestId('flow-run-inspector-close'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when the backdrop is clicked', () => {
|
||||
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
|
||||
const onClose = vi.fn();
|
||||
renderDrawer('thread-1', onClose);
|
||||
fireEvent.click(screen.getByTestId('flow-run-inspector-backdrop'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when Escape is pressed', () => {
|
||||
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
|
||||
const onClose = vi.fn();
|
||||
renderDrawer('thread-1', onClose);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Approve/Dismiss contract for the flow-pending-approval notification card
|
||||
* (issue B3a). Asserts that Approve reads `{ flow_id, thread_id, node_ids }`
|
||||
* from the notification's action payload, calls `flowsApi.resumeFlow` with
|
||||
* those args, clears the notification on success, surfaces a localized error
|
||||
* on failure, and that Dismiss clears the notification WITHOUT calling any RPC
|
||||
* (there is no `flows_deny` endpoint yet).
|
||||
* Approve/Dismiss/View-run contract for the flow-pending-approval
|
||||
* notification card (issues B3a + B3b). Asserts that Approve reads
|
||||
* `{ flow_id, thread_id, node_ids }` from the notification's action payload,
|
||||
* calls `flowsApi.resumeFlow` with those args, clears the notification on
|
||||
* success, surfaces a localized error on failure (including when `node_ids`
|
||||
* contains non-string entries — an invalid payload), that Dismiss clears the
|
||||
* notification WITHOUT calling any RPC (there is no `flows_deny` endpoint
|
||||
* yet), and that "View run" opens the {@link FlowRunInspectorDrawer}.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
@@ -17,6 +19,11 @@ import FlowApprovalCard from './FlowApprovalCard';
|
||||
const resumeFlow = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../services/api/flowsApi', () => ({ resumeFlow }));
|
||||
|
||||
vi.mock('../flows/FlowRunInspectorDrawer', () => ({
|
||||
FlowRunInspectorDrawer: ({ runId }: { runId: string | null; onClose: () => void }) =>
|
||||
runId ? <div data-testid="flow-run-inspector-drawer-stub">{runId}</div> : null,
|
||||
}));
|
||||
|
||||
function makeItem(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
||||
return {
|
||||
id: 'flow-pending-approval:flow-1:thread-1',
|
||||
@@ -92,7 +99,11 @@ describe('FlowApprovalCard', () => {
|
||||
it('does NOT clear the notification when the run parks again on the next gate', async () => {
|
||||
// Sequential gates: resume returns with pending_approvals still non-empty and
|
||||
// the core re-publishes the same-id prompt — the card must not wipe it.
|
||||
resumeFlow.mockResolvedValue({ output: null, pending_approvals: ['node-c'], thread_id: 'thread-1' });
|
||||
resumeFlow.mockResolvedValue({
|
||||
output: null,
|
||||
pending_approvals: ['node-c'],
|
||||
thread_id: 'thread-1',
|
||||
});
|
||||
store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() });
|
||||
renderCard(makeItem());
|
||||
|
||||
@@ -105,9 +116,7 @@ describe('FlowApprovalCard', () => {
|
||||
expect(item?.actions).toHaveLength(1);
|
||||
expect(item?.read).toBe(false);
|
||||
// Approve re-enabled so the user can act on the next gate.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('flow-approval-approve')).not.toBeDisabled()
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId('flow-approval-approve')).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it('shows a localized error and re-enables the buttons when resumeFlow rejects', async () => {
|
||||
@@ -168,4 +177,54 @@ describe('FlowApprovalCard', () => {
|
||||
});
|
||||
expect(resumeFlow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats non-string node_ids as an invalid payload (Approve errors, no resumeFlow call)', async () => {
|
||||
renderCard(
|
||||
makeItem({
|
||||
actions: [
|
||||
{
|
||||
actionId: 'approve',
|
||||
label: 'Review',
|
||||
payload: { flow_id: 'flow-1', thread_id: 'thread-1', node_ids: [42, null] },
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-approval-approve'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
(_content, element) =>
|
||||
element?.tagName.toLowerCase() === 'p' &&
|
||||
(element?.textContent ?? '').includes(
|
||||
'Could not resume the workflow. Please try again.'
|
||||
)
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(resumeFlow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render "View run" when the payload is invalid', () => {
|
||||
renderCard(
|
||||
makeItem({
|
||||
actions: [{ actionId: 'approve', label: 'Review', payload: { flow_id: 'flow-1' } }],
|
||||
})
|
||||
);
|
||||
expect(screen.queryByTestId('flow-approval-view-run')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"View run" opens the run inspector drawer for the payload thread_id', () => {
|
||||
renderCard(makeItem());
|
||||
|
||||
expect(screen.queryByTestId('flow-run-inspector-drawer-stub')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-approval-view-run'));
|
||||
|
||||
const drawer = screen.getByTestId('flow-run-inspector-drawer-stub');
|
||||
expect(drawer).toBeInTheDocument();
|
||||
expect(drawer).toHaveTextContent('thread-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,12 +15,16 @@
|
||||
* actions and marks it read. Dismiss is UI-only: there is no `flows_deny` /
|
||||
* cancel-run RPC yet (documented follow-up — the run stays parked
|
||||
* `pending_approval` server-side and can still be approved later from the run
|
||||
* history once B3b's inspector ships), so Dismiss just clears the prompt from
|
||||
* the Notification Center without touching the engine.
|
||||
* history), so Dismiss just clears the prompt from the Notification Center
|
||||
* without touching the engine.
|
||||
*
|
||||
* Styling mirrors the existing amber approval chrome
|
||||
* (`WorkflowRunApprovalCard`) and the `role="alertdialog"` a11y pattern
|
||||
* (`ApprovalRequestCard`) so this reads as the same affordance family.
|
||||
*
|
||||
* "View run" (B3b) opens {@link FlowRunInspectorDrawer} for the run's status +
|
||||
* step timeline (run id === the payload's `thread_id`) without disturbing the
|
||||
* Approve/Dismiss flow above.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useState } from 'react';
|
||||
@@ -33,6 +37,7 @@ import {
|
||||
markRead,
|
||||
type NotificationItem,
|
||||
} from '../../store/notificationSlice';
|
||||
import { FlowRunInspectorDrawer } from '../flows/FlowRunInspectorDrawer';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
const log = debug('notifications:flow-approval-card');
|
||||
@@ -50,7 +55,8 @@ function isFlowApprovalPayload(value: unknown): value is FlowApprovalPayload {
|
||||
return (
|
||||
typeof record.flow_id === 'string' &&
|
||||
typeof record.thread_id === 'string' &&
|
||||
Array.isArray(record.node_ids)
|
||||
Array.isArray(record.node_ids) &&
|
||||
record.node_ids.every((x: unknown) => typeof x === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +73,7 @@ const FlowApprovalCard = ({ notification: n }: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [pending, setPending] = useState<'approve' | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inspecting, setInspecting] = useState(false);
|
||||
|
||||
const payload = n.actions?.[0]?.payload;
|
||||
const parsed = isFlowApprovalPayload(payload) ? payload : null;
|
||||
@@ -174,9 +181,31 @@ const FlowApprovalCard = ({ notification: n }: Props) => {
|
||||
onClick={handleDismiss}>
|
||||
{t('notifications.flow.dismiss')}
|
||||
</Button>
|
||||
{parsed && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
data-testid="flow-approval-view-run"
|
||||
onClick={() => {
|
||||
log(
|
||||
'viewRun: opening drawer notification=%s threadId=%s',
|
||||
n.id,
|
||||
parsed.thread_id
|
||||
);
|
||||
setInspecting(true);
|
||||
}}>
|
||||
{t('notifications.flow.viewRun')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{parsed && (
|
||||
<FlowRunInspectorDrawer
|
||||
runId={inspecting ? parsed.thread_id : null}
|
||||
onClose={() => setInspecting(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* useFlowRunPoller (issue B3b) — poll-until-terminal contract.
|
||||
*
|
||||
* Asserts: initial loading→resolved, 2s poll cadence while `running` /
|
||||
* `pending_approval`, stop on `completed`/`failed`, stop when `runId` goes
|
||||
* `null`, error surfaced (and no further poll) on rejection, and effect
|
||||
* cleanup on unmount.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { FlowRun } from '../../services/api/flowsApi';
|
||||
import { useFlowRunPoller } from '../useFlowRunPoller';
|
||||
|
||||
const getFlowRun = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../services/api/flowsApi', () => ({ getFlowRun }));
|
||||
|
||||
function makeRun(overrides: Partial<FlowRun> = {}): FlowRun {
|
||||
return {
|
||||
id: 'thread-1',
|
||||
flow_id: 'flow-1',
|
||||
thread_id: 'thread-1',
|
||||
status: 'running',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
steps: [],
|
||||
pending_approvals: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useFlowRunPoller', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('starts in loading and resolves with the first fetched run', async () => {
|
||||
getFlowRun.mockResolvedValue(makeRun());
|
||||
const { result } = renderHook(() => useFlowRunPoller('thread-1'));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.run).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.run?.status).toBe('running');
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('polls every 2s while the run is running', async () => {
|
||||
getFlowRun.mockResolvedValue(makeRun({ status: 'running' }));
|
||||
renderHook(() => useFlowRunPoller('thread-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('keeps polling while pending_approval (not terminal)', async () => {
|
||||
getFlowRun.mockResolvedValue(makeRun({ status: 'pending_approval' }));
|
||||
const { result } = renderHook(() => useFlowRunPoller('thread-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.run?.status).toBe('pending_approval');
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops polling once the run completes', async () => {
|
||||
getFlowRun.mockResolvedValue(
|
||||
makeRun({ status: 'completed', finished_at: '2026-01-01T00:01:00Z' })
|
||||
);
|
||||
const { result } = renderHook(() => useFlowRunPoller('thread-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.run?.status).toBe('completed');
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops polling once the run fails', async () => {
|
||||
getFlowRun.mockResolvedValue(makeRun({ status: 'failed', error: 'boom' }));
|
||||
const { result } = renderHook(() => useFlowRunPoller('thread-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.run?.status).toBe('failed');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops and clears state when runId becomes null', async () => {
|
||||
getFlowRun.mockResolvedValue(makeRun({ status: 'running' }));
|
||||
const { result, rerender } = renderHook(({ runId }) => useFlowRunPoller(runId), {
|
||||
initialProps: { runId: 'thread-1' as string | null },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.run).not.toBeNull();
|
||||
|
||||
rerender({ runId: null });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(result.current.run).toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sets error on rejection and does not schedule another poll', async () => {
|
||||
getFlowRun.mockRejectedValue(new Error('network down'));
|
||||
const { result } = renderHook(() => useFlowRunPoller('thread-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('network down');
|
||||
expect(result.current.loading).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up pending timers on unmount', async () => {
|
||||
getFlowRun.mockResolvedValue(makeRun({ status: 'running' }));
|
||||
const { unmount } = renderHook(() => useFlowRunPoller('thread-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
// No further calls after unmount.
|
||||
expect(getFlowRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when runId starts null', async () => {
|
||||
const { result } = renderHook(() => useFlowRunPoller(null));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.run).toBeNull();
|
||||
expect(getFlowRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* useFlowRunPoller (issue B3b)
|
||||
* ----------------------------
|
||||
*
|
||||
* Poll-until-terminal loop for a single durable `tinyflows` run, feeding the
|
||||
* {@link FlowRunInspectorDrawer}. The flows engine emits no socket events for
|
||||
* run progress (same situation as the `workflow_run_*` orchestration surface),
|
||||
* so this mirrors the setTimeout-chained poll loop in
|
||||
* `components/intelligence/IntelligenceOrchestrationTab.tsx` (~lines 112-143):
|
||||
* schedule the next poll only after the current one resolves and the run is
|
||||
* still non-terminal, guard against races with `cancelled`/`inFlight`, and
|
||||
* never let an unmounted component call `setState`.
|
||||
*
|
||||
* `pending_approval` is explicitly NOT terminal — a paused run still needs
|
||||
* live status so the drawer reflects an approval elsewhere resolving it.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { type FlowRun, type FlowRunStatus, getFlowRun } from '../services/api/flowsApi';
|
||||
|
||||
const log = debug('flows:poller');
|
||||
|
||||
/** How often to poll a non-terminal run for progress. */
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
const TERMINAL = new Set<FlowRunStatus>(['completed', 'failed']);
|
||||
|
||||
function isTerminal(run: FlowRun | null): boolean {
|
||||
return run !== null && TERMINAL.has(run.status);
|
||||
}
|
||||
|
||||
export interface UseFlowRunPollerResult {
|
||||
run: FlowRun | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll `openhuman.flows_get_run` for `runId` every {@link POLL_INTERVAL_MS}ms
|
||||
* while the run is `running` or `pending_approval`. Stops polling once the
|
||||
* run reaches a terminal status, when `runId` becomes `null`, when `runId`
|
||||
* changes, or on unmount. A failed fetch surfaces `error` and does NOT
|
||||
* schedule another poll — a broken endpoint shouldn't be hammered.
|
||||
*/
|
||||
export function useFlowRunPoller(runId: string | null): UseFlowRunPollerResult {
|
||||
// Lazy initial state keyed off the `runId` this hook instance first mounts
|
||||
// with, so the loading spinner is already correct on the very first paint
|
||||
// without a synchronous `setState` in the effect body below.
|
||||
const [run, setRun] = useState<FlowRun | null>(null);
|
||||
const [loading, setLoading] = useState(() => runId !== null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset view state for the new target — avoids painting the previous
|
||||
// runId's data/error under a different runId while the first fetch for
|
||||
// it is in flight. (On the very first mount this just re-applies the
|
||||
// lazy-initial values above, so it's a no-op paint-wise.)
|
||||
setRun(null);
|
||||
setError(null);
|
||||
|
||||
if (!runId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
let cancelled = false;
|
||||
let inFlight = false;
|
||||
let pollHandle: number | undefined;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled || inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const next = await getFlowRun(runId);
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
setRun(next);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
if (!isTerminal(next)) {
|
||||
pollHandle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS);
|
||||
} else {
|
||||
log('tick: runId=%s reached terminal status=%s', runId, next.status);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('tick: error runId=%s err=%s', runId, msg);
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
setError(msg);
|
||||
setLoading(false);
|
||||
// Do not schedule another poll — leave retrying to the caller (e.g.
|
||||
// reopening the drawer) rather than hammering a broken endpoint.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
void tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollHandle !== undefined) window.clearTimeout(pollHandle);
|
||||
};
|
||||
}, [runId]);
|
||||
|
||||
return { run, loading, error };
|
||||
}
|
||||
|
||||
export default useFlowRunPoller;
|
||||
@@ -3547,6 +3547,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': 'في انتظار {count} من بوابات الموافقة',
|
||||
'notifications.flow.approveHint': 'استئناف سير العمل بعد نقطة التحقق هذه',
|
||||
'notifications.flow.dismissHint': 'إخفاء هذا التنبيه دون استئناف سير العمل',
|
||||
'notifications.flow.viewRun': 'عرض التشغيل',
|
||||
'flowRuns.inspector.title': 'تفاصيل التشغيل',
|
||||
'flowRuns.inspector.startedAt': 'بدأ',
|
||||
'flowRuns.inspector.finishedAt': 'انتهى',
|
||||
'flowRuns.inspector.running': 'قيد التشغيل…',
|
||||
'flowRuns.inspector.error': 'خطأ',
|
||||
'flowRuns.inspector.pendingApprovals': 'الموافقات المعلقة',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count} عقدة (عقد) في انتظار الموافقة',
|
||||
'flowRuns.inspector.steps': 'الخطوات',
|
||||
'flowRuns.inspector.noSteps': 'لم يتم تسجيل أي خطوات بعد.',
|
||||
'flowRuns.inspector.output': 'المخرجات',
|
||||
'flowRuns.inspector.port': 'المنفذ',
|
||||
'flowRuns.inspector.loading': 'جارٍ تحميل التشغيل…',
|
||||
'flowRuns.inspector.loadError': 'تعذّر تحميل هذا التشغيل',
|
||||
'flowRuns.status.running': 'قيد التشغيل',
|
||||
'flowRuns.status.completed': 'مكتمل',
|
||||
'flowRuns.status.pending_approval': 'بانتظار الموافقة',
|
||||
'flowRuns.status.failed': 'فشل',
|
||||
'oauth.button.connecting': 'جارٍ الاتصال...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'انتهت مهلة تسجيل الدخول — لم يكتمل المتصفح إعادة توجيه OAuth. يرجى المحاولة مرة أخرى.',
|
||||
|
||||
@@ -3628,6 +3628,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': '{count}টি অনুমোদন গেট মুলতুবি আছে',
|
||||
'notifications.flow.approveHint': 'এই চেকপয়েন্টের পরে ওয়ার্কফ্লো আবার শুরু করুন',
|
||||
'notifications.flow.dismissHint': 'ওয়ার্কফ্লো আবার শুরু না করে এই প্রম্পটটি লুকান',
|
||||
'notifications.flow.viewRun': 'রান দেখুন',
|
||||
'flowRuns.inspector.title': 'রান বিবরণ',
|
||||
'flowRuns.inspector.startedAt': 'শুরু হয়েছে',
|
||||
'flowRuns.inspector.finishedAt': 'শেষ হয়েছে',
|
||||
'flowRuns.inspector.running': 'চলছে…',
|
||||
'flowRuns.inspector.error': 'ত্রুটি',
|
||||
'flowRuns.inspector.pendingApprovals': 'মুলতুবি অনুমোদন',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count}টি নোড অনুমোদনের অপেক্ষায়',
|
||||
'flowRuns.inspector.steps': 'ধাপ',
|
||||
'flowRuns.inspector.noSteps': 'এখনও কোনো ধাপ রেকর্ড করা হয়নি।',
|
||||
'flowRuns.inspector.output': 'আউটপুট',
|
||||
'flowRuns.inspector.port': 'পোর্ট',
|
||||
'flowRuns.inspector.loading': 'রান লোড হচ্ছে…',
|
||||
'flowRuns.inspector.loadError': 'এই রানটি লোড করা যায়নি',
|
||||
'flowRuns.status.running': 'চলছে',
|
||||
'flowRuns.status.completed': 'সম্পন্ন',
|
||||
'flowRuns.status.pending_approval': 'অনুমোদনের অপেক্ষায়',
|
||||
'flowRuns.status.failed': 'ব্যর্থ',
|
||||
'oauth.button.connecting': 'সংযোগ হচ্ছে...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'সাইন-ইন টাইম আউট হয়েছে — ব্রাউজার OAuth পুনর্নির্দেশনা সম্পন্ন করেনি। অনুগ্রহ করে আবার চেষ্টা করুন।',
|
||||
|
||||
@@ -3717,6 +3717,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': 'Wartet auf {count} Genehmigungsschritt(e)',
|
||||
'notifications.flow.approveHint': 'Workflow nach diesem Kontrollpunkt fortsetzen',
|
||||
'notifications.flow.dismissHint': 'Diesen Hinweis ausblenden, ohne den Workflow fortzusetzen',
|
||||
'notifications.flow.viewRun': 'Lauf anzeigen',
|
||||
'flowRuns.inspector.title': 'Laufdetails',
|
||||
'flowRuns.inspector.startedAt': 'Gestartet',
|
||||
'flowRuns.inspector.finishedAt': 'Beendet',
|
||||
'flowRuns.inspector.running': 'Läuft…',
|
||||
'flowRuns.inspector.error': 'Fehler',
|
||||
'flowRuns.inspector.pendingApprovals': 'Ausstehende Genehmigungen',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count} Knoten warten auf Genehmigung',
|
||||
'flowRuns.inspector.steps': 'Schritte',
|
||||
'flowRuns.inspector.noSteps': 'Noch keine Schritte aufgezeichnet.',
|
||||
'flowRuns.inspector.output': 'Ausgabe',
|
||||
'flowRuns.inspector.port': 'Port',
|
||||
'flowRuns.inspector.loading': 'Lauf wird geladen…',
|
||||
'flowRuns.inspector.loadError': 'Dieser Lauf konnte nicht geladen werden',
|
||||
'flowRuns.status.running': 'Läuft',
|
||||
'flowRuns.status.completed': 'Abgeschlossen',
|
||||
'flowRuns.status.pending_approval': 'Wartet auf Genehmigung',
|
||||
'flowRuns.status.failed': 'Fehlgeschlagen',
|
||||
'oauth.button.connecting': 'Verbinden...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Anmeldung abgelaufen — der Browser hat die OAuth-Weiterleitung nicht abgeschlossen. Bitte versuche es erneut.',
|
||||
|
||||
@@ -4267,6 +4267,24 @@ const en: TranslationMap = {
|
||||
'notifications.flow.gateCount': 'Waiting on {count} approval gate(s)',
|
||||
'notifications.flow.approveHint': 'Resume the workflow past this checkpoint',
|
||||
'notifications.flow.dismissHint': 'Hide this prompt without resuming the workflow',
|
||||
'notifications.flow.viewRun': 'View run',
|
||||
'flowRuns.inspector.title': 'Run details',
|
||||
'flowRuns.inspector.startedAt': 'Started',
|
||||
'flowRuns.inspector.finishedAt': 'Finished',
|
||||
'flowRuns.inspector.running': 'Running…',
|
||||
'flowRuns.inspector.error': 'Error',
|
||||
'flowRuns.inspector.pendingApprovals': 'Pending approvals',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count} node(s) awaiting approval',
|
||||
'flowRuns.inspector.steps': 'Steps',
|
||||
'flowRuns.inspector.noSteps': 'No steps recorded yet.',
|
||||
'flowRuns.inspector.output': 'Output',
|
||||
'flowRuns.inspector.port': 'Port',
|
||||
'flowRuns.inspector.loading': 'Loading run…',
|
||||
'flowRuns.inspector.loadError': 'Could not load this run',
|
||||
'flowRuns.status.running': 'Running',
|
||||
'flowRuns.status.completed': 'Completed',
|
||||
'flowRuns.status.pending_approval': 'Awaiting approval',
|
||||
'flowRuns.status.failed': 'Failed',
|
||||
'oauth.button.connecting': 'Connecting...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Sign-in timed out — the browser did not complete the OAuth redirect. Please try again.',
|
||||
|
||||
@@ -3691,6 +3691,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': 'Esperando {count} puerta(s) de aprobación',
|
||||
'notifications.flow.approveHint': 'Reanudar el flujo de trabajo después de este punto de control',
|
||||
'notifications.flow.dismissHint': 'Ocultar este aviso sin reanudar el flujo de trabajo',
|
||||
'notifications.flow.viewRun': 'Ver ejecución',
|
||||
'flowRuns.inspector.title': 'Detalles de la ejecución',
|
||||
'flowRuns.inspector.startedAt': 'Iniciado',
|
||||
'flowRuns.inspector.finishedAt': 'Finalizado',
|
||||
'flowRuns.inspector.running': 'En ejecución…',
|
||||
'flowRuns.inspector.error': 'Error',
|
||||
'flowRuns.inspector.pendingApprovals': 'Aprobaciones pendientes',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count} nodo(s) esperando aprobación',
|
||||
'flowRuns.inspector.steps': 'Pasos',
|
||||
'flowRuns.inspector.noSteps': 'Aún no se han registrado pasos.',
|
||||
'flowRuns.inspector.output': 'Salida',
|
||||
'flowRuns.inspector.port': 'Puerto',
|
||||
'flowRuns.inspector.loading': 'Cargando ejecución…',
|
||||
'flowRuns.inspector.loadError': 'No se pudo cargar esta ejecución',
|
||||
'flowRuns.status.running': 'En ejecución',
|
||||
'flowRuns.status.completed': 'Completado',
|
||||
'flowRuns.status.pending_approval': 'Esperando aprobación',
|
||||
'flowRuns.status.failed': 'Fallido',
|
||||
'oauth.button.connecting': 'Conectando...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'El inicio de sesión expiró — el navegador no completó la redirección OAuth. Por favor, inténtalo de nuevo.',
|
||||
|
||||
@@ -3706,6 +3706,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': 'En attente de {count} validation(s)',
|
||||
'notifications.flow.approveHint': 'Reprendre le workflow après ce point de contrôle',
|
||||
'notifications.flow.dismissHint': 'Masquer cette invite sans reprendre le workflow',
|
||||
'notifications.flow.viewRun': "Voir l'exécution",
|
||||
'flowRuns.inspector.title': "Détails de l'exécution",
|
||||
'flowRuns.inspector.startedAt': 'Démarré',
|
||||
'flowRuns.inspector.finishedAt': 'Terminé',
|
||||
'flowRuns.inspector.running': 'En cours…',
|
||||
'flowRuns.inspector.error': 'Erreur',
|
||||
'flowRuns.inspector.pendingApprovals': 'Approbations en attente',
|
||||
'flowRuns.inspector.pendingApprovalsCount': "{count} nœud(s) en attente d'approbation",
|
||||
'flowRuns.inspector.steps': 'Étapes',
|
||||
'flowRuns.inspector.noSteps': 'Aucune étape enregistrée pour le moment.',
|
||||
'flowRuns.inspector.output': 'Sortie',
|
||||
'flowRuns.inspector.port': 'Port',
|
||||
'flowRuns.inspector.loading': "Chargement de l'exécution…",
|
||||
'flowRuns.inspector.loadError': 'Impossible de charger cette exécution',
|
||||
'flowRuns.status.running': 'En cours',
|
||||
'flowRuns.status.completed': 'Terminé',
|
||||
'flowRuns.status.pending_approval': "En attente d'approbation",
|
||||
'flowRuns.status.failed': 'Échoué',
|
||||
'oauth.button.connecting': 'Connexion en cours…',
|
||||
'oauth.button.loopbackTimeout':
|
||||
"La connexion a expiré — le navigateur n'a pas complété la redirection OAuth. Veuillez réessayer.",
|
||||
|
||||
@@ -3629,6 +3629,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': '{count} अनुमोदन गेट लंबित हैं',
|
||||
'notifications.flow.approveHint': 'इस चेकपॉइंट के बाद वर्कफ़्लो फिर से शुरू करें',
|
||||
'notifications.flow.dismissHint': 'वर्कफ़्लो को फिर से शुरू किए बिना यह संकेत छिपाएं',
|
||||
'notifications.flow.viewRun': 'रन देखें',
|
||||
'flowRuns.inspector.title': 'रन विवरण',
|
||||
'flowRuns.inspector.startedAt': 'शुरू हुआ',
|
||||
'flowRuns.inspector.finishedAt': 'समाप्त हुआ',
|
||||
'flowRuns.inspector.running': 'चल रहा है…',
|
||||
'flowRuns.inspector.error': 'त्रुटि',
|
||||
'flowRuns.inspector.pendingApprovals': 'लंबित अनुमोदन',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count} नोड अनुमोदन की प्रतीक्षा में',
|
||||
'flowRuns.inspector.steps': 'चरण',
|
||||
'flowRuns.inspector.noSteps': 'अभी तक कोई चरण दर्ज नहीं किया गया है।',
|
||||
'flowRuns.inspector.output': 'आउटपुट',
|
||||
'flowRuns.inspector.port': 'पोर्ट',
|
||||
'flowRuns.inspector.loading': 'रन लोड हो रहा है…',
|
||||
'flowRuns.inspector.loadError': 'यह रन लोड नहीं हो सका',
|
||||
'flowRuns.status.running': 'चल रहा है',
|
||||
'flowRuns.status.completed': 'पूर्ण',
|
||||
'flowRuns.status.pending_approval': 'अनुमोदन की प्रतीक्षा में',
|
||||
'flowRuns.status.failed': 'विफल',
|
||||
'oauth.button.connecting': 'कनेक्ट हो रहा है...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'साइन-इन का समय समाप्त हो गया — ब्राउज़र ने OAuth पुनर्निर्देशन पूरा नहीं किया। कृपया पुनः प्रयास करें।',
|
||||
|
||||
@@ -3636,6 +3636,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': 'Menunggu {count} gerbang persetujuan',
|
||||
'notifications.flow.approveHint': 'Lanjutkan alur kerja setelah titik pemeriksaan ini',
|
||||
'notifications.flow.dismissHint': 'Sembunyikan prompt ini tanpa melanjutkan alur kerja',
|
||||
'notifications.flow.viewRun': 'Lihat proses',
|
||||
'flowRuns.inspector.title': 'Detail proses',
|
||||
'flowRuns.inspector.startedAt': 'Dimulai',
|
||||
'flowRuns.inspector.finishedAt': 'Selesai',
|
||||
'flowRuns.inspector.running': 'Berjalan…',
|
||||
'flowRuns.inspector.error': 'Kesalahan',
|
||||
'flowRuns.inspector.pendingApprovals': 'Persetujuan tertunda',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count} node menunggu persetujuan',
|
||||
'flowRuns.inspector.steps': 'Langkah',
|
||||
'flowRuns.inspector.noSteps': 'Belum ada langkah yang tercatat.',
|
||||
'flowRuns.inspector.output': 'Keluaran',
|
||||
'flowRuns.inspector.port': 'Port',
|
||||
'flowRuns.inspector.loading': 'Memuat proses…',
|
||||
'flowRuns.inspector.loadError': 'Tidak dapat memuat proses ini',
|
||||
'flowRuns.status.running': 'Berjalan',
|
||||
'flowRuns.status.completed': 'Selesai',
|
||||
'flowRuns.status.pending_approval': 'Menunggu persetujuan',
|
||||
'flowRuns.status.failed': 'Gagal',
|
||||
'oauth.button.connecting': 'Menghubungkan...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Masuk habis waktu — browser tidak menyelesaikan pengalihan OAuth. Silakan coba lagi.',
|
||||
|
||||
@@ -3686,6 +3686,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': 'In attesa di {count} punto/i di approvazione',
|
||||
'notifications.flow.approveHint': 'Riprendi il workflow dopo questo checkpoint',
|
||||
'notifications.flow.dismissHint': 'Nascondi questo avviso senza riprendere il workflow',
|
||||
'notifications.flow.viewRun': 'Visualizza esecuzione',
|
||||
'flowRuns.inspector.title': 'Dettagli esecuzione',
|
||||
'flowRuns.inspector.startedAt': 'Avviato',
|
||||
'flowRuns.inspector.finishedAt': 'Terminato',
|
||||
'flowRuns.inspector.running': 'In esecuzione…',
|
||||
'flowRuns.inspector.error': 'Errore',
|
||||
'flowRuns.inspector.pendingApprovals': 'Approvazioni in sospeso',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count} nodo/i in attesa di approvazione',
|
||||
'flowRuns.inspector.steps': 'Passaggi',
|
||||
'flowRuns.inspector.noSteps': 'Nessun passaggio registrato finora.',
|
||||
'flowRuns.inspector.output': 'Output',
|
||||
'flowRuns.inspector.port': 'Porta',
|
||||
'flowRuns.inspector.loading': 'Caricamento esecuzione…',
|
||||
'flowRuns.inspector.loadError': 'Impossibile caricare questa esecuzione',
|
||||
'flowRuns.status.running': 'In esecuzione',
|
||||
'flowRuns.status.completed': 'Completato',
|
||||
'flowRuns.status.pending_approval': 'In attesa di approvazione',
|
||||
'flowRuns.status.failed': 'Non riuscito',
|
||||
'oauth.button.connecting': 'Connessione...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Accesso scaduto — il browser non ha completato il reindirizzamento OAuth. Riprova.',
|
||||
|
||||
@@ -3592,6 +3592,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': '승인 게이트 {count}개 대기 중',
|
||||
'notifications.flow.approveHint': '이 체크포인트 이후 워크플로 재개',
|
||||
'notifications.flow.dismissHint': '워크플로를 재개하지 않고 이 알림 숨기기',
|
||||
'notifications.flow.viewRun': '실행 보기',
|
||||
'flowRuns.inspector.title': '실행 세부정보',
|
||||
'flowRuns.inspector.startedAt': '시작됨',
|
||||
'flowRuns.inspector.finishedAt': '종료됨',
|
||||
'flowRuns.inspector.running': '실행 중…',
|
||||
'flowRuns.inspector.error': '오류',
|
||||
'flowRuns.inspector.pendingApprovals': '대기 중인 승인',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '노드 {count}개가 승인 대기 중',
|
||||
'flowRuns.inspector.steps': '단계',
|
||||
'flowRuns.inspector.noSteps': '아직 기록된 단계가 없습니다.',
|
||||
'flowRuns.inspector.output': '출력',
|
||||
'flowRuns.inspector.port': '포트',
|
||||
'flowRuns.inspector.loading': '실행 로드 중…',
|
||||
'flowRuns.inspector.loadError': '이 실행을 로드할 수 없습니다',
|
||||
'flowRuns.status.running': '실행 중',
|
||||
'flowRuns.status.completed': '완료됨',
|
||||
'flowRuns.status.pending_approval': '승인 대기 중',
|
||||
'flowRuns.status.failed': '실패',
|
||||
'oauth.button.connecting': '연결 중...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'로그인 시간 초과 — 브라우저가 OAuth 리디렉션을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
|
||||
@@ -3672,6 +3672,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': 'Oczekiwanie na {count} bramkę/bramki zatwierdzenia',
|
||||
'notifications.flow.approveHint': 'Wznów przepływ pracy po tym punkcie kontrolnym',
|
||||
'notifications.flow.dismissHint': 'Ukryj ten monit bez wznawiania przepływu pracy',
|
||||
'notifications.flow.viewRun': 'Zobacz przebieg',
|
||||
'flowRuns.inspector.title': 'Szczegóły przebiegu',
|
||||
'flowRuns.inspector.startedAt': 'Rozpoczęto',
|
||||
'flowRuns.inspector.finishedAt': 'Zakończono',
|
||||
'flowRuns.inspector.running': 'W trakcie…',
|
||||
'flowRuns.inspector.error': 'Błąd',
|
||||
'flowRuns.inspector.pendingApprovals': 'Oczekujące zatwierdzenia',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count} węzeł(y) oczekuje(ą) na zatwierdzenie',
|
||||
'flowRuns.inspector.steps': 'Kroki',
|
||||
'flowRuns.inspector.noSteps': 'Nie zarejestrowano jeszcze żadnych kroków.',
|
||||
'flowRuns.inspector.output': 'Dane wyjściowe',
|
||||
'flowRuns.inspector.port': 'Port',
|
||||
'flowRuns.inspector.loading': 'Ładowanie przebiegu…',
|
||||
'flowRuns.inspector.loadError': 'Nie można załadować tego przebiegu',
|
||||
'flowRuns.status.running': 'W trakcie',
|
||||
'flowRuns.status.completed': 'Zakończono',
|
||||
'flowRuns.status.pending_approval': 'Oczekuje na zatwierdzenie',
|
||||
'flowRuns.status.failed': 'Niepowodzenie',
|
||||
'oauth.button.connecting': 'Łączenie...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Logowanie przekroczyło limit czasu — przeglądarka nie ukończyła przekierowania OAuth. Spróbuj ponownie.',
|
||||
|
||||
@@ -3687,6 +3687,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': 'Aguardando {count} porta(s) de aprovação',
|
||||
'notifications.flow.approveHint': 'Retomar o fluxo de trabalho após este ponto de verificação',
|
||||
'notifications.flow.dismissHint': 'Ocultar este aviso sem retomar o fluxo de trabalho',
|
||||
'notifications.flow.viewRun': 'Ver execução',
|
||||
'flowRuns.inspector.title': 'Detalhes da execução',
|
||||
'flowRuns.inspector.startedAt': 'Iniciado',
|
||||
'flowRuns.inspector.finishedAt': 'Concluído',
|
||||
'flowRuns.inspector.running': 'Em execução…',
|
||||
'flowRuns.inspector.error': 'Erro',
|
||||
'flowRuns.inspector.pendingApprovals': 'Aprovações pendentes',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count} nó(s) aguardando aprovação',
|
||||
'flowRuns.inspector.steps': 'Etapas',
|
||||
'flowRuns.inspector.noSteps': 'Nenhuma etapa registrada ainda.',
|
||||
'flowRuns.inspector.output': 'Saída',
|
||||
'flowRuns.inspector.port': 'Porta',
|
||||
'flowRuns.inspector.loading': 'Carregando execução…',
|
||||
'flowRuns.inspector.loadError': 'Não foi possível carregar esta execução',
|
||||
'flowRuns.status.running': 'Em execução',
|
||||
'flowRuns.status.completed': 'Concluído',
|
||||
'flowRuns.status.pending_approval': 'Aguardando aprovação',
|
||||
'flowRuns.status.failed': 'Falhou',
|
||||
'oauth.button.connecting': 'Conectando...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Login expirou — o navegador não concluiu o redirecionamento OAuth. Por favor, tente novamente.',
|
||||
|
||||
@@ -3661,6 +3661,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': 'Ожидание {count} шлюз(ов) одобрения',
|
||||
'notifications.flow.approveHint': 'Возобновить рабочий процесс после этой контрольной точки',
|
||||
'notifications.flow.dismissHint': 'Скрыть это уведомление без возобновления рабочего процесса',
|
||||
'notifications.flow.viewRun': 'Просмотреть запуск',
|
||||
'flowRuns.inspector.title': 'Детали запуска',
|
||||
'flowRuns.inspector.startedAt': 'Начато',
|
||||
'flowRuns.inspector.finishedAt': 'Завершено',
|
||||
'flowRuns.inspector.running': 'Выполняется…',
|
||||
'flowRuns.inspector.error': 'Ошибка',
|
||||
'flowRuns.inspector.pendingApprovals': 'Ожидающие подтверждения',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count} узел(-ов) ожидает подтверждения',
|
||||
'flowRuns.inspector.steps': 'Шаги',
|
||||
'flowRuns.inspector.noSteps': 'Пока не зафиксировано ни одного шага.',
|
||||
'flowRuns.inspector.output': 'Вывод',
|
||||
'flowRuns.inspector.port': 'Порт',
|
||||
'flowRuns.inspector.loading': 'Загрузка запуска…',
|
||||
'flowRuns.inspector.loadError': 'Не удалось загрузить этот запуск',
|
||||
'flowRuns.status.running': 'Выполняется',
|
||||
'flowRuns.status.completed': 'Завершено',
|
||||
'flowRuns.status.pending_approval': 'Ожидает подтверждения',
|
||||
'flowRuns.status.failed': 'Не удалось',
|
||||
'oauth.button.connecting': 'Подключение...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Время входа истекло — браузер не завершил перенаправление OAuth. Пожалуйста, попробуйте снова.',
|
||||
|
||||
@@ -3438,6 +3438,24 @@ const messages: TranslationMap = {
|
||||
'notifications.flow.gateCount': '正在等待 {count} 个批准节点',
|
||||
'notifications.flow.approveHint': '在此检查点之后恢复工作流',
|
||||
'notifications.flow.dismissHint': '隐藏此提示但不恢复工作流',
|
||||
'notifications.flow.viewRun': '查看运行',
|
||||
'flowRuns.inspector.title': '运行详情',
|
||||
'flowRuns.inspector.startedAt': '开始时间',
|
||||
'flowRuns.inspector.finishedAt': '结束时间',
|
||||
'flowRuns.inspector.running': '运行中…',
|
||||
'flowRuns.inspector.error': '错误',
|
||||
'flowRuns.inspector.pendingApprovals': '待批准',
|
||||
'flowRuns.inspector.pendingApprovalsCount': '{count} 个节点等待批准',
|
||||
'flowRuns.inspector.steps': '步骤',
|
||||
'flowRuns.inspector.noSteps': '尚未记录任何步骤。',
|
||||
'flowRuns.inspector.output': '输出',
|
||||
'flowRuns.inspector.port': '端口',
|
||||
'flowRuns.inspector.loading': '正在加载运行…',
|
||||
'flowRuns.inspector.loadError': '无法加载此运行',
|
||||
'flowRuns.status.running': '运行中',
|
||||
'flowRuns.status.completed': '已完成',
|
||||
'flowRuns.status.pending_approval': '等待批准',
|
||||
'flowRuns.status.failed': '失败',
|
||||
'oauth.button.connecting': '连接中...',
|
||||
'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。',
|
||||
'oauth.login.continueWith': '继续使用',
|
||||
|
||||
Reference in New Issue
Block a user