Update README and documentation; refactor frontend components for improved structure and resilience

This commit is contained in:
glenn schrooyen
2025-09-11 23:46:29 +02:00
parent 63b4bb487d
commit b9206de1a0
49 changed files with 27058 additions and 581 deletions

View File

@@ -5,59 +5,73 @@ import { DashboardOutlined, SettingOutlined, BarChartOutlined } from '@ant-desig
import Dashboard from './components/Dashboard';
import SystemMetrics from './components/SystemMetrics';
import Settings from './components/Settings';
import OfflineMode from './components/OfflineMode';
import ErrorBoundary from './components/common/ErrorBoundary';
import { useServiceStatus } from './hooks/useServiceStatus';
import './App.css';
const { Header, Sider, Content } = Layout;
const { Title } = Typography;
function App() {
const serviceStatus = useServiceStatus();
const handleRetry = () => {
window.location.reload();
};
return (
<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' }}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/metrics" element={<SystemMetrics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Content>
<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>
</Layout>
</ErrorBoundary>
);
}

View File

@@ -1,136 +1,59 @@
import React from 'react';
import { Row, Col, Card, Statistic, Progress, List, Typography } from 'antd';
import {
DashboardOutlined,
ServerOutlined,
DatabaseOutlined,
WifiOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons';
import { Row, Col, Typography, Alert } from 'antd';
import SystemMetrics from './SystemMetrics';
import ServiceStatusBanner from './ServiceStatusBanner';
import SystemStatsCards from './dashboard/SystemStatsCards';
import ServiceStatusList from './dashboard/ServiceStatusList';
import RecentEventsList from './dashboard/RecentEventsList';
import LoadingSpinner from './common/LoadingSpinner';
import { useServiceStatus, useSystemData } from '../hooks/useServiceStatus';
import { ERROR_MESSAGES } from '../constants';
const { Title, Text } = Typography;
const { Title } = Typography;
const Dashboard = () => {
// Mock data - in real app, this would come from API
const systemStats = {
cpu: 45.2,
memory: 68.5,
disk: 32.1,
network: 12.3
const serviceStatus = useServiceStatus();
const { systemStats, services, events: recentEvents, loading, error } = useSystemData();
const handleRefresh = () => {
window.location.reload();
};
const services = [
{ name: 'Home Assistant', status: 'online', uptime: '7d 12h' },
{ name: 'Frigate', status: 'online', uptime: '7d 12h' },
{ name: 'Immich', status: 'online', uptime: '7d 12h' },
{ name: 'n8n', status: 'offline', uptime: '0d 0h' },
{ name: 'PostgreSQL', status: 'online', uptime: '7d 12h' },
{ name: 'Redis', status: 'online', uptime: '7d 12h' }
];
const recentEvents = [
{ time: '2 minutes ago', event: 'Person detected at front door', service: 'Frigate' },
{ time: '5 minutes ago', event: 'CPU usage above 80%', service: 'System' },
{ time: '12 minutes ago', event: 'Alice arrived home', service: 'Home Assistant' },
{ time: '1 hour ago', event: 'New photo uploaded', service: 'Immich' }
];
const getStatusIcon = (status) => {
return status === 'online' ?
<CheckCircleOutlined style={{ color: '#52c41a' }} /> :
<ExclamationCircleOutlined style={{ color: '#ff4d4f' }} />;
};
if (loading) {
return (
<div className="dashboard-container">
<LoadingSpinner message="Loading dashboard..." />
</div>
);
}
return (
<div className="dashboard-container">
<ServiceStatusBanner serviceStatus={serviceStatus} onRefresh={handleRefresh} />
<Title level={2}>System Overview</Title>
{error && (
<Alert
message={ERROR_MESSAGES.DATA_LOADING_ERROR}
description={error}
type="warning"
style={{ marginBottom: 16 }}
/>
)}
{/* System Metrics */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic
title="CPU Usage"
value={systemStats.cpu}
suffix="%"
prefix={<ServerOutlined />}
/>
<Progress percent={systemStats.cpu} showInfo={false} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Memory Usage"
value={systemStats.memory}
suffix="%"
prefix={<DatabaseOutlined />}
/>
<Progress percent={systemStats.memory} showInfo={false} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Disk Usage"
value={systemStats.disk}
suffix="%"
prefix={<DatabaseOutlined />}
/>
<Progress percent={systemStats.disk} showInfo={false} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Network"
value={systemStats.network}
suffix="Mbps"
prefix={<WifiOutlined />}
/>
</Card>
</Col>
</Row>
<SystemStatsCards systemStats={systemStats} />
<Row gutter={16}>
{/* Service Status */}
<Col span={12}>
<Card title="Service Status" style={{ height: 400 }}>
<List
dataSource={services}
renderItem={(service) => (
<List.Item>
<List.Item.Meta
avatar={getStatusIcon(service.status)}
title={service.name}
description={`Uptime: ${service.uptime}`}
/>
<Text type={service.status === 'online' ? 'success' : 'danger'}>
{service.status.toUpperCase()}
</Text>
</List.Item>
)}
/>
</Card>
<ServiceStatusList services={services} />
</Col>
{/* Recent Events */}
<Col span={12}>
<Card title="Recent Events" style={{ height: 400 }}>
<List
dataSource={recentEvents}
renderItem={(event) => (
<List.Item>
<List.Item.Meta
title={event.event}
description={`${event.time}${event.service}`}
/>
</List.Item>
)}
/>
</Card>
<RecentEventsList events={recentEvents} />
</Col>
</Row>

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Alert, Button, Space } from 'antd';
import { WifiOutlined, ReloadOutlined } from '@ant-design/icons';
const OfflineMode = ({ onRetry }) => {
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 }}
/>
);
};
export default OfflineMode;

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { Alert, Button, Space } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import StatusIcon from './common/StatusIcon';
import { UI_CONSTANTS } from '../constants';
const ServiceStatusBanner = ({ serviceStatus, onRefresh }) => {
const getStatusMessage = () => {
switch (serviceStatus.overall) {
case 'online':
return 'All services are running normally';
case 'partial':
return 'Some services are unavailable - limited functionality';
case 'offline':
return 'Backend services are offline - running in offline mode';
default:
return 'Checking service status...';
}
};
const getStatusType = () => {
switch (serviceStatus.overall) {
case 'online':
return 'success';
case 'partial':
return 'warning';
case 'offline':
return 'error';
default:
return 'info';
}
};
const getServiceDetails = () => {
const details = [];
if (!serviceStatus.apiGateway.available) {
details.push(`API Gateway: ${serviceStatus.apiGateway.error || 'Unavailable'}`);
}
if (!serviceStatus.serviceAdapters.available) {
details.push(`Service Adapters: ${serviceStatus.serviceAdapters.error || 'Unavailable'}`);
}
if (!serviceStatus.apiDocs.available) {
details.push(`API Docs: ${serviceStatus.apiDocs.error || 'Unavailable'}`);
}
return details;
};
if (serviceStatus.overall === 'online') {
return null; // Don't show banner when everything is working
}
return (
<Alert
message={
<Space>
<StatusIcon status={serviceStatus.overall} />
<span>{getStatusMessage()}</span>
{onRefresh && (
<Button
type="link"
size="small"
icon={<ReloadOutlined />}
onClick={onRefresh}
>
Refresh
</Button>
)}
</Space>
}
description={
serviceStatus.overall !== 'checking' && (
<div>
{getServiceDetails().length > 0 && (
<div style={{ marginTop: 8 }}>
<strong>Service Details:</strong>
<ul style={{ margin: '4px 0 0 0', paddingLeft: '20px' }}>
{getServiceDetails().map((detail, index) => (
<li key={index}>{detail}</li>
))}
</ul>
</div>
)}
{serviceStatus.overall === 'offline' && (
<div style={{ marginTop: 8 }}>
<strong>Offline Mode:</strong> The frontend is running with fallback data.
Start the backend services to enable full functionality.
</div>
)}
</div>
)
}
type={getStatusType()}
showIcon={false}
style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM }}
closable={serviceStatus.overall === 'partial'}
/>
);
};
export default ServiceStatusBanner;

