754 lines
27 KiB
TypeScript
754 lines
27 KiB
TypeScript
import { Link, useLocation } from "@remix-run/react";
|
||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||
import { useEffect, useState, useRef, useCallback } from "react";
|
||
import { S2, S2Error } from "@s2-dev/streamstore";
|
||
import {
|
||
Clipboard,
|
||
ClipboardCheck,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
TerminalSquareIcon,
|
||
LayoutDashboardIcon,
|
||
GitBranchIcon,
|
||
ServerIcon,
|
||
} from "lucide-react";
|
||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||
import { GitMetadata } from "~/components/GitMetadata";
|
||
import { VercelLink } from "~/components/integrations/VercelLink";
|
||
import { RuntimeIcon } from "~/components/RuntimeIcon";
|
||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||
import { Badge } from "~/components/primitives/Badge";
|
||
import { LinkButton } from "~/components/primitives/Buttons";
|
||
import { DateTimeAccurate } from "~/components/primitives/DateTime";
|
||
import { Header2 } from "~/components/primitives/Headers";
|
||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||
import * as Property from "~/components/primitives/PropertyTable";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHeader,
|
||
TableHeaderCell,
|
||
TableRow,
|
||
} from "~/components/primitives/Table";
|
||
import { DeploymentError } from "~/components/runs/v3/DeploymentError";
|
||
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from "~/components/primitives/Tooltip";
|
||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||
import { useOrganization } from "~/hooks/useOrganizations";
|
||
import { useProject } from "~/hooks/useProject";
|
||
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
|
||
import { requireUserId } from "~/services/session.server";
|
||
import { cn } from "~/utils/cn";
|
||
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
|
||
import { capitalizeWord } from "~/utils/string";
|
||
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
|
||
import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas";
|
||
|
||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||
const userId = await requireUserId(request);
|
||
const { organizationSlug, projectParam, envParam, deploymentParam } =
|
||
v3DeploymentParams.parse(params);
|
||
|
||
try {
|
||
const presenter = new DeploymentPresenter();
|
||
const { deployment, eventStream } = await presenter.call({
|
||
userId,
|
||
organizationSlug,
|
||
projectSlug: projectParam,
|
||
environmentSlug: envParam,
|
||
deploymentShortCode: deploymentParam,
|
||
});
|
||
|
||
return typedjson({ deployment, eventStream });
|
||
} catch (error) {
|
||
console.error(error);
|
||
throw new Response(undefined, {
|
||
status: 400,
|
||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||
});
|
||
}
|
||
};
|
||
|
||
type LogEntry = {
|
||
message: string;
|
||
timestamp: Date;
|
||
level: "info" | "error" | "warn" | "debug";
|
||
};
|
||
|
||
function getTriggeredViaDisplay(triggeredVia: string | null | undefined): {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
} | null {
|
||
if (!triggeredVia) return null;
|
||
|
||
const iconClass = "size-4 text-text-dimmed";
|
||
|
||
switch (triggeredVia) {
|
||
case "cli:manual":
|
||
return {
|
||
icon: <TerminalSquareIcon className={iconClass} />,
|
||
label: "CLI (Manual)",
|
||
};
|
||
case "cli:github_actions":
|
||
return {
|
||
icon: <GitBranchIcon className={iconClass} />,
|
||
label: "CLI (GitHub Actions)",
|
||
};
|
||
case "cli:gitlab_ci":
|
||
return {
|
||
icon: <ServerIcon className={iconClass} />,
|
||
label: "CLI (GitLab CI)",
|
||
};
|
||
case "cli:circleci":
|
||
return {
|
||
icon: <ServerIcon className={iconClass} />,
|
||
label: "CLI (CircleCI)",
|
||
};
|
||
case "cli:jenkins":
|
||
return {
|
||
icon: <ServerIcon className={iconClass} />,
|
||
label: "CLI (Jenkins)",
|
||
};
|
||
case "cli:azure_pipelines":
|
||
return {
|
||
icon: <ServerIcon className={iconClass} />,
|
||
label: "CLI (Azure Pipelines)",
|
||
};
|
||
case "cli:bitbucket_pipelines":
|
||
return {
|
||
icon: <ServerIcon className={iconClass} />,
|
||
label: "CLI (Bitbucket Pipelines)",
|
||
};
|
||
case "cli:travis_ci":
|
||
return {
|
||
icon: <ServerIcon className={iconClass} />,
|
||
label: "CLI (Travis CI)",
|
||
};
|
||
case "cli:buildkite":
|
||
return {
|
||
icon: <ServerIcon className={iconClass} />,
|
||
label: "CLI (Buildkite)",
|
||
};
|
||
case "cli:ci_other":
|
||
return {
|
||
icon: <ServerIcon className={iconClass} />,
|
||
label: "CLI (CI)",
|
||
};
|
||
case "git_integration:github":
|
||
return {
|
||
icon: <GitBranchIcon className={iconClass} />,
|
||
label: "GitHub Integration",
|
||
};
|
||
case "dashboard":
|
||
return {
|
||
icon: <LayoutDashboardIcon className={iconClass} />,
|
||
label: "Dashboard",
|
||
};
|
||
default:
|
||
// Handle any unknown values gracefully
|
||
if (triggeredVia.startsWith("cli:")) {
|
||
return {
|
||
icon: <TerminalSquareIcon className={iconClass} />,
|
||
label: `CLI (${triggeredVia.replace("cli:", "")})`,
|
||
};
|
||
}
|
||
return {
|
||
icon: <ServerIcon className={iconClass} />,
|
||
label: triggeredVia,
|
||
};
|
||
}
|
||
}
|
||
|
||
export default function Page() {
|
||
const { deployment, eventStream } = useTypedLoaderData<typeof loader>();
|
||
const organization = useOrganization();
|
||
const project = useProject();
|
||
const environment = useEnvironment();
|
||
const location = useLocation();
|
||
const page = new URLSearchParams(location.search).get("page");
|
||
|
||
const logsDisabled = eventStream === undefined;
|
||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||
const [isStreaming, setIsStreaming] = useState(true);
|
||
const [streamError, setStreamError] = useState<string | null>(null);
|
||
const isPending = deployment.status === "PENDING";
|
||
|
||
useEffect(() => {
|
||
if (logsDisabled) return;
|
||
|
||
const abortController = new AbortController();
|
||
|
||
setLogs([]);
|
||
setStreamError(null);
|
||
setIsStreaming(true);
|
||
|
||
const streamLogs = async () => {
|
||
try {
|
||
const s2 = new S2({ accessToken: eventStream.s2.accessToken });
|
||
const basin = s2.basin(eventStream.s2.basin);
|
||
const stream = basin.stream(eventStream.s2.stream);
|
||
|
||
const readSession = await stream.readSession(
|
||
{
|
||
start: { from: { seqNum: 0 }, clamp: true },
|
||
stop: { waitSecs: 60 },
|
||
},
|
||
{ signal: abortController.signal }
|
||
);
|
||
|
||
for await (const record of readSession) {
|
||
const decoded = record.body;
|
||
const result = DeploymentEventFromString.safeParse(decoded);
|
||
|
||
if (!result.success) {
|
||
// fallback to the previous format in s2 logs for compatibility
|
||
try {
|
||
const headers: Record<string, string> = {};
|
||
|
||
if (record.headers) {
|
||
for (const [name, value] of record.headers) {
|
||
headers[name] = value;
|
||
}
|
||
}
|
||
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
|
||
|
||
setLogs((prevLogs) => [
|
||
...prevLogs,
|
||
{
|
||
timestamp: new Date(record.timestamp),
|
||
message: decoded,
|
||
level,
|
||
},
|
||
]);
|
||
} catch (err) {
|
||
console.error("Failed to parse log record:", err);
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
const event = result.data;
|
||
if (event.type !== "log") {
|
||
continue;
|
||
}
|
||
|
||
setLogs((prevLogs) => [
|
||
...prevLogs,
|
||
{
|
||
timestamp: new Date(record.timestamp),
|
||
message: event.data.message,
|
||
level: event.data.level,
|
||
},
|
||
]);
|
||
}
|
||
} catch (error) {
|
||
if (abortController.signal.aborted) return;
|
||
|
||
const isNotFoundError =
|
||
error instanceof S2Error &&
|
||
error.code &&
|
||
["permission_denied", "stream_not_found"].includes(error.code);
|
||
if (isNotFoundError) return;
|
||
|
||
console.error("Failed to stream logs:", error);
|
||
setStreamError("Failed to stream logs");
|
||
} finally {
|
||
if (!abortController.signal.aborted) {
|
||
setIsStreaming(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
streamLogs();
|
||
|
||
return () => {
|
||
abortController.abort();
|
||
};
|
||
}, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]);
|
||
|
||
return (
|
||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||
<Header2 className={cn("whitespace-nowrap")}>Deploy: {deployment.shortCode}</Header2>
|
||
|
||
<AdminDebugTooltip>
|
||
<Property.Table>
|
||
<Property.Item>
|
||
<Property.Label>ID</Property.Label>
|
||
<Property.Value>{deployment.id}</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Project ID</Property.Label>
|
||
<Property.Value>{deployment.projectId}</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Org ID</Property.Label>
|
||
<Property.Value>{deployment.organizationId}</Property.Value>
|
||
</Property.Item>
|
||
{deployment.imageReference && (
|
||
<Property.Item>
|
||
<Property.Label>Image</Property.Label>
|
||
<Property.Value>{deployment.imageReference}</Property.Value>
|
||
</Property.Item>
|
||
)}
|
||
<Property.Item>
|
||
<Property.Label>Platform</Property.Label>
|
||
<Property.Value>{deployment.imagePlatform}</Property.Value>
|
||
</Property.Item>
|
||
{deployment.externalBuildData && (
|
||
<Property.Item>
|
||
<Property.Label>Build Server</Property.Label>
|
||
<Property.Value>
|
||
<Link
|
||
to={`/resources/${deployment.projectId}/deployments/${deployment.id}/logs`}
|
||
className="extra-small/bright/mono underline"
|
||
>
|
||
{deployment.externalBuildData.buildId}
|
||
</Link>
|
||
</Property.Value>
|
||
</Property.Item>
|
||
)}
|
||
</Property.Table>
|
||
</AdminDebugTooltip>
|
||
|
||
<LinkButton
|
||
to={`${v3DeploymentsPath(organization, project, environment)}${
|
||
page ? `?page=${page}` : ""
|
||
}`}
|
||
variant="minimal/small"
|
||
TrailingIcon={ExitIcon}
|
||
shortcut={{ key: "esc" }}
|
||
shortcutPosition="before-trailing-icon"
|
||
className="pl-1"
|
||
/>
|
||
</div>
|
||
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||
<div className="flex flex-col">
|
||
<div className="p-3">
|
||
<Property.Table>
|
||
<Property.Item>
|
||
<Property.Label>Deploy</Property.Label>
|
||
<Property.Value className="flex items-center gap-2">
|
||
<span>{deployment.shortCode}</span>
|
||
{deployment.label && (
|
||
<Badge variant="extra-small" className="capitalize">
|
||
{deployment.label}
|
||
</Badge>
|
||
)}
|
||
</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Environment</Property.Label>
|
||
<Property.Value>
|
||
<EnvironmentCombo environment={deployment.environment} />
|
||
</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Version</Property.Label>
|
||
<Property.Value>{deployment.version}</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Status</Property.Label>
|
||
<Property.Value>
|
||
<DeploymentStatus
|
||
status={deployment.status}
|
||
isBuilt={deployment.isBuilt}
|
||
className="text-sm"
|
||
/>
|
||
</Property.Value>
|
||
</Property.Item>
|
||
{!logsDisabled && (
|
||
<Property.Item>
|
||
<Property.Label>Logs</Property.Label>
|
||
<LogsDisplay
|
||
logs={logs}
|
||
isStreaming={isStreaming}
|
||
streamError={streamError}
|
||
initialCollapsed={(
|
||
["PENDING", "DEPLOYED", "TIMED_OUT"] satisfies (typeof deployment.status)[]
|
||
).includes(deployment.status)}
|
||
/>
|
||
</Property.Item>
|
||
)}
|
||
{deployment.canceledAt && (
|
||
<Property.Item>
|
||
<Property.Label>Canceled at</Property.Label>
|
||
<Property.Value>
|
||
<>
|
||
<DateTimeAccurate date={deployment.canceledAt} /> UTC
|
||
</>
|
||
</Property.Value>
|
||
</Property.Item>
|
||
)}
|
||
{deployment.canceledReason && (
|
||
<Property.Item>
|
||
<Property.Label>Cancelation reason</Property.Label>
|
||
<Property.Value>{deployment.canceledReason}</Property.Value>
|
||
</Property.Item>
|
||
)}
|
||
<Property.Item>
|
||
<Property.Label>Tasks</Property.Label>
|
||
<Property.Value>{deployment.tasks ? deployment.tasks.length : "–"}</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>SDK Version</Property.Label>
|
||
<Property.Value>
|
||
{deployment.sdkVersion ? deployment.sdkVersion : "–"}
|
||
</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>CLI Version</Property.Label>
|
||
<Property.Value>
|
||
{deployment.cliVersion ? deployment.cliVersion : "–"}
|
||
</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Runtime</Property.Label>
|
||
<Property.Value>
|
||
<RuntimeIcon
|
||
runtime={deployment.runtime}
|
||
runtimeVersion={deployment.runtimeVersion}
|
||
withLabel
|
||
/>
|
||
</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Worker type</Property.Label>
|
||
<Property.Value>{capitalizeWord(deployment.type)}</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Started at</Property.Label>
|
||
<Property.Value>
|
||
{deployment.startedAt ? (
|
||
<>
|
||
<DateTimeAccurate date={deployment.startedAt} /> UTC
|
||
</>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Installed at</Property.Label>
|
||
<Property.Value>
|
||
{deployment.installedAt ? (
|
||
<>
|
||
<DateTimeAccurate date={deployment.installedAt} /> UTC
|
||
</>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Built at</Property.Label>
|
||
<Property.Value>
|
||
{deployment.builtAt ? (
|
||
<>
|
||
<DateTimeAccurate date={deployment.builtAt} /> UTC
|
||
</>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Deployed at</Property.Label>
|
||
<Property.Value>
|
||
{deployment.deployedAt ? (
|
||
<>
|
||
<DateTimeAccurate date={deployment.deployedAt} /> UTC
|
||
</>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</Property.Value>
|
||
</Property.Item>
|
||
|
||
<Property.Item>
|
||
<Property.Label>Git</Property.Label>
|
||
<Property.Value>
|
||
<div className="-ml-1 mt-0.5 flex flex-col">
|
||
<GitMetadata git={deployment.git} />
|
||
</div>
|
||
</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Deployed by</Property.Label>
|
||
<Property.Value>
|
||
{deployment.git?.source === "trigger_github_app" ? (
|
||
<UserTag
|
||
name={deployment.git.ghUsername ?? "GitHub Integration"}
|
||
avatarUrl={deployment.git.ghUserAvatarUrl}
|
||
/>
|
||
) : deployment.deployedBy ? (
|
||
<UserTag
|
||
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName ?? ""}
|
||
avatarUrl={deployment.deployedBy.avatarUrl ?? undefined}
|
||
/>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</Property.Value>
|
||
</Property.Item>
|
||
<Property.Item>
|
||
<Property.Label>Triggered via</Property.Label>
|
||
<Property.Value>
|
||
{(() => {
|
||
const display = getTriggeredViaDisplay(deployment.triggeredVia);
|
||
if (!display) return "–";
|
||
return (
|
||
<span className="flex items-center gap-1.5">
|
||
{display.icon}
|
||
{display.label}
|
||
</span>
|
||
);
|
||
})()}
|
||
</Property.Value>
|
||
</Property.Item>
|
||
{deployment.vercelDeploymentUrl && (
|
||
<Property.Item>
|
||
<Property.Label>Linked</Property.Label>
|
||
<Property.Value>
|
||
<div className="-ml-1 mt-0.5 flex flex-col">
|
||
<VercelLink vercelDeploymentUrl={deployment.vercelDeploymentUrl} />
|
||
</div>
|
||
</Property.Value>
|
||
</Property.Item>
|
||
)}
|
||
</Property.Table>
|
||
</div>
|
||
|
||
{deployment.errorData && <DeploymentError errorData={deployment.errorData} />}
|
||
|
||
{deployment.tasks && (
|
||
<div className="divide-y divide-background-bright overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||
<Table variant="bright">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHeaderCell className="px-2">Task</TableHeaderCell>
|
||
<TableHeaderCell className="px-2">File path</TableHeaderCell>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{deployment.tasks.map((t) => {
|
||
const path = v3RunsPath(organization, project, environment, {
|
||
tasks: [t.slug],
|
||
});
|
||
return (
|
||
<TableRow key={t.slug}>
|
||
<TableCell to={path}>
|
||
<div className="inline-flex flex-col gap-0.5">
|
||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||
{t.slug}
|
||
</Paragraph>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell to={path}>{t.filePath}</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LogsDisplay({
|
||
logs,
|
||
isStreaming,
|
||
streamError,
|
||
initialCollapsed = false,
|
||
}: {
|
||
logs: LogEntry[];
|
||
isStreaming: boolean;
|
||
streamError: string | null;
|
||
initialCollapsed?: boolean;
|
||
}) {
|
||
const [copied, setCopied] = useState(false);
|
||
const [mouseOver, setMouseOver] = useState(false);
|
||
const [collapsed, setCollapsed] = useState(initialCollapsed);
|
||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
setCollapsed(initialCollapsed);
|
||
}, [initialCollapsed]);
|
||
|
||
// auto-scroll log container to bottom when new logs arrive
|
||
useEffect(() => {
|
||
if (logsContainerRef.current) {
|
||
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
|
||
}
|
||
}, [logs]);
|
||
|
||
const onCopyLogs = useCallback(
|
||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const logsText = logs.map((log) => log.message).join("\n");
|
||
navigator.clipboard.writeText(logsText);
|
||
setCopied(true);
|
||
setTimeout(() => {
|
||
setCopied(false);
|
||
}, 1500);
|
||
},
|
||
[logs]
|
||
);
|
||
|
||
const errorCount = logs.filter((log) => log.level === "error").length;
|
||
const warningCount = logs.filter((log) => log.level === "warn").length;
|
||
|
||
return (
|
||
<div className="mt-1.5 overflow-hidden rounded-md border border-grid-bright">
|
||
<div className="flex items-center justify-between border-b border-grid-dimmed px-3 py-2">
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex items-center gap-1.5">
|
||
<div
|
||
className={cn(
|
||
"h-2 w-2 rounded-full",
|
||
errorCount > 0 ? "bg-error/80" : "bg-surface-control"
|
||
)}
|
||
/>
|
||
<Paragraph variant="extra-small/dimmed/mono" className="w-[ch-10]">
|
||
{`${errorCount} ${errorCount === 1 ? "error" : "errors"}`}
|
||
</Paragraph>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<div
|
||
className={cn(
|
||
"h-2 w-2 rounded-full",
|
||
warningCount > 0 ? "bg-warning/80" : "bg-surface-control"
|
||
)}
|
||
/>
|
||
<Paragraph variant="extra-small/dimmed/mono">
|
||
{`${warningCount} ${warningCount === 1 ? "warning" : "warnings"}`}
|
||
</Paragraph>
|
||
</div>
|
||
</div>
|
||
{logs.length > 0 && (
|
||
<div className="flex items-center gap-3">
|
||
<TooltipProvider>
|
||
<Tooltip open={copied || mouseOver} disableHoverableContent>
|
||
<TooltipTrigger
|
||
onClick={onCopyLogs}
|
||
onMouseEnter={() => setMouseOver(true)}
|
||
onMouseLeave={() => setMouseOver(false)}
|
||
className={cn(
|
||
"transition-colors duration-100 focus-custom hover:cursor-pointer",
|
||
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
|
||
)}
|
||
>
|
||
<div className="size-4 shrink-0">
|
||
{copied ? (
|
||
<ClipboardCheck className="size-full" />
|
||
) : (
|
||
<Clipboard className="size-full" />
|
||
)}
|
||
</div>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="left" className="text-xs">
|
||
{copied ? "Copied" : "Copy"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
|
||
<TooltipProvider>
|
||
<Tooltip disableHoverableContent>
|
||
<TooltipTrigger
|
||
onClick={() => setCollapsed(!collapsed)}
|
||
className={cn(
|
||
"transition-colors duration-100 focus-custom hover:cursor-pointer",
|
||
"text-text-dimmed hover:text-text-bright"
|
||
)}
|
||
>
|
||
{collapsed ? (
|
||
<ChevronDown className="size-4" />
|
||
) : (
|
||
<ChevronUp className="size-4" />
|
||
)}
|
||
</TooltipTrigger>
|
||
<TooltipContent side="left" className="text-xs">
|
||
{collapsed ? "Expand" : "Collapse"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<div
|
||
ref={logsContainerRef}
|
||
className={cn(
|
||
"grow overflow-x-auto overflow-y-scroll font-mono text-xs transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
||
collapsed ? "h-16" : "h-64"
|
||
)}
|
||
>
|
||
<div className="flex w-fit min-w-full flex-col">
|
||
{logs.length === 0 && (
|
||
<div className="flex gap-x-2.5 border-l-2 border-transparent px-2.5 py-1">
|
||
{streamError ? (
|
||
<span className="text-error">Failed fetching logs</span>
|
||
) : (
|
||
<span className="text-text-dimmed">
|
||
{isStreaming ? "Waiting for logs..." : "No logs yet"}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{logs.map((log, index) => {
|
||
return (
|
||
<div
|
||
key={index}
|
||
className={cn(
|
||
"flex w-full gap-x-2.5 border-l-2 px-2.5 py-1",
|
||
log.level === "error" && "border-error/60 bg-error/15 hover:bg-error/25",
|
||
log.level === "warn" && "border-warning/60 bg-warning/20 hover:bg-warning/30",
|
||
log.level === "info" && "border-transparent hover:bg-background-hover"
|
||
)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
"select-none whitespace-nowrap py-px",
|
||
log.level === "error" && "text-error/80",
|
||
log.level === "warn" && "text-warning/70",
|
||
log.level === "info" && "text-text-dimmed"
|
||
)}
|
||
>
|
||
<DateTimeAccurate date={log.timestamp} hideDate hour12={false} />
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
"whitespace-nowrap",
|
||
log.level === "error" && "text-error",
|
||
log.level === "warn" && "text-warning",
|
||
log.level === "info" && "text-text-bright"
|
||
)}
|
||
>
|
||
{log.message}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
{collapsed && (
|
||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-linear-to-t from-background-bright/90 to-transparent" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|