diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index 9dcea4842..9bd03e9bc 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -1,3 +1,131 @@ -export const TabLogs = () => { - return "This is the logs tab"; +// Components +import Stack from "@mui/material/Stack"; +import { BaseBox } from "@/Components/design-elements"; +import Typography from "@mui/material/Typography"; + +// Types +import type { DockerContainerLogsResponse } from "@/Types/Monitor"; + +// Hooks +import { useGet } from "@/Hooks/UseApi"; +import { useState, useEffect, useMemo } from "react"; +import type { DockerLog } from "@/Types/Check"; +import { useSelector } from "react-redux"; + +// Util +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { flattenDockerLogs } from "@/Utils/MonitorUtils"; +import { Box } from "@mui/material"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import type { RootState } from "@/store"; + +interface TabLogsProps { + monitorId?: string; + containerName?: string; +} + +export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { + // State + const [olderLogs, setOlderLogs] = useState([]); + const [olderCursor, setOlderCursor] = useState(undefined); // undefined -> no older requested, string -> cursor, null -> no more data + const [loadingOlder, setLoadingOlder] = useState(false); + const [odlerError, setOlderError] = useState(null); + + const baseLogsUrl = + monitorId && containerName + ? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs` + : null; + + const { + data: head, + isLoading, + isValidating, + error: headError, + refetch, + } = useGet( + baseLogsUrl, + {}, + { + refreshInterval: 10_000, + keepPreviousData: true, + revalidateOnFocus: true, + } + ); + + const rows = useMemo(() => flattenDockerLogs(head?.logs ?? []), [head?.logs]); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + return ( + + + {`${rows.length} recent lines`} + {isValidating && "Refreshing..."} + + + {[...rows].reverse().map((row) => { + return ( + + + + {formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)} + + + + {row.stream} + + + + {row.text} + + + {row.gapBefore && ( + + + + {"Some lines were skipped between checks"} + + + + )} + + ); + })} + + + ); }; diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx index b4583e4c1..51f765785 100644 --- a/client/src/Pages/Docker/ContainerDetails/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -70,7 +70,12 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) = {activeTab === 0 && } - {activeTab === 1 && } + {activeTab === 1 && ( + + )} ); }; diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index 419ff7736..1585b7771 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -161,6 +161,31 @@ export interface DockerContainerMount { rw: boolean; } +export const DockerLogStreams = ["stdout", "stderr"] as const; +export type DockerLogStream = (typeof DockerLogStreams)[number]; + +export interface DockerLogLine { + ts: string; + stream: DockerLogStream; + text: string; +} + +export interface DockerLog { + id: string; + metadata: { + monitorId: string; + teamId: string; + containerId: string; + containerName: string; + }; + lines: DockerLogLine[]; + gap: boolean; + checkedAt: string; + expiry: string; + createdAt: string; + updatedAt: string; +} + export interface DockerContainerInfo { id: string; name: string; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index a0ed555d4..300bd2ce4 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -5,6 +5,7 @@ import type { DailyCheckBucket, DockerContainerInfo, DockerContainerSummary, + DockerLog, } from "@/Types/Check"; import type { PageSpeedGroupedCheck } from "@/Types/Check"; import type { GeoContinent } from "@/Types/GeoCheck"; @@ -322,6 +323,10 @@ export interface DockerContainerResponse { stats: DockerContainerStats; } +export interface DockerContainerLogsResponse { + logs: DockerLog[]; + nextCursor: string | null; +} export interface Game { name: string; options?: { diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index 3a7778b75..cdc85ac84 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -1,10 +1,13 @@ import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; import type { PaletteKey } from "@/Utils/Theme/Theme"; import type { ValueType } from "@/Components/design-elements/StatusLabel"; -import type { - DockerContainerMount, - DockerContainerPort, - DockerContainerState, +import { + DockerLogStreams, + type DockerContainerMount, + type DockerContainerPort, + type DockerContainerState, + type DockerLog, + type DockerLogLine, } from "@/Types/Check"; export const getMonitorPath = (type: MonitorType): string => { @@ -144,3 +147,35 @@ export const getDockerMountLabel = (mount: DockerContainerMount) => { if (mount.source) return mount.source; return mount.type; }; + +export const DockerLogStreamFilters = ["all", ...DockerLogStreams] as const; +export type DockerLogStreamFilter = (typeof DockerLogStreamFilters)[number]; + +export interface DockerLogRow extends DockerLogLine { + key: string; + gapBefore: boolean; // This signifies lines skipped +} + +export const flattenDockerLogs = (logs: DockerLog[]): DockerLogRow[] => { + const rows: DockerLogRow[] = []; + for (const log of [...logs].reverse()) { + log.lines.forEach((line: DockerLogLine, idx: number) => { + rows.push({ ...line, key: `${log.id}:${idx}`, gapBefore: idx === 0 && log.gap }); + }); + } + return rows; +}; + +export const filterDockerLogRows = ( + rows: DockerLogRow[], + stream: DockerLogStreamFilter, + query: string +): DockerLogRow[] => { + const normalizedQuery = query.trim().toLowerCase(); + return rows.filter((row) => { + return ( + (stream === "all" || row.stream === stream) && + (normalizedQuery === "" || row.text.toLowerCase().includes(normalizedQuery)) + ); + }); +};