View File

@@ -1,9 +1,12 @@
import React from 'react';
import { Card, Row, Col, Statistic, Progress } from 'antd';
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';
const SystemMetrics = () => {
// Mock data for charts
const { systemStats, loading, error } = useSystemData();
// Mock data for charts (fallback when services are unavailable)
const cpuData = [
{ time: '00:00', cpu: 25 },
{ time: '04:00', cpu: 30 },
@@ -34,26 +37,45 @@ const SystemMetrics = () => {
{ time: '24:00', in: 6, out: 4 }
];
if (loading) {
return (
<Card title="System Performance Metrics">
<div style={{ textAlign: 'center', padding: '50px' }}>
Loading metrics...
</div>
</Card>
);
}
return (
<div>
{error && (
<Alert
message="Metrics Unavailable"
description="Real-time metrics are not available. Showing sample data."
type="warning"
style={{ marginBottom: 16 }}
/>
)}
<Card title="System Performance Metrics" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={8}>
<Card size="small">
<Statistic title="CPU Usage (24h)" value={45.2} suffix="%" />
<Progress percent={45.2} showInfo={false} />
<Statistic title="CPU Usage (24h)" value={systemStats.cpu || 0} suffix="%" />
<Progress percent={systemStats.cpu || 0} showInfo={false} />
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic title="Memory Usage (24h)" value={68.5} suffix="%" />
<Progress percent={68.5} showInfo={false} />
<Statistic title="Memory Usage (24h)" value={systemStats.memory || 0} suffix="%" />
<Progress percent={systemStats.memory || 0} showInfo={false} />
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic title="Disk Usage" value={32.1} suffix="%" />
<Progress percent={32.1} showInfo={false} />
<Statistic title="Disk Usage" value={systemStats.disk || 0} suffix="%" />
<Progress percent={systemStats.disk || 0} showInfo={false} />
</Card>
</Col>
</Row>

View File

@@ -0,0 +1,76 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Button } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
this.setState({
error,
errorInfo
});
// Log error to console in development
if (process.env.NODE_ENV === 'development') {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
}
handleReload = () => {
this.setState({ hasError: false, error: null, errorInfo: null });
window.location.reload();
};
render() {
if (this.state.hasError) {
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
<Alert
message="Something went wrong"
description={
<div>
<p>The application encountered an unexpected error.</p>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details style={{ marginTop: '16px', textAlign: 'left' }}>
<summary>Error Details (Development)</summary>
<pre style={{ marginTop: '8px', fontSize: '12px' }}>
{this.state.error.toString()}
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={this.handleReload}
style={{ marginTop: '16px' }}
>
Reload Page
</Button>
</div>
}
type="error"
showIcon
/>
</div>
);
}
return this.props.children;
}
}
ErrorBoundary.propTypes = {
children: PropTypes.node.isRequired
};
export default ErrorBoundary;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Spin } from 'antd';
import { UI_CONSTANTS } from '../../constants';
const LoadingSpinner = ({
message = 'Loading...',
size = UI_CONSTANTS.SPINNER_SIZE,
centered = true
}) => {
const containerStyle = centered ? {
textAlign: 'center',
padding: `${UI_CONSTANTS.PADDING.LARGE}px`
} : {};
return (
<div style={containerStyle}>
<Spin size={size} />
{message && (
<div style={{ marginTop: 16 }}>
{message}
</div>
)}
</div>
);
};
LoadingSpinner.propTypes = {
message: PropTypes.string,
size: PropTypes.oneOf(['small', 'default', 'large']),
centered: PropTypes.bool
};
export default LoadingSpinner;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
CheckCircleOutlined,
ExclamationCircleOutlined,
CloseCircleOutlined
} from '@ant-design/icons';
import { COLORS } from '../../constants';
const StatusIcon = ({ status, size = 'default' }) => {
const iconProps = {
style: {
color: getStatusColor(status),
fontSize: size === 'large' ? '20px' : '16px'
}
};
switch (status) {
case 'online':
return <CheckCircleOutlined {...iconProps} />;
case 'partial':
return <ExclamationCircleOutlined {...iconProps} />;
case 'offline':
return <CloseCircleOutlined {...iconProps} />;
default:
return <ExclamationCircleOutlined {...iconProps} />;
}
};
const getStatusColor = (status) => {
switch (status) {
case 'online':
return COLORS.SUCCESS;
case 'partial':
return COLORS.WARNING;
case 'offline':
return COLORS.ERROR;
default:
return COLORS.DISABLED;
}
};
StatusIcon.propTypes = {
status: PropTypes.oneOf(['online', 'partial', 'offline', 'checking']).isRequired,
size: PropTypes.oneOf(['default', 'large'])
};
export default StatusIcon;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Card, List } from 'antd';
import { UI_CONSTANTS } from '../../constants';
const RecentEventsList = ({ events }) => {
const renderEventItem = (event) => (
<List.Item>
<List.Item.Meta
title={event.event}
description={`${event.time}${event.service}`}
/>
</List.Item>
);
return (
<Card title="Recent Events" style={{ height: UI_CONSTANTS.CARD_HEIGHT }}>
<List
dataSource={events}
renderItem={renderEventItem}
/>
</Card>
);
};
RecentEventsList.propTypes = {
events: PropTypes.arrayOf(PropTypes.shape({
time: PropTypes.string.isRequired,
event: PropTypes.string.isRequired,
service: PropTypes.string.isRequired
})).isRequired
};
export default RecentEventsList;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Card, List, Typography } from 'antd';
import StatusIcon from '../common/StatusIcon';
import { UI_CONSTANTS } from '../../constants';
const { Text } = Typography;
const ServiceStatusList = ({ services }) => {
const renderServiceItem = (service) => (
<List.Item>
<List.Item.Meta
avatar={<StatusIcon status={service.status} />}
title={service.name}
description={`Uptime: ${service.uptime}`}
/>
<Text type={service.status === 'online' ? 'success' : 'danger'}>
{service.status.toUpperCase()}
</Text>
</List.Item>
);
return (
<Card title="Service Status" style={{ height: UI_CONSTANTS.CARD_HEIGHT }}>
<List
dataSource={services}
renderItem={renderServiceItem}
/>
</Card>
);
};
ServiceStatusList.propTypes = {
services: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
status: PropTypes.oneOf(['online', 'offline']).isRequired,
uptime: PropTypes.string.isRequired
})).isRequired
};
export default ServiceStatusList;

