diff --git a/lib/iris/dashboard/src/components/controller/JobsTab.vue b/lib/iris/dashboard/src/components/controller/JobsTab.vue index 588fffe5d1..dd6d02753f 100644 --- a/lib/iris/dashboard/src/components/controller/JobsTab.vue +++ b/lib/iris/dashboard/src/components/controller/JobsTab.vue @@ -5,7 +5,7 @@ import { controllerRpcCall, useControllerRpc } from '@/composables/useRpc' import { useAutoRefresh, DEFAULT_REFRESH_MS } from '@/composables/useAutoRefresh' import { SEGMENT_COLORS, stateToName, stateDisplayName } from '@/types/status' import type { JobState } from '@/types/status' -import type { JobStatus, JobQuery, ListJobsResponse } from '@/types/rpc' +import type { JobStatus, JobQuery, ListJobsResponse, GetJobStatusResponse } from '@/types/rpc' import { timestampMs, formatDuration, formatRelativeTime } from '@/utils/formatting' import { flattenLoadedJobTree, getLeafJobName } from '@/utils/jobTree' import StatusBadge from '@/components/shared/StatusBadge.vue' @@ -39,6 +39,8 @@ const route = useRoute() const router = useRouter() const EXPANDED_JOBS_KEY = 'iris.controller.expandedJobs' +const STARRED_JOBS_KEY = 'iris.controller.starredJobs' +const MAX_STARRED_JOBS = 10 // -- State (hydrated from URL query params) -- @@ -68,6 +70,12 @@ const stateFilter = ref(queryStr(route.query.state)) const expandedJobs = ref>(loadExpandedJobs()) const childJobsByParent = ref>(new Map()) const loadingChildJobs = ref>(new Set()) +const starredJobIds = ref>(loadStarredJobs()) +const showStarredOnly = ref(queryStr(route.query.starred) === '1') +const starredJobsData = ref([]) +const starredLoading = ref(false) +const starredError = ref(null) +const starLimitNotice = ref(null) const JOB_STATES: JobState[] = [ 'pending', 'building', 'running', 'succeeded', 'failed', 'killed', 'worker_failed', 'unschedulable', @@ -113,6 +121,72 @@ function saveExpandedJobs() { } } +// -- Local storage for starred jobs (persists across sessions) -- + +function loadStarredJobs(): Set { + try { + const stored = localStorage.getItem(STARRED_JOBS_KEY) + return stored ? new Set(JSON.parse(stored) as string[]) : new Set() + } catch { + return new Set() + } +} + +function saveStarredJobs() { + try { + localStorage.setItem(STARRED_JOBS_KEY, JSON.stringify([...starredJobIds.value])) + } catch { + // ignore + } +} + +function toggleStar(job: JobStatus) { + const next = new Set(starredJobIds.value) + if (next.has(job.jobId)) { + next.delete(job.jobId) + } else { + if (next.size >= MAX_STARRED_JOBS) { + starLimitNotice.value = `You can star at most ${MAX_STARRED_JOBS} jobs — unstar one first.` + setTimeout(() => { starLimitNotice.value = null }, 4000) + return + } + next.add(job.jobId) + } + starredJobIds.value = next + saveStarredJobs() + if (showStarredOnly.value) { + void fetchStarredJobs() + } +} + +// Fetch each starred job individually — the ListJobs RPC does not support +// filtering by a set of job IDs, so this is the simplest correct way to +// show only starred jobs without losing any due to pagination. +async function fetchStarredJobs() { + const ids = [...starredJobIds.value] + if (ids.length === 0) { + starredJobsData.value = [] + starredError.value = null + return + } + starredLoading.value = true + starredError.value = null + try { + const results = await Promise.allSettled( + ids.map(id => controllerRpcCall('GetJobStatus', { jobId: id })), + ) + starredJobsData.value = results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled' && !!r.value?.job) + .map(r => r.value.job) + const failures = results.filter(r => r.status === 'rejected').length + if (failures > 0 && starredJobsData.value.length === 0) { + starredError.value = `Failed to load ${failures} starred job${failures !== 1 ? 's' : ''}` + } + } finally { + starredLoading.value = false + } +} + async function loadChildJobs(parentJobId: string) { if (loadingChildJobs.value.has(parentJobId)) return const nextLoading = new Set(loadingChildJobs.value) @@ -142,6 +216,11 @@ async function refreshExpandedChildren() { } async function fetchAll() { + if (showStarredOnly.value) { + await fetchStarredJobs() + await refreshExpandedChildren() + return + } await fetchJobs() await refreshExpandedChildren() } @@ -153,7 +232,15 @@ watch([page, sortField, sortDir, nameFilter, stateFilter], () => { childJobsByParent.value = new Map() expandedJobs.value = new Set() saveExpandedJobs() - fetchJobs() + if (!showStarredOnly.value) fetchJobs() +}) + +watch(showStarredOnly, (on) => { + childJobsByParent.value = new Map() + expandedJobs.value = new Set() + saveExpandedJobs() + if (on) void fetchStarredJobs() + else void fetchJobs() }) watch(stateFilter, () => { @@ -161,7 +248,7 @@ watch(stateFilter, () => { }) // Sync filter/sort/page state into the URL so back-button and link sharing work. -watch([page, sortField, sortDir, nameFilter, stateFilter], () => { +watch([page, sortField, sortDir, nameFilter, stateFilter, showStarredOnly], () => { router.replace({ query: { ...route.query, @@ -170,27 +257,87 @@ watch([page, sortField, sortDir, nameFilter, stateFilter], () => { page: page.value !== 0 ? String(page.value) : undefined, name: nameFilter.value || undefined, state: stateFilter.value || undefined, + starred: showStarredOnly.value ? '1' : undefined, }, }) }) +// -- Starred-only client-side filter + sort -- + +function jobSortKey(job: JobStatus, field: SortField): number | string { + switch (field) { + case 'date': return timestampMs(job.submittedAt) || 0 + case 'name': return job.name ?? '' + case 'state': return stateToName(job.state) + case 'failures': return job.failureCount ?? 0 + case 'preemptions': return job.preemptionCount ?? 0 + } +} + +function compareJobs(a: JobStatus, b: JobStatus): number { + const av = jobSortKey(a, sortField.value) + const bv = jobSortKey(b, sortField.value) + const sign = sortDir.value === 'asc' ? 1 : -1 + if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * sign + return String(av).localeCompare(String(bv)) * sign +} + +const filteredStarredJobs = computed(() => { + const ids = starredJobIds.value + const nameF = nameFilter.value.toLowerCase() + const stateF = stateFilter.value + return starredJobsData.value + .filter(j => ids.has(j.jobId)) + .filter(j => !nameF || (j.name ?? '').toLowerCase().includes(nameF)) + .filter(j => !stateF || stateToName(j.state) === stateF) + .slice() + .sort(compareJobs) +}) + +const effectiveJobs = computed(() => showStarredOnly.value ? filteredStarredJobs.value : jobs.value) +const effectiveLoading = computed(() => showStarredOnly.value ? starredLoading.value : loading.value) +const effectiveError = computed(() => showStarredOnly.value ? starredError.value : error.value) +const effectiveTotalCount = computed(() => showStarredOnly.value ? filteredStarredJobs.value.length : totalCount.value) + // -- Job tree (lazy-loaded children) -- -const flattenedJobs = computed(() => flattenLoadedJobTree(jobs.value, childJobsByParent.value, expandedJobs.value)) +const flattenedJobs = computed(() => flattenLoadedJobTree(effectiveJobs.value, childJobsByParent.value, expandedJobs.value)) + +// Whether a row should render the expand toggle. In starred-only mode we +// may have fetched the job via GetJobStatus against an older controller +// that doesn't populate `has_children`; show the toggle defensively for +// top-level rows and let `loadChildJobs` reveal whether it actually has +// children. +function showExpandToggle(job: JobStatus, depth: number): boolean { + if (job.hasChildren) return true + if (showStarredOnly.value && depth === 0) return true + return false +} // -- Interactions -- -function toggleExpanded(job: JobStatus) { +async function toggleExpanded(job: JobStatus) { const next = new Set(expandedJobs.value) if (next.has(job.jobId)) { next.delete(job.jobId) - } else { - next.add(job.jobId) - if (!childJobsByParent.value.has(job.jobId)) { - void loadChildJobs(job.jobId) - } + expandedJobs.value = next + saveExpandedJobs() + return } + next.add(job.jobId) expandedJobs.value = next saveExpandedJobs() + if (!childJobsByParent.value.has(job.jobId)) { + await loadChildJobs(job.jobId) + // Defensive: auto-collapse if the load returned no children, so the + // expanded arrow doesn't dangle over an empty list (matters when the + // server doesn't populate hasChildren on GetJobStatus responses). + if ((childJobsByParent.value.get(job.jobId) ?? []).length === 0) { + const reset = new Set(expandedJobs.value) + reset.delete(job.jobId) + expandedJobs.value = reset + saveExpandedJobs() + } + } } function handleSort(field: SortField) { @@ -212,10 +359,11 @@ function handleFilterClear() { localFilter.value = '' nameFilter.value = '' stateFilter.value = '' + showStarredOnly.value = false page.value = 0 } -const hasActiveFilter = computed(() => !!nameFilter.value || !!stateFilter.value) +const hasActiveFilter = computed(() => !!nameFilter.value || !!stateFilter.value || showStarredOnly.value) // -- Formatting -- @@ -341,21 +489,51 @@ function sortIndicator(field: SortField): string { Reset + - {{ totalCount }} job{{ totalCount !== 1 ? 's' : '' }} + {{ effectiveTotalCount }} job{{ effectiveTotalCount !== 1 ? 's' : '' }}
- {{ error }} + {{ effectiveError }} +
+ + +
+ {{ starLimitNotice }}
-
+
@@ -365,8 +543,10 @@ function sortIndicator(field: SortField): string { @@ -409,7 +589,7 @@ function sortIndicator(field: SortField): string { > + @@ -501,7 +699,7 @@ function sortIndicator(field: SortField): string {
diff --git a/lib/iris/src/iris/cluster/controller/service.py b/lib/iris/src/iris/cluster/controller/service.py index 07414ee37e..45907ab912 100644 --- a/lib/iris/src/iris/cluster/controller/service.py +++ b/lib/iris/src/iris/cluster/controller/service.py @@ -1229,6 +1229,8 @@ def get_job_status( resources = _resource_spec_from_job_row(job) + has_children = bool(_parent_ids_with_children(self._db, [job.job_id])) + proto_job_status = job_pb2.JobStatus( job_id=job.job_id.to_wire(), state=job.state, @@ -1242,6 +1244,7 @@ def get_job_status( task_count=summary.task_count if summary else 0, completed_count=summary.completed_count if summary else 0, resources=resources, + has_children=has_children, ) if job.started_at: proto_job_status.started_at.CopyFrom(timestamp_to_proto(job.started_at)) diff --git a/lib/iris/tests/cluster/controller/test_service.py b/lib/iris/tests/cluster/controller/test_service.py index ce3df74b2a..604eb02c5d 100644 --- a/lib/iris/tests/cluster/controller/test_service.py +++ b/lib/iris/tests/cluster/controller/test_service.py @@ -365,6 +365,28 @@ def test_get_job_status_returns_status(service): assert response.job.state == job_pb2.JOB_STATE_PENDING +def test_get_job_status_reports_has_children(service, state): + """GetJobStatus sets has_children so the dashboard can render the expand toggle.""" + service.launch_job(make_job_request("parent-job"), None) + parent_id = JobName.root("test-user", "parent-job") + + child_id = JobName.from_wire(parent_id.to_wire() + "/child") + child_req = controller_pb2.Controller.LaunchJobRequest( + name=child_id.to_wire(), + entrypoint=job_pb2.RuntimeEntrypoint(), + resources=job_pb2.ResourceSpecProto(cpu_millicores=1000, memory_bytes=1024**3), + environment=job_pb2.EnvironmentConfig(), + ) + child_req.entrypoint.run_command.argv[:] = ["python", "-c", "pass"] + state.submit_job(child_id, child_req, Timestamp.now()) + + parent = service.get_job_status(controller_pb2.Controller.GetJobStatusRequest(job_id=parent_id.to_wire()), None) + assert parent.job.has_children is True + + child = service.get_job_status(controller_pb2.Controller.GetJobStatusRequest(job_id=child_id.to_wire()), None) + assert child.job.has_children is False + + def test_get_job_status_not_found(service): """Verify get_job_status raises ConnectError for unknown job.""" request = controller_pb2.Controller.GetJobStatusRequest(job_id=JobName.root("test-user", "nonexistent").to_wire())