mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 16:02:28 +00:00
d578e40101
* feat: add selfApproval option to WAC waitForApproval + inline approval buttons Add self-approval configuration to WAC workflows and inline approve/reject buttons in WorkflowTimeline. - TS SDK: add selfApproval option to waitForApproval() - Python SDK: add self_approval param to wait_for_approval() - Backend: store approval_conditions in flow_status for WAC, enforce self-approval checks on resume endpoints - Frontend: show Approve/Reject buttons in timeline with form support (EE), gated by user permissions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: revert sqlx query change + regenerate system prompts - Revert get_suspended_flow_info to use original sqlx::query_as! with COALESCE to avoid sqlx offline cache mismatch in CI - Detect WAC by checking if FlowStatus parsing fails + suspend > 0 - Re-fetch flow_status column separately for WAC approval conditions - Regenerate auto-generated system prompt files for SDK changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: use resume URLs for WAC inline approval buttons - Backend generates HMAC-signed resume/cancel URLs when creating WAC approval, stores them in timeline entry and approval meta - Frontend uses anonymous resume endpoint (like classic flows) with fallback to resumeSuspendedFlowAsOwner for admins - Buttons show for everyone when URLs are present; server-side self_approval_disabled check enforces restrictions - Show warning for admins/owners when self-approval is disabled - selfApproval: false requires EE (errors at dispatch on CE) - self_approval_disabled check moved outside user_auth_required gate so it works independently - WAC detection no longer requires task import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add resume_suspended and approval_info endpoints - New approval_token DB table for token-based approval access - New POST /jobs_u/flow/resume_suspended/{job_id} endpoint: - OptAuthed: works with login or approval_token - Checks approval_conditions (self_approval, groups, auth) - Admins/owners bypass rules - New GET /jobs_u/flow/approval_info/{job_id} endpoint: - Returns form, rules, can_approve status - HMAC anonymous endpoint now bypasses all approval_conditions (secret = full capability) - getResumeUrls approvalPage URL now uses token format - WAC approval dispatch generates and stores approval tokens - Mark resumeSuspendedFlowAsOwner as legacy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: simplify frontend to use resume_suspended endpoint - OpenAPI spec updated with resume_suspended and approval_info endpoints - WorkflowTimeline: removed URL parsing, now calls single resumeSuspended endpoint for both approve and reject - Buttons show for any logged-in user viewing the job (backend enforces authorization rules) - Kept self-approval warning for admins Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: stateless approval tokens, new approval page, FlowStatusWaitingForEvents update - Replace DB-stored approval tokens with stateless HMAC derivation: token = HMAC(workspace_key, job_id + "approval_token") Verifiable without DB lookup, not reversible to resume secret - Drop approval_token migration (no DB table needed) - FlowStatusWaitingForEvents: use resumeSuspended endpoint instead of URL parsing + resumeSuspendedFlowAsOwner - New approval page route /approve/{ws}/{job}?token= that uses approval_info and resume_suspended endpoints - Old approval page route kept for back-compat Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: match old approval page content in new approval page - Add FlowMetadata, JobArgs, FlowGraphV2, DisplayResult - Add approvers with tooltips, flow arguments section - Add admin self-approval bypass warning - Add "Open run details" link - Fetch full job alongside approval_info for all UI data Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: filter _MODULES from args, show 'workflow' for WAC approvals Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove deno template from approval/prompt SuspendDrawer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: approval page form display + hide deno from approval script picker - Fix form schema rendering on new approval page by wrapping flat WAC form schemas in { properties, order } for SchemaForm - Hide deno from the approval step language picker in flow editor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove deno from canHaveApproval in script_helpers.ts The insert menu uses canHaveApproval() from script_helpers.ts via FlowInputsQuick, not the displayLang function in FlowInputs.svelte. Revert the unnecessary FlowInputs.svelte change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: return form schema and description in approval_info for classic flows The approval_info endpoint was returning None for form_schema on classic flows. Now fetches raw_flow to get suspend.resume_form schema, hide_cancel, and the step's completed result for description. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: inline Login component on approval page instead of redirect Show the Login component directly on the approval page when authentication is required. On successful login, reloads user and approval info without navigating away. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: show resume buttons for all users, not just owners The resume_suspended endpoint handles authorization server-side, so the frontend should always show the buttons. Remove isOwner gate and the "cannot resume" message. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent layout shift on resume by removing spinner from cancel button Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent resume button expansion by using disabled instead of loading The loading prop adds a Loader2 spinner that expands the button width. Use disabled={loading} instead to prevent layout shift. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: approval page login redirects back with full page reload Set rd to the full URL (starts with http) so Login.redirectUser() uses window.location.href instead of goto(), triggering a full page reload after login. This ensures the approval page re-fetches data as an authenticated user. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fetch flow definition from flow_version when raw_flow is null Deployed flows don't store raw_flow on the job. Fall back to flow_version table using runnable_id to get suspend settings (form schema, hide_cancel) for the approval_info endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: show specific reasons when user cannot approve Display whether denial is due to self-approval being disabled, required group membership, or both. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: support both nested and flat form schema in waitForApproval Users can now pass either: waitForApproval({ form: { schema: { name: { type: "string" } } } }) or: waitForApproval({ form: { name: { type: "string" } } }) Both WorkflowTimeline and approval page handle both formats. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: convert sqlx query macros to non-macro for CI offline cache Replace sqlx::query! and sqlx::query_scalar! with sqlx::query and sqlx::query_as to avoid SQLX_OFFLINE cache misses in CI. Also remove unused LogIn import from approval page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: suppress dead code warning + unused isOwner variable - Add #[allow(dead_code)] to without_flow method (CI -D warnings) - Rename isOwner to _isOwner in FlowStatusWaitingForEvents (unused) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: security and robustness fixes from PR review - Add workspace_id verification in resume_suspended to prevent cross-workspace approval (#3) - Fix token leakage: use relative path for login redirect instead of full URL with token (#4) - Handle getJob failure independently from approval_info so the page works for unauthenticated users (#7) - Clear error state on successful data load (#13) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback — shared token gen, rand resume_id, UX - Move generate_approval_token to windmill-common::variables (shared between windmill-api and windmill-worker, eliminates duplicate HMAC) - Use rand::random::<u32>() for resume_id instead of DefaultHasher - Stop polling after approve/reject on approval page - Add cancelLoading state to WorkflowTimeline Reject button Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2176 lines
70 KiB
Svelte
2176 lines
70 KiB
Svelte
<script lang="ts">
|
|
import FlowStatusViewerInner from './FlowStatusViewerInner.svelte'
|
|
|
|
import {
|
|
type FlowStatusModule,
|
|
type Job,
|
|
JobService,
|
|
type FlowStatus,
|
|
type FlowModuleValue,
|
|
type FlowModule,
|
|
ResourceService,
|
|
type CompletedJob,
|
|
type WorkflowStatus
|
|
} from '$lib/gen'
|
|
import { workspaceStore } from '$lib/stores'
|
|
import { base } from '$lib/base'
|
|
import FlowJobResult from './FlowJobResult.svelte'
|
|
import WorkflowTimeline from './WorkflowTimeline.svelte'
|
|
import DisplayResult from './DisplayResult.svelte'
|
|
|
|
import { getContext, setContext, tick, untrack } from 'svelte'
|
|
import { onDestroy } from 'svelte'
|
|
import { Badge, Button, Skeleton, Tab } from './common'
|
|
import Tabs from './common/tabs/Tabs.svelte'
|
|
import { type DurationStatus, type FlowStatusViewerContext, type GraphModuleState } from './graph'
|
|
import ModuleStatus from './ModuleStatus.svelte'
|
|
import { clone, isScriptPreview, msToSec, readFieldsRecursively, truncateRev } from '$lib/utils'
|
|
import JobArgs from './JobArgs.svelte'
|
|
import { ChevronDown, ExternalLink, Hourglass } from 'lucide-svelte'
|
|
import { deepEqual } from 'fast-equals'
|
|
import FlowTimeline from './FlowTimeline.svelte'
|
|
import { dfs } from './flows/dfs'
|
|
import { dfs as dfsPreviousResults } from '$lib/components/flows/previousResults'
|
|
import Alert from './common/alert/Alert.svelte'
|
|
import FlowGraphViewerStep from './FlowGraphViewerStep.svelte'
|
|
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
|
|
import { buildPrefix } from './graph/graphBuilder.svelte'
|
|
import { parseInputArgsAssets } from './assets/lib'
|
|
import FlowLogViewerWrapper from './FlowLogViewerWrapper.svelte'
|
|
import type { FlowGraphAssetContext } from './flows/types'
|
|
import { createState } from '$lib/svelte5Utils.svelte'
|
|
import JobLoader from './JobLoader.svelte'
|
|
import {
|
|
AI_TOOL_CALL_PREFIX,
|
|
AI_TOOL_MESSAGE_PREFIX,
|
|
AI_MCP_TOOL_CALL_PREFIX,
|
|
AI_WEBSEARCH_PREFIX,
|
|
getToolCallId
|
|
} from './graph/renderers/nodes/AIToolNode.svelte'
|
|
import JobAssetsViewer from './assets/JobAssetsViewer.svelte'
|
|
import McpToolCallDetails from './McpToolCallDetails.svelte'
|
|
import JobOtelTraces from './JobOtelTraces.svelte'
|
|
import JobDetailHeader from './runs/JobDetailHeader.svelte'
|
|
import LogViewer from './LogViewer.svelte'
|
|
import { SelectionManager } from './graph/selectionUtils.svelte'
|
|
import { useThrottle } from 'runed'
|
|
import { Splitpanes, Pane } from 'svelte-splitpanes'
|
|
import { getActiveReplay } from './recording/flowRecording.svelte'
|
|
|
|
let {
|
|
flowState: flowStateStore,
|
|
retryStatus,
|
|
suspendStatus,
|
|
hideDownloadInGraph,
|
|
hideTimeline,
|
|
hideNodeDefinition,
|
|
hideDownloadLogs
|
|
} = getContext<FlowStatusViewerContext>('FlowStatusViewer')
|
|
|
|
interface Props {
|
|
jobId: string
|
|
initialJob?: Job | undefined
|
|
workspaceId?: string | undefined
|
|
flowJobIds?:
|
|
| {
|
|
moduleId: string
|
|
flowJobs: string[]
|
|
flowJobsSuccess: (boolean | undefined)[]
|
|
flowJobsDuration:
|
|
| {
|
|
started_at?: (string | undefined)[]
|
|
duration_ms?: (number | undefined)[]
|
|
}
|
|
| undefined
|
|
length: number
|
|
branchall?: boolean
|
|
}
|
|
| undefined
|
|
//only useful when forloops are optimized and the job doesn't contain the mod id anymore
|
|
innerModule?: FlowModuleValue | undefined
|
|
render?: boolean
|
|
selectedNode?: string | undefined
|
|
globalModuleStates: Record<string, GraphModuleState>[]
|
|
globalDurationStatuses?: Record<string, DurationStatus>[]
|
|
isSelectedBranch?: boolean
|
|
isSubflow?: boolean
|
|
reducedPolling?: boolean
|
|
wideResults?: boolean
|
|
topModuleStates?: Record<string, GraphModuleState>
|
|
workspace?: string | undefined
|
|
prefix?: string | undefined
|
|
subflowParentsGlobalModuleStates?: Record<string, GraphModuleState>[]
|
|
subflowParentsDurationStatuses?: Record<string, DurationStatus>[]
|
|
isForloopSelected?: boolean
|
|
updateRecursiveRefreshFn?: (jobId: string, updateFn: (clear, root) => Promise<void>) => void
|
|
refreshGlobal: (moduleId: string, clear: boolean, root: string) => Promise<void>
|
|
updateGlobalRefresh: (moduleId: string, updateFn: (clear, root) => Promise<void>) => void
|
|
job?: (Job & { result_stream?: string }) | undefined
|
|
rightColumnSelect?: 'timeline' | 'node_status' | 'node_definition' | 'user_states' | 'tracing'
|
|
localModuleStates?: Record<string, GraphModuleState>
|
|
localDurationStatuses?: Record<string, DurationStatus>
|
|
onResultStreamUpdate?: ({
|
|
jobId,
|
|
result_stream
|
|
}: {
|
|
jobId: string
|
|
result_stream?: string
|
|
}) => void
|
|
customUi?: {
|
|
tagLabel?: string | undefined
|
|
}
|
|
graphTabOpen: boolean
|
|
isNodeSelected: boolean
|
|
loadExtraLogs?: (logs: string) => void
|
|
onStart?: () => void
|
|
onJobsLoaded?: ({ job, force }: { job: Job; force: boolean }) => void
|
|
onDone?: ({ job }: { job: CompletedJob }) => void
|
|
toolCallStore?: {
|
|
getStoredToolCallJob: (storeKey: string) => Job | undefined
|
|
setStoredToolCallJob: (storeKey: string, job: Job) => void
|
|
getLocalToolCallJobs: (prefix: string) => Record<number, Job>
|
|
isToolCallToBeLoaded: (storeKey: string) => boolean
|
|
addToolCallToLoad: (storeKey: string) => void
|
|
}
|
|
showLogsWithResult?: boolean
|
|
showJobDetailHeader?: boolean
|
|
}
|
|
|
|
let {
|
|
jobId,
|
|
initialJob = undefined,
|
|
workspaceId = undefined,
|
|
flowJobIds = undefined,
|
|
innerModule = undefined,
|
|
render = true,
|
|
selectedNode = $bindable(undefined),
|
|
globalModuleStates,
|
|
globalDurationStatuses = [],
|
|
updateRecursiveRefreshFn = undefined,
|
|
isSelectedBranch = true,
|
|
isSubflow = false,
|
|
reducedPolling = false,
|
|
wideResults = false,
|
|
workspace = $workspaceStore,
|
|
prefix = undefined,
|
|
topModuleStates = undefined,
|
|
refreshGlobal,
|
|
updateGlobalRefresh,
|
|
subflowParentsGlobalModuleStates = [],
|
|
subflowParentsDurationStatuses = [],
|
|
isForloopSelected = false,
|
|
job = $bindable(undefined),
|
|
rightColumnSelect = $bindable('timeline'),
|
|
localModuleStates = $bindable({}),
|
|
localDurationStatuses = $bindable({}),
|
|
customUi,
|
|
onResultStreamUpdate = undefined,
|
|
graphTabOpen,
|
|
isNodeSelected,
|
|
loadExtraLogs = undefined,
|
|
onStart = undefined,
|
|
onJobsLoaded = undefined,
|
|
onDone = undefined,
|
|
toolCallStore,
|
|
showLogsWithResult = false,
|
|
showJobDetailHeader = false
|
|
}: Props = $props()
|
|
|
|
let getTopModuleStates = $derived(topModuleStates ?? localModuleStates)
|
|
|
|
// Cache replay check to avoid repeated function calls in the template
|
|
let isReplay = $derived(!!getActiveReplay())
|
|
|
|
let resultStreams: Record<string, string | undefined> = $state({})
|
|
|
|
if (untrack(() => onResultStreamUpdate) == undefined) {
|
|
onResultStreamUpdate = ({
|
|
jobId,
|
|
result_stream
|
|
}: {
|
|
jobId: string
|
|
result_stream?: string
|
|
}) => {
|
|
resultStreams[jobId] = result_stream
|
|
}
|
|
}
|
|
|
|
let recursiveRefresh: Record<string, (clear, root) => Promise<void>> = $state({})
|
|
let updateRecursiveRefreshInner = (
|
|
childJobId: string,
|
|
updateFn: (clear, root) => Promise<void>
|
|
) => {
|
|
if (childJobId) {
|
|
recursiveRefresh[childJobId] = updateFn
|
|
}
|
|
}
|
|
|
|
// Add support for the input args assets shown as an asset node
|
|
const _flowGraphAssetsCtx = getContext<FlowGraphAssetContext | undefined>('FlowGraphAssetContext')
|
|
let extendedFlowGraphAssetsCtx = $state(createState(clone(_flowGraphAssetsCtx)))
|
|
setContext('FlowGraphAssetContext', extendedFlowGraphAssetsCtx)
|
|
$effect(() => {
|
|
readFieldsRecursively(_flowGraphAssetsCtx)
|
|
job?.args
|
|
untrack(() => {
|
|
if (extendedFlowGraphAssetsCtx && _flowGraphAssetsCtx) {
|
|
const inputAssets = parseInputArgsAssets(job?.args ?? {})
|
|
const resourceMetadataCache = _flowGraphAssetsCtx.val.resourceMetadataCache
|
|
for (const asset of inputAssets) {
|
|
if (asset.kind === 'resource' && !(asset.path in resourceMetadataCache)) {
|
|
resourceMetadataCache[asset.path] = undefined
|
|
if (!isReplay) {
|
|
ResourceService.getResource({
|
|
workspace: workspace ?? $workspaceStore!,
|
|
path: asset.path
|
|
})
|
|
.then((r) => (resourceMetadataCache[asset.path] = r))
|
|
.catch((err) => {})
|
|
}
|
|
}
|
|
}
|
|
extendedFlowGraphAssetsCtx.val = clone(_flowGraphAssetsCtx?.val)
|
|
extendedFlowGraphAssetsCtx.val.additionalAssetsMap['Input'] = inputAssets
|
|
}
|
|
})
|
|
})
|
|
|
|
let jobResults: any[] = $state(
|
|
untrack(() => flowJobIds)?.flowJobs?.map(
|
|
(x, id) => `iter #${id + 1} not loaded by frontend yet`
|
|
) ?? []
|
|
)
|
|
|
|
function asWorkflowStatus(x: any): Record<string, WorkflowStatus> {
|
|
if (!x || typeof x !== 'object') return {}
|
|
const result: Record<string, WorkflowStatus> = {}
|
|
for (const [k, v] of Object.entries(x)) {
|
|
if (!k.startsWith('_') || k.startsWith('_step/')) result[k] = v as WorkflowStatus
|
|
}
|
|
return result
|
|
}
|
|
|
|
function getStepResults(x: any): Record<string, any> {
|
|
return x?._checkpoint?.completed_steps ?? {}
|
|
}
|
|
|
|
let retry_selected = $state('')
|
|
let timeout: number | undefined = undefined
|
|
|
|
let expandedSubflows: Record<string, { modules: FlowModule[]; groups?: any[] }> = $state({})
|
|
|
|
let selectionManager = new SelectionManager()
|
|
|
|
function onFlowModuleId() {
|
|
let modId = flowJobIds?.moduleId
|
|
if (modId) {
|
|
let prefixedId = buildSubflowKey(modId, prefix)
|
|
updateGlobalRefresh(prefixedId, async (clear, root) => {
|
|
// console.debug('updateGlobalRefreshInner refresh', prefixedId, clear, root)
|
|
await refresh(clear, root) // refresh(true, loopJob)
|
|
})
|
|
}
|
|
}
|
|
|
|
function updateModuleStates(
|
|
moduleState: Record<string, GraphModuleState>,
|
|
key: string,
|
|
newValue: GraphModuleState,
|
|
keepType: boolean | undefined
|
|
) {
|
|
if (
|
|
newValue.selectedForloop != undefined &&
|
|
moduleState[key]?.selectedForloop != undefined &&
|
|
newValue.selectedForloop != moduleState[key].selectedForloop
|
|
) {
|
|
let newState = { ...moduleState[key] }
|
|
if (
|
|
newValue.type == 'InProgress' &&
|
|
moduleState[key]?.type != 'InProgress' &&
|
|
!(
|
|
keepType &&
|
|
(moduleState[key]?.type === 'Success' || moduleState[key]?.type === 'Failure')
|
|
)
|
|
) {
|
|
newState.type = 'InProgress'
|
|
} else if (['Success', 'Failure'].includes(newValue.type)) {
|
|
newState.type = newValue.type
|
|
}
|
|
|
|
if (
|
|
moduleState[key]?.job_id != newValue.job_id ||
|
|
!deepEqual(moduleState[key]?.args, newValue.args) ||
|
|
!deepEqual(moduleState[key]?.result, newValue.result)
|
|
) {
|
|
newState.args = newValue.args
|
|
newState.result = newValue.result
|
|
newState.job_id = newValue.job_id
|
|
}
|
|
moduleState[key] = newState
|
|
return
|
|
}
|
|
|
|
if (moduleState[key]?.selectedForLoopSetManually) {
|
|
if (
|
|
newValue.selectedForloop != undefined &&
|
|
moduleState[key]?.selectedForloop != newValue.selectedForloop
|
|
) {
|
|
return moduleState
|
|
} else {
|
|
newValue.selectedForLoopSetManually = true
|
|
newValue.selectedForloopIndex = moduleState[key]?.selectedForloopIndex
|
|
newValue.selectedForloop = moduleState[key]?.selectedForloop
|
|
}
|
|
} else if (moduleState[key]?.selectedForloopIndex != undefined) {
|
|
newValue.selectedForloopIndex = moduleState[key]?.selectedForloopIndex
|
|
newValue.selectedForloop = moduleState[key]?.selectedForloop
|
|
}
|
|
|
|
if (keepType && (moduleState[key]?.type == 'Success' || moduleState[key]?.type == 'Failure')) {
|
|
newValue.type = moduleState[key].type
|
|
}
|
|
if (!deepEqual(moduleState[key], newValue)) {
|
|
// console.debug('updateModuleStates 2', key, $state.snapshot(moduleState))
|
|
moduleState[key] = newValue
|
|
}
|
|
}
|
|
|
|
function buildSubflowKey(key: string, prefix: string | undefined) {
|
|
return prefix ? 'subflow:' + prefix + key : key
|
|
}
|
|
|
|
async function refresh(clearLoop: boolean, rootJob: string | undefined) {
|
|
console.debug('refresh', clearLoop, rootJob)
|
|
let modId = flowJobIds?.moduleId
|
|
let topModuleStates = getTopModuleStates
|
|
|
|
if (clearLoop) {
|
|
if (!rootJob) {
|
|
if (modId && topModuleStates) {
|
|
let prefixedId = buildSubflowKey(modId, prefix)
|
|
delete topModuleStates[prefixedId] // TODO: this is not working
|
|
}
|
|
|
|
if (subflowParentsGlobalModuleStates.length > 0) {
|
|
let subflowModuleStates =
|
|
subflowParentsGlobalModuleStates?.[subflowParentsGlobalModuleStates?.length - 1]
|
|
|
|
for (let mod of innerModules ?? []) {
|
|
if (mod.id) {
|
|
delete subflowModuleStates[buildSubflowKey(mod.id, prefix)]
|
|
}
|
|
}
|
|
} else {
|
|
for (let mod of innerModules ?? []) {
|
|
if (mod.id && topModuleStates) {
|
|
let prefixedId = buildSubflowKey(mod.id, prefix)
|
|
delete topModuleStates[prefixedId]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let state = modId ? topModuleStates?.[buildSubflowKey(modId, prefix)] : undefined
|
|
let loopjob = state?.selectedForloop
|
|
let njob = flowJobIds && modId && loopjob ? storedListJobs?.[loopjob] : job
|
|
|
|
if (njob) {
|
|
onJobsLoaded?.({ job: njob, force: true })
|
|
}
|
|
}
|
|
|
|
let callRec = async (rec: (clear, root) => Promise<void>) => {
|
|
await tick()
|
|
await rec(clearLoop, undefined)
|
|
}
|
|
if (rootJob) {
|
|
let rec = recursiveRefresh[rootJob]
|
|
if (rec) {
|
|
await callRec(rec)
|
|
// console.debug('refresh recursive 1', rec)
|
|
} else {
|
|
// console.debug('refresh recursive no rec', rootJob)
|
|
}
|
|
} else {
|
|
for (let rec of Object.values(recursiveRefresh)) {
|
|
await callRec(rec)
|
|
// console.debug('refresh recursive 2', rec)
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateRecursiveRefresh(jobId: string) {
|
|
if (jobId) {
|
|
updateRecursiveRefreshFn?.(jobId, async (clear, root) => {
|
|
if (globalModuleStates.length > 0 || isSubflow) {
|
|
await refresh(clear, root)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
function setModuleState(
|
|
key: string,
|
|
value: Partial<GraphModuleState>,
|
|
force?: boolean,
|
|
keepType?: boolean
|
|
) {
|
|
let newValue = { ...(localModuleStates[key] ?? {}), ...value }
|
|
if (!deepEqual(localModuleStates[key], value) || force) {
|
|
// console.debug('setModuleState', key, force, keepType, $state.snapshot(value))
|
|
;[localModuleStates, ...globalModuleStates].forEach((s) => {
|
|
updateModuleStates(s, key, newValue, keepType)
|
|
})
|
|
if (prefix) {
|
|
let prefixedId = buildSubflowKey(key, prefix)
|
|
subflowParentsGlobalModuleStates.forEach((s) => {
|
|
updateModuleStates(s, prefixedId, newValue, keepType)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function setDurationStatusByJob(
|
|
key: string,
|
|
id: string,
|
|
value: DurationStatus['byJob'][string],
|
|
overwrite: boolean = false
|
|
) {
|
|
if (!deepEqual(localDurationStatuses[key]?.byJob?.[id], value)) {
|
|
if (localDurationStatuses[key]?.byJob == undefined) {
|
|
localDurationStatuses[key] = { byJob: {} }
|
|
}
|
|
localDurationStatuses[key].byJob = {
|
|
[id]: value,
|
|
...(overwrite ? {} : (localDurationStatuses[key].byJob ?? {}))
|
|
}
|
|
|
|
globalDurationStatuses.forEach((s) => {
|
|
s[key].byJob = { [id]: value, ...(overwrite ? {} : (s[key].byJob ?? {})) }
|
|
})
|
|
if (prefix) {
|
|
subflowParentsDurationStatuses.forEach((s) => {
|
|
s[buildSubflowKey(key, prefix)].byJob = {
|
|
[id]: value,
|
|
...(overwrite ? {} : (s[buildSubflowKey(key, prefix)].byJob ?? {}))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function initializeByJob(modId: string) {
|
|
if (localDurationStatuses[modId] == undefined) {
|
|
localDurationStatuses[modId] = { byJob: {} }
|
|
}
|
|
globalDurationStatuses.forEach((x) => {
|
|
if (x[modId] == undefined) {
|
|
x[modId] = { byJob: {} }
|
|
}
|
|
})
|
|
if (prefix) {
|
|
subflowParentsDurationStatuses.forEach((x) => {
|
|
let key = buildSubflowKey(modId, prefix)
|
|
if (x[key] == undefined) {
|
|
x[key] = { byJob: {} }
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
let innerModules = $state(undefined) as FlowStatusModule[] | undefined
|
|
function updateStatus(status: FlowStatus) {
|
|
innerModules =
|
|
status?.modules?.concat(
|
|
status.failure_module.type != 'WaitingForPriorSteps' ? status.failure_module : []
|
|
) ?? []
|
|
if (status.preprocessor_module) {
|
|
innerModules.unshift(status.preprocessor_module)
|
|
}
|
|
updateInnerModules()
|
|
|
|
let count = status.retry?.fail_count
|
|
if (count) {
|
|
retryStatus.val[jobId ?? ''] = count
|
|
} else if (retryStatus.val[jobId ?? ''] != undefined) {
|
|
delete retryStatus.val[jobId ?? '']
|
|
}
|
|
let jobStatus = job?.flow_status?.modules?.[job?.flow_status.step]
|
|
|
|
if (jobStatus && jobStatus.count != undefined) {
|
|
suspendStatus.val[jobId ?? ''] = { nb: jobStatus.count, job: job! }
|
|
} else if (suspendStatus.val[jobId ?? ''] != undefined) {
|
|
delete suspendStatus.val[jobId ?? '']
|
|
}
|
|
}
|
|
|
|
function updateDurationStatuses(
|
|
key: string,
|
|
durationStatuses: Record<string, DurationStatus['byJob'][string]>
|
|
) {
|
|
if (localDurationStatuses[key] == undefined) {
|
|
localDurationStatuses[key] = { byJob: {} }
|
|
}
|
|
localDurationStatuses[key].byJob = durationStatuses
|
|
globalDurationStatuses.forEach((s) => {
|
|
if (s[key] == undefined) {
|
|
s[key] = { byJob: {} }
|
|
}
|
|
s[key].byJob = durationStatuses
|
|
})
|
|
if (prefix) {
|
|
subflowParentsDurationStatuses.forEach((s) => {
|
|
if (s[key] == undefined) {
|
|
s[key] = { byJob: {} }
|
|
}
|
|
s[key].byJob = durationStatuses
|
|
})
|
|
}
|
|
}
|
|
|
|
let jobMissingStartedAt: Record<string, number | 'P'> = {}
|
|
|
|
let setSelectedLoopSwitch = useThrottle(async (lastStarted: string, mod: FlowStatusModule) => {
|
|
let position = mod.flow_jobs?.indexOf(lastStarted)
|
|
if (!position) return
|
|
for (const flow_job of mod.flow_jobs ?? []) {
|
|
if (flow_job === lastStarted) {
|
|
break
|
|
} else if (flow_job !== lastStarted && suspendStatus.val[flow_job]) {
|
|
delete suspendStatus.val[flow_job]
|
|
}
|
|
}
|
|
setIteration(position, lastStarted, false, mod.id ?? '', true)
|
|
}, 2000)
|
|
|
|
function updateInnerModules() {
|
|
if (localModuleStates) {
|
|
innerModules?.forEach((mod, i) => {
|
|
if (mod.type === 'WaitingForEvents' && innerModules?.[i - 1]?.type === 'Success') {
|
|
setModuleState(mod.id ?? '', {
|
|
type: mod.type,
|
|
args: job?.args,
|
|
tag: job?.tag,
|
|
script_hash: job?.script_hash
|
|
})
|
|
} else if (
|
|
mod.type === 'WaitingForExecutor' &&
|
|
localModuleStates[mod.id ?? '']?.scheduled_for == undefined
|
|
) {
|
|
if (!isReplay) {
|
|
JobService.getJob({
|
|
workspace: workspaceId ?? $workspaceStore ?? '',
|
|
id: mod.job ?? '',
|
|
noLogs: true,
|
|
noCode: true
|
|
})
|
|
.then((job) => {
|
|
const newState = {
|
|
type: mod.type,
|
|
scheduled_for: job?.['scheduled_for'],
|
|
job_id: job?.id,
|
|
parent_module: mod['parent_module'],
|
|
args: job?.args,
|
|
tag: job?.tag,
|
|
script_hash: job?.script_hash
|
|
}
|
|
|
|
setModuleState(mod.id ?? '', newState)
|
|
})
|
|
.catch((e) => {
|
|
console.error(`Could not load inner module for job ${mod.job}`, e)
|
|
})
|
|
}
|
|
} else if (
|
|
(mod.flow_jobs || mod.branch_chosen) &&
|
|
(mod.type == 'Success' || mod.type == 'Failure') &&
|
|
!['Success', 'Failure'].includes(localModuleStates?.[mod.id ?? '']?.type)
|
|
) {
|
|
let branchChosen = mod.branch_chosen
|
|
? {
|
|
branchChosen:
|
|
mod.branch_chosen.type == 'default' ? 0 : (mod.branch_chosen.branch ?? 0) + 1
|
|
}
|
|
: {}
|
|
// console.debug('updateInnerModules', mod.id, mod.type, branchChosen)
|
|
setModuleState(
|
|
mod.id ?? '',
|
|
{
|
|
type: mod.type,
|
|
...branchChosen
|
|
},
|
|
true
|
|
)
|
|
} else if (isForloopSelected) {
|
|
setModuleState(mod.id ?? '', {}, true)
|
|
}
|
|
|
|
if (mod.flow_jobs_success || mod.flow_jobs_duration || mod.flow_jobs) {
|
|
setModuleState(mod.id ?? '', {
|
|
flow_jobs_success: mod.flow_jobs_success,
|
|
flow_jobs_duration: mod.flow_jobs_duration,
|
|
flow_jobs: mod.flow_jobs,
|
|
iteration_total: mod.iterator?.itered_len ?? mod.flow_jobs?.length
|
|
})
|
|
}
|
|
|
|
if (mod.flow_jobs_duration && mod.flow_jobs) {
|
|
let key = buildSubflowKey(mod.id ?? '', prefix)
|
|
let durationStatuses = Object.fromEntries(
|
|
mod.flow_jobs.map((flowJobId, idx) => {
|
|
let started_at_str = mod.flow_jobs_duration?.started_at?.[idx]
|
|
let started_at = started_at_str ? new Date(started_at_str).getTime() : undefined
|
|
let duration_ms = mod.flow_jobs_duration?.duration_ms?.[idx]
|
|
if (started_at == undefined) {
|
|
let missingStartedAt = jobMissingStartedAt[flowJobId]
|
|
if (missingStartedAt != 'P') {
|
|
started_at = missingStartedAt
|
|
}
|
|
} else {
|
|
delete jobMissingStartedAt[flowJobId]
|
|
}
|
|
return [
|
|
flowJobId,
|
|
{
|
|
created_at: started_at,
|
|
started_at: started_at,
|
|
duration_ms: duration_ms
|
|
}
|
|
]
|
|
})
|
|
)
|
|
let missingStartedAtIds = Object.keys(durationStatuses)
|
|
.filter(
|
|
(id) => durationStatuses[id].created_at == undefined && jobMissingStartedAt[id] != 'P'
|
|
)
|
|
.slice(0, 100)
|
|
|
|
updateDurationStatuses(key, durationStatuses)
|
|
|
|
if (missingStartedAtIds.length > 0) {
|
|
// Mark as pending to prevent duplicate fetches
|
|
missingStartedAtIds.forEach((id) => {
|
|
jobMissingStartedAt[id] = 'P'
|
|
})
|
|
if (!isReplay) {
|
|
JobService.getStartedAtByIds({
|
|
workspace: workspaceId ?? $workspaceStore ?? '',
|
|
requestBody: missingStartedAtIds
|
|
})
|
|
.then((jobs) => {
|
|
let lastStarted: string | undefined = undefined
|
|
let anySet = false
|
|
let nDurationStatuses = localDurationStatuses[key]?.byJob
|
|
missingStartedAtIds.forEach((id, idx) => {
|
|
const startedAt = jobs[idx]
|
|
const time = startedAt ? new Date(startedAt).getTime() : undefined
|
|
if (time) {
|
|
jobMissingStartedAt[id] = time
|
|
} else {
|
|
delete jobMissingStartedAt[id]
|
|
}
|
|
if (nDurationStatuses && time) {
|
|
if (!nDurationStatuses[id]?.duration_ms) {
|
|
anySet = true
|
|
lastStarted = id
|
|
nDurationStatuses[id] = {
|
|
created_at: time,
|
|
started_at: time
|
|
}
|
|
}
|
|
}
|
|
})
|
|
if (anySet) {
|
|
updateDurationStatuses(key, nDurationStatuses)
|
|
if (lastStarted) setSelectedLoopSwitch(lastStarted, mod)
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
console.error(`Could not load inner module duration status for job ${mod.job}`, e)
|
|
})
|
|
}
|
|
} else {
|
|
setIteration(0, mod.flow_jobs?.[0] ?? '', false, mod.id ?? '', true)
|
|
}
|
|
}
|
|
|
|
if (mod.agent_actions && mod.id) {
|
|
setModuleState(mod.id, {
|
|
agent_actions: mod.agent_actions
|
|
})
|
|
mod.agent_actions.forEach((action, idx) => {
|
|
if (mod.id) {
|
|
if (action.type == 'tool_call') {
|
|
const toolCallId = getToolCallId(idx, mod.id, action.module_id)
|
|
const success = mod.agent_actions_success?.[idx]
|
|
setModuleState(toolCallId, {
|
|
job_id: action.job_id,
|
|
type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress'
|
|
})
|
|
} else if (action.type == 'mcp_tool_call') {
|
|
const mcpToolCallId = AI_MCP_TOOL_CALL_PREFIX + '-' + mod.id + '-' + idx
|
|
const success = mod.agent_actions_success?.[idx]
|
|
setModuleState(mcpToolCallId, {
|
|
type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress'
|
|
})
|
|
} else if (action.type == 'web_search') {
|
|
const websearchId = AI_WEBSEARCH_PREFIX + '-' + mod.id + '-' + idx
|
|
setModuleState(websearchId, {
|
|
type: 'Success'
|
|
})
|
|
} else if (action.type == 'message') {
|
|
const toolCallId = getToolCallId(idx, mod.id)
|
|
setModuleState(toolCallId, {
|
|
type: 'Success'
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
let debounceJobId: string | undefined = undefined
|
|
let lastRefreshed: Date | undefined = undefined
|
|
function debounceLoadJobInProgress() {
|
|
const pollingRate = reducedPolling ? 5000 : 1000
|
|
if (
|
|
lastRefreshed &&
|
|
new Date().getTime() - lastRefreshed.getTime() < pollingRate &&
|
|
debounceJobId == jobId
|
|
) {
|
|
timeout && clearTimeout(timeout)
|
|
}
|
|
timeout = setTimeout(() => {
|
|
loadJobInProgress()
|
|
lastRefreshed = new Date()
|
|
debounceJobId = jobId
|
|
timeout = undefined
|
|
}, pollingRate)
|
|
}
|
|
|
|
let notAnonynmous = $state(false)
|
|
let started = false
|
|
let jobLoader: JobLoader | undefined = undefined
|
|
|
|
function setJob(newJob: Job, force: boolean) {
|
|
if (!deepEqual(job, newJob) || isForloopSelected || force || innerModules == undefined) {
|
|
if (initialJob) {
|
|
// keep raw_flow/raw_code from initial job if they exist
|
|
job = {
|
|
...newJob,
|
|
raw_flow: initialJob.raw_flow ?? newJob.raw_flow,
|
|
raw_code: initialJob.raw_code ?? newJob.raw_code
|
|
}
|
|
} else {
|
|
job = newJob
|
|
}
|
|
job?.flow_status && updateStatus(job?.flow_status)
|
|
onJobsLoaded?.({ job, force: false })
|
|
notAnonynmous = false
|
|
if (job?.type == 'CompletedJob' && !destroyed) {
|
|
onDone?.({ job })
|
|
}
|
|
}
|
|
}
|
|
async function loadJobInProgress() {
|
|
if (!started) {
|
|
started = true
|
|
onStart?.()
|
|
}
|
|
if (jobId != '00000000-0000-0000-0000-000000000000') {
|
|
try {
|
|
if (
|
|
jobId == initialJob?.id &&
|
|
initialJob?.id != undefined &&
|
|
initialJob?.type === 'CompletedJob'
|
|
) {
|
|
setJob(initialJob, false)
|
|
} else {
|
|
jobLoader?.watchJob(jobId, {
|
|
change(newJob) {
|
|
setJob(newJob, true)
|
|
},
|
|
resultStreamUpdate({ id, result_stream }: { id: string; result_stream?: string }) {
|
|
onResultStreamUpdate?.({ jobId: id, result_stream })
|
|
},
|
|
loadExtraLogs({ id, logs }: { id: string; logs: string }) {
|
|
if (id == jobId && job) {
|
|
job.logs = logs
|
|
}
|
|
if (loadExtraLogs) {
|
|
loadExtraLogs(logs)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
} catch (e) {
|
|
if (
|
|
e?.body?.includes('As a non logged in user, you can only see jobs ran by anonymous users')
|
|
) {
|
|
notAnonynmous = true
|
|
} else {
|
|
console.error(e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let destroyed = false
|
|
|
|
updateRecursiveRefresh(untrack(() => jobId))
|
|
|
|
async function updateJobId() {
|
|
if (jobId !== job?.id || innerModules == undefined) {
|
|
localModuleStates = {}
|
|
flowTimeline?.reset()
|
|
timeout && clearTimeout(timeout)
|
|
innerModules = undefined
|
|
if (flowJobIds) {
|
|
let modId = flowJobIds?.moduleId ?? ''
|
|
|
|
if (localDurationStatuses[modId] == undefined) {
|
|
localDurationStatuses[modId] = { byJob: {} }
|
|
}
|
|
let prefixed = buildSubflowKey(modId, prefix)
|
|
globalDurationStatuses.forEach((x) => {
|
|
if (x[prefixed] == undefined) {
|
|
x[prefixed] = { byJob: {} }
|
|
}
|
|
})
|
|
} else {
|
|
recursiveRefresh = {}
|
|
localDurationStatuses = {}
|
|
updateRecursiveRefresh(jobId)
|
|
}
|
|
await loadJobInProgress()
|
|
}
|
|
}
|
|
|
|
let forloop_selected = $derived(
|
|
getTopModuleStates?.[buildSubflowKey(flowJobIds?.moduleId ?? '', prefix)]?.selectedForloop
|
|
)
|
|
|
|
onDestroy(() => {
|
|
destroyed = true
|
|
timeout && clearTimeout(timeout)
|
|
// sub?.()
|
|
})
|
|
|
|
function isSuccess(arg: any): boolean | undefined {
|
|
if (arg == undefined) {
|
|
return undefined
|
|
} else {
|
|
return arg == true
|
|
}
|
|
}
|
|
|
|
function onJobsLoadedInner(mod: FlowStatusModule, job: Job, force?: boolean): void {
|
|
let id = mod.id
|
|
if (id && ((mod.flow_jobs ?? []).length == 0 || force)) {
|
|
// console.debug('onJobsLoadedInner', id, job.id, force)
|
|
if (flowStateStore) {
|
|
flowStateStore[buildSubflowKey(id, prefix)] = {
|
|
...(flowStateStore?.[buildSubflowKey(id, prefix)] ?? {}),
|
|
previewResult: job['result'],
|
|
previewArgs: job.args,
|
|
previewJobId: job.id,
|
|
previewSuccess: job['success']
|
|
}
|
|
}
|
|
|
|
initializeByJob(id)
|
|
let started_at = job.started_at ? new Date(job.started_at).getTime() : undefined
|
|
if (job.type == 'QueuedJob') {
|
|
setModuleState(
|
|
id,
|
|
{
|
|
type: 'InProgress',
|
|
job_id: job.id,
|
|
logs: job.logs,
|
|
args: job.args,
|
|
tag: job.tag,
|
|
started_at,
|
|
parent_module: mod['parent_module'],
|
|
script_hash: job.script_hash,
|
|
workflow_as_code_status: job['workflow_as_code_status']
|
|
},
|
|
force
|
|
)
|
|
setDurationStatusByJob(
|
|
id,
|
|
job.id,
|
|
{
|
|
created_at: job.created_at ? new Date(job.created_at).getTime() : undefined,
|
|
started_at
|
|
},
|
|
true
|
|
)
|
|
} else {
|
|
const parent_module = mod['parent_module']
|
|
|
|
// Delete existing failure node attached to the same parent module
|
|
removeFailureNode(id, parent_module)
|
|
|
|
setModuleState(
|
|
id,
|
|
{
|
|
args: job.args,
|
|
type: job['success'] ? 'Success' : 'Failure',
|
|
logs: job.logs,
|
|
result: job['result'],
|
|
job_id: job.id,
|
|
tag: job.tag,
|
|
parent_module,
|
|
duration_ms: job['duration_ms'],
|
|
started_at: started_at,
|
|
flow_jobs: mod.flow_jobs,
|
|
flow_jobs_success: mod.flow_jobs_success,
|
|
flow_jobs_duration: mod.flow_jobs_duration,
|
|
iteration_total: mod.iterator?.itered_len,
|
|
retries: mod?.failed_retries?.length,
|
|
skipped: mod.skipped,
|
|
agent_actions: mod.agent_actions,
|
|
script_hash: job.script_hash,
|
|
workflow_as_code_status: job['workflow_as_code_status']
|
|
},
|
|
force
|
|
)
|
|
|
|
setDurationStatusByJob(
|
|
id,
|
|
job.id,
|
|
{
|
|
created_at: job.created_at ? new Date(job.created_at).getTime() : undefined,
|
|
started_at,
|
|
duration_ms: job['duration_ms']
|
|
},
|
|
true
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function setIteration(
|
|
j: number,
|
|
id: string,
|
|
clicked: boolean,
|
|
modId: string,
|
|
isForloop: boolean
|
|
) {
|
|
if (modId) {
|
|
let prefixedId = buildSubflowKey(modId, prefix)
|
|
|
|
let topModuleStates = getTopModuleStates
|
|
let state = topModuleStates?.[prefixedId]
|
|
|
|
if (clicked && state?.selectedForloop) {
|
|
await refreshGlobal?.(prefixedId, true, state.selectedForloop)
|
|
}
|
|
let manualOnce = state?.selectedForLoopSetManually
|
|
if (
|
|
clicked ||
|
|
(!manualOnce &&
|
|
(state == undefined || !isForloop || j >= (state.selectedForloopIndex ?? -1)))
|
|
) {
|
|
let setManually = clicked || manualOnce
|
|
|
|
let newState: Partial<GraphModuleState> = {
|
|
...(state ?? {}),
|
|
selectedForloop: id,
|
|
selectedForloopIndex: j,
|
|
selectedForLoopSetManually: setManually
|
|
}
|
|
|
|
const selectedNotEqual =
|
|
id != state?.selectedForloop ||
|
|
j != state?.selectedForloopIndex ||
|
|
setManually != state?.selectedForLoopSetManually
|
|
// console.debug('setIteration', selectedNotEqual, state, topModuleStates)
|
|
if (selectedNotEqual) {
|
|
if (topModuleStates) {
|
|
topModuleStates[prefixedId] = {
|
|
type: 'WaitingForPriorSteps',
|
|
args: {},
|
|
...newState
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (clicked) {
|
|
await refreshGlobal?.(prefixedId, false, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
function innerJobLoaded(jobLoaded: Job, j: number, clicked: boolean, force: boolean) {
|
|
let modId = flowJobIds?.moduleId
|
|
|
|
if (modId) {
|
|
setIteration(
|
|
j,
|
|
jobLoaded.id,
|
|
clicked,
|
|
modId,
|
|
innerModule?.type == 'forloopflow' || innerModule?.type == 'whileloopflow'
|
|
)
|
|
|
|
let prefixedId = buildSubflowKey(modId, prefix)
|
|
|
|
// if (flowStateStore) {
|
|
// flowStateStore[modId] = {
|
|
// ...((flowStateStore[modId] as object) ?? {}),
|
|
// previewResult: jobLoaded.args
|
|
// }
|
|
// }
|
|
if (flowStateStore && flowStateStore[prefixedId] == undefined) {
|
|
flowStateStore[prefixedId] = {}
|
|
}
|
|
if (flowStateStore) {
|
|
if (
|
|
!flowStateStore?.[prefixedId]?.previewResult ||
|
|
!Array.isArray(flowStateStore[prefixedId]?.previewResult)
|
|
) {
|
|
flowStateStore[prefixedId].previewResult = []
|
|
}
|
|
flowStateStore[prefixedId].previewArgs = jobLoaded.args
|
|
}
|
|
if (jobLoaded.type == 'QueuedJob') {
|
|
jobResults[j] = 'Job in progress ...'
|
|
} else if (jobLoaded.type == 'CompletedJob') {
|
|
if (flowStateStore?.[prefixedId]) {
|
|
flowStateStore[prefixedId].previewResult[j] = jobLoaded.result
|
|
}
|
|
jobResults[j] = jobLoaded.result
|
|
}
|
|
|
|
let started_at = jobLoaded.started_at ? new Date(jobLoaded.started_at).getTime() : undefined
|
|
|
|
let created_at = jobLoaded.created_at ? new Date(jobLoaded.created_at).getTime() : undefined
|
|
|
|
let job_id = jobLoaded.id
|
|
initializeByJob(modId)
|
|
|
|
let v: Partial<GraphModuleState> = {
|
|
started_at,
|
|
flow_jobs: flowJobIds?.flowJobs,
|
|
flow_jobs_success: flowJobIds?.flowJobsSuccess,
|
|
flow_jobs_duration: flowJobIds?.flowJobsDuration,
|
|
iteration_total: flowJobIds?.length,
|
|
duration_ms: undefined
|
|
}
|
|
|
|
let currentIndex = getTopModuleStates?.[prefixedId]?.selectedForloopIndex == j
|
|
|
|
if (currentIndex) {
|
|
v.logs = jobLoaded.logs
|
|
v.args = jobLoaded.args
|
|
v.job_id = jobLoaded.id
|
|
}
|
|
if (jobLoaded.type == 'QueuedJob') {
|
|
if (started_at && localModuleStates[modId]?.type != 'InProgress') {
|
|
v.type = 'InProgress'
|
|
}
|
|
} else if (jobLoaded.type == 'CompletedJob') {
|
|
v.flow_jobs_results = jobResults
|
|
if (currentIndex) {
|
|
v.result = jobLoaded.result
|
|
}
|
|
}
|
|
setModuleState(modId, v, force, true)
|
|
if (innerModule?.type == 'branchall') {
|
|
if (jobLoaded.type == 'QueuedJob') {
|
|
setDurationStatusByJob(
|
|
modId,
|
|
job_id,
|
|
{
|
|
created_at,
|
|
started_at
|
|
},
|
|
false
|
|
)
|
|
} else if (jobLoaded.type == 'CompletedJob') {
|
|
setDurationStatusByJob(
|
|
modId,
|
|
job_id,
|
|
{
|
|
created_at,
|
|
started_at,
|
|
duration_ms: jobLoaded.duration_ms
|
|
},
|
|
false
|
|
)
|
|
}
|
|
}
|
|
|
|
if (jobLoaded.job_kind == 'script' || isScriptPreview(jobLoaded.job_kind)) {
|
|
let id: string | undefined = undefined
|
|
if (
|
|
(innerModule?.type == 'forloopflow' || innerModule?.type == 'whileloopflow') &&
|
|
innerModule.modules.length == 1
|
|
) {
|
|
id = innerModule?.modules?.[0]?.id
|
|
}
|
|
if (id) {
|
|
onJobsLoadedInner({ id } as FlowStatusModule, jobLoaded)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let flowTimeline: FlowTimeline | undefined = $state()
|
|
|
|
let stepDetail: FlowModule | string | undefined = $state(undefined)
|
|
|
|
let storedListJobs: Record<number, Job> = $state({})
|
|
|
|
let selectedToolCall: string | undefined = $state(undefined)
|
|
|
|
let wrapperHeight: number = $state(0)
|
|
|
|
let retryStatusHeight: number = $state(0)
|
|
|
|
function removeFailureNode(id: string, parent_module: any) {
|
|
if (id?.startsWith('failure-') && parent_module) {
|
|
;[...globalModuleStates, localModuleStates].forEach((stateMap) => {
|
|
if (id) {
|
|
Object.keys(stateMap).forEach((key) => {
|
|
if (stateMap[key]?.parent_module == parent_module) {
|
|
delete stateMap[key]
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
export type FlowModuleForTimeline = {
|
|
id: string
|
|
type: FlowModuleValue['type']
|
|
}
|
|
|
|
function allModulesForTimeline(
|
|
modules: FlowModule[],
|
|
expandedSubflows: Record<string, { modules: FlowModule[]; groups?: any[] }>
|
|
): FlowModuleForTimeline[] {
|
|
const ids = dfs(modules, (x) => ({ id: x.id, type: x.value.type }) as FlowModuleForTimeline, {
|
|
skipToolNodes: true
|
|
})
|
|
|
|
function rec(
|
|
ids: FlowModuleForTimeline[],
|
|
prefix: string | undefined
|
|
): FlowModuleForTimeline[] {
|
|
return ids.concat(
|
|
ids.flatMap(({ id }) => {
|
|
let fms = expandedSubflows[id]?.modules
|
|
let oid = id.split(':').pop()
|
|
if (!oid) {
|
|
return []
|
|
}
|
|
let nprefix = buildPrefix(prefix, oid)
|
|
return fms
|
|
? rec(
|
|
dfs(
|
|
fms,
|
|
(x) => ({
|
|
id: x.id.startsWith('subflow:') ? x.id : buildSubflowKey(x.id, nprefix),
|
|
type: x.value.type
|
|
}),
|
|
{ skipToolNodes: true }
|
|
),
|
|
nprefix
|
|
)
|
|
: []
|
|
})
|
|
)
|
|
}
|
|
|
|
return rec(ids, undefined)
|
|
}
|
|
|
|
let subflowsSize = $state(500)
|
|
|
|
// Splitpanes sizes for graph and details sections
|
|
let graphPaneSize = $state(66)
|
|
let detailsPaneSize = $state(34)
|
|
|
|
function setParentModuleState(modId: string, state: Partial<GraphModuleState>) {
|
|
;[localModuleStates, ...globalModuleStates].forEach((stateMap) => {
|
|
if (stateMap[modId]) {
|
|
stateMap[modId] = { ...stateMap[modId], ...state }
|
|
}
|
|
})
|
|
if (prefix) {
|
|
let prefixedId = buildSubflowKey(modId, prefix)
|
|
subflowParentsGlobalModuleStates.forEach((stateMap) => {
|
|
if (stateMap[prefixedId]) {
|
|
stateMap[prefixedId] = { ...stateMap[prefixedId], ...state }
|
|
}
|
|
})
|
|
}
|
|
}
|
|
async function onSelectedIteration(
|
|
detail:
|
|
| { id: string; index: number; manuallySet: true; moduleId: string }
|
|
| { manuallySet: false; moduleId: string }
|
|
) {
|
|
let prefixedId = buildSubflowKey(detail.moduleId, prefix)
|
|
if (detail.manuallySet) {
|
|
let rootJobId = detail.id
|
|
await tick()
|
|
|
|
let previousId = getTopModuleStates?.[prefixedId]?.selectedForloop
|
|
if (previousId) {
|
|
await refreshGlobal?.(prefixedId, true, previousId)
|
|
}
|
|
|
|
setParentModuleState(detail.moduleId, {
|
|
selectedForloop: detail.id,
|
|
selectedForloopIndex: detail.index,
|
|
selectedForLoopSetManually: true
|
|
})
|
|
|
|
await tick()
|
|
|
|
await refreshGlobal?.(prefixedId, false, rootJobId)
|
|
} else {
|
|
setParentModuleState(detail.moduleId, {
|
|
selectedForLoopSetManually: false
|
|
})
|
|
}
|
|
if (selectedNode?.startsWith(AI_TOOL_CALL_PREFIX)) {
|
|
const [, agentModuleId, toolCallIndex, _] = selectedNode.split('-')
|
|
const parentLoopsPrefix = getParentLoopsPrefix(agentModuleId)
|
|
toolCallStore?.addToolCallToLoad(parentLoopsPrefix + agentModuleId + '-' + toolCallIndex)
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
flowJobIds?.moduleId && untrack(() => onFlowModuleId())
|
|
})
|
|
$effect(() => {
|
|
isForloopSelected && globalModuleStates && untrack(() => debounceLoadJobInProgress())
|
|
})
|
|
$effect(() => {
|
|
jobId && untrack(() => updateJobId())
|
|
})
|
|
let isListJob = $derived(flowJobIds != undefined && Array.isArray(flowJobIds?.flowJobs))
|
|
$effect(() => {
|
|
if (isSelectedBranch) {
|
|
let modId = flowJobIds?.moduleId
|
|
if (modId) {
|
|
let selectedForloop = getTopModuleStates?.[buildSubflowKey(modId, prefix)]?.selectedForloop
|
|
untrack(() => {
|
|
if (selectedForloop != forloop_selected) {
|
|
forloop_selected = selectedForloop
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
let selected = $derived(isListJob ? 'sequence' : 'graph') as
|
|
| 'sequence'
|
|
| 'graph'
|
|
| 'logs'
|
|
| 'assets'
|
|
|
|
let animateLogsTab = $state(false)
|
|
|
|
let noLogs = $derived(graphTabOpen && !isNodeSelected)
|
|
|
|
/**
|
|
* Returns a string like "forloopmodid1-{iter1}-forloopmodid2-{iter2}-forloopmodid3-{iter3}-"
|
|
* that can be used to prefix tool call store keys for nested tool calls.
|
|
*/
|
|
function getParentLoopsPrefix(modId: string) {
|
|
if (job?.raw_flow) {
|
|
const indices: string[] = []
|
|
const parents = dfsPreviousResults(modId, { value: job?.raw_flow, summary: '' }, true)
|
|
for (const parent of parents) {
|
|
if (parent.value.type === 'forloopflow' || parent.value.type === 'whileloopflow') {
|
|
const state = localModuleStates[parent.id]
|
|
if (state?.selectedForloopIndex !== undefined) {
|
|
indices.push(parent.id + '-' + state.selectedForloopIndex.toString())
|
|
}
|
|
}
|
|
}
|
|
indices.reverse()
|
|
return indices.length > 0 ? indices.join('-') + '-' : ''
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
// Set all tabs content to the same height to prevent layout jumps
|
|
let tabsHeight = $state({
|
|
sequenceHeight: 0,
|
|
logsHeight: 0,
|
|
assetsHeight: 0,
|
|
graphHeight: 0
|
|
})
|
|
|
|
let minTabHeight = $derived(
|
|
Math.max(
|
|
tabsHeight.sequenceHeight,
|
|
tabsHeight.logsHeight,
|
|
tabsHeight.assetsHeight,
|
|
tabsHeight.graphHeight
|
|
)
|
|
)
|
|
|
|
let totalEventsWaiting = $derived(
|
|
Object.values(suspendStatus?.val ?? {}).reduce((a, b) => a + (b?.nb ?? 0), 0)
|
|
)
|
|
</script>
|
|
|
|
<JobLoader workspaceOverride={workspaceId} {noLogs} noCode bind:this={jobLoader} />
|
|
{#if notAnonynmous}
|
|
<Alert type="error" title="Required Auth">
|
|
As a non logged in user, you can only see jobs ran by anonymous users like you
|
|
</Alert>
|
|
{:else if job}
|
|
<div class="flow-root w-full space-y-4 {wideResults ? '' : 'max-w-7xl px-4'} mx-auto">
|
|
<!-- {#if innerModules.length > 0 && true}
|
|
<h3 class="text-md leading-6 font-bold text-primay border-b pb-2">Flow result</h3>
|
|
{:else}
|
|
<div class="h-8" />
|
|
{/if} -->
|
|
{#if isListJob}
|
|
{#if render}
|
|
<div class="w-full border rounded-sm bg-surface p-1 overflow-auto max-h-[90vh]">
|
|
<DisplayResult
|
|
workspaceId={isReplay ? undefined : job?.workspace_id}
|
|
jobId={isReplay ? undefined : jobId}
|
|
result_stream={job?.result_stream}
|
|
result={jobResults}
|
|
language={job?.language}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{:else if render}
|
|
<div class={'flex flex-col w-full'}>
|
|
{#if showLogsWithResult && job}
|
|
<!-- Side-by-side result and logs for simple jobs -->
|
|
{#if job && showJobDetailHeader}
|
|
<div class="mb-4">
|
|
<JobDetailHeader {job} extraCompact />
|
|
</div>
|
|
{/if}
|
|
<div class="grid grid-cols-2 grid-rows-[minmax(200px,400px)] gap-4 w-full">
|
|
<!-- Result Column -->
|
|
<div class="flex flex-col min-h-0">
|
|
<h3 class="shrink-0 text-xs font-semibold text-emphasis mb-1">Result</h3>
|
|
<div class="flex-1 min-h-0 overflow-auto rounded-md border bg-surface-tertiary p-4">
|
|
{#if job !== undefined && (job.result_stream || (job.type == 'CompletedJob' && 'result' in job && job.result !== undefined))}
|
|
<DisplayResult
|
|
workspaceId={isReplay ? undefined : job?.workspace_id}
|
|
result_stream={job.result_stream}
|
|
jobId={isReplay ? undefined : job?.id}
|
|
result={'result' in job ? job.result : undefined}
|
|
language={job?.language}
|
|
isTest={false}
|
|
/>
|
|
{:else if job}
|
|
<div
|
|
class="w-full h-full flex items-center justify-center text-secondary text-sm"
|
|
>
|
|
No output is available yet
|
|
</div>
|
|
{:else}
|
|
<div class="w-full h-full flex items-center justify-center">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs Column -->
|
|
<div class="flex flex-col min-h-0">
|
|
<h3 class="shrink-0 text-xs font-semibold text-emphasis mb-1">Logs</h3>
|
|
<div class="flex-1 min-h-0 overflow-auto rounded-md border bg-surface-tertiary">
|
|
<LogViewer
|
|
jobId={isReplay ? undefined : job.id}
|
|
download={!isReplay}
|
|
duration={job?.['duration_ms']}
|
|
mem={job?.['mem_peak']}
|
|
isLoading={job?.['running'] == false}
|
|
content={job?.logs}
|
|
tag={job?.tag}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<!-- Default single-column result -->
|
|
<h3 class="text-xs font-semibold text-emphasis mb-1">Result</h3>
|
|
<div class="flex-1 overflow-auto rounded-md border bg-surface-tertiary p-4 max-h-screen">
|
|
{#if job !== undefined && (job.result_stream || (job.type == 'CompletedJob' && 'result' in job && job.result !== undefined))}
|
|
<DisplayResult
|
|
workspaceId={isReplay ? undefined : job?.workspace_id}
|
|
result_stream={job.result_stream}
|
|
jobId={isReplay ? undefined : job?.id}
|
|
result={'result' in job ? job.result : undefined}
|
|
language={job?.language}
|
|
isTest={false}
|
|
/>
|
|
{:else if job}
|
|
<div class="w-full h-full flex items-center justify-center text-secondary text-sm">
|
|
No output is available yet
|
|
</div>
|
|
{:else}
|
|
<div class="w-full h-full flex items-center justify-center">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{#if render}
|
|
{#if innerModules && innerModules.length > 0 && !isListJob}
|
|
<Tabs class="mx-auto pt-2 {wideResults ? '' : 'max-w-7xl'}" bind:selected>
|
|
<Tab value="graph" label="Graph" />
|
|
<Tab
|
|
value="logs"
|
|
class={animateLogsTab
|
|
? 'animate-pulse animate-duration-1000 bg-surface-inverse text-primary-inverse'
|
|
: ''}
|
|
label="Logs"
|
|
/>
|
|
|
|
<Tab value="sequence" label="Details" />
|
|
<Tab value="assets" label="Assets" />
|
|
</Tabs>
|
|
{:else}
|
|
<div class="h-[30px]"></div>
|
|
{/if}
|
|
{/if}
|
|
<div
|
|
class="{selected != 'sequence' ? 'hidden' : ''} max-w-7xl mx-auto"
|
|
bind:clientHeight={tabsHeight.sequenceHeight}
|
|
style="min-height: {minTabHeight}px"
|
|
>
|
|
{#if isListJob}
|
|
<h3 class="text-md leading-6 font-bold text-primary border-b mb-4">
|
|
Subflows ({flowJobIds?.flowJobs.length})
|
|
</h3>
|
|
<div class="overflow-auto max-h-1/2">
|
|
{#each flowJobIds?.flowJobs ?? [] as loopJobId, j (loopJobId)}
|
|
{#if render && j + subflowsSize + 1 == (flowJobIds?.flowJobs.length ?? 0)}
|
|
<Button variant="default" on:click={() => (subflowsSize += 500)}
|
|
>Load 500 more...</Button
|
|
>
|
|
{/if}
|
|
{#if render && (j + subflowsSize + 1 > (flowJobIds?.flowJobs.length ?? 0) || forloop_selected == loopJobId)}
|
|
<Button
|
|
variant={forloop_selected === loopJobId ? 'contained' : 'border'}
|
|
color={flowJobIds?.flowJobsSuccess?.[j] === false
|
|
? 'red'
|
|
: forloop_selected === loopJobId
|
|
? 'dark'
|
|
: 'light'}
|
|
btnClasses="w-full flex justify-start"
|
|
on:click={async () => {
|
|
let storedJob = storedListJobs[j]
|
|
if (!storedJob && !isReplay) {
|
|
storedJob = await JobService.getJob({
|
|
workspace: workspaceId ?? $workspaceStore ?? '',
|
|
id: loopJobId,
|
|
noLogs: true,
|
|
noCode: true
|
|
})
|
|
storedListJobs[j] = storedJob
|
|
}
|
|
if (storedJob) {
|
|
innerJobLoaded(storedJob, j, true, false)
|
|
}
|
|
}}
|
|
endIcon={{
|
|
icon: ChevronDown,
|
|
classes: forloop_selected == loopJobId ? '!rotate-180' : ''
|
|
}}
|
|
>
|
|
<span class="truncate font-mono">
|
|
#{j + 1}: {loopJobId}
|
|
</span>
|
|
</Button>
|
|
{/if}
|
|
{#if forloop_selected == loopJobId || innerModule?.type == 'branchall'}
|
|
{@const forloopIsSelected =
|
|
forloop_selected == loopJobId ||
|
|
(innerModule?.type != 'forloopflow' && innerModule?.type != 'whileloopflow')}
|
|
{@const forLoopStoreKeyPrefix =
|
|
innerModule?.type == 'forloopflow' || innerModule?.type == 'whileloopflow'
|
|
? (flowJobIds?.moduleId ?? '') + '-' + j + '-'
|
|
: ''}
|
|
<!-- <LogId id={loopJobId} /> -->
|
|
<div class="border p-6" class:hidden={forloop_selected != loopJobId}>
|
|
<FlowStatusViewerInner
|
|
topModuleStates={getTopModuleStates}
|
|
{refreshGlobal}
|
|
isSelectedBranch={isSelectedBranch && forloopIsSelected}
|
|
updateRecursiveRefreshFn={updateRecursiveRefreshInner}
|
|
job={storedListJobs[j]}
|
|
initialJob={storedListJobs[j]}
|
|
globalModuleStates={forloopIsSelected
|
|
? [localModuleStates, ...globalModuleStates]
|
|
: []}
|
|
globalDurationStatuses={[localDurationStatuses, ...globalDurationStatuses]}
|
|
{prefix}
|
|
subflowParentsGlobalModuleStates={forloopIsSelected
|
|
? subflowParentsGlobalModuleStates
|
|
: []}
|
|
{subflowParentsDurationStatuses}
|
|
{updateGlobalRefresh}
|
|
render={forloop_selected == loopJobId && selected == 'sequence' && render}
|
|
isForloopSelected={forloop_selected == loopJobId &&
|
|
(innerModule?.type == 'forloopflow' || innerModule?.type == 'whileloopflow')}
|
|
reducedPolling={reducedPolling ||
|
|
(!!flowJobIds?.flowJobs.length && flowJobIds?.flowJobs.length > 20)}
|
|
{workspaceId}
|
|
jobId={loopJobId}
|
|
onJobsLoaded={({ job, force }) => {
|
|
storedListJobs[j] = job
|
|
innerJobLoaded(job, j, false, force)
|
|
}}
|
|
{onResultStreamUpdate}
|
|
graphTabOpen={selected == 'graph' && graphTabOpen}
|
|
isNodeSelected={forloop_selected == loopJobId}
|
|
toolCallStore={{
|
|
getStoredToolCallJob: (storeKey: string) =>
|
|
toolCallStore?.getStoredToolCallJob(forLoopStoreKeyPrefix + storeKey),
|
|
setStoredToolCallJob: (storeKey: string, job: Job) =>
|
|
toolCallStore?.setStoredToolCallJob(forLoopStoreKeyPrefix + storeKey, job),
|
|
getLocalToolCallJobs: (prefix: string) =>
|
|
toolCallStore?.getLocalToolCallJobs(forLoopStoreKeyPrefix + prefix) ?? {},
|
|
addToolCallToLoad: (storeKey: string) =>
|
|
toolCallStore?.addToolCallToLoad(forLoopStoreKeyPrefix + storeKey),
|
|
isToolCallToBeLoaded: (storeKey: string) =>
|
|
toolCallStore?.isToolCallToBeLoaded(forLoopStoreKeyPrefix + storeKey) ?? false
|
|
}}
|
|
showLogsWithResult
|
|
showJobDetailHeader
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{:else if innerModules && innerModules.length > 0 && ((job.raw_flow?.modules.length ?? 0) > 0 || innerModules[0]?.id == 'preprocessor')}
|
|
{@const hasPreprocessor = innerModules[0]?.id == 'preprocessor' ? 1 : 0}
|
|
{@const isPreprocessorOnly = hasPreprocessor && (job.raw_flow?.modules.length ?? 0) === 0}
|
|
<!-- if the flow is preprocessor only, we should only display the first innerModule: the second one is a placeholder added when running empty flows -->
|
|
{@const modules = isPreprocessorOnly ? [innerModules[0]] : (innerModules ?? [])}
|
|
<ul class="w-full">
|
|
<h3 class="text-md leading-6 font-bold text-primary border-b mb-4 py-2">
|
|
Step-by-step
|
|
</h3>
|
|
|
|
{#each modules as mod, i}
|
|
{#if render}
|
|
<div class="line w-8 h-10"></div>
|
|
<h3 class="text-primary mb-2 w-full">
|
|
{#if mod.id === 'preprocessor'}
|
|
Preprocessor module
|
|
{:else if job?.raw_flow?.modules && i < job?.raw_flow?.modules.length + hasPreprocessor}
|
|
Step
|
|
<span class="font-medium text-primary">
|
|
{i + 1 - hasPreprocessor}
|
|
</span>
|
|
out of
|
|
<span class="font-medium text-primary">{job?.raw_flow?.modules.length}</span>
|
|
{#if job.raw_flow?.modules[i]?.summary}
|
|
: <span class="font-medium text-primary">
|
|
{job.raw_flow?.modules[i]?.summary ?? ''}
|
|
</span>
|
|
{/if}
|
|
{:else}
|
|
Failure module
|
|
{/if}
|
|
</h3>
|
|
<div class="line w-8 h-10"></div>
|
|
{/if}
|
|
<li class="w-full border p-6 space-y-2 bg-blue-50/50 dark:bg-frost-900/50">
|
|
{#if render && Array.isArray(mod.failed_retries)}
|
|
{#each mod.failed_retries as failedRetry, j}
|
|
<Button
|
|
variant={retry_selected === failedRetry ? 'contained' : 'border'}
|
|
color="red"
|
|
btnClasses="w-full flex justify-start"
|
|
on:click={() => {
|
|
if (retry_selected == failedRetry) {
|
|
retry_selected = ''
|
|
} else {
|
|
retry_selected = failedRetry
|
|
}
|
|
}}
|
|
endIcon={{
|
|
icon: ChevronDown,
|
|
classes: retry_selected == failedRetry ? '!rotate-180' : ''
|
|
}}
|
|
>
|
|
<span class="truncate font-mono">
|
|
# Attempt {j + 1}: {failedRetry}
|
|
</span>
|
|
</Button>
|
|
|
|
<!-- <LogId id={loopJobId} /> -->
|
|
<div class="border p-6" class:hidden={retry_selected != failedRetry}>
|
|
<FlowStatusViewerInner
|
|
topModuleStates={getTopModuleStates}
|
|
{refreshGlobal}
|
|
isSelectedBranch={isSelectedBranch && retry_selected == failedRetry}
|
|
{updateGlobalRefresh}
|
|
updateRecursiveRefreshFn={updateRecursiveRefreshInner}
|
|
globalModuleStates={[localModuleStates, ...globalModuleStates]}
|
|
globalDurationStatuses={[localDurationStatuses, ...globalDurationStatuses]}
|
|
{prefix}
|
|
{subflowParentsGlobalModuleStates}
|
|
{subflowParentsDurationStatuses}
|
|
render={failedRetry == retry_selected && render}
|
|
{reducedPolling}
|
|
{workspaceId}
|
|
jobId={failedRetry}
|
|
{onResultStreamUpdate}
|
|
graphTabOpen={selected == 'graph' && graphTabOpen}
|
|
isNodeSelected={retry_selected == failedRetry}
|
|
/>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
{#if ['InProgress', 'Success', 'Failure'].includes(mod.type)}
|
|
{#if job.raw_flow?.modules[i]?.value.type == 'flow'}
|
|
<FlowStatusViewerInner
|
|
topModuleStates={getTopModuleStates}
|
|
{isSelectedBranch}
|
|
{refreshGlobal}
|
|
updateRecursiveRefreshFn={updateRecursiveRefreshInner}
|
|
globalModuleStates={[]}
|
|
{updateGlobalRefresh}
|
|
globalDurationStatuses={[]}
|
|
prefix={buildPrefix(prefix, mod.id ?? '')}
|
|
subflowParentsGlobalModuleStates={[
|
|
localModuleStates,
|
|
...globalModuleStates,
|
|
...subflowParentsGlobalModuleStates
|
|
]}
|
|
subflowParentsDurationStatuses={[
|
|
localDurationStatuses,
|
|
...globalDurationStatuses,
|
|
...subflowParentsDurationStatuses
|
|
]}
|
|
render={selected == 'sequence' && render}
|
|
{workspaceId}
|
|
jobId={mod.job ?? ''}
|
|
{reducedPolling}
|
|
isSubflow
|
|
onJobsLoaded={({ job, force }) => {
|
|
onJobsLoadedInner(mod, job, force)
|
|
}}
|
|
{onResultStreamUpdate}
|
|
graphTabOpen={selected == 'graph' && graphTabOpen}
|
|
isNodeSelected={false}
|
|
/>
|
|
{:else if mod.flow_jobs?.length == 0 && mod.job == '00000000-0000-0000-0000-000000000000'}
|
|
<div class="text-secondary">no subflow (empty loop?)</div>
|
|
{:else}
|
|
<FlowStatusViewerInner
|
|
topModuleStates={getTopModuleStates}
|
|
{refreshGlobal}
|
|
updateRecursiveRefreshFn={updateRecursiveRefreshInner}
|
|
globalModuleStates={[localModuleStates, ...globalModuleStates]}
|
|
globalDurationStatuses={[localDurationStatuses, ...globalDurationStatuses]}
|
|
render={selected == 'sequence' && render}
|
|
{workspaceId}
|
|
{prefix}
|
|
{updateGlobalRefresh}
|
|
{subflowParentsGlobalModuleStates}
|
|
{subflowParentsDurationStatuses}
|
|
{isSelectedBranch}
|
|
jobId={mod.job ?? ''}
|
|
{reducedPolling}
|
|
innerModule={mod.flow_jobs ? job.raw_flow?.modules[i]?.value : undefined}
|
|
flowJobIds={mod.flow_jobs
|
|
? {
|
|
moduleId: mod.id ?? '',
|
|
flowJobs: mod.flow_jobs,
|
|
flowJobsSuccess: mod.flow_jobs_success ?? [],
|
|
flowJobsDuration: mod.flow_jobs_duration,
|
|
length: mod.iterator?.itered_len ?? mod.flow_jobs.length,
|
|
branchall: job?.raw_flow?.modules?.[i]?.value?.type == 'branchall'
|
|
}
|
|
: undefined}
|
|
onJobsLoaded={({ job, force }) => {
|
|
onJobsLoadedInner(mod, job, force)
|
|
}}
|
|
loadExtraLogs={(logs) => {
|
|
setModuleState(mod.id ?? '', {
|
|
logs
|
|
})
|
|
}}
|
|
{onResultStreamUpdate}
|
|
graphTabOpen={selected == 'graph' && graphTabOpen}
|
|
isNodeSelected={localModuleStates?.[selectedNode ?? '']?.job_id == mod.job}
|
|
{toolCallStore}
|
|
showLogsWithResult={['script', 'rawscript'].includes(
|
|
job.raw_flow?.modules[i]?.value?.type ?? ''
|
|
)}
|
|
showJobDetailHeader={['script', 'rawscript'].includes(
|
|
job.raw_flow?.modules[i]?.value?.type ?? ''
|
|
)}
|
|
/>
|
|
{#if mod.agent_actions && mod.agent_actions.length > 0 && mod.id}
|
|
{@const storeKeyPrefix = getParentLoopsPrefix(mod.id)}
|
|
{#each mod.agent_actions as agentAction, j}
|
|
{#if agentAction.type === 'tool_call'}
|
|
{@const toolCallId = getToolCallId(j, mod.id, agentAction.module_id)}
|
|
{@const localToolCallKey = mod.id + '-' + j}
|
|
{@const storeKey = storeKeyPrefix + localToolCallKey}
|
|
{@const storedToolCallJob = toolCallStore?.getStoredToolCallJob(storeKey)}
|
|
{@const isSelected = localToolCallKey === selectedToolCall}
|
|
<Button
|
|
variant={isSelected ? 'contained' : 'border'}
|
|
color={mod.agent_actions_success?.[j] === false
|
|
? 'red'
|
|
: isSelected
|
|
? 'dark'
|
|
: 'light'}
|
|
btnClasses="w-full flex justify-start"
|
|
on:click={async () => {
|
|
if (isSelected) {
|
|
selectedToolCall = undefined
|
|
} else {
|
|
selectedToolCall = localToolCallKey
|
|
}
|
|
}}
|
|
endIcon={{
|
|
icon: ChevronDown,
|
|
classes: isSelected ? '!rotate-180' : ''
|
|
}}
|
|
>
|
|
<span class="truncate font-mono">
|
|
Tool call: {agentAction.function_name}
|
|
</span>
|
|
</Button>
|
|
{#if isSelected || storedToolCallJob || toolCallStore?.isToolCallToBeLoaded(storeKey)}
|
|
<FlowStatusViewerInner
|
|
topModuleStates={getTopModuleStates}
|
|
{refreshGlobal}
|
|
updateRecursiveRefreshFn={updateRecursiveRefreshInner}
|
|
globalModuleStates={[localModuleStates, ...globalModuleStates]}
|
|
globalDurationStatuses={[
|
|
localDurationStatuses,
|
|
...globalDurationStatuses
|
|
]}
|
|
render={selected == 'sequence' && render && isSelected}
|
|
{workspaceId}
|
|
{prefix}
|
|
{updateGlobalRefresh}
|
|
{subflowParentsGlobalModuleStates}
|
|
{subflowParentsDurationStatuses}
|
|
{isSelectedBranch}
|
|
jobId={agentAction.job_id}
|
|
job={storedToolCallJob}
|
|
initialJob={storedToolCallJob}
|
|
{reducedPolling}
|
|
onJobsLoaded={({ job, force }) => {
|
|
toolCallStore?.setStoredToolCallJob(storeKey, job)
|
|
onJobsLoadedInner({ id: toolCallId } as FlowStatusModule, job, force)
|
|
}}
|
|
loadExtraLogs={(logs) => {
|
|
setModuleState(toolCallId, {
|
|
logs
|
|
})
|
|
}}
|
|
{onResultStreamUpdate}
|
|
graphTabOpen={selected == 'graph' && graphTabOpen}
|
|
isNodeSelected={localModuleStates?.[toolCallId]?.job_id ==
|
|
agentAction.job_id}
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
{/if}
|
|
{/if}
|
|
{:else}
|
|
<ModuleStatus
|
|
type={mod.type}
|
|
scheduled_for={localModuleStates?.[mod.id ?? '']?.scheduled_for}
|
|
/>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else}
|
|
<div class="p-2 text-primary text-sm italic">Empty flow</div>
|
|
{/if}
|
|
</div>
|
|
{#if selected == 'logs' && render}
|
|
<div
|
|
class="mx-auto overflow-auto"
|
|
bind:clientHeight={tabsHeight.logsHeight}
|
|
style="min-height: {minTabHeight}px"
|
|
>
|
|
<FlowLogViewerWrapper
|
|
{job}
|
|
{localModuleStates}
|
|
{localDurationStatuses}
|
|
{workspaceId}
|
|
{render}
|
|
{onSelectedIteration}
|
|
/>
|
|
</div>
|
|
{:else if selected == 'assets' && render}
|
|
<div
|
|
class="p-2"
|
|
bind:clientHeight={tabsHeight.assetsHeight}
|
|
style="min-height: {minTabHeight}px"
|
|
>
|
|
<JobAssetsViewer {job} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if render}
|
|
{#if job.raw_flow && !isListJob}
|
|
<div
|
|
class="{selected != 'graph' ? 'hidden' : ''} grow mt-4"
|
|
bind:clientHeight={tabsHeight.graphHeight}
|
|
style="min-height: {minTabHeight}px"
|
|
>
|
|
<div class="border h-full rounded-md" bind:clientHeight={wrapperHeight}>
|
|
<Splitpanes>
|
|
<Pane bind:size={graphPaneSize} minSize={30}>
|
|
<div class="bg-surface-secondary h-full overflow-auto">
|
|
<div class="flex flex-col" bind:clientHeight={retryStatusHeight}>
|
|
{#each Object.values(retryStatus?.val ?? {}) as count}
|
|
{#if count}
|
|
<span class="text-sm">
|
|
Retry in progress, # of failed attempts: {count}
|
|
</span>
|
|
{/if}
|
|
{/each}
|
|
{#if totalEventsWaiting}
|
|
<span class="text-sm">
|
|
Flow suspended, waiting for {totalEventsWaiting} events
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
<FlowGraphV2
|
|
{selectionManager}
|
|
triggerNode={true}
|
|
download={!hideDownloadInGraph}
|
|
minHeight={wrapperHeight - retryStatusHeight}
|
|
success={jobId != undefined && isSuccess(job?.['success'])}
|
|
flowModuleStates={localModuleStates}
|
|
bind:expandedSubflows
|
|
onSelect={(e) => {
|
|
if (rightColumnSelect != 'node_definition' && rightColumnSelect != 'tracing') {
|
|
rightColumnSelect = 'node_status'
|
|
}
|
|
if (typeof e == 'string') {
|
|
if (e == 'Input') {
|
|
selectedNode = 'start'
|
|
stepDetail = undefined
|
|
} else if (e == 'Result') {
|
|
selectedNode = 'end'
|
|
stepDetail = 'end'
|
|
} else {
|
|
const id = e.startsWith(AI_TOOL_CALL_PREFIX) ? e.split('-').pop() : e
|
|
const mod = dfs(job?.raw_flow?.modules ?? [], (m) => m).find(
|
|
(m) => m?.id === id
|
|
)
|
|
stepDetail = mod
|
|
selectedNode = e
|
|
if (e.startsWith(AI_TOOL_CALL_PREFIX)) {
|
|
const [_prefix, agentModuleId, j, _toolModuleId] = e.split('-')
|
|
const parentLoopsPrefix = getParentLoopsPrefix(agentModuleId)
|
|
const jIdx = Number(j)
|
|
const storeKey = parentLoopsPrefix + agentModuleId + '-' + jIdx
|
|
toolCallStore?.addToolCallToLoad(storeKey)
|
|
}
|
|
}
|
|
} else {
|
|
stepDetail = e
|
|
selectedNode = e.id
|
|
}
|
|
}}
|
|
{onSelectedIteration}
|
|
earlyStop={job.raw_flow?.skip_expr !== undefined}
|
|
cache={job.raw_flow?.cache_ttl !== undefined}
|
|
modules={job.raw_flow?.modules ?? []}
|
|
notes={job.raw_flow?.notes ?? []}
|
|
groups={job.raw_flow?.groups}
|
|
failureModule={job.raw_flow?.failure_module}
|
|
preprocessorModule={job.raw_flow?.preprocessor_module}
|
|
allowSimplifiedPoll={false}
|
|
{workspace}
|
|
/>
|
|
</div>
|
|
</Pane>
|
|
<Pane bind:size={detailsPaneSize} minSize={25} class="!overflow-visible">
|
|
<div class="pt-1 overflow-auto min-h-[700px] flex flex-col h-full">
|
|
<Tabs bind:selected={rightColumnSelect}>
|
|
{#if !hideTimeline}
|
|
<Tab value="timeline" label="Timeline" />
|
|
{/if}
|
|
<Tab value="node_status" label="Node status" />
|
|
{#if !hideNodeDefinition}
|
|
<Tab value="node_definition" label="Node definition" />
|
|
{/if}
|
|
{#if Object.keys(job?.flow_status?.user_states ?? {}).length > 0}
|
|
<Tab value="user_states" label="User States" />
|
|
{/if}
|
|
<Tab value="tracing" label="Tracing" />
|
|
</Tabs>
|
|
{#if rightColumnSelect == 'timeline'}
|
|
<FlowTimeline
|
|
{localModuleStates}
|
|
{onSelectedIteration}
|
|
selfWaitTime={job?.self_wait_time_ms}
|
|
aggregateWaitTime={job?.aggregate_wait_time_ms}
|
|
flowDone={job?.['success'] != undefined}
|
|
bind:this={flowTimeline}
|
|
flowModules={allModulesForTimeline(
|
|
job?.raw_flow?.modules ?? [],
|
|
expandedSubflows ?? {}
|
|
)}
|
|
buildSubflowKey={(key) => buildSubflowKey(key, prefix)}
|
|
durationStatuses={localDurationStatuses}
|
|
/>
|
|
{:else if rightColumnSelect == 'node_status'}
|
|
<div class="p-4 grow flex flex-col gap-6">
|
|
{#if selectedNode?.startsWith(AI_TOOL_MESSAGE_PREFIX)}
|
|
<div class="pt-2 pb-4">
|
|
<Alert
|
|
type="info"
|
|
title="Message output is available on the AI agent node"
|
|
/>
|
|
</div>
|
|
{:else if selectedNode?.startsWith(AI_MCP_TOOL_CALL_PREFIX)}
|
|
{@const [, agentModuleId, toolCallIndex] = selectedNode.split('-')}
|
|
{@const agentNode = localModuleStates?.[agentModuleId]}
|
|
{@const agentActions = agentNode?.agent_actions}
|
|
{@const mcpActionIndex = parseInt(toolCallIndex)}
|
|
{@const mcpAction =
|
|
agentActions && mcpActionIndex >= 0 && mcpActionIndex < agentActions.length
|
|
? agentActions[mcpActionIndex]
|
|
: undefined}
|
|
{#if mcpAction?.type === 'mcp_tool_call' && agentNode?.result?.messages}
|
|
{@const message = agentNode.result.messages.find(
|
|
(m: { agent_action?: { call_id?: string }; content?: any }) =>
|
|
m.agent_action?.call_id === mcpAction.call_id
|
|
)}
|
|
<McpToolCallDetails
|
|
functionName={mcpAction.function_name}
|
|
args={mcpAction.arguments ?? {}}
|
|
result={message?.content}
|
|
type="Success"
|
|
workspaceId={job?.workspace_id}
|
|
/>
|
|
{/if}
|
|
{:else if selectedNode?.startsWith(AI_WEBSEARCH_PREFIX)}
|
|
<Alert
|
|
type="info"
|
|
title="Web search output is available on the AI agent node"
|
|
/>
|
|
{:else if selectedNode}
|
|
{@const node = localModuleStates[selectedNode]}
|
|
{#if selectedNode == 'end'}
|
|
<FlowJobResult
|
|
tagLabel={customUi?.tagLabel}
|
|
workspaceId={isReplay ? undefined : job?.workspace_id}
|
|
jobId={isReplay ? undefined : job?.id}
|
|
filename={job.id}
|
|
loading={job['running']}
|
|
tag={job?.tag}
|
|
col
|
|
result={job['result']}
|
|
logs={job.logs ?? ''}
|
|
downloadLogs={!hideDownloadLogs && !isReplay}
|
|
/>
|
|
{:else if selectedNode == 'start'}
|
|
{#if job.args}
|
|
<JobArgs
|
|
id={isReplay ? undefined : job.id}
|
|
workspace={isReplay
|
|
? undefined
|
|
: (job.workspace_id ?? $workspaceStore ?? 'no_w')}
|
|
args={job.args}
|
|
/>
|
|
{:else}
|
|
<p class="text-secondary">No arguments</p>
|
|
{/if}
|
|
{:else if node}
|
|
{@const module =
|
|
stepDetail && typeof stepDetail !== 'string' ? stepDetail : undefined}
|
|
{@const agentTools =
|
|
module && module.value.type === 'aiagent'
|
|
? module.value.tools
|
|
: undefined}
|
|
{@const parentLoopsPrefix = getParentLoopsPrefix(module?.id ?? '')}
|
|
{#if node.flow_jobs_results}
|
|
<div>
|
|
<span class="pl-1 text-emphasis text-xs font-medium"
|
|
>Result of step as collection of all subflows</span
|
|
>
|
|
<div class="overflow-auto max-h-[200px] p-2">
|
|
<DisplayResult
|
|
workspaceId={isReplay ? undefined : job?.workspace_id}
|
|
result={node.flow_jobs_results}
|
|
nodeId={selectedNode}
|
|
jobId={isReplay ? undefined : job?.id}
|
|
language={job?.language}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<div class="flex flex-col gap-2">
|
|
{#if node.flow_jobs_results}
|
|
<span class="pl-1 text-xs font-medium text-emphasis pt-4"
|
|
>Selected subflow</span
|
|
>
|
|
{/if}
|
|
<div class="flex flex-col gap-6">
|
|
<div class="flex flex-col gap-6">
|
|
<div class="flex gap-2 min-w-0 w-full items-center">
|
|
<ModuleStatus
|
|
type={node.type}
|
|
scheduled_for={node.scheduled_for}
|
|
skipped={node.skipped}
|
|
/>
|
|
{#if node.duration_ms}
|
|
<Badge>
|
|
<Hourglass class="mr-2" size={10} />
|
|
{msToSec(node.duration_ms)} s
|
|
</Badge>
|
|
{/if}
|
|
{#if node.job_id && !isReplay}
|
|
<div class="grow w-full flex flex-row-reverse">
|
|
<a
|
|
class="text-right text-xs"
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
href="{base}/run/{node.job_id ??
|
|
''}?workspace={job?.workspace_id}"
|
|
>
|
|
{truncateRev(node.job_id ?? '', 10)}
|
|
<ExternalLink size={12} class="inline-block" />
|
|
</a>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if !node.isListJob}
|
|
<div>
|
|
<div class="text-xs text-emphasis font-semibold mb-1">Inputs</div>
|
|
<JobArgs
|
|
id={isReplay ? undefined : node.job_id}
|
|
workspace={isReplay
|
|
? undefined
|
|
: (job.workspace_id ?? $workspaceStore ?? 'no_w')}
|
|
args={node.args}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{#if node.workflow_as_code_status}
|
|
<div>
|
|
<div class="text-xs text-emphasis font-semibold mb-1"
|
|
>Workflow timeline</div
|
|
>
|
|
<WorkflowTimeline
|
|
flow_status={asWorkflowStatus(node.workflow_as_code_status)}
|
|
flowDone={node.type === 'Success' || node.type === 'Failure'}
|
|
stepResults={getStepResults(node.workflow_as_code_status)}
|
|
result={node.result}
|
|
success={node.type === 'Success'}
|
|
jobId={node.job_id}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<FlowJobResult
|
|
tagLabel={customUi?.tagLabel}
|
|
workspaceId={isReplay ? undefined : job?.workspace_id}
|
|
jobId={isReplay ? undefined : node.job_id}
|
|
loading={node.type != 'Success' && node.type != 'Failure'}
|
|
waitingForExecutor={node.type == 'WaitingForExecutor'}
|
|
refreshLog={node.type == 'InProgress'}
|
|
col
|
|
result_stream={resultStreams[node.job_id ?? '']}
|
|
result={node.result}
|
|
tag={node.tag}
|
|
logs={node.logs}
|
|
downloadLogs={!hideDownloadLogs && !isReplay}
|
|
aiAgentStatus={agentTools &&
|
|
node?.job_id &&
|
|
(node.type === 'Success' || node.type === 'Failure')
|
|
? {
|
|
tools: agentTools,
|
|
agentJob: {
|
|
id: node.job_id,
|
|
result: node.result,
|
|
logs: node.logs,
|
|
args: node.args,
|
|
success: node.type === 'Success',
|
|
type: 'CompletedJob'
|
|
},
|
|
storedToolCallJobs: module
|
|
? toolCallStore?.getLocalToolCallJobs(parentLoopsPrefix)
|
|
: undefined,
|
|
onToolJobLoaded: (job, idx) => {
|
|
if (module) {
|
|
const storeKey = parentLoopsPrefix + module.id + '-' + idx
|
|
toolCallStore?.setStoredToolCallJob(storeKey, job)
|
|
}
|
|
}
|
|
}
|
|
: undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<p class="p-2 text-primary italic"
|
|
>The execution of this node has no information attached to it. The job
|
|
likely did not run yet</p
|
|
>
|
|
{/if}
|
|
{:else}<p class="p-2 text-primary italic"
|
|
>Select a node to see its details here</p
|
|
>{/if}
|
|
</div>
|
|
{:else if rightColumnSelect == 'node_definition'}
|
|
{@const node = selectedNode ? localModuleStates[selectedNode] : undefined}
|
|
<FlowGraphViewerStep {stepDetail} jobScriptHash={node?.script_hash} />
|
|
{:else if rightColumnSelect == 'user_states'}
|
|
<div class="p-2">
|
|
<JobArgs argLabel="Key" args={job?.flow_status?.user_states ?? {}} />
|
|
</div>
|
|
{:else if rightColumnSelect == 'tracing'}
|
|
{@const node = selectedNode ? localModuleStates[selectedNode] : undefined}
|
|
{#if node?.job_id}
|
|
<JobOtelTraces jobId={node.job_id} />
|
|
{:else}
|
|
<div class="p-4 text-secondary"
|
|
>Select a node with a job to see HTTP request traces</div
|
|
>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</Pane>
|
|
</Splitpanes>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{:else}
|
|
<Skeleton layout={[[15], 1, [70]]}></Skeleton>
|
|
{/if}
|
|
|
|
<style>
|
|
.line {
|
|
background: repeating-linear-gradient(to bottom, transparent 0 4px, #bbb 4px 8px) 50%/1px 100%
|
|
no-repeat;
|
|
}
|
|
</style>
|