Compare commits

...

1 Commits

Author SHA1 Message Date
Jaayden Halko
38ecb37517 feat(site): add build logs viewer for template versions
This adds a side panel that displays build logs when viewing a template
version, addressing issue #17793. Users can now troubleshoot failed
template builds directly from the template version page.

Features:
- Side panel with build logs (collapsed by default, opens with button)
- Real-time log streaming for running builds
- Search functionality across log output
- Filter logs by level (trace, debug, info, warn, error)
- Status-aware display (pending, running, succeeded, failed, canceled)
- New shadcn-style Sheet component for side panels

Technical details:
- Reuses existing API endpoint /api/v2/templateversions/{id}/logs
- Leverages WorkspaceBuildLogs component for consistent rendering
- Uses useWatchVersionLogs hook for real-time updates
- Migrates away from MUI Drawer to shadcn/ui patterns
- All TypeScript checks pass, no circular dependencies

Closes #17793

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 16:56:13 +00:00
3 changed files with 396 additions and 2 deletions

View File

@@ -0,0 +1,122 @@
/**
* Sheet component for slide-out panels
* Based on shadcn/ui Sheet component
* @see {@link https://ui.shadcn.com/docs/components/sheet}
*/
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { XIcon } from "lucide-react";
import {
type ComponentPropsWithoutRef,
type ElementRef,
forwardRef,
type HTMLAttributes,
} from "react";
import { cn } from "utils/cn";
export const Sheet = DialogPrimitive.Root;
const SheetPortal = DialogPrimitive.Portal;
const SheetOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
`fixed inset-0 z-50 bg-overlay
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0`,
className,
)}
{...props}
/>
));
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
const sheetVariants = cva(
`fixed z-50 gap-6 bg-surface-primary p-6 shadow-lg transition ease-in-out
data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300
data-[state=open]:duration-500`,
{
variants: {
side: {
top: `inset-x-0 top-0 border-b border-border-primary
data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top`,
bottom: `inset-x-0 bottom-0 border-t border-border-primary
data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom`,
left: `inset-y-0 left-0 h-full w-3/4 border-r border-border-primary sm:max-w-xl
data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left`,
right: `inset-y-0 right-0 h-full w-3/4 border-l border-border-primary sm:max-w-xl
data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right`,
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
export const SheetContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-surface-primary transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-border-focus focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-surface-secondary">
<XIcon className="size-icon-md" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = DialogPrimitive.Content.displayName;
export const SheetHeader = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
export const SheetTitle = forwardRef<
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-content-primary", className)}
{...props}
/>
));
SheetTitle.displayName = DialogPrimitive.Title.displayName;
export const SheetDescription = forwardRef<
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-content-secondary", className)}
{...props}
/>
));
SheetDescription.displayName = DialogPrimitive.Description.displayName;

View File

