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
21 changes: 19 additions & 2 deletions apps/dashboard/src/components/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,27 @@ export function AppLayout({ children }: AppLayoutProps) {
</h1>
</Link>
</div>
<nav className="shrink-0 text-sm">
<div className="shrink-0 text-sm">
<ModeSwitcher />
</nav>
</div>
</div>
<nav aria-label="Main navigation" className="mt-4 flex gap-1 text-sm">
<Link
to="/"
activeOptions={{ exact: true }}
activeProps={{ className: "bg-muted text-foreground" }}
className="text-muted-foreground hover:text-foreground rounded-md px-3 py-2"
>
Runs
</Link>
<Link
to="/schedules"
activeProps={{ className: "bg-muted text-foreground" }}
className="text-muted-foreground hover:text-foreground rounded-md px-3 py-2"
>
Schedules
</Link>
</nav>
</div>
</header>

Expand Down
62 changes: 62 additions & 0 deletions apps/dashboard/src/components/list-pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { CursorPaginationControls } from "./cursor-pagination-controls";
import { Button } from "./ui/button";
import {
RUNS_PAGE_SIZE_OPTIONS,
resolveRunsPageSize,
shouldShowPaginationControls,
type RunsPaginationSearch,
} from "@/lib/runs-page-pagination";
import type { PaginatedResponse } from "openworkflow/internal";

interface ListPaginationProps {
pagination: PaginatedResponse<unknown>["pagination"];
search: RunsPaginationSearch;
itemCount: number;
itemName: string;
onChange: (search: RunsPaginationSearch) => void;
}

export function ListPagination({
pagination,
search,
itemCount,
itemName,
onChange,
}: ListPaginationProps) {
if (itemCount === 0 && (search.after ?? search.before)) {
return (
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
onChange({ limit: search.limit });
}}
>
Back to first page
</Button>
</div>
);
}
if (!shouldShowPaginationControls(pagination)) return null;
return (
<CursorPaginationControls
hasNextPage={!!pagination.next}
hasPreviousPage={!!pagination.prev}
itemCount={itemCount}
itemName={itemName}
pageSize={resolveRunsPageSize(search.limit)}
pageSizeOptions={RUNS_PAGE_SIZE_OPTIONS}
onNextPage={() => {
onChange({ limit: search.limit, after: pagination.next ?? undefined });
}}
onPreviousPage={() => {
onChange({ limit: search.limit, before: pagination.prev ?? undefined });
}}
onPageSizeChange={(limit) => {
onChange({ limit });
}}
/>
);
}
131 changes: 131 additions & 0 deletions apps/dashboard/src/components/schedule-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { ScheduleEditor } from "./schedule-editor";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
import { Button } from "./ui/button";
import {
deleteWorkflowScheduleServerFn,
setWorkflowSchedulePausedServerFn,
} from "@/lib/api";
import { PauseIcon, PlayIcon, TrashIcon } from "@phosphor-icons/react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import type { WorkflowSchedule } from "openworkflow";
import { useState } from "react";

// oxlint-disable-next-line max-lines-per-function -- Keep the confirmation and mutation state together, as in RunAction.
export function ScheduleActions({ schedule }: { schedule: WorkflowSchedule }) {
const router = useRouter();
const navigate = useNavigate();
const [deleting, setDeleting] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const paused = schedule.nextRunAt === null;
const toggleLabel = paused ? "Resume Schedule" : "Pause Schedule";

async function performAction(action: "toggle" | "delete") {
setPending(true);
setError(null);
try {
const data = { workflowScheduleId: schedule.id };
if (action === "delete") {
await deleteWorkflowScheduleServerFn({ data });
await navigate({ to: "/schedules" });
} else {
await setWorkflowSchedulePausedServerFn({
data: { ...data, paused: !paused },
});
}
await router.invalidate();
setDeleting(false);
} catch (cause) {
setError(
cause instanceof Error ? cause.message : "Unable to update schedule",
);
} finally {
setPending(false);
}
}

return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={pending}
onClick={() => {
void performAction("toggle");
}}
>
{paused ? <PlayIcon /> : <PauseIcon />}
{pending && !deleting ? "Updating..." : toggleLabel}
</Button>
<ScheduleEditor schedule={schedule} disabled={pending} />
<AlertDialog
open={deleting}
onOpenChange={(next) => {
if (!pending) {
setDeleting(next);
setError(null);
}
}}
>
<Button
type="button"
variant="outline"
disabled={pending}
onClick={() => {
setDeleting(true);
setError(null);
}}
>
<TrashIcon />
Delete
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete schedule?</AlertDialogTitle>
<AlertDialogDescription>
Delete “{schedule.id}” to stop future scheduled runs. Existing
runs are kept. If you define this schedule in code, remove it
there too so the next worker startup doesn't recreate it.
</AlertDialogDescription>
</AlertDialogHeader>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>
Keep Schedule
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={pending}
onClick={(event) => {
event.preventDefault();
void performAction("delete");
}}
>
{pending ? "Deleting..." : "Delete Schedule"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{error && !deleting && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
</div>
);
}
Loading
Loading