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

### 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:
GSRN
2025-09-18 02:37:58 +02:00
parent 4b2ef7e246
commit 48c755dff3
17 changed files with 1754 additions and 194 deletions

View File

@@ -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: {

View File

@@ -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 {

View File

@@ -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>
);
}

View File

@@ -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 }}>

View File

@@ -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 &quot;Check Connection&quot; above once services are running</li>
</ol>
</div>
}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
</div>
);
};

View File

@@ -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>
);
};

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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',
};

View 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>
);
};

View 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>
);
};

View 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 };
};

View File

@@ -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);
}