IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

logs-section.tsx

Open raw
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { SETTINGS_PANEL_INSET, SETTINGS_PANEL_SHELL } from "@/modules/settings/components/panel/surface";
import { queryKeys } from "@/lib/shared/query-keys";
import type { RawLogEntry, LogSource } from "@/lib/server/modules/logs/service";
import { cn } from "@/lib/utils";

type LogsApiResponse = {
  data: {
    entries: RawLogEntry[];
    source: LogSource;
    truncated: boolean;
    error?: string;
  };
};

async function fetchLogs(source: LogSource): Promise<LogsApiResponse["data"]> {
  const res = await fetch(`/api/v1/logs?source=${source}`);
  if (!res.ok) throw new Error(`Failed to load ${source} logs`);
  const json = (await res.json()) as LogsApiResponse;
  return json.data;
}

const TABS: { id: LogSource; label: string }[] = [
  { id: "homeio", label: "Homeio" },
  { id: "system", label: "System" },
  { id: "docker", label: "Docker" },
];

function levelColor(level?: string) {
  switch (level) {
    case "error": return "text-status-red";
    case "warn": return "text-status-amber";
    case "debug": return "text-muted-foreground/60";
    default: return "text-foreground/80";
  }
}

function levelBadgeColor(level?: string) {
  switch (level) {
    case "error": return "bg-status-red/15 text-status-red";
    case "warn": return "bg-status-amber/15 text-status-amber";
    case "debug": return "bg-secondary text-muted-foreground";
    default: return "bg-primary/10 text-primary";
  }
}

function formatTimestamp(ts?: string) {
  if (!ts) return null;
  try {
    return new Date(ts).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
  } catch {
    return ts.slice(11, 19) || null;
  }
}

function HomeioLogRow({ entry }: { entry: RawLogEntry }) {
  const [expanded, setExpanded] = useState(false);
  const ts = formatTimestamp(entry.timestamp);
  const hasDetail = Boolean(entry.error || entry.message);

  return (
    <div
      className={cn("border-b border-glass-border/40 px-3 py-1.5 transition-colors last:border-0", hasDetail && "cursor-pointer hover:bg-background/30")}
      onClick={() => hasDetail && setExpanded((p) => !p)}
    >
      <div className="flex min-w-0 items-center gap-2">
        {ts && <span className="w-16 shrink-0 font-mono text-2xs text-muted-foreground/60">{ts}</span>}
        {entry.level && (
          <span className={cn("shrink-0 rounded px-1.5 py-0.5 text-2xs font-bold uppercase tracking-widest", levelBadgeColor(entry.level))}>
            {entry.level}
          </span>
        )}
        {entry.layer && <span className="shrink-0 font-mono text-2xs text-muted-foreground/70">{entry.layer}</span>}
        <span className={cn("truncate font-mono text-2xs", levelColor(entry.level))}>
          {entry.action ?? entry.raw}
        </span>
        {entry.status && <span className="ml-auto shrink-0 text-2xs text-muted-foreground/50">{entry.status}</span>}
      </div>
      {expanded && hasDetail && (
        <div className={cn("ml-18 mt-1.5 rounded-lg p-2", SETTINGS_PANEL_INSET)}>
          {entry.message && <p className="break-all font-mono text-2xs text-foreground/80">{entry.message}</p>}
          {entry.error && <p className="mt-0.5 break-all font-mono text-2xs text-status-red">{entry.error.message}</p>}
        </div>
      )}
    </div>
  );
}

function RawLogRow({ entry }: { entry: RawLogEntry }) {
  const isError = /\b(error|failed|failure|crit|emerg|alert)\b/i.test(entry.raw);
  const isWarn = /\b(warn|warning)\b/i.test(entry.raw);
  return (
    <div className="border-b border-glass-border/40 px-3 py-1 last:border-0">
      <span className={cn("break-all font-mono text-2xs leading-relaxed", isError ? "text-status-red" : isWarn ? "text-status-amber" : "text-foreground/75")}>
        {entry.raw}
      </span>
    </div>
  );
}

const REFETCH_INTERVAL = 8_000;

