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
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';

import type { JobState } from '@/contracts';

const props = defineProps<{
counts: Partial<Record<JobState, number>>;
}>();

const groups: { title: string; caption: string; states: JobState[] }[] = [
{ title: 'Queue', caption: 'Waiting for a worker or running', states: ['Pending', 'Scheduled', 'Active'] },
{ title: 'Waiting', caption: 'Paused until input arrives', states: ['AwaitingContinuation', 'AwaitingParameters'] },
{ title: 'Finished', caption: 'Reached a terminal state', states: ['Succeeded', 'Failed', 'Cancelled', 'Skipped'] },
];

const summary = computed(() => groups.map((group) => {
const rows = group.states.map(state => ({ state, label: stateLabel(state), value: props.counts[state] ?? 0 }));
const total = rows.reduce((sum, row) => sum + row.value, 0);
return { ...group, rows, total };
}));

function stateLabel(state: string): string {
const words = state.replace(/([a-z\d])(?=[A-Z])/g, '$1 ');
return words.charAt(0).toUpperCase() + words.slice(1).toLowerCase();
}

function share(value: number, total: number): string {
return total === 0 ? '0%' : `${(value / total) * 100}%`;
}
</script>

<template>
<section class="panel state-summary" aria-label="Job states">
<div v-for="group in summary" :key="group.title" class="state-group">
<header>
<span class="eyebrow">{{ group.title }}</span>
<strong :key="group.total">{{ group.total.toLocaleString() }}</strong>
<p>{{ group.caption }}</p>
</header>

<div class="state-group-bar" aria-hidden="true">
<template v-if="group.total > 0">
<span
v-for="row in group.rows"
:key="row.state"
:data-state="row.state.toLowerCase()"
:style="{ width: share(row.value, group.total) }"
></span>
</template>
</div>

<ul>
<li v-for="row in group.rows" :key="row.state">
<RouterLink
class="state-row"
:class="{ empty: row.value === 0 }"
:data-state="row.state.toLowerCase()"
:to="{ name: 'jobs', query: { state: row.state } }"
>
<span class="state-dot" aria-hidden="true"></span>
<span class="state-row-label">{{ row.label }}</span>
<span class="state-row-value">{{ row.value.toLocaleString() }}</span>
</RouterLink>
</li>
</ul>
</div>
</section>
</template>

This file was deleted.

150 changes: 116 additions & 34 deletions src/Immediate.Jobs.Dashboard/DashboardClient/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
--warning-soft: #fdf1dc;
--info: #3177b8;
--info-soft: #e5f1fb;
--pause: #7457c2;
--shadow: 0 1px 2px rgb(23 32 43 / 5%), 0 10px 28px rgb(23 32 43 / 5%);
}

Expand All @@ -44,6 +45,7 @@
--warning-soft: #382c18;
--info: #75b7ef;
--info-soft: #172f45;
--pause: #b39cf0;
--shadow: 0 1px 2px rgb(0 0 0 / 25%), 0 14px 34px rgb(0 0 0 / 18%);
}

Expand Down Expand Up @@ -390,62 +392,133 @@ code {
margin-top: 28px;
}

.metric-card {
--metric-color: var(--text);
.state-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}

.state-group {
display: flex;
min-width: 0;
border: 1px solid var(--border);
border-radius: 11px;
background: var(--panel);
padding: 16px 17px;
box-shadow: 0 1px 2px rgb(23 32 43 / 3%);
flex-direction: column;
gap: 14px;
padding: 18px 20px;
}

