Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cn } from "@unkey/ui/src/lib/utils";

type FilterButtonProps = {
isActive: boolean;
count: number;
count: number | string;
onClick: () => void;
icon: React.ComponentType<IconProps>;
label: string;
Expand All @@ -27,7 +27,7 @@ export const FilterButton = ({
>
<Icon size="sm-regular" className={isActive ? "" : "text-grayA-9"} />
<span className={isActive ? "" : "text-grayA-9"}>{label}</span>
<div className="rounded size-[18px] flex items-center justify-center text-[10px] leading-4 bg-gray-6 text-black dark:text-white">
<div className="rounded w-[22px] h-[18px] flex items-center justify-center text-[10px] leading-4 bg-gray-6 text-black dark:text-white">
{count}
</div>
</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,113 +1,180 @@
import { trpc } from "@/lib/trpc/client";
import { format } from "date-fns";
import { useQueryTime } from "@/providers/query-time-provider";
import { useEffect, useMemo, useRef, useState } from "react";
import { EXCLUDED_HOSTS } from "../../../gateway-logs/constants";

const BUILD_STEPS_REFETCH_INTERVAL = 500;
const GATEWAY_LOGS_REFETCH_INTERVAL = 2000;
const GATEWAY_LOGS_LIMIT = 20;
const GATEWAY_LOGS_SINCE = "1m";
const MAX_STORED_LOGS = 200;
const SCROLL_RESET_DELAY = 50;
const ERROR_STATUS_THRESHOLD = 500;
const WARNING_STATUS_THRESHOLD = 400;

type LogEntry = {
timestamp: string;
level?: "info" | "warning" | "error";
type: "build" | "gateway";
id: string;
timestamp: number;
message: string;
level?: "warning" | "error";
};

type LogFilter = "all" | "errors" | "warnings";
type LogFilter = "all" | "warnings" | "errors";

type UseDeploymentLogsProps = {
deploymentId: string;
showBuildSteps: boolean;
};

type UseDeploymentLogsReturn = {
// State
logFilter: LogFilter;
searchTerm: string;
isExpanded: boolean;
showFade: boolean;
// Computed
filteredLogs: LogEntry[];
logCounts: {
total: number;
errors: number;
warnings: number;
errors: number;
};
// Loading state
isLoading: boolean;
// Actions
setLogFilter: (filter: LogFilter) => void;
setSearchTerm: (term: string) => void;
setExpanded: (expanded: boolean) => void;
handleScroll: (e: React.UIEvent<HTMLDivElement>) => void;
handleFilterChange: (filter: LogFilter) => void;
handleSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
// Refs
scrollRef: React.RefObject<HTMLDivElement>;
};

export function useDeploymentLogs({
deploymentId,
showBuildSteps,
}: UseDeploymentLogsProps): UseDeploymentLogsReturn {
const [logFilter, setLogFilter] = useState<LogFilter>("all");
const [searchTerm, setSearchTerm] = useState("");
const [isExpanded, setIsExpanded] = useState(false);
const [showFade, setShowFade] = useState(true);
const [storedLogs, setStoredLogs] = useState<Map<string, LogEntry>>(new Map());
const scrollRef = useRef<HTMLDivElement>(null);
const { queryTime: timestamp } = useQueryTime();

const { data: buildData, isLoading: buildLoading } = trpc.deploy.deployment.buildSteps.useQuery(
{ deploymentId },
{
enabled: showBuildSteps && isExpanded,
refetchInterval: BUILD_STEPS_REFETCH_INTERVAL,
},
);

// Fetch logs via tRPC
const { data: logsData, isLoading } = trpc.deploy.deployment.buildLogs.useQuery({
deploymentId,
});
const { data: gatewayData, isLoading: gatewayLoading } = trpc.logs.queryLogs.useQuery(
{
limit: GATEWAY_LOGS_LIMIT,
endTime: timestamp,
startTime: timestamp,
host: { filters: [], exclude: EXCLUDED_HOSTS },
method: { filters: [] },
path: { filters: [] },
status: { filters: [] },
requestId: null,
since: GATEWAY_LOGS_SINCE,
},
{
enabled: !showBuildSteps && isExpanded,
refetchInterval: GATEWAY_LOGS_REFETCH_INTERVAL,
refetchOnWindowFocus: false,
},
);

// Transform tRPC logs to match the expected format
const logs = useMemo((): LogEntry[] => {
if (!logsData?.logs) {
return [];
// Update stored logs when build data changes
useEffect(() => {
if (showBuildSteps && buildData?.logs) {
const logMap = new Map<string, LogEntry>();
buildData.logs.forEach((log) => {
logMap.set(log.id, {
type: "build",
id: log.id,
timestamp: log.timestamp,
message: log.message,
});
});
setStoredLogs(logMap);
}
}, [showBuildSteps, buildData]);

return logsData.logs.map((log) => ({
timestamp: format(new Date(log.timestamp), "HH:mm:ss.SSS"),
level: log.level,
message: log.message,
}));
}, [logsData]);

// Auto-expand when logs are fetched
// Update stored logs when gateway data changes
useEffect(() => {
if (logsData?.logs && logsData.logs.length > 0) {
setIsExpanded(true);
if (!showBuildSteps && gatewayData?.logs) {
setStoredLogs((prev) => {
const newMap = new Map(prev);

gatewayData.logs.forEach((log) => {
let level: "warning" | "error" | undefined;
if (log.response_status >= ERROR_STATUS_THRESHOLD) {
level = "error";
} else if (log.response_status >= WARNING_STATUS_THRESHOLD) {
level = "warning";
}

newMap.set(log.request_id, {
type: "gateway",
id: log.request_id,
timestamp: log.time,
message: `${log.response_status} ${log.method} ${log.path} (${log.service_latency}ms)`,
level,
});
});

const sortedEntries = Array.from(newMap.entries())
.sort((a, b) => b[1].timestamp - a[1].timestamp)
.slice(0, MAX_STORED_LOGS);

return new Map(sortedEntries);
});
}
}, [logsData]);
}, [showBuildSteps, gatewayData]);

// Calculate log counts
const logCounts = useMemo(
() => ({
const logs = useMemo(() => {
return Array.from(storedLogs.values()).sort((a, b) => b.timestamp - a.timestamp);
}, [storedLogs]);

const logCounts = useMemo(() => {
const warnings = logs.filter((log) => log.level === "warning").length;
const errors = logs.filter((log) => log.level === "error").length;

return {
total: logs.length,
errors: logs.filter((log) => log.level === "error").length,
warnings: logs.filter((log) => log.level === "warning").length,
}),
[logs],
);
warnings,
errors,
};
}, [logs]);

// Filter logs by level and search term
const filteredLogs = useMemo(() => {
let filtered = logs;

// Apply level filter
if (logFilter === "errors") {
filtered = logs.filter((log) => log.level === "error");
} else if (logFilter === "warnings") {
filtered = logs.filter((log) => log.level === "warning");
if (logFilter === "warnings") {
filtered = logs.filter((log) => log.type === "build" || log.level === "warning");
} else if (logFilter === "errors") {
filtered = logs.filter((log) => log.type === "build" || log.level === "error");
}

// Apply search filter
if (searchTerm.trim()) {
filtered = filtered.filter(
(log) =>
log.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.timestamp.includes(searchTerm) ||
log.level?.toLowerCase().includes(searchTerm.toLowerCase()),
filtered = filtered.filter((log) =>
log.message.toLowerCase().includes(searchTerm.toLowerCase()),
);
}

return filtered;
}, [logs, logFilter, searchTerm]);

// Auto-expand when logs are available
useEffect(() => {
if (logs.length > 0) {
setIsExpanded(true);
}
}, [logs.length]);

const resetScroll = () => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
Expand All @@ -118,7 +185,7 @@ export function useDeploymentLogs({
const setExpanded = (expanded: boolean) => {
setIsExpanded(expanded);
if (!expanded) {
setTimeout(resetScroll, 50);
setTimeout(resetScroll, SCROLL_RESET_DELAY);
}
};

Expand All @@ -139,24 +206,19 @@ export function useDeploymentLogs({
};

return {
// State
logFilter,
searchTerm,
isExpanded,
showFade,
// Computed
filteredLogs,
logCounts,
// Loading state
isLoading,
// Actions
isLoading: showBuildSteps ? buildLoading : gatewayLoading,
setLogFilter,
setSearchTerm,
setExpanded,
handleScroll,
handleFilterChange,
handleSearchChange,
// Refs
scrollRef,
};
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { formatNumber } from "@/lib/fmt";
import { eq, useLiveQuery } from "@tanstack/react-db";
import {
ChevronDown,
Expand All @@ -12,9 +13,11 @@ import {
Magnifier,
TriangleWarning2,
} from "@unkey/icons";
import { Badge, Button, Card, CopyButton, Input, TimestampInfo } from "@unkey/ui";
import { Badge, Button, CopyButton, Input, TimestampInfo } from "@unkey/ui";
import { cn } from "@unkey/ui/src/lib/utils";
import { format } from "date-fns";
import { useProjectLayout } from "../../layout-provider";
import { Card } from "../card";
import { FilterButton } from "./filter-button";
import { Avatar } from "./git-avatar";
import { useDeploymentLogs } from "./hooks/use-deployment-logs";
Expand Down Expand Up @@ -68,16 +71,19 @@ type Props = {
deploymentId: string;
};

export const ActiveDeploymentCard: React.FC<Props> = ({ deploymentId }) => {
export const ActiveDeploymentCard = ({ deploymentId }: Props) => {
const { collections } = useProjectLayout();
const { data } = useLiveQuery((q) =>
q
.from({ deployment: collections.deployments })
.where(({ deployment }) => eq(deployment.id, deploymentId)),
);

const deployment = data.at(0);

// If deployment status is not ready it means we gotta keep showing build steps.
// Then, user can switch between runtime(not implemented yet) and gateway logs
const showBuildSteps = deployment?.status !== "ready";

const {
logFilter,
searchTerm,
Expand All @@ -90,7 +96,10 @@ export const ActiveDeploymentCard: React.FC<Props> = ({ deploymentId }) => {
handleFilterChange,
handleSearchChange,
scrollRef,
} = useDeploymentLogs({ deploymentId });
} = useDeploymentLogs({
deploymentId,
showBuildSteps,
});

if (!deployment) {
return <ActiveDeploymentCardSkeleton />;
Expand Down Expand Up @@ -153,7 +162,9 @@ export const ActiveDeploymentCard: React.FC<Props> = ({ deploymentId }) => {
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="text-grayA-9 text-xs">Build logs</div>
<div className="text-grayA-9 text-xs">
{showBuildSteps ? "Build logs" : "Gateway logs"}
</div>
<Button size="icon" variant="ghost" onClick={() => setExpanded(!isExpanded)}>
<ChevronDown
className={cn(
Expand All @@ -176,21 +187,22 @@ export const ActiveDeploymentCard: React.FC<Props> = ({ deploymentId }) => {
<div className="flex items-center gap-1.5 px-3 mb-3">
<FilterButton
isActive={logFilter === "all"}
count={logCounts.total}
count={formatNumber(logCounts.total)}
onClick={() => handleFilterChange("all")}
icon={Layers3}
label="All Logs"
/>
{/*//INFO: Let's keep them for now we might need them in the future*/}
<FilterButton
isActive={logFilter === "errors"}
count={logCounts.errors}
count={formatNumber(logCounts.errors)}
onClick={() => handleFilterChange("errors")}
icon={CircleXMark}
label="Errors"
/>
<FilterButton
isActive={logFilter === "warnings"}
count={logCounts.warnings}
count={formatNumber(logCounts.warnings)}
onClick={() => handleFilterChange("warnings")}
icon={TriangleWarning2}
label="Warnings"
Expand Down Expand Up @@ -228,7 +240,9 @@ export const ActiveDeploymentCard: React.FC<Props> = ({ deploymentId }) => {
<div className="text-center text-gray-9 text-sm py-4 flex items-center justify-center h-full">
{searchTerm
? `No logs match "${searchTerm}"`
: `No ${logFilter === "all" ? "build" : logFilter} logs available`}
: `No ${
logFilter === "all" ? (showBuildSteps ? "build" : "gateway") : logFilter
} logs available`}
</div>
) : (
<div className="flex flex-col gap-px">
Expand All @@ -249,10 +263,9 @@ export const ActiveDeploymentCard: React.FC<Props> = ({ deploymentId }) => {
transitionDelay: isExpanded ? `${200 + index * 20}ms` : "0ms",
}}
>
<span className="text-grayA-9 pl-3">{log.timestamp}</span>
{log.level && (
<span className="font-medium">[{log.level.toUpperCase()}]</span>
)}
<span className="text-grayA-9 pl-3">
{format(new Date(log.timestamp), "HH:mm:ss.SSS")}
</span>
<span className="pr-3">{log.message}</span>
</div>
))}
Expand Down
Loading