🔀 Merge pull request #1931 from JasperX777/jasper/feature-1853-compact-glance

Add Glances multi-system monitoring widget
Fixes #1853, and thank you to #1937
This commit is contained in:
Alicia Sykes 2026-03-12 17:41:41 +00:00 committed by GitHub
commit d95c198ed2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 564 additions and 0 deletions

View file

@ -90,6 +90,7 @@ Dashy has support for displaying dynamic content in the form of widgets. There a
- [Resource Usage Alerts](#resource-usage-alerts)
- [Public & Private IP](#ip-address)
- [CPU Temperature](#cpu-temp)
- [Compact Metrics](#compact-metrics)
- **[Dynamic Widgets](#dynamic-widgets)**
- [Iframe Widget](#iframe-widget)
- [HTML Embed Widget](#html-embedded-widget)
@ -3154,6 +3155,35 @@ You'll need to enable the sensors plugin to use this widget, using: `--enable-pl
---
### Compact Metrics
A multi-system overview widget that displays CPU, memory and disk usage for multiple Glances instances in a compact table. Click on a row to see detailed metrics for that system.
#### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`systems`** | `array` | Required | An array of systems to monitor, each with `header` (display name) and `url` (Glances base URL)
**`apiVersion`** | `number` | _Optional_ | Glances API version. Defaults to `4`
**`username`** | `string` | _Optional_ | If Glances is password-protected, specify the username
**`password`** | `string` | _Optional_ | If Glances is password-protected, specify the password
#### Example
```yaml
- type: gl-compact-metrics
options:
systems:
- header: Server 1
url: http://192.168.1.10:61208
- header: Server 2
url: http://192.168.1.11:61208
- header: NAS
url: http://192.168.1.20:61208
```
---
## Dynamic Widgets
### Iframe Widget

View file

@ -0,0 +1,533 @@
<template>
<div class="gl-compact-metrics">
<!-- Compact table view -->
<div v-if="!selectedSystem" class="compact-table">
<div class="table-header">
<div class="node-column">Node</div>
<div class="metric-column">CPU</div>
<div class="metric-column">Memory</div>
<div class="metric-column">Disk</div>
</div>
<div class="table-body">
<div
v-for="(system, index) in systems"
:key="index"
class="table-row"
@click="showSystemDetails(system)"
>
<div class="node-column">
<div class="node-name">{{ system.header }}</div>
<div class="node-ip">{{ system.url.replace(/^https?:\/\//, '') }}</div>
</div>
<div class="metric-column">
<span class="metric-value" :class="getMetricClass('cpu', system.url)">
{{ getMetricDisplay(system.url, 'cpu') }}
</span>
</div>
<div class="metric-column">
<span class="metric-value" :class="getMetricClass('mem', system.url)">
{{ getMetricDisplay(system.url, 'mem') }}
</span>
</div>
<div class="metric-column">
<span class="metric-value" :class="getMetricClass('disk', system.url)">
{{ getMetricDisplay(system.url, 'disk') }}
</span>
</div>
</div>
</div>
</div>
<!-- Detail view -->
<div v-if="selectedSystem" class="detail-view">
<div class="detail-header">
<h3>{{ selectedSystem.header }}</h3>
<button class="back-btn" @click="backToCompactView">Back</button>
</div>
<div class="detail-content" v-if="!errors[selectedSystem.url]">
<div class="detail-section">
<h4>System Information</h4>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Hostname:</span>
<span class="info-value">{{ detailData.system?.hostname || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">OS:</span>
<span class="info-value">{{ detailData.system?.os_name || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">Uptime:</span>
<span class="info-value">{{ uptimeDisplay }}</span>
</div>
</div>
</div>
<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="progressClass(m.value)"
:style="{ width: (m.value || 0) + '%' }"
></div>
<span class="progress-text">{{ m.value !== null ? m.value + '%' : '-' }}</span>
</div>
<div class="memory-details" v-if="m.key === 'mem' && detailData.mem">
<small>
Used: {{ detailData.mem.used | formatSize }} /
Total: {{ detailData.mem.total | formatSize }}
</small>
</div>
</div>
<div class="detail-section" v-if="filteredPartitions.length > 0">
<h4>Disk Partitions</h4>
<div class="partition-list">
<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">
{{ disk.used | formatSize }} / {{ disk.size | formatSize }}
</span>
<span class="usage-percent" :class="usageClass(diskPercent(disk))">
({{ diskPercent(disk) }}%)
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="errors[selectedSystem.url]" class="error-message">
Unable to retrieve details for {{ selectedSystem.header }}
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { convertBytes } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
data() {
return {
metricsData: {},
detailData: {},
errors: {},
selectedSystem: null,
};
},
computed: {
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() {
const { uptime } = this.detailData;
if (uptime && typeof uptime === 'string') return uptime;
return '-';
},
},
filters: {
formatSize(bytes) {
if (!bytes) return '0 Bytes';
return convertBytes(bytes);
},
},
methods: {
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}`;
},
requestConfig(endpoint) {
const headers = {};
if (this.options.username && this.options.password) {
const creds = `${this.options.username}:${this.options.password}`;
headers.Authorization = `Basic ${window.btoa(creds)}`;
}
if (this.useProxy) {
return {
url: this.proxyReqEndpoint,
headers: { 'Target-URL': endpoint, CustomHeaders: JSON.stringify(headers) },
};
}
return { url: endpoint, headers };
},
async fetchSystemData(endpoint) {
const config = this.requestConfig(endpoint);
const response = await axios.get(config.url, {
headers: config.headers,
timeout: this.options.timeout || 8000,
});
return response.data;
},
async fetchAllMetrics() {
const promises = this.systems.map(
(system) => this.fetchSystemMetrics(system),
);
await Promise.allSettled(promises);
this.finishLoading();
},
async fetchSystemMetrics(system) {
const { url } = system;
try {
const data = await this.fetchSystemData(
this.makeSystemUrl(url, 'all'),
);
this.processMetricsData(url, data);
this.$set(this.errors, url, false);
if (this.selectedSystem && this.selectedSystem.url === url) {
this.detailData = data;
}
} catch (_) {
this.$set(this.errors, url, true);
if (this.selectedSystem && this.selectedSystem.url === url) {
this.detailData = {};
}
}
},
processMetricsData(systemUrl, data) {
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;
data.fs.forEach((disk) => {
if (disk.size && disk.used) {
totalSize += disk.size;
totalUsed += disk.used;
}
});
processed.disk = totalSize > 0
? Math.round((totalUsed / totalSize) * 100) : 0;
}
this.$set(this.metricsData, systemUrl, processed);
},
getMetricValue(systemUrl, metric) {
return this.metricsData[systemUrl]?.[metric] ?? null;
},
getMetricDisplay(systemUrl, metric) {
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';
return this.usageClass(this.getMetricValue(systemUrl, metric));
},
usageClass(pct) {
if (pct > 90) return 'critical';
if (pct >= 50) return 'warning';
return 'good';
},
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 = {};
this.fetchSystemDetails(system);
},
async fetchSystemDetails(system) {
try {
const data = await this.fetchSystemData(
this.makeSystemUrl(system.url, 'all'),
);
this.detailData = data;
this.$set(this.errors, system.url, false);
} catch (_) {
this.$set(this.errors, system.url, true);
this.detailData = {};
}
},
backToCompactView() {
this.selectedSystem = null;
this.detailData = {};
},
},
created() {
this.overrideUpdateInterval = 5;
},
};
</script>
<style scoped lang="scss">
.gl-compact-metrics {
color: var(--widget-text-color);
}
.compact-table {
border-radius: var(--curve-factor);
overflow: hidden;
background: var(--widget-accent-color);
}
.table-header {
display: flex;
padding: 0.6rem 0;
font-weight: bold;
font-size: 0.85rem;
border-bottom: 2px solid var(--widget-accent-color);
opacity: 0.8;
}
.table-body {
display: flex;
flex-direction: column;
}
.table-row {
display: flex;
padding: 0.5rem 0;
border-bottom: 1px solid var(--widget-accent-color);
cursor: pointer;
transition: background 0.2s ease;
&:hover { background: var(--widget-accent-color); }
&:last-child { border-bottom: none; }
}
.node-column {
flex: 2;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
}
.metric-column {
flex: 1;
padding: 0 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.node-name {
font-weight: 600;
font-size: 0.85rem;
line-height: 1.2;
}
.node-ip {
font-size: 0.7rem;
opacity: 0.6;
line-height: 1;
}
.metric-value {
font-size: 0.8rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: var(--curve-factor);
&.good { color: var(--success); }
&.warning { color: var(--warning); }
&.critical { color: var(--danger); }
&.error { opacity: 0.5; }
}
.detail-view {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--widget-accent-color);
h3 {
margin: 0;
font-size: 1.2rem;
font-weight: bold;
}
}
.back-btn {
background: var(--widget-accent-color);
color: var(--widget-text-color);
border: none;
padding: 0.5rem 1rem;
border-radius: var(--curve-factor);
cursor: pointer;
font-size: 0.8rem;
&:hover { opacity: 0.8; }
}
.detail-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.detail-section {
background: var(--widget-accent-color);
padding: 1rem;
border-radius: var(--curve-factor);
h4 {
margin: 0 0 0.6rem 0;
font-size: 0.9rem;
font-weight: 600;
opacity: 0.8;
}
}
.info-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.4rem;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--widget-accent-color);
}
.info-label { opacity: 0.7; }
.info-value { font-weight: 600; }
.progress-bar {
position: relative;
height: 24px;
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
overflow: hidden;
margin-bottom: 0.4rem;
}
.progress-fill {
height: 100%;
transition: width 0.5s ease;
}
.progress-good { background: var(--success); }
.progress-warning { background: var(--warning); }
.progress-critical { background: var(--danger); }
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.75rem;
font-weight: 600;
z-index: 1;
}
.memory-details {
margin-top: 0.4rem;
opacity: 0.7;
}
.partition-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 320px;
overflow-y: auto;
}
.partition-item {
padding: 0.6rem;
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;
margin-bottom: 0.3rem;
font-family: var(--font-monospace);
word-break: break-all;
}
.partition-usage {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.7rem;
}
.usage-percent {
font-weight: 600;
&.good { color: var(--success); }
&.warning { color: var(--warning); }
&.critical { color: var(--danger); }
}
.error-message {
text-align: center;
color: var(--danger);
padding: 2rem;
font-size: 0.9rem;
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
}
</style>

View file

@ -129,6 +129,7 @@ const COMPAT = {
weather: 'Weather',
'weather-forecast': 'WeatherForecast',
'xkcd-comic': 'XkcdComic',
'gl-compact-metrics': 'GlCompactMetrics',
};
export default {