♻️ GlCompactMetrics to use WidgetBase shared methods

This commit is contained in:
Alicia Sykes 2026-03-12 17:25:47 +00:00
parent fa118dd5c8
commit a41111fbab

View file

@ -1,9 +1,5 @@
<template>
<div class="metrics-wrapper">
<div v-if="options.label" class="widget-label">
{{ options.label }}
</div>
<div class="gl-compact-metrics">
<!-- Compact table view -->
<div v-if="!selectedSystem" class="compact-table">
<div class="table-header">
@ -12,10 +8,9 @@
<div class="metric-column">Memory</div>
<div class="metric-column">Disk</div>
</div>
<div class="table-body">
<div
v-for="(system, index) in options.systems"
v-for="(system, index) in systems"
:key="index"
class="table-row"
@click="showSystemDetails(system)"
@ -47,9 +42,7 @@
<div v-if="selectedSystem" class="detail-view">
<div class="detail-header">
<h3>{{ selectedSystem.header }}</h3>
<button class="back-btn" @click="backToCompactView">
Back
</button>
<button class="back-btn" @click="backToCompactView">Back</button>
</div>
<div class="detail-content" v-if="!errors[selectedSystem.url]">
@ -66,77 +59,40 @@
</div>
<div class="info-item">
<span class="info-label">Uptime:</span>
<span class="info-value">{{ getUptimeDisplay() }}</span>
<span class="info-value">{{ uptimeDisplay }}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>CPU Usage</h4>
<div class="detail-section" v-for="m in detailMetrics" :key="m.key">
<h4>{{ m.title }}</h4>
<div class="progress-bar">
<div
class="progress-fill"
:class="getProgressClass(getMetricValue(selectedSystem.url, 'cpu') || 0)"
:style="{ width: (getMetricValue(selectedSystem.url, 'cpu') || 0) + '%' }"
:class="progressClass(m.value)"
:style="{ width: (m.value || 0) + '%' }"
></div>
<span class="progress-text">
{{ getMetricValue(selectedSystem.url, 'cpu') !== null
? getMetricValue(selectedSystem.url, 'cpu') + '%' : '-' }}
</span>
<span class="progress-text">{{ m.value !== null ? m.value + '%' : '-' }}</span>
</div>
</div>
<div class="detail-section">
<h4>Memory Usage</h4>
<div class="progress-bar">
<div
class="progress-fill"
:class="getProgressClass(getMetricValue(selectedSystem.url, 'mem') || 0)"
:style="{ width: (getMetricValue(selectedSystem.url, 'mem') || 0) + '%' }"
></div>
<span class="progress-text">
{{ getMetricValue(selectedSystem.url, 'mem') !== null
? getMetricValue(selectedSystem.url, 'mem') + '%' : '-' }}
</span>
</div>
<div class="memory-details" v-if="detailData.mem">
<div class="memory-details" v-if="m.key === 'mem' && detailData.mem">
<small>
Used: {{ formatBytes(detailData.mem.used) }} /
Total: {{ formatBytes(detailData.mem.total) }}
Used: {{ detailData.mem.used | formatSize }} /
Total: {{ detailData.mem.total | formatSize }}
</small>
</div>
</div>
<div class="detail-section">
<h4>Disk Usage</h4>
<div class="progress-bar">
<div
class="progress-fill"
:class="getProgressClass(getMetricValue(selectedSystem.url, 'disk') || 0)"
:style="{ width: (getMetricValue(selectedSystem.url, 'disk') || 0) + '%' }"
></div>
<span class="progress-text">
{{ getMetricValue(selectedSystem.url, 'disk') !== null
? getMetricValue(selectedSystem.url, 'disk') + '%' : '-' }}
</span>
</div>
</div>
<div class="detail-section" v-if="detailData.fs && detailData.fs.length > 0">
<div class="detail-section" v-if="filteredPartitions.length > 0">
<h4>Disk Partitions</h4>
<div class="partition-list">
<div v-for="(disk, index) in detailData.fs.filter(d => d.mnt_point && d.size > 0)"
:key="index" class="partition-item">
<div v-for="(disk, index) in filteredPartitions" :key="index" class="partition-item">
<div class="partition-path">{{ disk.mnt_point }}</div>
<div class="partition-usage">
<span class="usage-text">
{{ formatBytes(disk.used || 0) }} / {{ formatBytes(disk.size || 0) }}
{{ disk.used | formatSize }} / {{ disk.size | formatSize }}
</span>
<span class="usage-percent"
:class="getUsageClass(
disk.size > 0 ? Math.round(((disk.used || 0) / disk.size) * 100)
: 0)">
({{ disk.size > 0 ? Math.round(((disk.used || 0) / disk.size) * 100) : 0 }}%)
<span class="usage-percent" :class="usageClass(diskPercent(disk))">
({{ diskPercent(disk) }}%)
</span>
</div>
</div>
@ -152,8 +108,8 @@
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { convertBytes } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
@ -161,80 +117,97 @@ export default {
return {
metricsData: {},
detailData: {},
loading: {},
errors: {},
refreshInterval: null,
selectedSystem: null,
};
},
computed: {
refreshRate() {
return this.options.refreshRate || 5000;
systems() {
if (!this.options.systems || !Array.isArray(this.options.systems)) {
this.error('You must specify a \'systems\' array for GlCompactMetrics');
return [];
}
return this.options.systems;
},
apiVersion() {
return this.options.apiVersion || 4;
},
filteredPartitions() {
if (!this.detailData.fs) return [];
return this.detailData.fs.filter(d => d.mnt_point && d.size > 0);
},
detailMetrics() {
if (!this.selectedSystem) return [];
const { url } = this.selectedSystem;
return [
{ key: 'cpu', title: 'CPU Usage', value: this.getMetricValue(url, 'cpu') },
{ key: 'mem', title: 'Memory Usage', value: this.getMetricValue(url, 'mem') },
{ key: 'disk', title: 'Disk Usage', value: this.getMetricValue(url, 'disk') },
];
},
uptimeDisplay() {
if (this.detailData.uptime && typeof this.detailData.uptime === 'string') {
return this.detailData.uptime;
}
return '-';
},
},
mounted() {
this.initializeSystems();
this.fetchAllMetrics();
this.startRefreshInterval();
},
beforeDestroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
filters: {
formatSize(bytes) {
if (!bytes || bytes === 0) return '0 Bytes';
return convertBytes(bytes);
},
},
methods: {
initializeSystems() {
this.options.systems.forEach((system) => {
this.$set(this.metricsData, system.url, {});
this.$set(this.loading, system.url, false);
this.$set(this.errors, system.url, false);
fetchData() {
this.systems.forEach((system) => {
if (!this.metricsData[system.url]) {
this.$set(this.metricsData, system.url, {});
this.$set(this.errors, system.url, false);
}
});
this.fetchAllMetrics();
},
makeSystemUrl(systemUrl, path) {
return `${systemUrl.replace(/\/$/, '')}/api/${this.apiVersion}/${path}`;
},
authHeaders() {
if (this.options.username && this.options.password) {
const creds = `${this.options.username}:${this.options.password}`;
return { Authorization: `Basic ${window.btoa(creds)}` };
}
return null;
},
async fetchAllMetrics() {
const promises = this.options.systems.map((system) => this.fetchSystemMetrics(system));
const promises = this.systems.map((system) => this.fetchSystemMetrics(system));
await Promise.allSettled(promises);
},
async fetchSystemMetrics(system) {
const { url } = system;
this.$set(this.loading, url, true);
// Don't reset error state here to prevent flickering
try {
const apiUrl = `${url.replace(/\/$/, '')}/api/4/all`;
const response = await axios.get(apiUrl, { timeout: 8000 });
this.processMetricsData(url, response.data);
// Clear error state only on successful fetch
const data = await this.makeRequest(this.makeSystemUrl(url, 'all'), this.authHeaders());
this.processMetricsData(url, data);
this.$set(this.errors, url, false);
// Update detail data if this system is currently selected
if (this.selectedSystem && this.selectedSystem.url === url) {
this.detailData = response.data;
this.detailData = data;
}
} catch (error) {
console.error(`Failed to fetch metrics for ${system.header}:`, error);
} catch (e) {
this.error(`Failed to fetch metrics for ${system.header}`, e, true);
this.$set(this.errors, url, true);
// Clear detail data if this is the selected system and it fails
if (this.selectedSystem && this.selectedSystem.url === url) {
this.detailData = {};
}
} finally {
this.$set(this.loading, url, false);
}
},
processMetricsData(systemUrl, data) {
const processedData = {};
if (data.cpu) {
processedData.cpu = Math.round(data.cpu.total || 0);
}
if (data.mem) {
processedData.mem = Math.round(data.mem.percent || 0);
}
const processed = {};
if (data.cpu) processed.cpu = Math.round(data.cpu.total || 0);
if (data.mem) processed.mem = Math.round(data.mem.percent || 0);
if (data.fs && Array.isArray(data.fs)) {
let totalSize = 0;
let totalUsed = 0;
@ -244,72 +217,58 @@ export default {
totalUsed += disk.used;
}
});
processedData.disk = totalSize > 0 ? Math.round((totalUsed / totalSize) * 100) : 0;
processed.disk = totalSize > 0 ? Math.round((totalUsed / totalSize) * 100) : 0;
}
this.$set(this.metricsData, systemUrl, processedData);
this.$set(this.metricsData, systemUrl, processed);
},
getMetricValue(systemUrl, metric, defaultValue = null) {
return this.metricsData[systemUrl]?.[metric] ?? defaultValue;
getMetricValue(systemUrl, metric) {
return this.metricsData[systemUrl]?.[metric] ?? null;
},
getMetricDisplay(systemUrl, metric) {
// Display '-' if there's an error or no data available
if (this.errors[systemUrl] || this.getMetricValue(systemUrl, metric) === null) {
return '-';
}
if (this.errors[systemUrl] || this.getMetricValue(systemUrl, metric) === null) return '-';
return `${this.getMetricValue(systemUrl, metric)}%`;
},
getMetricClass(metric, systemUrl) {
if (this.errors[systemUrl] || this.getMetricValue(systemUrl, metric) === null) {
return 'error';
}
const value = this.getMetricValue(systemUrl, metric, 0);
return this.getUsageClass(value);
if (this.errors[systemUrl] || this.getMetricValue(systemUrl, metric) === null) return 'error';
return this.usageClass(this.getMetricValue(systemUrl, metric));
},
// Three states: green (<50), orange (50-90), red (>90)
getUsageClass(percentage) {
if (percentage > 90) return 'critical';
if (percentage >= 50) return 'warning';
usageClass(pct) {
if (pct > 90) return 'critical';
if (pct >= 50) return 'warning';
return 'good';
},
getProgressClass(percentage) {
if (percentage > 90) return 'progress-critical';
if (percentage >= 50) return 'progress-warning';
progressClass(pct) {
if (pct > 90) return 'progress-critical';
if (pct >= 50) return 'progress-warning';
return 'progress-good';
},
diskPercent(disk) {
return disk.size > 0 ? Math.round(((disk.used || 0) / disk.size) * 100) : 0;
},
showSystemDetails(system) {
this.selectedSystem = system;
this.detailData = {}; // Clear previous data immediately
this.detailData = {};
this.fetchSystemDetails(system);
},
async fetchSystemDetails(system) {
try {
const apiUrl = `${system.url.replace(/\/$/, '')}/api/4/all`;
const response = await axios.get(apiUrl, { timeout: 8000 });
this.detailData = response.data;
// Clear error state on successful fetch
const data = await this.makeRequest(
this.makeSystemUrl(system.url, 'all'), this.authHeaders(),
);
this.detailData = data;
this.$set(this.errors, system.url, false);
// Try to fetch uptime data separately
try {
const uptimeUrl = `${system.url.replace(/\/$/, '')}/api/4/uptime`;
const uptimeResponse = await axios.get(uptimeUrl, { timeout: 5000 });
this.detailData.uptimeData = uptimeResponse.data;
} catch (uptimeError) {
// Uptime API unavailable, will use alternative data sources
}
} catch (error) {
console.error(`Failed to fetch details for ${system.header}:`, error);
} catch (e) {
this.error(`Failed to fetch details for ${system.header}`, e, true);
this.$set(this.errors, system.url, true);
this.detailData = {}; // Clear detail data on error
this.detailData = {};
}
},
@ -317,99 +276,31 @@ export default {
this.selectedSystem = null;
this.detailData = {};
},
startRefreshInterval() {
this.refreshInterval = setInterval(() => {
this.fetchAllMetrics();
}, this.refreshRate);
},
formatBytes(bytes) {
if (bytes == null || isNaN(bytes)) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / (k ** i)).toFixed(1))} ${sizes[i]}`;
},
formatUptime(seconds) {
if (!seconds || seconds <= 0) return '-';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days} days ${hours} hours ${minutes} minutes`;
} else if (hours > 0) {
return `${hours} hours ${minutes} minutes`;
} else {
return `${minutes} minutes`;
}
},
getUptimeDisplay() {
// Try different data sources in priority order
let uptime = null;
// 1. Dedicated uptime API data
if (this.detailData.uptimeData) {
uptime = this.detailData.uptimeData;
} else if (this.detailData.uptime) {
// 2. Uptime field (may be string format)
uptime = this.detailData.uptime;
if (typeof uptime === 'string') {
return uptime;
}
} else if (this.detailData.system && this.detailData.system.uptime) {
// 3. System object uptime
uptime = this.detailData.system.uptime;
} else if (this.detailData.boot_time) {
// 4. Calculate from boot time
const now = Date.now() / 1000;
uptime = now - this.detailData.boot_time;
}
// Format if it's a number
if (typeof uptime === 'number') {
return this.formatUptime(uptime);
}
return '-';
},
},
created() {
this.overrideUpdateInterval = 5;
},
};
</script>
<style scoped>
.metrics-wrapper {
padding: 0.8rem;
color: #fff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
<style scoped lang="scss">
.gl-compact-metrics {
color: var(--widget-text-color);
}
.widget-label {
margin-bottom: 0.8rem;
font-weight: bold;
font-size: 1.1rem;
}
/* Compact table styles */
.compact-table {
border-radius: 8px;
border-radius: var(--curve-factor);
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background: var(--widget-accent-color);
}
.table-header {
display: flex;
background: rgba(255, 255, 255, 0.15);
padding: 0.6rem 0;
font-weight: bold;
font-size: 0.85rem;
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
border-bottom: 2px solid var(--widget-accent-color);
opacity: 0.8;
}
.table-body {
@ -420,19 +311,11 @@ export default {
.table-row {
display: flex;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
border-bottom: 1px solid var(--widget-accent-color);
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.table-row:hover {
background: rgba(255, 255, 255, 0.08);
transform: translateX(2px);
}
.table-row:last-child {
border-bottom: none;
transition: background 0.2s ease;
&:hover { background: var(--widget-accent-color); }
&:last-child { border-bottom: none; }
}
.node-column {
@ -449,7 +332,6 @@ export default {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.node-name {
@ -460,7 +342,7 @@ export default {
.node-ip {
font-size: 0.7rem;
color: #aaa;
opacity: 0.6;
line-height: 1;
}
@ -468,29 +350,14 @@ export default {
font-size: 0.8rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: all 0.3s ease;
border-radius: var(--curve-factor);
&.good { color: var(--success); }
&.warning { color: var(--warning); }
&.critical { color: var(--danger); }
&.error { opacity: 0.5; }
}
.metric-value.good {
color: #4CAF50;
}
.metric-value.warning {
color: #FF9800;
}
.metric-value.critical {
color: #F44336;
}
.metric-value.error {
color: #BDBDBD;
}
/* Detail view styles */
.detail-view {
position: relative;
animation: fadeIn 0.3s ease-in-out;
}
@ -505,59 +372,41 @@ export default {
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(255, 255, 255, 0.2);
}
.detail-header h3 {
margin: 0;
font-size: 1.2rem;
font-weight: bold;
color: #fff;
border-bottom: 2px solid var(--widget-accent-color);
h3 {
margin: 0;
font-size: 1.2rem;
font-weight: bold;
}
}
.back-btn {
display: flex;
align-items: center;
gap: 0.4rem;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.2));
color: #fff;
background: var(--widget-accent-color);
color: var(--widget-text-color);
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
border-radius: var(--curve-factor);
cursor: pointer;
font-size: 0.8rem;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.back-btn:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.3));
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
&:hover { opacity: 0.8; }
}
.detail-content {
display: flex;
flex-direction: column;
gap: 1.2rem;
gap: 1rem;
}
.detail-section {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.08));
background: var(--widget-accent-color);
padding: 1rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.detail-section h4 {
margin: 0 0 0.6rem 0;
font-size: 0.95rem;
font-weight: 600;
color: #E0E0E0;
text-transform: uppercase;
letter-spacing: 0.5px;
border-radius: var(--curve-factor);
h4 {
margin: 0 0 0.6rem 0;
font-size: 0.9rem;
font-weight: 600;
opacity: 0.8;
}
}
.info-grid {
@ -572,53 +421,29 @@ export default {
align-items: center;
font-size: 0.8rem;
padding: 0.3rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
border-bottom: 1px solid var(--widget-accent-color);
}
.info-label {
color: #B0B0B0;
font-weight: 500;
}
.info-value {
color: #fff;
font-weight: 600;
}
.info-label { opacity: 0.7; }
.info-value { font-weight: 600; }
.progress-bar {
position: relative;
height: 24px;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
overflow: hidden;
margin-bottom: 0.4rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.progress-fill {
height: 100%;
transition: all 0.5s ease;
position: relative;
overflow: hidden;
transition: width 0.5s ease;
}
.progress-good {
background: #4CAF50;
}
.progress-warning {
background: #FF9800;
}
.progress-critical {
background: #F44336;
animation: criticalPulse 1.5s infinite alternate;
}
@keyframes criticalPulse {
0% { opacity: 0.8; }
100% { opacity: 1; }
}
.progress-good { background: var(--success); }
.progress-warning { background: var(--warning); }
.progress-critical { background: var(--danger); }
.progress-text {
position: absolute;
@ -627,15 +452,12 @@ export default {
transform: translate(-50%, -50%);
font-size: 0.75rem;
font-weight: 600;
color: #fff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
z-index: 10;
z-index: 1;
}
.memory-details,
.disk-details {
.memory-details {
margin-top: 0.4rem;
color: #B0B0B0;
opacity: 0.7;
}
.partition-list {
@ -648,18 +470,16 @@ export default {
.partition-item {
padding: 0.6rem;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.06));
border-radius: 8px;
border-left: 4px solid #007bff;
transition: all 0.3s ease;
background: var(--widget-background-color);
border-radius: var(--curve-factor);
border-left: 3px solid var(--widget-text-color);
}
.partition-path {
font-size: 0.8rem;
font-weight: 500;
color: #fff;
margin-bottom: 0.3rem;
font-family: 'Courier New', monospace;
font-family: var(--font-monospace);
word-break: break-all;
}
@ -670,33 +490,19 @@ export default {
font-size: 0.7rem;
}
.usage-text {
color: #ccc;
}
.usage-percent {
font-weight: 600;
}
.good {
color: #4CAF50 !important;
}
.warning {
color: #FF9800 !important;
}
.critical {
color: #F44336 !important;
&.good { color: var(--success); }
&.warning { color: var(--warning); }
&.critical { color: var(--danger); }
}
.error-message {
text-align: center;
color: #EF5350;
color: var(--danger);
padding: 2rem;
font-size: 0.9rem;
background: rgba(244, 67, 54, 0.1);
border-radius: 8px;
border: 1px solid rgba(244, 67, 54, 0.3);
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
}
</style>