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
77 changes: 77 additions & 0 deletions frontend/src/lib/api/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { apiClient } from './client';
import type {
SpansResponse,
MetricsResponse,
UsageResponse,
TelemetryHealthResponse,
SpanQueryParams,
MetricQueryParams,
UsageQueryParams
} from './types';

export async function getHealth(): Promise<TelemetryHealthResponse> {
try {
// Health endpoint returns simple {"status":"ok"} without envelope
const response = await fetch('/api/health/live');
if (!response.ok) {
const error = await response.text();
console.error('Health check failed:', response.status, error);
throw new Error(`Health check failed: ${response.status}`);
}
const data = await response.json();
// Return in expected format even if backend sends simple format
return {
status: data.status ?? 'ok',
checks: data.checks
};
} catch (err) {
console.error('Health check error:', err);
throw err;
}
}

export async function getSpans(params?: SpanQueryParams): Promise<SpansResponse> {
const searchParams = new URLSearchParams();
if (params?.service) searchParams.set('service', params.service);
if (params?.operation) searchParams.set('operation', params.operation);
if (params?.status) searchParams.set('status', params.status);
if (params?.start_time) searchParams.set('start_time', params.start_time);
if (params?.end_time) searchParams.set('end_time', params.end_time);
if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset) searchParams.set('offset', String(params.offset));

const query = searchParams.toString();
return apiClient.request<SpansResponse>({
method: 'GET',
path: `/telemetry/spans${query ? `?${query}` : ''}`
});
}

export async function getMetrics(params?: MetricQueryParams): Promise<MetricsResponse> {
const searchParams = new URLSearchParams();
if (params?.name) searchParams.set('name', params.name);
if (params?.window) searchParams.set('window', params.window);
if (params?.limit) searchParams.set('limit', String(params.limit));

const query = searchParams.toString();
return apiClient.request<MetricsResponse>({
method: 'GET',
path: `/telemetry/metrics${query ? `?${query}` : ''}`
});
}

export async function getUsage(params?: UsageQueryParams): Promise<UsageResponse> {
const searchParams = new URLSearchParams();
if (params?.agent_id) searchParams.set('agent_id', params.agent_id);
if (params?.event_type) searchParams.set('event_type', params.event_type);
if (params?.from) searchParams.set('from', params.from);
if (params?.to) searchParams.set('to', params.to);
if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset) searchParams.set('offset', String(params.offset));

const query = searchParams.toString();
return apiClient.request<UsageResponse>({
method: 'GET',
path: `/telemetry/usage${query ? `?${query}` : ''}`
});
}
4 changes: 2 additions & 2 deletions frontend/src/lib/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export interface UsageResponse {
}

// --- Telemetry: Health ---
export type HealthStatus = 'healthy' | 'degraded' | 'error';
export type HealthStatus = 'healthy' | 'degraded' | 'error' | 'ok';

export interface SubsystemCheck {
status: string;
Expand All @@ -182,7 +182,7 @@ export interface SubsystemCheck {

export interface TelemetryHealthResponse {
status: HealthStatus;
checks: Record<string, SubsystemCheck>;
checks?: Record<string, SubsystemCheck>;
}

// --- Health ---
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/components/layout/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
{ href: '/agents', icon: Bot, label: 'Agents', adminOnly: false, disabled: true },
{ href: '/chat', icon: MessageSquare, label: 'Chat', adminOnly: false, disabled: true },
{ href: '/memory', icon: HardDrive, label: 'Memory', adminOnly: false, disabled: true },
{ href: '/telemetry', icon: Activity, label: 'Telemetry', adminOnly: false, disabled: true },
{ href: '/telemetry', icon: Activity, label: 'Telemetry', adminOnly: false },
{ href: '/admin/users', icon: Shield, label: 'Admin', adminOnly: true }
]);

Expand Down
44 changes: 44 additions & 0 deletions frontend/src/lib/components/shared/DataState.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script lang="ts">
import { AlertCircle, Database } from 'lucide-svelte';
import Skeleton from '$lib/components/ui/skeleton/skeleton.svelte';
import { Button } from '$lib/components/ui/button';

type Props = {
loading?: boolean;
error?: string | null;
empty?: boolean;
emptyMessage?: string;
children?: import('svelte').Snippet;
};

let {
loading = false,
error = null,
empty = false,
emptyMessage = 'No data available',
children
}: Props = $props();
</script>

{#if loading}
<div class="space-y-2">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-3/4" />
<Skeleton class="h-4 w-1/2" />
</div>
{:else if error}
<div class="flex flex-col items-center justify-center gap-2 py-8 text-center">
<AlertCircle class="h-8 w-8 text-destructive" />
<p class="text-sm text-muted-foreground">{error}</p>
<Button variant="outline" size="sm" onclick={() => location.reload()}>
Retry
</Button>
</div>
{:else if empty}
<div class="flex flex-col items-center justify-center gap-2 py-8 text-center">
<Database class="h-8 w-8 text-muted-foreground" />
<p class="text-sm text-muted-foreground">{emptyMessage}</p>
</div>
{:else if children}
{@render children()}
{/if}
68 changes: 68 additions & 0 deletions frontend/src/lib/components/shared/Pagination.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-svelte';

type Props = {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
};

let { page, totalPages, onPageChange }: Props = $props();

const pages = $derived(() => {
const result: (number | 'ellipsis')[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
result.push(i);
}
} else {
result.push(1);
if (page > 3) result.push('ellipsis');
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
result.push(i);
}
if (page < totalPages - 2) result.push('ellipsis');
result.push(totalPages);
}
return result;
});
</script>

