899 lines
21 KiB
Vue
899 lines
21 KiB
Vue
<!-- Real-time AI News System Monitoring Dashboard -->
|
||
<template>
|
||
<view class="dashboard-container">
|
||
<!-- Header -->
|
||
<view class="dashboard-header">
|
||
<text class="dashboard-title">AI News System Dashboard</text>
|
||
<view class="status-indicator" :class="systemHealth.status">
|
||
<text class="status-text">{{ systemHealth.status.toUpperCase() }}</text>
|
||
<text class="health-score">{{ systemHealth.score }}/100</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Quick Stats -->
|
||
<view class="quick-stats">
|
||
<view class="stat-card">
|
||
<text class="stat-value">{{ formatNumber(stats.requests.total) }}</text>
|
||
<text class="stat-label">Total Requests</text>
|
||
<text class="stat-change" :class="getChangeClass(requestsChange)">
|
||
{{ formatChange(requestsChange) }}
|
||
</text>
|
||
</view>
|
||
|
||
<view class="stat-card">
|
||
<text class="stat-value">{{ (stats.requests.successRate * 100).toFixed(1) }}%</text>
|
||
<text class="stat-label">Success Rate</text>
|
||
<text class="stat-change" :class="getChangeClass(successRateChange)">
|
||
{{ formatChange(successRateChange) }}
|
||
</text>
|
||
</view>
|
||
|
||
<view class="stat-card">
|
||
<text class="stat-value">{{ stats.timing.averageLatency.toFixed(0) }}ms</text>
|
||
<text class="stat-label">Avg Latency</text>
|
||
<text class="stat-change" :class="getChangeClass(-latencyChange)">
|
||
{{ formatChange(latencyChange) }}
|
||
</text>
|
||
</view>
|
||
|
||
<view class="stat-card">
|
||
<text class="stat-value">${{ stats.costs.total.toFixed(2) }}</text>
|
||
<text class="stat-label">Total Cost</text>
|
||
<text class="stat-change" :class="getChangeClass(-costChange)">
|
||
${{ costChange.toFixed(2) }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Health Checks -->
|
||
<view class="health-section">
|
||
<text class="section-title">System Health Checks</text>
|
||
<view class="health-checks">
|
||
<view class="health-check"
|
||
v-for="(check, key) in systemHealth.checks"
|
||
:key="key"
|
||
:class="getHealthCheckClass(key, check)">
|
||
<text class="check-name">{{ formatCheckName(key) }}</text>
|
||
<text class="check-value">{{ formatCheckValue(key, check) }}</text>
|
||
<view class="check-indicator" :class="getHealthCheckClass(key, check)"></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Active Alerts -->
|
||
<view class="alerts-section" v-if="systemHealth.alerts.length > 0">
|
||
<text class="section-title">Active Alerts ({{ systemHealth.alerts.length }})</text>
|
||
<scroll-view class="alerts-list" scroll-y="true">
|
||
<view class="alert-item"
|
||
v-for="alert in systemHealth.alerts"
|
||
:key="alert.id"
|
||
:class="alert.severity">
|
||
<view class="alert-header">
|
||
<text class="alert-severity">{{ alert.severity.toUpperCase() }}</text>
|
||
<text class="alert-time">{{ formatTime(alert.timestamp) }}</text>
|
||
</view>
|
||
<text class="alert-message">{{ alert.message }}</text>
|
||
<text class="alert-source">Source: {{ alert.source }}</text>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<!-- Performance Charts -->
|
||
<view class="charts-section">
|
||
<text class="section-title">Performance Trends</text>
|
||
|
||
<!-- Response Time Chart -->
|
||
<view class="chart-container">
|
||
<text class="chart-title">Response Time (Last Hour)</text>
|
||
<view class="chart-area">
|
||
<canvas class="chart-canvas"
|
||
canvas-id="responseTimeChart"
|
||
@touchstart="onChartTouch"
|
||
@touchend="onChartTouchEnd"></canvas>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Request Volume Chart -->
|
||
<view class="chart-container">
|
||
<text class="chart-title">Request Volume</text>
|
||
<view class="chart-area">
|
||
<canvas class="chart-canvas"
|
||
canvas-id="requestVolumeChart"></canvas>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Cost Distribution -->
|
||
<view class="chart-container">
|
||
<text class="chart-title">Cost by Provider</text>
|
||
<view class="cost-breakdown">
|
||
<view class="cost-item" v-for="(cost, provider) in stats.costs.byProvider" :key="provider">
|
||
<view class="cost-bar-container">
|
||
<text class="provider-name">{{ provider }}</text>
|
||
<view class="cost-bar">
|
||
<view class="cost-fill"
|
||
:style="{ width: (cost / stats.costs.total * 100) + '%' }"></view>
|
||
</view>
|
||
<text class="cost-value">${{ cost.toFixed(2) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Optimization Recommendations -->
|
||
<view class="recommendations-section" v-if="recommendations.length > 0">
|
||
<text class="section-title">Optimization Recommendations</text>
|
||
<scroll-view class="recommendations-list" scroll-y="true">
|
||
<view class="recommendation-item"
|
||
v-for="(rec, index) in recommendations"
|
||
:key="index"
|
||
:class="rec.priority">
|
||
<view class="rec-header">
|
||
<text class="rec-type">{{ rec.type.toUpperCase() }}</text>
|
||
<text class="rec-priority">{{ rec.priority.toUpperCase() }}</text>
|
||
</view>
|
||
<text class="rec-description">{{ rec.description }}</text>
|
||
<view class="rec-impact">
|
||
<text class="impact-title">Expected Impact:</text>
|
||
<text v-if="rec.expectedImpact.performanceGain" class="impact-item">
|
||
🚀 {{ rec.expectedImpact.performanceGain }}
|
||
</text>
|
||
<text v-if="rec.expectedImpact.costSaving" class="impact-item">
|
||
💰 {{ rec.expectedImpact.costSaving }}
|
||
</text>
|
||
<text v-if="rec.expectedImpact.reliabilityImprovement" class="impact-item">
|
||
🛡️ {{ rec.expectedImpact.reliabilityImprovement }}
|
||
</text>
|
||
</view>
|
||
<view class="rec-actions">
|
||
<button class="btn-apply" @click="applyRecommendation(rec)" :disabled="rec.applying">
|
||
{{ rec.applying ? 'Applying...' : 'Apply' }}
|
||
</button>
|
||
<button class="btn-dismiss" @click="dismissRecommendation(rec)">
|
||
Dismiss
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<!-- Control Panel -->
|
||
<view class="controls-section">
|
||
<text class="section-title">Controls</text>
|
||
<view class="control-buttons">
|
||
<button class="control-btn" @click="refreshData" :disabled="isRefreshing">
|
||
{{ isRefreshing ? 'Refreshing...' : 'Refresh Data' }}
|
||
</button>
|
||
<button class="control-btn" @click="exportData">
|
||
Export Metrics
|
||
</button>
|
||
<button class="control-btn" @click="toggleAutoRefresh">
|
||
{{ autoRefresh ? 'Stop Auto-Refresh' : 'Start Auto-Refresh' }}
|
||
</button>
|
||
<button class="control-btn danger" @click="clearAlerts">
|
||
Clear Alerts
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
|
||
import {
|
||
AIPerformanceMonitor,
|
||
type PerformanceStats,
|
||
type SystemHealth,
|
||
type OptimizationRecommendation,
|
||
defaultPerformanceConfig
|
||
} from '../services/AIPerformanceMonitor.uts'
|
||
|
||
// Reactive data
|
||
const monitor = new AIPerformanceMonitor(defaultPerformanceConfig)
|
||
const stats = ref<PerformanceStats>({
|
||
timeRange: { start: 0, end: 0, duration: 0 },
|
||
requests: { total: 0, successful: 0, failed: 0, successRate: 0 },
|
||
timing: { averageLatency: 0, medianLatency: 0, p95Latency: 0, p99Latency: 0 },
|
||
costs: { total: 0, average: 0, byProvider: {} },
|
||
cache: { hitRate: 0, totalRequests: 0, hits: 0, misses: 0 },
|
||
errors: { byType: {}, byProvider: {}, topErrors: [] }
|
||
})
|
||
|
||
const systemHealth = ref<SystemHealth>({
|
||
status: 'healthy',
|
||
score: 100,
|
||
checks: {
|
||
apiConnectivity: true,
|
||
memoryUsage: 0,
|
||
errorRate: 0,
|
||
responseTime: 0,
|
||
costBudget: 0,
|
||
cacheEfficiency: 0
|
||
},
|
||
alerts: []
|
||
})
|
||
|
||
const recommendations = ref<OptimizationRecommendation[]>([])
|
||
const isRefreshing = ref(false)
|
||
const autoRefresh = ref(true)
|
||
let refreshInterval: number | null = null
|
||
|
||
// Previous values for change calculation
|
||
const previousStats = ref<PerformanceStats | null>(null)
|
||
|
||
// Computed properties for changes
|
||
const requestsChange = computed(() => {
|
||
if (!previousStats.value) return 0
|
||
return stats.value.requests.total - previousStats.value.requests.total
|
||
})
|
||
|
||
const successRateChange = computed(() => {
|
||
if (!previousStats.value) return 0
|
||
return stats.value.requests.successRate - previousStats.value.requests.successRate
|
||
})
|
||
|
||
const latencyChange = computed(() => {
|
||
if (!previousStats.value) return 0
|
||
return stats.value.timing.averageLatency - previousStats.value.timing.averageLatency
|
||
})
|
||
|
||
const costChange = computed(() => {
|
||
if (!previousStats.value) return 0
|
||
return stats.value.costs.total - previousStats.value.costs.total
|
||
})
|
||
|
||
// Lifecycle hooks
|
||
onMounted(async () => {
|
||
console.log('🚀 Starting monitoring dashboard...')
|
||
monitor.startMonitoring()
|
||
await refreshData()
|
||
|
||
if (autoRefresh.value) {
|
||
startAutoRefresh()
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
console.log('🛑 Stopping monitoring dashboard...')
|
||
stopAutoRefresh()
|
||
monitor.stopMonitoring()
|
||
})
|
||
|
||
// Methods
|
||
const refreshData = async () => {
|
||
if (isRefreshing.value) return
|
||
|
||
isRefreshing.value = true
|
||
try {
|
||
// Store previous stats for change calculation
|
||
previousStats.value = { ...stats.value }
|
||
|
||
// Get fresh data
|
||
const now = Date.now()
|
||
const oneHourAgo = now - 3600000
|
||
|
||
stats.value = monitor.getPerformanceStats(oneHourAgo, now)
|
||
systemHealth.value = monitor.getSystemHealth()
|
||
recommendations.value = monitor.getOptimizationRecommendations()
|
||
|
||
console.log('📊 Dashboard data refreshed')
|
||
} catch (error) {
|
||
console.error('❌ Failed to refresh dashboard data:', error)
|
||
} finally {
|
||
isRefreshing.value = false
|
||
}
|
||
}
|
||
|
||
const startAutoRefresh = () => {
|
||
if (refreshInterval) return
|
||
|
||
refreshInterval = setInterval(async () => {
|
||
await refreshData()
|
||
}, 30000) // Refresh every 30 seconds
|
||
|
||
console.log('🔄 Auto-refresh started')
|
||
}
|
||
|
||
const stopAutoRefresh = () => {
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval)
|
||
refreshInterval = null
|
||
console.log('⏹️ Auto-refresh stopped')
|
||
}
|
||
}
|
||
|
||
const toggleAutoRefresh = () => {
|
||
autoRefresh.value = !autoRefresh.value
|
||
|
||
if (autoRefresh.value) {
|
||
startAutoRefresh()
|
||
} else {
|
||
stopAutoRefresh()
|
||
}
|
||
}
|
||
|
||
const applyRecommendation = async (rec: OptimizationRecommendation) => {
|
||
rec.applying = true
|
||
try {
|
||
const result = await monitor.applyOptimizations([rec])
|
||
if (result.applied > 0) {
|
||
console.log('✅ Recommendation applied successfully')
|
||
// Remove from list
|
||
const index = recommendations.value.indexOf(rec)
|
||
if (index > -1) {
|
||
recommendations.value.splice(index, 1)
|
||
}
|
||
} else {
|
||
console.error('❌ Failed to apply recommendation')
|
||
}
|
||
} catch (error) {
|
||
console.error('💥 Error applying recommendation:', error)
|
||
} finally {
|
||
rec.applying = false
|
||
}
|
||
}
|
||
|
||
const dismissRecommendation = (rec: OptimizationRecommendation) => {
|
||
const index = recommendations.value.indexOf(rec)
|
||
if (index > -1) {
|
||
recommendations.value.splice(index, 1)
|
||
}
|
||
}
|
||
|
||
const exportData = () => {
|
||
try {
|
||
const data = monitor.exportPerformanceData('json')
|
||
|
||
// In real implementation, this would trigger a download
|
||
console.log('📤 Exporting performance data...')
|
||
console.log(data)
|
||
|
||
// For uni-app, you might want to save to local storage or share
|
||
uni.setStorageSync('ai-news-performance-data', data)
|
||
|
||
uni.showToast({
|
||
title: 'Data exported to storage',
|
||
icon: 'success'
|
||
})
|
||
} catch (error) {
|
||
console.error('❌ Failed to export data:', error)
|
||
uni.showToast({
|
||
title: 'Export failed',
|
||
icon: 'error'
|
||
})
|
||
}
|
||
}
|
||
|
||
const clearAlerts = () => {
|
||
systemHealth.value.alerts = []
|
||
uni.showToast({
|
||
title: 'Alerts cleared',
|
||
icon: 'success'
|
||
})
|
||
}
|
||
|
||
// Utility methods
|
||
const formatNumber = (num: number): string => {
|
||
if (num >= 1000000) {
|
||
return (num / 1000000).toFixed(1) + 'M'
|
||
} else if (num >= 1000) {
|
||
return (num / 1000).toFixed(1) + 'K'
|
||
}
|
||
return num.toString()
|
||
}
|
||
|
||
const formatChange = (change: number): string => {
|
||
if (change === 0) return '0'
|
||
const sign = change > 0 ? '+' : ''
|
||
return `${sign}${change.toFixed(1)}`
|
||
}
|
||
|
||
const getChangeClass = (change: number): string => {
|
||
if (change > 0) return 'positive'
|
||
if (change < 0) return 'negative'
|
||
return 'neutral'
|
||
}
|
||
|
||
const formatCheckName = (key: string): string => {
|
||
return key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())
|
||
}
|
||
|
||
const formatCheckValue = (key: string, value: any): string => {
|
||
switch (key) {
|
||
case 'apiConnectivity':
|
||
return value ? 'Connected' : 'Disconnected'
|
||
case 'memoryUsage':
|
||
return (value * 100).toFixed(1) + '%'
|
||
case 'errorRate':
|
||
return (value * 100).toFixed(2) + '%'
|
||
case 'responseTime':
|
||
return value.toFixed(0) + 'ms'
|
||
case 'costBudget':
|
||
return (value * 100).toFixed(1) + '%'
|
||
case 'cacheEfficiency':
|
||
return (value * 100).toFixed(1) + '%'
|
||
default:
|
||
return String(value)
|
||
}
|
||
}
|
||
|
||
const getHealthCheckClass = (key: string, value: any): string => {
|
||
switch (key) {
|
||
case 'apiConnectivity':
|
||
return value ? 'healthy' : 'critical'
|
||
case 'memoryUsage':
|
||
return value > 0.8 ? 'critical' : value > 0.6 ? 'warning' : 'healthy'
|
||
case 'errorRate':
|
||
return value > 0.1 ? 'critical' : value > 0.05 ? 'warning' : 'healthy'
|
||
case 'responseTime':
|
||
return value > 5000 ? 'critical' : value > 3000 ? 'warning' : 'healthy'
|
||
case 'costBudget':
|
||
return value > 0.9 ? 'critical' : value > 0.7 ? 'warning' : 'healthy'
|
||
case 'cacheEfficiency':
|
||
return value < 0.3 ? 'warning' : value < 0.5 ? 'healthy' : 'excellent'
|
||
default:
|
||
return 'neutral'
|
||
}
|
||
}
|
||
|
||
const formatTime = (timestamp: number): string => {
|
||
const now = Date.now()
|
||
const diff = now - timestamp
|
||
|
||
if (diff < 60000) return 'Just now'
|
||
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'
|
||
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'
|
||
return new Date(timestamp).toLocaleDateString()
|
||
}
|
||
|
||
// Chart event handlers
|
||
const onChartTouch = (e: any) => {
|
||
console.log('Chart touched:', e)
|
||
}
|
||
|
||
const onChartTouchEnd = (e: any) => {
|
||
console.log('Chart touch ended:', e)
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.dashboard-container {
|
||
padding: 20px;
|
||
background-color: #f5f5f5;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.dashboard-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
padding: 20px;
|
||
background: white;
|
||
border-radius: 12px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.dashboard-title {
|
||
font-size: 24px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.status-indicator {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 10px 20px;
|
||
border-radius: 8px;
|
||
min-width: 100px;
|
||
}
|
||
|
||
.status-indicator.healthy {
|
||
background-color: #e8f5e8;
|
||
border: 2px solid #4caf50;
|
||
}
|
||
|
||
.status-indicator.warning {
|
||
background-color: #fff3e0;
|
||
border: 2px solid #ff9800;
|
||
}
|
||
|
||
.status-indicator.critical {
|
||
background-color: #ffebee;
|
||
border: 2px solid #f44336;
|
||
}
|
||
|
||
.status-text {
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.health-score {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.quick-stats {
|
||
display: flex;
|
||
gap: 15px;
|
||
margin-bottom: 20px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.stat-card {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
padding: 20px;
|
||
background: white;
|
||
border-radius: 12px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-value {
|
||
display: block;
|
||
font-size: 28px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.stat-label {
|
||
display: block;
|
||
font-size: 14px;
|
||
color: #666;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.stat-change {
|
||
display: block;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.stat-change.positive {
|
||
color: #4caf50;
|
||
}
|
||
|
||
.stat-change.negative {
|
||
color: #f44336;
|
||
}
|
||
|
||
.stat-change.neutral {
|
||
color: #666;
|
||
}
|
||
|
||
.health-section,
|
||
.alerts-section,
|
||
.charts-section,
|
||
.recommendations-section,
|
||
.controls-section {
|
||
margin-bottom: 20px;
|
||
padding: 20px;
|
||
background: white;
|
||
border-radius: 12px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.section-title {
|
||
display: block;
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.health-checks {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||
gap: 15px;
|
||
}
|
||
|
||
.health-check {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
border-left: 4px solid #ddd;
|
||
}
|
||
|
||
.health-check.healthy {
|
||
background-color: #e8f5e8;
|
||
border-left-color: #4caf50;
|
||
}
|
||
|
||
.health-check.warning {
|
||
background-color: #fff3e0;
|
||
border-left-color: #ff9800;
|
||
}
|
||
|
||
.health-check.critical {
|
||
background-color: #ffebee;
|
||
border-left-color: #f44336;
|
||
}
|
||
|
||
.check-name {
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.check-value {
|
||
color: #666;
|
||
}
|
||
|
||
.alerts-list {
|
||
max-height: 300px;
|
||
}
|
||
|
||
.alert-item {
|
||
padding: 15px;
|
||
margin-bottom: 10px;
|
||
border-radius: 8px;
|
||
border-left: 4px solid #ddd;
|
||
}
|
||
|
||
.alert-item.info {
|
||
background-color: #e3f2fd;
|
||
border-left-color: #2196f3;
|
||
}
|
||
|
||
.alert-item.warning {
|
||
background-color: #fff3e0;
|
||
border-left-color: #ff9800;
|
||
}
|
||
|
||
.alert-item.error {
|
||
background-color: #ffebee;
|
||
border-left-color: #f44336;
|
||
}
|
||
|
||
.alert-item.critical {
|
||
background-color: #fce4ec;
|
||
border-left-color: #e91e63;
|
||
}
|
||
|
||
.alert-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.alert-severity {
|
||
font-weight: bold;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.alert-time {
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.alert-message {
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
color: #333;
|
||
}
|
||
|
||
.alert-source {
|
||
display: block;
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.chart-container {
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.chart-title {
|
||
display: block;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.chart-area {
|
||
height: 200px;
|
||
position: relative;
|
||
}
|
||
|
||
.chart-canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.cost-breakdown {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.cost-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
}
|
||
|
||
.cost-bar-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex: 1;
|
||
}
|
||
|
||
.provider-name {
|
||
min-width: 80px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.cost-bar {
|
||
flex: 1;
|
||
height: 20px;
|
||
background-color: #f0f0f0;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.cost-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #4caf50, #2196f3);
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.cost-value {
|
||
min-width: 60px;
|
||
text-align: right;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.recommendations-list {
|
||
max-height: 400px;
|
||
}
|
||
|
||
.recommendation-item {
|
||
padding: 15px;
|
||
margin-bottom: 15px;
|
||
border-radius: 8px;
|
||
border-left: 4px solid #ddd;
|
||
}
|
||
|
||
.recommendation-item.low {
|
||
background-color: #f9f9f9;
|
||
border-left-color: #9e9e9e;
|
||
}
|
||
|
||
.recommendation-item.medium {
|
||
background-color: #fff3e0;
|
||
border-left-color: #ff9800;
|
||
}
|
||
|
||
.recommendation-item.high {
|
||
background-color: #ffebee;
|
||
border-left-color: #f44336;
|
||
}
|
||
|
||
.recommendation-item.critical {
|
||
background-color: #fce4ec;
|
||
border-left-color: #e91e63;
|
||
}
|
||
|
||
.rec-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.rec-type {
|
||
font-weight: bold;
|
||
font-size: 12px;
|
||
padding: 4px 8px;
|
||
background-color: #e0e0e0;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.rec-priority {
|
||
font-weight: bold;
|
||
font-size: 12px;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.rec-description {
|
||
display: block;
|
||
margin-bottom: 10px;
|
||
color: #333;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.rec-impact {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.impact-title {
|
||
display: block;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.impact-item {
|
||
display: block;
|
||
margin-bottom: 2px;
|
||
color: #666;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.rec-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.btn-apply,
|
||
.btn-dismiss {
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.btn-apply {
|
||
background-color: #4caf50;
|
||
color: white;
|
||
}
|
||
|
||
.btn-apply:disabled {
|
||
background-color: #ccc;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-dismiss {
|
||
background-color: #f5f5f5;
|
||
color: #333;
|
||
}
|
||
|
||
.control-buttons {
|
||
display: flex;
|
||
gap: 15px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.control-btn {
|
||
padding: 12px 24px;
|
||
border: 2px solid #2196f3;
|
||
background-color: #2196f3;
|
||
color: white;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.control-btn:hover {
|
||
background-color: #1976d2;
|
||
border-color: #1976d2;
|
||
}
|
||
|
||
.control-btn:disabled {
|
||
background-color: #ccc;
|
||
border-color: #ccc;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.control-btn.danger {
|
||
background-color: #f44336;
|
||
border-color: #f44336;
|
||
}
|
||
|
||
.control-btn.danger:hover {
|
||
background-color: #d32f2f;
|
||
border-color: #d32f2f;
|
||
}
|
||
</style>
|