-
Notifications
You must be signed in to change notification settings - Fork 18
feat: deployment plan ui #1008
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: deployment plan ui #1008
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { useState } from "react"; | ||
| import { DiffEditor } from "@monaco-editor/react"; | ||
|
|
||
| import { trpc } from "~/api/trpc"; | ||
| import { useTheme } from "~/components/ThemeProvider"; | ||
| import { | ||
| Dialog, | ||
| DialogContent, | ||
| DialogHeader, | ||
| DialogTitle, | ||
| DialogTrigger, | ||
| } from "~/components/ui/dialog"; | ||
| import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; | ||
|
|
||
| type PlanDiffDialogProps = { | ||
| deploymentId: string; | ||
| resultId: string; | ||
| title: string; | ||
| children: React.ReactNode; | ||
| }; | ||
|
|
||
| type DiffView = "split" | "unified"; | ||
|
|
||
| export function PlanDiffDialog({ | ||
| deploymentId, | ||
| resultId, | ||
| title, | ||
| children, | ||
| }: PlanDiffDialogProps) { | ||
| const [open, setOpen] = useState(false); | ||
| const [view, setView] = useState<DiffView>("split"); | ||
| const { theme } = useTheme(); | ||
|
|
||
| const diffQuery = trpc.deployment.plans.resultDiff.useQuery( | ||
| { deploymentId, resultId }, | ||
| { enabled: open }, | ||
| ); | ||
|
|
||
| return ( | ||
| <Dialog open={open} onOpenChange={setOpen}> | ||
| <DialogTrigger asChild>{children}</DialogTrigger> | ||
| <DialogContent className="flex h-[90vh] w-[95vw] max-w-[95vw] flex-col p-0 sm:max-w-[95vw]"> | ||
| <DialogHeader className="flex-row items-center justify-between border-b p-4 pr-12"> | ||
| <DialogTitle>{title}</DialogTitle> | ||
| <Tabs value={view} onValueChange={(v) => setView(v as DiffView)}> | ||
| <TabsList> | ||
| <TabsTrigger value="split">Split</TabsTrigger> | ||
| <TabsTrigger value="unified">Unified</TabsTrigger> | ||
| </TabsList> | ||
| </Tabs> | ||
| </DialogHeader> | ||
| <div className="min-h-0 flex-1"> | ||
| {diffQuery.isLoading ? ( | ||
| <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> | ||
| Loading diff... | ||
| </div> | ||
| ) : diffQuery.data == null ? ( | ||
| <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> | ||
| No diff available | ||
| </div> | ||
| ) : ( | ||
| <DiffEditor | ||
| height="100%" | ||
| language="yaml" | ||
| theme={theme === "dark" ? "vs-dark" : "vs"} | ||
| original={diffQuery.data.current} | ||
| modified={diffQuery.data.proposed} | ||
| options={{ | ||
| readOnly: true, | ||
| renderSideBySide: view === "split", | ||
| minimap: { enabled: true }, | ||
| scrollBeyondLastLine: false, | ||
| automaticLayout: true, | ||
| }} | ||
| /> | ||
| )} | ||
| </div> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| export const PlanStatusDisplayName: Record<string, string> = { | ||
| computing: "Computing", | ||
| completed: "Completed", | ||
| errored: "Errored", | ||
| unsupported: "Unsupported", | ||
| }; | ||
|
Comment on lines
+1
to
+6
|
||
|
|
||
| const PlanStatusBadgeColor: Record<string, string> = { | ||
| computing: | ||
| "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800", | ||
| completed: | ||
| "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800", | ||
| errored: | ||
| "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 border-red-200 dark:border-red-800", | ||
| unsupported: | ||
| "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 border-yellow-200 dark:border-yellow-800", | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| export function PlanStatusBadge({ status }: { status: string }) { | ||
| return ( | ||
| <span | ||
| className={`inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium ${ | ||
| PlanStatusBadgeColor[status] ?? | ||
| "border-neutral-200 bg-neutral-100 text-neutral-700" | ||
| }`} | ||
| > | ||
| {PlanStatusDisplayName[status] ?? status} | ||
| </span> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| import type { RouterOutputs } from "@ctrlplane/trpc"; | ||
| import { FileText } from "lucide-react"; | ||
| import { Link, useParams } from "react-router"; | ||
|
|
||
| import { trpc } from "~/api/trpc"; | ||
| import { | ||
| Breadcrumb, | ||
| BreadcrumbItem, | ||
| BreadcrumbList, | ||
| BreadcrumbPage, | ||
| BreadcrumbSeparator, | ||
| } from "~/components/ui/breadcrumb"; | ||
| import { Button } from "~/components/ui/button"; | ||
| import { Separator } from "~/components/ui/separator"; | ||
| import { SidebarTrigger } from "~/components/ui/sidebar"; | ||
| import { | ||
| Table, | ||
| TableBody, | ||
| TableCell, | ||
| TableHead, | ||
| TableHeader, | ||
| TableRow, | ||
| } from "~/components/ui/table"; | ||
| import { useWorkspace } from "~/components/WorkspaceProvider"; | ||
| import { useDeployment } from "./_components/DeploymentProvider"; | ||
| import { DeploymentsNavbarTabs } from "./_components/DeploymentsNavbarTabs"; | ||
| import { PlanDiffDialog } from "./_components/plans/PlanDiffDialog"; | ||
| import { PlanStatusBadge } from "./_components/plans/PlanStatusBadge"; | ||
|
|
||
| export function meta() { | ||
| return [ | ||
| { title: "Plan Details - Ctrlplane" }, | ||
| { name: "description", content: "View plan results" }, | ||
| ]; | ||
| } | ||
|
|
||
| type Result = RouterOutputs["deployment"]["plans"]["results"][number]; | ||
|
|
||
| function ChangesCell({ | ||
| result, | ||
| deploymentId, | ||
| }: { | ||
| result: Result; | ||
| deploymentId: string; | ||
| }) { | ||
| if (result.status === "computing") | ||
| return <span className="text-muted-foreground">—</span>; | ||
| if (result.status === "errored") | ||
| return ( | ||
| <span | ||
| className="text-red-600 dark:text-red-400" | ||
| title={result.message ?? undefined} | ||
| > | ||
| Errored | ||
| </span> | ||
| ); | ||
| if (result.status === "unsupported") | ||
| return <span className="text-muted-foreground">Unsupported</span>; | ||
| if (result.hasChanges === true) | ||
| return ( | ||
| <PlanDiffDialog | ||
| deploymentId={deploymentId} | ||
| resultId={result.resultId} | ||
| title={`${result.environment.name} · ${result.resource.name} · ${result.agent.name}`} | ||
| > | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| className="h-6 cursor-pointer hover:bg-accent hover:text-accent-foreground" | ||
| > | ||
| View diff | ||
| </Button> | ||
| </PlanDiffDialog> | ||
| ); | ||
| if (result.hasChanges === false) | ||
| return <span className="text-muted-foreground">No changes</span>; | ||
| return <span className="text-muted-foreground">—</span>; | ||
| } | ||
|
|
||
| function ResultsTableHeader() { | ||
| return ( | ||
| <TableHeader> | ||
| <TableRow className="bg-muted/50"> | ||
| <TableHead className="font-medium">Environment</TableHead> | ||
| <TableHead className="font-medium">Resource</TableHead> | ||
| <TableHead className="font-medium">Agent</TableHead> | ||
| <TableHead className="font-medium">Status</TableHead> | ||
| <TableHead className="font-medium">Changes</TableHead> | ||
| </TableRow> | ||
| </TableHeader> | ||
| ); | ||
| } | ||
|
|
||
| function ResultsTableRow({ | ||
| result, | ||
| deploymentId, | ||
| }: { | ||
| result: Result; | ||
| deploymentId: string; | ||
| }) { | ||
| return ( | ||
| <TableRow className="hover:bg-muted/50"> | ||
| <TableCell>{result.environment.name}</TableCell> | ||
| <TableCell>{result.resource.name}</TableCell> | ||
| <TableCell>{result.agent.name}</TableCell> | ||
| <TableCell> | ||
| <PlanStatusBadge status={result.status} /> | ||
| </TableCell> | ||
| <TableCell> | ||
| <ChangesCell result={result} deploymentId={deploymentId} /> | ||
| </TableCell> | ||
| </TableRow> | ||
| ); | ||
| } | ||
|
|
||
| function NoResults() { | ||
| return ( | ||
| <div className="flex h-full items-center justify-center"> | ||
| <div className="flex flex-col items-center space-y-4 text-center"> | ||
| <div className="rounded-full bg-muted p-4"> | ||
| <FileText className="h-8 w-8 text-muted-foreground" /> | ||
| </div> | ||
| <div className="space-y-1"> | ||
| <h3 className="font-semibold">No results</h3> | ||
| <p className="text-sm text-muted-foreground"> | ||
| This plan has no release targets | ||
| </p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default function DeploymentPlanDetail() { | ||
| const { workspace } = useWorkspace(); | ||
| const { deployment } = useDeployment(); | ||
| const { planId } = useParams<{ planId: string }>(); | ||
|
|
||
| const resultsQuery = trpc.deployment.plans.results.useQuery( | ||
| { deploymentId: deployment.id, planId: planId! }, | ||
| { enabled: !!planId, refetchInterval: 5000 }, | ||
| ); | ||
|
|
||
| const results = resultsQuery.data ?? []; | ||
|
|
||
| return ( | ||
| <> | ||
| <header className="flex h-16 shrink-0 items-center justify-between gap-2 border-b pr-4"> | ||
| <div className="flex items-center gap-2 px-4"> | ||
| <SidebarTrigger className="-ml-1" /> | ||
| <Separator | ||
| orientation="vertical" | ||
| className="mr-2 data-[orientation=vertical]:h-4" | ||
| /> | ||
| <Breadcrumb> | ||
| <BreadcrumbList> | ||
| <BreadcrumbItem> | ||
| <Link to={`/${workspace.slug}/deployments`}>Deployments</Link> | ||
| </BreadcrumbItem> | ||
| <BreadcrumbSeparator /> | ||
| <BreadcrumbItem> | ||
| <Link to={`/${workspace.slug}/deployments/${deployment.id}`}> | ||
| {deployment.name} | ||
| </Link> | ||
| </BreadcrumbItem> | ||
| <BreadcrumbSeparator /> | ||
| <BreadcrumbItem> | ||
| <Link | ||
| to={`/${workspace.slug}/deployments/${deployment.id}/plans`} | ||
| > | ||
| Plans | ||
| </Link> | ||
| </BreadcrumbItem> | ||
| <BreadcrumbSeparator /> | ||
| <BreadcrumbPage className="font-mono">{planId}</BreadcrumbPage> | ||
| </BreadcrumbList> | ||
| </Breadcrumb> | ||
| </div> | ||
|
|
||
| <div className="flex items-center gap-4"> | ||
| <DeploymentsNavbarTabs /> | ||
| </div> | ||
| </header> | ||
|
|
||
| {results.length === 0 && !resultsQuery.isLoading ? ( | ||
| <NoResults /> | ||
| ) : ( | ||
| <Table> | ||
| <ResultsTableHeader /> | ||
| <TableBody> | ||
| {results.map((r) => ( | ||
| <ResultsTableRow | ||
| key={r.resultId} | ||
| result={r} | ||
| deploymentId={deployment.id} | ||
| /> | ||
| ))} | ||
| </TableBody> | ||
| </Table> | ||
| )} | ||
| </> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PlanDiffDialog hardcodes the Monaco theme to "vs-dark". The app already has theme-aware Monaco usage elsewhere (e.g. VersionActionsPanel uses ThemeProvider to switch between "vs" and "vs-dark"), so this dialog will look wrong in light mode. Use the existing ThemeProvider to select the Monaco theme based on the current app theme.