Skip to content

Commit 1d206f9

Browse files
committed
implement periodic job list view
1 parent b9af1bb commit 1d206f9

9 files changed

Lines changed: 636 additions & 11 deletions

File tree

riverproui/internal/prohandler/pro_handler_api_endpoints.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ func (req *periodicJobListRequest) ExtractRaw(r *http.Request) error {
7878

7979
func (a *periodicJobListEndpoint[TTx]) Execute(ctx context.Context, req *periodicJobListRequest) (*listResponse[uitype.RiverPeriodicJob], error) {
8080
result, err := a.DB.PeriodicJobGetAll(ctx, &riverprodriver.PeriodicJobGetAllParams{
81-
Max: ptrutil.ValOrDefault(req.Limit, 100),
82-
Schema: a.Client.Schema(),
81+
Max: ptrutil.ValOrDefault(req.Limit, 100),
82+
Schema: a.Client.Schema(),
83+
StaleUpdatedAtHorizon: time.Now().Add(-24 * time.Hour),
8384
})
8485
if err != nil {
8586
return nil, fmt.Errorf("error listing periodic jobs: %w", err)

src/components/Layout.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Toast from "@components/Toast";
2+
import { useFeatures } from "@contexts/Features.hook";
23
import { useSidebarSetting } from "@contexts/SidebarSetting.hook";
34
import {
45
Dialog,
@@ -7,13 +8,13 @@ import {
78
TransitionChild,
89
} from "@headlessui/react";
910
import {
11+
CalendarDaysIcon,
1012
Cog6ToothIcon,
1113
InboxStackIcon,
1214
QueueListIcon,
1315
RectangleGroupIcon,
1416
XMarkIcon,
1517
} from "@heroicons/react/24/outline";
16-
import useFeature from "@hooks/use-feature";
1718
import { Link } from "@tanstack/react-router";
1819
import { Fragment, PropsWithChildren, useMemo } from "react";
1920

@@ -22,7 +23,7 @@ type LayoutProps = PropsWithChildren<object>;
2223
const Layout = ({ children }: LayoutProps) => {
2324
const { open: sidebarOpen, setOpen: setSidebarOpen } = useSidebarSetting();
2425

25-
const featureEnabledWorkflows = useFeature("ENABLE_WORKFLOWS", true);
26+
const { features } = useFeatures();
2627

2728
const navigation = useMemo(
2829
() =>
@@ -34,14 +35,15 @@ const Layout = ({ children }: LayoutProps) => {
3435
search: {},
3536
},
3637
{ href: "/queues", icon: InboxStackIcon, name: "Queues" },
38+
{ href: "/workflows", icon: RectangleGroupIcon, name: "Workflows" },
3739
{
38-
hidden: !featureEnabledWorkflows,
39-
href: "/workflows",
40-
icon: RectangleGroupIcon,
41-
name: "Workflows",
40+
hidden: !features.durablePeriodicJobs,
41+
href: "/periodic-jobs",
42+
icon: CalendarDaysIcon,
43+
name: "Periodic Jobs",
4244
},
4345
].filter((item) => item.hidden === undefined || item.hidden === false),
44-
[featureEnabledWorkflows],
46+
[features.durablePeriodicJobs],
4547
);
4648

4749
return (
@@ -163,8 +165,8 @@ const Layout = ({ children }: LayoutProps) => {
163165
</Transition>
164166

165167
{/* Static sidebar for desktop */}
166-
<div className="hidden overflow-x-hidden bg-slate-100 shadow-sm shadow-slate-400 transition-all hover:w-40 lg:fixed lg:inset-y-0 lg:left-0 lg:z-20 lg:flex lg:w-16 lg:overflow-y-auto lg:pb-4 dark:bg-slate-800 dark:shadow-slate-600">
167-
<div className="flex w-40 grow flex-col">
168+
<div className="hidden overflow-x-hidden bg-slate-100 shadow-sm shadow-slate-400 transition-all hover:w-48 lg:fixed lg:inset-y-0 lg:left-0 lg:z-20 lg:flex lg:w-16 lg:overflow-y-auto lg:pb-4 dark:bg-slate-800 dark:shadow-slate-600">
169+
<div className="flex w-48 shrink-0 grow flex-col">
168170
<nav className="flex flex-1 flex-col">
169171
<ul className="flex flex-1 flex-col" role="list">
170172
{navigation.map((item) => (
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
3+
import { PeriodicJob } from "@services/periodicJobs";
4+
5+
import PeriodicJobList from "./PeriodicJobList";
6+
7+
const meta: Meta<typeof PeriodicJobList> = {
8+
component: PeriodicJobList,
9+
parameters: {
10+
layout: "fullscreen",
11+
},
12+
title: "Pages/PeriodicJobList",
13+
};
14+
15+
export default meta;
16+
type Story = StoryObj<typeof PeriodicJobList>;
17+
18+
// Helper function to create mock periodic jobs
19+
const createMockJob = (
20+
id: string,
21+
daysAgo: number,
22+
nextRunDays: number,
23+
): PeriodicJob => {
24+
const now = new Date();
25+
const createdAt = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
26+
const nextRunAt = new Date(now.getTime() + nextRunDays * 24 * 60 * 60 * 1000);
27+
const updatedAt = new Date(
28+
now.getTime() - Math.floor(daysAgo / 2) * 24 * 60 * 60 * 1000,
29+
);
30+
31+
return {
32+
createdAt,
33+
id,
34+
nextRunAt,
35+
updatedAt,
36+
};
37+
};
38+
39+
export const Loading: Story = {
40+
args: {
41+
jobs: [],
42+
loading: true,
43+
},
44+
};
45+
46+
export const Empty: Story = {
47+
args: {
48+
jobs: [],
49+
loading: false,
50+
},
51+
};
52+
53+
export const SingleJob: Story = {
54+
args: {
55+
jobs: [createMockJob("periodic-job-1", 7, 1)],
56+
loading: false,
57+
},
58+
};
59+
60+
export const MultipleJobs: Story = {
61+
args: {
62+
jobs: [
63+
createMockJob("periodic-job-1", 30, 1),
64+
createMockJob("periodic-job-2", 15, 3),
65+
createMockJob("periodic-job-3", 7, 7),
66+
createMockJob("periodic-job-4", 3, 14),
67+
createMockJob("periodic-job-5", 1, 30),
68+
],
69+
loading: false,
70+
},
71+
};
72+
73+
export const JobsWithVariousTimings: Story = {
74+
args: {
75+
jobs: [
76+
createMockJob("hourly-job", 0.5, 0.04), // 1 hour ago, next run in ~1 hour
77+
createMockJob("daily-job", 1, 1), // 1 day ago, next run in 1 day
78+
createMockJob("weekly-job", 7, 7), // 1 week ago, next run in 1 week
79+
createMockJob("monthly-job", 30, 30), // 1 month ago, next run in 1 month
80+
createMockJob("overdue-job", 5, -1), // 5 days ago, overdue by 1 day
81+
],
82+
loading: false,
83+
},
84+
};
85+
86+
export const LongJobIds: Story = {
87+
args: {
88+
jobs: [
89+
createMockJob(
90+
"very-long-periodic-job-id-that-might-cause-layout-issues",
91+
7,
92+
1,
93+
),
94+
createMockJob(
95+
"another-extremely-long-periodic-job-identifier-for-testing-purposes",
96+
14,
97+
3,
98+
),
99+
createMockJob("short-id", 21, 7),
100+
],
101+
loading: false,
102+
},
103+
};
104+
105+
export const ManyJobs: Story = {
106+
args: {
107+
jobs: Array.from({ length: 20 }, (_, i) =>
108+
createMockJob(
109+
`periodic-job-${i + 1}`,
110+
Math.floor(Math.random() * 30),
111+
Math.floor(Math.random() * 30) + 1,
112+
),
113+
),
114+
loading: false,
115+
},
116+
};
117+
118+
export const RecentJobs: Story = {
119+
args: {
120+
jobs: [
121+
createMockJob("recent-job-1", 0.1, 0.1), // Very recent
122+
createMockJob("recent-job-2", 0.5, 0.5), // 12 hours ago
123+
createMockJob("recent-job-3", 1, 1), // 1 day ago
124+
],
125+
loading: false,
126+
},
127+
};
128+
129+
export const FutureJobs: Story = {
130+
args: {
131+
jobs: [
132+
createMockJob("future-job-1", 1, 30), // Next run in 30 days
133+
createMockJob("future-job-2", 7, 60), // Next run in 60 days
134+
createMockJob("future-job-3", 14, 90), // Next run in 90 days
135+
],
136+
loading: false,
137+
},
138+
};

src/components/PeriodicJobList.tsx

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import PeriodicJobListEmptyState from "@components/PeriodicJobListEmptyState";
2+
import RelativeTimeFormatter from "@components/RelativeTimeFormatter";
3+
import TopNavTitleOnly from "@components/TopNavTitleOnly";
4+
import { PeriodicJob } from "@services/periodicJobs";
5+
6+
type PeriodicJobsPageProps = {
7+
jobs: PeriodicJob[];
8+
loading: boolean;
9+
};
10+
11+
const PeriodicJobList = ({ jobs, loading }: PeriodicJobsPageProps) => {
12+
return (
13+
<div className="size-full">
14+
<TopNavTitleOnly title="Periodic Jobs" />
15+
16+
<div className="mx-auto px-4 sm:px-6 lg:px-8">
17+
{loading ? (
18+
<div>Loading…</div>
19+
) : jobs.length > 0 ? (
20+
<PeriodicJobTable jobs={jobs} />
21+
) : (
22+
<PeriodicJobListEmptyState hasAny={true} />
23+
)}
24+
</div>
25+
</div>
26+
);
27+
};
28+
29+
export default PeriodicJobList;
30+
31+
type PeriodicJobsTableProps = {
32+
jobs: PeriodicJob[];
33+
};
34+
35+
const PeriodicJobTable = ({ jobs }: PeriodicJobsTableProps) => {
36+
return (
37+
<div className="-mx-4 mt-8 sm:-mx-0">
38+
<table className="min-w-full table-fixed divide-y divide-slate-300 dark:divide-slate-700">
39+
<thead>
40+
<tr>
41+
<th
42+
className="py-2.5 pr-3 pl-4 text-left text-sm font-semibold text-slate-900 sm:pl-0 dark:text-slate-100"
43+
scope="col"
44+
>
45+
ID
46+
</th>
47+
<th
48+
className="hidden px-3 py-2.5 text-right text-sm font-semibold text-slate-900 md:table-cell dark:text-slate-100"
49+
scope="col"
50+
>
51+
Created
52+
</th>
53+
<th
54+
className="hidden px-3 py-2.5 text-right text-sm font-semibold text-slate-900 sm:table-cell dark:text-slate-100"
55+
scope="col"
56+
>
57+
Next run
58+
</th>
59+
<th
60+
className="hidden px-3 py-2.5 text-right text-sm font-semibold text-slate-900 lg:table-cell dark:text-slate-100"
61+
scope="col"
62+
>
63+
Updated
64+
</th>
65+
</tr>
66+
</thead>
67+
<tbody className="divide-y divide-slate-200 bg-white dark:divide-slate-800 dark:bg-slate-900">
68+
{jobs.map((job) => (
69+
<PeriodicJobListItem job={job} key={job.id} />
70+
))}
71+
</tbody>
72+
</table>
73+
</div>
74+
);
75+
};
76+
77+
type PeriodicJobListItemProps = {
78+
job: PeriodicJob;
79+
};
80+
81+
const PeriodicJobListItem = ({ job }: PeriodicJobListItemProps) => {
82+
return (
83+
<tr>
84+
<td className="w-full max-w-0 py-2 pr-3 pl-4 text-sm font-medium text-slate-700 sm:w-auto sm:max-w-none sm:pl-0 dark:text-slate-300">
85+
<span className="font-mono font-semibold dark:text-slate-100">
86+
{job.id}
87+
</span>
88+
<dl className="space-y-2 font-normal md:hidden">
89+
<div className="sm:hidden">
90+
<dt className="text-xs font-medium text-slate-500 dark:text-slate-400">
91+
Next run
92+
</dt>
93+
<dd className="mt-1 truncate">
94+
<RelativeTimeFormatter addSuffix time={job.nextRunAt} />
95+
</dd>
96+
</div>
97+
<div className="md:hidden">
98+
<dt className="text-xs font-medium text-slate-500 dark:text-slate-400">
99+
Created
100+
</dt>
101+
<dd className="mt-1 truncate">
102+
<RelativeTimeFormatter
103+
addSuffix
104+
includeSeconds
105+
time={job.createdAt}
106+
/>
107+
</dd>
108+
</div>
109+
<div className="lg:hidden">
110+
<dt className="text-xs font-medium text-slate-500 dark:text-slate-400">
111+
Updated
112+
</dt>
113+
<dd className="mt-1 truncate">
114+
<RelativeTimeFormatter
115+
addSuffix
116+
humanize
117+
includeSeconds
118+
time={job.updatedAt}
119+
/>
120+
</dd>
121+
</div>
122+
</dl>
123+
</td>
124+
<td className="hidden px-3 py-2 text-right text-sm text-slate-500 md:table-cell dark:text-slate-300">
125+
<RelativeTimeFormatter
126+
addSuffix
127+
humanize
128+
includeSeconds
129+
time={job.createdAt}
130+
/>
131+
</td>
132+
<td className="hidden px-3 py-2 text-right text-sm text-slate-500 sm:table-cell dark:text-slate-300">
133+
<RelativeTimeFormatter addSuffix time={job.nextRunAt} />
134+
</td>
135+
<td className="hidden px-3 py-2 text-right text-sm text-slate-500 lg:table-cell dark:text-slate-300">
136+
<RelativeTimeFormatter
137+
addSuffix
138+
humanize
139+
includeSeconds
140+
time={job.updatedAt}
141+
/>
142+
</td>
143+
</tr>
144+
);
145+
};

0 commit comments

Comments
 (0)