Skip to content

Commit be9a0c8

Browse files
committed
feat: show next scheduled dag run in details
1 parent 01495ae commit be9a0c8

7 files changed

Lines changed: 628 additions & 460 deletions

File tree

api/v1/api.gen.go

Lines changed: 438 additions & 435 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v1/api.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7934,6 +7934,10 @@ components:
79347934
type: object
79357935
description: "Detailed DAG configuration information"
79367936
properties:
7937+
nextRun:
7938+
type: string
7939+
format: date-time
7940+
description: "Scheduler-aware next planned run time. Pending overdue one-offs remain visible until consumed."
79377941
group:
79387942
type: string
79397943
description: "Logical grouping of related DAGs for organizational purposes"

internal/service/frontend/api/v1/dags.go

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,19 @@ func (a *API) GetDAGSpec(ctx context.Context, request api.GetDAGSpecRequestObjec
253253
errs = append(errs, dag.BuildWarnings...)
254254
}
255255

256+
details := toDAGDetails(dag)
257+
if details != nil {
258+
projectionDAG := dag
259+
if dag != nil {
260+
dagCopy := *dag
261+
dagCopy.Name = a.resolveDAGName(ctx, request.FileName)
262+
projectionDAG = &dagCopy
263+
}
264+
details.NextRun = a.projectNextRun(ctx, projectionDAG)
265+
}
266+
256267
return &api.GetDAGSpec200JSONResponse{
257-
Dag: toDAGDetails(dag),
268+
Dag: details,
258269
Spec: yamlSpec,
259270
Errors: errs,
260271
}, nil
@@ -387,6 +398,9 @@ func (a *API) getDAGDetailsData(ctx context.Context, fileName string) (api.GetDA
387398
}
388399

389400
details := toDAGDetails(dag)
401+
if details != nil {
402+
details.NextRun = a.projectNextRun(ctx, dag)
403+
}
390404

