Files
windmill/frontend/src/lib/components/runs/JobDetailFieldConfig.ts
T
Ruben Fiszelandwindmill-internal-app[bot] dda59767c2 feat: stamp webhook trigger_kind on token-driven job runs (#10431)
* feat: stamp ui vs webhook trigger_kind on direct job runs

* fix: gate ui trigger kind on min worker version and dedupe display names

* docs: state that the ui trigger kind attributes rather than proves

* refactor: fold the trigger fallback into one trigger_or_fallback helper

* feat: hold trigger_kind as a tolerant label on the worker paths

* chore: refresh the sqlx offline cache for the trigger_kind label queries

* chore: update ee-repo-ref to 7de7daff5eed410e0c815ad6b292d2b4303f02f2

This commit updates the EE repository reference after PR #700 was merged in windmill-ee-private.

Previous ee-repo-ref: 974ab910d9a30c5565e1198ee312acc6d11239f3

New ee-repo-ref: 7de7daff5eed410e0c815ad6b292d2b4303f02f2

Automated by sync-ee-ref workflow.

* fix: keep the API job structs tolerant of unknown trigger kinds too

* chore: point ee-repo-ref at the merged EE main

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-31 14:46:08 +00:00

547 lines
12 KiB
TypeScript

import type { Job } from '$lib/gen'
import { triggerDisplayNamesMap, triggerIconMap } from '$lib/components/triggers/utils'
import { formatMemory } from '$lib/utils'
import { flowPathToHref } from '$lib/scripts'
import { Calendar, Bot } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
/**
* Categories of job types for field configuration
*/
export type JobCategory =
| 'script'
| 'flow'
| 'dependencies'
| 'flow_node'
| 'ai_agent'
| 'system'
| 'trigger_job'
/**
* Field types that can be displayed in the job header
*/
export type JobField =
| 'created_at'
| 'started_at'
| 'created_by'
| 'permissioned_as'
| 'memory_peak'
| 'run_id'
| 'trigger_info'
| 'parent_job'
| 'schedule_path'
| 'script_hash'
| 'script_path'
| 'worker'
| 'language'
| 'step_info'
| 'flow_status'
| 'duration'
| 'priority'
| 'concurrency_key'
/**
* Configuration for how a field should be displayed
*/
export interface FieldConfig {
field: JobField
label: string
getValue: (job: Job) => string | null
getHref?: (job: Job, workspaceId: string) => string | null
}
/**
* Configuration for field presence per job category
*/
export type FieldPresenceConfig = Record<JobCategory, Record<JobField, boolean>>
/**
* Categorizes a job based on its job_kind
*/
export function getJobCategory(job: Job): JobCategory {
switch (job.job_kind) {
case 'script':
case 'preview':
case 'script_hub':
return 'script'
case 'flow':
case 'flowpreview':
case 'singlestepflow':
return 'flow'
case 'dependencies':
case 'flowdependencies':
case 'appdependencies':
return 'dependencies'
case 'flownode':
case 'flowscript':
case 'appscript':
return 'flow_node'
case 'aiagent':
return 'ai_agent'
case 'identity':
case 'deploymentcallback':
return 'system'
default:
// Check if it was triggered by something other than manual/schedule
if (job.parent_job || job.schedule_path) {
return 'trigger_job'
}
return 'script' // Default fallback
}
}
// For flow/app-shaped jobs `script_hash` is a flow_version/app_version id
// (i64) serialized as zero-padded 16-char hex.
export function isFlowVersionHash(job: Job): boolean {
switch (job.job_kind) {
case 'flow':
case 'flowpreview':
case 'singlestepflow':
case 'flownode':
case 'flowdependencies':
case 'appdependencies':
return true
default:
return false
}
}
/**
* Gets trigger information from a job
*/
export function getTriggerInfo(job: Job): { type: string; icon: any; detail?: string } | null {
if (job.schedule_path) {
return {
type: 'Schedule',
icon: Calendar,
detail: job.schedule_path
}
}
// Check for trigger type from job trigger_kind if available
const triggerKind = job.trigger_kind
if (triggerKind && triggerKind in triggerIconMap) {
// No detail: unlike the schedule branch above, the kind carries no path to add,
// and repeating it would render as "Webhook: webhook".
return {
type: triggerDisplayNamesMap[triggerKind],
icon: triggerIconMap[triggerKind]
}
}
// Infer from job properties
if (job.parent_job) {
if (job.is_flow_step) {
return {
type: 'Flow Step',
icon: BarsStaggered
}
} else {
return {
type: 'Triggered',
icon: Bot
}
}
}
return null
}
/**
* Field configurations for all possible fields
*/
export const fieldConfigs: Record<JobField, FieldConfig> = {
created_at: {
field: 'created_at',
label: 'Received',
getValue: (job) => job.created_at || null
},
started_at: {
field: 'started_at',
label: 'Started',
getValue: (job) => ('started_at' in job ? job.started_at : null) || null
},
created_by: {
field: 'created_by',
label: 'By',
getValue: (job) => job.created_by || 'unknown'
},
permissioned_as: {
field: 'permissioned_as',
label: 'Permissioned as',
getValue: (job) => job.permissioned_as || null
},
memory_peak: {
field: 'memory_peak',
label: 'Mem peak',
getValue: (job) => {
if (!job.mem_peak) return null
const formatted = formatMemory(job.mem_peak, true)
return `${formatted.display}|${formatted.tooltip}`
}
},
run_id: {
field: 'run_id',
label: 'Run ID',
getValue: (job) => job.id
},
trigger_info: {
field: 'trigger_info',
label: 'Trigger',
getValue: (job) => {
const triggerInfo = getTriggerInfo(job)
return triggerInfo ? triggerInfo.type : null
}
},
parent_job: {
field: 'parent_job',
label: 'Parent',
getValue: (job) => job.parent_job || null,
getHref: (job, workspaceId) =>
job.parent_job ? `/run/${job.parent_job}?workspace=${workspaceId}` : null
},
schedule_path: {
field: 'schedule_path',
label: 'Schedule',
getValue: (job) => job.schedule_path || null
},
script_hash: {
field: 'script_hash',
label: 'Hash',
getValue: (job) => {
if (!job.script_hash) return null
// Decode hex → decimal for flow/app version ids.
if (isFlowVersionHash(job)) {
try {
return BigInt('0x' + job.script_hash).toString()
} catch {
return job.script_hash.toString()
}
}
return job.script_hash.toString()
},
getHref: (job, workspaceId) => {
if (!job.script_hash) return null
if (job.job_kind === 'script' || job.job_kind === 'dependencies') {
return `/scripts/get/${job.script_hash}?workspace=${workspaceId}`
}
// Only flow-shaped jobs have a pinned-version viewer; app dep stays unlinked.
const isFlowKind =
job.job_kind === 'flow' ||
job.job_kind === 'flowpreview' ||
job.job_kind === 'singlestepflow' ||
job.job_kind === 'flownode' ||
job.job_kind === 'flowdependencies'
if (isFlowKind && job.script_path) {
let version: string
try {
version = BigInt('0x' + job.script_hash).toString()
} catch {
return null
}
return `/flows/get/${job.script_path}?workspace=${workspaceId}&version=${version}`
}
return null
}
},
script_path: {
field: 'script_path',
label: 'Path',
getValue: (job) => job.script_path || null,
getHref: (job, _workspaceId) => {
if (!job.script_path) return null
const isScript = job.job_kind === 'script'
return isScript ? `/scripts/get/${job.script_hash}` : flowPathToHref(job.script_path)
}
},
worker: {
field: 'worker',
label: 'Worker',
getValue: (job) => job.worker || null
},
language: {
field: 'language',
label: 'Language',
getValue: (job) => job.language || null
},
step_info: {
field: 'step_info',
label: 'Step',
getValue: (job) => {
if (job.is_flow_step && 'flow_step_id' in job) {
return (job as any).flow_step_id
}
return null
}
},
flow_status: {
field: 'flow_status',
label: 'Flow Status',
getValue: (job) => {
if (job.flow_status?.step !== undefined) {
return `Step ${job.flow_status.step + 1}`
}
return null
}
},
duration: {
field: 'duration',
label: 'Duration',
getValue: (job) => {
if ('duration_ms' in job && job.duration_ms) {
const ms = job.duration_ms
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${(ms / 60000).toFixed(1)}m`
}
return null
}
},
priority: {
field: 'priority',
label: 'Priority',
getValue: (job) => job.priority?.toString() || null
},
concurrency_key: {
field: 'concurrency_key',
label: 'Concurrency',
getValue: () => null // This will be provided separately as a prop
}
}
/**
* Field presence configuration per job category
* Defines which fields should be shown for each job type
*/
export const categoryFieldPresence: FieldPresenceConfig = {
script: {
created_at: true,
created_by: true,
permissioned_as: false, // Will be conditionally shown
worker: true,
run_id: true,
started_at: true,
memory_peak: true,
script_hash: true,
language: true,
duration: false,
// Optional fields
trigger_info: false,
parent_job: false,
schedule_path: false,
script_path: false,
step_info: false,
flow_status: false,
priority: false,
concurrency_key: false
},
flow: {
created_at: true,
started_at: true,
created_by: true,
permissioned_as: false, // Will be conditionally shown
worker: true,
run_id: true,
script_path: true,
script_hash: true,
flow_status: false,
duration: false,
// Optional fields
memory_peak: false,
trigger_info: false,
parent_job: false,
schedule_path: false,
language: false,
step_info: false,
priority: false,
concurrency_key: false
},
dependencies: {
created_at: true,
created_by: true,
permissioned_as: false, // Will be conditionally shown
worker: true,
run_id: true,
started_at: true,
script_hash: true,
script_path: true,
duration: false,
// Optional fields
memory_peak: false,
trigger_info: false,
parent_job: false,
schedule_path: false,
language: false,
step_info: false,
flow_status: false,
priority: false,
concurrency_key: false
},
flow_node: {
created_at: true,
created_by: true,
permissioned_as: false, // Will be conditionally shown
worker: true,
run_id: true,
started_at: true,
step_info: false,
script_hash: true,
memory_peak: true,
parent_job: true,
// Optional fields
trigger_info: false,
schedule_path: false,
script_path: false,
language: false,
flow_status: false,
duration: false,
priority: false,
concurrency_key: false
},
ai_agent: {
created_at: true,
created_by: true,
permissioned_as: false, // Will be conditionally shown
worker: true,
run_id: true,
started_at: true,
duration: false,
memory_peak: true,
// Optional fields
trigger_info: false,
parent_job: false,
schedule_path: false,
script_hash: false,
script_path: false,
language: false,
step_info: false,
flow_status: false,
priority: false,
concurrency_key: false
},
system: {
created_at: true,
created_by: true,
permissioned_as: false, // Will be conditionally shown
worker: true,
run_id: true,
started_at: true,
duration: false,
// Optional fields
memory_peak: false,
trigger_info: false,
parent_job: false,
schedule_path: false,
script_hash: false,
script_path: false,
language: false,
step_info: false,
flow_status: false,
priority: false,
concurrency_key: false
},
trigger_job: {
created_at: true,
created_by: true,
permissioned_as: false, // Will be conditionally shown
worker: true,
run_id: true,
started_at: true,
parent_job: true,
schedule_path: true,
trigger_info: true,
// Optional fields
memory_peak: false,
script_hash: false,
script_path: false,
language: false,
step_info: false,
flow_status: false,
duration: false,
priority: false,
concurrency_key: false
}
}
/**
* Gets relevant fields for a job based on its category and characteristics
*/
export function getRelevantFields(job: Job): FieldConfig[] {
const category = getJobCategory(job)
const fieldsPresence = categoryFieldPresence[category]
// Conditionally show permissioned_as field when different from created_by
const shouldShowPermissionedAs =
job.permissioned_as !== `u/${job.created_by}` && job.permissioned_as !== job.created_by
// Define the field ordering: created_by, permissioned_as, started_at, worker, then others
const fieldOrder: JobField[] = [
'created_at',
'started_at',
'created_by',
'permissioned_as',
'worker',
'run_id',
'memory_peak',
'script_hash',
'script_path',
'language',
'duration',
'flow_status',
'step_info',
'parent_job',
'schedule_path',
'trigger_info',
'priority',
'concurrency_key'
]
// Return fields in the specified order if they are present for this category
return fieldOrder
.filter((fieldName) => {
if (fieldName === 'permissioned_as') {
return shouldShowPermissionedAs
}
if (fieldName === 'parent_job') {
// Always show parent_job when it exists, regardless of category configuration
return job.parent_job !== null && job.parent_job !== undefined
}
if (fieldName === 'schedule_path') {
// Always show schedule_path when it exists, regardless of category configuration
return job.schedule_path !== null && job.schedule_path !== undefined
}
if (fieldName === 'trigger_info' && job.trigger_kind) {
// A stamped trigger kind is how the run was started, whatever the category — but
// only one `getTriggerInfo` can name, or the row renders empty. A schedule is
// left out because its own field already says the same thing.
return !job.schedule_path && job.trigger_kind in triggerIconMap
}
return fieldsPresence[fieldName]
})
.map((fieldName) => fieldConfigs[fieldName])
.filter((config) => config) // Safety check
}