diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx index 2854c46b7f..a482fd21e0 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx @@ -379,6 +379,7 @@ export const KeysList = ({ "text-xs align-middle whitespace-nowrap pr-4", idx === 0 ? "pl-[18px]" : "", column.key === "key" ? "py-[6px]" : "py-1", + column.cellClassName, )} style={{ height: `${rowHeight}px` }} > diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/authorization/permissions/components/table/permissions-list.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/authorization/permissions/components/table/permissions-list.tsx index 136e0b61f8..06ffe0d4f1 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/authorization/permissions/components/table/permissions-list.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/authorization/permissions/components/table/permissions-list.tsx @@ -234,6 +234,7 @@ export const PermissionsList = () => { className={cn( "text-xs align-middle whitespace-nowrap", column.key === "permission" ? "py-[6px]" : "py-1", + column.cellClassName, )} style={{ height: `${rowHeight}px` }} > diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/authorization/roles/components/table/roles-list.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/authorization/roles/components/table/roles-list.tsx index 1d7e11ef3a..0f715df7fd 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/authorization/roles/components/table/roles-list.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/authorization/roles/components/table/roles-list.tsx @@ -219,6 +219,7 @@ export const RolesList = () => { className={cn( "text-xs align-middle whitespace-nowrap", column.key === "role" ? "py-[6px]" : "py-1", + column.cellClassName, )} style={{ height: `${rowHeight}px` }} > diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/table/identities-list.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/table/identities-list.tsx index 2272fa3451..b33cc1ca80 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/table/identities-list.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/table/identities-list.tsx @@ -318,6 +318,7 @@ export const IdentitiesList = () => { "text-xs align-middle whitespace-nowrap pr-4", idx === 0 ? "pl-[18px]" : "", column.key === "externalId" ? "py-[6px]" : "py-1", + column.cellClassName, )} style={{ height: `${rowHeight}px` }} > diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/metrics/metric-card.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/metrics/metric-card.tsx index 9fc2410f00..a4153425ad 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/metrics/metric-card.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/metrics/metric-card.tsx @@ -1,4 +1,5 @@ import type { TimeseriesData } from "@/components/logs/overview-charts/types"; +import { formatLatency } from "@/lib/utils/metric-formatters"; import type { IconProps } from "@unkey/icons"; import type { ComponentType } from "react"; import { LogsTimeseriesBarChart } from "../../../network/unkey-flow/components/overlay/node-details-panel/components/chart"; @@ -77,8 +78,16 @@ export function MetricCard({
- {currentValue} - {config.unit} + {metricType === "latency" ? ( + + {formatLatency(currentValue)} + + ) : ( + <> + {currentValue} + {config.unit} + + )}
{secondaryValue && ( <> diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-info-section.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-info-section.tsx index 23d9f42390..953eac5ee0 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-info-section.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-info-section.tsx @@ -8,6 +8,7 @@ import { ActiveDeploymentCard } from "../../../../../../components/active-deploy import { DeploymentStatusBadge } from "../../../../../../components/deployment-status-badge"; import { DisabledWrapper } from "../../../../../../components/disabled-wrapper"; import { InfoChip } from "../../../../../../components/info-chip"; +import { RegionFlags } from "../../../../../../components/region-flags"; import { Section, SectionHeader } from "../../../../../../components/section"; import { useProject } from "../../../../../layout-provider"; @@ -16,14 +17,16 @@ export function DeploymentInfoSection() { const deploymentId = params?.deploymentId as string; const { collections, setIsDetailsOpen, isDetailsOpen } = useProject(); - const deployment = useLiveQuery( + const { data } = useLiveQuery( (q) => q .from({ deployment: collections.deployments }) .where(({ deployment }) => eq(deployment.id, deploymentId)), [deploymentId], ); - const deploymentStatus = deployment.data.at(0)?.status; + + const deployment = data.at(0); + const deploymentStatus = deployment?.status; return (
@@ -37,7 +40,7 @@ export function DeploymentInfoSection() {
@@ -55,17 +58,7 @@ export function DeploymentInfoSection() {
-
-
- us-flag -
-
- de-flag -
-
- in-flag -
-
+
} - statusBadge={ - - } + statusBadge={} />
); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/table/components/latency-badge.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/table/components/latency-badge.tsx index be957863b5..7e328a1918 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/table/components/latency-badge.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/table/components/latency-badge.tsx @@ -1,25 +1,21 @@ import { cn } from "@/lib/utils"; +import { formatLatency } from "@/lib/utils/metric-formatters"; import type { SentinelLogsResponse } from "@unkey/clickhouse/src/sentinel"; -import { Badge } from "@unkey/ui"; +import { Badge, InfoTooltip } from "@unkey/ui"; export const LatencyBadge = ({ log }: { log: SentinelLogsResponse }) => { const style = getLatencyStyle(log.total_latency); const tooltipText = `Total: ${log.total_latency}ms | Instance: ${log.instance_latency}ms | Sentinel: ${log.sentinel_latency}ms`; return ( - - {formatLatency(log.total_latency)} - + + + {formatLatency(log.total_latency)} + + ); }; -const formatLatency = (latency: number): string => { - return `${latency}ms`; -}; - /** * Get CSS classes for latency badge based on performance thresholds * - >500ms: error (red) diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/navigations/deployment-navbar.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/navigations/deployment-navbar.tsx new file mode 100644 index 0000000000..4afbe82896 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/navigations/deployment-navbar.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { QuickNavPopover } from "@/components/navbar-popover"; +import { Navbar } from "@/components/navigation/navbar"; +import { ChevronExpandY, Cloud } from "@unkey/icons"; +import { useDeploymentBreadcrumbConfig } from "./use-deployment-breadcrumb-config"; + +export function DeploymentNavbar() { + const breadcrumbs = useDeploymentBreadcrumbConfig(); + + return ( + + }> + {breadcrumbs + .filter((breadcrumb) => breadcrumb.shouldRender) + .map((breadcrumb) => ( + + {breadcrumb.quickNavConfig ? ( + +
+ {breadcrumb.children} + +
+
+ ) : ( + breadcrumb.children + )} +
+ ))} +
+
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/navigations/use-deployment-breadcrumb-config.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/navigations/use-deployment-breadcrumb-config.ts new file mode 100644 index 0000000000..52326594c1 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/navigations/use-deployment-breadcrumb-config.ts @@ -0,0 +1,115 @@ +"use client"; + +import type { QuickNavItem } from "@/components/navbar-popover"; +import type { Navbar } from "@/components/navigation/navbar"; +import { shortenId } from "@/lib/shorten-id"; +import { useParams, useSelectedLayoutSegments } from "next/navigation"; +import { useMemo } from "react"; +import type { ComponentPropsWithoutRef } from "react"; + +export type BreadcrumbItem = ComponentPropsWithoutRef & { + /** Unique identifier for the breadcrumb item */ + id: string; + /** Internal: determines if this breadcrumb should be rendered */ + shouldRender: boolean; + /** Optional QuickNav dropdown configuration */ + quickNavConfig?: { + items: QuickNavItem[]; + activeItemId?: string; + shortcutKey?: string; + }; +}; + +export function useDeploymentBreadcrumbConfig(): BreadcrumbItem[] { + const params = useParams(); + const segments = useSelectedLayoutSegments(); + + const workspaceSlug = params.workspaceSlug as string; + const projectId = params.projectId as string; + const deploymentId = params.deploymentId as string; + + // Detect current tab from segments + const currentTab = segments.includes("network") + ? "network" + : segments.includes("runtime-logs") + ? "runtime-logs" + : "overview"; + + return useMemo(() => { + const basePath = `/${workspaceSlug}/projects/${projectId}`; + + // Deployment tabs for QuickNav + const deploymentTabs: QuickNavItem[] = [ + { + id: "overview", + label: "Overview", + href: `${basePath}/deployments/${deploymentId}`, + }, + { + id: "runtime-logs", + label: "Runtime Logs", + href: `${basePath}/deployments/${deploymentId}/runtime-logs`, + }, + { + id: "network", + label: "Network", + href: `${basePath}/deployments/${deploymentId}/network`, + }, + ]; + + return [ + { + id: "projects", + href: `/${workspaceSlug}/projects`, + children: "Projects", + shouldRender: true, + active: false, + isLast: false, + }, + { + id: "project", + href: `${basePath}`, + children: projectId, + shouldRender: true, + active: false, + isLast: false, + }, + { + id: "deployments", + href: `${basePath}/deployments`, + children: "Deployments", + shouldRender: true, + active: false, + isLast: false, + }, + { + id: "deployment", + href: `${basePath}/deployments/${deploymentId}`, + children: shortenId(deploymentId), + isIdentifier: true, + shouldRender: true, + active: false, + isLast: false, + }, + { + id: "deployment-tab", + href: "#", + noop: true, + active: true, + children: + currentTab === "overview" + ? "Overview" + : currentTab === "runtime-logs" + ? "Runtime Logs" + : "Network", + shouldRender: true, + isLast: true, + quickNavConfig: { + items: deploymentTabs, + activeItemId: currentTab, + shortcutKey: "T", + }, + }, + ]; + }, [workspaceSlug, projectId, deploymentId, currentTab]); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/layout.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/layout.tsx new file mode 100644 index 0000000000..828fef9753 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/layout.tsx @@ -0,0 +1,14 @@ +import { DeploymentNavbar } from "./(overview)/navigations/deployment-navbar"; + +export default function DeploymentLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx index 1fd2c2e8d2..4d6305efe7 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx @@ -71,7 +71,7 @@ export function DeploymentNetworkView({ > setSelectedNode(node)} renderNode={(node, parent) => renderDeploymentNode(node, parent, deploymentId ?? undefined)} renderConnection={(path, parent, child) => ( diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/components/card-footer.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/components/card-footer.tsx index 7991bd6b14..9231d10985 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/components/card-footer.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/components/card-footer.tsx @@ -1,3 +1,5 @@ +import { RegionFlag } from "@/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flag"; +import { formatCpu, formatMemory } from "@/lib/utils/deployment-formatters"; import { Bolt, ChartActivity, Focus } from "@unkey/icons"; import type { SentinelNode } from "../types"; import { MetricPill } from "./metric-pill"; @@ -26,11 +28,7 @@ export function CardFooter(props: CardFooterProps) { return (
- {flagCode && ( -
- {flagCode} -
- )} + {flagCode && } {rps !== undefined && ( } @@ -62,40 +60,6 @@ export function CardFooter(props: CardFooterProps) { ); } -export function formatCpu(millicores: number): string { - if (millicores === 0) { - return "—"; - } - if (millicores === 256) { - return "1/4 vCPU"; - } - if (millicores === 512) { - return "1/2 vCPU"; - } - if (millicores === 768) { - return "3/4 vCPU"; - } - if (millicores === 1024) { - return "1 vCPU"; - } - - if (millicores >= 1024 && millicores % 1024 === 0) { - return `${millicores / 1024} vCPU`; - } - - return `${millicores}m vCPU`; -} - -export function formatMemory(mib: number): string { - if (mib === 0) { - return "—"; - } - if (mib >= 1024) { - return `${(mib / 1024).toFixed(mib % 1024 === 0 ? 0 : 1)} GiB`; - } - return `${mib} MiB`; -} - function formatRps(rps: number): string { return `${rps} RPS`; } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/sentinel-node.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/sentinel-node.tsx index 6916b8fe0b..0a88090633 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/sentinel-node.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/sentinel-node.tsx @@ -1,3 +1,4 @@ +import { RegionFlag } from "@/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flag"; import { trpc } from "@/lib/trpc/client"; import { InfoTooltip } from "@unkey/ui"; import { CardFooter } from "./components/card-footer"; @@ -40,9 +41,7 @@ export function SentinelNode({ node, deploymentId }: SentinelNodeProps) { className="px-2.5 py-1 rounded-[10px] bg-white dark:bg-blackA-12 text-xs z-30" position={{ align: "center", side: "top", sideOffset: 5 }} > -
- {flagCode} -
+ } title={node.label} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/node-details-panel.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/node-details-panel.tsx index bfd23157fa..7fa01b1bfd 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/node-details-panel.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/node-details-panel.tsx @@ -1,3 +1,4 @@ +import { RegionFlag } from "@/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flag"; import { ChartActivity, Dots, Layers3 } from "@unkey/icons"; import { Button, InfoTooltip } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; @@ -41,9 +42,7 @@ const SentinelNodeDetails = ({ className="px-2.5 py-1 rounded-[10px] bg-white dark:bg-blackA-12 text-xs z-30" position={{ align: "center", side: "top", sideOffset: 5 }} > -
- {flagCode} -
+ ), title: node.label, diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/node-details-panel/region-node/sentinel-instances.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/node-details-panel/region-node/sentinel-instances.tsx index 6d83c6fc37..97f3df2022 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/node-details-panel/region-node/sentinel-instances.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/node-details-panel/region-node/sentinel-instances.tsx @@ -1,6 +1,6 @@ +import { formatCpu, formatMemory } from "@/lib/utils/deployment-formatters"; import { Bolt, ChartActivity, CircleCheck, Focus, Heart, Layers2 } from "@unkey/icons"; import type { DeploymentNode } from "../../../nodes"; -import { formatCpu, formatMemory } from "../../../nodes/components/card-footer"; import { MetricPill } from "../../../nodes/components/metric-pill"; import { StatusIndicator } from "../../../nodes/status/status-indicator"; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/control-cloud/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/control-cloud/index.tsx new file mode 100644 index 0000000000..b42855f657 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/control-cloud/index.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { ControlCloud } from "@unkey/ui"; +import { format } from "date-fns"; +import { useRuntimeLogsFilters } from "../../hooks/use-runtime-logs-filters"; + +const formatFieldName = (field: string): string => { + switch (field) { + case "startTime": + return "Start time"; + case "endTime": + return "End time"; + case "severity": + return "Severity"; + case "message": + return "Message"; + case "since": + return ""; + default: + return field.charAt(0).toUpperCase() + field.slice(1); + } +}; + +const formatValue = (value: string | number, field: string): string => { + if (typeof value === "number" && (field === "startTime" || field === "endTime")) { + return format(value, "MMM d, yyyy HH:mm:ss"); + } + if (field === "severity") { + return value.toString().toUpperCase(); + } + return String(value); +}; + +export function RuntimeLogsControlCloud() { + const { filters, removeFilter, updateFilters } = useRuntimeLogsFilters(); + + return ( + + ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-datetime/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-datetime/index.tsx new file mode 100644 index 0000000000..6810409ae7 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-datetime/index.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { DatetimePopover } from "@/components/logs/datetime/datetime-popover"; +import { cn } from "@/lib/utils"; +import { Calendar } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useEffect, useState } from "react"; +import { useRuntimeLogsFilters } from "../../../../hooks/use-runtime-logs-filters"; + +export function RuntimeLogsDateTime() { + const [title, setTitle] = useState(null); + const { filters, updateFilters } = useRuntimeLogsFilters(); + + useEffect(() => { + if (!title) { + setTitle("Last 6 hours"); + } + }, [title]); + + const timeValues = filters + .filter((f) => ["startTime", "endTime", "since"].includes(f.field)) + .reduce( + (acc, f) => { + acc[f.field] = f.value; + return acc; + }, + {} as Record, + ); + + return ( + { + const nonTimeFilters = filters.filter( + (f) => !["since", "startTime", "endTime"].includes(f.field), + ); + const newFilters = [...nonTimeFilters]; + + if (since !== undefined) { + newFilters.push({ + id: crypto.randomUUID(), + field: "since" as const, + operator: "is" as const, + value: since, + }); + } else if (startTime) { + newFilters.push({ + id: crypto.randomUUID(), + field: "startTime" as const, + operator: "is" as const, + value: startTime, + }); + if (endTime) { + newFilters.push({ + id: crypto.randomUUID(), + field: "endTime" as const, + operator: "is" as const, + value: endTime, + }); + } + } + + updateFilters(newFilters); + }} + initialTitle={title ?? ""} + onSuggestionChange={setTitle} + > +
+ +
+
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-filters/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-filters/index.tsx new file mode 100644 index 0000000000..790102b862 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-filters/index.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { type FilterItemConfig, FiltersPopover } from "@/components/logs/checkbox/filters-popover"; +import { BarsFilter } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useRuntimeLogsFilters } from "../../../../hooks/use-runtime-logs-filters"; +import { RuntimeLogsMessageFilter } from "./runtime-logs-message-filter"; +import { RuntimeLogsSeverityFilter } from "./runtime-logs-severity-filter"; + +const FILTER_ITEMS: FilterItemConfig[] = [ + { + id: "severity", + label: "Severity", + shortcut: "S", + shortcutLabel: "S", + component: , + }, + { + id: "message", + label: "Message", + shortcut: "M", + shortcutLabel: "M", + component: , + }, +]; + +export function RuntimeLogsFilters() { + const { filters } = useRuntimeLogsFilters(); + + const filterCount = filters.filter((f) => f.field === "severity" || f.field === "message").length; + + return ( + +
+ +
+
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-filters/runtime-logs-message-filter.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-filters/runtime-logs-message-filter.tsx new file mode 100644 index 0000000000..af2ca9b0be --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-filters/runtime-logs-message-filter.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; +import { useRuntimeLogsFilters } from "../../../../hooks/use-runtime-logs-filters"; + +const OPTIONS = [{ id: "contains" as const, label: "contains" }]; + +export const RuntimeLogsMessageFilter = () => { + const { filters, updateFilters } = useRuntimeLogsFilters(); + + const messageFilter = filters.find((f) => f.field === "message"); + const defaultText = messageFilter ? String(messageFilter.value) : ""; + + const handleApply = (_operator: string, text: string) => { + const otherFilters = filters.filter((f) => f.field !== "message"); + const newFilters = text + ? [ + ...otherFilters, + { + id: crypto.randomUUID(), + field: "message" as const, + operator: "contains" as const, + value: text, + }, + ] + : otherFilters; + updateFilters(newFilters); + }; + + return ( + + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-filters/runtime-logs-severity-filter.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-filters/runtime-logs-severity-filter.tsx new file mode 100644 index 0000000000..891ac5e58e --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-filters/runtime-logs-severity-filter.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; +import { useRuntimeLogsFilters } from "../../../../hooks/use-runtime-logs-filters"; + +type SeverityOption = { + id: number; + severity: string; + display: string; + label: string; + color: string; + checked: boolean; +}; + +const options: SeverityOption[] = [ + { + id: 1, + severity: "error", + display: "ERROR", + label: "Error", + color: "bg-error-9", + checked: false, + }, + { + id: 2, + severity: "warn", + display: "WARN", + label: "Warning", + color: "bg-warning-8", + checked: false, + }, + { + id: 3, + display: "INFO", + severity: "info", + label: "Info", + color: "bg-info-9", + checked: false, + }, + { + id: 4, + severity: "debug", + display: "DEBUG", + label: "Debug", + color: "bg-grayA-9", + checked: false, + }, +]; + +export function RuntimeLogsSeverityFilter() { + const { filters, updateFilters } = useRuntimeLogsFilters(); + + return ( + ( + <> +
+ {checkbox.label} + + )} + createFilterValue={(option) => ({ + value: option.severity, + metadata: { + colorClass: option.color, + label: option.label, + }, + })} + filters={filters} + updateFilters={updateFilters} + /> + ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-refresh.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-refresh.tsx new file mode 100644 index 0000000000..454bdb4cbb --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-refresh.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { useQueryTime } from "@/providers/query-time-provider"; +import { RefreshButton } from "@unkey/ui"; + +export function RuntimeLogsRefresh() { + // const { toggleLive, isLive } = useRuntimeLogs(); + const { refreshQueryTime } = useQueryTime(); + const { + deploy: { runtimeLogs }, + } = trpc.useUtils(); + + const handleRefresh = () => { + refreshQueryTime(); + runtimeLogs.query.invalidate(); + }; + + return ; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-search/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-search/index.tsx new file mode 100644 index 0000000000..229e241cd5 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/components/runtime-logs-search/index.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { LLMSearch, toast, transformStructuredOutputToFilters } from "@unkey/ui"; +import { useRuntimeLogsFilters } from "../../../../hooks/use-runtime-logs-filters"; + +export const RuntimeLogsSearch = () => { + const { filters, updateFilters } = useRuntimeLogsFilters(); + const queryLLMForStructuredOutput = trpc.deploy.runtimeLogs.llmSearch.useMutation({ + onSuccess(data) { + const typedData = data as + | { + filters: Array<{ + field: string; + filters: Array<{ operator: string; value: string | number }>; + }>; + } + | undefined; + + if (!typedData || typedData.filters.length === 0) { + toast.error( + "Please provide more specific search criteria. Your query requires additional details for accurate results.", + { + duration: 8000, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + }, + ); + return; + } + const transformedFilters = transformStructuredOutputToFilters( + typedData, + filters.filter((f) => f.field !== "message"), + ) as typeof filters; + updateFilters(transformedFilters); + }, + onError(error) { + const errorMessage = `Unable to process your search request${ + error.message ? `' ${error.message} '` : "." + } Please try again or refine your search criteria.`; + + toast.error(errorMessage, { + duration: 8000, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + className: "font-medium", + }); + }, + }); + + return ( + + queryLLMForStructuredOutput.mutateAsync({ + query, + timestamp: Date.now(), + }) + } + /> + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/index.tsx new file mode 100644 index 0000000000..94dd3a8fcf --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/controls/index.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { + ControlsContainer, + ControlsLeft, + ControlsRight, +} from "@/components/logs/controls-container"; +import { RuntimeLogsDateTime } from "./components/runtime-logs-datetime"; +import { RuntimeLogsFilters } from "./components/runtime-logs-filters"; +import { RuntimeLogsRefresh } from "./components/runtime-logs-refresh"; +import { RuntimeLogsSearch } from "./components/runtime-logs-search"; + +export function RuntimeLogsControls() { + return ( + + + + + + + + + + + ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/hooks/use-runtime-logs-query.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/hooks/use-runtime-logs-query.ts new file mode 100644 index 0000000000..c490c9f482 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/hooks/use-runtime-logs-query.ts @@ -0,0 +1,59 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { useParams } from "next/navigation"; +import { useMemo } from "react"; +import type { RuntimeLogsFilter } from "../../../types"; + +type UseRuntimeLogsQueryParams = { + limit?: number; + filters: RuntimeLogsFilter[]; +}; + +export function useRuntimeLogsQuery({ limit = 50, filters }: UseRuntimeLogsQueryParams) { + const params = useParams<{ projectId: string; deploymentId: string }>(); + + // Transform filters to tRPC input format + const queryInput = useMemo(() => { + const severityFilters = filters + .filter((f) => f.field === "severity") + .map((f) => ({ operator: "is" as const, value: String(f.value) })); + + const messageFilter = filters.find((f) => f.field === "message"); + const startTimeFilter = filters.find((f) => f.field === "startTime"); + const endTimeFilter = filters.find((f) => f.field === "endTime"); + const sinceFilter = filters.find((f) => f.field === "since"); + + return { + projectId: params.projectId, + deploymentId: params.deploymentId, + limit, + startTime: startTimeFilter ? Number(startTimeFilter.value) : Date.now() - 6 * 60 * 60 * 1000, + endTime: endTimeFilter ? Number(endTimeFilter.value) : Date.now(), + since: sinceFilter ? String(sinceFilter.value) : "6h", + severity: severityFilters.length > 0 ? { filters: severityFilters } : null, + message: messageFilter ? String(messageFilter.value) : null, + }; + }, [filters, limit, params]); + + const { data, isLoading, error, hasNextPage, fetchNextPage, isFetchingNextPage } = + trpc.deploy.runtimeLogs.query.useInfiniteQuery(queryInput, { + getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextCursor : undefined), + }); + + const logs = useMemo(() => { + return data?.pages.flatMap((page) => page.logs) ?? []; + }, [data]); + + const total = data?.pages[0]?.total ?? 0; + + return { + logs, + total, + isLoading, + error, + hasMore: hasNextPage, + loadMore: fetchNextPage, + isLoadingMore: isFetchingNextPage, + }; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/runtime-log-details/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/runtime-log-details/index.tsx new file mode 100644 index 0000000000..82b6c39f67 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/runtime-log-details/index.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { LogDetails as SharedLogDetails } from "@/components/logs/details/log-details"; +import { LogSection } from "@/components/logs/details/log-details/components/log-section"; +import { TimestampInfo } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useRuntimeLogs } from "../../../context/runtime-logs-provider"; +import { RuntimeLogHeader } from "./runtime-log-header"; + +type Props = { + distanceToTop: number; +}; + +export function RuntimeLogDetails({ distanceToTop }: Props) { + const { setSelectedLog, selectedLog: log } = useRuntimeLogs(); + + if (!log) { + return null; + } + + const handleClose = () => { + setSelectedLog(null); + }; + + return ( + + + + + + + +
+ Time:{" "} + +
+
+ Severity:{" "} + {log.severity} +
+
+ Message:{" "} + {log.message} +
+
+ } + /> + + +
+ Deployment ID:{" "} + {log.deployment_id} +
+
+ Region:{" "} + {log.region} +
+
+ } + /> + + {log.attributes && ( + {JSON.stringify(log.attributes, null, 2)}} + /> + )} + + + ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/runtime-log-details/runtime-log-header.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/runtime-log-details/runtime-log-header.tsx new file mode 100644 index 0000000000..d01a72b98d --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/runtime-log-details/runtime-log-header.tsx @@ -0,0 +1,34 @@ +import { cn } from "@/lib/utils"; +import { XMark } from "@unkey/icons"; +import { Badge, Button } from "@unkey/ui"; +import type { RuntimeLog } from "../../../types"; + +type Props = { + log: RuntimeLog; + onClose: () => void; +}; + +export const RuntimeLogHeader = ({ onClose, log }: Props) => { + return ( +
+
+ + {log.severity} + +
+
+ +
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/runtime-logs-table.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/runtime-logs-table.tsx new file mode 100644 index 0000000000..f57121b84c --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/components/table/runtime-logs-table.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { VirtualTable } from "@/components/virtual-table/index"; +import type { Column } from "@/components/virtual-table/types"; +import { cn } from "@/lib/utils"; +import { Badge, Empty, TimestampInfo } from "@unkey/ui"; +import { useMemo } from "react"; +import { useRuntimeLogs } from "../../context/runtime-logs-provider"; +import { useRuntimeLogsFilters } from "../../hooks/use-runtime-logs-filters"; +import type { RuntimeLog } from "../../types"; +import { useRuntimeLogsQuery } from "./hooks/use-runtime-logs-query"; + +type StatusStyle = { + base: string; + hover: string; + selected: string; + badge: { + default: string; + selected: string; + }; + focusRing: string; +}; + +const STATUS_STYLES: Record<"success" | "warning" | "error", StatusStyle> = { + success: { + base: "text-grayA-9", + hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-grayA-3", + selected: "text-accent-12 bg-grayA-3 hover:text-accent-12", + badge: { + default: "bg-grayA-3 text-grayA-11 group-hover:bg-grayA-5", + selected: "bg-grayA-5 text-grayA-12 hover:bg-grayA-5", + }, + focusRing: "focus:ring-accent-7", + }, + warning: { + base: "text-warning-11 bg-warning-2", + hover: "hover:bg-warning-3", + selected: "bg-warning-3", + badge: { + default: "bg-warning-4 text-warning-11 group-hover:bg-warning-5", + selected: "bg-warning-5 text-warning-11 hover:bg-warning-5", + }, + focusRing: "focus:ring-warning-7", + }, + error: { + base: "text-error-11 bg-error-2", + hover: "hover:bg-error-3", + selected: "bg-error-3", + badge: { + default: "bg-error-4 text-error-11 group-hover:bg-error-5", + selected: "bg-error-5 text-error-11 hover:bg-error-5", + }, + focusRing: "focus:ring-error-7", + }, +}; + +const getSeverityStyle = (severity: string): StatusStyle => { + const upper = severity.toUpperCase(); + if (upper === "ERROR") { + return STATUS_STYLES.error; + } + if (upper === "WARN" || upper === "WARNING") { + return STATUS_STYLES.warning; + } + return STATUS_STYLES.success; +}; + +const getLogKey = (log: RuntimeLog): string => `${log.time}-${log.region}-${log.message}`; + +const getSelectedClassName = (log: RuntimeLog, isSelected: boolean): string => { + if (!isSelected) { + return ""; + } + return getSeverityStyle(log.severity).selected; +}; + +export function RuntimeLogsTable() { + const { filters } = useRuntimeLogsFilters(); + const { selectedLog, setSelectedLog } = useRuntimeLogs(); + const { logs, total, isLoading, hasMore, loadMore, isLoadingMore } = useRuntimeLogsQuery({ + filters, + }); + + const selectedLogKey = selectedLog ? getLogKey(selectedLog) : null; + + const columns: Column[] = useMemo( + () => [ + { + key: "time", + header: "Time", + width: "12%", + headerClassName: "pl-4", + render: (log) => ( +
+ +
+ ), + }, + { + key: "severity", + header: "Severity", + width: "10%", + render: (log) => { + const style = getSeverityStyle(log.severity); + const isSelected = selectedLogKey === getLogKey(log); + return ( + + {log.severity} + + ); + }, + }, + { + key: "region", + header: "Region", + width: "10%", + render: (log) => ( +
+ {log.region} +
+ ), + }, + { + key: "message", + header: "Message", + width: "20%", + render: (log) => ( +
+ {log.message} +
+ ), + }, + { + key: "attributes", + header: "Attributes", + width: "auto", + render: (log) => { + const attrStr = log.attributes ? JSON.stringify(log.attributes) : ""; + return ( +
+ {attrStr || "—"} +
+ ); + }, + }, + ], + [selectedLogKey], + ); + + const getRowClassName = (log: RuntimeLog): string => { + const style = getSeverityStyle(log.severity); + const isSelected = selectedLogKey === getLogKey(log); + + return cn( + style.base, + style.hover, + "group rounded-md", + "focus:outline-none focus:ring-1 focus:ring-opacity-40", + style.focusRing, + isSelected && style.selected, + selectedLogKey && { + "opacity-50 z-0": !isSelected, + "opacity-100 z-10": isSelected, + }, + ); + }; + + return ( + + Showing + {new Intl.NumberFormat().format(logs.length)} + of + {new Intl.NumberFormat().format(total)} + logs +
+ ), + }} + emptyState={ +
+ + + No runtime logs + + No logs found for the selected filters and time range. + + +
+ } + /> + ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/context/runtime-logs-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/context/runtime-logs-provider.tsx new file mode 100644 index 0000000000..24f16e5fd3 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/context/runtime-logs-provider.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { createContext, useContext, useState } from "react"; +import type { RuntimeLog } from "../types"; + +type RuntimeLogsContextType = { + selectedLog: RuntimeLog | null; + setSelectedLog: (log: RuntimeLog | null) => void; +}; + +const RuntimeLogsContext = createContext(undefined); + +export function RuntimeLogsProvider({ children }: { children: React.ReactNode }) { + const [selectedLog, setSelectedLog] = useState(null); + + return ( + + {children} + + ); +} + +export function useRuntimeLogs() { + const context = useContext(RuntimeLogsContext); + if (!context) { + throw new Error("useRuntimeLogs must be used within RuntimeLogsProvider"); + } + return context; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/hooks/use-runtime-logs-filters.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/hooks/use-runtime-logs-filters.ts new file mode 100644 index 0000000000..243518544a --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/hooks/use-runtime-logs-filters.ts @@ -0,0 +1,148 @@ +"use client"; + +import { + parseAsFilterValueArray, + parseAsRelativeTime, +} from "@/components/logs/validation/utils/nuqs-parsers"; +import { + type RuntimeLogsFilterField, + type RuntimeLogsFilterOperator, + type RuntimeLogsFilterUrlValue, + type RuntimeLogsFilterValue, + type RuntimeLogsQuerySearchParams, + runtimeLogsFilterFieldConfig, +} from "@/lib/schemas/runtime-logs.filter.schema"; +import { parseAsInteger, useQueryStates } from "nuqs"; +import { useCallback, useEffect, useMemo } from "react"; + +const parseAsFilterValArray = parseAsFilterValueArray([ + "is", + "contains", +]); + +export const queryParamsPayload = { + severity: parseAsFilterValArray, + message: parseAsFilterValArray, + startTime: parseAsInteger, + endTime: parseAsInteger, + since: parseAsRelativeTime, +} as const; + +export function useRuntimeLogsFilters() { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload, { + history: "push", + }); + + // Initialize default "6h" filter on mount if no time filter exists + useEffect(() => { + if ( + searchParams.since === null && + searchParams.startTime === null && + searchParams.endTime === null + ) { + setSearchParams({ since: "6h" }); + } + }, []); + + const filters = useMemo(() => { + const activeFilters: RuntimeLogsFilterValue[] = []; + + searchParams.severity?.forEach((severity) => { + activeFilters.push({ + id: crypto.randomUUID(), + field: "severity", + operator: severity.operator, + value: severity.value, + metadata: { + colorClass: runtimeLogsFilterFieldConfig.severity.getColorClass?.( + severity.value as string, + ), + }, + }); + }); + + searchParams.message?.forEach((msg) => { + activeFilters.push({ + id: crypto.randomUUID(), + field: "message", + operator: msg.operator, + value: msg.value, + }); + }); + + ["startTime", "endTime", "since"].forEach((field) => { + const value = searchParams[field as keyof RuntimeLogsQuerySearchParams]; + if (value !== null && value !== undefined) { + activeFilters.push({ + id: crypto.randomUUID(), + field: field as RuntimeLogsFilterField, + operator: "is", + value: value as string | number, + }); + } + }); + + return activeFilters; + }, [searchParams]); + + const updateFilters = useCallback( + (newFilters: RuntimeLogsFilterValue[]) => { + const newParams: Partial = { + severity: null, + message: null, + startTime: null, + endTime: null, + since: null, + }; + + // Group filters by field + const severityFilters: RuntimeLogsFilterUrlValue[] = []; + const messageFilters: RuntimeLogsFilterUrlValue[] = []; + + newFilters.forEach((filter) => { + switch (filter.field) { + case "severity": + severityFilters.push({ + value: filter.value, + operator: filter.operator, + }); + break; + case "message": + messageFilters.push({ + value: filter.value, + operator: filter.operator, + }); + break; + case "startTime": + case "endTime": + newParams[filter.field] = filter.value as number; + break; + case "since": + newParams.since = filter.value as string; + break; + } + }); + + // Set arrays to null when empty, otherwise use the filtered values + newParams.severity = severityFilters.length > 0 ? severityFilters : null; + newParams.message = messageFilters.length > 0 ? messageFilters : null; + + setSearchParams(newParams); + }, + [setSearchParams], + ); + + const removeFilter = useCallback( + (id: string) => { + const newFilters = filters.filter((f) => f.id !== id); + updateFilters(newFilters); + }, + [filters, updateFilters], + ); + + return { + filters, + removeFilter, + updateFilters, + }; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/page.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/page.tsx new file mode 100644 index 0000000000..cbe241b3c8 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useState } from "react"; +import { RuntimeLogsControlCloud } from "./components/control-cloud"; +import { RuntimeLogsControls } from "./components/controls"; +import { RuntimeLogDetails } from "./components/table/runtime-log-details"; +import { RuntimeLogsTable } from "./components/table/runtime-logs-table"; +import { RuntimeLogsProvider } from "./context/runtime-logs-provider"; + +export default function RuntimeLogsPage() { + const [tableDistanceToTop, setTableDistanceToTop] = useState(0); + + return ( + + +
{ + if (el) { + const rect = el.getBoundingClientRect(); + setTableDistanceToTop(rect.top); + } + }} + /> + + + + + ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/types.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/types.ts new file mode 100644 index 0000000000..8dd2f2b4da --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/runtime-logs/types.ts @@ -0,0 +1,9 @@ +import type { RuntimeLog } from "@/lib/schemas/runtime-logs.schema"; + +export type { RuntimeLog }; + +export type { + RuntimeLogsFilterField, + RuntimeLogsFilterOperator, + RuntimeLogsFilterValue as RuntimeLogsFilter, +} from "@/lib/schemas/runtime-logs.filter.schema"; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/deployments-list.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/deployments-list.tsx index 0924b19905..71240600b8 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/deployments-list.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/deployments-list.tsx @@ -4,6 +4,7 @@ import type { Column } from "@/components/virtual-table/types"; import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; import type { Deployment, Environment } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; +import { formatCpu, formatMemory } from "@/lib/utils/deployment-formatters"; import { BookBookmark, CodeBranch, Cube } from "@unkey/icons"; import { Loading } from "@unkey/ui"; import { Button, Empty, TimestampInfo } from "@unkey/ui"; @@ -133,30 +134,30 @@ export const DeploymentsList = () => { ); }, }, - { key: "status", header: "Status", - width: "12%", + width: "15%", render: ({ deployment }) => , }, { key: "domains", header: "Domains", - width: "20%", - render: ({ deployment }) => ( -
- -
- ), + width: "25%", + render: ({ deployment }) => { + return ( +
+ +
+ ); + }, }, { key: "instances" as const, header: "Instances", width: "10%", + headerClassName: "hidden 2xl:table-cell", + cellClassName: "hidden 2xl:table-cell", render: ({ deployment }: { deployment: Deployment }) => { return (
@@ -181,17 +182,15 @@ export const DeploymentsList = () => {
- - {deployment.cpuMillicores / 1024} + + {formatCpu(deployment.cpuMillicores)} - vCPU
/
- - {deployment.memoryMib} + + {formatMemory(deployment.memoryMib)} - MiB
@@ -201,8 +200,9 @@ export const DeploymentsList = () => { { key: "source", header: "Source", - width: "13%", - headerClassName: "pl-[18px]", + width: "15%", + headerClassName: "hidden 2xl:table-cell", + cellClassName: "hidden 2xl:table-cell", render: ({ deployment }) => { const isSelected = deployment.id === selectedDeployment?.deployment.id; const iconContainer = ( @@ -217,7 +217,7 @@ export const DeploymentsList = () => {
); return ( -
+
{iconContainer}
@@ -258,6 +258,8 @@ export const DeploymentsList = () => { key: "author" as const, header: "Author", width: "10%", + headerClassName: "hidden 2xl:table-cell", + cellClassName: "hidden 2xl:table-cell", render: ({ deployment }: { deployment: Deployment }) => { return (
@@ -342,12 +344,13 @@ export const DeploymentsList = () => { layoutMode: "grid", rowBorders: true, containerPadding: "px-0", + tableLayout: "auto", }} renderSkeletonRow={({ columns, rowHeight }) => columns.map((column) => ( {column.key === "deployment_id" && } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/components/deployment-logs-content.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/components/deployment-logs-content.tsx index 663966c63d..703b1f3e91 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/components/deployment-logs-content.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/components/deployment-logs-content.tsx @@ -8,6 +8,7 @@ import { format } from "date-fns"; import { useDeploymentLogs } from "../hooks/use-deployment-logs"; import { useDeploymentLogsContext } from "../providers/deployment-logs-provider"; import { FilterButton } from "./filter-button"; +import { RuntimeLogsContent } from "./runtime-logs-content"; const ANIMATION_STYLES = { expand: "transition-all duration-400 ease-in", @@ -15,12 +16,12 @@ const ANIMATION_STYLES = { } as const; type Props = { + projectId: string; deploymentId: string; - showBuildSteps: boolean; }; -export function DeploymentLogsContent({ deploymentId, showBuildSteps }: Props) { - const { isExpanded } = useDeploymentLogsContext(); +export function DeploymentLogsContent({ projectId, deploymentId }: Props) { + const { isExpanded, logType } = useDeploymentLogsContext(); const { logFilter, @@ -34,9 +35,12 @@ export function DeploymentLogsContent({ deploymentId, showBuildSteps }: Props) { scrollRef, } = useDeploymentLogs({ deploymentId, - showBuildSteps, }); + if (logType === "runtime") { + return ; + } + return (
{searchTerm ? `No logs match "${searchTerm}"` - : `No ${ - logFilter === "all" ? (showBuildSteps ? "build" : "sentinel") : logFilter - } logs available`} + : `No ${logFilter === "all" ? "sentinel" : logFilter} logs available`}
) : (
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/components/deployment-logs-trigger.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/components/deployment-logs-trigger.tsx index 9f5c9efa06..48f104d974 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/components/deployment-logs-trigger.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/components/deployment-logs-trigger.tsx @@ -5,17 +5,27 @@ import { Button } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import { useDeploymentLogsContext } from "../providers/deployment-logs-provider"; -type Props = { - showBuildSteps: boolean; -}; - -export function DeploymentLogsTrigger({ showBuildSteps }: Props) { - const { isExpanded, toggleExpanded } = useDeploymentLogsContext(); +export function DeploymentLogsTrigger() { + const { isExpanded, toggleExpanded, logType, setLogType } = useDeploymentLogsContext(); return ( - + | + + - +
); } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/components/runtime-logs-content.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/components/runtime-logs-content.tsx new file mode 100644 index 0000000000..a7348e062b --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/components/runtime-logs-content.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { TimestampInfo } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useRuntimeLogs } from "../hooks/use-runtime-logs"; +import { useDeploymentLogsContext } from "../providers/deployment-logs-provider"; + +const ANIMATION_STYLES = { + expand: "transition-all duration-400 ease-in", + slideIn: "transition-all duration-500 ease-out", +} as const; + +const SEVERITY_STYLES = { + ERROR: "bg-gradient-to-r from-errorA-3 to-errorA-1 text-errorA-12", + WARN: "bg-gradient-to-r from-warningA-3 to-warningA-1 text-warningA-12", + INFO: "text-grayA-12", + DEBUG: "text-grayA-9", +} as const; + +const SEVERITY_ABBR = { + ERROR: "ERR", + WARN: "WRN", + INFO: "INF", + DEBUG: "DBG", +} as const; + +type Props = { + projectId: string; + deploymentId: string; +}; + +export function RuntimeLogsContent({ projectId, deploymentId }: Props) { + const { isExpanded } = useDeploymentLogsContext(); + const { logs, isLoading } = useRuntimeLogs({ projectId, deploymentId }); + + return ( +
+
+
+ {isLoading && logs.length === 0 ? ( +
+ Loading runtime logs... +
+ ) : logs.length === 0 ? ( +
+ No runtime logs available +
+ ) : ( +
+ {logs.map((log, index) => { + const severity = log.severity.toUpperCase() as keyof typeof SEVERITY_STYLES; + const severityStyle = SEVERITY_STYLES[severity] ?? SEVERITY_STYLES.INFO; + + return ( +
+ + + + + [{SEVERITY_ABBR[severity] ?? "LOG"}] + + {log.region} + {log.message} + {JSON.stringify(log.attributes)} +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/hooks/use-deployment-logs.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/hooks/use-deployment-logs.tsx index 7b546e4a5c..a5a7653d00 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/hooks/use-deployment-logs.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/hooks/use-deployment-logs.tsx @@ -3,7 +3,7 @@ import { useQueryTime } from "@/providers/query-time-provider"; import { useEffect, useMemo, useRef, useState } from "react"; import { EXCLUDED_HOSTS } from "../../../sentinel-logs/constants"; -const BUILD_STEPS_REFETCH_INTERVAL = 500; +// const BUILD_STEPS_REFETCH_INTERVAL = 500; const GATEWAY_LOGS_REFETCH_INTERVAL = 2000; const GATEWAY_LOGS_LIMIT = 20; const GATEWAY_LOGS_SINCE = "1m"; @@ -13,7 +13,7 @@ const ERROR_STATUS_THRESHOLD = 500; const WARNING_STATUS_THRESHOLD = 400; type LogEntry = { - type: "build" | "sentinel"; + type: "sentinel"; id: string; timestamp: number; message: string; @@ -24,7 +24,6 @@ type LogFilter = "all" | "warnings" | "errors"; type UseDeploymentLogsProps = { deploymentId: string | null; - showBuildSteps: boolean; }; type UseDeploymentLogsReturn = { @@ -50,7 +49,6 @@ type UseDeploymentLogsReturn = { export function useDeploymentLogs({ deploymentId, - showBuildSteps, }: UseDeploymentLogsProps): UseDeploymentLogsReturn { const [logFilter, setLogFilter] = useState("all"); const [searchTerm, setSearchTerm] = useState(""); @@ -60,16 +58,16 @@ export function useDeploymentLogs({ const scrollRef = useRef(null) as React.MutableRefObject; const { queryTime: timestamp } = useQueryTime(); - const { data: buildData, isLoading: buildLoading } = trpc.deploy.deployment.buildSteps.useQuery( - { - // without this check TS yells at us - deploymentId: deploymentId ?? "", - }, - { - enabled: showBuildSteps && isExpanded && Boolean(deploymentId), - refetchInterval: BUILD_STEPS_REFETCH_INTERVAL, - }, - ); + // const { data: buildData, isLoading: buildLoading } = trpc.deploy.deployment.buildSteps.useQuery( + // { + // // without this check TS yells at us + // deploymentId: deploymentId ?? "", + // }, + // { + // enabled: showBuildSteps && isExpanded && Boolean(deploymentId), + // refetchInterval: BUILD_STEPS_REFETCH_INTERVAL, + // }, + // ); const { data: sentinelData, isLoading: sentinelLoading } = trpc.logs.queryLogs.useQuery( { @@ -84,31 +82,31 @@ export function useDeploymentLogs({ since: GATEWAY_LOGS_SINCE, }, { - enabled: !showBuildSteps && isExpanded, + enabled: isExpanded, refetchInterval: GATEWAY_LOGS_REFETCH_INTERVAL, refetchOnWindowFocus: false, }, ); - // Update stored logs when build data changes - useEffect(() => { - if (showBuildSteps && buildData?.logs) { - const logMap = new Map(); - buildData.logs.forEach((log) => { - logMap.set(log.id, { - type: "build", - id: log.id, - timestamp: log.timestamp, - message: log.message, - }); - }); - setStoredLogs(logMap); - } - }, [showBuildSteps, buildData]); + // // Update stored logs when build data changes + // useEffect(() => { + // if (showBuildSteps && buildData?.logs) { + // const logMap = new Map(); + // buildData.logs.forEach((log) => { + // logMap.set(log.id, { + // type: "build", + // id: log.id, + // timestamp: log.timestamp, + // message: log.message, + // }); + // }); + // setStoredLogs(logMap); + // } + // }, [showBuildSteps, buildData]); // Update stored logs when sentinel data changes useEffect(() => { - if (!showBuildSteps && sentinelData?.logs) { + if (sentinelData?.logs) { setStoredLogs((prev) => { const newMap = new Map(prev); @@ -136,7 +134,7 @@ export function useDeploymentLogs({ return new Map(sortedEntries); }); } - }, [showBuildSteps, sentinelData]); + }, [sentinelData]); const logs = useMemo(() => { return Array.from(storedLogs.values()).sort((a, b) => b.timestamp - a.timestamp); @@ -157,9 +155,9 @@ export function useDeploymentLogs({ let filtered = logs; if (logFilter === "warnings") { - filtered = logs.filter((log) => log.type === "build" || log.level === "warning"); + filtered = logs.filter((log) => log.level === "warning"); } else if (logFilter === "errors") { - filtered = logs.filter((log) => log.type === "build" || log.level === "error"); + filtered = logs.filter((log) => log.level === "error"); } if (searchTerm.trim()) { @@ -215,7 +213,7 @@ export function useDeploymentLogs({ showFade, filteredLogs, logCounts, - isLoading: showBuildSteps ? buildLoading : sentinelLoading, + isLoading: sentinelLoading, setLogFilter, setSearchTerm, setExpanded, diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/hooks/use-runtime-logs.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/hooks/use-runtime-logs.tsx new file mode 100644 index 0000000000..b90a0a9fe4 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/hooks/use-runtime-logs.tsx @@ -0,0 +1,46 @@ +import type { RuntimeLog } from "@/lib/schemas/runtime-logs.schema"; +import { trpc } from "@/lib/trpc/client"; +import { useQueryTime } from "@/providers/query-time-provider"; + +const RUNTIME_LOGS_REFETCH_INTERVAL = 2000; +const RUNTIME_LOGS_LIMIT = 10; +const RUNTIME_LOGS_SINCE = "6h"; + +type UseRuntimeLogsProps = { + projectId: string; + deploymentId: string; +}; + +type UseRuntimeLogsReturn = { + logs: RuntimeLog[]; + isLoading: boolean; +}; + +export function useRuntimeLogs({ + projectId, + deploymentId, +}: UseRuntimeLogsProps): UseRuntimeLogsReturn { + const { queryTime: timestamp } = useQueryTime(); + + const { data, isLoading } = trpc.deploy.runtimeLogs.query.useQuery( + { + projectId, + deploymentId, + limit: RUNTIME_LOGS_LIMIT, + startTime: timestamp, + endTime: timestamp, + since: RUNTIME_LOGS_SINCE, + severity: null, + message: null, + }, + { + refetchInterval: RUNTIME_LOGS_REFETCH_INTERVAL, + refetchOnWindowFocus: false, + }, + ); + + return { + logs: data?.logs ?? [], + isLoading, + }; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/providers/deployment-logs-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/providers/deployment-logs-provider.tsx index 45433d2b4b..34c07b0716 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/providers/deployment-logs-provider.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/providers/deployment-logs-provider.tsx @@ -7,17 +7,22 @@ type DeploymentLogsContextValue = { isExpanded: boolean; setExpanded: (expanded: boolean) => void; toggleExpanded: () => void; + logType: "sentinel" | "runtime"; + setLogType: (type: "sentinel" | "runtime") => void; }; const DeploymentLogsContext = createContext(null); export function DeploymentLogsProvider({ children }: { children: ReactNode }) { - const [isExpanded, setExpanded] = useState(false); + const [isExpanded, setExpanded] = useState(true); + const [logType, setLogType] = useState<"sentinel" | "runtime">("runtime"); const toggleExpanded = () => setExpanded((prev) => !prev); return ( - + {children} ); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/add-custom-domain.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/add-custom-domain.tsx similarity index 81% rename from web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/add-custom-domain.tsx rename to web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/add-custom-domain.tsx index a01be3e08c..a5e2d5d3c2 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/add-custom-domain.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/add-custom-domain.tsx @@ -121,7 +121,7 @@ export function AddCustomDomain({ }; return ( -
+
setDomain(extractDomain(e.target.value))} onKeyDown={handleKeyDown} - className={cn("min-h-[32px] text-sm flex-1", error && "border-red-6 focus:border-red-7")} + className={cn("h-8 text-xs flex-1 font-mono", error && "border-red-6 focus:border-red-7")} autoComplete="off" spellCheck={false} />
- - + +
+ + +
{error &&

{error}

}
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/custom-domain-row.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/custom-domain-row.tsx similarity index 97% rename from web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/custom-domain-row.tsx rename to web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/custom-domain-row.tsx index 79437114a9..c7e25db1ec 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/custom-domain-row.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/custom-domain-row.tsx @@ -111,9 +111,9 @@ export function CustomDomainRow({ domain, projectId, onDelete, onRetry }: Custom return (
-
+
- + @@ -161,12 +161,12 @@ export function CustomDomainRow({ domain, projectId, onDelete, onRetry }: Custom {deleteButtonRef.current && ( diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/hooks/use-custom-domains-manager.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/hooks/use-custom-domains-manager.ts similarity index 100% rename from web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/hooks/use-custom-domains-manager.ts rename to web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/hooks/use-custom-domains-manager.ts diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/index.tsx similarity index 56% rename from web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/index.tsx rename to web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/index.tsx index 6030628214..5c653b005b 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/index.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/index.tsx @@ -1,6 +1,7 @@ "use client"; -import { Plus } from "@unkey/icons"; +import { Link4, Plus } from "@unkey/icons"; import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; import { useState } from "react"; import { AddCustomDomain } from "./add-custom-domain"; import { CustomDomainRow, CustomDomainRowSkeleton } from "./custom-domain-row"; @@ -21,7 +22,12 @@ export function CustomDomainsSection({ projectId, environments }: CustomDomainsS const cancelAdding = () => setIsAddingNew(false); return ( -
+
{/* Domain list */}
{isLoading ? ( @@ -74,16 +80,39 @@ export function CustomDomainsSection({ projectId, environments }: CustomDomainsS function EmptyState({ onAdd, hasEnvironments }: { onAdd: () => void; hasEnvironments: boolean }) { return ( -
-

No custom domains configured

- {hasEnvironments ? ( - - ) : ( -

Create an environment first to add custom domains

- )} +
+
+ {/* Icon with subtle animation */} +
+
+
+ +
+
+ {/* Content */} +
+

No custom domains configured

+ {hasEnvironments ? ( +

+ Add a custom domain to serve your application from your own domain. +

+ ) : ( +

+ Create an environment first to add custom domains +

+ )} +
+ {/* Button */} + {hasEnvironments && ( + + )} +
); } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/types.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/types.ts similarity index 100% rename from web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/custom-domains-section/types.ts rename to web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/types.ts diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/add-env-vars.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/add-env-vars.tsx index 4c2d93f0c4..d9581c1616 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/add-env-vars.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/add-env-vars.tsx @@ -298,12 +298,18 @@ export function AddEnvVars({
-
- diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-save-actions.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-save-actions.tsx index fe253ef580..e13d59a5bc 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-save-actions.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-save-actions.tsx @@ -18,7 +18,7 @@ export const EnvVarSaveActions = ({ <> - {!isOnDeploymentDetail && ( - - - - )}
+ {!isOnDeploymentDetail && ( + + + + )}
); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/use-breadcrumb-config.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/use-breadcrumb-config.ts index 9854e2f289..7f110ebaf7 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/use-breadcrumb-config.ts +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/use-breadcrumb-config.ts @@ -74,14 +74,6 @@ export const useBreadcrumbConfig = ({ href: `${basePath}/${projectId}/sentinel-logs`, segment: "sentinel-logs", }, - { - id: "openapi-diff", - label: "OpenAPI Diff", - href: `${basePath}/${projectId}/openapi-diff`, - segment: "openapi-diff", - disabled: true, - disabledTooltip: "Coming soon", - }, ]; // Determine active subpage based on segment diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/components/skeleton.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/components/skeleton.tsx index be6081f499..788e68d87a 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/components/skeleton.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/components/skeleton.tsx @@ -55,8 +55,14 @@ export const ActiveDeploymentCardSkeleton = () => (
-
Build logs
- + | + +
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-status-badge.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-status-badge.tsx index 81309c0b8d..a0937f63bc 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-status-badge.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-status-badge.tsx @@ -1,43 +1,37 @@ -import { CircleCheck, CircleWarning } from "@unkey/icons"; +import { CircleWarning } from "@unkey/icons"; import { Badge } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; type DeploymentStatus = "pending" | "building" | "deploying" | "network" | "ready" | "failed"; type StatusConfig = { variant: "warning" | "success" | "error"; - icon: React.ComponentType; text: string; }; const STATUS_CONFIG: Record = { pending: { variant: "warning", - icon: CircleWarning, text: "Queued", }, building: { variant: "warning", - icon: CircleWarning, text: "Building", }, deploying: { variant: "warning", - icon: CircleWarning, text: "Deploying", }, network: { variant: "warning", - icon: CircleWarning, text: "Assigning Domains", }, ready: { variant: "success", - icon: CircleCheck, text: "Ready", }, failed: { variant: "error", - icon: CircleWarning, text: "Error", }, }; @@ -53,12 +47,11 @@ export const DeploymentStatusBadge = ({ status, className }: Props) => { } const config = STATUS_CONFIG[status]; - const Icon = config.icon; return ( - +
- + {config.text}
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flag.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flag.tsx new file mode 100644 index 0000000000..cca7e8193f --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flag.tsx @@ -0,0 +1,66 @@ +import type { FlagCode } from "@/lib/trpc/routers/deploy/network/utils"; +import { cn } from "@/lib/utils"; + +type RegionFlagSize = "xs" | "sm" | "md" | "lg"; +type RegionFlagShape = "rounded" | "circle"; + +type RegionFlagProps = { + flagCode: FlagCode; + size?: RegionFlagSize; + shape?: RegionFlagShape; + className?: string; +}; + +const sizeConfig = { + xs: { + container: "size-4", + flag: "size-4", + padding: "", + }, + sm: { + container: "size-[22px]", + flag: "size-4", + padding: "p-[3px]", + }, + md: { + container: "size-9", + flag: "size-4", + padding: "", + }, + lg: { + container: "size-12", + flag: "size-[22px]", + padding: "", + }, +}; + +const shapeClass = { + rounded: "rounded-[10px]", + circle: "rounded-full", +}; + +export function RegionFlag({ + flagCode, + size = "md", + shape = "rounded", + className, +}: RegionFlagProps) { + const config = sizeConfig[size]; + const hasExplicitPadding = config.padding !== ""; + + return ( +
+ {flagCode} +
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flags.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flags.tsx new file mode 100644 index 0000000000..6122b31ff9 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flags.tsx @@ -0,0 +1,16 @@ +import type { FlagCode } from "@/lib/trpc/routers/deploy/network/utils"; +import { RegionFlag } from "./region-flag"; + +type RegionFlagsProps = { + instances: { id: string; flagCode: FlagCode }[]; +}; + +export function RegionFlags({ instances }: RegionFlagsProps) { + return ( +
+ {instances.map((instance) => ( + + ))} +
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx index f97123a773..094d8e2145 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx @@ -1,6 +1,7 @@ "use client"; import { collection, collectionManager } from "@/lib/collections"; import { eq, useLiveQuery } from "@tanstack/react-db"; +import { usePathname } from "next/navigation"; import { use, useEffect, useState } from "react"; import { ProjectDetailsExpandable } from "./(overview)/details/project-details-expandables"; import { ProjectLayoutContext } from "./(overview)/layout-provider"; @@ -28,6 +29,10 @@ const ProjectLayout = ({ projectId, children }: ProjectLayoutProps) => { const [tableDistanceToTop, setTableDistanceToTop] = useState(0); const [isDetailsOpen, setIsDetailsOpen] = useState(false); + const pathname = usePathname(); + const isOnDeploymentDetail = + pathname?.includes("/deployments/") && pathname.split("/").filter(Boolean).length >= 5; // /workspace/projects/projectId/deployments/deploymentId/* + const collections = collectionManager.getProjectCollections(projectId); const projects = useLiveQuery((q) => @@ -35,17 +40,15 @@ const ProjectLayout = ({ projectId, children }: ProjectLayoutProps) => { ); const liveDeploymentId = projects.data.at(0)?.liveDeploymentId; - const lastestDeploymentId = projects.data.at(0)?.latestDeploymentId; - // We just wanna refetch domains as soon as lastestCommitTimestamp changes. - // We could use the liveDeploymentId for that but when user make `env=preview` this doesn't refetch properly. + // Refetch domains when live deployment changes to show domains for the currently active deployment. // biome-ignore lint/correctness/useExhaustiveDependencies: Read above. useEffect(() => { //@ts-expect-error Without this we can't refetch domains on-demand. It's either this or we do `refetchInternal` on domains collection level. // Second approach causing too any re-renders. This is fine because data is partitioned and centralized in this context. // Until they introduce a way to invalidate collections properly we stick to this. collections.domains.utils.refetch(); - }, [lastestDeploymentId]); + }, [liveDeploymentId]); return ( { }} >
- setIsDetailsOpen(!isDetailsOpen)} - isDetailsOpen={isDetailsOpen} - liveDeploymentId={liveDeploymentId} - onMount={setTableDistanceToTop} - /> + {!isOnDeploymentDetail && ( + setIsDetailsOpen(!isDetailsOpen)} + isDetailsOpen={isDetailsOpen} + liveDeploymentId={liveDeploymentId} + onMount={setTableDistanceToTop} + /> + )}
{children}
q.from({ project: collection.projects }).where(({ project }) => eq(project.id, projectId)), @@ -26,8 +26,8 @@ export default function ProjectDetails() { (q) => q .from({ domain: collections.domains }) - .where(({ domain }) => eq(domain.deploymentId, project?.liveDeploymentId)), - [project?.liveDeploymentId], + .where(({ domain }) => eq(domain.deploymentId, liveDeploymentId)), + [liveDeploymentId], ); const { data: environments } = useLiveQuery((q) => q.from({ env: collections.environments })); @@ -41,10 +41,6 @@ export default function ProjectDetails() { ); const deploymentStatus = deployment.data.at(0)?.status; - // If deployment status is not ready it means we gotta keep showing build steps. - // Then, user can switch between runtime(not implemented yet) and sentinel logs - const showBuildSteps = deploymentStatus !== "ready"; - return (
@@ -56,12 +52,12 @@ export default function ProjectDetails() { } - trailingContent={} + trailingContent={} expandableContent={ project?.liveDeploymentId ? ( ) : null } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/settings/root-keys/components/table/root-keys-list.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/settings/root-keys/components/table/root-keys-list.tsx index 03fe3c05db..a1d3338c6d 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/settings/root-keys/components/table/root-keys-list.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/settings/root-keys/components/table/root-keys-list.tsx @@ -156,6 +156,7 @@ export const RootKeysList = () => { className={cn( "text-xs align-middle whitespace-nowrap", column.key === "root_key" ? "py-[6px]" : "py-1", + column.cellClassName, )} style={{ height: `${rowHeight}px` }} > diff --git a/web/apps/dashboard/components/logs/details/log-details/index.tsx b/web/apps/dashboard/components/logs/details/log-details/index.tsx index 72e8a6d2e8..5835f026af 100644 --- a/web/apps/dashboard/components/logs/details/log-details/index.tsx +++ b/web/apps/dashboard/components/logs/details/log-details/index.tsx @@ -1,6 +1,7 @@ "use client"; import { extractResponseField, safeParseJson } from "@/app/(app)/[workspaceSlug]/logs/utils"; import { ResizablePanel } from "@/components/logs/details/resizable-panel"; +import type { RuntimeLog } from "@/lib/schemas/runtime-logs.schema"; import type { AuditLog } from "@/lib/trpc/routers/audit/schema"; import { cn } from "@/lib/utils"; import type { KeysOverviewLog } from "@unkey/clickhouse/src/keys/keys"; @@ -22,7 +23,7 @@ const createPanelStyle = (distanceToTop: number) => ({ }); export type StandardLogTypes = Log | RatelimitLog; -export type SupportedLogTypes = StandardLogTypes | KeysOverviewLog | AuditLog; +export type SupportedLogTypes = StandardLogTypes | KeysOverviewLog | AuditLog | RuntimeLog; type LogDetailsContextValue = { animated: boolean; @@ -97,6 +98,10 @@ const isStandardLog = (log: SupportedLogTypes): log is Log | RatelimitLog => { return "request_headers" in log && "response_headers" in log; }; +// const isRuntimeLog = (log: SupportedLogTypes): log is RuntimeLog => { +// return "deployment_id" in log +// }; + // Main LogDetails component type LogDetailsProps = { distanceToTop: number; diff --git a/web/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx b/web/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx index b6dfb18a55..8600f1676a 100644 --- a/web/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx +++ b/web/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx @@ -23,7 +23,7 @@ export const NestedNavItem = ({ item, onLoadMore, depth = 0, - maxDepth = 1, + maxDepth = 2, isSubItem = false, className, }: NavProps & { diff --git a/web/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx b/web/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx index 084017181a..92985a0df7 100644 --- a/web/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx +++ b/web/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx @@ -3,7 +3,7 @@ import type { NavItem } from "@/components/navigation/sidebar/workspace-navigati import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; import { collection } from "@/lib/collections"; import { useLiveQuery } from "@tanstack/react-db"; -import { Cloud, Connections, Gear, GridCircle, Layers3 } from "@unkey/icons"; +import { Cloud, Connections, GridCircle, Layers3 } from "@unkey/icons"; import { useSelectedLayoutSegments } from "next/navigation"; import { useMemo } from "react"; @@ -32,6 +32,39 @@ export const useProjectNavigation = (baseNavItems: NavItem[]) => { const currentSubRoute = segments.at(subRouteIndex); + // Detect if viewing deployment detail page + const deploymentIdIndex = subRouteIndex + 1; + const deploymentTabIndex = subRouteIndex + 2; + const isOnDeploymentDetail = Boolean( + currentProjectActive && currentSubRoute === "deployments" && segments.at(deploymentIdIndex), + ); + const deploymentId = segments.at(deploymentIdIndex) as string | undefined; + const currentDeploymentTab = segments.at(deploymentTabIndex); + + // deployment tab sub-items if viewing deployment detail + const deploymentTabItems: NavItem[] | undefined = isOnDeploymentDetail + ? [ + { + icon: GridCircle, + href: `${basePath}/${project.id}/deployments/${deploymentId}`, + label: "Overview", + active: !currentDeploymentTab || currentDeploymentTab === "overview", + }, + { + icon: Layers3, + href: `${basePath}/${project.id}/deployments/${deploymentId}/runtime-logs`, + label: "Runtime Logs", + active: currentDeploymentTab === "runtime-logs", + }, + { + icon: Connections, + href: `${basePath}/${project.id}/deployments/${deploymentId}/network`, + label: "Network", + active: currentDeploymentTab === "network", + }, + ] + : undefined; + // Create sub-items const subItems: NavItem[] = [ { @@ -45,6 +78,7 @@ export const useProjectNavigation = (baseNavItems: NavItem[]) => { href: `${basePath}/${project.id}/deployments`, label: "Deployments", active: currentProjectActive && currentSubRoute === "deployments", + ...(deploymentTabItems && { items: deploymentTabItems }), }, { icon: Layers3, @@ -52,18 +86,6 @@ export const useProjectNavigation = (baseNavItems: NavItem[]) => { label: "Sentinel Logs", active: currentProjectActive && currentSubRoute === "sentinel-logs", }, - { - icon: Connections, - href: `${basePath}/${project.id}/openapi-diff`, - label: "Open API Diff", - active: currentProjectActive && currentSubRoute === "openapi-diff", - }, - { - icon: Gear, - href: `${basePath}/${project.id}/settings`, - label: "Settings", - active: currentProjectActive && currentSubRoute === "settings", - }, ]; const projectNavItem: NavItem = { diff --git a/web/apps/dashboard/components/virtual-table/index.tsx b/web/apps/dashboard/components/virtual-table/index.tsx index eb2f51cfef..df67c95029 100644 --- a/web/apps/dashboard/components/virtual-table/index.tsx +++ b/web/apps/dashboard/components/virtual-table/index.tsx @@ -11,6 +11,7 @@ import { useVirtualData } from "./hooks/useVirtualData"; import type { Column, SeparatorItem, SortDirection, VirtualTableProps } from "./types"; const MOBILE_TABLE_HEIGHT = 400; + const calculateTableLayout = (columns: Column[]) => { return columns.map((column) => { let width = "auto"; @@ -87,15 +88,15 @@ export const VirtualTable = forwardRef>( const tableClassName = cn( "w-full", isGridLayout ? "border-collapse" : "border-separate border-spacing-0", - "table-fixed", // Add fixed table layout for proper column width control + "table-auto xl:table-fixed", + config.tableLayout === "fixed" ? "!table-fixed" : "!table-auto", ); const containerClassName = cn( "overflow-auto relative pb-4 bg-white dark:bg-black ", - config.containerPadding || "px-2", // Default to px-2 if containerPadding is not specified + config.containerPadding || "px-2", ); - // Expose refs and methods to parent components. Primarily used for anchoring log details. // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable and shouldn't be in deps useImperativeHandle( ref, @@ -116,6 +117,11 @@ export const VirtualTable = forwardRef>( ref={containerRef} > + + {columns.map((_, idx) => ( + + ))} + {columns.map((column) => ( @@ -124,6 +130,7 @@ export const VirtualTable = forwardRef>( className={cn( "text-sm font-medium text-accent-12 py-1 text-left", column.headerClassName, + column.cellClassName, )} >
{column.header}
@@ -155,9 +162,8 @@ export const VirtualTable = forwardRef>( >
- {colWidths.map((col, idx) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: - + {columns.map((_, idx) => ( + ))} @@ -168,6 +174,7 @@ export const VirtualTable = forwardRef>( className={cn( "text-sm font-medium text-accent-12 py-1 text-left relative", column.headerClassName, + column.cellClassName, )} > @@ -218,7 +225,7 @@ export const VirtualTable = forwardRef>( style={{ height: `${config.rowHeight}px` }} > {columns.map((column) => ( - ))} @@ -254,7 +261,6 @@ export const VirtualTable = forwardRef>( : false; if (isGridLayout) { - // Grid layout: single row with optional border return ( >( "text-xs align-middle whitespace-nowrap pr-4", idx === 0 ? "rounded-l-md" : "", idx === columns.length - 1 ? "rounded-r-md" : "", + column.cellClassName, )} > {column.render(typedItem)} @@ -315,7 +322,7 @@ export const VirtualTable = forwardRef>( ); } - // Classic layout: fragment with configurable spacer row + return ( {(config.rowSpacing ?? 4) > 0 && ( @@ -362,7 +369,7 @@ export const VirtualTable = forwardRef>( }} className={cn( "cursor-pointer transition-colors hover:bg-accent/50 focus:outline-none focus:ring-1 focus:ring-opacity-40", - config.rowBorders && "border-b border-gray-4", // Still allow borders in classic mode + config.rowBorders && "border-b border-gray-4", rowClassName?.(typedItem), selectedClassName?.(typedItem, isSelected), )} @@ -375,6 +382,7 @@ export const VirtualTable = forwardRef>( "text-xs align-middle whitespace-nowrap pr-4", idx === 0 ? "rounded-l-md" : "", idx === columns.length - 1 ? "rounded-r-md" : "", + column.cellClassName, )} > {column.render(typedItem)} @@ -395,7 +403,6 @@ export const VirtualTable = forwardRef>( />
+
- {/* Without this check bottom status section blinks in the UI and disappears */} {loadMoreFooterProps && ( = { header?: string; width: ColumnWidth; headerClassName?: string; + cellClassName?: string; render: (item: TTableData) => React.ReactNode; sort?: { sortable?: boolean; @@ -31,6 +32,7 @@ export interface TableConfig { overscan: number; throttleDelay: number; headerHeight: number; + tableLayout?: "fixed" | "auto"; // Layout options layoutMode?: TableLayoutMode; // 'classic' or 'grid' diff --git a/web/apps/dashboard/lib/collections/deploy/deployments.ts b/web/apps/dashboard/lib/collections/deploy/deployments.ts index 92a4a59837..c6ba3c7bad 100644 --- a/web/apps/dashboard/lib/collections/deploy/deployments.ts +++ b/web/apps/dashboard/lib/collections/deploy/deployments.ts @@ -1,4 +1,5 @@ "use client"; +import { flagCodes } from "@/lib/trpc/routers/deploy/network/utils"; import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { createCollection } from "@tanstack/react-db"; import { z } from "zod"; @@ -21,6 +22,8 @@ const schema = z.object({ instances: z.array( z.object({ id: z.string(), + region: z.string(), + flagCode: z.enum(flagCodes), }), ), cpuMillicores: z.number().int(), diff --git a/web/apps/dashboard/lib/schemas/runtime-logs.filter.schema.ts b/web/apps/dashboard/lib/schemas/runtime-logs.filter.schema.ts new file mode 100644 index 0000000000..5c39485a80 --- /dev/null +++ b/web/apps/dashboard/lib/schemas/runtime-logs.filter.schema.ts @@ -0,0 +1,84 @@ +import type { + FilterValue, + NumberConfig, + StringConfig, +} from "@/components/logs/validation/filter.types"; +import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; +import { z } from "zod"; + +// Configuration +export const runtimeLogsFilterFieldConfig: RuntimeLogsFilterFieldConfigs = { + severity: { + type: "string", + operators: ["is"], + validValues: ["ERROR", "WARN", "INFO", "DEBUG"] as const, + getColorClass: (value) => { + const colors: Record = { + ERROR: "text-error-11 bg-error-9", + WARN: "text-warning-11 bg-warning-8", + INFO: "text-info-11 bg-info-9", + DEBUG: "text-grayA-9 bg-grayA-9", + }; + return colors[value.toUpperCase()] || colors.DEBUG; + }, + }, + message: { + type: "string", + operators: ["is", "contains"], + }, + startTime: { + type: "number", + operators: ["is"], + }, + endTime: { + type: "number", + operators: ["is"], + }, + since: { + type: "string", + operators: ["is"], + }, +} as const; + +// Schemas +export const runtimeLogsFilterOperatorEnum = z.enum(["is", "contains"]); + +export const runtimeLogsFilterFieldEnum = z.enum([ + "severity", + "message", + "startTime", + "endTime", + "since", +]); + +export const runtimeLogsFilterOutputSchema = createFilterOutputSchema( + runtimeLogsFilterFieldEnum, + runtimeLogsFilterOperatorEnum, + runtimeLogsFilterFieldConfig, +); + +// Types +export type RuntimeLogsFilterOperator = z.infer; +export type RuntimeLogsFilterField = z.infer; + +export type RuntimeLogsFilterFieldConfigs = { + severity: StringConfig; + message: StringConfig; + startTime: NumberConfig; + endTime: NumberConfig; + since: StringConfig; +}; + +export type RuntimeLogsFilterUrlValue = Pick< + FilterValue, + "value" | "operator" +>; +export type RuntimeLogsFilterValue = FilterValue; + +export type RuntimeLogsQuerySearchParams = { + severity: RuntimeLogsFilterUrlValue[] | null; + message: RuntimeLogsFilterUrlValue[] | null; + startTime?: number | null; + endTime?: number | null; + since?: string | null; +}; diff --git a/web/apps/dashboard/lib/schemas/runtime-logs.schema.ts b/web/apps/dashboard/lib/schemas/runtime-logs.schema.ts new file mode 100644 index 0000000000..6373265fe4 --- /dev/null +++ b/web/apps/dashboard/lib/schemas/runtime-logs.schema.ts @@ -0,0 +1,36 @@ +import { runtimeLog } from "@unkey/clickhouse/src/runtime-logs"; +import { z } from "zod"; + +export type RuntimeLog = z.infer; + +export const runtimeLogsRequestSchema = z.object({ + projectId: z.string(), + deploymentId: z.string(), + limit: z.int(), + startTime: z.int(), + endTime: z.int(), + since: z.string(), + severity: z + .object({ + filters: z.array( + z.object({ + operator: z.literal("is"), + value: z.string(), + }), + ), + }) + .nullable(), + message: z.string().nullable(), + cursor: z.number().nullable().optional(), +}); + +export type RuntimeLogsRequestSchema = z.infer; + +export const runtimeLogsResponseSchema = z.object({ + logs: z.array(runtimeLog), + hasMore: z.boolean(), + total: z.number(), + nextCursor: z.int().optional(), +}); + +export type RuntimeLogsResponseSchema = z.infer; diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/deployment/list.ts b/web/apps/dashboard/lib/trpc/routers/deploy/deployment/list.ts index 108d9765f0..93898425ec 100644 --- a/web/apps/dashboard/lib/trpc/routers/deploy/deployment/list.ts +++ b/web/apps/dashboard/lib/trpc/routers/deploy/deployment/list.ts @@ -2,6 +2,7 @@ import { db } from "@/lib/db"; import { workspaceProcedure } from "@/lib/trpc/trpc"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import { mapRegionToFlag } from "../network/utils"; export const listDeployments = workspaceProcedure .input(z.object({ projectId: z.string() })) @@ -30,6 +31,7 @@ export const listDeployments = workspaceProcedure instances: { columns: { id: true, + region: true, }, }, }, @@ -39,6 +41,7 @@ export const listDeployments = workspaceProcedure return deployments.map(({ openapiSpec, ...deployment }) => ({ ...deployment, + instances: deployment.instances.map((i) => ({ ...i, flagCode: mapRegionToFlag(i.region) })), gitBranch: deployment.gitBranch ?? "main", gitCommitAuthorAvatarUrl: deployment.gitCommitAuthorAvatarUrl ?? "https://github.com/identicons/dummy-user.png", diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/network/utils.ts b/web/apps/dashboard/lib/trpc/routers/deploy/network/utils.ts index c24163aa95..2e1a8f16a9 100644 --- a/web/apps/dashboard/lib/trpc/routers/deploy/network/utils.ts +++ b/web/apps/dashboard/lib/trpc/routers/deploy/network/utils.ts @@ -1,7 +1,8 @@ import type { HealthStatus } from "@/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/types"; import type { Instance, Sentinel } from "@/lib/db"; -type FlagCode = "us" | "de" | "au" | "jp" | "in" | "br"; +export const flagCodes = ["us", "de", "au", "jp", "in", "br"] as const; +export type FlagCode = (typeof flagCodes)[number]; export function mapInstanceStatusToHealth(status: Instance["status"]): HealthStatus { switch (status) { diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/llm-search/index.ts b/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/llm-search/index.ts new file mode 100644 index 0000000000..c65e45a96e --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/llm-search/index.ts @@ -0,0 +1,18 @@ +import { env } from "@/lib/env"; +import { withLlmAccess, workspaceProcedure } from "@/lib/trpc/trpc"; +import OpenAI from "openai"; +import { z } from "zod"; +import { getStructuredSearchFromLLM } from "./utils"; + +const openai = env().OPENAI_API_KEY + ? new OpenAI({ + apiKey: env().OPENAI_API_KEY, + }) + : null; + +export const llmSearch = workspaceProcedure + .use(withLlmAccess()) + .input(z.object({ query: z.string(), timestamp: z.number() })) + .mutation(async ({ input, ctx }) => { + return await getStructuredSearchFromLLM(openai, ctx.validatedQuery, input.timestamp); + }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/llm-search/utils.ts b/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/llm-search/utils.ts new file mode 100644 index 0000000000..fc955cf627 --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/llm-search/utils.ts @@ -0,0 +1,340 @@ +import { + runtimeLogsFilterFieldConfig, + runtimeLogsFilterOutputSchema, +} from "@/lib/schemas/runtime-logs.filter.schema"; +import { TRPCError } from "@trpc/server"; +import type OpenAI from "openai"; +import z from "zod"; + +export async function getStructuredSearchFromLLM( + openai: OpenAI | null, + userSearchMsg: string, + usersReferenceMS: number, +) { + try { + if (!openai) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "OpenAI isn't configured correctly, please check your API key", + }); + } + const completion = await openai.beta.chat.completions.parse({ + model: "gpt-5-mini-2025-08-07", + n: 1, + messages: [ + { + role: "system", + content: getSystemPrompt(usersReferenceMS), + }, + { + role: "user", + content: userSearchMsg, + }, + ], + response_format: { + type: "json_schema", + json_schema: { + name: "runtime-logs-ai-search", + strict: true, + schema: z.toJSONSchema(runtimeLogsFilterOutputSchema, { target: "draft-7" }), + }, + }, + }); + + if (!completion.choices[0].message.parsed) { + throw new TRPCError({ + code: "UNPROCESSABLE_CONTENT", + message: + "Try phrases like:\n" + + "• 'errors in the last hour'\n" + + "• 'warnings containing timeout'\n" + + "• 'debug logs from yesterday'\n" + + "For help, contact support@unkey.dev", + }); + } + + return completion.choices[0].message.parsed; + } catch (error) { + console.error( + `Something went wrong when querying OpenAI. Input: ${JSON.stringify( + userSearchMsg, + )}\n Output ${(error as Error).message}}`, + ); + if (error instanceof TRPCError) { + throw error; + } + + if ((error as { response: { status: number } }).response?.status === 429) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Search rate limit exceeded. Please try again in a few minutes.", + }); + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to process your search query. Please try again or contact support@unkey.dev if the issue persists.", + }); + } +} + +export const getSystemPrompt = (usersReferenceMS: number) => { + const operatorsByField = Object.entries(runtimeLogsFilterFieldConfig) + .map(([field, config]) => { + const operators = config.operators.join(", "); + let constraints = ""; + if (field === "severity") { + constraints = " and must be one of: ERROR, WARN, INFO, DEBUG"; + } + return `- ${field} accepts ${operators} operator${ + config.operators.length > 1 ? "s" : "" + }${constraints}`; + }) + .join("\n"); + + return `You are an expert at converting natural language queries into filters for runtime container logs. Handle complex queries by breaking them into clear filters. Use ${usersReferenceMS} timestamp for time-related queries. + +Examples: + +# Severity Filtering +Query: "show errors in the last hour" +Result: [ + { + field: "severity", + filters: [ + { operator: "is", value: "ERROR" } + ] + }, + { + field: "since", + filters: [{ + operator: "is", + value: "1h" + }] + } +] + +Query: "warnings and errors from yesterday" +Result: [ + { + field: "severity", + filters: [ + { operator: "is", value: "WARN" }, + { operator: "is", value: "ERROR" } + ] + }, + { + field: "since", + filters: [{ + operator: "is", + value: "1d" + }] + } +] + +# Message Filtering +Query: "show warnings containing 'timeout'" +Result: [ + { + field: "severity", + filters: [ + { operator: "is", value: "WARN" } + ] + }, + { + field: "message", + filters: [ + { operator: "contains", value: "timeout" } + ] + } +] + +Query: "find logs with deployment failed" +Result: [ + { + field: "message", + filters: [ + { operator: "contains", value: "deployment failed" } + ] + } +] + +# Time Range Filtering +Query: "show all debug logs from yesterday" +Result: [ + { + field: "severity", + filters: [ + { operator: "is", value: "DEBUG" } + ] + }, + { + field: "since", + filters: [{ + operator: "is", + value: "1d" + }] + } +] + +Query: "errors in last 30 minutes" +Result: [ + { + field: "severity", + filters: [ + { operator: "is", value: "ERROR" } + ] + }, + { + field: "since", + filters: [{ + operator: "is", + value: "30m" + }] + } +] + +# Complex Combinations +Query: "find INFO logs with connection from last 2 hours" +Result: [ + { + field: "severity", + filters: [ + { operator: "is", value: "INFO" } + ] + }, + { + field: "message", + filters: [ + { operator: "contains", value: "connection" } + ] + }, + { + field: "since", + filters: [{ + operator: "is", + value: "2h" + }] + } +] + +Query: "warnings and errors containing crash or panic since 6h" +Result: [ + { + field: "severity", + filters: [ + { operator: "is", value: "WARN" }, + { operator: "is", value: "ERROR" } + ] + }, + { + field: "message", + filters: [ + { operator: "contains", value: "crash" }, + { operator: "contains", value: "panic" } + ] + }, + { + field: "since", + filters: [{ + operator: "is", + value: "6h" + }] + } +] + +Remember: +${operatorsByField} +- For relative time queries, support: + • Nx[m] for minutes (e.g., 30m, 45m) + • Nx[h] for hours (e.g., 1h, 24h) + • Nx[d] for days (e.g., 1d, 7d) +- severity must be exactly one of: ERROR, WARN, INFO, DEBUG (case-sensitive) +- message operator "contains" for substring matching, "is" for exact match +- since and startTime/endTime are mutually exclusive - prefer since for relative time +- For multiple time ranges mentioned, use the longest duration + +Special handling rules: +1. Map "error", "errors" → severity: ERROR +2. Map "warning", "warnings", "warn" → severity: WARN +3. Map "info", "information" → severity: INFO +4. Map "debug" → severity: DEBUG +5. For queries like "yesterday", use "1d" for since +6. For "last hour", use "1h" for since +7. For message filtering, extract quoted strings or key terms +8. When seeing "failed", "failure", "crash", "panic" → use contains on message field + +Error Handling Rules: +1. Invalid time formats: Convert to nearest supported range (e.g., "1w" → "7d") +2. Unknown severity levels: Map to closest match or default to INFO +3. For multiple time ranges: Use the longest +4. For ambiguous terms like "issues" or "problems", include WARN and ERROR + +Ambiguity Resolution: +1. Explicit severity over implicit (e.g., "ERROR" over "failed") +2. Specific time over general (e.g., "30m" over "recently") +3. When both severity and message filtering apply, include both +4. For terms like "critical", "severe" → map to ERROR +5. For terms like "notice", "alert" → map to WARN + +Output Validation: +1. Required fields: field, filters +2. Filters must have: operator, value +3. Values must match field constraints: + - severity: must be ERROR, WARN, INFO, or DEBUG + - message: any string + - since: valid duration string (e.g., "1h", "30m", "2d") + - startTime/endTime: valid timestamp in milliseconds + +Additional Examples: + +# Error Handling +Query: "show logs from last week" +Result: [ + { + field: "since", + filters: [{ + operator: "is", + value: "7d" + }] + } +] + +Query: "critical errors in production" +Result: [ + { + field: "severity", + filters: [{ + operator: "is", + value: "ERROR" + }] + }, + { + field: "message", + filters: [{ + operator: "contains", + value: "production" + }] + } +] + +# Ambiguity Resolution +Query: "show issues from last day" +Result: [ + { + field: "severity", + filters: [ + { operator: "is", value: "WARN" }, + { operator: "is", value: "ERROR" } + ] + }, + { + field: "since", + filters: [{ + operator: "is", + value: "1d" + }] + } +]`; +}; diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/query.ts b/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/query.ts new file mode 100644 index 0000000000..4241df68a8 --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/query.ts @@ -0,0 +1,84 @@ +import { clickhouse } from "@/lib/clickhouse"; +import { db } from "@/lib/db"; +import { + type RuntimeLogsResponseSchema, + runtimeLogsRequestSchema, + runtimeLogsResponseSchema, +} from "@/lib/schemas/runtime-logs.schema"; +import { ratelimit, withRatelimit, workspaceProcedure } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { transformFilters } from "./utils"; + +export const queryRuntimeLogs = workspaceProcedure + .use(withRatelimit(ratelimit.read)) + .input(runtimeLogsRequestSchema) + .output(runtimeLogsResponseSchema) + .query(async ({ ctx, input }) => { + const workspace = await db.query.workspaces + .findFirst({ + where: (table, { and, eq, isNull }) => + and(eq(table.id, ctx.workspace.id), isNull(table.deletedAtM)), + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to retrieve runtime logs due to an error. If this issue persists, please contact support@unkey.dev.", + }); + }); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found, please contact support using support@unkey.dev.", + }); + } + + const project = await db.query.projects.findFirst({ + where: (table, { and, eq }) => + and(eq(table.id, input.projectId), eq(table.workspaceId, workspace.id)), + columns: { id: true }, + with: { + activeDeployment: { + columns: { + environmentId: true, + }, + }, + }, + }); + + if (!project?.activeDeployment?.environmentId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found or access denied", + }); + } + + const transformedInputs = transformFilters(input); + const { logsQuery, totalQuery } = await clickhouse.runtimeLogs.logs({ + ...transformedInputs, + workspaceId: workspace.id, + projectId: project.id, + environmentId: project.activeDeployment?.environmentId, + }); + + const [countResult, logsResult] = await Promise.all([totalQuery, logsQuery]); + + if (countResult.err || logsResult.err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Something went wrong when fetching data from clickhouse.", + }); + } + + const logs = logsResult.val; + + const response: RuntimeLogsResponseSchema = { + logs, + hasMore: logs.length === input.limit, + total: countResult.val[0].total_count, + nextCursor: logs.length > 0 ? logs[logs.length - 1].time : undefined, + }; + + return response; + }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/utils.ts b/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/utils.ts new file mode 100644 index 0000000000..9a7d039481 --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/runtime-logs/utils.ts @@ -0,0 +1,28 @@ +import type { RuntimeLogsRequestSchema } from "@/lib/schemas/runtime-logs.schema"; +import { getTimestampFromRelative } from "@/lib/utils"; +import type { RuntimeLogsRequest } from "@unkey/clickhouse/src/runtime-logs"; + +export function transformFilters( + params: RuntimeLogsRequestSchema, +): Omit { + const severity = params.severity?.filters?.map((f) => f.value) || []; + + let startTime = params.startTime; + let endTime = params.endTime; + + const hasRelativeTime = params.since !== ""; + if (hasRelativeTime) { + startTime = getTimestampFromRelative(params.since); + endTime = Date.now(); + } + + return { + deploymentId: params.deploymentId, + limit: params.limit, + startTime, + endTime, + severity, + message: params.message, + cursorTime: params.cursor ?? null, + }; +} diff --git a/web/apps/dashboard/lib/trpc/routers/index.ts b/web/apps/dashboard/lib/trpc/routers/index.ts index d8f70f3d90..3ac729c02e 100644 --- a/web/apps/dashboard/lib/trpc/routers/index.ts +++ b/web/apps/dashboard/lib/trpc/routers/index.ts @@ -65,6 +65,8 @@ import { getInstanceRps } from "./deploy/network/get-instance-rps"; import { getSentinelRps } from "./deploy/network/get-sentinel-rps"; import { createProject } from "./deploy/project/create"; import { listProjects } from "./deploy/project/list"; +import { llmSearch as runtimeLogsLlmSearch } from "./deploy/runtime-logs/llm-search"; +import { queryRuntimeLogs } from "./deploy/runtime-logs/query"; import { querySentinelLogs } from "./deploy/sentinel-logs/query"; import { listEnvironments } from "./environment/list"; import { githubRouter } from "./github"; @@ -415,6 +417,10 @@ export const router = t.router({ sentinelLogs: t.router({ query: querySentinelLogs, }), + runtimeLogs: t.router({ + query: queryRuntimeLogs, + llmSearch: runtimeLogsLlmSearch, + }), metrics: t.router({ getDeploymentRps, getDeploymentRpsTimeseries, diff --git a/web/apps/dashboard/lib/utils/deployment-formatters.ts b/web/apps/dashboard/lib/utils/deployment-formatters.ts new file mode 100644 index 0000000000..d26dd3dbeb --- /dev/null +++ b/web/apps/dashboard/lib/utils/deployment-formatters.ts @@ -0,0 +1,35 @@ +export function formatCpu(millicores: number): string { + if (millicores === 0) { + return "—"; + } + if (millicores === 256) { + return "1/4 vCPU"; + } + if (millicores === 512) { + return "1/2 vCPU"; + } + if (millicores === 768) { + return "3/4 vCPU"; + } + if (millicores === 1024) { + return "1 vCPU"; + } + + if (millicores >= 1024 && millicores % 1024 === 0) { + return `${millicores / 1024} vCPU`; + } + + return `${millicores}m vCPU`; +} + +export function formatMemory(mib: number): string { + if (mib === 0) { + return "—"; + } + // Convert to GiB when >= 1024 MiB + if (mib >= 1024) { + // Show decimals only if not a whole number + return `${(mib / 1024).toFixed(mib % 1024 === 0 ? 0 : 1)} GiB`; + } + return `${mib} MiB`; +} diff --git a/web/apps/dashboard/lib/utils/metric-formatters.ts b/web/apps/dashboard/lib/utils/metric-formatters.ts new file mode 100644 index 0000000000..584930c178 --- /dev/null +++ b/web/apps/dashboard/lib/utils/metric-formatters.ts @@ -0,0 +1,31 @@ +/** + * Formats latency value with adaptive units + * - <1000ms: shows "150ms" + * - 1-59s: shows "5.0s" + * - 1-59m: shows "1.5m" + * - 1-24h: shows "2.0h" + * - ≥1d: shows "1.2d" + */ +export function formatLatency(latency: number): string { + if (latency < 1000) { + return `${latency}ms`; + } + + const seconds = latency / 1000; + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } + + const minutes = seconds / 60; + if (minutes < 60) { + return `${minutes.toFixed(1)}m`; + } + + const hours = minutes / 60; + if (hours < 24) { + return `${hours.toFixed(1)}h`; + } + + const days = hours / 24; + return `${days.toFixed(1)}d`; +} diff --git a/web/internal/clickhouse/src/index.ts b/web/internal/clickhouse/src/index.ts index b20acb85da..7c54628e78 100644 --- a/web/internal/clickhouse/src/index.ts +++ b/web/internal/clickhouse/src/index.ts @@ -70,6 +70,7 @@ import { insertRatelimit, } from "./ratelimits"; import { insertApiRequest } from "./requests"; +import { getRuntimeLogs } from "./runtime-logs"; import { getDeploymentLatency, getDeploymentLatencyTimeseries, @@ -325,4 +326,9 @@ export class ClickHouse { }, }; } + public get runtimeLogs() { + return { + logs: getRuntimeLogs(this.querier), + }; + } } diff --git a/web/internal/clickhouse/src/runtime-logs.ts b/web/internal/clickhouse/src/runtime-logs.ts new file mode 100644 index 0000000000..4ff9c80fe5 --- /dev/null +++ b/web/internal/clickhouse/src/runtime-logs.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; +import type { Querier } from "./client/interface"; + +const TABLE = "default.runtime_logs_raw_v1"; + +export const runtimeLogsRequestSchema = z.object({ + workspaceId: z.string(), + projectId: z.string(), + deploymentId: z.string(), + environmentId: z.string(), + limit: z.int(), + startTime: z.int(), + endTime: z.int(), + severity: z.array(z.string()).nullable(), + message: z.string().nullable(), + cursorTime: z.int().nullable(), +}); + +export type RuntimeLogsRequest = z.infer; + +export const runtimeLog = z.object({ + time: z.int(), + severity: z.string(), + message: z.string(), + deployment_id: z.string(), + region: z.string(), + attributes: z.record(z.string(), z.unknown()).nullable(), +}); + +export type RuntimeLog = z.infer; + +export function getRuntimeLogs(ch: Querier) { + return async (args: RuntimeLogsRequest) => { + const filterConditions = ` + workspace_id = {workspaceId: String} + AND project_id = {projectId: String} + AND deployment_id = {deploymentId: String} + AND environment_id = {environmentId: String} + AND time BETWEEN {startTime: UInt64} AND {endTime: UInt64} + + AND ( + CASE + WHEN length({severity: Array(String)}) > 0 THEN + severity IN {severity: Array(String)} + ELSE TRUE + END + ) + + AND ( + {message: Nullable(String)} IS NULL + OR {message: Nullable(String)} = '' + OR positionCaseInsensitive(message, assumeNotNull({message: Nullable(String)})) > 0 + ) + `; + + const totalQuery = ch.query({ + query: ` + SELECT count(*) as total_count + FROM ${TABLE} + WHERE ${filterConditions}`, + params: runtimeLogsRequestSchema, + schema: z.object({ total_count: z.int() }), + }); + + const logsQuery = ch.query({ + query: ` + SELECT + time, severity, message, deployment_id, + region, attributes + FROM ${TABLE} + WHERE ${filterConditions} + AND ({cursorTime: Nullable(UInt64)} IS NULL OR time < {cursorTime: Nullable(UInt64)}) + ORDER BY time DESC + LIMIT {limit: Int}`, + params: runtimeLogsRequestSchema, + schema: runtimeLog, + }); + + return { + logsQuery: logsQuery(args), + totalQuery: totalQuery(args), + }; + }; +}