391405
localDAGs := make([]api.LocalDag, 0, len(dag.LocalDAGs))
392406
for _, localDAG := range dag.LocalDAGs {
@@ -1478,19 +1492,7 @@ func (a *API) GetDAGsListData(ctx context.Context, queryString string) (any, err
14781492

14791493
func (a *API) listDAGsData(ctx context.Context, listOpts exec.ListDAGsOptions) (api.ListDAGs200JSONResponse, error) {
14801494
projectionTime := time.Now()
1481-
var schedulerState *scheduler.SchedulerState
1482-
if a.schedulerStateStore != nil {
1483-
state, loadErr := a.schedulerStateStore.Load(ctx)
1484-
if loadErr != nil {
1485-
logger.Warn(ctx, "Failed to load scheduler state for DAG list projection", tag.Error(loadErr))
1486-
} else {
1487-
schedulerState = state
1488-
}
1489-
}
1490-
1491-
nextRunProjection := func(dag *core.DAG, now time.Time) time.Time {
1492-
return scheduler.NextPlannedRun(dag, now, schedulerState)
1493-
}
1495+
nextRunProjection := a.nextRunProjection(ctx)
14941496

14951497
listOpts.Time = &projectionTime
14961498
listOpts.NextRunProjection = nextRunProjection
@@ -1531,6 +1533,30 @@ func (a *API) listDAGsData(ctx context.Context, listOpts exec.ListDAGsOptions) (
15311533
}, nil
15321534
}
15331535

1536+
func (a *API) projectNextRun(ctx context.Context, dag *core.DAG) *time.Time {
1537+
nextRun := a.nextRunProjection(ctx)(dag, time.Now())
1538+
if nextRun.IsZero() {
1539+
return nil
1540+
}
1541+
return &nextRun
1542+
}
1543+
1544+
func (a *API) nextRunProjection(ctx context.Context) func(*core.DAG, time.Time) time.Time {
1545+
var schedulerState *scheduler.SchedulerState
1546+
if a.schedulerStateStore != nil {
1547+
state, loadErr := a.schedulerStateStore.Load(ctx)
1548+
if loadErr != nil {
1549+
logger.Warn(ctx, "Failed to load scheduler state for DAG next-run projection", tag.Error(loadErr))
1550+
} else {
1551+
schedulerState = state
1552+
}
1553+
}
1554+
1555+
return func(dag *core.DAG, now time.Time) time.Time {
1556+
return scheduler.NextPlannedRun(dag, now, schedulerState)
1557+
}
1558+
}
1559+
15341560
// parseIntParam parses an integer string, returning defaultVal if parsing fails or value is <= 0.
15351561
func parseIntParam(s string, defaultVal int) int {
15361562
if s == "" {

internal/service/frontend/api/v1/dags_internal_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,71 @@ steps:
9090
require.NotNil(t, sseResp.Dags[0].NextRun)
9191
require.True(t, listResp.Dags[0].NextRun.Equal(*sseResp.Dags[0].NextRun))
9292
}
93+
94+
func TestGetDAGDetailsAndSpecIncludeNextRun(t *testing.T) {
95+
t.Parallel()
96+
97+
helper := test.Setup(t, test.WithStatusPersistence())
98+
scheduledAt := time.Now().UTC().Truncate(time.Minute).Add(-10 * time.Minute)
99+
dag := helper.DAG(t, fmt.Sprintf(`
100+
name: dag-details-next-run
101+
schedule:
102+
- at: "%s"
103+
steps:
104+
- command: echo hi
105+
`, scheduledAt.Format(time.RFC3339)))
106+
107+
state := &scheduler.SchedulerState{
108+
Version: scheduler.SchedulerStateVersion,
109+
DAGs: map[string]scheduler.DAGWatermark{
110+
dag.Name: {
111+
OneOffs: map[string]scheduler.OneOffScheduleState{
112+
dag.Schedule[0].Fingerprint(): {
113+
ScheduledTime: scheduledAt,
114+
Status: scheduler.OneOffStatusPending,
115+
},
116+
},
117+
},
118+
},
119+
}
120+
121+
api := localapi.New(
122+
helper.DAGStore,
123+
helper.DAGRunStore,
124+
helper.QueueStore,
125+
helper.ProcStore,
126+
helper.DAGRunMgr,
127+
helper.Config,
128+
nil,
129+
helper.ServiceRegistry,
130+
nil,
131+
nil,
132+
localapi.WithSchedulerStateStore(stubSchedulerStateStore{state: state}),
133+
)
134+
135+
detailsRespObj, err := api.GetDAGDetails(context.Background(), openapi.GetDAGDetailsRequestObject{
136+
FileName: dag.FileName(),
137+
})
138+
require.NoError(t, err)
139+
140+
detailsResp, ok := detailsRespObj.(openapi.GetDAGDetails200JSONResponse)
141+
require.True(t, ok)
142+
require.NotNil(t, detailsResp.Dag)
143+
require.NotNil(t, detailsResp.Dag.NextRun)
144+
require.True(t, scheduledAt.Equal(*detailsResp.Dag.NextRun))
145+
146+
specRespObj, err := api.GetDAGSpec(context.Background(), openapi.GetDAGSpecRequestObject{
147+
FileName: dag.FileName(),
148+
})
149+
require.NoError(t, err)
150+
151+
specResp, ok := specRespObj.(*openapi.GetDAGSpec200JSONResponse)
152+
if !ok {
153+
valueResp, valueOK := specRespObj.(openapi.GetDAGSpec200JSONResponse)
154+
require.True(t, valueOK)
155+
specResp = &valueResp
156+
}
157+
require.NotNil(t, specResp.Dag)
158+
require.NotNil(t, specResp.Dag.NextRun)
159+
require.True(t, scheduledAt.Equal(*specResp.Dag.NextRun))
160+
}

ui/src/api/v1/schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3150,6 +3150,11 @@ export interface components {
31503150
WorkerHealthStatus: WorkerHealthStatus;
31513151
/** @description Detailed DAG configuration information */
31523152
DAGDetails: {
3153+
/**
3154+
* Format: date-time
3155+
* @description Scheduler-aware next planned run time. Pending overdue one-offs remain visible until consumed.
3156+
*/
3157+
nextRun?: string;
31533158
/** @description Logical grouping of related DAGs for organizational purposes */
31543159
group?: string;
31553160
/** @description Unique identifier for the DAG within its group */

ui/src/features/dags/components/dag-editor/DAGAttributes.tsx

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
*
44
* @module features/dags/components/dag-editor
55
*/
6+
import dayjs from '@/lib/dayjs';
67
import { Calendar, CheckSquare, Settings, Tag } from 'lucide-react';
78
import { components } from '../../../../api/v1/schema';
89
import { Badge } from '../../../../components/ui/badge';
910
import {
1011
getScheduleKey,
1112
getScheduleLabel,
13+
parseNextRun,
1214
} from '../../../../lib/dagSchedule';
1315

1416
/**
@@ -24,6 +26,8 @@ type Props = {
2426
* including name, schedule, description, and other properties
2527
*/
2628
function DAGAttributes({ dag }: Props) {
29+
const nextRun = parseNextRun(dag.nextRun);
30+
2731
return (
2832
<div>
2933
<h2 className="text-xl font-semibold text-foreground mb-4">
@@ -49,17 +53,26 @@ function DAGAttributes({ dag }: Props) {
4953
No schedule defined
5054
</div>
5155
) : (
52-
<div className="flex flex-wrap gap-2">
53-
{dag.schedule?.map((schedule, index) => (
54-
<Badge
55-
key={getScheduleKey(schedule, index)}
56-
variant="outline"
57-
title={schedule.kind === 'at' ? schedule.at || undefined : schedule.expression || undefined}
58-
className="max-w-full justify-start bg-primary/10 px-2.5 py-1 text-primary border-primary/30 whitespace-nowrap normal-case tracking-normal"
59-
>
60-
{getScheduleLabel(schedule)}
61-
</Badge>
62-
))}
56+
<div className="space-y-2">
57+
<div className="flex flex-wrap gap-2">
58+
{dag.schedule?.map((schedule, index) => (
59+
<Badge
60+
key={getScheduleKey(schedule, index)}
61+
variant="outline"
62+
title={schedule.kind === 'at' ? schedule.at || undefined : schedule.expression || undefined}
63+
className="max-w-full justify-start bg-primary/10 px-2.5 py-1 text-primary border-primary/30 whitespace-nowrap normal-case tracking-normal"
64+
>
65+
{getScheduleLabel(schedule)}
66+
</Badge>
67+
))}
68+
</div>
69+
70+
<div className="text-sm text-muted-foreground">
71+
<span className="font-medium text-foreground">Next run:</span>{' '}
72+
{nextRun
73+
? `${dayjs(nextRun).format('YYYY-MM-DD HH:mm:ss')} (${dayjs(nextRun).fromNow()})`
74+
: 'No upcoming run'}
75+
</div>
6376
</div>
6477
)}
6578
</div>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (C) 2026 Yota Hamada
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
import { cleanup, render, screen } from '@testing-library/react';
5+
import React from 'react';
6+
import { afterEach, describe, expect, it } from 'vitest';
7+
import dayjs from '@/lib/dayjs';
8+
import DAGAttributes from '../DAGAttributes';
9+
10+
afterEach(() => {
11+
cleanup();
12+
});
13+
14+
describe('DAGAttributes', () => {
15+
it('renders the next scheduled run when present', () => {
16+
const nextRun = '2026-04-03T12:00:00Z';
17+
18+
render(
19+
<DAGAttributes
20+
dag={{
21+
name: 'scheduled-dag',
22+
schedule: [{ expression: '0 12 * * *', kind: 'cron' }],
23+
nextRun,
24+
}}
25+
/>
26+
);
27+
28+
expect(screen.getByText('Next run:')).toBeInTheDocument();
29+
expect(
30+
screen.getByText(
31+
`${dayjs(nextRun).format('YYYY-MM-DD HH:mm:ss')} (${dayjs(nextRun).fromNow()})`
32+
)
33+
).toBeInTheDocument();
34+
});
35+
36+
it('renders no upcoming run when the next run is unavailable', () => {
37+
render(
38+
<DAGAttributes
39+
dag={{
40+
name: 'scheduled-dag',
41+
schedule: [{ expression: '0 12 * * *', kind: 'cron' }],
42+
}}
43+
/>
44+
);
45+
46+
expect(screen.getByText('Next run:')).toBeInTheDocument();
47+
expect(screen.getByText('No upcoming run')).toBeInTheDocument();
48+
});
49+
});

0 commit comments

Comments
 (0)