Add basic VMs page to start

This commit is contained in:
Joshua Boniface 2025-03-01 11:17:50 -05:00
parent af00d1fe61
commit 70639194ea
4 changed files with 921 additions and 12 deletions

View File

@ -18,6 +18,10 @@
<i class="fas fa-server"></i> <i class="fas fa-server"></i>
<span class="nav-text">Nodes</span> <span class="nav-text">Nodes</span>
</router-link> </router-link>
<router-link to="/vms" class="nav-item" active-class="active" data-title="VMs">
<i class="fas fa-desktop"></i>
<span class="nav-text">VMs</span>
</router-link>
</nav> </nav>
</div> </div>
<div class="sidebar-footer"> <div class="sidebar-footer">
@ -101,28 +105,28 @@ const updateMetricsHistory = (timestamp, status) => {
const data = [...metricsHistory.value[metric].data, const data = [...metricsHistory.value[metric].data,
typeof metrics[metric] === 'boolean' ? metrics[metric] : Math.round(metrics[metric]) typeof metrics[metric] === 'boolean' ? metrics[metric] : Math.round(metrics[metric])
]; ];
// Keep only last 180 points // Keep only last 180 points
if (labels.length > 180) { if (labels.length > 180) {
labels.shift(); labels.shift();
data.shift(); data.shift();
} }
metricsHistory.value[metric] = { metricsHistory.value[metric] = {
labels, labels,
data data
}; };
}); });
// Track node-specific metrics // Track node-specific metrics
if (!metricsHistory.value.nodes) { if (!metricsHistory.value.nodes) {
metricsHistory.value.nodes = {}; metricsHistory.value.nodes = {};
} }
// Update metrics for each node // Update metrics for each node
nodeData.value.forEach(node => { nodeData.value.forEach(node => {
const nodeName = node.name; const nodeName = node.name;
if (!metricsHistory.value.nodes[nodeName]) { if (!metricsHistory.value.nodes[nodeName]) {
metricsHistory.value.nodes[nodeName] = { metricsHistory.value.nodes[nodeName] = {
health: { labels: [], data: [] }, health: { labels: [], data: [] },
@ -131,7 +135,7 @@ const updateMetricsHistory = (timestamp, status) => {
allocated: { labels: [], data: [] } allocated: { labels: [], data: [] }
}; };
} }
// Calculate node metrics // Calculate node metrics
const nodeMetrics = { const nodeMetrics = {
health: node.health || 0, health: node.health || 0,
@ -139,18 +143,18 @@ const updateMetricsHistory = (timestamp, status) => {
memory: node.memory?.used && node.memory?.total ? Math.round((node.memory.used / node.memory.total) * 100) : 0, memory: node.memory?.used && node.memory?.total ? Math.round((node.memory.used / node.memory.total) * 100) : 0,
allocated: node.memory?.allocated && node.memory?.total ? Math.round((node.memory.allocated / node.memory.total) * 100) : 0 allocated: node.memory?.allocated && node.memory?.total ? Math.round((node.memory.allocated / node.memory.total) * 100) : 0
}; };
// Update each metric for this node // Update each metric for this node
Object.keys(nodeMetrics).forEach(metric => { Object.keys(nodeMetrics).forEach(metric => {
const nodeLabels = [...metricsHistory.value.nodes[nodeName][metric].labels, timestamp]; const nodeLabels = [...metricsHistory.value.nodes[nodeName][metric].labels, timestamp];
const nodeData = [...metricsHistory.value.nodes[nodeName][metric].data, nodeMetrics[metric]]; const nodeData = [...metricsHistory.value.nodes[nodeName][metric].data, nodeMetrics[metric]];
// Keep only last 180 points // Keep only last 180 points
if (nodeLabels.length > 180) { if (nodeLabels.length > 180) {
nodeLabels.shift(); nodeLabels.shift();
nodeData.shift(); nodeData.shift();
} }
metricsHistory.value.nodes[nodeName][metric] = { metricsHistory.value.nodes[nodeName][metric] = {
labels: nodeLabels, labels: nodeLabels,
data: nodeData data: nodeData
@ -170,10 +174,10 @@ const updateDashboard = async () => {
try { try {
const status = await api.fetchStatus(); const status = await api.fetchStatus();
const nodes = await api.fetchNodes(); const nodes = await api.fetchNodes();
console.log('[API] Status Response:', status); console.log('[API] Status Response:', status);
console.log('[API] Nodes Response:', nodes); console.log('[API] Nodes Response:', nodes);
// Update state with new objects instead of mutating // Update state with new objects instead of mutating
clusterData.value = { ...status }; clusterData.value = { ...status };
nodeData.value = [...nodes]; nodeData.value = [...nodes];

View File

@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import Overview from '../views/Overview.vue'; import Overview from '../views/Overview.vue';
import Nodes from '../views/Nodes.vue'; import Nodes from '../views/Nodes.vue';
import VMs from '../views/VMs.vue';
const routes = [ const routes = [
{ {
@ -12,6 +13,11 @@ const routes = [
path: '/nodes', path: '/nodes',
name: 'Nodes', name: 'Nodes',
component: Nodes component: Nodes
},
{
path: '/vms',
name: 'VMs',
component: VMs
} }
]; ];

View File

@ -70,11 +70,36 @@ export const useApiStore = defineStore('api', () => {
} }
}; };
const fetchVMs = async () => {
try {
const response = await fetch('/api/vm', {
headers: {
'X-API-URI': config.value.apiUri,
'X-API-Key': config.value.apiKey
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return Object.entries(data).map(([name, details]) => ({
name,
...details
}));
} catch (error) {
console.error('Error fetching VM data:', error);
throw error;
}
};
return { return {
config, config,
isConfigured, isConfigured,
updateConfig, updateConfig,
fetchStatus, fetchStatus,
fetchNodes fetchNodes,
fetchVMs
}; };
}); });

874
pvc-vue/src/views/VMs.vue Normal file
View File

@ -0,0 +1,874 @@
<template>
<div class="content-grid">
<PageTitle title="Virtual Machines" />
<!-- VM Selector Controls -->
<div class="vm-controls-container">
<div class="controls-row">
<button
class="btn btn-outline-secondary list-toggle-btn"
@click="toggleVMList"
:class="{ 'active': showVMList }"
>
<i class="fas fa-list"></i> List VMs
</button>
<div class="search-box">
<i class="fas fa-search search-icon"></i>
<input
type="text"
placeholder="Search VMs..."
:value="showVMList ? searchQuery : (selectedVMData?.name || '')"
@input="handleSearch"
@focus="handleSearchFocus"
class="form-control search-input"
>
<button
v-if="searchQuery"
class="btn-clear"
@click="clearSearch"
>
<i class="fas fa-times"></i>
</button>
</div>
<div class="filter-dropdown">
<button
class="btn btn-outline-secondary dropdown-toggle"
@click="toggleFilterMenu"
>
<i class="fas fa-filter"></i> Filters
<span v-if="activeFiltersCount > 0" class="filter-badge">{{ activeFiltersCount }}</span>
</button>
<div class="filter-menu" v-show="showFilterMenu">
<div class="filter-section">
<h6>Status</h6>
<div class="filter-options-dropdown">
<div class="filter-pills">
<button
v-for="state in availableStates"
:key="state"
class="filter-pill"
:class="{ 'active': appliedFilters.states[state] }"
@click="toggleFilter('states', state)"
>
{{ state }}
</button>
</div>
</div>
</div>
<div class="filter-section">
<h6>Node</h6>
<div class="filter-options-dropdown">
<div class="filter-pills">
<button
v-for="node in availableNodes"
:key="node"
class="filter-pill"
:class="{ 'active': appliedFilters.nodes[node] }"
@click="toggleFilter('nodes', node)"
>
{{ node }}
</button>
</div>
</div>
</div>
<div class="filter-actions">
<button class="btn btn-sm btn-outline-secondary" @click="resetFilters">Reset All</button>
<button class="btn btn-sm btn-primary" @click="toggleFilterMenu">Close</button>
</div>
</div>
</div>
</div>
</div>
<!-- VM List (Full Page) -->
<div v-if="showVMList" class="vm-list-fullpage">
<div v-if="filteredVMs.length === 0" class="no-vms-message">
<p>No VMs match your search criteria</p>
</div>
<div v-else class="vm-list">
<button
v-for="vm in filteredVMs"
:key="vm.name"
class="vm-list-item"
:class="{ 'active': selectedVM === vm.name }"
@click="selectVM(vm.name)"
>
<div class="vm-item-content">
<div class="vm-name">{{ vm.name }}</div>
<div class="vm-status" :class="getStatusClass(vm.state)">
<i class="fas fa-circle status-indicator"></i>
<span>{{ vm.state }}</span>
</div>
</div>
</button>
</div>
</div>
<!-- VM Details -->
<div v-if="selectedVMData && !showVMList" class="vm-details">
<!-- Information Cards Section -->
<div class="section-container" :class="{ 'collapsed': !sections.info }">
<!-- Collapsed section indicator -->
<div v-if="!sections.info" class="section-content-wrapper">
<div class="section-content">
<div class="collapsed-section-header">
<h6 class="card-title mb-0 metric-label">VM Information</h6>
</div>
</div>
<div class="toggle-column">
<button class="section-toggle" @click="toggleSection('info')">
<i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
<!-- Toggle button for expanded section -->
<div v-show="sections.info" class="section-content-wrapper">
<div class="section-content">
<div class="info-row">
<!-- Information cards will go here -->
<p>VM information cards will be populated here</p>
</div>
</div>
<div class="toggle-column expanded">
<button class="section-toggle" @click="toggleSection('info')">
<i class="fas fa-chevron-up"></i>
</button>
</div>
</div>
</div>
<!-- Utilization Graphs Section -->
<div class="section-container" :class="{ 'collapsed': !sections.graphs }">
<!-- Collapsed section indicator -->
<div v-if="!sections.graphs" class="section-content-wrapper">
<div class="section-content">
<div class="collapsed-section-header">
<h6 class="card-title mb-0 metric-label">Utilization Graphs</h6>
</div>
</div>
<div class="toggle-column">
<button class="section-toggle" @click="toggleSection('graphs')">
<i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
<!-- Toggle button for expanded section -->
<div v-show="sections.graphs" class="section-content-wrapper">
<div class="section-content">
<div class="graphs-row">
<!-- Graph components will go here -->
<p>VM utilization graphs will be populated here</p>
</div>
</div>
<div class="toggle-column expanded">
<button class="section-toggle" @click="toggleSection('graphs')">
<i class="fas fa-chevron-up"></i>
</button>
</div>
</div>
</div>
<!-- Resources Section -->
<div class="section-container" :class="{ 'collapsed': !sections.resources }">
<!-- Collapsed section indicator -->
<div v-if="!sections.resources" class="section-content-wrapper">
<div class="section-content">
<div class="collapsed-section-header">
<h6 class="card-title mb-0 metric-label">VM Resources</h6>
</div>
</div>
<div class="toggle-column">
<button class="section-toggle" @click="toggleSection('resources')">
<i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
<!-- Toggle button for expanded section -->
<div v-show="sections.resources" class="section-content-wrapper">
<div class="section-content">
<div class="resources-row">
<!-- Resource cards will go here -->
<p>VM resource details will be populated here</p>
</div>
</div>
<div class="toggle-column expanded">
<button class="section-toggle" @click="toggleSection('resources')">
<i class="fas fa-chevron-up"></i>
</button>
</div>
</div>
</div>
<!-- Network Section -->
<div class="section-container" :class="{ 'collapsed': !sections.network }">
<!-- Collapsed section indicator -->
<div v-if="!sections.network" class="section-content-wrapper">
<div class="section-content">
<div class="collapsed-section-header">
<h6 class="card-title mb-0 metric-label">Network Configuration</h6>
</div>
</div>
<div class="toggle-column">
<button class="section-toggle" @click="toggleSection('network')">
<i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
<!-- Toggle button for expanded section -->
<div v-show="sections.network" class="section-content-wrapper">
<div class="section-content">
<div class="network-row">
<!-- Network details will go here -->
<p>VM network configuration will be populated here</p>
</div>
</div>
<div class="toggle-column expanded">
<button class="section-toggle" @click="toggleSection('network')">
<i class="fas fa-chevron-up"></i>
</button>
</div>
</div>
</div>
</div>
<!-- No VM Selected Message -->
<div v-if="!selectedVMData && !showVMList" class="no-vm-selected">
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Please select a VM from the list to view its details
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import PageTitle from '../components/PageTitle.vue';
import { useApiStore } from '../stores/api';
const api = useApiStore();
const router = useRouter();
const route = useRoute();
// State
const vmData = ref([]);
const selectedVM = ref('');
const searchQuery = ref('');
const showVMList = ref(true);
const showFilterMenu = ref(false);
const appliedFilters = ref({
states: {},
nodes: {}
});
// Section visibility state
const sections = ref({
info: true,
graphs: true,
resources: true,
network: true
});
// Toggle VM list visibility
const toggleVMList = () => {
showVMList.value = !showVMList.value;
};
// Toggle filter menu
const toggleFilterMenu = () => {
showFilterMenu.value = !showFilterMenu.value;
};
// Close filter menu when clicking outside
const closeFilterMenuOnClickOutside = (event) => {
const filterDropdown = document.querySelector('.filter-dropdown');
if (filterDropdown && !filterDropdown.contains(event.target)) {
showFilterMenu.value = false;
}
};
// Add event listener for clicks outside filter menu
onMounted(() => {
document.addEventListener('click', closeFilterMenuOnClickOutside);
});
// Remove event listener when component is unmounted
onUnmounted(() => {
document.removeEventListener('click', closeFilterMenuOnClickOutside);
});
// Toggle section visibility
const toggleSection = (section) => {
sections.value[section] = !sections.value[section];
};
// Clear search
const clearSearch = () => {
searchQuery.value = '';
filterVMs();
};
// Toggle a filter on/off
const toggleFilter = (type, value) => {
appliedFilters.value[type][value] = !appliedFilters.value[type][value];
filterVMs();
};
// Reset filters
const resetFilters = () => {
// Reset all filters and apply immediately
Object.keys(appliedFilters.value.states).forEach(state => {
appliedFilters.value.states[state] = false;
});
Object.keys(appliedFilters.value.nodes).forEach(node => {
appliedFilters.value.nodes[node] = false;
});
filterVMs();
};
// Count active filters
const activeFiltersCount = computed(() => {
const stateFiltersCount = Object.values(appliedFilters.value.states).filter(v => v).length;
const nodeFiltersCount = Object.values(appliedFilters.value.nodes).filter(v => v).length;
return stateFiltersCount + nodeFiltersCount;
});
// Get available states from VM data
const availableStates = computed(() => {
const states = new Set();
vmData.value.forEach(vm => {
if (vm.state) states.add(vm.state);
});
return Array.from(states).sort();
});
// Get available nodes from VM data
const availableNodes = computed(() => {
const nodes = new Set();
vmData.value.forEach(vm => {
if (vm.node) nodes.add(vm.node);
});
return Array.from(nodes).sort();
});
// Filter VMs based on search query and active filter
const filteredVMs = computed(() => {
if (!vmData.value) return [];
let filtered = [...vmData.value];
// Apply search filter
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(vm =>
vm.name.toLowerCase().includes(query)
);
}
// Apply state filters
const hasStateFilters = Object.values(appliedFilters.value.states).some(v => v);
if (hasStateFilters) {
filtered = filtered.filter(vm => {
return appliedFilters.value.states[vm.state] === true;
});
}
// Apply node filters
const hasNodeFilters = Object.values(appliedFilters.value.nodes).some(v => v);
if (hasNodeFilters) {
filtered = filtered.filter(vm => {
return appliedFilters.value.nodes[vm.node] === true;
});
}
return filtered;
});
// Get the selected VM data
const selectedVMData = computed(() => {
if (!selectedVM.value || !vmData.value) return null;
return vmData.value.find(vm => vm.name === selectedVM.value) || null;
});
// Get status class based on VM state
const getStatusClass = (state) => {
if (!state) return 'status-unknown';
switch (state) {
case 'start':
return 'status-running';
case 'shutdown':
return 'status-stopped';
case 'pause':
return 'status-paused';
case 'crash':
return 'status-error';
default:
return 'status-unknown';
}
};
// Fetch VM data from API
const fetchVMData = async () => {
try {
vmData.value = await api.fetchVMs();
// Initialize filters with available states and nodes
availableStates.value.forEach(state => {
appliedFilters.value.states[state] = false;
});
availableNodes.value.forEach(node => {
appliedFilters.value.nodes[node] = false;
});
// Check if there's a VM specified in the URL
const vmFromQuery = route.query.vm;
if (vmFromQuery) {
const vmExists = vmData.value.some(vm => vm.name === vmFromQuery);
if (vmExists) {
selectVM(vmFromQuery);
// Hide VM list if a specific VM is requested
showVMList.value = false;
}
} else {
// Show VM list by default if no VM is specified
showVMList.value = true;
selectedVM.value = '';
}
} catch (error) {
console.error('Error fetching VM data:', error);
}
};
// Handle search input
const handleSearch = (event) => {
searchQuery.value = event.target.value;
filterVMs();
};
// Handle search focus
const handleSearchFocus = () => {
// Show VM list when search is focused
if (!showVMList.value) {
showVMList.value = true;
}
};
// Filter VMs based on current search and filters
const filterVMs = () => {
// This is handled by the computed property
// But we need this function for event handlers
};
// Select a VM
const selectVM = (vmName) => {
selectedVM.value = vmName;
router.push({ query: { vm: vmName } });
showVMList.value = false;
};
onMounted(() => {
fetchVMData();
});
// Watch for route changes to update selected VM
watch(() => route.query.vm, (newVm) => {
if (newVm && vmData.value.some(vm => vm.name === newVm)) {
selectVM(newVm);
// Hide VM list when navigating directly to a VM
showVMList.value = false;
} else if (vmData.value.length > 0) {
// If no VM specified in URL, show list and clear selection
selectedVM.value = '';
showVMList.value = true;
}
});
</script>
<style scoped>
/* VM Controls Styles */
.vm-controls-container {
margin-bottom: 0.5rem;
background-color: white;
border: 1px solid rgba(0, 0, 0, 0.125);
border-radius: 0.25rem;
padding: 0.5rem;
}
.controls-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.list-toggle-btn {
display: flex;
align-items: center;
gap: 0.5rem;
}
.list-toggle-btn.active {
background-color: #6c757d;
color: white;
}
.filter-dropdown {
position: relative;
}
.filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background-color: #0d6efd;
color: white;
border-radius: 50%;
font-size: 0.7rem;
margin-left: 0.5rem;
}
/* Filter Dropdown Menu */
.filter-menu {
position: absolute;
top: 100%;
right: 0;
z-index: 1000;
min-width: 250px;
padding: 0.75rem;
margin-top: 0.25rem;
background-color: white;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.25rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.filter-section {
margin-bottom: 1rem;
}
.filter-section h6 {
margin-bottom: 0.5rem;
font-weight: 600;
color: #495057;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding-bottom: 0.25rem;
}
.filter-options-dropdown {
max-height: 150px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-left: 0.25rem;
}
/* Filter Pills */
.filter-pills {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.25rem;
}
.filter-pill {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 1rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.filter-pill:hover {
background-color: #e9ecef;
}
.filter-pill.active {
background-color: #0d6efd;
color: white;
border-color: #0d6efd;
}
.filter-actions {
display: flex;
justify-content: space-between;
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding-top: 0.5rem;
margin-top: 0.5rem;
}
/* VM Selector Styles */
.vm-selector-container {
background-color: white;
border: 1px solid rgba(0, 0, 0, 0.125);
border-radius: 0.25rem;
margin-bottom: 1rem;
overflow: hidden;
}
.search-filter-container {
display: flex;
flex-wrap: wrap;
padding: 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background-color: rgba(0, 0, 0, 0.02);
gap: 0.75rem;
align-items: center;
}
.search-box {
position: relative;
flex: 1;
min-width: 200px;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
}
.search-input {
padding-left: 30px;
padding-right: 30px;
}
.btn-clear {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #6c757d;
cursor: pointer;
padding: 0;
font-size: 0.875rem;
}
.filter-buttons {
display: flex;
gap: 0.5rem;
}
/* Full-page VM List */
.vm-list-fullpage {
background-color: white;
border: 1px solid rgba(0, 0, 0, 0.125);
border-radius: 0.25rem;
margin-bottom: 1rem;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
}
.vm-list {
display: flex;
flex-direction: column;
width: 100%;
}
.vm-list-item {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
border: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background-color: white;
text-align: left;
cursor: pointer;
transition: background-color 0.2s;
}
.vm-list-item:last-child {
border-bottom: none;
}
.vm-list-item:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.vm-list-item.active {
background-color: rgba(13, 110, 253, 0.1);
border-left: 3px solid #0d6efd;
}
.vm-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.vm-name {
font-weight: 500;
}
.vm-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.status-indicator {
font-size: 0.625rem;
}
.status-running {
color: #28a745;
}
.status-stopped {
color: #6c757d;
}
.status-paused {
color: #ffc107;
}
.status-error {
color: #dc3545;
}
.status-unknown {
color: #6c757d;
}
.no-vms-message {
padding: 2rem;
text-align: center;
color: #6c757d;
}
.no-vm-selected {
padding: 2rem;
text-align: center;
}
/* VM Details Styles */
.vm-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Section Styles (reused from Nodes.vue) */
.section-container {
margin-bottom: 0.5rem;
}
.section-container.collapsed {
margin-bottom: 0.5rem;
}
.section-content-wrapper {
display: flex;
position: relative;
}
.section-content {
flex: 1;
min-width: 0;
padding-right: 40px;
}
.toggle-column {
position: absolute;
top: 4px;
right: 0;
width: 40px;
height: 30px;
z-index: 10;
padding-left: 6px;
}
.toggle-column.expanded {
top: 0;
right: 0;
height: 100%;
display: flex;
align-items: flex-start;
padding-top: 4px;
}
.section-toggle {
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: all 0.2s;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.8);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.section-toggle:hover {
background-color: rgba(0, 0, 0, 0.05);
color: #333;
}
.collapsed-section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background-color: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.125);
border-radius: 0.25rem;
height: 38px;
width: 100%;
}
.collapsed-section-header .card-title {
margin: 0;
color: #495057;
font-size: 0.95rem;
font-weight: 600;
}
.metric-label {
color: #495057;
font-weight: 600;
}
/* Responsive Styles */
@media (max-width: 768px) {
.search-filter-container {
flex-direction: column;
align-items: stretch;
}
.search-box {
width: 100%;
}
.filter-buttons {
width: 100%;
justify-content: space-between;
}
}
</style>