Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions frontend/app/api/queries/useComponentLogsQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
type UseQueryOptions,
useQuery,
useQueryClient,
} from "@tanstack/react-query";

export interface LogEntry {
timestamp: string; // ISO-8601 UTC
level: string; // "debug" | "info" | "warning" | "error" | "critical"
message: string;
detail?: string | null;
}

export interface ComponentLogsResponse {
component: string;
entries: LogEntry[];
count: number;
}

async function fetchComponentLogs(
component: string,
tail = 100,
): Promise<ComponentLogsResponse> {
const response = await fetch(
`/api/status/${encodeURIComponent(component)}/logs?tail=${tail}`,
);
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const detail =
typeof body?.detail === "string"
? body.detail
: `HTTP ${response.status}`;
throw new Error(detail);
}
return response.json() as Promise<ComponentLogsResponse>;
}

export const useComponentLogsQuery = (
component: string | null,
tail = 100,
options?: Omit<
UseQueryOptions<ComponentLogsResponse>,
"queryKey" | "queryFn"
>,
) => {
const queryClient = useQueryClient();

return useQuery(
{
queryKey: ["component-logs", component, tail],
queryFn: () => fetchComponentLogs(component as string, tail),
enabled: !!component,
retry: 1,
staleTime: 5000,
refetchOnWindowFocus: false,
...options,
},
queryClient,
);
};
2 changes: 2 additions & 0 deletions frontend/app/api/queries/useConsoleStatusQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface ComponentStatus {
version?: string | null;
build?: ComponentBuild;
metadata?: Record<string, unknown>;
/** Non-null when the last health-check failed; used to gate the Logs button. */
last_error?: string | null;
}

export interface ConsoleStatusResponse {
Expand Down
180 changes: 177 additions & 3 deletions frontend/components/console-status-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,30 @@ import {
EvCharger,
HelpCircle,
RefreshCw,
ScrollText,
Settings,
XCircle,
} from "lucide-react";
import { useCallback, useState } from "react";
import {
type LogEntry,
useComponentLogsQuery,
} from "@/app/api/queries/useComponentLogsQuery";
import {
type ComponentState,
type ComponentStatus,
useConsoleStatusQuery,
} from "@/app/api/queries/useConsoleStatusQuery";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
import { useProviderHealth } from "@/components/provider-health-banner";
import {
type ProviderHealthResponse,
useProviderHealthQuery,
} from "@/app/api/queries/useProviderHealthQuery";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";

// ─── status helpers ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -100,6 +114,136 @@ function MetaRow({ label, value }: { label: string; value: string }) {
);
}

// ─── log level helpers ────────────────────────────────────────────────────────

function logLevelColor(level: string) {
switch (level.toLowerCase()) {
case "error":
case "critical":
return "text-red-400";
case "warning":
return "text-amber-400";
case "info":
return "text-sky-400";
default:
return "text-zinc-400";
}
}

// ─── component logs modal ─────────────────────────────────────────────────────

interface ComponentLogsModalProps {
component: string;
displayName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}

function ComponentLogsModal({
component,
displayName,
open,
onOpenChange,
}: ComponentLogsModalProps) {
const { data, isLoading, isError, error, refetch, isFetching } =
useComponentLogsQuery(open ? component : null, 100);

const entries: LogEntry[] = data?.entries ?? [];

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"bg-zinc-900 border-zinc-700 text-zinc-100",
"w-[560px] max-w-[95vw] max-h-[70vh] flex flex-col gap-0 p-0",
)}
>
{/* Modal header */}
<DialogHeader className="shrink-0 flex flex-row items-center justify-between pl-4 pr-10 py-3 border-b border-zinc-700/60">
<div className="flex items-center gap-2">
<ScrollText size={14} className="text-zinc-400" />
<DialogTitle className="text-sm font-semibold text-zinc-100">
{displayName} — Logs
</DialogTitle>
{isFetching && (
<RefreshCw size={11} className="text-zinc-500 animate-spin" />
)}
</div>
<button
type="button"
onClick={() => void refetch()}
disabled={isFetching}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40"
>
Refresh
</button>
</DialogHeader>

{/* Log list */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-1.5 min-h-0">
{isLoading ? (
<div className="space-y-1.5">
{[...Array(5)].map((_, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholders
key={i}
className="h-8 rounded bg-zinc-800/60 animate-pulse"
/>
))}
</div>
) : isError ? (
<p className="text-sm text-red-400">
{error instanceof Error ? error.message : "Failed to load logs."}
</p>
) : entries.length === 0 ? (
<p className="text-xs text-zinc-500 italic text-center py-6">
No log entries recorded yet.
</p>
) : (
entries.map((entry, idx) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: log entries have no stable id
key={idx}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-array-index-as-key (warning)

Your users can see & submit the wrong data when this list reorders or filters, so use a stable id like key={item.id}, not the array index "idx".

Fix → Use a stable id from the item, like key={item.id} or key={item.slug}. Index keys break when the list reorders or filters.

Docs

className="rounded bg-zinc-800/50 border border-zinc-700/40 px-2.5 py-1.5"
>
<div className="flex items-center gap-2 flex-wrap">
<span
className={cn(
"text-[10px] font-semibold uppercase tabular-nums shrink-0",
logLevelColor(entry.level),
)}
>
{entry.level}
</span>
<span className="text-[10px] text-zinc-500 tabular-nums shrink-0">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
<span className="text-xs text-zinc-200 break-all">
{entry.message}
</span>
</div>
{entry.detail && (
<p className="text-[11px] text-zinc-400 mt-0.5 ml-0 break-all font-mono">
{entry.detail}
</p>
)}
</div>
))
)}
</div>

