From 67351bf58c6245cfefd8d8eb94498e5cd1e61f53 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 9 Dec 2025 09:29:27 +0000 Subject: [PATCH] feat: AI integration, Docker metrics, RAID display, and infrastructure improvements - Add Claude OAuth authentication support with hybrid API key/OAuth flow - Implement Docker container historical metrics in backend and charts API - Add CEPH cluster data collection and new Ceph page - Enhance RAID status display with detailed tooltips and visual indicators - Fix host deduplication logic with Docker bridge IP filtering - Fix NVMe temperature collection in host agent - Add comprehensive test coverage for new features - Improve frontend sparklines and metrics history handling - Fix navigation issues and frontend reload loops --- .agent/workflows/dev-environment.md | 46 ++ .gitignore | 1 - cmd/pulse/main.go | 9 +- frontend-modern/src/App.tsx | 15 +- .../src/api/__tests__/charts.test.ts | 259 ++++++++ frontend-modern/src/api/ai.ts | 24 + frontend-modern/src/api/charts.ts | 2 + .../components/Dashboard/EnhancedCPUBar.tsx | 2 +- .../src/components/Dashboard/MetricBar.tsx | 2 +- .../components/Dashboard/StackedMemoryBar.tsx | 2 +- .../src/components/Hosts/HostsOverview.tsx | 264 ++++++-- .../Hosts/__tests__/RAIDStatus.test.ts | 478 ++++++++++++++ .../components/Proxmox/ProxmoxSectionNav.tsx | 66 +- .../src/components/Settings/AISettings.tsx | 284 +++++++- .../src/components/Settings/Settings.tsx | 1 + .../components/shared/NodeSummaryTable.tsx | 28 +- .../src/components/shared/Sparkline.tsx | 13 +- .../src/hooks/__tests__/useResources.test.ts | 486 ++++++++++++++ frontend-modern/src/hooks/useResources.ts | 128 +++- frontend-modern/src/pages/Ceph.tsx | 366 +++++++++++ .../stores/__tests__/metricsHistory.test.ts | 409 ++++++++++++ frontend-modern/src/stores/metricsHistory.ts | 22 + .../src/types/__tests__/resource.test.ts | 251 +++++++ frontend-modern/src/types/ai.ts | 6 + .../src/utils/__tests__/format.test.ts | 315 +++++++++ .../src/utils/__tests__/metricsKeys.test.ts | 67 ++ .../src/utils/__tests__/status.test.ts | 275 ++++++++ internal/ai/providers/anthropic_oauth.go | 611 ++++++++++++++++++ internal/ai/providers/factory.go | 18 +- internal/api/ai_handlers.go | 346 ++++++++++ internal/api/charts_test.go | 439 +++++++++++++ internal/api/router.go | 123 +++- internal/api/types.go | 14 +- internal/ceph/collector.go | 353 ++++++++++ internal/config/ai.go | 52 +- internal/hostagent/agent.go | 123 ++++ internal/models/models.go | 129 ++++ internal/monitoring/host_agent_temps.go | 6 + internal/monitoring/monitor.go | 301 ++++++++- .../monitoring/monitor_host_agents_test.go | 18 +- internal/monitoring/monitor_polling.go | 52 +- internal/resources/ip_filtering_test.go | 213 ++++++ internal/resources/populate_test.go | 4 +- internal/resources/store.go | 73 ++- internal/resources/store_test.go | 142 ++-- internal/sensors/parser.go | 7 +- pkg/agents/host/report.go | 101 +++ 47 files changed, 6703 insertions(+), 243 deletions(-) create mode 100644 .agent/workflows/dev-environment.md create mode 100644 frontend-modern/src/api/__tests__/charts.test.ts create mode 100644 frontend-modern/src/components/Hosts/__tests__/RAIDStatus.test.ts create mode 100644 frontend-modern/src/hooks/__tests__/useResources.test.ts create mode 100644 frontend-modern/src/pages/Ceph.tsx create mode 100644 frontend-modern/src/stores/__tests__/metricsHistory.test.ts create mode 100644 frontend-modern/src/types/__tests__/resource.test.ts create mode 100644 frontend-modern/src/utils/__tests__/format.test.ts create mode 100644 frontend-modern/src/utils/__tests__/metricsKeys.test.ts create mode 100644 frontend-modern/src/utils/__tests__/status.test.ts create mode 100644 internal/ai/providers/anthropic_oauth.go create mode 100644 internal/api/charts_test.go create mode 100644 internal/ceph/collector.go create mode 100644 internal/resources/ip_filtering_test.go diff --git a/.agent/workflows/dev-environment.md b/.agent/workflows/dev-environment.md new file mode 100644 index 0000000..890d17d --- /dev/null +++ b/.agent/workflows/dev-environment.md @@ -0,0 +1,46 @@ +--- +description: How the development environment works and how to manage it +--- + +# Pulse Development Environment + +This is a **development-only machine**. Production is never run here. + +## Primary Service: `pulse-hot-dev` + +The dev environment uses a **single systemd service** that runs both backend and frontend with hot-reloading: + +```bash +# Status check +systemctl status pulse-hot-dev + +# Restart (if needed) +// turbo +sudo systemctl restart pulse-hot-dev + +# View logs +journalctl -u pulse-hot-dev -f +``` + +### What `pulse-hot-dev` does: +1. **Go Backend** (`./pulse`): Monitored by `inotifywait` - auto-rebuilds and restarts when `.go` files change +2. **Vite Frontend**: HMR enabled - browser updates instantly when frontend files change + +### Access URLs: +- **Frontend**: http://192.168.0.123:5173/ (Vite dev server with HMR) +- **Backend API**: http://192.168.0.123:7655/ (Go server, proxied through Vite) + +## DO NOT USE these services in development: +- `pulse.service` - This is for production (runs pre-built binary without hot-reload) +- Do NOT create separate `pulse-frontend.service` - the hot-dev script handles everything + +## When things don't work: + +1. **Frontend not loading at :5173** → Check if `pulse-hot-dev` is running +2. **Backend changes not reflected** → Check logs: `journalctl -u pulse-hot-dev -f` +3. **Need full restart** → `sudo systemctl restart pulse-hot-dev` + +## Key Files: +- **Hot-dev script**: `/opt/pulse/scripts/hot-dev.sh` +- **Systemd service**: `/etc/systemd/system/pulse-hot-dev.service` +- **Makefile targets**: `make dev` or `make dev-hot` diff --git a/.gitignore b/.gitignore index 81f66e9..431a06a 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,3 @@ tmp_*.sh # Local agent directories scripts/agent/ docs/internal/ -.agent/ diff --git a/cmd/pulse/main.go b/cmd/pulse/main.go index a299ac9..ef3cb0c 100644 --- a/cmd/pulse/main.go +++ b/cmd/pulse/main.go @@ -141,8 +141,15 @@ func runServer() { } // Set state getter for WebSocket hub + // IMPORTANT: Return StateFrontend (not StateSnapshot) to match broadcast format. + // StateSnapshot uses time.Time fields while StateFrontend uses Unix timestamps, + // and includes frontend-specific field transformations. Without this conversion, + // nodes/hosts would be missing on initial page load but appear after broadcasts. wsHub.SetStateGetter(func() interface{} { - return reloadableMonitor.GetState() + // GetMonitor().GetState() returns models.StateSnapshot + state := reloadableMonitor.GetMonitor().GetState() + // Convert to frontend format, matching what BroadcastState does + return state.ToFrontend() }) // Wire up Prometheus metrics for alert lifecycle diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index d7a14ea..ce3f104 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -56,6 +56,7 @@ const StorageComponent = lazy(() => import('./components/Storage/Storage')); const Backups = lazy(() => import('./components/Backups/Backups')); const Replication = lazy(() => import('./components/Replication/Replication')); const MailGateway = lazy(() => import('./components/PMG/MailGateway')); +const CephPage = lazy(() => import('./pages/Ceph')); const AlertsPage = lazy(() => import('./pages/Alerts').then((module) => ({ default: module.Alerts })), ); @@ -105,18 +106,9 @@ function DockerRoute() { return ; } -// Hosts route component - uses unified resources via useResourcesAsLegacy hook +// Hosts route component - HostsOverview uses useResourcesAsLegacy directly for proper reactivity function HostsRoute() { - const wsContext = useContext(WebSocketContext); - if (!wsContext) { - return
Loading...
; - } - const { state } = wsContext; - const { asHosts } = useResourcesAsLegacy(); - - return ( - - ); + return ; } // Helper to detect if an update is actively in progress (not just checking for updates) @@ -877,6 +869,7 @@ function App() { } /> + diff --git a/frontend-modern/src/api/__tests__/charts.test.ts b/frontend-modern/src/api/__tests__/charts.test.ts new file mode 100644 index 0000000..b50c7dd --- /dev/null +++ b/frontend-modern/src/api/__tests__/charts.test.ts @@ -0,0 +1,259 @@ +/** + * Tests for Charts API types and interface + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import type { ChartData, ChartsResponse, TimeRange, MetricPoint, ChartStats } from '@/api/charts'; + +// Note: We test the types and interfaces here since the actual API calls +// require a running backend. Integration tests should cover the full flow. + +describe('Charts API Types', () => { + describe('TimeRange', () => { + it('supports all expected time range values', () => { + const validRanges: TimeRange[] = ['5m', '15m', '30m', '1h', '4h', '12h', '24h', '7d']; + + validRanges.forEach(range => { + // This is a compile-time check - if it compiles, the types are correct + const r: TimeRange = range; + expect(r).toBe(range); + }); + }); + }); + + describe('MetricPoint', () => { + it('has required timestamp and value properties', () => { + const point: MetricPoint = { + timestamp: 1733700000000, + value: 45.7, + }; + + expect(point.timestamp).toBe(1733700000000); + expect(point.value).toBe(45.7); + }); + }); + + describe('ChartData', () => { + it('supports all metric types', () => { + const data: ChartData = { + cpu: [{ timestamp: 1000, value: 50 }], + memory: [{ timestamp: 1000, value: 60 }], + disk: [{ timestamp: 1000, value: 70 }], + diskread: [{ timestamp: 1000, value: 1000000 }], + diskwrite: [{ timestamp: 1000, value: 500000 }], + netin: [{ timestamp: 1000, value: 2000000 }], + netout: [{ timestamp: 1000, value: 1500000 }], + }; + + expect(data.cpu).toHaveLength(1); + expect(data.memory![0].value).toBe(60); + }); + + it('allows partial data (all fields optional)', () => { + const cpuOnly: ChartData = { + cpu: [{ timestamp: 1000, value: 25 }], + }; + + expect(cpuOnly.cpu).toBeDefined(); + expect(cpuOnly.memory).toBeUndefined(); + expect(cpuOnly.disk).toBeUndefined(); + }); + + it('allows empty arrays for metrics', () => { + const emptyData: ChartData = { + cpu: [], + memory: [], + }; + + expect(emptyData.cpu).toHaveLength(0); + }); + }); + + describe('ChartsResponse', () => { + it('includes all required fields', () => { + const response: ChartsResponse = { + data: {}, + nodeData: {}, + storageData: {}, + timestamp: Date.now(), + stats: { oldestDataTimestamp: Date.now() - 3600000 }, + }; + + expect(response.data).toBeDefined(); + expect(response.nodeData).toBeDefined(); + expect(response.timestamp).toBeGreaterThan(0); + }); + + it('supports optional dockerData field', () => { + const response: ChartsResponse = { + data: {}, + nodeData: {}, + storageData: {}, + dockerData: { + 'container-abc123': { + cpu: [{ timestamp: 1000, value: 10 }], + memory: [{ timestamp: 1000, value: 20 }], + }, + }, + timestamp: Date.now(), + stats: { oldestDataTimestamp: Date.now() }, + }; + + expect(response.dockerData).toBeDefined(); + expect(response.dockerData!['container-abc123'].cpu).toHaveLength(1); + }); + + it('supports optional dockerHostData field', () => { + const response: ChartsResponse = { + data: {}, + nodeData: {}, + storageData: {}, + dockerHostData: { + 'host-1': { + cpu: [{ timestamp: 1000, value: 30 }], + memory: [{ timestamp: 1000, value: 40 }], + }, + }, + timestamp: Date.now(), + stats: { oldestDataTimestamp: Date.now() }, + }; + + expect(response.dockerHostData).toBeDefined(); + expect(response.dockerHostData!['host-1'].cpu![0].value).toBe(30); + }); + + it('supports optional guestTypes field for VM/container type mapping', () => { + const response: ChartsResponse = { + data: { + 'vm-100': { cpu: [{ timestamp: 1000, value: 50 }] }, + 'ct-200': { cpu: [{ timestamp: 1000, value: 30 }] }, + }, + nodeData: {}, + storageData: {}, + guestTypes: { + 'vm-100': 'vm', + 'ct-200': 'container', + }, + timestamp: Date.now(), + stats: { oldestDataTimestamp: Date.now() }, + }; + + expect(response.guestTypes!['vm-100']).toBe('vm'); + expect(response.guestTypes!['ct-200']).toBe('container'); + }); + + it('contains all data sources for comprehensive monitoring', () => { + const comprehensiveResponse: ChartsResponse = { + // VM and container metrics (legacy format) + data: { + 'pve1/qemu/100': { + cpu: [{ timestamp: 1000, value: 45 }], + memory: [{ timestamp: 1000, value: 55 }], + disk: [{ timestamp: 1000, value: 30 }], + }, + 'pve1/lxc/200': { + cpu: [{ timestamp: 1000, value: 15 }], + }, + }, + // Node metrics + nodeData: { + 'pve1': { + cpu: [{ timestamp: 1000, value: 35 }], + memory: [{ timestamp: 1000, value: 65 }], + }, + }, + // Storage metrics + storageData: { + 'local-zfs': { + disk: [{ timestamp: 1000, value: 50 }], + }, + }, + // Docker container metrics + dockerData: { + 'abc123': { + cpu: [{ timestamp: 1000, value: 5 }], + memory: [{ timestamp: 1000, value: 128 }], + }, + }, + // Docker host metrics + dockerHostData: { + 'docker-host-1': { + cpu: [{ timestamp: 1000, value: 25 }], + }, + }, + // Guest type mapping + guestTypes: { + 'pve1/qemu/100': 'vm', + 'pve1/lxc/200': 'container', + }, + timestamp: 1733700000000, + stats: { + oldestDataTimestamp: 1733696400000, // 1 hour ago + }, + }; + + expect(Object.keys(comprehensiveResponse.data)).toHaveLength(2); + expect(Object.keys(comprehensiveResponse.nodeData)).toHaveLength(1); + expect(Object.keys(comprehensiveResponse.dockerData!)).toHaveLength(1); + expect(Object.keys(comprehensiveResponse.dockerHostData!)).toHaveLength(1); + }); + }); + + describe('ChartStats', () => { + it('contains oldest data timestamp for data availability', () => { + const stats: ChartStats = { + oldestDataTimestamp: 1733696400000, + }; + + expect(stats.oldestDataTimestamp).toBe(1733696400000); + }); + + it('can be used to determine available data range', () => { + const now = Date.now(); + const oneHourAgo = now - 3600000; + + const stats: ChartStats = { + oldestDataTimestamp: oneHourAgo, + }; + + const availableRangeMs = now - stats.oldestDataTimestamp; + expect(availableRangeMs).toBeCloseTo(3600000, -2); // Allow 100ms tolerance + }); + }); +}); + +describe('Time Range to Milliseconds Conversion', () => { + // This tests the concept matching metricsHistory.ts timeRangeToMs + function timeRangeToMs(range: TimeRange): number { + switch (range) { + case '5m': return 5 * 60 * 1000; + case '15m': return 15 * 60 * 1000; + case '30m': return 30 * 60 * 1000; + case '1h': return 60 * 60 * 1000; + case '4h': return 4 * 60 * 60 * 1000; + case '12h': return 12 * 60 * 60 * 1000; + case '24h': return 24 * 60 * 60 * 1000; + case '7d': return 7 * 24 * 60 * 60 * 1000; + default: return 60 * 60 * 1000; + } + } + + const expectedValues: [TimeRange, number][] = [ + ['5m', 300000], + ['15m', 900000], + ['30m', 1800000], + ['1h', 3600000], + ['4h', 14400000], + ['12h', 43200000], + ['24h', 86400000], + ['7d', 604800000], + ]; + + it.each(expectedValues)('converts %s to %d ms', (range, expectedMs) => { + expect(timeRangeToMs(range)).toBe(expectedMs); + }); + + it('defaults to 1 hour for unknown range', () => { + // @ts-expect-error - Testing invalid input + expect(timeRangeToMs('invalid')).toBe(3600000); + }); +}); diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index de4547e..c8b9122 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -32,6 +32,30 @@ export class AIAPI { }) as Promise; } + // Start OAuth flow for Claude Pro/Max subscription + // Returns the authorization URL to redirect the user to + static async startOAuth(): Promise<{ auth_url: string; state: string }> { + return apiFetchJSON(`${this.baseUrl}/ai/oauth/start`, { + method: 'POST', + }) as Promise<{ auth_url: string; state: string }>; + } + + // Exchange manually-pasted authorization code for tokens + static async exchangeOAuthCode(code: string, state: string): Promise<{ success: boolean; message: string }> { + return apiFetchJSON(`${this.baseUrl}/ai/oauth/exchange`, { + method: 'POST', + body: JSON.stringify({ code, state }), + }) as Promise<{ success: boolean; message: string }>; + } + + // Disconnect OAuth and clear tokens + static async disconnectOAuth(): Promise<{ success: boolean; message: string }> { + return apiFetchJSON(`${this.baseUrl}/ai/oauth/disconnect`, { + method: 'POST', + }) as Promise<{ success: boolean; message: string }>; + } + + // Execute an AI prompt static async execute(request: AIExecuteRequest): Promise { return apiFetchJSON(`${this.baseUrl}/ai/execute`, { diff --git a/frontend-modern/src/api/charts.ts b/frontend-modern/src/api/charts.ts index 962d8ab..9a8f23f 100644 --- a/frontend-modern/src/api/charts.ts +++ b/frontend-modern/src/api/charts.ts @@ -31,6 +31,8 @@ export interface ChartsResponse { data: Record; // VM/Container data keyed by ID nodeData: Record; // Node data keyed by ID storageData: Record; // Storage data keyed by ID + dockerData?: Record; // Docker container data keyed by container ID + dockerHostData?: Record; // Docker host data keyed by host ID guestTypes?: Record; // Maps guest ID to type timestamp: number; stats: ChartStats; diff --git a/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx b/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx index 9785abd..3606625 100644 --- a/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx +++ b/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx @@ -117,7 +117,7 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) { } > {/* Sparkline mode - full width, flex centered like stacked bars */} -
+
{/* Sparkline mode - full width, flex centered like stacked bars */} -
+
{/* Sparkline mode - full width, flex centered like stacked bars */} -
+
| null | u ); } -type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | 'uptime'; - -interface HostsOverviewProps { - hosts: Host[]; - connectionHealth: Record; +// RAID status cell with rich tooltip showing array details +interface HostRAIDStatusCellProps { + raid: HostRAIDArray[] | undefined; } -export const HostsOverview: Component = (props) => { +function HostRAIDStatusCell(props: HostRAIDStatusCellProps) { + const [showTooltip, setShowTooltip] = createSignal(false); + const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); + + const hasArrays = () => props.raid && props.raid.length > 0; + + // Analyze overall status + const status = createMemo(() => { + if (!props.raid || props.raid.length === 0) { + return { type: 'none' as const, label: '-', color: 'text-gray-400' }; + } + + let hasDegraded = false; + let hasRebuilding = false; + let maxRebuildPercent = 0; + + for (const array of props.raid) { + const state = array.state.toLowerCase(); + if (state.includes('degraded') || array.failedDevices > 0) { + hasDegraded = true; + } + if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) { + hasRebuilding = true; + maxRebuildPercent = Math.max(maxRebuildPercent, array.rebuildPercent); + } + } + + if (hasDegraded) { + return { type: 'degraded' as const, label: 'Degraded', color: 'text-red-600 dark:text-red-400' }; + } + if (hasRebuilding) { + return { + type: 'rebuilding' as const, + label: `${Math.round(maxRebuildPercent)}%`, + color: 'text-amber-600 dark:text-amber-400' + }; + } + return { type: 'ok' as const, label: 'OK', color: 'text-green-600 dark:text-green-400' }; + }); + + const handleMouseEnter = (e: MouseEvent) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + setShowTooltip(true); + }; + + const handleMouseLeave = () => { + setShowTooltip(false); + }; + + // Get state color for individual devices + const getDeviceStateColor = (state: string) => { + const s = state.toLowerCase(); + if (s.includes('active') || s.includes('sync')) return 'text-green-400'; + if (s.includes('spare')) return 'text-blue-400'; + if (s.includes('faulty') || s.includes('removed')) return 'text-red-400'; + if (s.includes('rebuilding')) return 'text-amber-400'; + return 'text-gray-400'; + }; + + // Get array state color + const getArrayStateColor = (array: HostRAIDArray) => { + const state = array.state.toLowerCase(); + if (state.includes('degraded') || array.failedDevices > 0) return 'text-red-400'; + if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) return 'text-amber-400'; + if (state.includes('clean') || state.includes('active')) return 'text-green-400'; + return 'text-gray-400'; + }; + + return ( + <> + + }> + {/* Status icon */} + + + + + + + + + + + + + + + + + {props.raid!.length > 1 ? `${props.raid!.length} ` : ''}{status().label} + + + + + + +
+
+
+ + + + RAID Arrays ({props.raid!.length}) +
+ +
+ + {(array) => ( +
+ {/* Array header */} +
+
+ {array.device} + {array.level} +
+ + {array.state} + +
+ + {/* Array name if present */} + +
{array.name}
+
+ + {/* Device counts */} +
+ Active: {array.activeDevices} + Working: {array.workingDevices} + 0}> + Spare: {array.spareDevices} + + 0}> + Failed: {array.failedDevices} + +
+ + {/* Rebuild progress */} + 0}> +
+
+ Rebuilding + {array.rebuildPercent.toFixed(1)}% +
+
+
+
+ +
+ Speed: {array.rebuildSpeed} +
+
+
+ + + {/* Individual devices */} + 0}> +
+ + {(dev) => ( + + {dev.device.replace('/dev/', '')} + + )} + +
+
+
+ )} + +
+
+
+ + + + ); +} + +type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | 'uptime'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface HostsOverviewProps { } + +export const HostsOverview: Component = () => { const navigate = useNavigate(); const wsContext = useWebSocket(); const [search, setSearch] = createSignal(''); @@ -278,6 +479,10 @@ export const HostsOverview: Component = (props) => { const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); const { isMobile } = useBreakpoint(); + // Use the hook directly to ensure reactivity is maintained + // This fixes the issue where props.hosts would not update when the underlying data changes + const { asHosts } = useResourcesAsLegacy(); + // Column visibility management const columnVisibility = useColumnVisibility( STORAGE_KEYS.HOSTS_HIDDEN_COLUMNS, @@ -331,16 +536,19 @@ export const HostsOverview: Component = (props) => { const reconnecting = () => wsContext.reconnecting(); const reconnect = () => wsContext.reconnect(); + // Access asHosts() directly inside the memo to maintain reactivity + const hosts = () => asHosts() as Host[]; + const isInitialLoading = createMemo(() => { - return !connected() && !reconnecting() && props.hosts.length === 0; + return !connected() && !reconnecting() && hosts().length === 0; }); const sortedHosts = createMemo(() => { - const hosts = [...props.hosts]; + const hostList = [...hosts()]; const key = sortKey(); const direction = sortDirection(); - return hosts.sort((a, b) => { + return hostList.sort((a: Host, b: Host) => { let comparison = 0; switch (key) { case 'name': @@ -356,8 +564,8 @@ export const HostsOverview: Component = (props) => { comparison = (a.memory?.usage ?? 0) - (b.memory?.usage ?? 0); break; case 'disk': { - const aDisk = a.disks?.reduce((sum, d) => sum + (d.usage ?? 0), 0) ?? 0; - const bDisk = b.disks?.reduce((sum, d) => sum + (d.usage ?? 0), 0) ?? 0; + const aDisk = a.disks?.reduce((sum: number, d: { usage?: number }) => sum + (d.usage ?? 0), 0) ?? 0; + const bDisk = b.disks?.reduce((sum: number, d: { usage?: number }) => sum + (d.usage ?? 0), 0) ?? 0; comparison = aDisk - bDisk; break; } @@ -410,27 +618,6 @@ export const HostsOverview: Component = (props) => { - const getRaidStatus = (host: Host): { status: string; color: string } => { - if (!host.raid || host.raid.length === 0) return { status: '-', color: 'text-gray-400' }; - - let hasDegraded = false; - let hasRebuilding = false; - - for (const array of host.raid) { - const state = array.state.toLowerCase(); - if (state.includes('degraded') || array.failedDevices > 0) { - hasDegraded = true; - } - if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) { - hasRebuilding = true; - } - } - - if (hasDegraded) return { status: 'Degraded', color: 'text-red-600 dark:text-red-400' }; - if (hasRebuilding) return { status: 'Rebuild', color: 'text-amber-600 dark:text-amber-400' }; - return { status: 'OK', color: 'text-green-600 dark:text-green-400' }; - }; - const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"; return ( @@ -604,13 +791,13 @@ export const HostsOverview: Component = (props) => { Kernel - RAID + RAID - {(host) => } + {(host) => } @@ -661,8 +848,6 @@ interface HostRowProps { isMobile: () => boolean; getDiskStats: (host: Host) => { percent: number; used: number; total: number }; - - getRaidStatus: (host: Host) => { status: string; color: string }; } const HostRow: Component = (props) => { @@ -709,7 +894,6 @@ const HostRow: Component = (props) => { const cpuPercent = host.cpuUsage ?? 0; const memPercent = host.memory?.usage ?? 0; const diskStats = props.getDiskStats(host); - const raidStatus = props.getRaidStatus(host); const rowClass = () => { const base = 'transition-all duration-200'; @@ -909,9 +1093,7 @@ const HostRow: Component = (props) => {
- - {raidStatus.status} - +
diff --git a/frontend-modern/src/components/Hosts/__tests__/RAIDStatus.test.ts b/frontend-modern/src/components/Hosts/__tests__/RAIDStatus.test.ts new file mode 100644 index 0000000..b7a9f6d --- /dev/null +++ b/frontend-modern/src/components/Hosts/__tests__/RAIDStatus.test.ts @@ -0,0 +1,478 @@ +/** + * Tests for RAID status display logic + * + * These tests cover the RAID status analysis and display logic used in HostsOverview. + */ +import { describe, expect, it } from 'vitest'; + +// Mock types matching HostRAIDArray from api.ts +interface HostRAIDDevice { + device: string; + state: string; + slot: number; +} + +interface HostRAIDArray { + device: string; + name?: string; + level: string; + state: string; + totalDevices: number; + activeDevices: number; + workingDevices: number; + failedDevices: number; + spareDevices: number; + uuid?: string; + devices: HostRAIDDevice[]; + rebuildPercent: number; + rebuildSpeed?: string; +} + +// Status analysis logic matching HostRAIDStatusCell +type RAIDStatusType = 'none' | 'ok' | 'degraded' | 'rebuilding'; + +interface RAIDStatus { + type: RAIDStatusType; + label: string; + color: string; +} + +function analyzeRAIDStatus(raid: HostRAIDArray[] | undefined): RAIDStatus { + if (!raid || raid.length === 0) { + return { type: 'none', label: '-', color: 'text-gray-400' }; + } + + let hasDegraded = false; + let hasRebuilding = false; + let maxRebuildPercent = 0; + + for (const array of raid) { + const state = array.state.toLowerCase(); + if (state.includes('degraded') || array.failedDevices > 0) { + hasDegraded = true; + } + if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) { + hasRebuilding = true; + maxRebuildPercent = Math.max(maxRebuildPercent, array.rebuildPercent); + } + } + + if (hasDegraded) { + return { type: 'degraded', label: 'Degraded', color: 'text-red-600 dark:text-red-400' }; + } + if (hasRebuilding) { + return { + type: 'rebuilding', + label: `${Math.round(maxRebuildPercent)}%`, + color: 'text-amber-600 dark:text-amber-400' + }; + } + return { type: 'ok', label: 'OK', color: 'text-green-600 dark:text-green-400' }; +} + +function getDeviceStateColor(state: string): string { + const s = state.toLowerCase(); + if (s.includes('active') || s.includes('sync')) return 'text-green-400'; + if (s.includes('spare')) return 'text-blue-400'; + if (s.includes('faulty') || s.includes('removed')) return 'text-red-400'; + if (s.includes('rebuilding')) return 'text-amber-400'; + return 'text-gray-400'; +} + +function getArrayStateColor(array: HostRAIDArray): string { + const state = array.state.toLowerCase(); + if (state.includes('degraded') || array.failedDevices > 0) return 'text-red-400'; + if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) return 'text-amber-400'; + if (state.includes('clean') || state.includes('active')) return 'text-green-400'; + return 'text-gray-400'; +} + +describe('RAID Status Analysis', () => { + describe('analyzeRAIDStatus', () => { + it('returns none status for undefined raid', () => { + const status = analyzeRAIDStatus(undefined); + expect(status.type).toBe('none'); + expect(status.label).toBe('-'); + }); + + it('returns none status for empty raid array', () => { + const status = analyzeRAIDStatus([]); + expect(status.type).toBe('none'); + }); + + it('returns ok status for healthy RAID1', () => { + const raid: HostRAIDArray[] = [{ + device: '/dev/md0', + level: 'raid1', + state: 'clean, active', + totalDevices: 2, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, + spareDevices: 0, + devices: [ + { device: 'sda1', state: 'active sync', slot: 0 }, + { device: 'sdb1', state: 'active sync', slot: 1 }, + ], + rebuildPercent: 0, + }]; + + const status = analyzeRAIDStatus(raid); + expect(status.type).toBe('ok'); + expect(status.label).toBe('OK'); + expect(status.color).toContain('green'); + }); + + it('returns degraded status when array has failed devices', () => { + const raid: HostRAIDArray[] = [{ + device: '/dev/md0', + level: 'raid1', + state: 'clean, degraded', + totalDevices: 2, + activeDevices: 1, + workingDevices: 1, + failedDevices: 1, + spareDevices: 0, + devices: [ + { device: 'sda1', state: 'active sync', slot: 0 }, + { device: 'sdb1', state: 'faulty', slot: 1 }, + ], + rebuildPercent: 0, + }]; + + const status = analyzeRAIDStatus(raid); + expect(status.type).toBe('degraded'); + expect(status.label).toBe('Degraded'); + expect(status.color).toContain('red'); + }); + + it('returns degraded status when state includes degraded', () => { + const raid: HostRAIDArray[] = [{ + device: '/dev/md0', + level: 'raid5', + state: 'degraded, recovering', + totalDevices: 3, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, // No failed but degraded state + spareDevices: 0, + devices: [], + rebuildPercent: 50, + }]; + + const status = analyzeRAIDStatus(raid); + expect(status.type).toBe('degraded'); + }); + + it('returns rebuilding status with percentage', () => { + const raid: HostRAIDArray[] = [{ + device: '/dev/md0', + level: 'raid1', + state: 'clean, recovering', + totalDevices: 2, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, + spareDevices: 0, + devices: [], + rebuildPercent: 45.5, + rebuildSpeed: '100MB/s', + }]; + + const status = analyzeRAIDStatus(raid); + expect(status.type).toBe('rebuilding'); + expect(status.label).toBe('46%'); // Rounded + expect(status.color).toContain('amber'); + }); + + it('returns max rebuild percentage when multiple arrays rebuilding', () => { + const raid: HostRAIDArray[] = [ + { + device: '/dev/md0', + level: 'raid1', + state: 'recovering', + totalDevices: 2, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, + spareDevices: 0, + devices: [], + rebuildPercent: 30, + }, + { + device: '/dev/md1', + level: 'raid1', + state: 'recovering', + totalDevices: 2, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, + spareDevices: 0, + devices: [], + rebuildPercent: 75, + }, + ]; + + const status = analyzeRAIDStatus(raid); + expect(status.type).toBe('rebuilding'); + expect(status.label).toBe('75%'); + }); + + it('prioritizes degraded over rebuilding', () => { + const raid: HostRAIDArray[] = [ + { + device: '/dev/md0', + level: 'raid1', + state: 'clean, degraded, recovering', + totalDevices: 2, + activeDevices: 1, + workingDevices: 1, + failedDevices: 1, // Failed device + spareDevices: 0, + devices: [], + rebuildPercent: 50, // Also rebuilding + }, + ]; + + const status = analyzeRAIDStatus(raid); + expect(status.type).toBe('degraded'); // Degraded takes priority + }); + + it('handles resync state as rebuilding', () => { + const raid: HostRAIDArray[] = [{ + device: '/dev/md0', + level: 'raid1', + state: 'clean, resyncing', + totalDevices: 2, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, + spareDevices: 0, + devices: [], + rebuildPercent: 0, // resync state triggers rebuilding even without percent + }]; + + const status = analyzeRAIDStatus(raid); + expect(status.type).toBe('rebuilding'); + }); + + it('handles multiple healthy arrays', () => { + const raid: HostRAIDArray[] = [ + { + device: '/dev/md0', + level: 'raid1', + state: 'clean', + totalDevices: 2, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, + spareDevices: 0, + devices: [], + rebuildPercent: 0, + }, + { + device: '/dev/md1', + level: 'raid5', + state: 'active', + totalDevices: 3, + activeDevices: 3, + workingDevices: 3, + failedDevices: 0, + spareDevices: 1, + devices: [], + rebuildPercent: 0, + }, + ]; + + const status = analyzeRAIDStatus(raid); + expect(status.type).toBe('ok'); + }); + }); + + describe('getDeviceStateColor', () => { + it('returns green for active devices', () => { + expect(getDeviceStateColor('active sync')).toContain('green'); + expect(getDeviceStateColor('Active Sync')).toContain('green'); + }); + + it('returns blue for spare devices', () => { + expect(getDeviceStateColor('spare')).toContain('blue'); + expect(getDeviceStateColor('hot spare')).toContain('blue'); + }); + + it('returns red for faulty devices', () => { + expect(getDeviceStateColor('faulty')).toContain('red'); + expect(getDeviceStateColor('removed')).toContain('red'); + }); + + it('returns amber for rebuilding devices', () => { + expect(getDeviceStateColor('rebuilding')).toContain('amber'); + }); + + it('returns gray for unknown states', () => { + expect(getDeviceStateColor('')).toContain('gray'); + expect(getDeviceStateColor('unknown')).toContain('gray'); + }); + }); + + describe('getArrayStateColor', () => { + it('returns green for clean/active arrays', () => { + const cleanArray: HostRAIDArray = { + device: '/dev/md0', + level: 'raid1', + state: 'clean', + totalDevices: 2, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, + spareDevices: 0, + devices: [], + rebuildPercent: 0, + }; + expect(getArrayStateColor(cleanArray)).toContain('green'); + + const activeArray: HostRAIDArray = { ...cleanArray, state: 'active' }; + expect(getArrayStateColor(activeArray)).toContain('green'); + }); + + it('returns red for degraded arrays', () => { + const degradedArray: HostRAIDArray = { + device: '/dev/md0', + level: 'raid1', + state: 'degraded', + totalDevices: 2, + activeDevices: 1, + workingDevices: 1, + failedDevices: 1, + spareDevices: 0, + devices: [], + rebuildPercent: 0, + }; + expect(getArrayStateColor(degradedArray)).toContain('red'); + }); + + it('returns red for arrays with failed devices even if state is clean', () => { + const failedDeviceArray: HostRAIDArray = { + device: '/dev/md0', + level: 'raid1', + state: 'clean', // State says clean but... + totalDevices: 2, + activeDevices: 1, + workingDevices: 1, + failedDevices: 1, // Has a failed device + spareDevices: 0, + devices: [], + rebuildPercent: 0, + }; + expect(getArrayStateColor(failedDeviceArray)).toContain('red'); + }); + + it('returns amber for recovering/resyncing arrays', () => { + const recoveringArray: HostRAIDArray = { + device: '/dev/md0', + level: 'raid1', + state: 'recovering', + totalDevices: 2, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, + spareDevices: 0, + devices: [], + rebuildPercent: 50, + }; + expect(getArrayStateColor(recoveringArray)).toContain('amber'); + + const resyncArray: HostRAIDArray = { ...recoveringArray, state: 'resyncing' }; + expect(getArrayStateColor(resyncArray)).toContain('amber'); + }); + + it('returns amber for arrays with rebuild percent > 0', () => { + const rebuildingArray: HostRAIDArray = { + device: '/dev/md0', + level: 'raid1', + state: 'clean', // State is clean but... + totalDevices: 2, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, + spareDevices: 0, + devices: [], + rebuildPercent: 25, // Rebuilding + }; + expect(getArrayStateColor(rebuildingArray)).toContain('amber'); + }); + }); +}); + +describe('RAID Level Support', () => { + const raidLevels = ['raid0', 'raid1', 'raid5', 'raid6', 'raid10']; + + it.each(raidLevels)('supports %s level', (level) => { + const array: HostRAIDArray = { + device: '/dev/md0', + level: level, + state: 'clean', + totalDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3, + activeDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3, + workingDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3, + failedDevices: 0, + spareDevices: 0, + devices: [], + rebuildPercent: 0, + }; + + const status = analyzeRAIDStatus([array]); + expect(status.type).toBe('ok'); + }); +}); + +describe('RAID Device Counts', () => { + it('displays correct device counts for RAID1 with spares', () => { + const array: HostRAIDArray = { + device: '/dev/md0', + level: 'raid1', + state: 'clean', + totalDevices: 3, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, + spareDevices: 1, + devices: [ + { device: 'sda1', state: 'active sync', slot: 0 }, + { device: 'sdb1', state: 'active sync', slot: 1 }, + { device: 'sdc1', state: 'spare', slot: -1 }, + ], + rebuildPercent: 0, + }; + + expect(array.activeDevices).toBe(2); + expect(array.workingDevices).toBe(2); + expect(array.spareDevices).toBe(1); + expect(array.failedDevices).toBe(0); + expect(array.devices).toHaveLength(3); + }); + + it('displays degraded state for RAID5 missing one device', () => { + const array: HostRAIDArray = { + device: '/dev/md0', + level: 'raid5', + state: 'clean, degraded', + totalDevices: 4, + activeDevices: 3, + workingDevices: 3, + failedDevices: 1, + spareDevices: 0, + devices: [ + { device: 'sda1', state: 'active sync', slot: 0 }, + { device: 'sdb1', state: 'active sync', slot: 1 }, + { device: 'sdc1', state: 'active sync', slot: 2 }, + { device: 'sdd1', state: 'removed', slot: 3 }, + ], + rebuildPercent: 0, + }; + + const status = analyzeRAIDStatus([array]); + expect(status.type).toBe('degraded'); + expect(array.failedDevices).toBe(1); + }); +}); diff --git a/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx b/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx index 1e7dc43..5b9779d 100644 --- a/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx +++ b/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx @@ -3,7 +3,7 @@ import { createMemo, For } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import { useWebSocket } from '@/App'; -type ProxmoxSection = 'overview' | 'storage' | 'replication' | 'backups' | 'mail'; +type ProxmoxSection = 'overview' | 'storage' | 'ceph' | 'replication' | 'backups' | 'mail'; interface ProxmoxSectionNavProps { current: ProxmoxSection; @@ -15,41 +15,51 @@ const allSections: Array<{ label: string; path: string; }> = [ - { - id: 'overview', - label: 'Overview', - path: '/proxmox/overview', - }, - { - id: 'storage', - label: 'Storage', - path: '/proxmox/storage', - }, - { - id: 'replication', - label: 'Replication', - path: '/proxmox/replication', - }, - { - id: 'mail', - label: 'Mail Gateway', - path: '/proxmox/mail', - }, - { - id: 'backups', - label: 'Backups', - path: '/proxmox/backups', - }, -]; + { + id: 'overview', + label: 'Overview', + path: '/proxmox/overview', + }, + { + id: 'storage', + label: 'Storage', + path: '/proxmox/storage', + }, + { + id: 'ceph', + label: 'Ceph', + path: '/proxmox/ceph', + }, + { + id: 'replication', + label: 'Replication', + path: '/proxmox/replication', + }, + { + id: 'mail', + label: 'Mail Gateway', + path: '/proxmox/mail', + }, + { + id: 'backups', + label: 'Backups', + path: '/proxmox/backups', + }, + ]; export const ProxmoxSectionNav: Component = (props) => { const navigate = useNavigate(); const { state } = useWebSocket(); // Only show Mail Gateway tab if PMG instances are configured + // Only show Ceph tab if Ceph clusters are detected (from agent or Proxmox API) const sections = createMemo(() => { const hasPMG = state.pmg && state.pmg.length > 0; - return allSections.filter((section) => section.id !== 'mail' || hasPMG); + const hasCeph = state.cephClusters && state.cephClusters.length > 0; + return allSections.filter((section) => + (section.id !== 'mail' || hasPMG) && + (section.id !== 'ceph' || hasCeph) + ); }); const baseClasses = diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index ea3b2e5..54f3431 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -7,7 +7,7 @@ import { formField, labelClass, controlClass, formHelpText } from '@/components/ import { notificationStore } from '@/stores/notifications'; import { logger } from '@/utils/logger'; import { AIAPI } from '@/api/ai'; -import type { AISettings as AISettingsType, AIProvider } from '@/types/ai'; +import type { AISettings as AISettingsType, AIProvider, AuthMethod } from '@/types/ai'; import { PROVIDER_NAMES, PROVIDER_DESCRIPTIONS, DEFAULT_MODELS } from '@/types/ai'; const PROVIDERS: AIProvider[] = ['anthropic', 'openai', 'ollama', 'deepseek']; @@ -17,6 +17,14 @@ export const AISettings: Component = () => { const [loading, setLoading] = createSignal(false); const [saving, setSaving] = createSignal(false); const [testing, setTesting] = createSignal(false); + const [startingOAuth, setStartingOAuth] = createSignal(false); + const [disconnectingOAuth, setDisconnectingOAuth] = createSignal(false); + const [exchangingCode, setExchangingCode] = createSignal(false); + + // OAuth flow state + const [oauthAuthUrl, setOAuthAuthUrl] = createSignal(null); + const [oauthState, setOAuthState] = createSignal(null); + const [oauthCode, setOAuthCode] = createSignal(''); const [form, setForm] = createStore({ enabled: false, @@ -26,6 +34,7 @@ export const AISettings: Component = () => { baseUrl: '', clearApiKey: false, autonomousMode: false, + authMethod: 'api_key' as AuthMethod, }); const resetForm = (data: AISettingsType | null) => { @@ -38,6 +47,7 @@ export const AISettings: Component = () => { baseUrl: '', clearApiKey: false, autonomousMode: false, + authMethod: 'api_key', }); return; } @@ -50,6 +60,7 @@ export const AISettings: Component = () => { baseUrl: data.base_url || '', clearApiKey: false, autonomousMode: data.autonomous_mode || false, + authMethod: data.auth_method || 'api_key', }); }; @@ -71,6 +82,29 @@ export const AISettings: Component = () => { onMount(() => { loadSettings(); + + // Check for OAuth callback parameters in URL + const params = new URLSearchParams(window.location.search); + const oauthSuccess = params.get('ai_oauth_success'); + const oauthError = params.get('ai_oauth_error'); + + if (oauthSuccess === 'true') { + notificationStore.success('Successfully connected to Claude with your subscription!'); + // Clean up URL + window.history.replaceState({}, '', window.location.pathname); + // Reload settings to get updated OAuth status + loadSettings(); + } else if (oauthError) { + const errorMessages: Record = { + 'missing_params': 'OAuth callback missing required parameters', + 'invalid_state': 'Invalid OAuth state - please try again', + 'token_exchange_failed': 'Failed to complete authentication with Claude', + 'save_failed': 'Failed to save OAuth credentials', + }; + notificationStore.error(errorMessages[oauthError] || `OAuth error: ${oauthError}`); + // Clean up URL + window.history.replaceState({}, '', window.location.pathname); + } }); const handleProviderChange = (provider: AIProvider) => { @@ -145,8 +179,82 @@ export const AISettings: Component = () => { } }; - const needsApiKey = () => form.provider !== 'ollama'; + const handleStartOAuth = async () => { + setStartingOAuth(true); + try { + const result = await AIAPI.startOAuth(); + // Store the auth URL and state for the user to visit manually + setOAuthAuthUrl(result.auth_url); + setOAuthState(result.state); + setOAuthCode(''); + notificationStore.info('Click the link below to sign in, then paste the code back here', 5000); + } catch (error) { + logger.error('[AISettings] OAuth start failed:', error); + const message = error instanceof Error ? error.message : 'Failed to start OAuth flow'; + notificationStore.error(message); + } finally { + setStartingOAuth(false); + } + }; + + const handleExchangeCode = async () => { + const code = oauthCode().trim(); + const state = oauthState(); + + if (!code || !state) { + notificationStore.error('Please enter the authorization code'); + return; + } + + setExchangingCode(true); + try { + await AIAPI.exchangeOAuthCode(code, state); + notificationStore.success('Successfully connected to Claude with your subscription!'); + // Clear OAuth flow state + setOAuthAuthUrl(null); + setOAuthState(null); + setOAuthCode(''); + // Reload settings + await loadSettings(); + } catch (error) { + logger.error('[AISettings] OAuth code exchange failed:', error); + const message = error instanceof Error ? error.message : 'Failed to exchange authorization code'; + notificationStore.error(message); + } finally { + setExchangingCode(false); + } + }; + + const handleCancelOAuth = () => { + setOAuthAuthUrl(null); + setOAuthState(null); + setOAuthCode(''); + }; + + const handleDisconnectOAuth = async () => { + if (!confirm('Are you sure you want to disconnect your Claude subscription? You will need to provide an API key to continue using AI features.')) { + return; + } + + setDisconnectingOAuth(true); + try { + await AIAPI.disconnectOAuth(); + notificationStore.success('Claude subscription disconnected'); + // Reload settings + await loadSettings(); + } catch (error) { + logger.error('[AISettings] OAuth disconnect failed:', error); + const message = error instanceof Error ? error.message : 'Failed to disconnect OAuth'; + notificationStore.error(message); + } finally { + setDisconnectingOAuth(false); + } + }; + + const needsApiKey = () => form.provider !== 'ollama' && (form.provider !== 'anthropic' || form.authMethod !== 'oauth'); const showBaseUrl = () => form.provider === 'ollama' || form.provider === 'openai' || form.provider === 'deepseek'; + const isAnthropicWithOAuth = () => form.provider === 'anthropic' && form.authMethod === 'oauth'; + const showAuthMethodSelector = () => form.provider === 'anthropic'; return ( {
- {/* API Key - not shown for Ollama */} + {/* Authentication Method - only for Anthropic */} + +
+ +
+ + +
+

+ {form.authMethod === 'api_key' + ? 'Pay-per-use API billing from console.anthropic.com' + : 'Use your Claude Pro ($20/mo) or Max ($100+/mo) subscription'} +

+
+
+ + {/* OAuth Login/Status - shown when Anthropic + OAuth selected */} + +
+ {/* Connected state */} + +
+
+
+ + + + Connected to Claude with your subscription +
+

+ AI requests use your Claude Pro/Max subscription limits instead of API billing. +

+
+ +
+
+ + {/* OAuth flow in progress - show URL and code input */} + +
+
+ Step 1: Click the link below to sign in with your Anthropic account: +
+ + {oauthAuthUrl()} + +
+ Step 2: After signing in, you'll see a code. Paste it here: +
+
+ setOAuthCode(e.currentTarget.value)} + placeholder="Paste authorization code here..." + class="flex-1 px-3 py-2 text-sm border border-amber-300 dark:border-amber-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400" + /> + +
+ +
+
+ + {/* Initial state - show sign in button */} + +
+
+ Use your Claude subscription +

+ Sign in with your Anthropic account to use your Pro/Max subscription for AI features instead of API billing. +

+
+ +
+
+
+
+ + {/* API Key - not shown for Ollama or when using OAuth */}
@@ -382,17 +646,21 @@ export const AISettings: Component = () => { /> {settings()?.configured - ? `Ready to use with ${settings()?.model}` - : needsApiKey() - ? 'API key required to enable AI features' - : 'Configure Ollama server URL to enable AI features'} + ? settings()?.oauth_connected + ? `Ready to use with ${settings()?.model} (via Claude subscription)` + : `Ready to use with ${settings()?.model}` + : form.authMethod === 'oauth' && form.provider === 'anthropic' + ? 'Sign in with your Claude subscription to enable AI features' + : needsApiKey() + ? 'API key required to enable AI features' + : 'Configure Ollama server URL to enable AI features'}
{/* Actions */}
- +