|
|
@@ -1,78 +1,568 @@
|
|
|
<template>
|
|
|
<div class="dashboard">
|
|
|
+ <!-- Alert Banner -->
|
|
|
+ <div v-if="activeAlerts.length > 0" class="alerts-section">
|
|
|
+ <div
|
|
|
+ v-for="alert in activeAlerts"
|
|
|
+ :key="alert.id"
|
|
|
+ :class="['alert-banner', `alert-${alert.severity}`]"
|
|
|
+ >
|
|
|
+ <div class="alert-content">
|
|
|
+ <div class="alert-icon">{{ alert.severity === 'critical' ? '🔴' : '⚠️' }}</div>
|
|
|
+ <div class="alert-text">
|
|
|
+ <div class="alert-title">{{ alert.title }}</div>
|
|
|
+ <div class="alert-message">{{ alert.message }}</div>
|
|
|
+ <div class="alert-time">{{ formatTime(alert.created_at) }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="alert-actions">
|
|
|
+ <button
|
|
|
+ v-if="!alert.acknowledged"
|
|
|
+ @click="acknowledgeAlert(alert.id)"
|
|
|
+ class="btn-acknowledge"
|
|
|
+ >
|
|
|
+ Acknowledge
|
|
|
+ </button>
|
|
|
+ <button @click="dismissAlert(alert.id)" class="btn-dismiss">
|
|
|
+ ✕
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Page Header -->
|
|
|
<div class="page-header">
|
|
|
- <h1>Superadmin Dashboard</h1>
|
|
|
- <p>System overview and statistics</p>
|
|
|
+ <h1>System Monitoring Dashboard</h1>
|
|
|
+ <p>Real-time host metrics and device status</p>
|
|
|
</div>
|
|
|
|
|
|
+ <!-- Device Statistics -->
|
|
|
<div class="stats-grid">
|
|
|
<div class="stat-card">
|
|
|
- <div class="stat-icon">🏢</div>
|
|
|
+ <div class="stat-icon">📡</div>
|
|
|
<div class="stat-content">
|
|
|
- <div class="stat-value">-</div>
|
|
|
- <div class="stat-label">Organizations</div>
|
|
|
+ <div class="stat-value">{{ deviceStats.total }}</div>
|
|
|
+ <div class="stat-label">Total Devices</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div class="stat-card">
|
|
|
- <div class="stat-icon">📡</div>
|
|
|
+ <div class="stat-card stat-online">
|
|
|
+ <div class="stat-icon">✅</div>
|
|
|
<div class="stat-content">
|
|
|
- <div class="stat-value">-</div>
|
|
|
- <div class="stat-label">Devices</div>
|
|
|
+ <div class="stat-value">{{ deviceStats.online }}</div>
|
|
|
+ <div class="stat-label">Online</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div class="stat-card">
|
|
|
- <div class="stat-icon">👥</div>
|
|
|
+ <div class="stat-card stat-offline">
|
|
|
+ <div class="stat-icon">⭕</div>
|
|
|
<div class="stat-content">
|
|
|
- <div class="stat-value">-</div>
|
|
|
- <div class="stat-label">Users</div>
|
|
|
+ <div class="stat-value">{{ deviceStats.offline }}</div>
|
|
|
+ <div class="stat-label">Offline</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div class="stat-card">
|
|
|
- <div class="stat-icon">📊</div>
|
|
|
+ <div class="stat-card stat-error">
|
|
|
+ <div class="stat-icon">❌</div>
|
|
|
<div class="stat-content">
|
|
|
- <div class="stat-value">-</div>
|
|
|
- <div class="stat-label">Events Today</div>
|
|
|
+ <div class="stat-value">{{ deviceStats.error }}</div>
|
|
|
+ <div class="stat-label">Error</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div class="content-section">
|
|
|
- <h2>Quick Actions</h2>
|
|
|
- <div class="actions-grid">
|
|
|
- <router-link to="/superadmin/organizations" class="action-card">
|
|
|
- <span class="action-icon">🏢</span>
|
|
|
- <span class="action-title">Manage Organizations</span>
|
|
|
- <span class="action-desc">Create and configure organizations</span>
|
|
|
- </router-link>
|
|
|
-
|
|
|
- <router-link to="/superadmin/devices" class="action-card">
|
|
|
- <span class="action-icon">📡</span>
|
|
|
- <span class="action-title">Manage Devices</span>
|
|
|
- <span class="action-desc">View and assign devices</span>
|
|
|
- </router-link>
|
|
|
-
|
|
|
- <router-link to="/superadmin/users" class="action-card">
|
|
|
- <span class="action-icon">👥</span>
|
|
|
- <span class="action-title">Manage Users</span>
|
|
|
- <span class="action-desc">View all system users</span>
|
|
|
- </router-link>
|
|
|
+ <!-- Host Metrics Charts -->
|
|
|
+ <div class="metrics-section">
|
|
|
+ <h2>Host Metrics</h2>
|
|
|
+
|
|
|
+ <div class="charts-grid">
|
|
|
+ <!-- CPU Chart -->
|
|
|
+ <div class="chart-card">
|
|
|
+ <h3>CPU Usage</h3>
|
|
|
+ <Line v-if="cpuChartData" :data="cpuChartData" :options="cpuChartOptions" />
|
|
|
+ <div v-else class="chart-loading">Loading...</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Memory Chart -->
|
|
|
+ <div class="chart-card">
|
|
|
+ <h3>Memory Usage</h3>
|
|
|
+ <Line v-if="memoryChartData" :data="memoryChartData" :options="memoryChartOptions" />
|
|
|
+ <div v-else class="chart-loading">Loading...</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Load Average Chart -->
|
|
|
+ <div class="chart-card">
|
|
|
+ <h3>Load Average</h3>
|
|
|
+ <Line v-if="loadChartData" :data="loadChartData" :options="loadChartOptions" />
|
|
|
+ <div v-else class="chart-loading">Loading...</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Disk I/O Chart -->
|
|
|
+ <div class="chart-card">
|
|
|
+ <h3>Disk I/O (IOPS)</h3>
|
|
|
+ <Line v-if="diskChartData" :data="diskChartData" :options="diskChartOptions" />
|
|
|
+ <div v-else class="chart-loading">Loading...</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Network Chart -->
|
|
|
+ <div class="chart-card">
|
|
|
+ <h3>Network Throughput (MB/s)</h3>
|
|
|
+ <Line v-if="networkChartData" :data="networkChartData" :options="networkChartOptions" />
|
|
|
+ <div v-else class="chart-loading">Loading...</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Disk Usage Chart -->
|
|
|
+ <div class="chart-card">
|
|
|
+ <h3>Disk Usage</h3>
|
|
|
+ <Line v-if="diskUsageChartData" :data="diskUsageChartData" :options="diskUsageChartOptions" />
|
|
|
+ <div v-else class="chart-loading">Loading...</div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-// Stats will be loaded from API later
|
|
|
+import { ref, onMounted, onUnmounted } from 'vue'
|
|
|
+import { Line } from 'vue-chartjs'
|
|
|
+import {
|
|
|
+ Chart as ChartJS,
|
|
|
+ CategoryScale,
|
|
|
+ LinearScale,
|
|
|
+ PointElement,
|
|
|
+ LineElement,
|
|
|
+ Title,
|
|
|
+ Tooltip,
|
|
|
+ Legend,
|
|
|
+ Filler
|
|
|
+} from 'chart.js'
|
|
|
+import axios from '@/api/client'
|
|
|
+
|
|
|
+// Register Chart.js components
|
|
|
+ChartJS.register(
|
|
|
+ CategoryScale,
|
|
|
+ LinearScale,
|
|
|
+ PointElement,
|
|
|
+ LineElement,
|
|
|
+ Title,
|
|
|
+ Tooltip,
|
|
|
+ Legend,
|
|
|
+ Filler
|
|
|
+)
|
|
|
+
|
|
|
+// Data
|
|
|
+const activeAlerts = ref([])
|
|
|
+const deviceStats = ref({ total: 0, online: 0, offline: 0, error: 0 })
|
|
|
+const hostMetrics = ref([])
|
|
|
+
|
|
|
+// Chart data
|
|
|
+const cpuChartData = ref(null)
|
|
|
+const memoryChartData = ref(null)
|
|
|
+const loadChartData = ref(null)
|
|
|
+const diskChartData = ref(null)
|
|
|
+const networkChartData = ref(null)
|
|
|
+const diskUsageChartData = ref(null)
|
|
|
+
|
|
|
+// Chart options with red threshold highlighting
|
|
|
+const createChartOptions = (title, yMax = 100, thresholdValue = null, unit = '%') => ({
|
|
|
+ responsive: true,
|
|
|
+ maintainAspectRatio: false,
|
|
|
+ plugins: {
|
|
|
+ legend: {
|
|
|
+ display: true,
|
|
|
+ position: 'top'
|
|
|
+ },
|
|
|
+ tooltip: {
|
|
|
+ mode: 'index',
|
|
|
+ intersect: false,
|
|
|
+ callbacks: {
|
|
|
+ label: (context) => {
|
|
|
+ return `${context.dataset.label}: ${context.parsed.y.toFixed(2)}${unit}`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ scales: {
|
|
|
+ y: {
|
|
|
+ beginAtZero: true,
|
|
|
+ max: yMax,
|
|
|
+ ticks: {
|
|
|
+ callback: (value) => `${value}${unit}`
|
|
|
+ }
|
|
|
+ },
|
|
|
+ x: {
|
|
|
+ ticks: {
|
|
|
+ maxRotation: 45,
|
|
|
+ minRotation: 45
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ elements: {
|
|
|
+ line: {
|
|
|
+ tension: 0.4
|
|
|
+ }
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const cpuChartOptions = createChartOptions('CPU Usage', 100, 90, '%')
|
|
|
+const memoryChartOptions = createChartOptions('Memory Usage', 100, 90, '%')
|
|
|
+const loadChartOptions = createChartOptions('Load Average', null, null, '')
|
|
|
+const diskChartOptions = createChartOptions('Disk IOPS', null, null, ' IOPS')
|
|
|
+const networkChartOptions = createChartOptions('Network', null, null, ' MB/s')
|
|
|
+const diskUsageChartOptions = createChartOptions('Disk Usage', 100, 90, '%')
|
|
|
+
|
|
|
+let pollingInterval = null
|
|
|
+
|
|
|
+// Functions
|
|
|
+async function loadAlerts() {
|
|
|
+ try {
|
|
|
+ const { data } = await axios.get('/superadmin/monitoring/alerts', {
|
|
|
+ params: { limit: 10, dismissed: false }
|
|
|
+ })
|
|
|
+ activeAlerts.value = data.alerts
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to load alerts:', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function acknowledgeAlert(alertId) {
|
|
|
+ try {
|
|
|
+ await axios.post(`/superadmin/monitoring/alerts/${alertId}/acknowledge`)
|
|
|
+ await loadAlerts()
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to acknowledge alert:', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function dismissAlert(alertId) {
|
|
|
+ try {
|
|
|
+ await axios.post(`/superadmin/monitoring/alerts/${alertId}/dismiss`)
|
|
|
+ activeAlerts.value = activeAlerts.value.filter(a => a.id !== alertId)
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to dismiss alert:', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function loadDeviceStats() {
|
|
|
+ try {
|
|
|
+ const { data } = await axios.get('/superadmin/devices')
|
|
|
+ const devices = data.devices
|
|
|
+
|
|
|
+ deviceStats.value = {
|
|
|
+ total: devices.length,
|
|
|
+ online: devices.filter(d => d.status === 'online').length,
|
|
|
+ offline: devices.filter(d => d.status === 'offline').length,
|
|
|
+ error: devices.filter(d => d.status === 'error').length
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to load device stats:', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function loadHostMetrics() {
|
|
|
+ try {
|
|
|
+ const { data } = await axios.get('/superadmin/monitoring/host-metrics/recent', {
|
|
|
+ params: { limit: 30 }
|
|
|
+ })
|
|
|
+ hostMetrics.value = data.metrics.reverse() // Oldest first
|
|
|
+ updateCharts()
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to load host metrics:', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function updateCharts() {
|
|
|
+ if (hostMetrics.value.length === 0) return
|
|
|
+
|
|
|
+ const labels = hostMetrics.value.map(m =>
|
|
|
+ new Date(m.timestamp).toLocaleTimeString('en-US', {
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit'
|
|
|
+ })
|
|
|
+ )
|
|
|
+
|
|
|
+ // CPU Chart
|
|
|
+ cpuChartData.value = {
|
|
|
+ labels,
|
|
|
+ datasets: [
|
|
|
+ {
|
|
|
+ label: 'CPU %',
|
|
|
+ data: hostMetrics.value.map(m => m.cpu_percent),
|
|
|
+ borderColor: 'rgb(99, 102, 241)',
|
|
|
+ backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
|
|
+ fill: true,
|
|
|
+ segment: {
|
|
|
+ borderColor: ctx => {
|
|
|
+ const value = ctx.p1.parsed.y
|
|
|
+ return value >= 90 ? 'rgb(239, 68, 68)' : 'rgb(99, 102, 241)'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ // Memory Chart
|
|
|
+ memoryChartData.value = {
|
|
|
+ labels,
|
|
|
+ datasets: [
|
|
|
+ {
|
|
|
+ label: 'Memory %',
|
|
|
+ data: hostMetrics.value.map(m => m.memory_percent),
|
|
|
+ borderColor: 'rgb(34, 197, 94)',
|
|
|
+ backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
|
|
+ fill: true,
|
|
|
+ segment: {
|
|
|
+ borderColor: ctx => {
|
|
|
+ const value = ctx.p1.parsed.y
|
|
|
+ return value >= 90 ? 'rgb(239, 68, 68)' : 'rgb(34, 197, 94)'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ // Load Average Chart
|
|
|
+ const cpuCount = hostMetrics.value[0]?.cpu_count || 1
|
|
|
+ loadChartData.value = {
|
|
|
+ labels,
|
|
|
+ datasets: [
|
|
|
+ {
|
|
|
+ label: 'Load 1m',
|
|
|
+ data: hostMetrics.value.map(m => m.load_1),
|
|
|
+ borderColor: 'rgb(244, 114, 182)',
|
|
|
+ backgroundColor: 'rgba(244, 114, 182, 0.1)',
|
|
|
+ fill: false
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: 'Load 5m',
|
|
|
+ data: hostMetrics.value.map(m => m.load_5),
|
|
|
+ borderColor: 'rgb(251, 146, 60)',
|
|
|
+ backgroundColor: 'rgba(251, 146, 60, 0.1)',
|
|
|
+ fill: false
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: 'Load 15m',
|
|
|
+ data: hostMetrics.value.map(m => m.load_15),
|
|
|
+ borderColor: 'rgb(234, 179, 8)',
|
|
|
+ backgroundColor: 'rgba(234, 179, 8, 0.1)',
|
|
|
+ fill: false
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: `Threshold (${cpuCount} cores)`,
|
|
|
+ data: Array(labels.length).fill(cpuCount),
|
|
|
+ borderColor: 'rgba(239, 68, 68, 0.5)',
|
|
|
+ borderDash: [5, 5],
|
|
|
+ fill: false,
|
|
|
+ pointRadius: 0
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ // Disk I/O Chart (IOPS)
|
|
|
+ diskChartData.value = {
|
|
|
+ labels,
|
|
|
+ datasets: [
|
|
|
+ {
|
|
|
+ label: 'Read IOPS',
|
|
|
+ data: hostMetrics.value.map(m => m.disk_read_iops),
|
|
|
+ borderColor: 'rgb(59, 130, 246)',
|
|
|
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
|
+ fill: false
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: 'Write IOPS',
|
|
|
+ data: hostMetrics.value.map(m => m.disk_write_iops),
|
|
|
+ borderColor: 'rgb(168, 85, 247)',
|
|
|
+ backgroundColor: 'rgba(168, 85, 247, 0.1)',
|
|
|
+ fill: false
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ // Network Chart (MB/s)
|
|
|
+ networkChartData.value = {
|
|
|
+ labels,
|
|
|
+ datasets: [
|
|
|
+ {
|
|
|
+ label: 'In MB/s',
|
|
|
+ data: hostMetrics.value.map(m => m.net_in_mbps),
|
|
|
+ borderColor: 'rgb(14, 165, 233)',
|
|
|
+ backgroundColor: 'rgba(14, 165, 233, 0.1)',
|
|
|
+ fill: false
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: 'Out MB/s',
|
|
|
+ data: hostMetrics.value.map(m => m.net_out_mbps),
|
|
|
+ borderColor: 'rgb(245, 158, 11)',
|
|
|
+ backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
|
|
+ fill: false
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ // Disk Usage Chart
|
|
|
+ diskUsageChartData.value = {
|
|
|
+ labels,
|
|
|
+ datasets: [
|
|
|
+ {
|
|
|
+ label: 'Disk Usage %',
|
|
|
+ data: hostMetrics.value.map(m => m.disk_usage_percent),
|
|
|
+ borderColor: 'rgb(236, 72, 153)',
|
|
|
+ backgroundColor: 'rgba(236, 72, 153, 0.1)',
|
|
|
+ fill: true,
|
|
|
+ segment: {
|
|
|
+ borderColor: ctx => {
|
|
|
+ const value = ctx.p1.parsed.y
|
|
|
+ return value >= 90 ? 'rgb(239, 68, 68)' : 'rgb(236, 72, 153)'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function formatTime(timestamp) {
|
|
|
+ const date = new Date(timestamp)
|
|
|
+ const now = new Date()
|
|
|
+ const diffMs = now - date
|
|
|
+ const diffMins = Math.floor(diffMs / 60000)
|
|
|
+
|
|
|
+ if (diffMins < 1) return 'just now'
|
|
|
+ if (diffMins < 60) return `${diffMins} minutes ago`
|
|
|
+ const diffHours = Math.floor(diffMins / 60)
|
|
|
+ if (diffHours < 24) return `${diffHours} hours ago`
|
|
|
+ return date.toLocaleString()
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshData() {
|
|
|
+ await Promise.all([
|
|
|
+ loadAlerts(),
|
|
|
+ loadDeviceStats(),
|
|
|
+ loadHostMetrics()
|
|
|
+ ])
|
|
|
+}
|
|
|
+
|
|
|
+// Lifecycle
|
|
|
+onMounted(async () => {
|
|
|
+ await refreshData()
|
|
|
+ // Poll every 30 seconds
|
|
|
+ pollingInterval = setInterval(refreshData, 30000)
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ if (pollingInterval) {
|
|
|
+ clearInterval(pollingInterval)
|
|
|
+ }
|
|
|
+})
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
.dashboard {
|
|
|
padding: 32px;
|
|
|
+ max-width: 1600px;
|
|
|
+ margin: 0 auto;
|
|
|
+}
|
|
|
+
|
|
|
+/* Alert Banner */
|
|
|
+.alerts-section {
|
|
|
+ margin-bottom: 24px;
|
|
|
+}
|
|
|
+
|
|
|
+.alert-banner {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 16px 20px;
|
|
|
+ border-radius: 8px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+.alert-critical {
|
|
|
+ background: #fee2e2;
|
|
|
+ border-left: 4px solid #dc2626;
|
|
|
+}
|
|
|
+
|
|
|
+.alert-warning {
|
|
|
+ background: #fef3c7;
|
|
|
+ border-left: 4px solid #f59e0b;
|
|
|
+}
|
|
|
+
|
|
|
+.alert-info {
|
|
|
+ background: #dbeafe;
|
|
|
+ border-left: 4px solid #3b82f6;
|
|
|
}
|
|
|
|
|
|
+.alert-content {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 12px;
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.alert-icon {
|
|
|
+ font-size: 24px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.alert-text {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.alert-title {
|
|
|
+ font-weight: 600;
|
|
|
+ font-size: 16px;
|
|
|
+ color: #1a202c;
|
|
|
+ margin-bottom: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.alert-message {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #4a5568;
|
|
|
+ margin-bottom: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.alert-time {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #718096;
|
|
|
+}
|
|
|
+
|
|
|
+.alert-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-acknowledge {
|
|
|
+ padding: 8px 16px;
|
|
|
+ background: white;
|
|
|
+ border: 1px solid #d1d5db;
|
|
|
+ border-radius: 6px;
|
|
|
+ font-size: 14px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-acknowledge:hover {
|
|
|
+ background: #f9fafb;
|
|
|
+ border-color: #9ca3af;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-dismiss {
|
|
|
+ padding: 8px 12px;
|
|
|
+ background: transparent;
|
|
|
+ border: none;
|
|
|
+ font-size: 18px;
|
|
|
+ cursor: pointer;
|
|
|
+ color: #6b7280;
|
|
|
+ transition: color 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-dismiss:hover {
|
|
|
+ color: #1f2937;
|
|
|
+}
|
|
|
+
|
|
|
+/* Page Header */
|
|
|
.page-header {
|
|
|
margin-bottom: 32px;
|
|
|
}
|
|
|
@@ -89,6 +579,7 @@
|
|
|
font-size: 16px;
|
|
|
}
|
|
|
|
|
|
+/* Device Stats */
|
|
|
.stats-grid {
|
|
|
display: grid;
|
|
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
|
@@ -104,6 +595,24 @@
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 16px;
|
|
|
+ transition: transform 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-card:hover {
|
|
|
+ transform: translateY(-2px);
|
|
|
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+.stat-online {
|
|
|
+ border-left: 4px solid #10b981;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-offline {
|
|
|
+ border-left: 4px solid #6b7280;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-error {
|
|
|
+ border-left: 4px solid #ef4444;
|
|
|
}
|
|
|
|
|
|
.stat-icon {
|
|
|
@@ -115,7 +624,7 @@
|
|
|
}
|
|
|
|
|
|
.stat-value {
|
|
|
- font-size: 28px;
|
|
|
+ font-size: 32px;
|
|
|
font-weight: 700;
|
|
|
color: #1a202c;
|
|
|
margin-bottom: 4px;
|
|
|
@@ -124,59 +633,54 @@
|
|
|
.stat-label {
|
|
|
font-size: 14px;
|
|
|
color: #718096;
|
|
|
+ font-weight: 500;
|
|
|
}
|
|
|
|
|
|
-.content-section {
|
|
|
+/* Metrics Section */
|
|
|
+.metrics-section {
|
|
|
background: white;
|
|
|
border-radius: 12px;
|
|
|
padding: 24px;
|
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
|
}
|
|
|
|
|
|
-.content-section h2 {
|
|
|
- font-size: 20px;
|
|
|
+.metrics-section h2 {
|
|
|
+ font-size: 24px;
|
|
|
font-weight: 600;
|
|
|
color: #1a202c;
|
|
|
- margin-bottom: 20px;
|
|
|
+ margin-bottom: 24px;
|
|
|
}
|
|
|
|
|
|
-.actions-grid {
|
|
|
+.charts-grid {
|
|
|
display: grid;
|
|
|
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
|
- gap: 16px;
|
|
|
+ grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
|
|
+ gap: 24px;
|
|
|
}
|
|
|
|
|
|
-.action-card {
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- align-items: flex-start;
|
|
|
- padding: 20px;
|
|
|
- border: 1px solid #e2e8f0;
|
|
|
+.chart-card {
|
|
|
+ background: #f9fafb;
|
|
|
border-radius: 8px;
|
|
|
- text-decoration: none;
|
|
|
- transition: all 0.2s;
|
|
|
-}
|
|
|
-
|
|
|
-.action-card:hover {
|
|
|
- border-color: #667eea;
|
|
|
- transform: translateY(-2px);
|
|
|
- box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
|
|
|
-}
|
|
|
-
|
|
|
-.action-icon {
|
|
|
- font-size: 32px;
|
|
|
- margin-bottom: 12px;
|
|
|
+ padding: 20px;
|
|
|
+ min-height: 300px;
|
|
|
}
|
|
|
|
|
|
-.action-title {
|
|
|
+.chart-card h3 {
|
|
|
font-size: 16px;
|
|
|
font-weight: 600;
|
|
|
- color: #1a202c;
|
|
|
- margin-bottom: 4px;
|
|
|
+ color: #374151;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-card canvas {
|
|
|
+ height: 250px !important;
|
|
|
}
|
|
|
|
|
|
-.action-desc {
|
|
|
+.chart-loading {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ height: 250px;
|
|
|
+ color: #9ca3af;
|
|
|
font-size: 14px;
|
|
|
- color: #718096;
|
|
|
}
|
|
|
</style>
|