From f718b55e5d6fc0c4f5ca046de4a7ffe9cfdb2860 Mon Sep 17 00:00:00 2001 From: tobilg Date: Fri, 2 Jan 2026 16:03:06 +0100 Subject: [PATCH 1/2] Implement dashboard export & import --- .../components/dashboard/DashboardDialog.tsx | 319 ++++++++++++++++-- .../components/dashboard/DashboardToolbar.tsx | 29 +- .../components/layout/SidebarDashboards.tsx | 14 + frontend/src/lib/dashboard-export.ts | 268 +++++++++++++++ frontend/src/stores/dashboardStore.ts | 37 ++ frontend/src/types/dashboard-export.ts | 29 ++ 6 files changed, 659 insertions(+), 37 deletions(-) create mode 100644 frontend/src/lib/dashboard-export.ts create mode 100644 frontend/src/types/dashboard-export.ts diff --git a/frontend/src/components/dashboard/DashboardDialog.tsx b/frontend/src/components/dashboard/DashboardDialog.tsx index 0ad5862..5eca4e9 100644 --- a/frontend/src/components/dashboard/DashboardDialog.tsx +++ b/frontend/src/components/dashboard/DashboardDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { Dialog, DialogContent, @@ -7,9 +7,13 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Upload, FileJson, AlertCircle, Link, Loader2 } from 'lucide-react' +import type { DashboardExport } from '@/types/dashboard-export' +import { validateDashboardImport, fetchDashboardFromUrl } from '@/lib/dashboard-export' interface DashboardDialogProps { open: boolean @@ -18,6 +22,7 @@ interface DashboardDialogProps { initialName?: string initialDescription?: string onSubmit: (name: string, description?: string) => Promise + onImport?: (data: DashboardExport) => Promise } export function DashboardDialog({ @@ -27,26 +32,94 @@ export function DashboardDialog({ initialName = '', initialDescription = '', onSubmit, + onImport, }: DashboardDialogProps) { const [name, setName] = useState(initialName) const [description, setDescription] = useState(initialDescription) const [loading, setLoading] = useState(false) + const [activeTab, setActiveTab] = useState<'create' | 'import'>('create') + + // Import-specific state + const [importSource, setImportSource] = useState<'file' | 'url'>('file') + const [importFile, setImportFile] = useState(null) + const [importUrl, setImportUrl] = useState('') + const [importData, setImportData] = useState(null) + const [importErrors, setImportErrors] = useState([]) + const [fetchingUrl, setFetchingUrl] = useState(false) + const fileInputRef = useRef(null) // Reset form when dialog opens useEffect(() => { if (open) { setName(initialName) setDescription(initialDescription) + setActiveTab('create') + setImportSource('file') + setImportFile(null) + setImportUrl('') + setImportData(null) + setImportErrors([]) + setFetchingUrl(false) } }, [open, initialName, initialDescription]) + const handleFileSelect = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + setImportFile(file) + setImportErrors([]) + setImportData(null) + + try { + const text = await file.text() + const json = JSON.parse(text) + const result = validateDashboardImport(json) + + if (result.valid && result.data) { + setImportData(result.data) + } else { + setImportErrors(result.errors) + } + } catch { + setImportErrors(['Invalid JSON file']) + } + } + + const handleFetchUrl = async () => { + if (!importUrl.trim()) return + + setFetchingUrl(true) + setImportErrors([]) + setImportData(null) + + const result = await fetchDashboardFromUrl(importUrl.trim()) + + if (result.error) { + setImportErrors([result.error]) + } else if (result.data) { + const validation = validateDashboardImport(result.data) + if (validation.valid && validation.data) { + setImportData(validation.data) + } else { + setImportErrors(validation.errors) + } + } + + setFetchingUrl(false) + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!name.trim()) return setLoading(true) try { - await onSubmit(name.trim(), description.trim() || undefined) + if (activeTab === 'import' && importData && onImport) { + await onImport(importData) + } else { + if (!name.trim()) return + await onSubmit(name.trim(), description.trim() || undefined) + } onOpenChange(false) } catch (error) { console.error('Failed to save dashboard:', error) @@ -55,49 +128,237 @@ export function DashboardDialog({ } } + // For edit mode, don't show tabs + if (mode === 'edit') { + return ( + + +
+ + Rename Dashboard + Update the dashboard name. + +
+
+ + setName(e.target.value)} + placeholder="My Dashboard" + autoFocus + /> +
+
+ + + + +
+
+
+ ) + } + + // Create mode with tabs return ( - +
- - {mode === 'create' ? 'Create Dashboard' : 'Rename Dashboard'} - + Create Dashboard - {mode === 'create' - ? 'Create a new dashboard to organize your widgets.' - : 'Update the dashboard name.'} + Create a new dashboard or import from a file. -
-
- - setName(e.target.value)} - placeholder="My Dashboard" - autoFocus - /> -
- {mode === 'create' && ( + + setActiveTab(v as 'create' | 'import')} + className="mt-4" + > + + Create New + Import + + + +
+ + setName(e.target.value)} + placeholder="My Dashboard" + autoFocus + /> +
- + setDescription(e.target.value)} placeholder="Dashboard description..." />
- )} -
- + + + + {/* Import source toggle */} +
+ + +
+ + {importSource === 'file' ? ( + <> + {/* Hidden file input */} + + + {/* Drop zone / file selector */} +
fileInputRef.current?.click()} + className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center cursor-pointer hover:border-muted-foreground/50 transition-colors" + > + {importFile ? ( +
+ + {importFile.name} +
+ ) : ( +
+ +

+ Click to select a dashboard JSON file +

+
+ )} +
+ + ) : ( + <> + {/* URL input */} +
+ +
+ setImportUrl(e.target.value)} + placeholder="https://example.com/dashboard.json" + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleFetchUrl() + } + }} + /> + +
+
+ + )} + + {/* Validation errors */} + {importErrors.length > 0 && ( +
+
+ +
+

Invalid file:

+
    + {importErrors.slice(0, 5).map((error, i) => ( +
  • {error}
  • + ))} + {importErrors.length > 5 && ( +
  • ...and {importErrors.length - 5} more errors
  • + )} +
+
+
+
+ )} + + {/* Preview of valid import */} + {importData && ( +
+

{importData.name}

+ {importData.description && ( +

{importData.description}

+ )} +

+ {importData.widgets.length} widget + {importData.widgets.length !== 1 ? 's' : ''} +

+
+ )} +
+ + + - diff --git a/frontend/src/components/dashboard/DashboardToolbar.tsx b/frontend/src/components/dashboard/DashboardToolbar.tsx index 5022801..6521e8a 100644 --- a/frontend/src/components/dashboard/DashboardToolbar.tsx +++ b/frontend/src/components/dashboard/DashboardToolbar.tsx @@ -1,13 +1,15 @@ import { useEffect } from 'react' import { Button } from '@/components/ui/button' import { DateRangePicker } from '@/components/ui/date-range-picker' -import { Plus, Settings, X } from 'lucide-react' +import { Download, Plus, Settings, X } from 'lucide-react' import { useIsMobile } from '@/hooks/use-mobile' import { useDashboardStore } from '@/stores/dashboardStore' +import { downloadDashboardExport } from '@/lib/dashboard-export' export function DashboardToolbar() { const isMobile = useIsMobile() const { + dashboard, isEditMode, setEditMode, timeSelection, @@ -54,13 +56,24 @@ export function DashboardToolbar() { ) : ( - + <> + {dashboard && ( + + )} + + ) )} diff --git a/frontend/src/components/layout/SidebarDashboards.tsx b/frontend/src/components/layout/SidebarDashboards.tsx index 30eae04..5309c99 100644 --- a/frontend/src/components/layout/SidebarDashboards.tsx +++ b/frontend/src/components/layout/SidebarDashboards.tsx @@ -26,6 +26,7 @@ import { DashboardDialog } from '@/components/dashboard/DashboardDialog' import { DeleteDashboardDialog } from '@/components/dashboard/DeleteDashboardDialog' import { toast } from 'sonner' import type { Dashboard } from '@/types/dashboard' +import type { DashboardExport } from '@/types/dashboard-export' export function SidebarDashboards() { const navigate = useNavigate() @@ -43,6 +44,7 @@ export function SidebarDashboards() { dashboardsLoading, loadDashboards, createNewDashboard, + importDashboard, renameDashboard, deleteDashboardById, setAsDefault, @@ -85,6 +87,17 @@ export function SidebarDashboards() { } } + const handleImport = async (data: DashboardExport) => { + try { + const dashboard = await importDashboard(data) + toast.success(`Dashboard "${dashboard.name}" imported`) + handleNavigate(`/dashboard/${dashboard.id}`) + } catch { + toast.error('Failed to import dashboard') + throw new Error('Failed to import dashboard') + } + } + const handleRename = async (name: string) => { if (!selectedDashboard) return try { @@ -219,6 +232,7 @@ export function SidebarDashboards() { onOpenChange={setCreateDialogOpen} mode="create" onSubmit={handleCreate} + onImport={handleImport} /> 0) { + exported.config = widget.config + } + + return exported +} + +/** + * Trigger a JSON file download for the dashboard export + */ +export function downloadDashboardExport(dashboard: DashboardWithWidgets): void { + const exportData = dashboardToExport(dashboard) + const json = JSON.stringify(exportData, null, 2) + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + + const filename = `ai-observer-export-${dashboard.id}.json` + + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +// ============================================================================= +// Validation Functions +// ============================================================================= + +/** + * Validate an imported dashboard JSON against the schema + */ +export function validateDashboardImport(data: unknown): ValidationResult { + const errors: string[] = [] + + // Check basic structure + if (typeof data !== 'object' || data === null) { + return { valid: false, errors: ['Invalid JSON structure'] } + } + + const obj = data as Record + + // Check schema version + if (typeof obj.schemaVersion !== 'number') { + errors.push('Missing or invalid schemaVersion') + } else if (obj.schemaVersion > DASHBOARD_EXPORT_SCHEMA_VERSION) { + errors.push( + `Unsupported schema version ${obj.schemaVersion}. Maximum supported: ${DASHBOARD_EXPORT_SCHEMA_VERSION}` + ) + } + + // Check name + if (typeof obj.name !== 'string' || obj.name.trim() === '') { + errors.push('Dashboard name is required') + } + + // Check description (optional) + if (obj.description !== undefined && typeof obj.description !== 'string') { + errors.push('Description must be a string') + } + + // Check widgets array + if (!Array.isArray(obj.widgets)) { + errors.push('Widgets must be an array') + } else { + obj.widgets.forEach((widget, index) => { + const widgetErrors = validateWidget(widget, index) + errors.push(...widgetErrors) + }) + } + + if (errors.length > 0) { + return { valid: false, errors } + } + + return { valid: true, errors: [], data: obj as unknown as DashboardExport } +} + +/** + * Validate a single widget + */ +function validateWidget(widget: unknown, index: number): string[] { + const errors: string[] = [] + const prefix = `Widget ${index + 1}` + + if (typeof widget !== 'object' || widget === null) { + return [`${prefix}: Invalid widget structure`] + } + + const w = widget as Record + + // Check widgetType + if (typeof w.widgetType !== 'string') { + errors.push(`${prefix}: widgetType is required`) + } else { + const validTypes = Object.values(WIDGET_TYPES) + if (!validTypes.includes(w.widgetType as (typeof validTypes)[number])) { + errors.push(`${prefix}: Unknown widget type "${w.widgetType}"`) + } + } + + // Check numeric fields + const numericFields = ['gridColumn', 'gridRow', 'colSpan', 'rowSpan'] as const + for (const field of numericFields) { + if (typeof w[field] !== 'number' || w[field] < 1) { + errors.push(`${prefix}: ${field} must be a positive number`) + } + } + + // Validate grid bounds + if (typeof w.gridColumn === 'number' && typeof w.colSpan === 'number') { + if (w.gridColumn + w.colSpan - 1 > 4) { + errors.push(`${prefix}: Widget exceeds grid width (max 4 columns)`) + } + } + + // Validate config for metric widgets + if ( + w.widgetType === WIDGET_TYPES.METRIC_VALUE || + w.widgetType === WIDGET_TYPES.METRIC_CHART + ) { + if (!w.config || typeof w.config !== 'object') { + errors.push(`${prefix}: Metric widgets require config with metricName`) + } else { + const config = w.config as Record + if (typeof config.metricName !== 'string' || config.metricName.trim() === '') { + errors.push(`${prefix}: Metric widgets require config.metricName`) + } + } + } + + // Config must be object if present + if (w.config !== undefined && (typeof w.config !== 'object' || w.config === null)) { + errors.push(`${prefix}: config must be an object`) + } + + return errors +} + +// ============================================================================= +// Import Helper Functions +// ============================================================================= + +/** + * Generate a unique dashboard name if conflict exists + */ +export function generateUniqueName(baseName: string, existingNames: string[]): string { + if (!existingNames.includes(baseName)) { + return baseName + } + + // Try "Name (Copy)" first + const copyName = `${baseName} (Copy)` + if (!existingNames.includes(copyName)) { + return copyName + } + + // Then try "Name (2)", "Name (3)", etc. + let counter = 2 + while (counter <= 100) { + const numberedName = `${baseName} (${counter})` + if (!existingNames.includes(numberedName)) { + return numberedName + } + counter++ + } + + // Fallback with timestamp + return `${baseName} (${Date.now()})` +} + +/** + * Fetch dashboard JSON from a URL + */ +export async function fetchDashboardFromUrl(url: string): Promise<{ data?: unknown; error?: string }> { + // Validate URL format + try { + const parsedUrl = new URL(url) + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return { error: 'URL must use http:// or https://' } + } + } catch { + return { error: 'Invalid URL format' } + } + + try { + const response = await fetch(url) + + if (!response.ok) { + return { error: `Failed to fetch: HTTP ${response.status}` } + } + + const contentType = response.headers.get('content-type') || '' + if (!contentType.includes('application/json') && !contentType.includes('text/')) { + return { error: 'Response is not JSON' } + } + + const data = await response.json() + return { data } + } catch (err) { + if (err instanceof SyntaxError) { + return { error: 'Response is not valid JSON' } + } + return { error: 'Network error: Failed to fetch URL' } + } +} + +/** + * Derive widget title from metadata based on widget type + */ +export function deriveWidgetTitle(widget: ExportedWidget): string { + // For metric widgets, use metric metadata + if ( + (widget.widgetType === WIDGET_TYPES.METRIC_VALUE || + widget.widgetType === WIDGET_TYPES.METRIC_CHART) && + widget.config?.metricName + ) { + return getMetricMetadata(widget.config.metricName).displayName + } + + // For built-in widgets, look up from WIDGET_DEFINITIONS + const definition = WIDGET_DEFINITIONS.find((d) => d.type === widget.widgetType) + if (definition) { + return definition.label + } + + // Fallback: format widget type as title + return widget.widgetType + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) +} diff --git a/frontend/src/stores/dashboardStore.ts b/frontend/src/stores/dashboardStore.ts index 9aee0c6..dec6e20 100644 --- a/frontend/src/stores/dashboardStore.ts +++ b/frontend/src/stores/dashboardStore.ts @@ -10,6 +10,8 @@ import type { TimeSelection, } from '@/types/dashboard' import { TIMEFRAME_OPTIONS, isAbsoluteTimeSelection } from '@/types/dashboard' +import type { DashboardExport } from '@/types/dashboard-export' +import { generateUniqueName, deriveWidgetTitle } from '@/lib/dashboard-export' // Grid utility functions function getOccupiedCells(widgets: DashboardWidget[]): Set { @@ -153,6 +155,7 @@ interface DashboardState { // Dashboard list actions loadDashboards: () => Promise createNewDashboard: (name: string, description?: string) => Promise + importDashboard: (exportData: DashboardExport) => Promise renameDashboard: (id: string, name: string) => Promise updateDashboardDetails: (id: string, name: string, description?: string) => Promise deleteDashboardById: (id: string) => Promise @@ -426,6 +429,40 @@ export const useDashboardStore = create((set, get) => ({ return dashboard }, + importDashboard: async (exportData: DashboardExport) => { + const { dashboards } = get() + const existingNames = dashboards.map((d) => d.name) + + // Generate unique name if conflict exists + const uniqueName = generateUniqueName(exportData.name, existingNames) + + // Create the dashboard + const dashboard = await api.createDashboard({ + name: uniqueName, + description: exportData.description, + isDefault: false, + }) + + // Create all widgets with derived titles + for (const widget of exportData.widgets) { + const title = deriveWidgetTitle(widget) + await api.createWidget(dashboard.id, { + widgetType: widget.widgetType, + title, + gridColumn: widget.gridColumn, + gridRow: widget.gridRow, + colSpan: widget.colSpan, + rowSpan: widget.rowSpan, + config: widget.config, + }) + } + + // Refresh dashboard list + await get().loadDashboards() + + return dashboard + }, + renameDashboard: async (id: string, name: string) => { await api.updateDashboard(id, { name }) // Update local state diff --git a/frontend/src/types/dashboard-export.ts b/frontend/src/types/dashboard-export.ts new file mode 100644 index 0000000..59e93af --- /dev/null +++ b/frontend/src/types/dashboard-export.ts @@ -0,0 +1,29 @@ +import type { WidgetConfig } from './dashboard' + +// Schema version for forward compatibility +export const DASHBOARD_EXPORT_SCHEMA_VERSION = 1 + +// Matches CreateWidgetRequest from API, but without 'title' (auto-derived on import) +export interface ExportedWidget { + widgetType: string + gridColumn: number + gridRow: number + colSpan: number + rowSpan: number + config?: WidgetConfig +} + +// Matches CreateDashboardRequest from API, plus schemaVersion and widgets +export interface DashboardExport { + schemaVersion: number + name: string + description?: string + widgets: ExportedWidget[] +} + +// Validation result type +export interface ValidationResult { + valid: boolean + errors: string[] + data?: DashboardExport +} From d8a3d7b49eeeff7cfa46691aca41e2c1f6b861a9 Mon Sep 17 00:00:00 2001 From: tobilg Date: Sun, 4 Jan 2026 19:21:12 +0100 Subject: [PATCH 2/2] Add tests --- frontend/package.json | 2 +- frontend/src/lib/dashboard-export.test.ts | 485 ++++++++++++++++++++++ 2 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/dashboard-export.test.ts diff --git a/frontend/package.json b/frontend/package.json index 0417f20..412adbe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/lib/dashboard-export.test.ts b/frontend/src/lib/dashboard-export.test.ts new file mode 100644 index 0000000..aa35172 --- /dev/null +++ b/frontend/src/lib/dashboard-export.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + dashboardToExport, + validateDashboardImport, + generateUniqueName, + fetchDashboardFromUrl, + deriveWidgetTitle, +} from './dashboard-export' +import type { DashboardWithWidgets } from '@/types/dashboard' +import type { ExportedWidget } from '@/types/dashboard-export' +import { DASHBOARD_EXPORT_SCHEMA_VERSION } from '@/types/dashboard-export' +import { WIDGET_TYPES } from '@/types/dashboard' + +// Mock metricMetadata +vi.mock('@/lib/metricMetadata', () => ({ + getMetricMetadata: vi.fn((metricName: string) => ({ + displayName: `Display: ${metricName}`, + description: 'Mock description', + })), +})) + +describe('dashboard-export', () => { + describe('dashboardToExport', () => { + it('converts dashboard to export format with correct schema version', () => { + const dashboard: DashboardWithWidgets = { + id: 'dashboard-123', + name: 'My Dashboard', + description: 'Test description', + isDefault: true, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + widgets: [], + } + + const result = dashboardToExport(dashboard) + + expect(result.schemaVersion).toBe(DASHBOARD_EXPORT_SCHEMA_VERSION) + expect(result.name).toBe('My Dashboard') + expect(result.description).toBe('Test description') + expect(result.widgets).toEqual([]) + }) + + it('strips IDs and timestamps from widgets', () => { + const dashboard: DashboardWithWidgets = { + id: 'dashboard-123', + name: 'Test', + isDefault: false, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + widgets: [ + { + id: 'widget-456', + dashboardId: 'dashboard-123', + widgetType: WIDGET_TYPES.STATS_TRACES, + title: 'Total Traces', + gridColumn: 1, + gridRow: 1, + colSpan: 2, + rowSpan: 1, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }, + ], + } + + const result = dashboardToExport(dashboard) + + expect(result.widgets).toHaveLength(1) + expect(result.widgets[0]).toEqual({ + widgetType: WIDGET_TYPES.STATS_TRACES, + gridColumn: 1, + gridRow: 1, + colSpan: 2, + rowSpan: 1, + }) + // Should not have id, dashboardId, title, timestamps + expect(result.widgets[0]).not.toHaveProperty('id') + expect(result.widgets[0]).not.toHaveProperty('dashboardId') + expect(result.widgets[0]).not.toHaveProperty('title') + expect(result.widgets[0]).not.toHaveProperty('createdAt') + }) + + it('includes config for widgets that have it', () => { + const dashboard: DashboardWithWidgets = { + id: 'dashboard-123', + name: 'Test', + isDefault: false, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + widgets: [ + { + id: 'widget-456', + dashboardId: 'dashboard-123', + widgetType: WIDGET_TYPES.METRIC_CHART, + title: 'Token Usage', + gridColumn: 1, + gridRow: 1, + colSpan: 2, + rowSpan: 2, + config: { metricName: 'claude_code.token.usage', aggregate: true }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }, + ], + } + + const result = dashboardToExport(dashboard) + + expect(result.widgets[0].config).toEqual({ + metricName: 'claude_code.token.usage', + aggregate: true, + }) + }) + + it('excludes empty config objects', () => { + const dashboard: DashboardWithWidgets = { + id: 'dashboard-123', + name: 'Test', + isDefault: false, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + widgets: [ + { + id: 'widget-456', + dashboardId: 'dashboard-123', + widgetType: WIDGET_TYPES.STATS_TRACES, + title: 'Total Traces', + gridColumn: 1, + gridRow: 1, + colSpan: 1, + rowSpan: 1, + config: {}, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }, + ], + } + + const result = dashboardToExport(dashboard) + + expect(result.widgets[0]).not.toHaveProperty('config') + }) + }) + + describe('validateDashboardImport', () => { + const validExport = { + schemaVersion: 1, + name: 'Test Dashboard', + description: 'A test dashboard', + widgets: [ + { + widgetType: WIDGET_TYPES.STATS_TRACES, + gridColumn: 1, + gridRow: 1, + colSpan: 1, + rowSpan: 1, + }, + ], + } + + it('validates a correct export', () => { + const result = validateDashboardImport(validExport) + + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + expect(result.data).toEqual(validExport) + }) + + it('rejects non-object data', () => { + expect(validateDashboardImport(null).valid).toBe(false) + expect(validateDashboardImport('string').valid).toBe(false) + expect(validateDashboardImport(123).valid).toBe(false) + expect(validateDashboardImport(undefined).valid).toBe(false) + }) + + it('requires schemaVersion', () => { + const data = { ...validExport, schemaVersion: undefined } + const result = validateDashboardImport(data) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Missing or invalid schemaVersion') + }) + + it('rejects unsupported schema versions', () => { + const data = { ...validExport, schemaVersion: 999 } + const result = validateDashboardImport(data) + + expect(result.valid).toBe(false) + expect(result.errors[0]).toContain('Unsupported schema version') + }) + + it('requires dashboard name', () => { + const data = { ...validExport, name: '' } + const result = validateDashboardImport(data) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Dashboard name is required') + }) + + it('validates description is a string if present', () => { + const data = { ...validExport, description: 123 } + const result = validateDashboardImport(data) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Description must be a string') + }) + + it('requires widgets to be an array', () => { + const data = { ...validExport, widgets: 'not an array' } + const result = validateDashboardImport(data) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Widgets must be an array') + }) + + it('validates widget structure', () => { + const data = { + ...validExport, + widgets: [{ widgetType: WIDGET_TYPES.STATS_TRACES }], // missing grid fields + } + const result = validateDashboardImport(data) + + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.includes('gridColumn'))).toBe(true) + }) + + it('rejects unknown widget types', () => { + const data = { + ...validExport, + widgets: [ + { + widgetType: 'unknown_type', + gridColumn: 1, + gridRow: 1, + colSpan: 1, + rowSpan: 1, + }, + ], + } + const result = validateDashboardImport(data) + + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.includes('Unknown widget type'))).toBe(true) + }) + + it('validates grid bounds (max 4 columns)', () => { + const data = { + ...validExport, + widgets: [ + { + widgetType: WIDGET_TYPES.STATS_TRACES, + gridColumn: 3, + gridRow: 1, + colSpan: 3, // 3 + 3 - 1 = 5 > 4 + rowSpan: 1, + }, + ], + } + const result = validateDashboardImport(data) + + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.includes('exceeds grid width'))).toBe(true) + }) + + it('requires config.metricName for metric widgets', () => { + const data = { + ...validExport, + widgets: [ + { + widgetType: WIDGET_TYPES.METRIC_CHART, + gridColumn: 1, + gridRow: 1, + colSpan: 2, + rowSpan: 2, + // missing config + }, + ], + } + const result = validateDashboardImport(data) + + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.includes('metricName'))).toBe(true) + }) + + it('accepts metric widgets with valid config', () => { + const data = { + ...validExport, + widgets: [ + { + widgetType: WIDGET_TYPES.METRIC_CHART, + gridColumn: 1, + gridRow: 1, + colSpan: 2, + rowSpan: 2, + config: { metricName: 'test.metric' }, + }, + ], + } + const result = validateDashboardImport(data) + + expect(result.valid).toBe(true) + }) + }) + + describe('generateUniqueName', () => { + it('returns original name if no conflict', () => { + const result = generateUniqueName('My Dashboard', ['Other Dashboard']) + expect(result).toBe('My Dashboard') + }) + + it('appends (Copy) on first conflict', () => { + const result = generateUniqueName('My Dashboard', ['My Dashboard']) + expect(result).toBe('My Dashboard (Copy)') + }) + + it('appends (2) if (Copy) also exists', () => { + const result = generateUniqueName('My Dashboard', ['My Dashboard', 'My Dashboard (Copy)']) + expect(result).toBe('My Dashboard (2)') + }) + + it('increments number until unique', () => { + const existing = [ + 'My Dashboard', + 'My Dashboard (Copy)', + 'My Dashboard (2)', + 'My Dashboard (3)', + ] + const result = generateUniqueName('My Dashboard', existing) + expect(result).toBe('My Dashboard (4)') + }) + + it('handles empty existing names array', () => { + const result = generateUniqueName('My Dashboard', []) + expect(result).toBe('My Dashboard') + }) + }) + + describe('fetchDashboardFromUrl', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('rejects invalid URL format', async () => { + const result = await fetchDashboardFromUrl('not-a-url') + expect(result.error).toBe('Invalid URL format') + }) + + it('rejects non-http protocols', async () => { + const result = await fetchDashboardFromUrl('ftp://example.com/dashboard.json') + expect(result.error).toBe('URL must use http:// or https://') + }) + + it('fetches and returns JSON data', async () => { + const mockData = { schemaVersion: 1, name: 'Test', widgets: [] } + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve(mockData), + }) + + const result = await fetchDashboardFromUrl('https://example.com/dashboard.json') + + expect(result.data).toEqual(mockData) + expect(result.error).toBeUndefined() + }) + + it('handles HTTP errors', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + }) + + const result = await fetchDashboardFromUrl('https://example.com/dashboard.json') + + expect(result.error).toBe('Failed to fetch: HTTP 404') + }) + + it('handles non-JSON content type', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'text/html' }), + json: () => Promise.resolve({}), + }) + + const result = await fetchDashboardFromUrl('https://example.com/dashboard.json') + + // text/ is allowed, so this should succeed + expect(result.error).toBeUndefined() + }) + + it('handles invalid JSON response', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.reject(new SyntaxError('Invalid JSON')), + }) + + const result = await fetchDashboardFromUrl('https://example.com/dashboard.json') + + expect(result.error).toBe('Response is not valid JSON') + }) + + it('handles network errors', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')) + + const result = await fetchDashboardFromUrl('https://example.com/dashboard.json') + + expect(result.error).toBe('Network error: Failed to fetch URL') + }) + }) + + describe('deriveWidgetTitle', () => { + it('uses metric metadata for metric value widgets', () => { + const widget: ExportedWidget = { + widgetType: WIDGET_TYPES.METRIC_VALUE, + gridColumn: 1, + gridRow: 1, + colSpan: 1, + rowSpan: 1, + config: { metricName: 'claude_code.token.usage' }, + } + + const result = deriveWidgetTitle(widget) + + expect(result).toBe('Display: claude_code.token.usage') + }) + + it('uses metric metadata for metric chart widgets', () => { + const widget: ExportedWidget = { + widgetType: WIDGET_TYPES.METRIC_CHART, + gridColumn: 1, + gridRow: 1, + colSpan: 2, + rowSpan: 2, + config: { metricName: 'gemini_cli.api.request.latency' }, + } + + const result = deriveWidgetTitle(widget) + + expect(result).toBe('Display: gemini_cli.api.request.latency') + }) + + it('uses WIDGET_DEFINITIONS for built-in widgets', () => { + const widget: ExportedWidget = { + widgetType: WIDGET_TYPES.STATS_TRACES, + gridColumn: 1, + gridRow: 1, + colSpan: 1, + rowSpan: 1, + } + + const result = deriveWidgetTitle(widget) + + expect(result).toBe('Total Traces') + }) + + it('uses WIDGET_DEFINITIONS for active services widget', () => { + const widget: ExportedWidget = { + widgetType: WIDGET_TYPES.ACTIVE_SERVICES, + gridColumn: 1, + gridRow: 1, + colSpan: 2, + rowSpan: 1, + } + + const result = deriveWidgetTitle(widget) + + expect(result).toBe('Active Services') + }) + + it('formats unknown widget types as fallback', () => { + const widget: ExportedWidget = { + widgetType: 'custom_unknown_widget', + gridColumn: 1, + gridRow: 1, + colSpan: 1, + rowSpan: 1, + } + + const result = deriveWidgetTitle(widget) + + expect(result).toBe('Custom Unknown Widget') + }) + }) +})