Skip to content
Merged
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
57 changes: 48 additions & 9 deletions src/components/EntityDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ interface ComponentTabContentProps {
hasTopicsInfo: boolean;
selectEntity: (path: string) => void;
entityType: SovdResourceEntityType;
topicsData: ComponentTopic[];
topicsData: ComponentTopic[] | null;
}

function ComponentTabContent({
Expand Down Expand Up @@ -122,14 +122,22 @@ function ComponentTabContent({
}

/**
* Data tab content - shows data items
* Data tab content - shows data items.
*
* `topicsData` uses a three-state convention:
* - `null` -> parent fetch is still in flight, render a skeleton
* - `[]` -> fetch completed with no items, fall through to topicsInfo / empty state
* - `[...]` -> render the list
*
* This lets the tab distinguish "loading" from "loaded empty" without a
* second self-fetch or a duplicate network request.
*/
interface DataTabContentProps {
selectedPath: string;
selectedEntity: NonNullable<AppState['selectedEntity']>;
hasTopicsInfo: boolean;
selectEntity: (path: string) => void;
topicsData: ComponentTopic[];
topicsData: ComponentTopic[] | null;
}

function DataTabContent({
Expand All @@ -139,9 +147,30 @@ function DataTabContent({
selectEntity,
topicsData,
}: DataTabContentProps) {
// Use topicsData from props (fetched via API), or fall back to selectedEntity.topics
const topics = topicsData.length > 0 ? topicsData : (selectedEntity.topics as ComponentTopic[] | undefined);
const hasTopics = topics && topics.length > 0;
if (topicsData === null) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-muted-foreground" />
<CardTitle className="text-base">Data</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-3 md:grid-cols-2">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-14 rounded-lg border bg-muted/30 animate-pulse" />
))}
</div>
</CardContent>
</Card>
);
}

// topicsData is loaded at this point. Prefer it over the entity-level fallback
// so we never surface stale `selectedEntity.topics` when the fresh fetch is empty.
const topics = topicsData.length > 0 ? topicsData : (selectedEntity.topics as ComponentTopic[] | undefined) || [];
const hasTopics = topics.length > 0;

if (hasTopics) {
return (
Expand Down Expand Up @@ -344,8 +373,11 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
faults: 0,
logs: 0,
});
// Store fetched topics data for the Data tab
const [topicsData, setTopicsData] = useState<ComponentTopic[]>([]);
// Store fetched topics data for the Data tab. `null` means "not yet loaded
// for the current entity" so the Data tab can render a skeleton instead of
// an empty-state flash while the fetch is in flight. `[]` means "loaded,
// no items". `[...]` means "loaded with items".
const [topicsData, setTopicsData] = useState<ComponentTopic[] | null>(null);

const {
selectedPath,
Expand Down Expand Up @@ -394,6 +426,11 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
logs: 0,
};
const doFetchResourceCounts = async () => {
// Mark topicsData as "not loaded yet for the current entity" so the
// Data tab renders a skeleton instead of an empty-state flash while
// the fetch is in flight. Any previous entity's data is discarded.
setTopicsData(null);

if (!selectedEntity) {
setResourceCounts(emptyCounts);
setTopicsData([]);
Expand Down Expand Up @@ -433,7 +470,9 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
// Use the already-fetched data length instead of a separate request
setResourceCounts({ ...counts, data: fetchedData.length, logs: 0 });
} catch {
// Silently handle errors - counts will stay at 0
// On unexpected failure fall back to "loaded empty" so the UI
// doesn't get stuck showing the skeleton forever.
setTopicsData([]);
}
};

Expand Down
54 changes: 53 additions & 1 deletion src/lib/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
unwrapItems,
transformFault,
Expand Down Expand Up @@ -89,6 +89,58 @@ describe('transformFault', () => {
expect(result.timestamp).toBe(new Date(1700000000 * 1000).toISOString());
});

describe('timestamp defensive parsing', () => {
// All fallback branches must return an ISO string close to "now".
// Asserting recency (not just "doesn't throw") actually verifies the
// fallback fired and produced a sane value.
const expectRecent = (iso: string) => {
const ts = new Date(iso).getTime();
const now = Date.now();
expect(ts).toBeGreaterThan(now - 5000);
expect(ts).toBeLessThanOrEqual(now);
};

// Suppress the dev-tools breadcrumb console.warn in fallback cases.
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});

it('falls back to current time when first_occurred is 0', () => {
const result = transformFault(makeFaultInput({ first_occurred: 0 }));
expectRecent(result.timestamp);
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('falls back to current time when first_occurred is negative', () => {
const result = transformFault(makeFaultInput({ first_occurred: -1 }));
expectRecent(result.timestamp);
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('parses ISO 8601 string first_occurred', () => {
const iso = '2026-04-13T10:00:00.000Z';
const result = transformFault(makeFaultInput({ first_occurred: iso }));
expect(result.timestamp).toBe(iso);
expect(warnSpy).not.toHaveBeenCalled();
});

it('falls back to current time when first_occurred is an invalid string', () => {
const result = transformFault(makeFaultInput({ first_occurred: 'not-a-date' }));
expectRecent(result.timestamp);
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('falls back to current time when first_occurred is missing', () => {
const result = transformFault(makeFaultInput({ first_occurred: undefined }));
expectRecent(result.timestamp);
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});

it('defaults entity_type to "app" when not in raw data', () => {
const result = transformFault(makeFaultInput());
expect(result.entity_type).toBe('app');
Expand Down
27 changes: 24 additions & 3 deletions src/lib/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,10 @@ export interface RawFaultItem {
severity: number;
severity_label: string;
status: string;
first_occurred: number;
last_occurred?: number;
/** Accepted as unix seconds (number), ISO 8601 string, or missing/invalid;
* `transformFault` normalises all of these to an ISO timestamp. */
first_occurred: number | string | null | undefined;
last_occurred?: number | string | null;
occurrence_count?: number;
reporting_sources?: string[];
/** Entity type if provided by the gateway (not currently included in
Expand Down Expand Up @@ -127,7 +129,26 @@ export function transformFault(apiFault: RawFaultItem): Fault {
message: apiFault.description,
severity,
status,
timestamp: new Date(apiFault.first_occurred * 1000).toISOString(),
timestamp: (() => {
try {
if (typeof apiFault.first_occurred === 'number' && apiFault.first_occurred > 0) {
return new Date(apiFault.first_occurred * 1000).toISOString();
}
if (typeof apiFault.first_occurred === 'string') {
const parsed = new Date(apiFault.first_occurred);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
Comment thread
bburda marked this conversation as resolved.
} catch {
// fall through to the warn + fallback below
}
Comment thread
bburda marked this conversation as resolved.
// Log a breadcrumb so operators correlating with syslog can tell
// the timestamp was fabricated by the UI and not reported by the
// gateway. Dev tools only; fallback keeps rendering alive.
console.warn('[transformFault] invalid first_occurred, falling back to now:', apiFault.first_occurred);
return new Date().toISOString();
})(),
entity_id,
entity_type,
parameters: {
Expand Down
Loading