export function LogsSection() {
  const [activeSource, setActiveSource] = useState<LogSource>("homeio");
  const [autoScroll, setAutoScroll] = useState(true);
  const scrollRef = useRef<HTMLDivElement>(null);

  const { data, isFetching, refetch, dataUpdatedAt } = useQuery({
    queryKey: queryKeys.logs(activeSource),
    queryFn: () => fetchLogs(activeSource),
    refetchInterval: REFETCH_INTERVAL,
    staleTime: REFETCH_INTERVAL / 2,
  });

  useEffect(() => {
    if (autoScroll && scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [dataUpdatedAt, autoScroll]);

  const handleScroll = useCallback(() => {
    const el = scrollRef.current;
    if (!el) return;
    setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 40);
  }, []);

  function handleDownload() {
    if (!data?.entries) return;
    const text = data.entries.map((e) => e.raw).join("\n");
    const blob = new Blob([text], { type: "text/plain" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `homeio-${activeSource}-logs.txt`;
    a.click();
    URL.revokeObjectURL(url);
  }

  const lastUpdated = dataUpdatedAt
    ? new Date(dataUpdatedAt).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
    : null;

  const sourceLabel =
    activeSource === "homeio" ? "Homeio Application Logs" :
    activeSource === "system" ? "System Journal" :
    "Docker Daemon Journal";

  return (
    <div className="flex flex-col gap-1.5">
      {/* Tab bar */}
      <div className={cn(SETTINGS_PANEL_INSET, "flex items-center gap-1 px-2 py-2")}>
        {TABS.map((tab) => (
          <button
            key={tab.id}
            onClick={() => setActiveSource(tab.id)}
            className={cn(
              "rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
              activeSource === tab.id ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-background/60 hover:text-foreground",
            )}
          >
            {tab.label}
          </button>
        ))}
        <div className="ml-auto flex items-center gap-2">
          {lastUpdated && (
            <span className="text-2xs text-muted-foreground/60">
              {isFetching ? "Refreshing…" : `Updated ${lastUpdated}`}
            </span>
          )}
          <button
            onClick={() => void refetch()}
            disabled={isFetching}
            className="rounded-lg px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
          >
            Refresh
          </button>
          <button
            onClick={handleDownload}
            disabled={!data?.entries?.length}
            className="rounded-lg px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
          >
            Download
          </button>
        </div>
      </div>

      {/* Log output */}
      <div className={cn(SETTINGS_PANEL_SHELL, "flex flex-col overflow-hidden")} style={{ height: "360px" }}>
        {/* Header */}
        <div className="flex shrink-0 items-center justify-between border-b border-glass-border/50 px-3 py-1.5">
          <span className="text-2xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{sourceLabel}</span>
          <div className="flex items-center gap-2">
            {data?.truncated ? (
              <span className="text-2xs text-status-amber">Showing last 500 lines</span>
            ) : data?.entries?.length ? (
              <span className="text-2xs text-muted-foreground/50">{data.entries.length} entries</span>
            ) : null}
            <button
              onClick={() => setAutoScroll((p) => !p)}
              className={cn(
                "rounded px-1.5 py-0.5 text-2xs transition-colors",
                autoScroll ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground",
              )}
            >
              {autoScroll ? "Auto-scroll on" : "Auto-scroll off"}
            </button>
          </div>
        </div>

        {/* Scroll area */}
        <div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto">
          {data?.error ? (
            <div className="flex h-full items-center justify-center">
              <span className="px-6 text-center text-xs text-status-amber">{data.error}</span>
            </div>
          ) : !data || data.entries.length === 0 ? (
            <div className="flex h-full items-center justify-center">
              <span className="text-xs text-muted-foreground">{isFetching ? "Loading logs…" : "No log entries found"}</span>
            </div>
          ) : activeSource === "homeio" ? (
            data.entries.map((entry, i) => <HomeioLogRow key={i} entry={entry} />)
          ) : (
            data.entries.map((entry, i) => <RawLogRow key={i} entry={entry} />)
          )}
        </div>
      </div>
    </div>
  );
}
bytes
9,098
lines
233
Type
tsx
Latest commit
16 October 2026