@@ -0,0 +1,251 @@
import { API } from "api/api";
import type { TemplateVersion } from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { Input } from "components/Input/Input";
import { Loader } from "components/Loader/Loader";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "components/Sheet/Sheet";
import { FileTextIcon, FilterIcon, SearchIcon, XIcon } from "lucide-react";
import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs";
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
import { type FC, useMemo, useState } from "react";
import { useQuery } from "react-query";
interface TemplateVersionLogsProps {
version: TemplateVersion;
open: boolean;
onClose: () => void;
}
type LogLevel = "trace" | "debug" | "info" | "warn" | "error";
const LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error"];
export const TemplateVersionLogs: FC<TemplateVersionLogsProps> = ({
version,
open,
onClose,
}) => {
const [searchQuery, setSearchQuery] = useState("");
const [selectedLevels, setSelectedLevels] = useState<Set<LogLevel>>(
new Set(LOG_LEVELS),
);
const jobStatus = version.job.status;
const isRunning = jobStatus === "running" || jobStatus === "pending";
// For completed builds, fetch logs once
const { data: completedLogs } = useQuery({
queryKey: ["templateVersion", version.id, "logs"],
queryFn: () => API.getTemplateVersionLogs(version.id),
enabled: !isRunning && open,
});
// For running builds, watch logs in real-time
const runningLogs = useWatchVersionLogs(isRunning ? version : undefined);
// Use the appropriate logs source
const logs = isRunning ? runningLogs : completedLogs;
// Filter logs based on search query and selected log levels
const filteredLogs = useMemo(() => {
if (!logs) return undefined;
return logs.filter((log) => {
// Filter by log level
if (log.log_level && !selectedLevels.has(log.log_level)) {
return false;
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
return (
log.output?.toLowerCase().includes(query) ||
log.stage?.toLowerCase().includes(query) ||
log.log_source?.toLowerCase().includes(query)
);
}
return true;
});
}, [logs, searchQuery, selectedLevels]);
const toggleLogLevel = (level: LogLevel) => {
setSelectedLevels((prev) => {
const newSet = new Set(prev);
if (newSet.has(level)) {
newSet.delete(level);
} else {
newSet.add(level);
}
return newSet;
});
};
const toggleAllLevels = () => {
if (selectedLevels.size === LOG_LEVELS.length) {
setSelectedLevels(new Set());
} else {
setSelectedLevels(new Set(LOG_LEVELS));
}
};
const clearSearch = () => {
setSearchQuery("");
};
const getJobStatusMessage = () => {
switch (jobStatus) {
case "pending":
return "Build is pending...";
case "running":
return "Build is running...";
case "succeeded":
return "Build completed successfully";
case "failed":
return "Build failed";
case "canceled":
case "canceling":
return "Build was canceled";
default:
return "Build logs";
}
};
const hasLogs = filteredLogs && filteredLogs.length > 0;
const allLogsFiltered = logs && logs.length > 0 && filteredLogs?.length === 0;
return (
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<SheetContent className="w-full sm:max-w-3xl overflow-hidden flex flex-col">
<SheetHeader>
<SheetTitle>{getJobStatusMessage()}</SheetTitle>
<SheetDescription>
Version {version.name} Created by {version.created_by.username}
</SheetDescription>
</SheetHeader>
{/* Search and Filter Controls */}
<div className="flex flex-col gap-3 py-4 border-b border-border-primary">
{/* Search Bar */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-icon-sm text-content-secondary" />
<Input
type="text"
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-10"
/>
{searchQuery && (
<button
type="button"
onClick={clearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-content-secondary hover:text-content-primary"
aria-label="Clear search"
>
<XIcon className="size-icon-sm" />
</button>
)}
</div>
{/* Log Level Filters */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 text-sm text-content-secondary">
<FilterIcon className="size-icon-sm" />
<span>Filter by level:</span>
</div>
<Button
variant="outline"
size="sm"
onClick={toggleAllLevels}
className="h-7"
>
{selectedLevels.size === LOG_LEVELS.length
? "Deselect All"
: "Select All"}
</Button>
{LOG_LEVELS.map((level) => {
const isSelected = selectedLevels.has(level);
return (
<Badge
key={level}
variant={isSelected ? "info" : "default"}
className="cursor-pointer select-none capitalize"
onClick={() => toggleLogLevel(level)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleLogLevel(level);
}
}}
>
{level}
</Badge>
);
})}
</div>
{/* Results count */}
{logs && (
<div className="text-xs text-content-secondary">
{allLogsFiltered ? (
<span>No logs match the current filters</span>
) : (
<span>
Showing {filteredLogs?.length ?? 0} of {logs.length} log
{logs.length !== 1 ? "s" : ""}
</span>
)}
</div>
)}
</div>
{/* Logs Display */}
<div className="flex-1 overflow-auto">
{!logs ? (
<div className="flex items-center justify-center h-32">
<Loader />
</div>
) : allLogsFiltered ? (
<div className="flex flex-col items-center justify-center h-32 text-content-secondary">
<FileTextIcon className="size-icon-lg mb-2" />
<p className="text-sm">
No logs match your search or filter criteria
</p>
<Button
variant="subtle"
size="sm"
onClick={() => {
setSearchQuery("");
setSelectedLevels(new Set(LOG_LEVELS));
}}
className="mt-2"
>
Clear all filters
</Button>
</div>
) : hasLogs ? (
<WorkspaceBuildLogs logs={filteredLogs} className="border-0" />
) : (
<div className="flex flex-col items-center justify-center h-32 text-content-secondary">
<FileTextIcon className="size-icon-lg mb-2" />
<p className="text-sm">
{isRunning ? "Waiting for logs..." : "No logs available"}
</p>
</div>
)}
</div>
</SheetContent>
</Sheet>
);
};

View File

@@ -10,11 +10,12 @@ import {
} from "components/PageHeader/PageHeader";
import { Stack } from "components/Stack/Stack";
import { Stats, StatsItem } from "components/Stats/Stats";
import { EditIcon, PlusIcon } from "lucide-react";
import { EditIcon, FileTextIcon, PlusIcon } from "lucide-react";
import { linkToTemplate, useLinks } from "modules/navigation";
import { TemplateFiles } from "modules/templates/TemplateFiles/TemplateFiles";
import { TemplateUpdateMessage } from "modules/templates/TemplateUpdateMessage";
import type { FC } from "react";
import { TemplateVersionLogs } from "modules/templates/TemplateVersionLogs/TemplateVersionLogs";
import { type FC, useState } from "react";
import { Link as RouterLink } from "react-router";
import { createDayString } from "utils/createDayString";
import type { TemplateVersionFiles } from "utils/templateVersion";
@@ -42,12 +43,23 @@ export const TemplateVersionPageView: FC<TemplateVersionPageViewProps> = ({
}) => {
const getLink = useLinks();
const templateLink = getLink(linkToTemplate(organizationName, templateName));
const [logsOpen, setLogsOpen] = useState(false);
return (
<Margins>
<PageHeader
actions={
<>
{currentVersion && (
<Button
variant="outline"
onClick={() => setLogsOpen(true)}
aria-label="View build logs"
>
<FileTextIcon className="!size-icon-sm" />
View Logs
</Button>
)}
{createWorkspaceUrl && (
<Button asChild>
<RouterLink to={createWorkspaceUrl}>
@@ -107,6 +119,15 @@ export const TemplateVersionPageView: FC<TemplateVersionPageViewProps> = ({
</>
)}
</Stack>
{/* Build Logs Sheet */}
{currentVersion && (
<TemplateVersionLogs
version={currentVersion}
open={logsOpen}
onClose={() => setLogsOpen(false)}
/>
)}
</Margins>
);
};