feat(web): paginated runs table, add loading page

This commit is contained in:
BennyKok 2023-12-31 01:07:39 +08:00
parent 744a222e26
commit d63ab1924b
18 changed files with 465 additions and 105 deletions

Binary file not shown.

View File

@ -66,6 +66,7 @@
"next-plausible": "^3.12.0", "next-plausible": "^3.12.0",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"next-usequerystate": "^1.13.2", "next-usequerystate": "^1.13.2",
"pg": "^8.11.3",
"react": "^18", "react": "^18",
"react-day-picker": "^8.9.1", "react-day-picker": "^8.9.1",
"react-dom": "^18", "react-dom": "^18",

View File

@ -0,0 +1,9 @@
"use client";
import { LoadingPageWrapper } from "@/components/LoadingWrapper";
import { usePathname } from "next/navigation";
export default function Loading() {
const pathName = usePathname();
return <LoadingPageWrapper className="h-full" tag={pathName.toLowerCase()} />;
}

View File

@ -0,0 +1,9 @@
"use client";
import { LoadingPageWrapper } from "@/components/LoadingWrapper";
import { usePathname } from "next/navigation";
export default function Loading() {
const pathName = usePathname();
return <LoadingPageWrapper className="h-full" tag={pathName.toLowerCase()} />;
}

View File

