@qwickapps/server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +45 -0
- package/README.md +321 -0
- package/dist/core/control-panel.d.ts +21 -0
- package/dist/core/control-panel.d.ts.map +1 -0
- package/dist/core/control-panel.js +416 -0
- package/dist/core/control-panel.js.map +1 -0
- package/dist/core/gateway.d.ts +133 -0
- package/dist/core/gateway.d.ts.map +1 -0
- package/dist/core/gateway.js +270 -0
- package/dist/core/gateway.js.map +1 -0
- package/dist/core/health-manager.d.ts +52 -0
- package/dist/core/health-manager.d.ts.map +1 -0
- package/dist/core/health-manager.js +192 -0
- package/dist/core/health-manager.js.map +1 -0
- package/dist/core/index.d.ts +10 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +8 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/logging.d.ts +83 -0
- package/dist/core/logging.d.ts.map +1 -0
- package/dist/core/logging.js +191 -0
- package/dist/core/logging.js.map +1 -0
- package/dist/core/types.d.ts +195 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +7 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/config-plugin.d.ts +15 -0
- package/dist/plugins/config-plugin.d.ts.map +1 -0
- package/dist/plugins/config-plugin.js +96 -0
- package/dist/plugins/config-plugin.js.map +1 -0
- package/dist/plugins/diagnostics-plugin.d.ts +29 -0
- package/dist/plugins/diagnostics-plugin.d.ts.map +1 -0
- package/dist/plugins/diagnostics-plugin.js +142 -0
- package/dist/plugins/diagnostics-plugin.js.map +1 -0
- package/dist/plugins/health-plugin.d.ts +17 -0
- package/dist/plugins/health-plugin.d.ts.map +1 -0
- package/dist/plugins/health-plugin.js +25 -0
- package/dist/plugins/health-plugin.js.map +1 -0
- package/dist/plugins/index.d.ts +14 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +10 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/logs-plugin.d.ts +22 -0
- package/dist/plugins/logs-plugin.d.ts.map +1 -0
- package/dist/plugins/logs-plugin.js +242 -0
- package/dist/plugins/logs-plugin.js.map +1 -0
- package/dist-ui/assets/index-Bk7ypbI4.js +465 -0
- package/dist-ui/assets/index-Bk7ypbI4.js.map +1 -0
- package/dist-ui/assets/index-CiizQQnb.css +1 -0
- package/dist-ui/index.html +13 -0
- package/package.json +98 -0
- package/src/core/control-panel.ts +493 -0
- package/src/core/gateway.ts +421 -0
- package/src/core/health-manager.ts +227 -0
- package/src/core/index.ts +25 -0
- package/src/core/logging.ts +234 -0
- package/src/core/types.ts +218 -0
- package/src/index.ts +55 -0
- package/src/plugins/config-plugin.ts +117 -0
- package/src/plugins/diagnostics-plugin.ts +178 -0
- package/src/plugins/health-plugin.ts +35 -0
- package/src/plugins/index.ts +17 -0
- package/src/plugins/logs-plugin.ts +314 -0
- package/ui/index.html +12 -0
- package/ui/src/App.tsx +65 -0
- package/ui/src/api/controlPanelApi.ts +148 -0
- package/ui/src/config/AppConfig.ts +18 -0
- package/ui/src/index.css +29 -0
- package/ui/src/index.tsx +11 -0
- package/ui/src/pages/ConfigPage.tsx +199 -0
- package/ui/src/pages/DashboardPage.tsx +264 -0
- package/ui/src/pages/DiagnosticsPage.tsx +315 -0
- package/ui/src/pages/HealthPage.tsx +204 -0
- package/ui/src/pages/LogsPage.tsx +267 -0
- package/ui/src/pages/NotFoundPage.tsx +41 -0
- package/ui/tsconfig.json +19 -0
- package/ui/vite.config.ts +21 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control Panel API Client
|
|
3
|
+
*
|
|
4
|
+
* Communicates with the backend Express API
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface HealthCheck {
|
|
8
|
+
status: 'healthy' | 'degraded' | 'unhealthy';
|
|
9
|
+
latency?: number;
|
|
10
|
+
lastChecked: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HealthResponse {
|
|
15
|
+
status: 'healthy' | 'degraded' | 'unhealthy';
|
|
16
|
+
timestamp: string;
|
|
17
|
+
uptime: number;
|
|
18
|
+
checks: Record<string, HealthCheck>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface InfoResponse {
|
|
22
|
+
product: string;
|
|
23
|
+
version: string;
|
|
24
|
+
uptime: number;
|
|
25
|
+
links: Array<{ label: string; url: string; external?: boolean }>;
|
|
26
|
+
branding?: {
|
|
27
|
+
primaryColor?: string;
|
|
28
|
+
logo?: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DiagnosticsResponse {
|
|
33
|
+
timestamp: string;
|
|
34
|
+
product: string;
|
|
35
|
+
version?: string;
|
|
36
|
+
uptime: number;
|
|
37
|
+
health: Record<string, HealthCheck>;
|
|
38
|
+
system: {
|
|
39
|
+
nodeVersion: string;
|
|
40
|
+
platform: string;
|
|
41
|
+
arch: string;
|
|
42
|
+
memory: {
|
|
43
|
+
total: number;
|
|
44
|
+
used: number;
|
|
45
|
+
free: number;
|
|
46
|
+
};
|
|
47
|
+
cpu: {
|
|
48
|
+
usage: number;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ConfigResponse {
|
|
54
|
+
config: Record<string, string | number | boolean>;
|
|
55
|
+
masked: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LogEntry {
|
|
59
|
+
timestamp: string;
|
|
60
|
+
level: string;
|
|
61
|
+
message: string;
|
|
62
|
+
source?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LogsResponse {
|
|
66
|
+
logs: LogEntry[];
|
|
67
|
+
total: number;
|
|
68
|
+
page: number;
|
|
69
|
+
limit: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface LogSource {
|
|
73
|
+
name: string;
|
|
74
|
+
type: 'file' | 'api';
|
|
75
|
+
available: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class ControlPanelApi {
|
|
79
|
+
private baseUrl: string;
|
|
80
|
+
|
|
81
|
+
constructor(baseUrl = '') {
|
|
82
|
+
this.baseUrl = baseUrl;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getHealth(): Promise<HealthResponse> {
|
|
86
|
+
const response = await fetch(`${this.baseUrl}/api/health`);
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error(`Health check failed: ${response.statusText}`);
|
|
89
|
+
}
|
|
90
|
+
return response.json();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getInfo(): Promise<InfoResponse> {
|
|
94
|
+
const response = await fetch(`${this.baseUrl}/api/info`);
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new Error(`Info request failed: ${response.statusText}`);
|
|
97
|
+
}
|
|
98
|
+
return response.json();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async getDiagnostics(): Promise<DiagnosticsResponse> {
|
|
102
|
+
const response = await fetch(`${this.baseUrl}/api/diagnostics`);
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
throw new Error(`Diagnostics request failed: ${response.statusText}`);
|
|
105
|
+
}
|
|
106
|
+
return response.json();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async getConfig(): Promise<ConfigResponse> {
|
|
110
|
+
const response = await fetch(`${this.baseUrl}/api/config`);
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
throw new Error(`Config request failed: ${response.statusText}`);
|
|
113
|
+
}
|
|
114
|
+
return response.json();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async getLogs(options: {
|
|
118
|
+
source?: string;
|
|
119
|
+
level?: string;
|
|
120
|
+
search?: string;
|
|
121
|
+
limit?: number;
|
|
122
|
+
page?: number;
|
|
123
|
+
} = {}): Promise<LogsResponse> {
|
|
124
|
+
const params = new URLSearchParams();
|
|
125
|
+
if (options.source) params.set('source', options.source);
|
|
126
|
+
if (options.level) params.set('level', options.level);
|
|
127
|
+
if (options.search) params.set('search', options.search);
|
|
128
|
+
if (options.limit) params.set('limit', options.limit.toString());
|
|
129
|
+
if (options.page) params.set('page', options.page.toString());
|
|
130
|
+
|
|
131
|
+
const response = await fetch(`${this.baseUrl}/api/logs?${params}`);
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new Error(`Logs request failed: ${response.statusText}`);
|
|
134
|
+
}
|
|
135
|
+
return response.json();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async getLogSources(): Promise<LogSource[]> {
|
|
139
|
+
const response = await fetch(`${this.baseUrl}/api/logs/sources`);
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
throw new Error(`Log sources request failed: ${response.statusText}`);
|
|
142
|
+
}
|
|
143
|
+
const data = await response.json();
|
|
144
|
+
return data.sources;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const api = new ControlPanelApi();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AppConfigBuilder } from '@qwickapps/react-framework';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default control panel configuration
|
|
5
|
+
* Consumers can override this by passing their own config
|
|
6
|
+
*/
|
|
7
|
+
export const defaultConfig = AppConfigBuilder.create()
|
|
8
|
+
.withName('Control Panel')
|
|
9
|
+
.withId('com.qwickapps.control-panel')
|
|
10
|
+
.withVersion('1.0.0')
|
|
11
|
+
.withDefaultTheme('dark')
|
|
12
|
+
.withDefaultPalette('cosmic')
|
|
13
|
+
.withThemeSwitcher(true)
|
|
14
|
+
.withPaletteSwitcher(true)
|
|
15
|
+
.withDisplay('standalone')
|
|
16
|
+
.build();
|
|
17
|
+
|
|
18
|
+
export type ControlPanelUIConfig = ReturnType<typeof AppConfigBuilder.prototype.build>;
|
package/ui/src/index.css
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* Custom scrollbar styling */
|
|
2
|
+
::-webkit-scrollbar {
|
|
3
|
+
width: 8px;
|
|
4
|
+
height: 8px;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
::-webkit-scrollbar-track {
|
|
8
|
+
background: var(--theme-background, #0f172a);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
::-webkit-scrollbar-thumb {
|
|
12
|
+
background: var(--theme-border, #334155);
|
|
13
|
+
border-radius: 4px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
::-webkit-scrollbar-thumb:hover {
|
|
17
|
+
background: var(--theme-text-secondary, #64748b);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Smooth scrolling */
|
|
21
|
+
html {
|
|
22
|
+
scroll-behavior: smooth;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Better focus styles */
|
|
26
|
+
:focus-visible {
|
|
27
|
+
outline: 2px solid var(--theme-primary, #6366f1);
|
|
28
|
+
outline-offset: 2px;
|
|
29
|
+
}
|
package/ui/src/index.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom/client';
|
|
3
|
+
import '@qwickapps/react-framework/index.css';
|
|
4
|
+
import './index.css';
|
|
5
|
+
import { App } from './App';
|
|
6
|
+
|
|
7
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
8
|
+
<React.StrictMode>
|
|
9
|
+
<App />
|
|
10
|
+
</React.StrictMode>
|
|
11
|
+
);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
Typography,
|
|
7
|
+
Table,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableCell,
|
|
10
|
+
TableContainer,
|
|
11
|
+
TableHead,
|
|
12
|
+
TableRow,
|
|
13
|
+
Chip,
|
|
14
|
+
CircularProgress,
|
|
15
|
+
IconButton,
|
|
16
|
+
Tooltip,
|
|
17
|
+
Snackbar,
|
|
18
|
+
Alert,
|
|
19
|
+
} from '@mui/material';
|
|
20
|
+
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
21
|
+
import VisibilityIcon from '@mui/icons-material/Visibility';
|
|
22
|
+
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
|
23
|
+
import LockIcon from '@mui/icons-material/Lock';
|
|
24
|
+
import { api, ConfigResponse } from '../api/controlPanelApi';
|
|
25
|
+
|
|
26
|
+
export function ConfigPage() {
|
|
27
|
+
const [config, setConfig] = useState<ConfigResponse | null>(null);
|
|
28
|
+
const [loading, setLoading] = useState(true);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
const [showMasked, setShowMasked] = useState<Record<string, boolean>>({});
|
|
31
|
+
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
|
|
32
|
+
open: false,
|
|
33
|
+
message: '',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const fetchConfig = async () => {
|
|
38
|
+
try {
|
|
39
|
+
const data = await api.getConfig();
|
|
40
|
+
setConfig(data);
|
|
41
|
+
setError(null);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
setError(err instanceof Error ? err.message : 'Failed to fetch config');
|
|
44
|
+
} finally {
|
|
45
|
+
setLoading(false);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
fetchConfig();
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const handleCopy = (value: string) => {
|
|
53
|
+
navigator.clipboard.writeText(value);
|
|
54
|
+
setSnackbar({ open: true, message: 'Copied to clipboard' });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const toggleMasked = (key: string) => {
|
|
58
|
+
setShowMasked((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const isMasked = (key: string): boolean => {
|
|
62
|
+
if (!config?.masked || !Array.isArray(config.masked)) return false;
|
|
63
|
+
return config.masked.some((mask) =>
|
|
64
|
+
key.toUpperCase().includes(mask.toUpperCase())
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const formatValue = (key: string, value: unknown): string => {
|
|
69
|
+
const strValue = String(value);
|
|
70
|
+
if (isMasked(key) && !showMasked[key]) {
|
|
71
|
+
return '••••••••';
|
|
72
|
+
}
|
|
73
|
+
return strValue;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (loading) {
|
|
77
|
+
return (
|
|
78
|
+
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
|
79
|
+
<CircularProgress />
|
|
80
|
+
</Box>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (error) {
|
|
85
|
+
return (
|
|
86
|
+
<Card sx={{ bgcolor: 'var(--theme-surface)', border: '1px solid var(--theme-error)' }}>
|
|
87
|
+
<CardContent>
|
|
88
|
+
<Typography color="error">{error}</Typography>
|
|
89
|
+
</CardContent>
|
|
90
|
+
</Card>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const configEntries = config ? Object.entries(config.config) : [];
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Box>
|
|
98
|
+
<Typography variant="h4" sx={{ mb: 1, color: 'var(--theme-text-primary)' }}>
|
|
99
|
+
Configuration
|
|
100
|
+
</Typography>
|
|
101
|
+
<Typography variant="body2" sx={{ mb: 4, color: 'var(--theme-text-secondary)' }}>
|
|
102
|
+
Current environment configuration (read-only)
|
|
103
|
+
</Typography>
|
|
104
|
+
|
|
105
|
+
{/* Config Table */}
|
|
106
|
+
<Card sx={{ bgcolor: 'var(--theme-surface)' }}>
|
|
107
|
+
<TableContainer>
|
|
108
|
+
<Table>
|
|
109
|
+
<TableHead>
|
|
110
|
+
<TableRow>
|
|
111
|
+
<TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>
|
|
112
|
+
Variable
|
|
113
|
+
</TableCell>
|
|
114
|
+
<TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>
|
|
115
|
+
Value
|
|
116
|
+
</TableCell>
|
|
117
|
+
<TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)', width: 100 }}>
|
|
118
|
+
Actions
|
|
119
|
+
</TableCell>
|
|
120
|
+
</TableRow>
|
|
121
|
+
</TableHead>
|
|
122
|
+
<TableBody>
|
|
123
|
+
{configEntries.map(([key, value]) => (
|
|
124
|
+
<TableRow key={key}>
|
|
125
|
+
<TableCell sx={{ borderColor: 'var(--theme-border)' }}>
|
|
126
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
127
|
+
<Typography sx={{ color: 'var(--theme-text-primary)', fontFamily: 'monospace' }}>
|
|
128
|
+
{key}
|
|
129
|
+
</Typography>
|
|
130
|
+
{isMasked(key) && (
|
|
131
|
+
<Chip
|
|
132
|
+
icon={<LockIcon sx={{ fontSize: 14 }} />}
|
|
133
|
+
label="Sensitive"
|
|
134
|
+
size="small"
|
|
135
|
+
sx={{
|
|
136
|
+
bgcolor: 'var(--theme-warning)20',
|
|
137
|
+
color: 'var(--theme-warning)',
|
|
138
|
+
height: 20,
|
|
139
|
+
'& .MuiChip-icon': { color: 'var(--theme-warning)' },
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
</Box>
|
|
144
|
+
</TableCell>
|
|
145
|
+
<TableCell sx={{ color: 'var(--theme-text-primary)', borderColor: 'var(--theme-border)', fontFamily: 'monospace' }}>
|
|
146
|
+
{formatValue(key, value)}
|
|
147
|
+
</TableCell>
|
|
148
|
+
<TableCell sx={{ borderColor: 'var(--theme-border)' }}>
|
|
149
|
+
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
|
150
|
+
{isMasked(key) && (
|
|
151
|
+
<Tooltip title={showMasked[key] ? 'Hide value' : 'Show value'}>
|
|
152
|
+
<IconButton
|
|
153
|
+
size="small"
|
|
154
|
+
onClick={() => toggleMasked(key)}
|
|
155
|
+
sx={{ color: 'var(--theme-text-secondary)' }}
|
|
156
|
+
>
|
|
157
|
+
{showMasked[key] ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
|
158
|
+
</IconButton>
|
|
159
|
+
</Tooltip>
|
|
160
|
+
)}
|
|
161
|
+
<Tooltip title="Copy value">
|
|
162
|
+
<IconButton
|
|
163
|
+
size="small"
|
|
164
|
+
onClick={() => handleCopy(String(value))}
|
|
165
|
+
sx={{ color: 'var(--theme-text-secondary)' }}
|
|
166
|
+
>
|
|
167
|
+
<ContentCopyIcon fontSize="small" />
|
|
168
|
+
</IconButton>
|
|
169
|
+
</Tooltip>
|
|
170
|
+
</Box>
|
|
171
|
+
</TableCell>
|
|
172
|
+
</TableRow>
|
|
173
|
+
))}
|
|
174
|
+
</TableBody>
|
|
175
|
+
</Table>
|
|
176
|
+
</TableContainer>
|
|
177
|
+
|
|
178
|
+
{configEntries.length === 0 && (
|
|
179
|
+
<CardContent>
|
|
180
|
+
<Typography sx={{ color: 'var(--theme-text-secondary)', textAlign: 'center' }}>
|
|
181
|
+
No configuration variables available
|
|
182
|
+
</Typography>
|
|
183
|
+
</CardContent>
|
|
184
|
+
)}
|
|
185
|
+
</Card>
|
|
186
|
+
|
|
187
|
+
<Snackbar
|
|
188
|
+
open={snackbar.open}
|
|
189
|
+
autoHideDuration={2000}
|
|
190
|
+
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
191
|
+
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
192
|
+
>
|
|
193
|
+
<Alert severity="success" variant="filled">
|
|
194
|
+
{snackbar.message}
|
|
195
|
+
</Alert>
|
|
196
|
+
</Snackbar>
|
|
197
|
+
</Box>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
Typography,
|
|
7
|
+
Grid,
|
|
8
|
+
CircularProgress,
|
|
9
|
+
Chip,
|
|
10
|
+
} from '@mui/material';
|
|
11
|
+
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
12
|
+
import ErrorIcon from '@mui/icons-material/Error';
|
|
13
|
+
import WarningIcon from '@mui/icons-material/Warning';
|
|
14
|
+
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
15
|
+
import MemoryIcon from '@mui/icons-material/Memory';
|
|
16
|
+
import StorageIcon from '@mui/icons-material/Storage';
|
|
17
|
+
import { api, HealthResponse, InfoResponse } from '../api/controlPanelApi';
|
|
18
|
+
|
|
19
|
+
function formatUptime(ms: number): string {
|
|
20
|
+
const seconds = Math.floor(ms / 1000);
|
|
21
|
+
const minutes = Math.floor(seconds / 60);
|
|
22
|
+
const hours = Math.floor(minutes / 60);
|
|
23
|
+
const days = Math.floor(hours / 24);
|
|
24
|
+
|
|
25
|
+
if (days > 0) {
|
|
26
|
+
return `${days}d ${hours % 24}h ${minutes % 60}m`;
|
|
27
|
+
}
|
|
28
|
+
if (hours > 0) {
|
|
29
|
+
return `${hours}h ${minutes % 60}m`;
|
|
30
|
+
}
|
|
31
|
+
if (minutes > 0) {
|
|
32
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
33
|
+
}
|
|
34
|
+
return `${seconds}s`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getStatusIcon(status: string) {
|
|
38
|
+
switch (status) {
|
|
39
|
+
case 'healthy':
|
|
40
|
+
return <CheckCircleIcon sx={{ color: 'var(--theme-success)' }} />;
|
|
41
|
+
case 'degraded':
|
|
42
|
+
return <WarningIcon sx={{ color: 'var(--theme-warning)' }} />;
|
|
43
|
+
case 'unhealthy':
|
|
44
|
+
return <ErrorIcon sx={{ color: 'var(--theme-error)' }} />;
|
|
45
|
+
default:
|
|
46
|
+
return <CircularProgress size={20} />;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getStatusColor(status: string): string {
|
|
51
|
+
switch (status) {
|
|
52
|
+
case 'healthy':
|
|
53
|
+
return 'var(--theme-success)';
|
|
54
|
+
case 'degraded':
|
|
55
|
+
return 'var(--theme-warning)';
|
|
56
|
+
case 'unhealthy':
|
|
57
|
+
return 'var(--theme-error)';
|
|
58
|
+
default:
|
|
59
|
+
return 'var(--theme-text-secondary)';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function DashboardPage() {
|
|
64
|
+
const [health, setHealth] = useState<HealthResponse | null>(null);
|
|
65
|
+
const [info, setInfo] = useState<InfoResponse | null>(null);
|
|
66
|
+
const [loading, setLoading] = useState(true);
|
|
67
|
+
const [error, setError] = useState<string | null>(null);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const fetchData = async () => {
|
|
71
|
+
try {
|
|
72
|
+
const [healthData, infoData] = await Promise.all([
|
|
73
|
+
api.getHealth(),
|
|
74
|
+
api.getInfo(),
|
|
75
|
+
]);
|
|
76
|
+
setHealth(healthData);
|
|
77
|
+
setInfo(infoData);
|
|
78
|
+
setError(null);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
setError(err instanceof Error ? err.message : 'Failed to fetch data');
|
|
81
|
+
} finally {
|
|
82
|
+
setLoading(false);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
fetchData();
|
|
87
|
+
const interval = setInterval(fetchData, 10000);
|
|
88
|
+
return () => clearInterval(interval);
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
if (loading) {
|
|
92
|
+
return (
|
|
93
|
+
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
|
94
|
+
<CircularProgress />
|
|
95
|
+
</Box>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (error) {
|
|
100
|
+
return (
|
|
101
|
+
<Card sx={{ bgcolor: 'var(--theme-surface)', border: '1px solid var(--theme-error)' }}>
|
|
102
|
+
<CardContent>
|
|
103
|
+
<Typography color="error">{error}</Typography>
|
|
104
|
+
</CardContent>
|
|
105
|
+
</Card>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const healthChecks = health ? Object.entries(health.checks) : [];
|
|
110
|
+
const healthyCount = healthChecks.filter(([, c]) => c.status === 'healthy').length;
|
|
111
|
+
const totalCount = healthChecks.length;
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Box>
|
|
115
|
+
<Typography variant="h4" sx={{ mb: 1, color: 'var(--theme-text-primary)' }}>
|
|
116
|
+
Dashboard
|
|
117
|
+
</Typography>
|
|
118
|
+
<Typography variant="body2" sx={{ mb: 4, color: 'var(--theme-text-secondary)' }}>
|
|
119
|
+
Real-time overview of {info?.product || 'your service'}
|
|
120
|
+
</Typography>
|
|
121
|
+
|
|
122
|
+
{/* Status Banner */}
|
|
123
|
+
<Card
|
|
124
|
+
sx={{
|
|
125
|
+
mb: 4,
|
|
126
|
+
bgcolor: 'var(--theme-surface)',
|
|
127
|
+
border: `2px solid ${getStatusColor(health?.status || 'unknown')}`,
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<CardContent sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
131
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
132
|
+
{getStatusIcon(health?.status || 'unknown')}
|
|
133
|
+
<Box>
|
|
134
|
+
<Typography variant="h6" sx={{ color: 'var(--theme-text-primary)' }}>
|
|
135
|
+
Service Status: {health?.status?.charAt(0).toUpperCase()}{health?.status?.slice(1)}
|
|
136
|
+
</Typography>
|
|
137
|
+
<Typography variant="body2" sx={{ color: 'var(--theme-text-secondary)' }}>
|
|
138
|
+
Last updated: {health?.timestamp ? new Date(health.timestamp).toLocaleString() : 'N/A'}
|
|
139
|
+
</Typography>
|
|
140
|
+
</Box>
|
|
141
|
+
</Box>
|
|
142
|
+
<Chip
|
|
143
|
+
label={`${healthyCount}/${totalCount} checks passing`}
|
|
144
|
+
sx={{
|
|
145
|
+
bgcolor: getStatusColor(health?.status || 'unknown') + '20',
|
|
146
|
+
color: getStatusColor(health?.status || 'unknown'),
|
|
147
|
+
}}
|
|
148
|
+
/>
|
|
149
|
+
</CardContent>
|
|
150
|
+
</Card>
|
|
151
|
+
|
|
152
|
+
{/* Stats Grid */}
|
|
153
|
+
<Grid container spacing={3} sx={{ mb: 4 }}>
|
|
154
|
+
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
|
155
|
+
<Card sx={{ bgcolor: 'var(--theme-surface)', height: '100%' }}>
|
|
156
|
+
<CardContent>
|
|
157
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
|
158
|
+
<AccessTimeIcon sx={{ color: 'var(--theme-primary)' }} />
|
|
159
|
+
<Typography variant="body2" sx={{ color: 'var(--theme-text-secondary)' }}>
|
|
160
|
+
Uptime
|
|
161
|
+
</Typography>
|
|
162
|
+
</Box>
|
|
163
|
+
<Typography variant="h4" sx={{ color: 'var(--theme-primary)' }}>
|
|
164
|
+
{health ? formatUptime(health.uptime) : 'N/A'}
|
|
165
|
+
</Typography>
|
|
166
|
+
</CardContent>
|
|
167
|
+
</Card>
|
|
168
|
+
</Grid>
|
|
169
|
+
|
|
170
|
+
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
|
171
|
+
<Card sx={{ bgcolor: 'var(--theme-surface)', height: '100%' }}>
|
|
172
|
+
<CardContent>
|
|
173
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
|
174
|
+
<CheckCircleIcon sx={{ color: 'var(--theme-success)' }} />
|
|
175
|
+
<Typography variant="body2" sx={{ color: 'var(--theme-text-secondary)' }}>
|
|
176
|
+
Health Checks
|
|
177
|
+
</Typography>
|
|
178
|
+
</Box>
|
|
179
|
+
<Typography variant="h4" sx={{ color: 'var(--theme-success)' }}>
|
|
180
|
+
{healthyCount}/{totalCount}
|
|
181
|
+
</Typography>
|
|
182
|
+
</CardContent>
|
|
183
|
+
</Card>
|
|
184
|
+
</Grid>
|
|
185
|
+
|
|
186
|
+
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
|
187
|
+
<Card sx={{ bgcolor: 'var(--theme-surface)', height: '100%' }}>
|
|
188
|
+
<CardContent>
|
|
189
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
|
190
|
+
<MemoryIcon sx={{ color: 'var(--theme-warning)' }} />
|
|
191
|
+
<Typography variant="body2" sx={{ color: 'var(--theme-text-secondary)' }}>
|
|
192
|
+
Version
|
|
193
|
+
</Typography>
|
|
194
|
+
</Box>
|
|
195
|
+
<Typography variant="h4" sx={{ color: 'var(--theme-text-primary)' }}>
|
|
196
|
+
{info?.version || 'N/A'}
|
|
197
|
+
</Typography>
|
|
198
|
+
</CardContent>
|
|
199
|
+
</Card>
|
|
200
|
+
</Grid>
|
|
201
|
+
|
|
202
|
+
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
|
203
|
+
<Card sx={{ bgcolor: 'var(--theme-surface)', height: '100%' }}>
|
|
204
|
+
<CardContent>
|
|
205
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
|
206
|
+
<StorageIcon sx={{ color: 'var(--theme-info)' }} />
|
|
207
|
+
<Typography variant="body2" sx={{ color: 'var(--theme-text-secondary)' }}>
|
|
208
|
+
Product
|
|
209
|
+
</Typography>
|
|
210
|
+
</Box>
|
|
211
|
+
<Typography variant="h5" sx={{ color: 'var(--theme-text-primary)' }}>
|
|
212
|
+
{info?.product || 'N/A'}
|
|
213
|
+
</Typography>
|
|
214
|
+
</CardContent>
|
|
215
|
+
</Card>
|
|
216
|
+
</Grid>
|
|
217
|
+
</Grid>
|
|
218
|
+
|
|
219
|
+
{/* Health Checks Detail */}
|
|
220
|
+
<Card sx={{ bgcolor: 'var(--theme-surface)' }}>
|
|
221
|
+
<CardContent>
|
|
222
|
+
<Typography variant="h6" sx={{ mb: 3, color: 'var(--theme-text-primary)' }}>
|
|
223
|
+
Health Checks
|
|
224
|
+
</Typography>
|
|
225
|
+
<Grid container spacing={2}>
|
|
226
|
+
{healthChecks.map(([name, check]) => (
|
|
227
|
+
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={name}>
|
|
228
|
+
<Box
|
|
229
|
+
sx={{
|
|
230
|
+
p: 2,
|
|
231
|
+
borderRadius: 1,
|
|
232
|
+
bgcolor: 'var(--theme-background)',
|
|
233
|
+
border: `1px solid ${getStatusColor(check.status)}40`,
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
|
237
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
238
|
+
{getStatusIcon(check.status)}
|
|
239
|
+
<Typography sx={{ color: 'var(--theme-text-primary)', fontWeight: 500 }}>
|
|
240
|
+
{name}
|
|
241
|
+
</Typography>
|
|
242
|
+
</Box>
|
|
243
|
+
{check.latency !== undefined && (
|
|
244
|
+
<Chip
|
|
245
|
+
label={`${check.latency}ms`}
|
|
246
|
+
size="small"
|
|
247
|
+
sx={{ bgcolor: 'var(--theme-surface)', color: 'var(--theme-text-secondary)' }}
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
250
|
+
</Box>
|
|
251
|
+
{check.error && (
|
|
252
|
+
<Typography variant="caption" sx={{ color: 'var(--theme-error)' }}>
|
|
253
|
+
{check.error}
|
|
254
|
+
</Typography>
|
|
255
|
+
)}
|
|
256
|
+
</Box>
|
|
257
|
+
</Grid>
|
|
258
|
+
))}
|
|
259
|
+
</Grid>
|
|
260
|
+
</CardContent>
|
|
261
|
+
</Card>
|
|
262
|
+
</Box>
|
|
263
|
+
);
|
|
264
|
+
}
|