View File

@@ -0,0 +1,76 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Row, Col, Card, Statistic, Progress } from 'antd';
import {
ServerOutlined,
DatabaseOutlined,
WifiOutlined
} from '@ant-design/icons';
import { UI_CONSTANTS } from '../../constants';
const SystemStatsCards = ({ systemStats }) => {
const stats = [
{
key: 'cpu',
title: 'CPU Usage',
value: systemStats.cpu || 0,
suffix: '%',
prefix: <ServerOutlined />
},
{
key: 'memory',
title: 'Memory Usage',
value: systemStats.memory || 0,
suffix: '%',
prefix: <DatabaseOutlined />
},
{
key: 'disk',
title: 'Disk Usage',
value: systemStats.disk || 0,
suffix: '%',
prefix: <DatabaseOutlined />
},
{
key: 'network',
title: 'Network',
value: systemStats.network || 0,
suffix: 'Mbps',
prefix: <WifiOutlined />
}
];
return (
<Row gutter={16} style={{ marginBottom: UI_CONSTANTS.MARGIN_TOP }}>
{stats.map((stat) => (
<Col span={6} key={stat.key}>
<Card>
<Statistic
title={stat.title}
value={stat.value}
suffix={stat.suffix}
prefix={stat.prefix}
/>
{stat.suffix === '%' && (
<Progress
percent={stat.value}
showInfo={false}
/>
)}
</Card>
</Col>
))}
</Row>
);
};
SystemStatsCards.propTypes = {
systemStats: PropTypes.shape({
cpu: PropTypes.number,
memory: PropTypes.number,
disk: PropTypes.number,
network: PropTypes.number
}).isRequired
};
export default SystemStatsCards;