<nav class="flex items-center gap-1" aria-label="Pagination">
<Button
variant="ghost"
size="icon"
disabled={page <= 1}
onclick={() => onPageChange(page - 1)}
aria-label="Previous page"
>
<ChevronLeft class="h-4 w-4" />
</Button>

{#each pages() as p}
{#if p === 'ellipsis'}
<span class="px-2 text-muted-foreground">...</span>
{:else}
<Button
variant={p === page ? 'secondary' : 'ghost'}
size="icon"
onclick={() => onPageChange(p)}
aria-label="Page {p}"
aria-current={p === page ? 'page' : undefined}
>
{p}
</Button>
{/if}
{/each}

<Button
variant="ghost"
size="icon"
disabled={page >= totalPages}
onclick={() => onPageChange(page + 1)}
aria-label="Next page"
>
<ChevronRight class="h-4 w-4" />
</Button>
</nav>
109 changes: 109 additions & 0 deletions frontend/src/lib/components/telemetry/HealthCards.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<script lang="ts">
import { Card, CardHeader, CardTitle, CardContent } from '$lib/components/ui/card';
import Skeleton from '$lib/components/ui/skeleton/skeleton.svelte';
import { Database, Zap, HardDrive } from 'lucide-svelte';
import type { TelemetryHealthResponse, HealthStatus } from '$lib/api/types';

type Props = {
health?: TelemetryHealthResponse;
loading?: boolean;
error?: string | null;
};

let { health, loading = false, error = null }: Props = $props();

function getStatusColor(status: HealthStatus): string {
switch (status) {
case 'healthy':
return 'text-green-500';
case 'degraded':
return 'text-yellow-500';
case 'error':
return 'text-red-500';
default:
return 'text-muted-foreground';
}
}

function getSubsystemStatus(subsystem: string): { status: HealthStatus; icon: typeof Database; label: string } {
// Handle simple {"status":"ok"} response without checks
if (!health?.checks) {
return { status: health?.status as HealthStatus ?? 'unknown', icon: Database, label: subsystem };
}
const check = health.checks[subsystem];
const status = (check?.status ?? 'unknown') as HealthStatus;

switch (subsystem) {
case 'database':
return { status, icon: Database, label: 'Database' };
case 'nats':
return { status, icon: Zap, label: 'NATS' };
case 'cache':
return { status, icon: HardDrive, label: 'Cache' };
default:
return { status, icon: Database, label: subsystem };
}
}

const subsystems = ['database', 'nats', 'cache'];
</script>

<div class="grid gap-4 md:grid-cols-3">
{#if loading}
{#each subsystems as _}
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-4 w-4 rounded-full" />
</CardHeader>
<CardContent>
<Skeleton class="h-8 w-16 mb-2" />
<Skeleton class="h-3 w-20" />
</CardContent>
</Card>
{/each}
{:else if error}
{#each subsystems as subsystem}
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{subsystem}</CardTitle>
<Database class="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold text-red-500">Error</div>
<p class="text-xs text-muted-foreground">Failed to load</p>
</CardContent>
</Card>
{/each}
{:else if health}
{#each subsystems as subsystem}
{@const { status, icon: Icon, label } = getSubsystemStatus(subsystem)}
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{label}</CardTitle>
<Icon class="h-4 w-4 {getStatusColor(status)}" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold {getStatusColor(status)} capitalize">{status}</div>
<p class="text-xs text-muted-foreground">
{#if !health?.checks}
Backend responding
{:else if health.checks[subsystem]}
{#if health.checks[subsystem].spans_last_hour !== undefined}
{health.checks[subsystem].spans_last_hour} spans/hr
{:else if health.checks[subsystem].metrics_last_hour !== undefined}
{health.checks[subsystem].metrics_last_hour} metrics/hr
{:else if health.checks[subsystem].connections !== undefined}
{health.checks[subsystem].connections} connections
{:else if health.checks[subsystem].hit_rate !== undefined}
{Math.round(health.checks[subsystem].hit_rate * 100)}% hit rate
{/if}
{:else}
No data
{/if}
</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
3 changes: 3 additions & 0 deletions frontend/src/lib/components/ui/button/button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
disabled?: boolean;
'onclick'?: (e: MouseEvent) => void;
'aria-label'?: string;
'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | boolean;
children?: import('svelte').Snippet;
}

Expand All @@ -23,6 +24,7 @@
disabled = false,
onclick,
'aria-label': ariaLabel,
'aria-current': ariaCurrent,
children
}: Props = $props();

Expand Down Expand Up @@ -54,6 +56,7 @@
)}
{onclick}
aria-label={ariaLabel}
aria-current={ariaCurrent}
>
{#if children}
{@render children()}
Expand Down
Loading