@ -0,0 +1,25 @@
import { LoadingWrapper } from "@/components/LoadingWrapper";
import { DeploymentsTable } from "@/components/RunsTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default async function Page({
params,
}: {
params: { workflow_id: string };
}) {
const workflow_id = params.workflow_id;
return (
<Card className="w-full h-fit">
<CardHeader>
<CardTitle>Deployments</CardTitle>
</CardHeader>
<CardContent>
<LoadingWrapper tag="deployments">
<DeploymentsTable workflow_id={workflow_id} />
</LoadingWrapper>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,27 @@
import { LoadingWrapper } from "@/components/LoadingWrapper";
import { RunsTable } from "@/components/RunsTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default async function Page({
params,
searchParams,
}: {
params: { workflow_id: string };
searchParams: { [key: string]: string | string[] | undefined };
}) {
const workflow_id = params.workflow_id;
return (
<Card className="w-full h-fit min-w-0">
<CardHeader>
<CardTitle>Run</CardTitle>
</CardHeader>
<CardContent>
<LoadingWrapper tag="runs">
<RunsTable workflow_id={workflow_id} searchParams={searchParams} />
</LoadingWrapper>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,11 @@
import { LoadingPageWrapper } from "@/components/LoadingWrapper";
import { Card } from "@/components/ui/card";
export default function Loading() {
// You can add any UI inside Loading, including a Skeleton.
return (
<Card className="w-full h-fit">
<LoadingPageWrapper tag="workflow details" />
</Card>
);
}

View File

@ -0,0 +1,56 @@
import { MachinesWSMain } from "@/components/MachinesWS";
import { VersionDetails } from "@/components/VersionDetails";
import {
CopyWorkflowVersion,
CreateDeploymentButton,
MachineSelect,
RunWorkflowButton,
VersionSelect,
ViewWorkflowDetailsButton,
} from "@/components/VersionSelect";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { getRelativeTime } from "@/lib/getRelativeTime";
import { getMachines } from "@/server/curdMachine";
import { findFirstTableWithVersion } from "@/server/findFirstTableWithVersion";
export default async function Page({
params,
}: {
params: { workflow_id: string };
}) {
const workflow_id = params.workflow_id;
const workflow = await findFirstTableWithVersion(workflow_id);
const machines = await getMachines();
return (
<Card className="w-full h-fit">
<CardHeader>
<CardTitle>{workflow?.name}</CardTitle>
<CardDescription suppressHydrationWarning={true}>
{getRelativeTime(workflow?.updated_at)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2 flex-wrap">
<VersionSelect workflow={workflow} />
<MachineSelect machines={machines} />
<RunWorkflowButton workflow={workflow} machines={machines} />
<CreateDeploymentButton workflow={workflow} machines={machines} />
<CopyWorkflowVersion workflow={workflow} />
<ViewWorkflowDetailsButton workflow={workflow} />
</div>
<VersionDetails workflow={workflow} />
<MachinesWSMain machines={machines} />
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,22 @@
export default async function Layout({
children,
deployment,
runs,
workflow,
}: {
children: React.ReactNode;
deployment: React.ReactNode;
runs: React.ReactNode;
workflow: React.ReactNode;
}) {
return (
<div className="mt-4 w-full grid grid-rows-[1fr,1fr] lg:grid-cols-[minmax(auto,500px),1fr] gap-4 max-h-[calc(100dvh-100px)]">
<div className="w-full flex gap-4 flex-col min-w-0">
{workflow}
{deployment}
</div>
{runs}
</div>
);
}

View File

@ -0,0 +1,6 @@
import { LoadingPageWrapper } from "@/components/LoadingWrapper";
export default function Loading() {
// You can add any UI inside Loading, including a Skeleton.
return <LoadingPageWrapper tag="workflow" />;
}

View File

@ -1,80 +0,0 @@
import { DeploymentsTable, RunsTable } from "../../../../components/RunsTable";
import { findFirstTableWithVersion } from "../../../../server/findFirstTableWithVersion";
import { MachinesWSMain } from "@/components/MachinesWS";
import { VersionDetails } from "@/components/VersionDetails";
import {
CopyWorkflowVersion,
CreateDeploymentButton,
MachineSelect,
RunWorkflowButton,
VersionSelect,
ViewWorkflowDetailsButton,
} from "@/components/VersionSelect";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { getRelativeTime } from "@/lib/getRelativeTime";
import { getMachines } from "@/server/curdMachine";
export default async function Page({
params,
}: {
params: { workflow_id: string };
}) {
const workflow_id = params.workflow_id;
const workflow = await findFirstTableWithVersion(workflow_id);
const machines = await getMachines();
return (
<div className="mt-4 w-full flex flex-col lg:flex-row gap-4 max-h-[calc(100dvh-100px)]">
<div className="flex gap-4 flex-col">
<Card className="w-full lg:w-fit lg:min-w-[600px] h-fit">
<CardHeader>
<CardTitle>{workflow?.name}</CardTitle>
<CardDescription suppressHydrationWarning={true}>
{getRelativeTime(workflow?.updated_at)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2 flex-wrap">
<VersionSelect workflow={workflow} />
<MachineSelect machines={machines} />
<RunWorkflowButton workflow={workflow} machines={machines} />
<CreateDeploymentButton workflow={workflow} machines={machines} />
<CopyWorkflowVersion workflow={workflow} />
<ViewWorkflowDetailsButton workflow={workflow} />
</div>
<VersionDetails workflow={workflow} />
<MachinesWSMain machines={machines} />
</CardContent>
</Card>
<Card className="w-full h-fit">
<CardHeader>
<CardTitle>Deployments</CardTitle>
</CardHeader>
<CardContent>
<DeploymentsTable workflow_id={workflow_id} />
</CardContent>
</Card>
</div>
<Card className="w-full h-fit">
<CardHeader>
<CardTitle>Run</CardTitle>
</CardHeader>
<CardContent>
<RunsTable workflow_id={workflow_id} />
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,9 @@
"use client";
import { LoadingPageWrapper } from "@/components/LoadingWrapper";
import { usePathname } from "next/navigation";
export default function Loading() {
const pathName = usePathname();
return <LoadingPageWrapper className="h-full" tag={pathName.toLowerCase()} />;
}

View File

@ -0,0 +1,37 @@
import { LoadingIcon } from "@/components/LoadingIcon";
import { cn } from "@/lib/utils";
import { Suspense } from "react";
export function LoadingWrapper(props: {
tag: string;
children?: React.ReactNode;
}) {
return (
<Suspense
fallback={
<div className="w-full py-4 flex justify-center items-center gap-2 text-sm">
Fetching {props.tag} <LoadingIcon />
</div>
}
>
{props.children}
</Suspense>
);
}
export function LoadingPageWrapper(props: {
tag: string;
children?: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"w-full py-4 flex justify-center items-center gap-2 text-sm",
props.className
)}
>
Fetching {props.tag} <LoadingIcon />
</div>
);
}

View File

@ -0,0 +1,42 @@
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
export function PaginationControl(props: {
totalPage: number;
currentPage: number;
}) {
return (
<Pagination>
<PaginationContent>
<PaginationPrevious
href={
props.currentPage > 1
? `?page=${props.currentPage - 1}`
: `?page=${props.currentPage}`
}
/>
<PaginationLink href="#" isActive>
{props.currentPage}
</PaginationLink>
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
<PaginationNext
className="cursor-pointer"
href={
props.currentPage < props.totalPage
? `?page=${props.currentPage + 1}`
: `?page=${props.totalPage}`
}
/>
</PaginationContent>
</Pagination>
);
}

View File

@ -1,5 +1,9 @@
import { findAllDeployments, findAllRuns } from "../server/findAllRuns"; import {
findAllDeployments,
findAllRunsWithCounts,
} from "../server/findAllRuns";
import { DeploymentDisplay } from "./DeploymentDisplay"; import { DeploymentDisplay } from "./DeploymentDisplay";
import { PaginationControl } from "./PaginationControl";
import { RunDisplay } from "./RunDisplay"; import { RunDisplay } from "./RunDisplay";
import { import {
Table, Table,
@ -9,29 +13,51 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { parseAsInteger } from "next-usequerystate";
export async function RunsTable(props: { workflow_id: string }) { const itemPerPage = 4;
const allRuns = await findAllRuns(props.workflow_id); const pageParser = parseAsInteger.withDefault(1);
export async function RunsTable(props: {
workflow_id: string;
searchParams: { [key: string]: string | string[] | undefined };
}) {
// await new Promise((resolve) => setTimeout(resolve, 5000));
const page = pageParser.parseServerSide(
props.searchParams?.page ?? undefined
);
const { allRuns, total } = await findAllRunsWithCounts({
workflow_id: props.workflow_id,
limit: itemPerPage,
offset: (page - 1) * itemPerPage,
});
return ( return (
<div className="overflow-auto h-[400px] w-full"> <div>
<Table className=""> <div className="overflow-auto h-[400px] w-full">
<TableCaption>A list of your recent runs.</TableCaption> <Table className="">
<TableHeader className="bg-background top-0 sticky"> {/* <TableCaption>A list of your recent runs.</TableCaption> */}
<TableRow> <TableHeader className="bg-background top-0 sticky">
<TableHead className="w-[100px]">Number</TableHead> <TableRow>
<TableHead className="">Machine</TableHead> <TableHead className="w-[100px]">Number</TableHead>
<TableHead className="">Time</TableHead> <TableHead className="">Machine</TableHead>
<TableHead className="w-[100px]">Version</TableHead> <TableHead className="">Time</TableHead>
<TableHead className="">Live Status</TableHead> <TableHead className="w-[100px]">Version</TableHead>
<TableHead className=" text-right">Status</TableHead> <TableHead className="">Live Status</TableHead>
</TableRow> <TableHead className=" text-right">Status</TableHead>
</TableHeader> </TableRow>
<TableBody> </TableHeader>
{allRuns.map((run) => ( <TableBody>
<RunDisplay run={run} key={run.id} /> {allRuns.map((run) => (
))} <RunDisplay run={run} key={run.id} />
</TableBody> ))}
</Table> </TableBody>
</Table>
</div>
<PaginationControl
totalPage={Math.ceil(total / itemPerPage)}
currentPage={page}
/>
</div> </div>
); );
} }

View File

@ -0,0 +1,117 @@
import type { ButtonProps } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import Link from "next/link";
import * as React from "react";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
));
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<typeof Link>;
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<PaginationItem>
<Link
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
</PaginationItem>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -1,14 +1,28 @@
"use server";
import { db } from "@/db/db"; import { db } from "@/db/db";
import { deploymentsTable, workflowRunsTable } from "@/db/schema"; import { deploymentsTable, workflowRunsTable } from "@/db/schema";
import { desc, eq, sql } from "drizzle-orm"; import { count, desc, eq, sql } from "drizzle-orm";
export async function findAllRuns(workflow_id: string) { type RunsSearchTypes = {
workflow_id: string;
limit: number;
offset: number;
};
export async function findAllRuns({
workflow_id,
limit = 10,
offset = 0,
}: RunsSearchTypes) {
return await db.query.workflowRunsTable.findMany({ return await db.query.workflowRunsTable.findMany({
where: eq(workflowRunsTable.workflow_id, workflow_id), where: eq(workflowRunsTable.workflow_id, workflow_id),
orderBy: desc(workflowRunsTable.created_at), orderBy: desc(workflowRunsTable.created_at),
limit: 10, offset: offset,
limit: limit,
extras: { extras: {
number: sql<number>`row_number() over (order by created_at)`.as("number"), number: sql<number>`row_number() over (order by created_at)`.as("number"),
total: sql<number>`count(*) over ()`.as("total"),
}, },
with: { with: {
machine: { machine: {
@ -26,6 +40,20 @@ export async function findAllRuns(workflow_id: string) {
}); });
} }
export async function findAllRunsWithCounts(props: RunsSearchTypes) {
const a = await db
.select({
count: count(workflowRunsTable.id),
})
.from(workflowRunsTable)
.where(eq(workflowRunsTable.workflow_id, props.workflow_id));
return {
allRuns: await findAllRuns(props),
total: a[0].count,
};
}
export async function findAllDeployments(workflow_id: string) { export async function findAllDeployments(workflow_id: string) {
return await db.query.deploymentsTable.findMany({ return await db.query.deploymentsTable.findMany({
where: eq(deploymentsTable.workflow_id, workflow_id), where: eq(deploymentsTable.workflow_id, workflow_id),