View File

@@ -0,0 +1,76 @@
// API Configuration
export const API_CONFIG = {
TIMEOUT: 5000,
RETRY_ATTEMPTS: 3,
REFRESH_INTERVALS: {
SERVICE_STATUS: 30000, // 30 seconds
SYSTEM_DATA: 60000, // 60 seconds
}
};
// 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',
API_DOCS: process.env.REACT_APP_DOCS_URL || 'http://localhost:8083',
};
// Service Status Types
export const SERVICE_STATUS = {
ONLINE: 'online',
OFFLINE: 'offline',
PARTIAL: 'partial',
CHECKING: 'checking'
};
// UI Constants
export const UI_CONSTANTS = {
CARD_HEIGHT: 400,
SPINNER_SIZE: 'large',
CHART_HEIGHT: 300,
MARGIN_BOTTOM: 16,
MARGIN_TOP: 24,
PADDING: {
SMALL: 16,
MEDIUM: 24,
LARGE: 50
}
};
// Colors
export const COLORS = {
SUCCESS: '#52c41a',
WARNING: '#faad14',
ERROR: '#ff4d4f',
INFO: '#1890ff',
DISABLED: '#d9d9d9'
};
// Error Messages
export const ERROR_MESSAGES = {
CONNECTION_TIMEOUT: 'Request timeout - service may be unavailable',
SERVICE_ERROR: 'Service error',
SERVICE_UNAVAILABLE: 'Service unavailable - check if backend is running',
UNKNOWN_ERROR: 'Unknown error occurred',
DATA_LOADING_ERROR: 'Data Loading Error',
METRICS_UNAVAILABLE: 'Metrics Unavailable'
};
// Fallback Data
export const FALLBACK_DATA = {
SYSTEM_STATS: {
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' }
]
};

