591 lines
21 KiB
Vue
591 lines
21 KiB
Vue
<template>
|
|
<v-card
|
|
color="blue-grey-darken-1"
|
|
variant="tonal"
|
|
class="h-100 d-flex flex-column"
|
|
>
|
|
<v-card-title class="d-flex align-center justify-space-between">
|
|
<div class="d-flex align-center">
|
|
<v-icon icon="mdi-server" class="mr-2"></v-icon>
|
|
<span class="text-subtitle-1 font-weight-bold">System Health</span>
|
|
</div>
|
|
<div class="d-flex align-center">
|
|
<v-chip size="small" :color="overallStatusColor" class="mr-2">
|
|
<v-icon :icon="overallStatusIcon" size="small" class="mr-1"></v-icon>
|
|
{{ overallStatusText }}
|
|
</v-chip>
|
|
<v-menu>
|
|
<template v-slot:activator="{ props }">
|
|
<v-btn
|
|
size="x-small"
|
|
variant="text"
|
|
v-bind="props"
|
|
class="text-caption"
|
|
>
|
|
{{ selectedEnvironment }}
|
|
<v-icon icon="mdi-chevron-down" size="small"></v-icon>
|
|
</v-btn>
|
|
</template>
|
|
<v-list density="compact">
|
|
<v-list-item
|
|
v-for="env in environmentOptions"
|
|
:key="env.value"
|
|
@click="selectedEnvironment = env.value"
|
|
>
|
|
<v-list-item-title>{{ env.label }}</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-menu>
|
|
</div>
|
|
</v-card-title>
|
|
|
|
<v-card-text class="flex-grow-1 pa-0">
|
|
<div class="pa-4">
|
|
<!-- System Status Overview -->
|
|
<div class="mb-4">
|
|
<div class="text-subtitle-2 font-weight-medium mb-2">System Status</div>
|
|
<v-row dense>
|
|
<v-col v-for="component in systemComponents" :key="component.name" cols="6" sm="3">
|
|
<v-card variant="outlined" class="pa-2">
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<div class="d-flex align-center">
|
|
<v-icon :icon="component.icon" size="small" :color="component.statusColor" class="mr-2"></v-icon>
|
|
<span class="text-caption font-weight-medium">{{ component.name }}</span>
|
|
</div>
|
|
<div class="text-caption text-grey mt-1">{{ component.description }}</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-caption" :class="`text-${component.statusColor}`">{{ component.status }}</div>
|
|
<div class="text-caption text-grey">{{ component.uptime }}%</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Response Time Indicator -->
|
|
<div v-if="component.responseTime" class="mt-2">
|
|
<div class="d-flex justify-space-between">
|
|
<span class="text-caption text-grey">Response</span>
|
|
<span class="text-caption" :class="getResponseTimeColor(component.responseTime)">{{ component.responseTime }}ms</span>
|
|
</div>
|
|
<v-progress-linear
|
|
:model-value="Math.min(component.responseTime / 10, 100)"
|
|
height="4"
|
|
:color="getResponseTimeColor(component.responseTime)"
|
|
class="mt-1"
|
|
></v-progress-linear>
|
|
</div>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
|
|
<!-- API Response Times Chart -->
|
|
<div class="mb-4">
|
|
<div class="text-subtitle-2 font-weight-medium mb-2">API Response Times (Last 24h)</div>
|
|
<div class="chart-container" style="height: 150px;">
|
|
<canvas ref="responseTimeChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Database Metrics -->
|
|
<div class="mb-4">
|
|
<div class="text-subtitle-2 font-weight-medium mb-2">Database Metrics</div>
|
|
<v-row dense>
|
|
<v-col cols="6" sm="3">
|
|
<v-card variant="outlined" class="pa-2 text-center">
|
|
<div class="text-h6 font-weight-bold" :class="databaseMetrics.connections > 80 ? 'text-error' : 'text-success'">{{ databaseMetrics.connections }}</div>
|
|
<div class="text-caption text-grey">Connections</div>
|
|
<div class="text-caption">{{ databaseMetrics.activeConnections }} active</div>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="6" sm="3">
|
|
<v-card variant="outlined" class="pa-2 text-center">
|
|
<div class="text-h6 font-weight-bold" :class="databaseMetrics.queryTime > 500 ? 'text-error' : databaseMetrics.queryTime > 200 ? 'text-warning' : 'text-success'">{{ databaseMetrics.queryTime }}ms</div>
|
|
<div class="text-caption text-grey">Avg Query Time</div>
|
|
<div class="text-caption">{{ databaseMetrics.queriesPerSecond }} qps</div>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="6" sm="3">
|
|
<v-card variant="outlined" class="pa-2 text-center">
|
|
<div class="text-h6 font-weight-bold" :class="databaseMetrics.cacheHitRate < 80 ? 'text-error' : databaseMetrics.cacheHitRate < 90 ? 'text-warning' : 'text-success'">{{ databaseMetrics.cacheHitRate }}%</div>
|
|
<div class="text-caption text-grey">Cache Hit Rate</div>
|
|
<div class="text-caption">{{ formatBytes(databaseMetrics.cacheSize) }} cache</div>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="6" sm="3">
|
|
<v-card variant="outlined" class="pa-2 text-center">
|
|
<div class="text-h6 font-weight-bold" :class="databaseMetrics.replicationLag > 1000 ? 'text-error' : databaseMetrics.replicationLag > 500 ? 'text-warning' : 'text-success'">{{ databaseMetrics.replicationLag }}ms</div>
|
|
<div class="text-caption text-grey">Replication Lag</div>
|
|
<div class="text-caption">{{ databaseMetrics.replicationStatus }}</div>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
|
|
<!-- Server Resources -->
|
|
<div>
|
|
<div class="text-subtitle-2 font-weight-medium mb-2">Server Resources</div>
|
|
<v-row dense>
|
|
<v-col cols="12" sm="6">
|
|
<v-card variant="outlined" class="pa-3">
|
|
<div class="d-flex justify-space-between align-center mb-2">
|
|
<span class="text-caption font-weight-medium">CPU Usage</span>
|
|
<span class="text-caption" :class="serverResources.cpu > 80 ? 'text-error' : serverResources.cpu > 60 ? 'text-warning' : 'text-success'">{{ serverResources.cpu }}%</span>
|
|
</div>
|
|
<v-progress-linear
|
|
:model-value="serverResources.cpu"
|
|
height="8"
|
|
:color="serverResources.cpu > 80 ? 'error' : serverResources.cpu > 60 ? 'warning' : 'success'"
|
|
rounded
|
|
></v-progress-linear>
|
|
<div class="text-caption text-grey mt-1">{{ serverResources.cpuCores }} cores @ {{ serverResources.cpuFrequency }}GHz</div>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" sm="6">
|
|
<v-card variant="outlined" class="pa-3">
|
|
<div class="d-flex justify-space-between align-center mb-2">
|
|
<span class="text-caption font-weight-medium">Memory Usage</span>
|
|
<span class="text-caption" :class="serverResources.memory > 80 ? 'text-error' : serverResources.memory > 60 ? 'text-warning' : 'text-success'">{{ serverResources.memory }}%</span>
|
|
</div>
|
|
<v-progress-linear
|
|
:model-value="serverResources.memory"
|
|
height="8"
|
|
:color="serverResources.memory > 80 ? 'error' : serverResources.memory > 60 ? 'warning' : 'success'"
|
|
rounded
|
|
></v-progress-linear>
|
|
<div class="text-caption text-grey mt-1">{{ formatBytes(serverResources.memoryUsed) }} / {{ formatBytes(serverResources.memoryTotal) }}</div>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" sm="6">
|
|
<v-card variant="outlined" class="pa-3">
|
|
<div class="d-flex justify-space-between align-center mb-2">
|
|
<span class="text-caption font-weight-medium">Disk I/O</span>
|
|
<span class="text-caption" :class="serverResources.diskIO > 80 ? 'text-error' : serverResources.diskIO > 60 ? 'text-warning' : 'text-success'">{{ serverResources.diskIO }}%</span>
|
|
</div>
|
|
<v-progress-linear
|
|
:model-value="serverResources.diskIO"
|
|
height="8"
|
|
:color="serverResources.diskIO > 80 ? 'error' : serverResources.diskIO > 60 ? 'warning' : 'success'"
|
|
rounded
|
|
></v-progress-linear>
|
|
<div class="text-caption text-grey mt-1">{{ formatBytes(serverResources.diskRead) }}/s read, {{ formatBytes(serverResources.diskWrite) }}/s write</div>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" sm="6">
|
|
<v-card variant="outlined" class="pa-3">
|
|
<div class="d-flex justify-space-between align-center mb-2">
|
|
<span class="text-caption font-weight-medium">Network</span>
|
|
<span class="text-caption" :class="serverResources.network > 80 ? 'text-error' : serverResources.network > 60 ? 'text-warning' : 'text-success'">{{ serverResources.network }}%</span>
|
|
</div>
|
|
<v-progress-linear
|
|
:model-value="serverResources.network"
|
|
height="8"
|
|
:color="serverResources.network > 80 ? 'error' : serverResources.network > 60 ? 'warning' : 'success'"
|
|
rounded
|
|
></v-progress-linear>
|
|
<div class="text-caption text-grey mt-1">{{ formatBytes(serverResources.networkIn) }}/s in, {{ formatBytes(serverResources.networkOut) }}/s out</div>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<v-card-actions class="pa-3">
|
|
<div class="d-flex justify-space-between align-center w-100">
|
|
<div class="text-caption">
|
|
<v-icon icon="mdi-clock" size="small" class="mr-1"></v-icon>
|
|
Last check: {{ lastCheckTime }}
|
|
<v-chip size="x-small" color="green" class="ml-2">
|
|
Auto-refresh: {{ refreshInterval }}s
|
|
</v-chip>
|
|
</div>
|
|
|
|
<div class="d-flex">
|
|
<v-btn
|
|
size="x-small"
|
|
variant="text"
|
|
@click="refreshHealth"
|
|
:loading="isRefreshing"
|
|
>
|
|
<v-icon icon="mdi-refresh" size="small" class="mr-1"></v-icon>
|
|
Refresh
|
|
</v-btn>
|
|
<v-btn
|
|
size="x-small"
|
|
variant="text"
|
|
@click="toggleAutoRefresh"
|
|
:color="autoRefresh ? 'primary' : 'grey'"
|
|
class="ml-2"
|
|
>
|
|
<v-icon :icon="autoRefresh ? 'mdi-pause' : 'mdi-play'" size="small" class="mr-1"></v-icon>
|
|
{{ autoRefresh ? 'Pause' : 'Resume' }}
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|
import { Chart, registerables } from 'chart.js'
|
|
|
|
// Register Chart.js components
|
|
Chart.register(...registerables)
|
|
|
|
// Types
|
|
interface SystemComponent {
|
|
name: string
|
|
description: string
|
|
status: 'healthy' | 'degraded' | 'down'
|
|
statusColor: string
|
|
icon: string
|
|
uptime: number
|
|
responseTime?: number
|
|
}
|
|
|
|
interface DatabaseMetrics {
|
|
connections: number
|
|
activeConnections: number
|
|
queryTime: number
|
|
queriesPerSecond: number
|
|
cacheHitRate: number
|
|
cacheSize: number
|
|
replicationLag: number
|
|
replicationStatus: string
|
|
}
|
|
|
|
interface ServerResources {
|
|
cpu: number
|
|
cpuCores: number
|
|
cpuFrequency: number
|
|
memory: number
|
|
memoryUsed: number
|
|
memoryTotal: number
|
|
diskIO: number
|
|
diskRead: number
|
|
diskWrite: number
|
|
network: number
|
|
networkIn: number
|
|
networkOut: number
|
|
}
|
|
|
|
// State
|
|
const selectedEnvironment = ref('production')
|
|
const isRefreshing = ref(false)
|
|
const autoRefresh = ref(true)
|
|
const refreshInterval = ref(30)
|
|
const responseTimeChart = ref<HTMLCanvasElement | null>(null)
|
|
let chartInstance: Chart | null = null
|
|
let refreshTimer: number | null = null
|
|
|
|
// Environment options
|
|
const environmentOptions = [
|
|
{ label: 'Production', value: 'production' },
|
|
{ label: 'Staging', value: 'staging' },
|
|
{ label: 'Development', value: 'development' },
|
|
{ label: 'Testing', value: 'testing' }
|
|
]
|
|
|
|
// System components data
|
|
const systemComponents = ref<SystemComponent[]>([
|
|
{ name: 'API Gateway', description: 'Main API endpoint', status: 'healthy', statusColor: 'success', icon: 'mdi-api', uptime: 99.9, responseTime: 45 },
|
|
{ name: 'Database', description: 'PostgreSQL cluster', status: 'healthy', statusColor: 'success', icon: 'mdi-database', uptime: 99.95, responseTime: 120 },
|
|
{ name: 'Cache', description: 'Redis cache layer', status: 'healthy', statusColor: 'success', icon: 'mdi-memory', uptime: 99.8, responseTime: 8 },
|
|
{ name: 'Message Queue', description: 'RabbitMQ broker', status: 'degraded', statusColor: 'warning', icon: 'mdi-message-processing', uptime: 98.5, responseTime: 250 },
|
|
{ name: 'File Storage', description: 'S3-compatible storage', status: 'healthy', statusColor: 'success', icon: 'mdi-file-cloud', uptime: 99.7, responseTime: 180 },
|
|
{ name: 'Authentication', description: 'OAuth2/JWT service', status: 'healthy', statusColor: 'success', icon: 'mdi-shield-account', uptime: 99.9, responseTime: 65 },
|
|
{ name: 'Monitoring', description: 'Prometheus/Grafana', status: 'healthy', statusColor: 'success', icon: 'mdi-chart-line', uptime: 99.8, responseTime: 95 },
|
|
{ name: 'Load Balancer', description: 'Nginx reverse proxy', status: 'healthy', statusColor: 'success', icon: 'mdi-load-balancer', uptime: 99.99, responseTime: 12 }
|
|
])
|
|
|
|
const databaseMetrics = ref<DatabaseMetrics>({
|
|
connections: 64,
|
|
activeConnections: 42,
|
|
queryTime: 85,
|
|
queriesPerSecond: 1250,
|
|
cacheHitRate: 92,
|
|
cacheSize: 2147483648, // 2GB
|
|
replicationLag: 45,
|
|
replicationStatus: 'Synced'
|
|
})
|
|
|
|
const serverResources = ref<ServerResources>({
|
|
cpu: 42,
|
|
cpuCores: 8,
|
|
cpuFrequency: 3.2,
|
|
memory: 68,
|
|
memoryUsed: 1090519040, // ~1GB
|
|
memoryTotal: 17179869184, // 16GB
|
|
diskIO: 28,
|
|
diskRead: 5242880, // 5MB/s
|
|
diskWrite: 1048576, // 1MB/s
|
|
network: 45,
|
|
networkIn: 2097152, // 2MB/s
|
|
networkOut: 1048576 // 1MB/s
|
|
})
|
|
|
|
// Computed properties
|
|
const overallStatus = computed(() => {
|
|
const healthyCount = systemComponents.value.filter(c => c.status === 'healthy').length
|
|
const totalCount = systemComponents.value.length
|
|
|
|
if (healthyCount === totalCount) return 'healthy'
|
|
if (healthyCount >= totalCount * 0.8) return 'degraded'
|
|
return 'critical'
|
|
})
|
|
|
|
const overallStatusColor = computed(() => {
|
|
switch (overallStatus.value) {
|
|
case 'healthy': return 'green'
|
|
case 'degraded': return 'orange'
|
|
case 'critical': return 'red'
|
|
default: return 'grey'
|
|
}
|
|
})
|
|
|
|
const overallStatusIcon = computed(() => {
|
|
switch (overallStatus.value) {
|
|
case 'healthy': return 'mdi-check-circle'
|
|
case 'degraded': return 'mdi-alert-circle'
|
|
case 'critical': return 'mdi-close-circle'
|
|
default: return 'mdi-help-circle'
|
|
}
|
|
})
|
|
|
|
const overallStatusText = computed(() => {
|
|
switch (overallStatus.value) {
|
|
case 'healthy': return 'All Systems Normal'
|
|
case 'degraded': return 'Minor Issues'
|
|
case case 'critical': return 'Critical Issues'
|
|
default: return 'Unknown'
|
|
}
|
|
})
|
|
|
|
const lastCheckTime = computed(() => {
|
|
const now = new Date()
|
|
return now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
})
|
|
|
|
// Helper functions
|
|
const getResponseTimeColor = (responseTime: number) => {
|
|
if (responseTime < 100) return 'success'
|
|
if (responseTime < 300) return 'warning'
|
|
return 'error'
|
|
}
|
|
|
|
const formatBytes = (bytes: number) => {
|
|
if (bytes >= 1073741824) {
|
|
return `${(bytes / 1073741824).toFixed(1)} GB`
|
|
} else if (bytes >= 1048576) {
|
|
return `${(bytes / 1048576).toFixed(1)} MB`
|
|
} else if (bytes >= 1024) {
|
|
return `${(bytes / 1024).toFixed(1)} KB`
|
|
}
|
|
return `${bytes} B`
|
|
}
|
|
|
|
// Chart functions
|
|
const initChart = () => {
|
|
if (!responseTimeChart.value) return
|
|
|
|
// Destroy existing chart
|
|
if (chartInstance) {
|
|
chartInstance.destroy()
|
|
}
|
|
|
|
const ctx = responseTimeChart.value.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
// Generate mock response time data for last 24 hours
|
|
const labels = Array.from({ length: 24 }, (_, i) => {
|
|
const hour = new Date(Date.now() - (23 - i) * 3600000)
|
|
return hour.getHours().toString().padStart(2, '0') + ':00'
|
|
})
|
|
|
|
const data = labels.map(() => {
|
|
const base = 50
|
|
const spike = Math.random() > 0.9 ? 300 : 0
|
|
const variance = Math.random() * 40
|
|
return Math.round(base + variance + spike)
|
|
})
|
|
|
|
chartInstance = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [{
|
|
label: 'API Response Time (ms)',
|
|
data,
|
|
borderColor: '#2196F3',
|
|
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
|
tension: 0.4,
|
|
fill: true,
|
|
pointBackgroundColor: (context) => {
|
|
const value = context.dataset.data[context.dataIndex] as number
|
|
return value > 200 ? '#F44336' : value > 100 ? '#FF9800' : '#4CAF50'
|
|
},
|
|
pointBorderColor: '#FFFFFF',
|
|
pointBorderWidth: 2
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (context) => {
|
|
return `Response Time: ${context.raw}ms`
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: {
|
|
display: false
|
|
},
|
|
ticks: {
|
|
maxRotation: 0,
|
|
callback: (value, index) => {
|
|
// Show only every 3rd hour label
|
|
return index % 3 === 0 ? labels[index] : ''
|
|
}
|
|
}
|
|
},
|
|
y: {
|
|
beginAtZero: true,
|
|
title: {
|
|
display: true,
|
|
text: 'Milliseconds (ms)'
|
|
},
|
|
ticks: {
|
|
callback: (value) => `${value}ms`
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Auto-refresh management
|
|
const startAutoRefresh = () => {
|
|
if (refreshTimer) clearInterval(refreshTimer)
|
|
refreshTimer = setInterval(() => {
|
|
refreshHealth()
|
|
}, refreshInterval.value * 1000) as unknown as number
|
|
}
|
|
|
|
const stopAutoRefresh = () => {
|
|
if (refreshTimer) {
|
|
clearInterval(refreshTimer)
|
|
refreshTimer = null
|
|
}
|
|
}
|
|
|
|
const toggleAutoRefresh = () => {
|
|
autoRefresh.value = !autoRefresh.value
|
|
if (autoRefresh.value) {
|
|
startAutoRefresh()
|
|
} else {
|
|
stopAutoRefresh()
|
|
}
|
|
}
|
|
|
|
// Actions
|
|
const refreshHealth = () => {
|
|
if (isRefreshing.value) return
|
|
|
|
isRefreshing.value = true
|
|
|
|
// Simulate API call
|
|
setTimeout(() => {
|
|
// Update system components with random variations
|
|
systemComponents.value.forEach(component => {
|
|
// Random status changes (rare)
|
|
if (Math.random() > 0.95) {
|
|
component.status = Math.random() > 0.7 ? 'degraded' : 'healthy'
|
|
component.statusColor = component.status === 'healthy' ? 'success' : 'warning'
|
|
}
|
|
|
|
// Update response times
|
|
if (component.responseTime) {
|
|
const variation = Math.random() * 40 - 20
|
|
component.responseTime = Math.max(10, Math.round(component.responseTime + variation))
|
|
}
|
|
|
|
// Update uptime (slight variations)
|
|
component.uptime = Math.min(99.99, component.uptime + (Math.random() * 0.1 - 0.05))
|
|
})
|
|
|
|
// Update database metrics
|
|
databaseMetrics.value.connections = Math.round(64 + Math.random() * 20 - 10)
|
|
databaseMetrics.value.activeConnections = Math.round(databaseMetrics.value.connections * 0.7)
|
|
databaseMetrics.value.queryTime = Math.round(85 + Math.random() * 30 - 15)
|
|
databaseMetrics.value.queriesPerSecond = Math.round(1250 + Math.random() * 200 - 100)
|
|
databaseMetrics.value.cacheHitRate = Math.min(99, Math.round(92 + Math.random() * 4 - 2))
|
|
databaseMetrics.value.replicationLag = Math.round(45 + Math.random() * 20 - 10)
|
|
|
|
// Update server resources
|
|
serverResources.value.cpu = Math.round(42 + Math.random() * 20 - 10)
|
|
serverResources.value.memory = Math.round(68 + Math.random() * 10 - 5)
|
|
serverResources.value.diskIO = Math.round(28 + Math.random() * 15 - 7)
|
|
serverResources.value.network = Math.round(45 + Math.random() * 20 - 10)
|
|
|
|
// Update chart
|
|
initChart()
|
|
|
|
isRefreshing.value = false
|
|
}, 800)
|
|
}
|
|
|
|
// Lifecycle hooks
|
|
onMounted(() => {
|
|
nextTick(() => {
|
|
initChart()
|
|
})
|
|
|
|
// Start auto-refresh if enabled
|
|
if (autoRefresh.value) {
|
|
startAutoRefresh()
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (chartInstance) {
|
|
chartInstance.destroy()
|
|
}
|
|
|
|
stopAutoRefresh()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.chart-container {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.h-100 {
|
|
height: 100%;
|
|
}
|
|
|
|
.v-progress-linear {
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.v-card {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.v-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
}
|
|
</style>
|