feat: Enhance frontend with theme support and offline capabilities
Some checks failed
Integration Tests / integration-tests (push) Failing after 24s
Integration Tests / performance-tests (push) Has been skipped
API Docs (Node.js Express) / test (20) (push) Failing after 42s
API Docs (Node.js Express) / build (push) Has been skipped
Service Adapters (Python FastAPI) / test (3.11) (push) Successful in 1m8s
Service Adapters (Python FastAPI) / test (3.12) (push) Successful in 1m13s
Frontend (React) / test (20) (push) Successful in 1m46s
Frontend (React) / build (push) Failing after 52s
Frontend (React) / lighthouse (push) Has been skipped
Service Adapters (Python FastAPI) / test (3.13) (push) Successful in 2m4s
Service Adapters (Python FastAPI) / build (push) Failing after 17s
Some checks failed
Integration Tests / integration-tests (push) Failing after 24s
Integration Tests / performance-tests (push) Has been skipped
API Docs (Node.js Express) / test (20) (push) Failing after 42s
API Docs (Node.js Express) / build (push) Has been skipped
Service Adapters (Python FastAPI) / test (3.11) (push) Successful in 1m8s
Service Adapters (Python FastAPI) / test (3.12) (push) Successful in 1m13s
Frontend (React) / test (20) (push) Successful in 1m46s
Frontend (React) / build (push) Failing after 52s
Frontend (React) / lighthouse (push) Has been skipped
Service Adapters (Python FastAPI) / test (3.13) (push) Successful in 2m4s
Service Adapters (Python FastAPI) / build (push) Failing after 17s
### 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.
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<Layout style={{
|
||||
minHeight: '100vh',
|
||||
background: 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)'
|
||||
}}>
|
||||
<Sider
|
||||
width={250}
|
||||
theme={dashboardSettings.theme === 'dark' ? 'dark' : 'light'}
|
||||
style={{
|
||||
background: 'var(--sider-bg)',
|
||||
borderRight: '1px solid var(--border-color)'
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '16px', textAlign: 'center' }}>
|
||||
<Title level={3} style={{ color: 'var(--sider-text)', margin: 0 }}>
|
||||
LabFusion
|
||||
</Title>
|
||||
</div>
|
||||
<Menu
|
||||
theme={dashboardSettings.theme === 'dark' ? 'dark' : 'light'}
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
onClick={handleMenuClick}
|
||||
items={[
|
||||
{
|
||||
key: 'dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
key: 'metrics',
|
||||
icon: <BarChartOutlined />,
|
||||
label: 'System Metrics',
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: 'Settings',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout style={{
|
||||
background: 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)'
|
||||
}}>
|
||||
<Header style={{
|
||||
background: 'var(--header-bg)',
|
||||
padding: '0 24px',
|
||||
boxShadow: '0 2px 8px var(--shadow)',
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
color: 'var(--text-primary)'
|
||||
}}>
|
||||
<Title level={4} style={{
|
||||
margin: 0,
|
||||
lineHeight: '64px',
|
||||
color: 'var(--text-primary)'
|
||||
}}>
|
||||
Homelab Dashboard
|
||||
</Title>
|
||||
</Header>
|
||||
<Content style={{
|
||||
margin: '24px',
|
||||
background: 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
padding: 0
|
||||
}}>
|
||||
{serviceStatus.overall === 'offline' && (
|
||||
<OfflineMode onRetry={handleRetry} />
|
||||
)}
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/metrics" element={<SystemMetrics />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider width={250} theme="dark">
|
||||
<div style={{ padding: '16px', textAlign: 'center' }}>
|
||||
<Title level={3} style={{ color: 'white', margin: 0 }}>
|
||||
LabFusion
|
||||
</Title>
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
defaultSelectedKeys={['dashboard']}
|
||||
items={[
|
||||
{
|
||||
key: 'dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
key: 'metrics',
|
||||
icon: <BarChartOutlined />,
|
||||
label: 'System Metrics',
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: 'Settings',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ background: '#fff', padding: '0 24px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
|
||||
<Title level={4} style={{ margin: 0, lineHeight: '64px' }}>
|
||||
Homelab Dashboard
|
||||
</Title>
|
||||
</Header>
|
||||
<Content style={{ margin: '24px', background: '#fff', borderRadius: '8px' }}>
|
||||
{serviceStatus.overall === 'offline' && (
|
||||
<OfflineMode onRetry={handleRetry} />
|
||||
)}
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/metrics" element={<SystemMetrics />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
<OfflineProvider>
|
||||
<SettingsProvider>
|
||||
<AppContent />
|
||||
</SettingsProvider>
|
||||
</OfflineProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="dashboard-container">
|
||||
<div className="dashboard-container" style={{
|
||||
background: 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
padding: '24px',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<ServiceStatusBanner serviceStatus={serviceStatus} onRefresh={handleRefresh} />
|
||||
|
||||
<Title level={2}>System Overview</Title>
|
||||
<Title level={2} style={{ color: 'var(--text-primary)' }}>System Overview</Title>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
@@ -45,17 +54,35 @@ const Dashboard = () => {
|
||||
{/* System Metrics */}
|
||||
<SystemStatsCards systemStats={systemStats} />
|
||||
|
||||
<Row gutter={16}>
|
||||
{/* Service Status */}
|
||||
<Col span={12}>
|
||||
{layout === 'list' ? (
|
||||
// List Layout - Vertical stacking
|
||||
<div>
|
||||
<ServiceStatusList services={services} />
|
||||
</Col>
|
||||
|
||||
{/* Recent Events */}
|
||||
<Col span={12}>
|
||||
<RecentEventsList events={recentEvents} />
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<RecentEventsList events={recentEvents} />
|
||||
</div>
|
||||
</div>
|
||||
) : layout === 'custom' ? (
|
||||
// Custom Layout - Different arrangement
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<ServiceStatusList services={services} />
|
||||
</Col>
|
||||
<Col span={24} style={{ marginTop: 16 }}>
|
||||
<RecentEventsList events={recentEvents} />
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
// Grid Layout - Default side-by-side
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<ServiceStatusList services={services} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<RecentEventsList events={recentEvents} />
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* System Metrics Chart */}
|
||||
<Row style={{ marginTop: 24 }}>
|
||||
|
||||
@@ -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 (
|
||||
<Alert
|
||||
message="Offline Mode"
|
||||
description={
|
||||
<div>
|
||||
<p>The frontend is running in offline mode because backend services are not available.</p>
|
||||
<p>To enable full functionality:</p>
|
||||
<ol style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||
<li>Start the backend services: <code>docker-compose up -d</code></li>
|
||||
<li>Or start individual services for development</li>
|
||||
<li>Refresh this page once services are running</li>
|
||||
</ol>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRetry}
|
||||
>
|
||||
Retry Connection
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.open('http://localhost:8083', '_blank')}
|
||||
>
|
||||
Check API Documentation
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Alert
|
||||
message="Offline Mode"
|
||||
description={
|
||||
<div>
|
||||
<Paragraph>
|
||||
The frontend is running in offline mode because backend services are not available.
|
||||
API calls have been disabled to prevent unnecessary network traffic.
|
||||
</Paragraph>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
size="small"
|
||||
title="Connection Status"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Space direction="vertical" size="small">
|
||||
<div>
|
||||
<WifiOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />
|
||||
<Text style={{ color: 'var(--text-primary)' }}>Services Offline</Text>
|
||||
</div>
|
||||
<div>
|
||||
<ClockCircleOutlined style={{ marginRight: 8, color: 'var(--text-secondary)' }} />
|
||||
<Text type="secondary" style={{ color: 'var(--text-secondary)' }}>
|
||||
Last check: {formatLastCheck(lastOnlineCheck)}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ color: 'var(--text-secondary)' }}>
|
||||
Consecutive failures: {consecutiveFailures}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
size="small"
|
||||
title="Quick Actions"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Space direction="vertical" size="small">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleManualCheck}
|
||||
block
|
||||
>
|
||||
Check Connection
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.open('http://localhost:8083', '_blank')}
|
||||
block
|
||||
>
|
||||
API Documentation
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Paragraph style={{ marginTop: 16, marginBottom: 0, color: 'var(--text-primary)' }}>
|
||||
<Text strong style={{ color: 'var(--text-primary)' }}>To enable full functionality:</Text>
|
||||
</Paragraph>
|
||||
<ol style={{ margin: '8px 0', paddingLeft: '20px', color: 'var(--text-primary)' }}>
|
||||
<li>Start the backend services: <code style={{ background: 'var(--bg-tertiary)', color: 'var(--text-primary)', padding: '2px 4px', borderRadius: '3px' }}>docker-compose up -d</code></li>
|
||||
<li>Or start individual services for development</li>
|
||||
<li>Click "Check Connection" above once services are running</li>
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="dashboard-container">
|
||||
<Title level={2}>Settings</Title>
|
||||
<div className="dashboard-container" style={{
|
||||
background: 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
padding: '24px',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<Title level={2} style={{ color: 'var(--text-primary)' }}>Settings</Title>
|
||||
|
||||
<Card title="Service Integrations" style={{ marginBottom: 24 }}>
|
||||
<Card
|
||||
title="Service Integrations"
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
initialValues={{
|
||||
homeAssistant: {
|
||||
enabled: true,
|
||||
url: 'http://homeassistant.local:8123',
|
||||
token: 'your-token-here'
|
||||
},
|
||||
frigate: {
|
||||
enabled: true,
|
||||
url: 'http://frigate.local:5000',
|
||||
token: 'your-token-here'
|
||||
},
|
||||
immich: {
|
||||
enabled: false,
|
||||
url: 'http://immich.local:2283',
|
||||
apiKey: 'your-api-key-here'
|
||||
}
|
||||
}}
|
||||
initialValues={settings}
|
||||
>
|
||||
{/* Home Assistant */}
|
||||
<Card size="small" title="Home Assistant" style={{ marginBottom: 16 }}>
|
||||
<Card
|
||||
size="small"
|
||||
title="Home Assistant"
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Form.Item name={['homeAssistant', 'enabled']} valuePropName="checked">
|
||||
<Switch checkedChildren="Enabled" unCheckedChildren="Disabled" />
|
||||
</Form.Item>
|
||||
@@ -58,7 +110,17 @@ const Settings = () => {
|
||||
</Card>
|
||||
|
||||
{/* Frigate */}
|
||||
<Card size="small" title="Frigate" style={{ marginBottom: 16 }}>
|
||||
<Card
|
||||
size="small"
|
||||
title="Frigate"
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Form.Item name={['frigate', 'enabled']} valuePropName="checked">
|
||||
<Switch checkedChildren="Enabled" unCheckedChildren="Disabled" />
|
||||
</Form.Item>
|
||||
@@ -71,7 +133,17 @@ const Settings = () => {
|
||||
</Card>
|
||||
|
||||
{/* Immich */}
|
||||
<Card size="small" title="Immich" style={{ marginBottom: 16 }}>
|
||||
<Card
|
||||
size="small"
|
||||
title="Immich"
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Form.Item name={['immich', 'enabled']} valuePropName="checked">
|
||||
<Switch checkedChildren="Enabled" unCheckedChildren="Disabled" />
|
||||
</Form.Item>
|
||||
@@ -83,33 +155,53 @@ const Settings = () => {
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
Save Settings
|
||||
</Button>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
Save Settings
|
||||
</Button>
|
||||
<Button onClick={handleReset} icon={<ReloadOutlined />}>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title="Dashboard Configuration">
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="Default Dashboard Layout">
|
||||
<Select defaultValue="grid" style={{ width: 200 }}>
|
||||
<Card
|
||||
title="Dashboard Configuration"
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Form
|
||||
layout="vertical"
|
||||
initialValues={settings.dashboard}
|
||||
onValuesChange={(changedValues) => {
|
||||
updateServiceSettings('dashboard', { ...settings.dashboard, ...changedValues });
|
||||
}}
|
||||
>
|
||||
<Form.Item label="Default Dashboard Layout" name="layout">
|
||||
<Select style={{ width: 200 }}>
|
||||
<Option value="grid">Grid Layout</Option>
|
||||
<Option value="list">List Layout</Option>
|
||||
<Option value="custom">Custom Layout</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Auto-refresh Interval">
|
||||
<Select defaultValue="30" style={{ width: 200 }}>
|
||||
<Option value="10">10 seconds</Option>
|
||||
<Option value="30">30 seconds</Option>
|
||||
<Option value="60">1 minute</Option>
|
||||
<Option value="300">5 minutes</Option>
|
||||
<Form.Item label="Auto-refresh Interval (seconds)" name="autoRefreshInterval">
|
||||
<Select style={{ width: 200 }}>
|
||||
<Option value={10}>10 seconds</Option>
|
||||
<Option value={30}>30 seconds</Option>
|
||||
<Option value={60}>1 minute</Option>
|
||||
<Option value={300}>5 minutes</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Theme">
|
||||
<Select defaultValue="light" style={{ width: 200 }}>
|
||||
<Form.Item label="Theme" name="theme">
|
||||
<Select style={{ width: 200 }}>
|
||||
<Option value="light">Light</Option>
|
||||
<Option value="dark">Dark</Option>
|
||||
<Option value="auto">Auto</Option>
|
||||
@@ -117,6 +209,54 @@ const Settings = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Settings Management"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong style={{ color: 'var(--text-primary)' }}>Export Settings</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ color: 'var(--text-secondary)' }}>Download your current settings as a JSON file</Text>
|
||||
<br />
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExport}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
Export Settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider style={{ borderColor: 'var(--border-color)' }} />
|
||||
|
||||
<div>
|
||||
<Text strong style={{ color: 'var(--text-primary)' }}>Import Settings</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ color: 'var(--text-secondary)' }}>Upload a previously exported settings file</Text>
|
||||
<br />
|
||||
<Upload
|
||||
beforeUpload={handleImport}
|
||||
accept=".json"
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
loading={loading}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
Import Settings
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<Card title="System Performance Metrics">
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
<Card
|
||||
title="System Performance Metrics"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '50px', color: 'var(--text-primary)' }}>
|
||||
Loading metrics...
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure systemStats is an object with fallback values
|
||||
const safeSystemStats = systemStats || {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
disk: 0,
|
||||
network: 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
background: 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
padding: '24px'
|
||||
}}>
|
||||
{error && (
|
||||
<Alert
|
||||
message="Metrics Unavailable"
|
||||
@@ -58,24 +78,57 @@ const SystemMetrics = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card title="System Performance Metrics" style={{ marginBottom: 16 }}>
|
||||
<Card
|
||||
title="System Performance Metrics"
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="CPU Usage (24h)" value={systemStats.cpu || 0} suffix="%" />
|
||||
<Progress percent={systemStats.cpu || 0} showInfo={false} />
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Statistic title="CPU Usage (24h)" value={safeSystemStats.cpu || 0} suffix="%" />
|
||||
<Progress percent={safeSystemStats.cpu || 0} showInfo={false} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="Memory Usage (24h)" value={systemStats.memory || 0} suffix="%" />
|
||||
<Progress percent={systemStats.memory || 0} showInfo={false} />
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Statistic title="Memory Usage (24h)" value={safeSystemStats.memory || 0} suffix="%" />
|
||||
<Progress percent={safeSystemStats.memory || 0} showInfo={false} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="Disk Usage" value={systemStats.disk || 0} suffix="%" />
|
||||
<Progress percent={systemStats.disk || 0} showInfo={false} />
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Statistic title="Disk Usage" value={safeSystemStats.disk || 0} suffix="%" />
|
||||
<Progress percent={safeSystemStats.disk || 0} showInfo={false} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -83,7 +136,15 @@ const SystemMetrics = () => {
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="CPU Usage Over Time">
|
||||
<Card
|
||||
title="CPU Usage Over Time"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={cpuData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@@ -96,7 +157,15 @@ const SystemMetrics = () => {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="Memory Usage Over Time">
|
||||
<Card
|
||||
title="Memory Usage Over Time"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={memoryData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@@ -112,7 +181,15 @@ const SystemMetrics = () => {
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col span={24}>
|
||||
<Card title="Network Traffic">
|
||||
<Card
|
||||
title="Network Traffic"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={networkData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
|
||||
@@ -14,7 +14,16 @@ const RecentEventsList = ({ events }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Card title="Recent Events" style={{ height: UI_CONSTANTS.CARD_HEIGHT }}>
|
||||
<Card
|
||||
title="Recent Events"
|
||||
style={{
|
||||
height: UI_CONSTANTS.CARD_HEIGHT,
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<List
|
||||
dataSource={events}
|
||||
renderItem={renderEventItem}
|
||||
|
||||
@@ -21,7 +21,16 @@ const ServiceStatusList = ({ services }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Card title="Service Status" style={{ height: UI_CONSTANTS.CARD_HEIGHT }}>
|
||||
<Card
|
||||
title="Service Status"
|
||||
style={{
|
||||
height: UI_CONSTANTS.CARD_HEIGHT,
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}
|
||||
headStyle={{ color: 'var(--text-primary)' }}
|
||||
bodyStyle={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<List
|
||||
dataSource={services}
|
||||
renderItem={renderServiceItem}
|
||||
|
||||
@@ -9,32 +9,40 @@ import {
|
||||
import { UI_CONSTANTS } from '../../constants';
|
||||
|
||||
const SystemStatsCards = ({ systemStats }) => {
|
||||
// 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: <DesktopOutlined />
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
title: 'Memory Usage',
|
||||
value: systemStats.memory || 0,
|
||||
value: safeSystemStats.memory || 0,
|
||||
suffix: '%',
|
||||
prefix: <DatabaseOutlined />
|
||||
},
|
||||
{
|
||||
key: 'disk',
|
||||
title: 'Disk Usage',
|
||||
value: systemStats.disk || 0,
|
||||
value: safeSystemStats.disk || 0,
|
||||
suffix: '%',
|
||||
prefix: <DatabaseOutlined />
|
||||
},
|
||||
{
|
||||
key: 'network',
|
||||
title: 'Network',
|
||||
value: systemStats.network || 0,
|
||||
value: safeSystemStats.network || 0,
|
||||
suffix: 'Mbps',
|
||||
prefix: <WifiOutlined />
|
||||
}
|
||||
@@ -70,7 +78,7 @@ SystemStatsCards.propTypes = {
|
||||
memory: PropTypes.number,
|
||||
disk: PropTypes.number,
|
||||
network: PropTypes.number
|
||||
}).isRequired
|
||||
})
|
||||
};
|
||||
|
||||
export default SystemStatsCards;
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
76
frontend/src/contexts/OfflineContext.jsx
Normal file
76
frontend/src/contexts/OfflineContext.jsx
Normal file
@@ -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 (
|
||||
<OfflineContext.Provider value={value}>
|
||||
{children}
|
||||
</OfflineContext.Provider>
|
||||
);
|
||||
};
|
||||
137
frontend/src/contexts/SettingsContext.jsx
Normal file
137
frontend/src/contexts/SettingsContext.jsx
Normal file
@@ -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 (
|
||||
<SettingsContext.Provider value={value}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
207
frontend/src/hooks/useOfflineAwareServiceStatus.js
Normal file
207
frontend/src/hooks/useOfflineAwareServiceStatus.js
Normal file
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user