From 0733942fcd26a8dbd855fa3d0f4616c0e4262528 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 11 Apr 2026 14:44:14 +0200 Subject: [PATCH 1/6] feat: add Logs tab to entity resource panel Adds a "Logs" tab to EntityResourceTabs for viewing ROS log entries per entity with severity/context filtering, auto-refresh polling, JSON download, and per-entity logs configuration editing. - Generic store actions + api-dispatch helpers for /logs endpoints - Local TypeScript interfaces mirroring the gateway JSON shape - LogsPanel component: dense table, click-to-expand rows, toolbar filters, auto-refresh with visibility gating, clear/download, lazy-loaded collapsible configuration row - Aggregation header for areas and functions - Wired as the 5th tab alongside Data/Operations/Config/Faults Closes #43 --- src/components/EntityResourceTabs.tsx | 15 +- src/components/LogsPanel.test.tsx | 659 ++++++++++++++++++++++++++ src/components/LogsPanel.tsx | 502 ++++++++++++++++++++ src/lib/api-dispatch.test.ts | 130 +++++ src/lib/api-dispatch.ts | 91 ++++ src/lib/log-types.ts | 86 ++++ src/lib/store.ts | 67 +++ src/lib/types.ts | 15 + src/test/setup.ts | 12 + 9 files changed, 1575 insertions(+), 2 deletions(-) create mode 100644 src/components/LogsPanel.test.tsx create mode 100644 src/components/LogsPanel.tsx create mode 100644 src/lib/log-types.ts diff --git a/src/components/EntityResourceTabs.tsx b/src/components/EntityResourceTabs.tsx index 1700391..c1187bf 100644 --- a/src/components/EntityResourceTabs.tsx +++ b/src/components/EntityResourceTabs.tsx @@ -1,16 +1,17 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Database, Zap, Settings, AlertTriangle, Loader2, MessageSquare } from 'lucide-react'; +import { Database, Zap, Settings, AlertTriangle, Loader2, MessageSquare, ScrollText } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; import { FaultsPanel } from '@/components/FaultsPanel'; +import { LogsPanel } from '@/components/LogsPanel'; import type { SovdResourceEntityType } from '@/lib/types'; import type { ComponentTopic, Operation, Fault } from '@/lib/types'; -type ResourceTab = 'data' | 'operations' | 'configurations' | 'faults'; +type ResourceTab = 'data' | 'operations' | 'configurations' | 'faults' | 'logs'; interface TabConfig { id: ResourceTab; @@ -23,6 +24,7 @@ const RESOURCE_TABS: TabConfig[] = [ { id: 'operations', label: 'Operations', icon: Zap }, { id: 'configurations', label: 'Config', icon: Settings }, { id: 'faults', label: 'Faults', icon: AlertTriangle }, + { id: 'logs', label: 'Logs', icon: ScrollText }, ]; interface EntityResourceTabsProps { @@ -39,6 +41,7 @@ interface LoadedResources { operations: boolean; configurations: boolean; faults: boolean; + logs: boolean; } /** @@ -55,6 +58,7 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate operations: false, configurations: false, faults: false, + logs: false, }); const loadedTabsRef = useRef(loadedTabs); loadedTabsRef.current = loadedTabs; @@ -111,6 +115,10 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate setFaults(faultsRes.items || []); break; } + case 'logs': { + // LogsPanel owns its own fetching; no parent-level count fetch. + break; + } } setLoadedTabs((prev) => ({ ...prev, [tab]: true })); } catch (error) { @@ -237,6 +245,9 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate {/* Faults Tab */} {activeTab === 'faults' && } + + {/* Logs Tab */} + {activeTab === 'logs' && } )} diff --git a/src/components/LogsPanel.test.tsx b/src/components/LogsPanel.test.tsx new file mode 100644 index 0000000..79e00fc --- /dev/null +++ b/src/components/LogsPanel.test.tsx @@ -0,0 +1,659 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LogsPanel } from './LogsPanel'; +import type { LogsFetchResult } from '@/lib/types'; + +const mockFetchEntityLogs = vi.fn(); +const mockGetLogsConfiguration = vi.fn(); +const mockUpdateLogsConfiguration = vi.fn(); + +vi.mock('@/lib/store', () => ({ + useAppStore: vi.fn((selector) => + selector({ + fetchEntityLogs: mockFetchEntityLogs, + getLogsConfiguration: mockGetLogsConfiguration, + updateLogsConfiguration: mockUpdateLogsConfiguration, + }) + ), +})); + +function emptyResult(): LogsFetchResult { + return { items: [] }; +} + +function sampleResult(): LogsFetchResult { + return { + items: [ + { + id: 'log_1', + timestamp: '2026-04-10T12:34:56.789000000Z', + severity: 'warning', + message: 'Temperature above 80C', + context: { + node: 'powertrain/engine/temp_sensor', + function: 'checkTemp', + file: 'temp_sensor.cpp', + line: 42, + }, + }, + { + id: 'log_2', + timestamp: '2026-04-10T12:34:57.123000000Z', + severity: 'error', + message: 'Sensor timeout', + context: { node: 'powertrain/engine/temp_sensor' }, + }, + ], + }; +} + +describe('LogsPanel', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockFetchEntityLogs.mockReset(); + mockGetLogsConfiguration.mockReset(); + mockUpdateLogsConfiguration.mockReset(); + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + configurable: true, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows loading state on first mount and transitions to empty when fetch resolves', async () => { + let resolveFetch: (value: LogsFetchResult) => void = () => {}; + mockFetchEntityLogs.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + render(); + + expect(screen.getByText(/Loading logs/i)).toBeInTheDocument(); + + resolveFetch(emptyResult()); + await waitFor(() => { + expect(screen.getByText(/No log entries/i)).toBeInTheDocument(); + }); + }); + + it('shows "Logs not available" state on 503 response', async () => { + mockFetchEntityLogs.mockResolvedValue({ items: [], errorStatus: 503 }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Logs not available on this gateway/i)).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + + it('shows empty state when initial fetch returns no entries', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + render(); + + await waitFor(() => { + expect(screen.getByText(/No log entries/i)).toBeInTheDocument(); + }); + }); + + it('renders table rows for entries', async () => { + mockFetchEntityLogs.mockResolvedValue(sampleResult()); + render(); + + await waitFor(() => { + expect(screen.getByText('Temperature above 80C')).toBeInTheDocument(); + expect(screen.getByText('Sensor timeout')).toBeInTheDocument(); + }); + expect(screen.getAllByText('powertrain/engine/temp_sensor')).toHaveLength(2); + }); + + it('calls fetchEntityLogs with default params on mount', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledWith( + 'apps', + 'motor', + { severity: 'debug', context: '' }, + expect.any(AbortSignal) + ); + }); + }); + + it('does not call getLogsConfiguration on mount', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalled(); + }); + expect(mockGetLogsConfiguration).not.toHaveBeenCalled(); + }); + + it('expands a row to show source location on click', async () => { + mockFetchEntityLogs.mockResolvedValue(sampleResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await waitFor(() => { + expect(screen.getByText('Temperature above 80C')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Temperature above 80C')); + + expect(screen.getByText(/checkTemp/)).toBeInTheDocument(); + expect(screen.getByText(/temp_sensor\.cpp:42/)).toBeInTheDocument(); + }); + + it('shows "No source location" for entries with empty context', async () => { + mockFetchEntityLogs.mockResolvedValue(sampleResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await waitFor(() => { + expect(screen.getByText('Sensor timeout')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Sensor timeout')); + + expect(screen.getByText(/No source location/i)).toBeInTheDocument(); + }); + + it('changes severity filter triggers refetch with new param', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + }); + + const severitySelect = screen.getByLabelText(/severity/i); + await user.selectOptions(severitySelect, 'error'); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledWith( + 'apps', + 'motor', + expect.objectContaining({ severity: 'error' }), + expect.any(AbortSignal) + ); + }); + }); + + it('hides context filter input for App entities', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalled(); + }); + + expect(screen.queryByPlaceholderText(/context/i)).not.toBeInTheDocument(); + }); + + it('shows context filter input for Component, Area, Function entities', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/context/i)).toBeInTheDocument(); + }); + + rerender(); + expect(screen.getByPlaceholderText(/context/i)).toBeInTheDocument(); + + rerender(); + expect(screen.getByPlaceholderText(/context/i)).toBeInTheDocument(); + }); + + it('debounces context filter changes by 300ms', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + }); + + const contextInput = screen.getByPlaceholderText(/context/i); + await user.type(contextInput, 'engine'); + + // No additional fetch yet + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + + // Debounce should not have fired yet (default debounce is 300ms). + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + + // Advance fake timers past the 300ms debounce window inside act so + // the resulting state updates are flushed cleanly. + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledWith( + 'components', + 'c1', + expect.objectContaining({ context: 'engine' }), + expect.any(AbortSignal) + ); + }); + }); + + it('client-side message search filters loaded entries without new fetch', async () => { + mockFetchEntityLogs.mockResolvedValue(sampleResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await waitFor(() => { + expect(screen.getByText('Temperature above 80C')).toBeInTheDocument(); + expect(screen.getByText('Sensor timeout')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText(/search messages/i); + await user.type(searchInput, 'timeout'); + + expect(screen.queryByText('Temperature above 80C')).not.toBeInTheDocument(); + expect(screen.getByText('Sensor timeout')).toBeInTheDocument(); + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); // no extra fetch + }); + + it('manual refresh button triggers fetch with current filters', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + }); + + await user.click(screen.getByRole('button', { name: /refresh/i })); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(2); + }); + }); + + it('auto-refresh is on by default and ticks at 5s interval', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(2); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(3); + }); + }); + + it('auto-refresh toggle pauses polling', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + }); + + await user.click(screen.getByRole('switch', { name: /auto-refresh/i })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + }); + + it('interval dropdown changes tick rate', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + }); + + await user.selectOptions(screen.getByLabelText(/interval/i), '2000'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(2); + }); + }); + + it('auto-refresh pauses when document is hidden', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + configurable: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + + await act(async () => { + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + configurable: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(2); + }); + }); + + it('clear button empties the displayed entries until next fetch', async () => { + mockFetchEntityLogs.mockResolvedValue(sampleResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await waitFor(() => { + expect(screen.getByText('Temperature above 80C')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /clear/i })); + + expect(screen.queryByText('Temperature above 80C')).not.toBeInTheDocument(); + expect(screen.getByText(/cleared.*next refresh/i)).toBeInTheDocument(); + }); + + it('download button writes a JSON blob with currently displayed entries', async () => { + mockFetchEntityLogs.mockResolvedValue(sampleResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:fake'); + const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + const clickSpy = vi.fn(); + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => { + const el = originalCreateElement(tagName) as HTMLElement; + if (tagName === 'a') { + (el as HTMLAnchorElement).click = clickSpy; + } + return el; + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Temperature above 80C')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /download/i })); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + const firstCall = createObjectURLSpy.mock.calls[0]; + expect(firstCall).toBeDefined(); + const blob = firstCall![0] as Blob; + expect(blob.type).toBe('application/json'); + + const text = await blob.text(); + const parsed = JSON.parse(text); + expect(parsed).toHaveLength(2); + expect(parsed[0].id).toBe('log_1'); + + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:fake'); + + createElementSpy.mockRestore(); + }); + + it('does not call getLogsConfiguration until gear icon is clicked', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + mockGetLogsConfiguration.mockResolvedValue({ severity_filter: 'info', max_entries: 200 }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalled(); + }); + expect(mockGetLogsConfiguration).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: /settings/i })); + + await waitFor(() => { + expect(mockGetLogsConfiguration).toHaveBeenCalledWith('apps', 'motor'); + }); + await waitFor(() => { + expect(screen.getByDisplayValue('200')).toBeInTheDocument(); + }); + }); + + it('caches config form state across re-expands', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + mockGetLogsConfiguration.mockResolvedValue({ severity_filter: 'info', max_entries: 200 }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + render(); + + await user.click(screen.getByRole('button', { name: /settings/i })); + await waitFor(() => { + expect(mockGetLogsConfiguration).toHaveBeenCalledTimes(1); + }); + + // Collapse + await user.click(screen.getByRole('button', { name: /settings/i })); + // Re-expand + await user.click(screen.getByRole('button', { name: /settings/i })); + + expect(mockGetLogsConfiguration).toHaveBeenCalledTimes(1); + }); + + it('retries getLogsConfiguration on re-expand when previous load failed', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + mockGetLogsConfiguration + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ severity_filter: 'warning', max_entries: 300 }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + render(); + + // First expand: GET fails (returns null). + await user.click(screen.getByRole('button', { name: /settings/i })); + await waitFor(() => { + expect(mockGetLogsConfiguration).toHaveBeenCalledTimes(1); + }); + + // Collapse, then re-expand. Failed load should retry. + await user.click(screen.getByRole('button', { name: /settings/i })); + await user.click(screen.getByRole('button', { name: /settings/i })); + + await waitFor(() => { + expect(mockGetLogsConfiguration).toHaveBeenCalledTimes(2); + }); + await waitFor(() => { + expect(screen.getByDisplayValue('300')).toBeInTheDocument(); + }); + }); + + it('saves config via PUT and triggers one refetch', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + mockGetLogsConfiguration.mockResolvedValue({ severity_filter: 'info', max_entries: 200 }); + mockUpdateLogsConfiguration.mockResolvedValue(true); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + render(); + + await user.click(screen.getByRole('button', { name: /settings/i })); + await waitFor(() => { + expect(screen.getByDisplayValue('200')).toBeInTheDocument(); + }); + + const maxEntriesInput = screen.getByLabelText(/max entries/i); + await user.clear(maxEntriesInput); + await user.type(maxEntriesInput, '500'); + + await user.click(screen.getByRole('button', { name: /^save$/i })); + + await waitFor(() => { + expect(mockUpdateLogsConfiguration).toHaveBeenCalledWith('apps', 'motor', { + severity_filter: 'info', + max_entries: 500, + }); + }); + + // One additional fetch after save (initial + refetch = 2) + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(2); + }); + }); + + it('disables Save when max_entries is less than 1', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + mockGetLogsConfiguration.mockResolvedValue({ severity_filter: 'info', max_entries: 200 }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + render(); + await user.click(screen.getByRole('button', { name: /settings/i })); + await waitFor(() => { + expect(screen.getByDisplayValue('200')).toBeInTheDocument(); + }); + + const maxEntriesInput = screen.getByLabelText(/max entries/i); + await user.clear(maxEntriesInput); + await user.type(maxEntriesInput, '0'); + + expect(screen.getByRole('button', { name: /^save$/i })).toBeDisabled(); + }); + + it('disables Save when max_entries is greater than 10000', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + mockGetLogsConfiguration.mockResolvedValue({ severity_filter: 'info', max_entries: 200 }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + render(); + await user.click(screen.getByRole('button', { name: /settings/i })); + await waitFor(() => { + expect(screen.getByDisplayValue('200')).toBeInTheDocument(); + }); + + const maxEntriesInput = screen.getByLabelText(/max entries/i); + await user.clear(maxEntriesInput); + await user.type(maxEntriesInput, '10001'); + + expect(screen.getByRole('button', { name: /^save$/i })).toBeDisabled(); + }); + + it('renders aggregation header for areas', async () => { + mockFetchEntityLogs.mockResolvedValue({ + items: [ + { + id: 'log_1', + timestamp: '2026-04-10T12:34:56.789000000Z', + severity: 'info', + message: 'area log', + context: { node: 'chassis/brake_ctrl' }, + }, + ], + 'x-medkit': { + aggregation_level: 'area', + aggregation_sources: ['chassis/brake_ctrl', 'chassis/steering_ctrl'], + host_count: 2, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/aggregated from 2 sources/i)).toBeInTheDocument(); + }); + }); + + it('does not render aggregation header when x-medkit is missing', async () => { + mockFetchEntityLogs.mockResolvedValue({ + items: [ + { + id: 'log_1', + timestamp: '2026-04-10T12:34:56.789000000Z', + severity: 'info', + message: 'app log', + context: { node: 'motor' }, + }, + ], + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('app log')).toBeInTheDocument(); + }); + expect(screen.queryByText(/aggregated from/i)).not.toBeInTheDocument(); + }); + + it('aborts in-flight request when entity changes', async () => { + const abortedSignals: AbortSignal[] = []; + mockFetchEntityLogs.mockImplementation((_et: string, _id: string, _params: unknown, signal: AbortSignal) => { + abortedSignals.push(signal); + return new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(emptyResult())); + }); + }); + + const { rerender } = render(); + await waitFor(() => { + expect(abortedSignals).toHaveLength(1); + }); + + rerender(); + await waitFor(() => { + expect(abortedSignals).toHaveLength(2); + }); + + expect(abortedSignals[0]?.aborted).toBe(true); + }); + + it('clears interval and aborts on unmount', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + const { unmount } = render(); + + await waitFor(() => { + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + }); + + unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + // Still only the single initial call - interval cleared. + expect(mockFetchEntityLogs).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/LogsPanel.tsx b/src/components/LogsPanel.tsx new file mode 100644 index 0000000..e1da198 --- /dev/null +++ b/src/components/LogsPanel.tsx @@ -0,0 +1,502 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Download, Loader2, RefreshCw, ScrollText, Search, Settings, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { useAppStore } from '@/lib/store'; +import type { LogCollection, LogEntry, LogSeverity, LogsFetchResult, SovdResourceEntityType } from '@/lib/types'; + +interface LogsPanelProps { + entityId: string; + entityType: SovdResourceEntityType; +} + +export function LogsPanel({ entityId, entityType }: LogsPanelProps) { + const { fetchEntityLogs, getLogsConfiguration, updateLogsConfiguration } = useAppStore( + useShallow((state) => ({ + fetchEntityLogs: state.fetchEntityLogs, + getLogsConfiguration: state.getLogsConfiguration, + updateLogsConfiguration: state.updateLogsConfiguration, + })) + ); + + const [entries, setEntries] = useState([]); + const [aggregation, setAggregation] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + const [errorStatus, setErrorStatus] = useState(null); + const [lastRefreshFailed, setLastRefreshFailed] = useState(false); + + const [severity, setSeverity] = useState('debug'); + const [contextFilter, setContextFilter] = useState(''); + const [contextDraft, setContextDraft] = useState(''); + const [messageSearch, setMessageSearch] = useState(''); + + const [isCleared, setIsCleared] = useState(false); + + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const [configOpen, setConfigOpen] = useState(false); + const [configLoaded, setConfigLoaded] = useState(false); + const [configLoading, setConfigLoading] = useState(false); + const [configSeverity, setConfigSeverity] = useState('debug'); + const [configMaxEntries, setConfigMaxEntries] = useState(100); + const [configSaving, setConfigSaving] = useState(false); + + const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true); + const [refreshIntervalMs, setRefreshIntervalMs] = useState(5000); + const [isDocumentVisible, setIsDocumentVisible] = useState( + typeof document === 'undefined' ? true : document.visibilityState === 'visible' + ); + + const toggleExpand = useCallback((id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const abortRef = useRef(null); + + const doFetch = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + try { + const result: LogsFetchResult = await fetchEntityLogs( + entityType, + entityId, + { severity, context: contextFilter }, + controller.signal + ); + if (controller.signal.aborted) return; + + if (result.errorStatus !== undefined) { + if (result.errorStatus === 503) { + setErrorStatus(503); + setEntries([]); + setAggregation(undefined); + } else { + setLastRefreshFailed(true); + } + } else { + setEntries(result.items); + setAggregation(result['x-medkit']); + setErrorStatus(null); + setLastRefreshFailed(false); + setIsCleared(false); + } + } catch (err) { + if ((err as { name?: string }).name === 'AbortError') return; + setLastRefreshFailed(true); + } finally { + if (!controller.signal.aborted) { + setIsLoading(false); + } + } + }, [fetchEntityLogs, entityType, entityId, severity, contextFilter]); + + useEffect(() => { + const timer = setTimeout(() => { + setContextFilter(contextDraft); + }, 300); + return () => clearTimeout(timer); + }, [contextDraft]); + + useEffect(() => { + void doFetch(); + return () => { + abortRef.current?.abort(); + }; + }, [doFetch]); + + useEffect(() => { + const onVisibilityChange = () => { + setIsDocumentVisible(document.visibilityState === 'visible'); + }; + document.addEventListener('visibilitychange', onVisibilityChange); + return () => document.removeEventListener('visibilitychange', onVisibilityChange); + }, []); + + useEffect(() => { + if (!autoRefreshEnabled || !isDocumentVisible) return; + const id = setInterval(() => { + void doFetch(); + }, refreshIntervalMs); + return () => clearInterval(id); + }, [autoRefreshEnabled, isDocumentVisible, refreshIntervalMs, doFetch]); + + const trimmedSearch = messageSearch.trim().toLowerCase(); + const displayedEntries = trimmedSearch + ? entries.filter((e) => e.message.toLowerCase().includes(trimmedSearch)) + : entries; + + const handleClear = useCallback(() => { + setEntries([]); + setIsCleared(true); + }, []); + + const handleDownload = useCallback(() => { + const payload = JSON.stringify(displayedEntries, null, 2); + const blob = new Blob([payload], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `logs-${entityType}-${entityId}-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, [displayedEntries, entityType, entityId]); + + const toggleConfig = useCallback(async () => { + const next = !configOpen; + setConfigOpen(next); + if (next && !configLoaded) { + setConfigLoading(true); + const cfg = await getLogsConfiguration(entityType, entityId); + if (cfg) { + setConfigSeverity(cfg.severity_filter); + setConfigMaxEntries(cfg.max_entries); + setConfigLoaded(true); + } + setConfigLoading(false); + } + }, [configOpen, configLoaded, getLogsConfiguration, entityType, entityId]); + + const configValid = configMaxEntries >= 1 && configMaxEntries <= 10000; + + const handleConfigSave = useCallback(async () => { + if (!configValid) return; + setConfigSaving(true); + const ok = await updateLogsConfiguration(entityType, entityId, { + severity_filter: configSeverity, + max_entries: configMaxEntries, + }); + setConfigSaving(false); + if (ok) { + setConfigOpen(false); + void doFetch(); + } + }, [configValid, updateLogsConfiguration, entityType, entityId, configSeverity, configMaxEntries, doFetch]); + + const showContextFilter = entityType !== 'apps'; + + const toolbar = ( +
+ + {showContextFilter && ( + setContextDraft(e.target.value)} + className="h-8 w-40 text-xs" + /> + )} +
+ + setMessageSearch(e.target.value)} + className="h-8 w-48 text-xs pl-7" + /> +
+ + + + {lastRefreshFailed && Last refresh failed} + + + +
+ ); + + let body: React.JSX.Element; + if (isLoading) { + body = ( + + + + Loading logs... + + + ); + } else if (errorStatus === 503) { + body = ( + + + +

Logs not available on this gateway

+ +
+
+ ); + } else if (isCleared && entries.length === 0) { + body = ( + + + Cleared - next refresh will repopulate. + + + ); + } else if (displayedEntries.length === 0) { + body = ( + + + +

No log entries

+

+ Try a lower severity filter or wait for new logs +

+
+
+ ); + } else { + body = ( + + + {aggregation?.aggregation_level && ( +
+ Aggregated from {aggregation.host_count ?? aggregation.aggregation_sources?.length ?? 0}{' '} + sources +
+ )} + + + + + + + + + + + {displayedEntries.map((entry) => { + const isExpanded = expandedIds.has(entry.id); + return ( + + toggleExpand(entry.id)} + > + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
TimeSeverityNodeMessage
+ {formatTime(entry.timestamp)} + {entry.severity} + {entry.context.node} + + {entry.message} +
+ {entry.context.function || entry.context.file ? ( +
+ {entry.context.function && ( +
+ Function:{' '} + + {entry.context.function} + +
+ )} + {entry.context.file && ( +
+ Location:{' '} + + {entry.context.file} + {entry.context.line + ? `:${entry.context.line}` + : ''} + +
+ )} +
+ Full timestamp:{' '} + {entry.timestamp} +
+
+ ) : ( +
No source location
+ )} +
+
+
+ ); + } + + return ( +
+ {toolbar} + {configOpen && ( +
+ {configLoading ? ( +
+ Loading configuration... +
+ ) : ( + <> + + + + {!configValid && ( + max_entries must be 1..10000 + )} + + )} +
+ )} + {body} +
+ ); +} + +function formatTime(isoTimestamp: string): string { + const date = new Date(isoTimestamp); + if (Number.isNaN(date.getTime())) return isoTimestamp; + const h = String(date.getUTCHours()).padStart(2, '0'); + const m = String(date.getUTCMinutes()).padStart(2, '0'); + const s = String(date.getUTCSeconds()).padStart(2, '0'); + const ms = String(date.getUTCMilliseconds()).padStart(3, '0'); + return `${h}:${m}:${s}.${ms}`; +} diff --git a/src/lib/api-dispatch.test.ts b/src/lib/api-dispatch.test.ts index 5445804..f4323f4 100644 --- a/src/lib/api-dispatch.test.ts +++ b/src/lib/api-dispatch.test.ts @@ -34,6 +34,9 @@ import { deleteEntityExecution, getEntityBulkDataCategories, getEntityBulkData, + getEntityLogs, + getEntityLogsConfiguration, + putEntityLogsConfiguration, } from './api-dispatch'; // --------------------------------------------------------------------------- @@ -598,3 +601,130 @@ describe('error propagation', () => { expect(result?.error).toEqual(err); }); }); + +// ============================================================================= +// getEntityLogs +// ============================================================================= + +describe('getEntityLogs', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it('dispatches to /apps/{app_id}/logs', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityLogs(client as any, 'apps', 'motor_ctrl', { severity: 'warning' }); + expect(client.GET).toHaveBeenCalledWith('/apps/{app_id}/logs', { + params: { + path: { app_id: 'motor_ctrl' }, + query: { severity: 'warning' }, + }, + }); + }); + + it('dispatches to /components/{component_id}/logs with context param', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityLogs(client as any, 'components', 'powertrain', { context: 'engine' }); + expect(client.GET).toHaveBeenCalledWith('/components/{component_id}/logs', { + params: { + path: { component_id: 'powertrain' }, + query: { context: 'engine' }, + }, + }); + }); + + it('dispatches to /areas/{area_id}/logs', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityLogs(client as any, 'areas', 'chassis', {}); + expect(client.GET).toHaveBeenCalledWith('/areas/{area_id}/logs', { + params: { + path: { area_id: 'chassis' }, + query: {}, + }, + }); + }); + + it('dispatches to /functions/{function_id}/logs', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityLogs(client as any, 'functions', 'braking', {}); + expect(client.GET).toHaveBeenCalledWith('/functions/{function_id}/logs', { + params: { + path: { function_id: 'braking' }, + query: {}, + }, + }); + }); + + it('passes AbortSignal through', async () => { + const controller = new AbortController(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityLogs(client as any, 'apps', 'motor', { severity: 'error' }, controller.signal); + expect(client.GET).toHaveBeenCalledWith( + '/apps/{app_id}/logs', + expect.objectContaining({ + signal: controller.signal, + }) + ); + }); +}); + +// ============================================================================= +// getEntityLogsConfiguration +// ============================================================================= + +describe('getEntityLogsConfiguration', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it('dispatches to /apps/{app_id}/logs/configuration', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityLogsConfiguration(client as any, 'apps', 'motor'); + expect(client.GET).toHaveBeenCalledWith('/apps/{app_id}/logs/configuration', { + params: { path: { app_id: 'motor' } }, + }); + }); + + it('dispatches to /components, /areas, /functions', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityLogsConfiguration(client as any, 'components', 'c1'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityLogsConfiguration(client as any, 'areas', 'a1'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityLogsConfiguration(client as any, 'functions', 'f1'); + expect(client.GET).toHaveBeenNthCalledWith(1, '/components/{component_id}/logs/configuration', { + params: { path: { component_id: 'c1' } }, + }); + expect(client.GET).toHaveBeenNthCalledWith(2, '/areas/{area_id}/logs/configuration', { + params: { path: { area_id: 'a1' } }, + }); + expect(client.GET).toHaveBeenNthCalledWith(3, '/functions/{function_id}/logs/configuration', { + params: { path: { function_id: 'f1' } }, + }); + }); +}); + +// ============================================================================= +// putEntityLogsConfiguration +// ============================================================================= + +describe('putEntityLogsConfiguration', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it('PUTs to /apps/{app_id}/logs/configuration with body', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await putEntityLogsConfiguration(client as any, 'apps', 'motor', { + severity_filter: 'warning', + max_entries: 500, + }); + expect(client.PUT).toHaveBeenCalledWith('/apps/{app_id}/logs/configuration', { + params: { path: { app_id: 'motor' } }, + body: { severity_filter: 'warning', max_entries: 500 }, + }); + }); +}); diff --git a/src/lib/api-dispatch.ts b/src/lib/api-dispatch.ts index 0a408c5..4ccaf4d 100644 --- a/src/lib/api-dispatch.ts +++ b/src/lib/api-dispatch.ts @@ -23,6 +23,7 @@ import type { MedkitClient } from '@selfpatch/ros2-medkit-client-ts'; import type { SovdResourceEntityType } from './types'; +import type { LogsQueryParams, LogsConfiguration } from './log-types'; // ============================================================================= // Entity Detail @@ -494,3 +495,93 @@ export function getEntityBulkData( }); } } + +// ============================================================================= +// Logs +// ============================================================================= + +export function getEntityLogs( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + params: LogsQueryParams, + signal?: AbortSignal +) { + const query: Record = {}; + if (params.severity) query.severity = params.severity; + if (params.context) query.context = params.context; + + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/logs', { + params: { path: { app_id: entityId }, query }, + signal, + }); + case 'components': + return client.GET('/components/{component_id}/logs', { + params: { path: { component_id: entityId }, query }, + signal, + }); + case 'areas': + return client.GET('/areas/{area_id}/logs', { + params: { path: { area_id: entityId }, query }, + signal, + }); + case 'functions': + return client.GET('/functions/{function_id}/logs', { + params: { path: { function_id: entityId }, query }, + signal, + }); + } +} + +export function getEntityLogsConfiguration(client: MedkitClient, entityType: SovdResourceEntityType, entityId: string) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/logs/configuration', { + params: { path: { app_id: entityId } }, + }); + case 'components': + return client.GET('/components/{component_id}/logs/configuration', { + params: { path: { component_id: entityId } }, + }); + case 'areas': + return client.GET('/areas/{area_id}/logs/configuration', { + params: { path: { area_id: entityId } }, + }); + case 'functions': + return client.GET('/functions/{function_id}/logs/configuration', { + params: { path: { function_id: entityId } }, + }); + } +} + +export function putEntityLogsConfiguration( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + config: LogsConfiguration +) { + switch (entityType) { + case 'apps': + return client.PUT('/apps/{app_id}/logs/configuration', { + params: { path: { app_id: entityId } }, + body: config, + }); + case 'components': + return client.PUT('/components/{component_id}/logs/configuration', { + params: { path: { component_id: entityId } }, + body: config, + }); + case 'areas': + return client.PUT('/areas/{area_id}/logs/configuration', { + params: { path: { area_id: entityId } }, + body: config, + }); + case 'functions': + return client.PUT('/functions/{function_id}/logs/configuration', { + params: { path: { function_id: entityId } }, + body: config, + }); + } +} diff --git a/src/lib/log-types.ts b/src/lib/log-types.ts new file mode 100644 index 0000000..4c84b55 --- /dev/null +++ b/src/lib/log-types.ts @@ -0,0 +1,86 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Local TypeScript interfaces mirroring the gateway /logs JSON shape. + * + * These are defined explicitly rather than derived from the generated + * `components['schemas']` re-export because the published + * @selfpatch/ros2-medkit-client-ts@0.1.1 package is missing + * `generated/schema.js`, which silently degrades those generated types + * to `any` (masked by `skipLibCheck: true`). + * + * Reference: gateway `log_manager.cpp::entry_to_json` for the source of truth. + */ + +export type LogSeverity = 'debug' | 'info' | 'warning' | 'error' | 'fatal'; + +export interface LogContext { + /** Logger FQN without leading slash, e.g. "powertrain/engine/temp_sensor" */ + node: string; + function?: string; + file?: string; + line?: number; +} + +export interface LogEntry { + /** Server-assigned monotonic ID, e.g. "log_123" */ + id: string; + /** ISO 8601 UTC with nanosecond precision */ + timestamp: string; + severity: LogSeverity; + message: string; + context: LogContext; +} + +export interface XMedkitAggregation { + entity_id?: string; + aggregation_level?: 'function' | 'area'; + aggregated?: boolean; + aggregation_sources?: string[]; + /** Function-level aggregation: number of hosted apps contributing logs */ + host_count?: number; + /** Area-level aggregation: number of components in the area */ + component_count?: number; + /** Area-level aggregation: number of apps aggregated across all components */ + app_count?: number; +} + +export interface LogCollection { + items: LogEntry[]; + 'x-medkit'?: XMedkitAggregation; +} + +/** + * Result of a fetchEntityLogs call. On network or HTTP errors, `items` is + * empty and `errorStatus` carries the HTTP status code (or -1 for + * transport-level failures). Callers use `errorStatus === 503` to render + * the "Logs not available on this gateway" state, distinct from a zero-entry + * successful response. + */ +export interface LogsFetchResult { + items: LogEntry[]; + 'x-medkit'?: XMedkitAggregation; + errorStatus?: number; +} + +export interface LogsConfiguration { + severity_filter: LogSeverity; + max_entries: number; +} + +export interface LogsQueryParams { + severity?: LogSeverity; + context?: string; +} diff --git a/src/lib/store.ts b/src/lib/store.ts index 1dff9fc..73f4627 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -44,7 +44,11 @@ import { deleteEntityConfiguration, deleteEntityConfigurations, getEntityBulkData, + getEntityLogs, + getEntityLogsConfiguration, + putEntityLogsConfiguration, } from './api-dispatch'; +import type { LogCollection, LogsConfiguration, LogsFetchResult, LogsQueryParams } from './log-types'; const STORAGE_KEY = 'ros2_medkit_web_ui_server_url'; const EXECUTION_POLL_INTERVAL_MS = 1000; @@ -166,6 +170,18 @@ export interface AppState { entityType: SovdResourceEntityType, entityId: string ) => Promise<{ items: Fault[]; count: number }>; + fetchEntityLogs: ( + entityType: SovdResourceEntityType, + entityId: string, + params: LogsQueryParams, + signal?: AbortSignal + ) => Promise; + getLogsConfiguration: (entityType: SovdResourceEntityType, entityId: string) => Promise; + updateLogsConfiguration: ( + entityType: SovdResourceEntityType, + entityId: string, + config: LogsConfiguration + ) => Promise; getFaultWithEnvironmentData: ( entityType: SovdResourceEntityType, entityId: string, @@ -1855,6 +1871,57 @@ export const useAppStore = create()( return transformFaultsResponse(data); }, + fetchEntityLogs: async (entityType, entityId, params, signal) => { + const { client } = get(); + if (!client) return { items: [], errorStatus: -1 }; + try { + const { data, error, response } = await getEntityLogs(client, entityType, entityId, params, signal); + if (error) { + return { + items: [], + errorStatus: response?.status ?? -1, + }; + } + const collection = (data as LogCollection) ?? { items: [] }; + return { + items: collection.items ?? [], + 'x-medkit': collection['x-medkit'], + }; + } catch (err) { + if ((err as { name?: string }).name === 'AbortError') throw err; + console.error('[store] fetchEntityLogs failed', err); + return { items: [], errorStatus: -1 }; + } + }, + + getLogsConfiguration: async (entityType, entityId) => { + const { client } = get(); + if (!client) return null; + const { data, error: fetchError } = await getEntityLogsConfiguration(client, entityType, entityId); + if (fetchError) return null; + return (data as LogsConfiguration) ?? null; + }, + + updateLogsConfiguration: async (entityType, entityId, config) => { + const { client } = get(); + if (!client) return false; + try { + const { error } = await putEntityLogsConfiguration(client, entityType, entityId, config); + if (error) { + const message = (error as { message?: string }).message ?? 'Unknown error'; + console.error('[store]', error); + toast.error(`Failed to update logs configuration: ${message}`); + return false; + } + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('[store]', err); + toast.error(`Failed to update logs configuration: ${message}`); + return false; + } + }, + getFaultWithEnvironmentData: async ( entityType: SovdResourceEntityType, entityId: string, diff --git a/src/lib/types.ts b/src/lib/types.ts index b118119..11f0567 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -38,6 +38,21 @@ export type GenericError = components['schemas']['GenericError']; */ export type SovdError = GenericError; +/** + * Log-related types from local definitions. + * See log-types.ts for detailed documentation. + */ +export type { + LogEntry, + LogContext, + LogCollection, + LogsFetchResult, + LogsConfiguration, + LogsQueryParams, + LogSeverity, + XMedkitAggregation, +} from './log-types'; + // ============================================================================= // Section 2 & 3: Manual type definitions // API types with significant differences from the generated schema, and diff --git a/src/test/setup.ts b/src/test/setup.ts index bb02c60..738051a 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,13 @@ import '@testing-library/jest-dom/vitest'; + +// jsdom's Blob lacks .text() - polyfill it so download tests can read blob contents +if (typeof Blob !== 'undefined' && !Blob.prototype.text) { + Blob.prototype.text = function () { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(this); + }); + }; +} From 286f2bfa944cf959e6a75c028283e7a954c5fc96 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 11 Apr 2026 22:11:57 +0200 Subject: [PATCH 2/6] feat: unify resource tab bar across all entity types + review fixes Unification: - New resource-tabs.tsx module exports RESOURCE_TABS config and renderResourceTabContent helper as the single source of truth for Data / Operations / Configurations / Faults / Logs tabs. - AppsPanel, FunctionsPanel, and EntityDetailPanel component view merge RESOURCE_TABS into their flat tab bars so the Logs tab now appears on all four entity types, not just Areas. LogsPanel polish: - Table caps at 60vh with a scrollable body and sticky column header so the page no longer grows unboundedly. - 404 responses render the same "Logs not available" state as 503, distinguished by message (entity-specific vs gateway-wide). - Windows-safe download filename (colons/dots replaced with hyphens). - Config Save button disabled until the initial GET succeeds; a "Failed to load configuration" state with Retry replaces the silent default-form bug where the user could overwrite server config with defaults. - Row expansion is keyboard-accessible (role, tabIndex, onKeyDown, aria-expanded). - Config-row state resets on entity change so navigating between entities no longer leaks cached config. Tests: +5 new LogsPanel tests covering the review findings. --- src/components/AppsPanel.tsx | 31 ++-- src/components/EntityDetailPanel.tsx | 71 ++++----- src/components/EntityResourceTabs.tsx | 42 +---- src/components/FunctionsPanel.tsx | 39 ++--- src/components/LogsPanel.test.tsx | 127 +++++++++++++++ src/components/LogsPanel.tsx | 214 ++++++++++++++++---------- src/components/resource-tabs.tsx | 82 ++++++++++ 7 files changed, 412 insertions(+), 194 deletions(-) create mode 100644 src/components/resource-tabs.tsx diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx index 3a7f0ae..4b7ddef 100644 --- a/src/components/AppsPanel.tsx +++ b/src/components/AppsPanel.tsx @@ -1,16 +1,19 @@ import { useState, useEffect } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Cpu, Database, Zap, Settings, AlertTriangle, ChevronRight, Box, Network, FileCode } from 'lucide-react'; +import { AlertTriangle, Box, ChevronRight, Cpu, Database, FileCode, Network, Settings, Zap } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { useAppStore } from '@/lib/store'; -import { ConfigurationPanel } from '@/components/ConfigurationPanel'; -import { FaultsPanel } from '@/components/FaultsPanel'; -import { OperationsPanel } from '@/components/OperationsPanel'; +import { + RESOURCE_TABS, + renderResourceTabContent, + isResourceTabId, + type ResourceTabId, +} from '@/components/resource-tabs'; import type { ComponentTopic, Operation, Fault } from '@/lib/types'; -type AppTab = 'overview' | 'data' | 'operations' | 'configurations' | 'faults'; +type AppTab = 'overview' | ResourceTabId; interface TabConfig { id: AppTab; @@ -18,13 +21,7 @@ interface TabConfig { icon: typeof Database; } -const APP_TABS: TabConfig[] = [ - { id: 'overview', label: 'Overview', icon: Cpu }, - { id: 'data', label: 'Data', icon: Database }, - { id: 'operations', label: 'Operations', icon: Zap }, - { id: 'configurations', label: 'Config', icon: Settings }, - { id: 'faults', label: 'Faults', icon: AlertTriangle }, -]; +const APP_TABS: TabConfig[] = [{ id: 'overview', label: 'Overview', icon: Cpu }, ...RESOURCE_TABS]; interface AppsPanelProps { appId: string; @@ -313,11 +310,11 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI )} - {activeTab === 'operations' && } - - {activeTab === 'configurations' && } - - {activeTab === 'faults' && } + {/* Operations / Configurations / Faults / Logs delegated to the shared helper */} + {activeTab !== 'overview' && + activeTab !== 'data' && + isResourceTabId(activeTab) && + renderResourceTabContent(activeTab, appId, 'apps')} {isLoading &&
Loading app resources...
} diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index cf6488c..628140d 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -7,7 +7,6 @@ import { ArrowUp, ArrowDown, Database, - Zap, Settings, RefreshCw, Box, @@ -15,7 +14,6 @@ import { Cpu, GitBranch, Home, - AlertTriangle, Server, } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; @@ -26,7 +24,7 @@ import { EntityDetailSkeleton } from '@/components/EntityDetailSkeleton'; import { DataPanel } from '@/components/DataPanel'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; -import { FaultsPanel } from '@/components/FaultsPanel'; +import { RESOURCE_TABS, renderResourceTabContent, type ResourceTabId } from '@/components/resource-tabs'; import { AreasPanel } from '@/components/AreasPanel'; import { AppsPanel } from '@/components/AppsPanel'; import { FunctionsPanel } from '@/components/FunctionsPanel'; @@ -35,21 +33,16 @@ import { FaultsDashboard } from '@/components/FaultsDashboard'; import { useAppStore, type AppState } from '@/lib/store'; import type { ComponentTopic, Parameter, SovdResourceEntityType } from '@/lib/types'; -type ComponentTab = 'data' | 'operations' | 'configurations' | 'faults'; +type ComponentTab = ResourceTabId; interface TabConfig { id: ComponentTab; label: string; icon: typeof Database; - description: string; + description?: string; } -const COMPONENT_TABS: TabConfig[] = [ - { id: 'data', label: 'Data', icon: Database, description: 'Data items & messages' }, - { id: 'operations', label: 'Operations', icon: Zap, description: 'Services & actions' }, - { id: 'configurations', label: 'Config', icon: Settings, description: 'Parameters' }, - { id: 'faults', label: 'Faults', icon: AlertTriangle, description: 'Diagnostic trouble codes' }, -]; +const COMPONENT_TABS: TabConfig[] = RESOURCE_TABS; /** * Determine entity type for API calls based on entity type @@ -113,26 +106,18 @@ function ComponentTabContent({ entityType, topicsData, }: ComponentTabContentProps) { - switch (activeTab) { - case 'data': - return ( - - ); - case 'operations': - return ; - case 'configurations': - return ; - case 'faults': - return ; - default: - return null; + if (activeTab === 'data') { + return ( + + ); } + return <>{renderResourceTabContent(activeTab, entityId, entityType)}; } /** @@ -351,12 +336,13 @@ interface EntityDetailPanelProps { export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntitySelect }: EntityDetailPanelProps) { const [activeTab, setActiveTab] = useState('data'); - const [resourceCounts, setResourceCounts] = useState<{ - data: number; - operations: number; - configurations: number; - faults: number; - }>({ data: 0, operations: 0, configurations: 0, faults: 0 }); + const [resourceCounts, setResourceCounts] = useState>({ + data: 0, + operations: 0, + configurations: 0, + faults: 0, + logs: 0, + }); // Store fetched topics data for the Data tab const [topicsData, setTopicsData] = useState([]); @@ -393,9 +379,16 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit // Fetch resource counts when entity changes useEffect(() => { + const emptyCounts: Record = { + data: 0, + operations: 0, + configurations: 0, + faults: 0, + logs: 0, + }; const doFetchResourceCounts = async () => { if (!selectedEntity) { - setResourceCounts({ data: 0, operations: 0, configurations: 0, faults: 0 }); + setResourceCounts(emptyCounts); setTopicsData([]); return; } @@ -408,7 +401,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit // Only fetch counts for entity types that have resources if (!isComponent && !isApp && !isArea && !isFunction) { - setResourceCounts({ data: 0, operations: 0, configurations: 0, faults: 0 }); + setResourceCounts(emptyCounts); setTopicsData([]); return; } @@ -431,7 +424,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit setTopicsData(fetchedData); // Use the already-fetched data length instead of a separate request - setResourceCounts({ ...counts, data: fetchedData.length }); + setResourceCounts({ ...counts, data: fetchedData.length, logs: 0 }); } catch { // Silently handle errors - counts will stay at 0 } diff --git a/src/components/EntityResourceTabs.tsx b/src/components/EntityResourceTabs.tsx index c1187bf..8e329dd 100644 --- a/src/components/EntityResourceTabs.tsx +++ b/src/components/EntityResourceTabs.tsx @@ -1,32 +1,13 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Database, Zap, Settings, AlertTriangle, Loader2, MessageSquare, ScrollText } from 'lucide-react'; +import { Database, Loader2, MessageSquare } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; -import { ConfigurationPanel } from '@/components/ConfigurationPanel'; -import { OperationsPanel } from '@/components/OperationsPanel'; -import { FaultsPanel } from '@/components/FaultsPanel'; -import { LogsPanel } from '@/components/LogsPanel'; +import { RESOURCE_TABS, renderResourceTabContent, type ResourceTabId } from '@/components/resource-tabs'; import type { SovdResourceEntityType } from '@/lib/types'; import type { ComponentTopic, Operation, Fault } from '@/lib/types'; -type ResourceTab = 'data' | 'operations' | 'configurations' | 'faults' | 'logs'; - -interface TabConfig { - id: ResourceTab; - label: string; - icon: typeof Database; -} - -const RESOURCE_TABS: TabConfig[] = [ - { id: 'data', label: 'Data', icon: Database }, - { id: 'operations', label: 'Operations', icon: Zap }, - { id: 'configurations', label: 'Config', icon: Settings }, - { id: 'faults', label: 'Faults', icon: AlertTriangle }, - { id: 'logs', label: 'Logs', icon: ScrollText }, -]; - interface EntityResourceTabsProps { entityId: string; entityType: SovdResourceEntityType; @@ -51,7 +32,7 @@ interface LoadedResources { * Resources are lazy-loaded per tab to avoid unnecessary API calls. */ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate }: EntityResourceTabsProps) { - const [activeTab, setActiveTab] = useState('data'); + const [activeTab, setActiveTab] = useState('data'); const [isLoading, setIsLoading] = useState(false); const [loadedTabs, setLoadedTabs] = useState({ data: false, @@ -86,7 +67,7 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate // Lazy load resources for the active tab const loadTabResources = useCallback( - async (tab: ResourceTab) => { + async (tab: ResourceTabId) => { if (loadedTabsRef.current[tab]) return; setIsLoading(true); @@ -235,19 +216,8 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate )} - {/* Operations Tab */} - {activeTab === 'operations' && } - - {/* Configurations Tab */} - {activeTab === 'configurations' && ( - - )} - - {/* Faults Tab */} - {activeTab === 'faults' && } - - {/* Logs Tab */} - {activeTab === 'logs' && } + {/* Operations / Configurations / Faults / Logs delegated to shared helper */} + {activeTab !== 'data' && renderResourceTabContent(activeTab, entityId, entityType)} )} diff --git a/src/components/FunctionsPanel.tsx b/src/components/FunctionsPanel.tsx index dd21fc3..628400c 100644 --- a/src/components/FunctionsPanel.tsx +++ b/src/components/FunctionsPanel.tsx @@ -1,23 +1,26 @@ import { useState, useEffect } from 'react'; import { useShallow } from 'zustand/shallow'; import { - GitBranch, + AlertTriangle, + ChevronRight, Cpu, Database, - Zap, - ChevronRight, - Users, + GitBranch, Info, - Settings, - AlertTriangle, Loader2, + Settings, + Users, + Zap, } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; -import { ConfigurationPanel } from '@/components/ConfigurationPanel'; -import { OperationsPanel } from '@/components/OperationsPanel'; -import { FaultsPanel } from '@/components/FaultsPanel'; +import { + RESOURCE_TABS, + renderResourceTabContent, + isResourceTabId, + type ResourceTabId, +} from '@/components/resource-tabs'; import type { ComponentTopic, Operation, Fault } from '@/lib/types'; /** Host app object returned from /functions/{id}/hosts */ @@ -27,7 +30,7 @@ interface FunctionHost { href: string; } -type FunctionTab = 'overview' | 'hosts' | 'data' | 'operations' | 'configurations' | 'faults'; +type FunctionTab = 'overview' | 'hosts' | ResourceTabId; interface TabConfig { id: FunctionTab; @@ -38,10 +41,7 @@ interface TabConfig { const FUNCTION_TABS: TabConfig[] = [ { id: 'overview', label: 'Overview', icon: Info }, { id: 'hosts', label: 'Hosts', icon: Cpu }, - { id: 'data', label: 'Data', icon: Database }, - { id: 'operations', label: 'Operations', icon: Zap }, - { id: 'configurations', label: 'Config', icon: Settings }, - { id: 'faults', label: 'Faults', icon: AlertTriangle }, + ...RESOURCE_TABS, ]; interface FunctionsPanelProps { @@ -357,11 +357,12 @@ export function FunctionsPanel({ functionId, functionName, description, path, on )} - {activeTab === 'operations' && } - - {activeTab === 'configurations' && } - - {activeTab === 'faults' && } + {/* Operations / Configurations / Faults / Logs delegated to the shared helper */} + {activeTab !== 'overview' && + activeTab !== 'hosts' && + activeTab !== 'data' && + isResourceTabId(activeTab) && + renderResourceTabContent(activeTab, functionId, 'functions')} {isLoading && ( diff --git a/src/components/LogsPanel.test.tsx b/src/components/LogsPanel.test.tsx index 79e00fc..0171cfc 100644 --- a/src/components/LogsPanel.test.tsx +++ b/src/components/LogsPanel.test.tsx @@ -94,6 +94,17 @@ describe('LogsPanel', () => { expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); }); + it('shows "Logs not available for this entity" on 404 response', async () => { + mockFetchEntityLogs.mockResolvedValue({ items: [], errorStatus: 404 }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Logs not available for this entity/i)).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + it('shows empty state when initial fetch returns no entries', async () => { mockFetchEntityLogs.mockResolvedValue(emptyResult()); render(); @@ -573,6 +584,122 @@ describe('LogsPanel', () => { expect(screen.getByRole('button', { name: /^save$/i })).toBeDisabled(); }); + it('shows a "Failed to load configuration" state when config GET returns null', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + mockGetLogsConfiguration.mockResolvedValue(null); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + render(); + await user.click(screen.getByRole('button', { name: /settings/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to load configuration/i)).toBeInTheDocument(); + }); + // No Save button rendered while the config failed to load. + expect(screen.queryByRole('button', { name: /^save$/i })).not.toBeInTheDocument(); + }); + + it('resets config-row state when entityId changes', async () => { + mockFetchEntityLogs.mockResolvedValue(emptyResult()); + mockGetLogsConfiguration + .mockResolvedValueOnce({ severity_filter: 'info', max_entries: 200 }) + .mockResolvedValueOnce({ severity_filter: 'warning', max_entries: 500 }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + const { rerender } = render(); + + // Open gear on entity A, load config + await user.click(screen.getByRole('button', { name: /settings/i })); + await waitFor(() => { + expect(screen.getByDisplayValue('200')).toBeInTheDocument(); + }); + + // Navigate to entity B + rerender(); + + // Config row should have been closed by the entity-change reset + expect(screen.queryByDisplayValue('200')).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue('500')).not.toBeInTheDocument(); + + // Open gear on entity B - it should re-fetch with the new value + await user.click(screen.getByRole('button', { name: /settings/i })); + await waitFor(() => { + expect(screen.getByDisplayValue('500')).toBeInTheDocument(); + }); + expect(mockGetLogsConfiguration).toHaveBeenCalledTimes(2); + expect(mockGetLogsConfiguration).toHaveBeenNthCalledWith(2, 'apps', 'motor_b'); + }); + + it('download filename uses filesystem-safe timestamp (no colons or dots)', async () => { + mockFetchEntityLogs.mockResolvedValue(sampleResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:fake'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + const capturedFilenames: string[] = []; + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => { + const el = originalCreateElement(tagName) as HTMLElement; + if (tagName === 'a') { + const anchor = el as HTMLAnchorElement; + anchor.click = vi.fn(); + Object.defineProperty(anchor, 'download', { + set(value: string) { + capturedFilenames.push(value); + }, + get() { + return capturedFilenames[capturedFilenames.length - 1] ?? ''; + }, + }); + } + return el; + }); + + render(); + await waitFor(() => { + expect(screen.getByText('Temperature above 80C')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /download/i })); + + expect(capturedFilenames.length).toBeGreaterThan(0); + const filename = capturedFilenames[capturedFilenames.length - 1] ?? ''; + // ISO timestamp with dots/colons replaced by hyphens. + expect(filename).toMatch(/^logs-apps-motor-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.json$/); + // Must NOT contain colons in the timestamp portion (before `.json`). + const base = filename.replace(/\.json$/, ''); + expect(base).not.toContain(':'); + }); + + it('log row is keyboard-focusable and toggles on Enter key', async () => { + mockFetchEntityLogs.mockResolvedValue(sampleResult()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await waitFor(() => { + expect(screen.getByText('Temperature above 80C')).toBeInTheDocument(); + }); + + // Find the row by its button role (tabIndex=0 + role="button") + const rows = screen.getAllByRole('button').filter((el) => el.tagName === 'TR'); + expect(rows.length).toBeGreaterThan(0); + const firstRow = rows[0]; + if (!firstRow) throw new Error('expected at least one log row'); + expect(firstRow).toHaveAttribute('tabIndex', '0'); + expect(firstRow).toHaveAttribute('aria-expanded', 'false'); + + firstRow.focus(); + await user.keyboard('{Enter}'); + + expect(screen.getByText(/checkTemp/)).toBeInTheDocument(); + // After expansion, aria-expanded should be true on the first row. + const rowsAfter = screen.getAllByRole('button').filter((el) => el.tagName === 'TR'); + const firstRowAfter = rowsAfter[0]; + if (!firstRowAfter) throw new Error('expected at least one log row'); + expect(firstRowAfter).toHaveAttribute('aria-expanded', 'true'); + }); + it('renders aggregation header for areas', async () => { mockFetchEntityLogs.mockResolvedValue({ items: [ diff --git a/src/components/LogsPanel.tsx b/src/components/LogsPanel.tsx index e1da198..fe3bae4 100644 --- a/src/components/LogsPanel.tsx +++ b/src/components/LogsPanel.tsx @@ -93,8 +93,13 @@ export function LogsPanel({ entityId, entityType }: LogsPanelProps) { if (controller.signal.aborted) return; if (result.errorStatus !== undefined) { - if (result.errorStatus === 503) { - setErrorStatus(503); + // 404 = entity has no /logs endpoint on this gateway. + // 503 = LogManager feature not available on this gateway. + // Both are "logs not available" states - show the unavailable card. + // Any other error (network failure, 5xx) keeps last-known entries + // and surfaces a "Last refresh failed" warning in the toolbar. + if (result.errorStatus === 503 || result.errorStatus === 404) { + setErrorStatus(result.errorStatus); setEntries([]); setAggregation(undefined); } else { @@ -147,6 +152,18 @@ export function LogsPanel({ entityId, entityType }: LogsPanelProps) { return () => clearInterval(id); }, [autoRefreshEnabled, isDocumentVisible, refreshIntervalMs, doFetch]); + // Reset config-row state when the entity changes so cached values from + // the previous entity do not leak into the new one (avoids saving stale + // config to a different entity). + useEffect(() => { + setConfigOpen(false); + setConfigLoaded(false); + setConfigLoading(false); + setConfigSeverity('debug'); + setConfigMaxEntries(100); + setConfigSaving(false); + }, [entityId, entityType]); + const trimmedSearch = messageSearch.trim().toLowerCase(); const displayedEntries = trimmedSearch ? entries.filter((e) => e.message.toLowerCase().includes(trimmedSearch)) @@ -161,29 +178,36 @@ export function LogsPanel({ entityId, entityType }: LogsPanelProps) { const payload = JSON.stringify(displayedEntries, null, 2); const blob = new Blob([payload], { type: 'application/json' }); const url = URL.createObjectURL(blob); + // Replace `:` and `.` with `-` to keep the filename valid on Windows + // NTFS and avoid browser-specific sanitization surprises. + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const a = document.createElement('a'); a.href = url; - a.download = `logs-${entityType}-${entityId}-${new Date().toISOString()}.json`; + a.download = `logs-${entityType}-${entityId}-${timestamp}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }, [displayedEntries, entityType, entityId]); + const loadConfig = useCallback(async () => { + setConfigLoading(true); + const cfg = await getLogsConfiguration(entityType, entityId); + if (cfg) { + setConfigSeverity(cfg.severity_filter); + setConfigMaxEntries(cfg.max_entries); + setConfigLoaded(true); + } + setConfigLoading(false); + }, [getLogsConfiguration, entityType, entityId]); + const toggleConfig = useCallback(async () => { const next = !configOpen; setConfigOpen(next); if (next && !configLoaded) { - setConfigLoading(true); - const cfg = await getLogsConfiguration(entityType, entityId); - if (cfg) { - setConfigSeverity(cfg.severity_filter); - setConfigMaxEntries(cfg.max_entries); - setConfigLoaded(true); - } - setConfigLoading(false); + await loadConfig(); } - }, [configOpen, configLoaded, getLogsConfiguration, entityType, entityId]); + }, [configOpen, configLoaded, loadConfig]); const configValid = configMaxEntries >= 1 && configMaxEntries <= 10000; @@ -308,12 +332,14 @@ export function LogsPanel({ entityId, entityType }: LogsPanelProps) { ); - } else if (errorStatus === 503) { + } else if (errorStatus === 503 || errorStatus === 404) { + const message = + errorStatus === 503 ? 'Logs not available on this gateway' : 'Logs not available for this entity'; body = ( -

Logs not available on this gateway

+

{message}

+ ) : ( <>