{/* Footer */}
<div className="shrink-0 px-4 py-2 border-t border-zinc-700/60">
<span className="text-[11px] text-zinc-500">
{entries.length} entr{entries.length === 1 ? "y" : "ies"} (most
recent 100)
</span>
</div>
</DialogContent>
</Dialog>
);
}

// ─── component placard ───────────────────────────────────────────────────────

interface ComponentCardProps {
Expand All @@ -108,6 +252,8 @@ interface ComponentCardProps {

function ComponentCard({ component }: ComponentCardProps) {
const [expanded, setExpanded] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const showLogsButton = component.name !== "providers";

const {
display_name,
Expand Down Expand Up @@ -204,8 +350,36 @@ function ComponentCard({ component }: ComponentCardProps) {
No additional details available.
</p>
)}

{/* Logs button — not shown for the synthetic Model Providers card */}
{showLogsButton && (
<div className="pt-1.5">
<button
type="button"
onClick={() => setLogsOpen(true)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium",
"bg-zinc-700/60 border border-zinc-600/60 text-zinc-200",
"hover:bg-zinc-600/60 hover:border-zinc-500/60 transition-colors",
)}
>
<ScrollText size={12} className="shrink-0" />
Logs
</button>
</div>
)}
</div>
)}

{/* Logs modal */}
{showLogsButton && (
<ComponentLogsModal
component={component.name}
displayName={display_name}
open={logsOpen}
onOpenChange={setLogsOpen}
/>
)}
</div>
);
}
Expand Down Expand Up @@ -295,7 +469,7 @@ export function ConsoleStatusPanel({ onClose }: ConsoleStatusPanelProps) {

// Reuses the shared /provider/health query (same cache as the banner), so an
// API-key failure surfaces here without an extra network round-trip.
const { health: providerHealth } = useProviderHealth();
const { data: providerHealth } = useProviderHealthQuery();

// Defensive: always read from data.components array
const backendComponents = Array.isArray(data?.components)
Expand Down
23 changes: 21 additions & 2 deletions src/api/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import asyncio

import httpx
from fastapi import Depends, HTTPException, Request
from fastapi import Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse

from api.schemas.status import StatusResponse
from api.schemas.status import LogEntry, LogsResponse, StatusResponse
from config.settings import clients
from dependencies import require_permission
from services.component_logs import KNOWN_COMPONENTS, get_entries
from services.status_service import aggregate_status
from session_manager import User
from utils.logging_config import get_logger
Expand All @@ -27,6 +28,24 @@ async def get_console_status(
raise HTTPException(status_code=500, detail="Failed to get status") from e


async def get_console_component_logs(
component: str,
tail: int = Query(default=100, ge=1, le=500),
user: User = Depends(require_permission("providers:read")),
) -> LogsResponse:
"""Return recent log entries for one component. GET /status/{component}/logs"""
if component not in KNOWN_COMPONENTS:
valid = ", ".join(sorted(KNOWN_COMPONENTS))
raise HTTPException(
status_code=404,
detail=f"Unknown component '{component}'. Valid names: {valid}",
)

raw = get_entries(component, tail=tail)
entries = [LogEntry(**e) for e in raw]
return LogsResponse(component=component, entries=entries, count=len(entries))


async def health_check(request: Request):
"""Simple liveness probe: Indicates that the OpenRAG Backend service is online and running."""
return JSONResponse({"status": "ok"}, status_code=200)
Expand Down
16 changes: 15 additions & 1 deletion src/app/routes/internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@
upload,
)
from api import keys as api_keys
from api.health import get_console_status, health_check, opensearch_health_ready
from api.health import (
get_console_component_logs,
get_console_status,
health_check,
opensearch_health_ready,
)
from api.schemas.tasks import ErrorResponse, TaskRetryResponse
from api.v2 import files as files_v2
from connectors.registry import get_connector_classes
Expand Down Expand Up @@ -393,6 +398,15 @@ def register_internal_routes(app: FastAPI):
app.add_api_route("/health", health_check, methods=["GET"], tags=["internal"])
app.add_api_route("/search/health", opensearch_health_ready, methods=["GET"], tags=["internal"])

# Console status endpoints (browser session auth — mirrors /v1/status* for the UI)
# The specific /logs sub-route must be registered before the bare /status route.
app.add_api_route(
"/status/{component}/logs",
get_console_component_logs,
methods=["GET"],
tags=["internal"],
)
app.add_api_route("/status", get_console_status, methods=["GET"], tags=["internal"])
# Console status endpoint (browser session auth — mirrors /v1/status for the UI).
# OSS-only: not registered in saas or on_prem deployments.
if is_run_mode_oss():
Expand Down
Loading