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:
-
- Start the backend services: docker-compose up -d
- Or start individual services for development
- Refresh this page once services are running
-
-
- }
- onClick={onRetry}
- >
- Retry Connection
-
- window.open('http://localhost:8083', '_blank')}
- >
- Check API Documentation
-
-
-
- }
- 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}
+
+
+
+
+
+
+
+
+ }
+ onClick={handleManualCheck}
+ block
+ >
+ Check Connection
+
+ window.open('http://localhost:8083', '_blank')}
+ block
+ >
+ API Documentation
+
+
+
+
+
+
+ To enable full functionality:
+
+
+ Start the backend services: docker-compose up -d
+ Or start individual services for development
+ Click "Check Connection" above once services are running
+
+
+ }
+ 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
-
+
-
-
-
+
+
+
Grid Layout
List Layout
Custom Layout
-
-
- 10 seconds
- 30 seconds
- 1 minute
- 5 minutes
+
+
+ 10 seconds
+ 30 seconds
+ 1 minute
+ 5 minutes
-
-
+
+
Light
Dark
Auto
@@ -117,6 +209,54 @@ const Settings = () => {
+
+
+
+
+ Export Settings
+
+ Download your current settings as a JSON file
+
+ }
+ onClick={handleExport}
+ style={{ marginTop: 8 }}
+ >
+ Export Settings
+
+
+
+
+
+
+ Import Settings
+
+ Upload a previously exported settings file
+
+
+ }
+ loading={loading}
+ style={{ marginTop: 8 }}
+ >
+ Import Settings
+
+
+
+
+
);
};
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)