View File

@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import { apiGateway, serviceAdapters, apiDocs, fallbackData } from '../services/api';
import { API_CONFIG, SERVICE_STATUS } from '../constants';
import { determineServiceStatus, formatServiceData, formatEventData } from '../utils/errorHandling';
export const useServiceStatus = () => {
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
});
useEffect(() => {
const checkServices = async () => {
setStatus(prev => ({ ...prev, loading: true }));
// 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);
setStatus(newStatus);
};
checkServices();
// Check services every 30 seconds
const interval = setInterval(checkServices, API_CONFIG.REFRESH_INTERVALS.SERVICE_STATUS);
return () => clearInterval(interval);
}, []);
return status;
};
export const useSystemData = () => {
const [data, setData] = useState({
loading: true,
systemStats: fallbackData.systemStats,
services: fallbackData.services,
events: fallbackData.events,
error: null
});
useEffect(() => {
const fetchData = async () => {
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
: fallbackData.systemStats;
const services = servicesResult.status === 'fulfilled' && servicesResult.value.success
? formatServiceData(servicesResult.value.data)
: fallbackData.services;
const events = eventsResult.status === 'fulfilled' && eventsResult.value.success
? formatEventData(eventsResult.value.data.events)
: fallbackData.events;
setData({
loading: false,
systemStats,
services,
events,
error: null
});
} catch (error) {
setData({
loading: false,
systemStats: fallbackData.systemStats,
services: fallbackData.services,
events: fallbackData.events,
error: 'Failed to fetch data from services'
});
}
};
fetchData();
// Refresh data every 60 seconds
const interval = setInterval(fetchData, API_CONFIG.REFRESH_INTERVALS.SYSTEM_DATA);
return () => clearInterval(interval);
}, []);
return data;
};

View File

