From 48c755dff36b05ec8dfdecd1a2cbfcc06993cfed Mon Sep 17 00:00:00 2001 From: GSRN Date: Thu, 18 Sep 2025 02:37:58 +0200 Subject: [PATCH] feat: Enhance frontend with theme support and offline capabilities ### Summary of Changes - Introduced theme-aware CSS variables for consistent styling across light and dark modes. - Updated `App.jsx` to manage theme settings and improve layout responsiveness. - Refactored `OfflineMode` component to provide detailed connection status and quick actions for users. - Enhanced `Dashboard`, `Settings`, and `SystemMetrics` components to utilize new theme variables and improve UI consistency. - Updated service URLs in constants and API documentation to reflect new configurations. ### Expected Results - Improved user experience with a cohesive design that adapts to user preferences. - Enhanced offline functionality, providing users with better feedback and control during service outages. - Streamlined codebase with consistent styling practices, making future updates easier. --- frontend/rsbuild.config.js | 5 + frontend/src/App.css | 22 +- frontend/src/App.jsx | 196 ++++-- frontend/src/components/Dashboard.jsx | 59 +- frontend/src/components/OfflineMode.jsx | 144 +++- frontend/src/components/Settings.jsx | 230 +++++-- frontend/src/components/SystemMetrics.jsx | 113 +++- .../components/dashboard/RecentEventsList.jsx | 11 +- .../dashboard/ServiceStatusList.jsx | 11 +- .../components/dashboard/SystemStatsCards.jsx | 18 +- frontend/src/constants/index.js | 2 +- frontend/src/contexts/OfflineContext.jsx | 76 +++ frontend/src/contexts/SettingsContext.jsx | 137 ++++ .../src/hooks/useOfflineAwareServiceStatus.js | 207 ++++++ frontend/src/index.css | 635 +++++++++++++++++- services/api-docs/server.js | 78 ++- services/service-adapters/main.py | 4 +- 17 files changed, 1754 insertions(+), 194 deletions(-) create mode 100644 frontend/src/contexts/OfflineContext.jsx create mode 100644 frontend/src/contexts/SettingsContext.jsx create mode 100644 frontend/src/hooks/useOfflineAwareServiceStatus.js diff --git a/frontend/rsbuild.config.js b/frontend/rsbuild.config.js index 7f92c76..3c8018c 100644 --- a/frontend/rsbuild.config.js +++ b/frontend/rsbuild.config.js @@ -34,6 +34,11 @@ export default defineConfig({ entry: { index: './src/index.js', }, + define: { + 'process.env.REACT_APP_API_URL': JSON.stringify(process.env.REACT_APP_API_URL || 'http://localhost:8080'), + 'process.env.REACT_APP_ADAPTERS_URL': JSON.stringify(process.env.REACT_APP_ADAPTERS_URL || 'http://localhost:8000'), + 'process.env.REACT_APP_DOCS_URL': JSON.stringify(process.env.REACT_APP_DOCS_URL || 'http://localhost:8083'), + }, }, tools: { rspack: { diff --git a/frontend/src/App.css b/frontend/src/App.css index f23d586..fe36e08 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -40,17 +40,19 @@ } .widget { - background: white; + background: var(--card-bg); border-radius: 8px; padding: 16px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: 0 2px 8px var(--shadow); + border: 1px solid var(--border-color); + color: var(--text-primary); } .widget-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; - color: #262626; + color: var(--text-primary); } .metric-grid { @@ -61,11 +63,13 @@ } .metric-card { - background: white; + background: var(--card-bg); border-radius: 8px; padding: 20px; text-align: center; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: 0 2px 8px var(--shadow); + border: 1px solid var(--border-color); + color: var(--text-primary); } .metric-value { @@ -76,7 +80,7 @@ } .metric-label { - color: #8c8c8c; + color: var(--text-secondary); font-size: 14px; } @@ -85,10 +89,12 @@ align-items: center; justify-content: space-between; padding: 12px 16px; - background: white; + background: var(--card-bg); border-radius: 8px; margin-bottom: 8px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + box-shadow: 0 1px 4px var(--shadow); + border: 1px solid var(--border-color); + color: var(--text-primary); } .status-indicator { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c75fb9d..c22bb81 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Routes, Route } from 'react-router-dom'; +import React, { useState } from 'react'; +import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'; import { Layout, Menu, Typography } from 'antd'; import { DashboardOutlined, SettingOutlined, BarChartOutlined } from '@ant-design/icons'; import Dashboard from './components/Dashboard.jsx'; @@ -7,70 +7,158 @@ import SystemMetrics from './components/SystemMetrics.jsx'; import Settings from './components/Settings.jsx'; import OfflineMode from './components/OfflineMode.jsx'; import ErrorBoundary from './components/common/ErrorBoundary.jsx'; -import { useServiceStatus } from './hooks/useServiceStatus'; +import { OfflineProvider } from './contexts/OfflineContext'; +import { SettingsProvider } from './contexts/SettingsContext'; +import { useOfflineAwareServiceStatus } from './hooks/useOfflineAwareServiceStatus'; +import { useSettings } from './contexts/SettingsContext'; import './App.css'; const { Header, Sider, Content } = Layout; const { Title } = Typography; -function App() { - const serviceStatus = useServiceStatus(); +function AppContent() { + const serviceStatus = useOfflineAwareServiceStatus(); + const navigate = useNavigate(); + const location = useLocation(); + const [selectedKey, setSelectedKey] = useState('dashboard'); + const { settings } = useSettings(); + + // Get dashboard settings with fallbacks + const dashboardSettings = settings.dashboard || { + theme: 'light', + layout: 'grid', + autoRefreshInterval: 30 + }; + + // Apply theme to document + React.useEffect(() => { + document.documentElement.setAttribute('data-theme', dashboardSettings.theme); + }, [dashboardSettings.theme]); const handleRetry = () => { window.location.reload(); }; + const handleMenuClick = ({ key }) => { + setSelectedKey(key); + switch (key) { + case 'dashboard': + navigate('/dashboard'); + break; + case 'metrics': + navigate('/metrics'); + break; + case 'settings': + navigate('/settings'); + break; + default: + navigate('/'); + } + }; + + // Update selected key based on current location + React.useEffect(() => { + const path = location.pathname; + if (path === '/' || path === '/dashboard') { + setSelectedKey('dashboard'); + } else if (path === '/metrics') { + setSelectedKey('metrics'); + } else if (path === '/settings') { + setSelectedKey('settings'); + } + }, [location.pathname]); + + return ( + + +
+ + LabFusion + +
+ , + label: 'Dashboard', + }, + { + key: 'metrics', + icon: , + label: 'System Metrics', + }, + { + key: 'settings', + icon: , + label: 'Settings', + }, + ]} + /> + + +
+ + Homelab Dashboard + +
+ + {serviceStatus.overall === 'offline' && ( + + )} + + } /> + } /> + } /> + } /> + + +
+ + ); +} + +function App() { return ( - - -
- - LabFusion - -
- , - label: 'Dashboard', - }, - { - key: 'metrics', - icon: , - label: 'System Metrics', - }, - { - key: 'settings', - icon: , - label: 'Settings', - }, - ]} - /> - - -
- - Homelab Dashboard - -
- - {serviceStatus.overall === 'offline' && ( - - )} - - } /> - } /> - } /> - } /> - - -
- + + + + + ); } diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 3d8fe90..51637e1 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -6,17 +6,21 @@ import SystemStatsCards from './dashboard/SystemStatsCards.jsx'; import ServiceStatusList from './dashboard/ServiceStatusList.jsx'; import RecentEventsList from './dashboard/RecentEventsList.jsx'; import LoadingSpinner from './common/LoadingSpinner.jsx'; -import { useServiceStatus, useSystemData } from '../hooks/useServiceStatus'; +import { useOfflineAwareServiceStatus, useOfflineAwareSystemData } from '../hooks/useOfflineAwareServiceStatus'; +import { useSettings } from '../contexts/SettingsContext'; import { ERROR_MESSAGES } from '../constants'; const { Title } = Typography; const Dashboard = () => { - const serviceStatus = useServiceStatus(); - const { systemStats, services, events: recentEvents, loading, error } = useSystemData(); + const serviceStatus = useOfflineAwareServiceStatus(); + const { systemStats, services, events: recentEvents, loading, error, fetchData } = useOfflineAwareSystemData(); + const { settings } = useSettings(); + + const layout = settings.dashboard?.layout || 'grid'; const handleRefresh = () => { - window.location.reload(); + fetchData(); }; if (loading) { @@ -28,10 +32,15 @@ const Dashboard = () => { } return ( -
+
- System Overview + System Overview {error && ( { {/* System Metrics */} - - {/* Service Status */} - + {layout === 'list' ? ( + // List Layout - Vertical stacking +
- - - {/* Recent Events */} - - - - +
+ +
+
+ ) : layout === 'custom' ? ( + // Custom Layout - Different arrangement + + + + + + + + + ) : ( + // Grid Layout - Default side-by-side + + + + + + + + + )} {/* System Metrics Chart */} diff --git a/frontend/src/components/OfflineMode.jsx b/frontend/src/components/OfflineMode.jsx index 7ed1971..f2d9d5a 100644 --- a/frontend/src/components/OfflineMode.jsx +++ b/frontend/src/components/OfflineMode.jsx @@ -1,40 +1,118 @@ import React from 'react'; -import { Alert, Button, Space } from 'antd'; -import { ReloadOutlined } from '@ant-design/icons'; +import { Alert, Button, Space, Typography, Card, Row, Col } from 'antd'; +import { ReloadOutlined, WifiOutlined, ClockCircleOutlined } from '@ant-design/icons'; +import { useOfflineMode } from '../contexts/OfflineContext'; + +const { Text, Paragraph } = Typography; const OfflineMode = ({ onRetry }) => { + const { lastOnlineCheck, consecutiveFailures, checkOnlineStatus } = useOfflineMode(); + + const handleManualCheck = async () => { + await checkOnlineStatus(); + if (onRetry) { + onRetry(); + } + }; + + const formatLastCheck = (timestamp) => { + const now = Date.now(); + const diff = now - timestamp; + const minutes = Math.floor(diff / 60000); + const seconds = Math.floor((diff % 60000) / 1000); + + if (minutes > 0) { + return `${minutes}m ${seconds}s ago`; + } + return `${seconds}s ago`; + }; + return ( - -

The frontend is running in offline mode because backend services are not available.

-

To enable full functionality:

-
    -
  1. Start the backend services: docker-compose up -d
  2. -
  3. Or start individual services for development
  4. -
  5. Refresh this page once services are running
  6. -
- - - - -
- } - type="info" - showIcon - style={{ marginBottom: 16 }} - /> +
+ + + The frontend is running in offline mode because backend services are not available. + API calls have been disabled to prevent unnecessary network traffic. + + + + + +
+ + Services Offline +
+
+ + + Last check: {formatLastCheck(lastOnlineCheck)} + +
+
+ + Consecutive failures: {consecutiveFailures} + +
+
+
+ + + + + + + + + +
+ + To enable full functionality: + +
    +
  1. Start the backend services: docker-compose up -d
  2. +
  3. Or start individual services for development
  4. +
  5. Click "Check Connection" above once services are running
  6. +
+
+ } + type="warning" + showIcon + style={{ marginBottom: 16 }} + /> +
); }; diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index 55b5dca..5fbf2e8 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -1,51 +1,103 @@ import React, { useState } from 'react'; -import { Card, Form, Input, Button, Switch, Select, Typography, message } from 'antd'; +import { Card, Form, Input, Button, Switch, Select, Typography, message, Space, Divider, Upload } from 'antd'; +import { DownloadOutlined, UploadOutlined, ReloadOutlined } from '@ant-design/icons'; +import { useSettings } from '../contexts/SettingsContext'; -const { Title } = Typography; +const { Title, Text } = Typography; const { Option } = Select; const Settings = () => { + const { settings, updateServiceSettings, resetSettings, exportSettings, importSettings } = useSettings(); const [form] = Form.useForm(); const [loading, setLoading] = useState(false); - const onFinish = (_values) => { + const onFinish = (values) => { setLoading(true); - // Simulate API call - setTimeout(() => { - setLoading(false); + + try { + // Update service settings + Object.keys(values).forEach(serviceName => { + if (values[serviceName]) { + updateServiceSettings(serviceName, values[serviceName]); + } + }); + message.success('Settings saved successfully!'); - }, 1000); + } catch { + message.error('Failed to save settings'); + } finally { + setLoading(false); + } + }; + + const handleReset = () => { + resetSettings(); + form.resetFields(); + message.success('Settings reset to defaults'); + }; + + const handleExport = () => { + try { + exportSettings(); + message.success('Settings exported successfully'); + } catch { + message.error('Failed to export settings'); + } + }; + + const handleImport = (file) => { + setLoading(true); + importSettings(file) + .then(() => { + message.success('Settings imported successfully'); + form.setFieldsValue(settings); + }) + .catch((error) => { + message.error(error.message); + }) + .finally(() => { + setLoading(false); + }); + return false; // Prevent default upload behavior }; return ( -
- Settings +
+ Settings - +
{/* Home Assistant */} - + @@ -58,7 +110,17 @@ const Settings = () => { {/* Frigate */} - + @@ -71,7 +133,17 @@ const Settings = () => { {/* Immich */} - + @@ -83,33 +155,53 @@ const Settings = () => { - + + + + - -
- - - - + + + + - - @@ -117,6 +209,54 @@ const Settings = () => {
+ + + +
+ Export Settings +
+ Download your current settings as a JSON file +
+ +
+ + + +
+ Import Settings +
+ Upload a previously exported settings file +
+ + + +
+
+
); }; diff --git a/frontend/src/components/SystemMetrics.jsx b/frontend/src/components/SystemMetrics.jsx index 8631989..2b870cf 100644 --- a/frontend/src/components/SystemMetrics.jsx +++ b/frontend/src/components/SystemMetrics.jsx @@ -1,10 +1,10 @@ import React from 'react'; import { Card, Row, Col, Statistic, Progress, Alert } from 'antd'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'; -import { useSystemData } from '../hooks/useServiceStatus'; +import { useOfflineAwareSystemData } from '../hooks/useOfflineAwareServiceStatus'; const SystemMetrics = () => { - const { systemStats, loading, error } = useSystemData(); + const { systemStats, loading, error } = useOfflineAwareSystemData(); // Mock data for charts (fallback when services are unavailable) const cpuData = [ @@ -39,16 +39,36 @@ const SystemMetrics = () => { if (loading) { return ( - -
+ +
Loading metrics...
); } + // Ensure systemStats is an object with fallback values + const safeSystemStats = systemStats || { + cpu: 0, + memory: 0, + disk: 0, + network: 0 + }; + return ( -
+
{error && ( { /> )} - + - - - + + + - - - + + + - - - + + + @@ -83,7 +136,15 @@ const SystemMetrics = () => { - + @@ -96,7 +157,15 @@ const SystemMetrics = () => { - + @@ -112,7 +181,15 @@ const SystemMetrics = () => { - + diff --git a/frontend/src/components/dashboard/RecentEventsList.jsx b/frontend/src/components/dashboard/RecentEventsList.jsx index e5907a3..bd60c3b 100644 --- a/frontend/src/components/dashboard/RecentEventsList.jsx +++ b/frontend/src/components/dashboard/RecentEventsList.jsx @@ -14,7 +14,16 @@ const RecentEventsList = ({ events }) => { ); return ( - + { ); return ( - + { + // Ensure systemStats is an object with fallback values + const safeSystemStats = systemStats || { + cpu: 0, + memory: 0, + disk: 0, + network: 0 + }; + const stats = [ { key: 'cpu', title: 'CPU Usage', - value: systemStats.cpu || 0, + value: safeSystemStats.cpu || 0, suffix: '%', prefix: }, { key: 'memory', title: 'Memory Usage', - value: systemStats.memory || 0, + value: safeSystemStats.memory || 0, suffix: '%', prefix: }, { key: 'disk', title: 'Disk Usage', - value: systemStats.disk || 0, + value: safeSystemStats.disk || 0, suffix: '%', prefix: }, { key: 'network', title: 'Network', - value: systemStats.network || 0, + value: safeSystemStats.network || 0, suffix: 'Mbps', prefix: } @@ -70,7 +78,7 @@ SystemStatsCards.propTypes = { memory: PropTypes.number, disk: PropTypes.number, network: PropTypes.number - }).isRequired + }) }; export default SystemStatsCards; \ No newline at end of file diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js index 53b6638..58f105c 100644 --- a/frontend/src/constants/index.js +++ b/frontend/src/constants/index.js @@ -11,7 +11,7 @@ export const API_CONFIG = { // Service URLs export const SERVICE_URLS = { API_GATEWAY: process.env.REACT_APP_API_URL || 'http://localhost:8080', - SERVICE_ADAPTERS: process.env.REACT_APP_ADAPTERS_URL || 'http://localhost:8000', + SERVICE_ADAPTERS: process.env.REACT_APP_ADAPTERS_URL || 'http://localhost:8001', API_DOCS: process.env.REACT_APP_DOCS_URL || 'http://localhost:8083', }; diff --git a/frontend/src/contexts/OfflineContext.jsx b/frontend/src/contexts/OfflineContext.jsx new file mode 100644 index 0000000..3754b24 --- /dev/null +++ b/frontend/src/contexts/OfflineContext.jsx @@ -0,0 +1,76 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; + +const OfflineContext = createContext(); + +export const useOfflineMode = () => { + const context = useContext(OfflineContext); + if (!context) { + throw new Error('useOfflineMode must be used within an OfflineProvider'); + } + return context; +}; + +export const OfflineProvider = ({ children }) => { + const [isOffline, setIsOffline] = useState(false); + const [lastOnlineCheck, setLastOnlineCheck] = useState(Date.now()); + const [consecutiveFailures, setConsecutiveFailures] = useState(0); + + // Offline detection logic + const MAX_CONSECUTIVE_FAILURES = 3; + const OFFLINE_CHECK_INTERVAL = 30000; // 30 seconds + const ONLINE_CHECK_INTERVAL = 10000; // 10 seconds when offline + + const markOffline = useCallback(() => { + setConsecutiveFailures(prev => prev + 1); + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + setIsOffline(true); + } + }, [consecutiveFailures]); + + const markOnline = useCallback(() => { + setConsecutiveFailures(0); + setIsOffline(false); + setLastOnlineCheck(Date.now()); + }, []); + + const checkOnlineStatus = useCallback(async () => { + try { + // Simple connectivity check + await fetch('/api/health', { + method: 'HEAD', + mode: 'no-cors', + cache: 'no-cache' + }); + markOnline(); + } catch { + markOffline(); + } + }, [markOnline, markOffline]); + + useEffect(() => { + if (isOffline) { + // When offline, check less frequently + const interval = setInterval(checkOnlineStatus, ONLINE_CHECK_INTERVAL); + return () => clearInterval(interval); + } else { + // When online, check more frequently + const interval = setInterval(checkOnlineStatus, OFFLINE_CHECK_INTERVAL); + return () => clearInterval(interval); + } + }, [isOffline, checkOnlineStatus]); + + const value = { + isOffline, + lastOnlineCheck, + consecutiveFailures, + markOffline, + markOnline, + checkOnlineStatus + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/contexts/SettingsContext.jsx b/frontend/src/contexts/SettingsContext.jsx new file mode 100644 index 0000000..fae9452 --- /dev/null +++ b/frontend/src/contexts/SettingsContext.jsx @@ -0,0 +1,137 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; + +const SettingsContext = createContext(); + +export const useSettings = () => { + const context = useContext(SettingsContext); + if (!context) { + throw new Error('useSettings must be used within a SettingsProvider'); + } + return context; +}; + +const DEFAULT_SETTINGS = { + // Service Integrations + homeAssistant: { + enabled: false, + url: 'http://homeassistant.local:8123', + token: '' + }, + frigate: { + enabled: false, + url: 'http://frigate.local:5000', + token: '' + }, + immich: { + enabled: false, + url: 'http://immich.local:2283', + apiKey: '' + }, + // Dashboard Configuration + dashboard: { + layout: 'grid', + autoRefreshInterval: 30, + theme: 'light' + }, + // API Configuration + api: { + timeout: 5000, + retryAttempts: 3 + } +}; + +export const SettingsProvider = ({ children }) => { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [loading, setLoading] = useState(true); + + // Load settings from localStorage on mount + useEffect(() => { + try { + const savedSettings = localStorage.getItem('labfusion-settings'); + if (savedSettings) { + const parsedSettings = JSON.parse(savedSettings); + setSettings({ ...DEFAULT_SETTINGS, ...parsedSettings }); + } + } catch (error) { + console.error('Failed to load settings:', error); + } finally { + setLoading(false); + } + }, []); + + // Save settings to localStorage whenever they change + useEffect(() => { + if (!loading) { + try { + localStorage.setItem('labfusion-settings', JSON.stringify(settings)); + } catch (error) { + console.error('Failed to save settings:', error); + } + } + }, [settings, loading]); + + const updateSettings = (newSettings) => { + setSettings(prev => ({ + ...prev, + ...newSettings + })); + }; + + const updateServiceSettings = (serviceName, serviceSettings) => { + setSettings(prev => ({ + ...prev, + [serviceName]: { + ...prev[serviceName], + ...serviceSettings + } + })); + }; + + const resetSettings = () => { + setSettings(DEFAULT_SETTINGS); + }; + + const exportSettings = () => { + const dataStr = JSON.stringify(settings, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = 'labfusion-settings.json'; + link.click(); + URL.revokeObjectURL(url); + }; + + const importSettings = (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const importedSettings = JSON.parse(e.target.result); + setSettings({ ...DEFAULT_SETTINGS, ...importedSettings }); + resolve(importedSettings); + } catch { + reject(new Error('Invalid settings file')); + } + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsText(file); + }); + }; + + const value = { + settings, + loading, + updateSettings, + updateServiceSettings, + resetSettings, + exportSettings, + importSettings + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/hooks/useOfflineAwareServiceStatus.js b/frontend/src/hooks/useOfflineAwareServiceStatus.js new file mode 100644 index 0000000..da2ebc1 --- /dev/null +++ b/frontend/src/hooks/useOfflineAwareServiceStatus.js @@ -0,0 +1,207 @@ +import { useState, useEffect, useCallback } from 'react'; +import { apiGateway, serviceAdapters, apiDocs } from '../services/api'; +import { API_CONFIG, SERVICE_STATUS } from '../constants'; +import { determineServiceStatus } from '../utils/errorHandling'; +import { useOfflineMode } from '../contexts/OfflineContext'; +import { useSettings } from '../contexts/SettingsContext'; + +export const useOfflineAwareServiceStatus = () => { + const { isOffline, markOffline, markOnline } = useOfflineMode(); + const { settings } = useSettings(); + const [status, setStatus] = useState({ + loading: true, + apiGateway: { available: false, error: null }, + serviceAdapters: { available: false, error: null }, + apiDocs: { available: false, error: null }, + overall: SERVICE_STATUS.CHECKING + }); + + const checkServices = useCallback(async () => { + // If we're in offline mode, don't make API calls + if (isOffline) { + setStatus(prev => ({ + ...prev, + loading: false, + overall: SERVICE_STATUS.OFFLINE + })); + return; + } + + setStatus(prev => ({ ...prev, loading: true })); + + try { + // Check all services in parallel + const [apiGatewayResult, adaptersResult, docsResult] = await Promise.allSettled([ + apiGateway.health(), + serviceAdapters.health(), + apiDocs.health() + ]); + + const newStatus = { + loading: false, + apiGateway: { + available: apiGatewayResult.status === 'fulfilled' && apiGatewayResult.value.success, + error: apiGatewayResult.status === 'rejected' ? 'Connection failed' : + (apiGatewayResult.value?.error || null) + }, + serviceAdapters: { + available: adaptersResult.status === 'fulfilled' && adaptersResult.value.success, + error: adaptersResult.status === 'rejected' ? 'Connection failed' : + (adaptersResult.value?.error || null) + }, + apiDocs: { + available: docsResult.status === 'fulfilled' && docsResult.value.success, + error: docsResult.status === 'rejected' ? 'Connection failed' : + (docsResult.value?.error || null) + }, + overall: SERVICE_STATUS.CHECKING + }; + + // Determine overall status + const availableServices = [ + newStatus.apiGateway.available, + newStatus.serviceAdapters.available, + newStatus.apiDocs.available + ].filter(Boolean).length; + + newStatus.overall = determineServiceStatus(availableServices, 3); + + // If no services are available, mark as offline + if (availableServices === 0) { + markOffline(); + } else { + markOnline(); + } + + setStatus(newStatus); + } catch { + markOffline(); + setStatus(prev => ({ + ...prev, + loading: false, + overall: SERVICE_STATUS.OFFLINE + })); + } + }, [isOffline, markOffline, markOnline]); + + useEffect(() => { + checkServices(); + + // Only set up interval if not offline + if (!isOffline) { + const refreshInterval = settings.dashboard?.autoRefreshInterval || API_CONFIG.REFRESH_INTERVALS.SERVICE_STATUS; + const interval = setInterval(checkServices, refreshInterval * 1000); // Convert to milliseconds + return () => clearInterval(interval); + } + }, [checkServices, isOffline, settings.dashboard?.autoRefreshInterval]); + + return { ...status, checkServices }; +}; + +export const useOfflineAwareSystemData = () => { + const { isOffline, markOffline, markOnline } = useOfflineMode(); + const { settings } = useSettings(); + const [data, setData] = useState({ + loading: true, + systemStats: null, + services: null, + events: null, + error: null + }); + + const fetchData = useCallback(async () => { + // If we're in offline mode, use fallback data and don't make API calls + if (isOffline) { + setData(prev => ({ + ...prev, + loading: false, + systemStats: { cpu: 0, memory: 0, disk: 0, network: 0 }, + services: [ + { name: 'API Gateway', status: 'offline', uptime: '0d 0h' }, + { name: 'Service Adapters', status: 'offline', uptime: '0d 0h' }, + { name: 'PostgreSQL', status: 'offline', uptime: '0d 0h' }, + { name: 'Redis', status: 'offline', uptime: '0d 0h' } + ], + events: [ + { time: 'Service unavailable', event: 'Backend services are not running', service: 'System' } + ], + error: 'Offline mode - services unavailable' + })); + return; + } + + setData(prev => ({ ...prev, loading: true })); + + try { + // Try to fetch real data from services + const [metricsResult, servicesResult, eventsResult] = await Promise.allSettled([ + apiGateway.getSystemMetrics(), + serviceAdapters.getServices(), + serviceAdapters.getEvents(10) + ]); + + const systemStats = metricsResult.status === 'fulfilled' && metricsResult.value.success + ? metricsResult.value.data + : { cpu: 0, memory: 0, disk: 0, network: 0 }; + + const services = servicesResult.status === 'fulfilled' && servicesResult.value.success + ? servicesResult.value.data + : [ + { name: 'API Gateway', status: 'offline', uptime: '0d 0h' }, + { name: 'Service Adapters', status: 'offline', uptime: '0d 0h' }, + { name: 'PostgreSQL', status: 'offline', uptime: '0d 0h' }, + { name: 'Redis', status: 'offline', uptime: '0d 0h' } + ]; + + const events = eventsResult.status === 'fulfilled' && eventsResult.value.success + ? eventsResult.value.data.events + : [{ time: 'Service unavailable', event: 'Backend services are not running', service: 'System' }]; + + // Check if any services are available + const hasAvailableServices = services.some(service => service.status !== 'offline'); + + if (!hasAvailableServices) { + markOffline(); + } else { + markOnline(); + } + + setData({ + loading: false, + systemStats, + services, + events, + error: null + }); + } catch (error) { + markOffline(); + setData({ + loading: false, + systemStats: { cpu: 0, memory: 0, disk: 0, network: 0 }, + services: [ + { name: 'API Gateway', status: 'offline', uptime: '0d 0h' }, + { name: 'Service Adapters', status: 'offline', uptime: '0d 0h' }, + { name: 'PostgreSQL', status: 'offline', uptime: '0d 0h' }, + { name: 'Redis', status: 'offline', uptime: '0d 0h' } + ], + events: [ + { time: 'Service unavailable', event: 'Backend services are not running', service: 'System' } + ], + error: `Failed to fetch data from services: ${error.message}` + }); + } + }, [isOffline, markOffline, markOnline]); + + useEffect(() => { + fetchData(); + + // Only set up interval if not offline + if (!isOffline) { + const refreshInterval = settings.dashboard?.autoRefreshInterval || API_CONFIG.REFRESH_INTERVALS.SYSTEM_DATA; + const interval = setInterval(fetchData, refreshInterval * 1000); // Convert to milliseconds + return () => clearInterval(interval); + } + }, [fetchData, isOffline, settings.dashboard?.autoRefreshInterval]); + + return { ...data, fetchData }; +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 8c2a3af..552453b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,35 @@ +:root { + /* Light theme colors */ + --bg-primary: #f5f5f5; + --bg-secondary: #ffffff; + --bg-tertiary: #fafafa; + --text-primary: #262626; + --text-secondary: #8c8c8c; + --text-tertiary: #666666; + --border-color: #d9d9d9; + --shadow: rgba(0, 0, 0, 0.1); + --card-bg: #ffffff; + --header-bg: #ffffff; + --sider-bg: #001529; + --sider-text: #ffffff; +} + +[data-theme="dark"] { + /* Dark theme colors */ + --bg-primary: #05152a; + --bg-secondary: #1f1f1f; + --bg-tertiary: #262626; + --text-primary: #ffffff; + --text-secondary: #a6a6a6; + --text-tertiary: #8c8c8c; + --border-color: #434343; + --shadow: rgba(0, 0, 0, 0.3); + --card-bg: #1f1f1f; + --header-bg: #001529; + --sider-bg: #001529; + --sider-text: #ffffff; +} + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', @@ -5,7 +37,9 @@ body { sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background-color: #f5f5f5; + background-color: var(--bg-primary); + color: var(--text-primary); + transition: background-color 0.3s ease, color 0.3s ease; } code { @@ -20,17 +54,23 @@ code { .dashboard-container { padding: 24px; min-height: 100vh; + background-color: var(--bg-primary); + color: var(--text-primary); } .widget-card { margin-bottom: 16px; border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: 0 2px 8px var(--shadow); + background-color: var(--card-bg); + border: 1px solid var(--border-color); } .metric-card { text-align: center; padding: 16px; + background-color: var(--card-bg); + color: var(--text-primary); } .metric-value { @@ -40,13 +80,14 @@ code { } .metric-label { - color: #666; + color: var(--text-secondary); margin-top: 8px; } .chart-container { height: 300px; padding: 16px; + background-color: var(--card-bg); } .status-indicator { @@ -66,5 +107,591 @@ code { } .status-unknown { - background-color: #d9d9d9; + background-color: var(--text-tertiary); } + +/* Theme-aware text colors */ +.text-primary { + color: var(--text-primary) !important; +} + +.text-secondary { + color: var(--text-secondary) !important; +} + +.text-tertiary { + color: var(--text-tertiary) !important; +} + +/* Theme-aware backgrounds */ +.bg-primary { + background-color: var(--bg-primary) !important; +} + +.bg-secondary { + background-color: var(--bg-secondary) !important; +} + +.bg-card { + background-color: var(--card-bg) !important; +} + +/* Override Ant Design default styles for theme consistency */ +.ant-layout { + background: var(--bg-primary); +} + +.ant-layout-content { + background: var(--bg-primary); + color: var(--text-primary); +} + +.ant-layout-header { + background: var(--header-bg); + color: var(--text-primary); +} + +.ant-layout-sider { + background: var(--sider-bg); + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + scroll-behavior: smooth; +} + +/* Sticky sidebar menu */ +.ant-layout-sider .ant-menu { + position: sticky; + top: 0; + height: calc(100vh - 80px); + overflow-y: auto; + border-right: none; + scroll-behavior: smooth; +} + +/* Ensure sidebar content is sticky */ +.ant-layout-sider > div:first-child { + position: sticky; + top: 0; + z-index: 10; + background: var(--sider-bg); + border-bottom: 1px solid var(--border-color); +} + +/* Sticky menu items */ +.ant-menu-inline { + position: sticky; + top: 80px; + height: calc(100vh - 80px); + overflow-y: auto; +} + +/* Custom scrollbar for sidebar */ +.ant-layout-sider::-webkit-scrollbar { + width: 6px; +} + +.ant-layout-sider::-webkit-scrollbar-track { + background: var(--sider-bg); +} + +.ant-layout-sider::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +.ant-layout-sider::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Ensure sidebar stays in place on mobile */ +@media (max-width: 768px) { + .ant-layout-sider { + position: fixed; + z-index: 1000; + } +} + +/* Ensure all text is theme-aware */ +.ant-typography { + color: var(--text-primary); +} + +/* Override any white backgrounds */ +* { + box-sizing: border-box; +} + +/* Remove any default white backgrounds */ +.ant-layout-content > * { + background: transparent; +} + +/* Theme-aware form elements */ +.ant-form-item-label > label { + color: var(--text-primary); +} + +/* Input fields */ +.ant-input { + background: var(--card-bg); + border-color: var(--border-color); + color: var(--text-primary); +} + +.ant-input:focus, +.ant-input-focused { + border-color: #1890ff; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); +} + +.ant-input:hover { + border-color: #40a9ff; +} + +.ant-input::placeholder { + color: var(--text-tertiary); +} + +/* Password input */ +.ant-input-password { + background: var(--card-bg); + border-color: var(--border-color); +} + +.ant-input-password .ant-input { + background: transparent; + color: var(--text-primary); +} + +/* Select dropdowns */ +.ant-select { + color: var(--text-primary); +} + +.ant-select-selector { + background: var(--card-bg); + border-color: var(--border-color); + color: var(--text-primary); +} + +.ant-select-selection-item { + color: var(--text-primary); +} + +.ant-select-selection-placeholder { + color: var(--text-tertiary); +} + +.ant-select:hover .ant-select-selector { + border-color: #40a9ff; +} + +.ant-select-focused .ant-select-selector { + border-color: #1890ff; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); +} + +/* Select dropdown menu */ +.ant-select-dropdown { + background: var(--card-bg); + border: 1px solid var(--border-color); + box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05); +} + +.ant-select-item { + color: var(--text-primary); +} + +.ant-select-item:hover { + background: var(--bg-tertiary); +} + +.ant-select-item-option-selected { + background: #e6f7ff; + color: #1890ff; +} + +.ant-select-item-option-selected:hover { + background: #bae7ff; +} + +/* Switches */ +.ant-switch { + background: var(--border-color); +} + +.ant-switch-checked { + background: #1890ff; +} + +.ant-switch-handle { + background: var(--card-bg); +} + +.ant-switch-checked .ant-switch-handle { + background: var(--card-bg); +} + +/* Buttons */ +.ant-btn { + border-color: var(--border-color); + color: var(--text-primary); + background: var(--card-bg); +} + +.ant-btn:hover { + border-color: #40a9ff; + color: #40a9ff; + background: var(--card-bg); +} + +.ant-btn:focus { + border-color: #1890ff; + color: #1890ff; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); +} + +.ant-btn-primary { + background: #1890ff; + border-color: #1890ff; + color: #ffffff; +} + +.ant-btn-primary:hover { + background: #40a9ff; + border-color: #40a9ff; + color: #ffffff; +} + +.ant-btn-primary:focus { + background: #1890ff; + border-color: #1890ff; + color: #ffffff; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); +} + +/* Link buttons */ +.ant-btn-link { + background: transparent; + border: none; + color: #1890ff; + box-shadow: none; +} + +.ant-btn-link:hover { + color: #40a9ff; + background: transparent; + border: none; +} + +.ant-btn-link:focus { + color: #1890ff; + background: transparent; + border: none; + box-shadow: none; +} + +/* Ghost buttons */ +.ant-btn-ghost { + background: transparent; + border-color: var(--border-color); + color: var(--text-primary); +} + +.ant-btn-ghost:hover { + background: var(--bg-tertiary); + border-color: #40a9ff; + color: #40a9ff; +} + +.ant-btn-ghost:focus { + background: transparent; + border-color: #1890ff; + color: #1890ff; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); +} + +/* Button groups */ +.ant-btn-group .ant-btn { + border-color: var(--border-color); +} + +.ant-btn-group .ant-btn:not(:first-child) { + border-left-color: var(--border-color); +} + +/* Button loading state */ +.ant-btn-loading { + color: var(--text-primary); +} + +.ant-btn-primary.ant-btn-loading { + color: #ffffff; +} + +/* Upload component */ +.ant-upload { + color: var(--text-primary); +} + +.ant-upload-btn { + background: var(--card-bg); + border-color: var(--border-color); + color: var(--text-primary); +} + +.ant-upload-btn:hover { + border-color: #40a9ff; + color: #40a9ff; +} + +/* Dividers */ +.ant-divider { + border-color: var(--border-color); +} + +/* Form validation messages */ +.ant-form-item-explain-error { + color: #ff4d4f; +} + +.ant-form-item-explain-success { + color: #52c41a; +} + +/* Alert components */ +.ant-alert { + background: var(--card-bg); + border: 1px solid var(--border-color); + color: var(--text-primary); +} + +.ant-alert-success { + background: #f6ffed; + border-color: #b7eb8f; + color: #389e0d; +} + +.ant-alert-info { + background: #e6f7ff; + border-color: #91d5ff; + color: #0958d9; +} + +.ant-alert-warning { + background: #fffbe6; + border-color: #ffe58f; + color: #d48806; +} + +.ant-alert-error { + background: #fff2f0; + border-color: #ffccc7; + color: #cf1322; +} + +/* Alert text in dark mode */ +[data-theme="dark"] .ant-alert-success { + background: #162312; + border-color: #389e0d; + color: #95de64; +} + +[data-theme="dark"] .ant-alert-info { + background: #111b26; + border-color: #1890ff; + color: #69c0ff; +} + +[data-theme="dark"] .ant-alert-warning { + background: #2b2111; + border-color: #faad14; + color: #ffd666; +} + +[data-theme="dark"] .ant-alert-error { + background: #2a1215; + border-color: #ff4d4f; + color: #ff7875; +} + +[data-theme="dark"] .ant-alert-message { + color: #e8dfdf; +} + +/* Dark theme form labels */ +[data-theme="dark"] .ant-form-item-label > label { + color: var(--text-primary); +} + +[data-theme="dark"] .ant-form-item-label > label.ant-form-item-required::before { + color: #ff4d4f; +} + +/* Dark theme form elements */ +[data-theme="dark"] .ant-form-item-explain { + color: var(--text-secondary); +} + +[data-theme="dark"] .ant-form-item-explain-error { + color: #ff7875; +} + +[data-theme="dark"] .ant-form-item-explain-success { + color: #95de64; +} + +/* Dark theme input placeholders */ +[data-theme="dark"] .ant-input::placeholder { + color: var(--text-tertiary); +} + +[data-theme="dark"] .ant-select-selection-placeholder { + color: var(--text-tertiary); +} + +/* Dark theme form containers */ +[data-theme="dark"] .ant-form { + color: var(--text-primary); +} + +[data-theme="dark"] .ant-form-item { + color: var(--text-primary); +} + +/* Dark theme switch labels */ +[data-theme="dark"] .ant-switch-checked .ant-switch-inner { + color: #ffffff; +} + +[data-theme="dark"] .ant-switch .ant-switch-inner { + color: var(--text-primary); +} + +/* Dark theme select dropdowns */ +[data-theme="dark"] .ant-select { + color: var(--text-primary); +} + +[data-theme="dark"] .ant-select-selector { + background: var(--card-bg) !important; + border-color: var(--border-color) !important; + color: var(--text-primary) !important; + border: 1px solid var(--border-color) !important; +} + +[data-theme="dark"] .ant-select-selection-item { + color: var(--text-primary) !important; +} + +[data-theme="dark"] .ant-select-selection-placeholder { + color: var(--text-tertiary) !important; +} + +[data-theme="dark"] .ant-select:hover .ant-select-selector { + border-color: #40a9ff !important; + background: var(--card-bg) !important; +} + +[data-theme="dark"] .ant-select-focused .ant-select-selector { + border-color: #1890ff !important; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2) !important; + background: var(--card-bg) !important; + color: var(--text-primary) !important; +} + +[data-theme="dark"] .ant-select-open .ant-select-selector { + background: var(--card-bg) !important; + color: var(--text-primary) !important; + border-color: #1890ff !important; +} + +/* Dark theme select input field */ +[data-theme="dark"] .ant-select-selection-search-input { + color: var(--text-primary) !important; + background: transparent !important; +} + +[data-theme="dark"] .ant-select-selection-search-input::placeholder { + color: var(--text-tertiary) !important; +} + +/* Dark theme select single mode */ +[data-theme="dark"] .ant-select-single .ant-select-selector { + background: var(--card-bg) !important; + border: 1px solid var(--border-color) !important; + color: var(--text-primary) !important; +} + +[data-theme="dark"] .ant-select-single .ant-select-selector .ant-select-selection-item { + color: var(--text-primary) !important; +} + +[data-theme="dark"] .ant-select-single .ant-select-selector .ant-select-selection-placeholder { + color: var(--text-tertiary) !important; +} + +/* Dark theme select dropdown menu */ +[data-theme="dark"] .ant-select-dropdown { + background: var(--card-bg); + border: 1px solid var(--border-color); + box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.3), 0 3px 6px -4px rgba(0, 0, 0, 0.2), 0 9px 28px 8px rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] .ant-select-item { + color: var(--text-primary); +} + +[data-theme="dark"] .ant-select-item:hover { + background: var(--bg-tertiary); +} + +[data-theme="dark"] .ant-select-item-option-selected { + background: #111b26; + color: #69c0ff; +} + +[data-theme="dark"] .ant-select-item-option-selected:hover { + background: #1f2937; +} + +/* Dark theme select arrow */ +[data-theme="dark"] .ant-select-arrow { + color: var(--text-secondary); +} + +[data-theme="dark"] .ant-select:hover .ant-select-arrow { + color: var(--text-primary); +} + +/* Dark theme select clear button */ +[data-theme="dark"] .ant-select-clear { + color: var(--text-secondary); + background: var(--card-bg); +} + +[data-theme="dark"] .ant-select-clear:hover { + color: var(--text-primary); +} + +/* Dark theme select loading */ +[data-theme="dark"] .ant-select-loading-icon { + color: var(--text-secondary); +} + +/* Dark theme select multiple tags */ +[data-theme="dark"] .ant-select-selection-item { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); +} + +[data-theme="dark"] .ant-select-selection-item-remove { + color: var(--text-secondary); +} + +[data-theme="dark"] .ant-select-selection-item-remove:hover { + color: var(--text-primary); +} \ No newline at end of file diff --git a/services/api-docs/server.js b/services/api-docs/server.js index 95375e7..f5d74aa 100644 --- a/services/api-docs/server.js +++ b/services/api-docs/server.js @@ -43,7 +43,7 @@ const SERVICES = { }, 'service-adapters': { name: 'Service Adapters', - url: process.env.SERVICE_ADAPTERS_URL || 'http://localhost:8000', + url: process.env.SERVICE_ADAPTERS_URL || 'http://localhost:8001', openapiPath: '/openapi.json', description: 'Integration adapters for Home Assistant, Frigate, Immich, and other services' }, @@ -84,7 +84,8 @@ async function fetchServiceSpec (serviceKey, service) { } const response = await axios.get(`${service.url}${service.openapiPath}`, { - timeout: 5000 + timeout: 5000, + rejectUnauthorized: false }) return response.data } catch (error) { @@ -126,7 +127,7 @@ async function generateUnifiedSpec () { description: 'API Gateway (Production)' }, { - url: 'http://localhost:8000', + url: 'http://localhost:8001', description: 'Service Adapters (Production)' }, { @@ -155,12 +156,45 @@ async function generateUnifiedSpec () { // Fetch specs from all services for (const [serviceKey, service] of Object.entries(SERVICES)) { const spec = await fetchServiceSpec(serviceKey, service) + + // Collect original tags before modifying them + const subCategories = new Set() + if (spec.paths) { + for (const [path, methods] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(methods)) { + if (operation.tags) { + operation.tags.forEach(tag => { + subCategories.add(tag) + }) + } + } + } + } // Merge paths with service prefix if (spec.paths) { for (const [path, methods] of Object.entries(spec.paths)) { const prefixedPath = `/${serviceKey}${path}` - unifiedSpec.paths[prefixedPath] = methods + const updatedMethods = {} + + for (const [method, operation] of Object.entries(methods)) { + // Use only the main service name as the primary tag + // Store original category in metadata for internal organization + const originalTags = operation.tags || ['General'] + const category = originalTags[0] || 'General' + + updatedMethods[method] = { + ...operation, + tags: [service.name], // Only main service tag for top-level grouping + summary: `[${category}] ${operation.summary || `${method.toUpperCase()} ${path}`}`, + 'x-service': serviceKey, + 'x-service-url': service.url, + 'x-original-tags': originalTags, + 'x-category': category + } + } + + unifiedSpec.paths[prefixedPath] = updatedMethods } } @@ -176,7 +210,9 @@ async function generateUnifiedSpec () { name: service.name, description: service.description, 'x-service-url': service.url, - 'x-service-status': service.status || 'active' + 'x-service-status': service.status || 'active', + 'x-service-key': serviceKey, + 'x-categories': Array.from(subCategories) // Store available categories for reference }) } @@ -314,12 +350,42 @@ app.get('/', swaggerUi.setup(null, { displayRequestDuration: true, filter: true, showExtensions: true, - showCommonExtensions: true + showCommonExtensions: true, + operationsSorter: function(a, b) { + // Sort by summary (which includes category tags) + const summaryA = a.get('summary') || ''; + const summaryB = b.get('summary') || ''; + return summaryA.localeCompare(summaryB); + }, + tagsSorter: 'alpha' }, customCss: ` .swagger-ui .topbar { display: none; } .swagger-ui .info { margin: 20px 0; } .swagger-ui .info .title { color: #1890ff; } + + /* Style service tags */ + .swagger-ui .opblock-tag { + margin: 20px 0 10px 0; + padding: 10px 0; + border-bottom: 2px solid #1890ff; + } + + /* Style operation blocks */ + .swagger-ui .opblock { + margin: 10px 0; + border-radius: 4px; + } + + /* Style operation summaries with category badges */ + .swagger-ui .opblock-summary-description { + font-weight: 500; + } + + /* Add some spacing between operations */ + .swagger-ui .opblock-tag-section .opblock { + margin-bottom: 15px; + } `, customSiteTitle: 'LabFusion API Documentation' })) diff --git a/services/service-adapters/main.py b/services/service-adapters/main.py index a9dab04..0862c7f 100644 --- a/services/service-adapters/main.py +++ b/services/service-adapters/main.py @@ -11,7 +11,7 @@ app = FastAPI( version="1.0.0", license_info={"name": "MIT License", "url": "https://opensource.org/licenses/MIT"}, servers=[ - {"url": "http://localhost:8000", "description": "Development Server"}, + {"url": "http://localhost:8001", "description": "Development Server"}, {"url": "https://adapters.labfusion.dev", "description": "Production Server"}, ], ) @@ -35,4 +35,4 @@ app.include_router(events.router) if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="127.0.0.1", port=8000) + uvicorn.run(app, host="127.0.0.1", port=8001)