Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 107 additions & 48 deletions lib/iris/dashboard/src/components/controller/JobDetail.vue
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { RouterLink } from 'vue-router'
import { controllerRpcCall } from '@/composables/useRpc'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
import { stateToName, stateDisplayName, statusColors } from '@/types/status'
import { stateToName, stateDisplayName } from '@/types/status'
import type {
JobStatus, TaskStatus, LaunchJobRequest,
GetJobStatusResponse, ListTasksResponse, ListJobsResponse,
ResourceUsage,
} from '@/types/rpc'
import { timestampMs, formatTimestamp, formatDuration, formatRelativeTime, formatBytes, formatDeviceConfig } from '@/utils/formatting'
import { flattenJobTree, getLeafJobName, getParentJobName, jobsWithChildren } from '@/utils/jobTree'
import PageShell from '@/components/layout/PageShell.vue'
import StatusBadge from '@/components/shared/StatusBadge.vue'
import InfoCard from '@/components/shared/InfoCard.vue'
import InfoRow from '@/components/shared/InfoRow.vue'
import EmptyState from '@/components/shared/EmptyState.vue'
import LogViewer from '@/components/shared/LogViewer.vue'

const router = useRouter()

const props = defineProps<{
jobId: string
}>()
Expand All @@ -30,7 +29,8 @@ const TERMINAL_STATES = new Set(['succeeded', 'failed', 'killed', 'worker_failed
const job = ref<JobStatus | null>(null)
const jobRequest = ref<LaunchJobRequest | null>(null)
const tasks = ref<TaskStatus[]>([])
const childJobs = ref<JobStatus[]>([])
const descendantJobs = ref<JobStatus[]>([])
const expandedChildJobs = ref<Set<string>>(new Set())
const loading = ref(true)
const error = ref<string | null>(null)
const profilingTaskId = ref<string | null>(null)
Expand All @@ -40,8 +40,11 @@ const stateFilter = ref('')

type SortColumn = 'task' | 'state' | 'mem' | 'cpu' | 'duration'
type SortDir = 'asc' | 'desc'
type ChildJobsView = 'direct' | 'all'

const sortColumn = ref<SortColumn | null>(null)
const sortDir = ref<SortDir>('asc')
const childJobsView = ref<ChildJobsView>('direct')

function toggleSort(col: SortColumn) {
if (sortColumn.value === col) {
Expand All @@ -63,13 +66,17 @@ async function copyJobName() {

// -- Fetch --

let fetchGeneration = 0

async function fetchData() {
const gen = ++fetchGeneration
error.value = null
try {
const [jobResp, tasksResp] = await Promise.all([
controllerRpcCall<GetJobStatusResponse>('GetJobStatus', { jobId: props.jobId }),
controllerRpcCall<ListTasksResponse>('ListTasks', { jobId: props.jobId }),
])
if (gen !== fetchGeneration) return // superseded by a newer fetchData()
if (!jobResp.job) {
error.value = 'Job not found'
return
Expand All @@ -85,20 +92,19 @@ async function fetchData() {
nameFilter: jobName,
limit: 500,
})
// Filter to direct children only: name starts with "jobName/" and has no further "/"
if (gen !== fetchGeneration) return // superseded by a newer fetchData()
const prefix = jobName + '/'
childJobs.value = (childResp.jobs ?? []).filter(j => {
if (!j.name.startsWith(prefix)) return false
const suffix = j.name.slice(prefix.length)
return suffix.length > 0 && !suffix.includes('/')
})
descendantJobs.value = (childResp.jobs ?? []).filter(j => j.name.startsWith(prefix))
} else {
childJobs.value = []
descendantJobs.value = []
}
} catch (e) {
if (gen !== fetchGeneration) return // superseded by a newer fetchData()
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
if (gen === fetchGeneration) {
loading.value = false
}
}
}

Expand All @@ -111,12 +117,26 @@ const isTerminal = computed(() => {
return TERMINAL_STATES.has(stateToName(job.value.state))
})

const { stop: stopRefresh } = useAutoRefresh(fetchData, 10_000)
const { stop: stopRefresh, start: startRefresh } = useAutoRefresh(fetchData, 10_000)

watch(isTerminal, (terminal) => {
if (terminal) stopRefresh()
})

// Re-fetch when navigating between jobs (Vue Router reuses the component).
watch(() => props.jobId, () => {
loading.value = true
job.value = null
jobRequest.value = null
tasks.value = []
descendantJobs.value = []
expandedChildJobs.value = new Set()
childJobsView.value = 'direct'
error.value = null
fetchData()
startRefresh()
})

// -- Formatting helpers --

function jobDuration(j: JobStatus): string {
Expand Down Expand Up @@ -153,20 +173,24 @@ function taskIndex(taskId: string): string {

// -- Child job helpers --

function childJobLeafName(name: string): string {
const lastSlash = name.lastIndexOf('/')
return lastSlash >= 0 ? name.slice(lastSlash + 1) : name
}
const visibleChildJobs = computed(() => {
if (childJobsView.value === 'all') return descendantJobs.value
const parentName = job.value?.name
if (!parentName) return []
return descendantJobs.value.filter(child => getParentJobName(child.name) === parentName)
})

function childJobDuration(j: JobStatus): string {
const started = timestampMs(j.startedAt)
if (started) {
const ended = timestampMs(j.finishedAt) || Date.now()
return formatDuration(started, ended)
const flattenedChildJobs = computed(() => flattenJobTree(visibleChildJobs.value, expandedChildJobs.value))
const expandableChildJobs = computed(() => jobsWithChildren(visibleChildJobs.value))

function toggleExpandedChildJob(jobName: string) {
const next = new Set(expandedChildJobs.value)
if (next.has(jobName)) {
next.delete(jobName)
} else {
next.add(jobName)
}
const submitted = timestampMs(j.submittedAt)
if (submitted) return 'queued ' + formatRelativeTime(submitted)
return '-'
expandedChildJobs.value = next
}

const SEGMENT_COLORS: Record<string, string> = {
Expand Down Expand Up @@ -491,10 +515,32 @@ async function handleProfile(taskId: string, profilerType: string, format: strin
</div>

<!-- Child Jobs -->
<div v-if="childJobs.length > 0" class="mb-6">
<h3 class="text-sm font-semibold uppercase tracking-wider text-text-secondary mb-3">
Child Jobs
</h3>
<div v-if="flattenedChildJobs.length > 0" class="mb-6">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold uppercase tracking-wider text-text-secondary">
Child Jobs
</h3>
<div class="inline-flex rounded-md border border-surface-border bg-surface p-0.5">
<button
class="px-2.5 py-1 text-xs rounded transition-colors"
:class="childJobsView === 'direct'
? 'bg-accent text-white'
: 'text-text-secondary hover:bg-surface-raised hover:text-text'"
@click="childJobsView = 'direct'"
>
Direct only
</button>
<button
class="px-2.5 py-1 text-xs rounded transition-colors"
:class="childJobsView === 'all'
? 'bg-accent text-white'
: 'text-text-secondary hover:bg-surface-raised hover:text-text'"
@click="childJobsView = 'all'"
>
All descendants
</button>
</div>
</div>
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-surface-border">
Expand All @@ -507,45 +553,58 @@ async function handleProfile(taskId: string, profilerType: string, format: strin
</thead>
<tbody>
<tr
v-for="child in childJobs"
:key="child.jobId"
class="border-b border-surface-border-subtle hover:bg-surface-raised transition-colors"
v-for="node in flattenedChildJobs"
:key="node.job.jobId"
class="group/row border-b border-surface-border-subtle hover:bg-surface-raised transition-colors"
>
<td class="px-3 py-2 text-[13px]">
<RouterLink
:to="'/job/' + encodeURIComponent(child.jobId)"
class="text-accent hover:underline font-mono"
>
{{ childJobLeafName(child.name) }}
</RouterLink>
<td
class="px-3 py-2 text-[13px]"
:style="{ paddingLeft: (node.depth * 20 + 12) + 'px' }"
>
<span class="inline-flex items-center gap-1">
<button
v-if="expandableChildJobs.has(node.job.name)"
class="text-text-muted hover:text-text select-none w-4 text-center text-xs"
@click.stop="toggleExpandedChildJob(node.job.name)"
>
{{ expandedChildJobs.has(node.job.name) ? '▼' : '▶' }}
</button>
<span v-else class="w-4" />
<RouterLink
:to="'/job/' + encodeURIComponent(node.job.jobId)"
class="text-accent hover:underline font-mono"
>
{{ getLeafJobName(node.job.name) }}
</RouterLink>
</span>
</td>
<td class="px-3 py-2 text-[13px]">
<StatusBadge :status="child.state" size="sm" />
<StatusBadge :status="node.job.state" size="sm" />
</td>
<td class="px-3 py-2 text-[13px] text-text-secondary font-mono">
{{ childJobDuration(child) }}
{{ jobDuration(node.job) }}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve queued duration for child jobs

The child-jobs table now renders duration via jobDuration(node.job), which returns '-' until startedAt is set. This drops the previous queued-time fallback (queued <relative time> from submittedAt) for pending child jobs, so users lose visibility into how long queued children have been waiting. That is a behavioral regression in the detail page’s status signal for unscheduled child jobs.

Useful? React with 👍 / 👎.

</td>
<td class="px-3 py-2 text-[13px]">
<div v-if="(child.taskCount ?? 0) === 0" class="text-xs text-text-muted">
<div v-if="(node.job.taskCount ?? 0) === 0" class="text-xs text-text-muted">
no tasks
</div>
<div v-else class="flex items-center gap-1.5">
<div class="flex h-2 w-28 rounded-full overflow-hidden bg-surface-sunken">
<div
v-for="(seg, i) in progressSegments(child)"
v-for="(seg, i) in progressSegments(node.job)"
:key="i"
:class="seg.colorClass"
:style="{ width: (seg.count / (child.taskCount ?? 1) * 100).toFixed(1) + '%' }"
:style="{ width: (seg.count / (node.job.taskCount ?? 1) * 100).toFixed(1) + '%' }"
:title="seg.label + ': ' + seg.count"
/>
</div>
<span class="text-xs text-text-secondary whitespace-nowrap">
{{ progressSummary(child) }}
{{ progressSummary(node.job) }}
</span>
</div>
</td>
<td class="px-3 py-2 text-xs text-text-muted max-w-xs truncate" :title="child.pendingReason ?? ''">
{{ child.pendingReason || '—' }}
<td class="px-3 py-2 text-xs text-text-muted max-w-xs truncate" :title="node.job.pendingReason ?? ''">
{{ node.job.pendingReason || '—' }}
</td>
</tr>
</tbody>
Expand Down
73 changes: 6 additions & 67 deletions lib/iris/dashboard/src/components/controller/JobsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { ref, computed, watch, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useControllerRpc } from '@/composables/useRpc'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
import { stateToName, statusColors } from '@/types/status'
import { stateToName } from '@/types/status'
import type { JobStatus, ListJobsResponse } from '@/types/rpc'
import { timestampMs, formatDuration, formatRelativeTime } from '@/utils/formatting'
import { flattenJobTree, getLeafJobName, jobsWithChildren } from '@/utils/jobTree'
import StatusBadge from '@/components/shared/StatusBadge.vue'
import EmptyState from '@/components/shared/EmptyState.vue'

Expand Down Expand Up @@ -86,72 +87,10 @@ watch([page, sortField, sortDir, nameFilter], () => {

// -- Job tree --

function getParentName(jobName: string): string | null {
if (!jobName) return null
const lastSlash = jobName.lastIndexOf('/')
if (lastSlash <= 0) return null
return jobName.slice(0, lastSlash)
}

function getLeafName(jobName: string): string {
if (!jobName) return jobName
const lastSlash = jobName.lastIndexOf('/')
return lastSlash >= 0 ? jobName.slice(lastSlash + 1) : jobName
}

interface JobTreeNode {
job: JobStatus
depth: number
}

const flattenedJobs = computed<JobTreeNode[]>(() => {
const jobList = jobs.value
const jobByName = new Map(jobList.map(j => [j.name, j]))
const childrenMap = new Map<string, JobStatus[]>()
const rootJobs: JobStatus[] = []

for (const job of jobList) {
const parentName = getParentName(job.name)
if (parentName && jobByName.has(parentName)) {
const children = childrenMap.get(parentName)
if (children) {
children.push(job)
} else {
childrenMap.set(parentName, [job])
}
} else {
rootJobs.push(job)
}
}

const result: JobTreeNode[] = []

function walk(list: JobStatus[], depth: number) {
for (const job of list) {
result.push({ job, depth })
const children = childrenMap.get(job.name)
if (children && expandedJobs.value.has(job.name)) {
walk(children, depth + 1)
}
}
}

walk(rootJobs, 0)
return result
})
const flattenedJobs = computed(() => flattenJobTree(jobs.value, expandedJobs.value))

// Track which jobs have children for expand/collapse UI
const jobsWithChildren = computed(() => {
const jobByName = new Map(jobs.value.map(j => [j.name, j]))
const set = new Set<string>()
for (const job of jobs.value) {
const parentName = getParentName(job.name)
if (parentName && jobByName.has(parentName)) {
set.add(parentName)
}
}
return set
})
const expandableJobs = computed(() => jobsWithChildren(jobs.value))

// -- Interactions --

Expand Down Expand Up @@ -376,7 +315,7 @@ function sortIndicator(field: SortField): string {
>
<span class="inline-flex items-center gap-1">
<button
v-if="jobsWithChildren.has(node.job.name)"
v-if="expandableJobs.has(node.job.name)"
class="text-text-muted hover:text-text select-none w-4 text-center text-xs"
@click.stop="toggleExpanded(node.job.name)"
>
Expand All @@ -387,7 +326,7 @@ function sortIndicator(field: SortField): string {
:to="'/job/' + encodeURIComponent(node.job.jobId)"
class="text-accent hover:underline font-mono"
>
{{ node.depth > 0 ? getLeafName(node.job.name) : (node.job.name || 'unnamed') }}
{{ node.depth > 0 ? getLeafJobName(node.job.name) : (node.job.name || 'unnamed') }}
</RouterLink>
<button
v-if="node.job.name"
Expand Down
11 changes: 11 additions & 0 deletions lib/iris/dashboard/src/components/controller/TaskDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ onMounted(async () => {
await fetchTask()
if (isActive.value) startRefresh()
})

// Re-fetch when navigating between tasks (Vue Router reuses the component).
// Clear stale data first so loading/error states render correctly if the fetch fails.
watch(() => props.taskId, async () => {
taskResponse.value = null
cpuHistory.value = []
memHistory.value = []
stopRefresh()
await fetchTask()
if (isActive.value) startRefresh()
})
</script>

<template>
Expand Down
Loading
Loading