@@ -0,0 +1,193 @@
import axios from 'axios';
import { API_CONFIG, SERVICE_URLS, FALLBACK_DATA } from '../constants';
import { handleRequestError, formatServiceData, formatEventData } from '../utils/errorHandling';
// Create axios instances with timeout and error handling
const apiClient = axios.create({
baseURL: SERVICE_URLS.API_GATEWAY,
timeout: API_CONFIG.TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
});
const adaptersClient = axios.create({
baseURL: SERVICE_URLS.SERVICE_ADAPTERS,
timeout: API_CONFIG.TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
});
const docsClient = axios.create({
baseURL: SERVICE_URLS.API_DOCS,
timeout: API_CONFIG.TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
});
// API Gateway endpoints
export const apiGateway = {
// Health check
health: async () => {
try {
const response = await apiClient.get('/health');
return { success: true, data: response.data };
} catch (error) {
return { success: false, ...handleRequestError(error) };
}
},
// Dashboards
getDashboards: async () => {
try {
const response = await apiClient.get('/api/dashboards');
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: [], ...handleRequestError(error) };
}
},
// System data
getEvents: async (params = {}) => {
try {
const response = await apiClient.get('/api/system/events', { params });
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: [], ...handleRequestError(error) };
}
},
getDeviceStates: async (params = {}) => {
try {
const response = await apiClient.get('/api/system/device-states', { params });
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: [], ...handleRequestError(error) };
}
},
getSystemMetrics: async () => {
try {
const response = await apiClient.get('/api/system/metrics');
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: null, ...handleRequestError(error) };
}
}
};
// Service Adapters endpoints
export const serviceAdapters = {
// Health check
health: async () => {
try {
const response = await adaptersClient.get('/health');
return { success: true, data: response.data };
} catch (error) {
return { success: false, ...handleRequestError(error) };
}
},
// Services status
getServices: async () => {
try {
const response = await adaptersClient.get('/services');
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: {}, ...handleRequestError(error) };
}
},
// Home Assistant
getHAEntities: async () => {
try {
const response = await adaptersClient.get('/home-assistant/entities');
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: { entities: [] }, ...handleRequestError(error) };
}
},
// Frigate
getFrigateEvents: async () => {
try {
const response = await adaptersClient.get('/frigate/events');
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: { events: [] }, ...handleRequestError(error) };
}
},
getFrigateCameras: async () => {
try {
const response = await adaptersClient.get('/frigate/cameras');
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: { cameras: [] }, ...handleRequestError(error) };
}
},
// Immich
getImmichAssets: async () => {
try {
const response = await adaptersClient.get('/immich/assets');
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: { assets: [] }, ...handleRequestError(error) };
}
},
getImmichAlbums: async () => {
try {
const response = await adaptersClient.get('/immich/albums');
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: { albums: [] }, ...handleRequestError(error) };
}
},
// Events
getEvents: async (limit = 100) => {
try {
const response = await adaptersClient.get('/events', { params: { limit } });
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: { events: [] }, ...handleRequestError(error) };
}
},
publishEvent: async (eventData) => {
try {
const response = await adaptersClient.post('/publish-event', eventData);
return { success: true, data: response.data };
} catch (error) {
return { success: false, ...handleRequestError(error) };
}
}
};
// API Docs endpoints
export const apiDocs = {
health: async () => {
try {
const response = await docsClient.get('/health');
return { success: true, data: response.data };
} catch (error) {
return { success: false, ...handleRequestError(error) };
}
},
getServices: async () => {
try {
const response = await docsClient.get('/services');
return { success: true, data: response.data };
} catch (error) {
return { success: false, data: {}, ...handleRequestError(error) };
}
}
};
// Export fallback data from constants
export const fallbackData = FALLBACK_DATA;

View File

@@ -0,0 +1,65 @@
import { ERROR_MESSAGES } from '../constants';
/**
* Handles API request errors and returns user-friendly messages
* @param {Error} error - The error object from axios
* @returns {Object} - Error object with user-friendly message
*/
export const handleRequestError = (error) => {
if (error.code === 'ECONNABORTED') {
return { error: ERROR_MESSAGES.CONNECTION_TIMEOUT };
}
if (error.response) {
return { error: `${ERROR_MESSAGES.SERVICE_ERROR}: ${error.response.status}` };
}
if (error.request) {
return { error: ERROR_MESSAGES.SERVICE_UNAVAILABLE };
}
return { error: ERROR_MESSAGES.UNKNOWN_ERROR };
};
/**
* Determines service status based on availability count
* @param {number} availableCount - Number of available services
* @param {number} totalCount - Total number of services
* @returns {string} - Service status
*/
export const determineServiceStatus = (availableCount, totalCount) => {
if (availableCount === 0) return 'offline';
if (availableCount === totalCount) return 'online';
return 'partial';
};
/**
* Formats service data for display
* @param {Object} serviceData - Raw service data
* @returns {Array} - Formatted service array
*/
export const formatServiceData = (serviceData) => {
if (!serviceData || typeof serviceData !== 'object') {
return [];
}
return Object.entries(serviceData).map(([key, service]) => ({
name: service.name || key,
status: service.status === 'healthy' ? 'online' : 'offline',
uptime: service.responseTime || '0d 0h'
}));
};
/**
* Formats event data for display
* @param {Array} events - Raw event data
* @returns {Array} - Formatted event array
*/
export const formatEventData = (events) => {
if (!Array.isArray(events)) {
return [];
}
return events.map(event => ({
time: new Date(event.timestamp).toLocaleString(),
event: `${event.event_type} from ${event.service}`,
service: event.service
}));
};