.metric-label {
display: flex;
align-items: center;
gap: 7px;
color: var(--text-subtle);
font-size: 10px;
font-weight: 650;
letter-spacing: 0.065em;
.state-group + .state-group {
border-left: 1px solid var(--border);
}

.metric-label > span {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--metric-color);
.state-group header {
display: flex;
flex-direction: column;
gap: 2px;
}

.metric-card strong {
display: block;
margin-top: 9px;
font-size: 27px;
.state-group header strong {
margin-top: 4px;
font-size: 28px;
font-variant-numeric: tabular-nums;
font-weight: 680;
color: var(--metric-color);
letter-spacing: -0.035em;
line-height: 1.1;
animation: metric-in 220ms ease-out;
}

.metric-card[data-state="succeeded"] {
--metric-color: var(--accent);
.state-group header p {
margin: 0;
color: var(--text-subtle);
font-size: 12px;
}

.state-group-bar {
display: flex;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: var(--panel-strong);
}

.state-group-bar span {
height: 100%;
background: var(--state-color);
transition: width 200ms ease;
}

.state-group ul {
display: grid;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}

.state-row {
display: flex;
align-items: center;
gap: 9px;
margin: 0 -8px;
border-radius: 7px;
padding: 6px 8px;
color: var(--text);
font-size: 13px;
text-decoration: none;
transition: background-color 120ms ease;
}

.metric-card[data-state="failed"] {
--metric-color: var(--danger);
.state-row:hover,
.state-row:focus-visible {
background: var(--panel-strong);
}

.metric-card[data-state="pending"],
.metric-card[data-state="scheduled"] {
--metric-color: var(--warning);
.state-row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}

.metric-card[data-state="active"] {
--metric-color: var(--info);
.state-dot {
width: 7px;
height: 7px;
flex: none;
border-radius: 999px;
background: var(--state-color);
}

.state-row-label {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.state-row-value {
font-variant-numeric: tabular-nums;
font-weight: 620;
}

.state-row.empty {
color: var(--text-subtle);
}

.state-row.empty .state-row-value {
font-weight: 500;
}

.state-row.empty .state-dot {
opacity: 0.4;
}

.state-summary [data-state="pending"] { --state-color: var(--warning); }
.state-summary [data-state="scheduled"] { --state-color: color-mix(in srgb, var(--warning) 55%, var(--panel)); }
.state-summary [data-state="active"] { --state-color: var(--info); }
.state-summary [data-state="awaitingcontinuation"] { --state-color: var(--pause); }
.state-summary [data-state="awaitingparameters"] { --state-color: color-mix(in srgb, var(--pause) 55%, var(--panel)); }
.state-summary [data-state="succeeded"] { --state-color: var(--accent); }
.state-summary [data-state="failed"] { --state-color: var(--danger); }
.state-summary [data-state="cancelled"] { --state-color: var(--text-subtle); }
.state-summary [data-state="skipped"] { --state-color: color-mix(in srgb, var(--info) 55%, var(--panel)); }

.chart-card {
display: grid;
grid-template-columns: minmax(120px, auto) minmax(0, 1fr);
Expand Down Expand Up @@ -1613,6 +1686,15 @@ tbody tr:hover {
grid-template-columns: minmax(0, 1fr);
}

.state-summary {
grid-template-columns: minmax(0, 1fr);
}

.state-group + .state-group {
border-top: 1px solid var(--border);
border-left: 0;
}

.filter-bar {
align-items: stretch;
flex-direction: column;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { useRouter } from 'vue-router';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import HistoryChart from '@/components/HistoryChart.vue';
import JobStateSummary from '@/components/JobStateSummary.vue';
import JobTable from '@/components/JobTable.vue';
import MetricCard from '@/components/MetricCard.vue';
import PageHeader from '@/components/PageHeader.vue';
import type { JobRecord, JobState } from '@/contracts';
import type { JobRecord } from '@/contracts';
import { formatDate } from '@/format';
import { errorText } from '@/notifications';
import { useOverviewQuery, useRecentJobsQuery } from '@/query';
Expand All @@ -21,18 +21,6 @@ const recentJobsQuery = useRecentJobsQuery();
const jobMutations = useJobMutations();
const cancelCandidate = ref<JobRecord>();

const summaryStates: JobState[] = [
'Pending',
'Scheduled',
'Active',
'AwaitingContinuation',
'AwaitingParameters',
'Succeeded',
'Failed',
'Cancelled',
'Skipped',
];

const snapshot = computed(() => overviewQuery.data.value);
const error = computed(() => overviewQuery.error.value ?? recentJobsQuery.error.value);

Expand All @@ -49,12 +37,8 @@ watch(snapshot, (value) => {
}
}, { immediate: true });

function count(state: JobState): number {
return snapshot.value?.counts[state] ?? 0;
}

function openJob(job: JobRecord): void {
void router.push({ name: 'jobs', params: { jobHandle: job.jobHandle } });
void router.push({ name: 'job-detail', params: { jobHandle: job.jobHandle } });
}

function openBatch(batchHandle: string): void {
Expand Down Expand Up @@ -85,9 +69,7 @@ async function confirmCancel(): Promise<void> {
<FeedbackState v-if="error" type="error" title="Dashboard data is unavailable" :description="errorText(error)" />
<FeedbackState v-else-if="!snapshot" type="loading" title="Loading dashboard" />
<template v-else>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard v-for="state in summaryStates" :key="state" :label="state" :value="count(state)" />
</div>
<JobStateSummary :counts="snapshot.counts" />

<div class="mt-4 grid gap-4 xl:grid-cols-2">
<article class="panel chart-card">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { getJobExecutions, getJobExecutionTelemetryLinks } from '@/api';
import BatchTable from '@/components/BatchTable.vue';
import HistoryChart from '@/components/HistoryChart.vue';
import JobDetail from '@/components/JobDetail.vue';
import JobStateSummary from '@/components/JobStateSummary.vue';
import JobTable from '@/components/JobTable.vue';
import MetricCard from '@/components/MetricCard.vue';
import ServerCard from '@/components/ServerCard.vue';
import WorkflowGraph from '@/components/WorkflowGraph.vue';
import type { BatchGraph, JobServerSnapshot } from '@/contracts';
Expand All @@ -34,13 +34,24 @@ describe('dashboard components', () => {
getJobExecutionTelemetryLinksMock.mockReset().mockResolvedValue([]);
});

it('renders multi-word metric labels as readable text', () => {
const wrapper = mount(MetricCard, {
props: { label: 'AwaitingContinuation', value: 3 },
it('groups job state counts by lifecycle stage and links to filtered jobs', async () => {
const router = createRouter({ history: createMemoryHistory(), routes });
const wrapper = mount(JobStateSummary, {
props: { counts: { Pending: 2, Active: 1, AwaitingContinuation: 3, Succeeded: 5, Failed: 1 } },
global: { plugins: [router] },
});

expect(wrapper.get('.metric-label').text()).toBe('Awaiting continuation');
expect(dashboardStyles).not.toMatch(/\.metric-label\s*\{[^}]*text-transform:\s*uppercase/s);
await router.isReady();

const groups = wrapper.findAll('.state-group');
expect(groups.map(group => group.get('.eyebrow').text())).toEqual(['Queue', 'Waiting', 'Finished']);
expect(groups.map(group => group.get('header strong').text())).toEqual(['3', '3', '6']);

const awaiting = wrapper.get('.state-row[data-state="awaitingcontinuation"]');
expect(awaiting.get('.state-row-label').text()).toBe('Awaiting continuation');
expect(awaiting.attributes('href')).toBe('/invocations?state=AwaitingContinuation');
expect(awaiting.classes()).not.toContain('empty');
expect(wrapper.get('.state-row[data-state="skipped"]').classes()).toContain('empty');
expect(dashboardStyles).not.toMatch(/\.state-row-label\s*\{[^}]*text-transform:\s*uppercase/s);
});

it('renders job rows, retained executions, and complete payload/context details', async () => {
Expand Down
Loading