Files
jabali-panel/resources/views/filament/admin/widgets/server-charts.blade.php
2026-01-30 01:46:40 +02:00

988 lines
47 KiB
PHP

<x-filament-widgets::widget
x-data="{
poller: null,
init() {
this.poller = setInterval(() => {
$wire.refreshData();
}, 60000);
$wire.refreshData();
},
stop() {
if (this.poller) {
clearInterval(this.poller);
this.poller = null;
}
}
}"
x-on:livewire:navigating.window="stop()"
x-on:beforeunload.window="stop()"
wire:poll.60s="refreshData"
>
@php
$data = $this->getData();
$cpu = $data['cpu'] ?? [];
$memory = $data['memory'] ?? [];
$disk = $data['disk'] ?? [];
$partitions = $disk['partitions'] ?? [];
$load = $data['load'] ?? [];
$uptime = $data['uptime'] ?? 'N/A';
$range = $this->range;
$historyPoints = match ($range) {
'5m' => 30,
'30m' => 180,
'day' => 24,
'week' => 28,
'month' => 30,
default => 30,
};
$historyIntervalSeconds = match ($range) {
'5m' => 10,
'30m' => 10,
'day' => 3600,
'week' => 21600,
'month' => 86400,
default => 60,
};
$historyLabelFormat = match ($range) {
'5m' => 'H:i:s',
'30m' => 'H:i',
'day' => 'H:00',
'week' => 'M d H:00',
'month' => 'M d',
default => 'H:i',
};
$cpuCores = max(1, (int)($cpu['cores'] ?? 1));
$load1 = (float)($load['1min'] ?? 0);
$load5 = (float)($load['5min'] ?? 0);
$load15 = (float)($load['15min'] ?? 0);
$ioWait = (float)($cpu['iowait'] ?? 0);
$memUsage = $memory['usage'] ?? 0;
$history = $this->getHistory(
$load1,
$ioWait,
(float) $memUsage,
(float) ($memory['swap_usage'] ?? 0),
$partitions,
);
$historyLabels = $history['labels'] ?? [];
$historyLoad = $history['load'] ?? [];
$historyIoWait = $history['iowait'] ?? [];
$historyMemory = $history['memory'] ?? [];
$historySwap = $history['swap'] ?? [];
// Memory values are in MB from agent
$memUsedGB = ($memory['used'] ?? 0) / 1024;
$memTotalGB = ($memory['total'] ?? 0) / 1024;
$swapUsedGB = ($memory['swap_used'] ?? 0) / 1024;
$swapTotalGB = ($memory['swap_total'] ?? 0) / 1024;
$hasSwap = $swapTotalGB > 0;
$diskMounts = collect($partitions)
->map(fn (array $partition) => $partition['mount'] ?? $partition['filesystem'] ?? null)
->filter()
->unique()
->values()
->all();
$diskTotals = [];
$diskUsedTotal = 0;
$diskTotal = 0;
foreach ($partitions as $partition) {
$mount = $partition['mount'] ?? $partition['filesystem'] ?? null;
if ($mount === null) {
continue;
}
$used = (float) ($partition['used'] ?? 0);
$total = (float) ($partition['total'] ?? 0);
$diskTotals[$mount] = $total > 0 ? round($total / 1024 / 1024 / 1024, 2) : 0;
$diskUsedTotal += $used;
$diskTotal += $total;
}
$diskUsedTotalGB = $diskUsedTotal > 0 ? $diskUsedTotal / 1024 / 1024 / 1024 : 0;
$diskTotalGB = $diskTotal > 0 ? $diskTotal / 1024 / 1024 / 1024 : 0;
$diskUsedSeries = [];
$diskFreeSeries = [];
foreach ($diskMounts as $mount) {
$partition = collect($partitions)->first(fn (array $row) => ($row['mount'] ?? $row['filesystem'] ?? null) === $mount);
$used = $partition ? ((float) ($partition['used'] ?? 0)) / 1024 / 1024 / 1024 : 0;
$total = $partition ? ((float) ($partition['total'] ?? 0)) / 1024 / 1024 / 1024 : 0;
$diskUsedSeries[] = round($used, 2);
$diskFreeSeries[] = round(max(0, $total - $used), 2);
}
@endphp
<style>
.server-charts-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 0;
}
@media (min-width: 640px) {
.server-charts-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.server-charts-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.chart-card {
padding: 0.5rem;
}
.chart-container {
height: 220px;
width: 100%;
position: relative;
pointer-events: auto;
overflow: visible;
}
.chart-container canvas,
.chart-container > div {
pointer-events: auto;
}
.chart-tooltip {
position: absolute;
top: 0;
left: 0;
transform: translate(-9999px, -9999px);
opacity: 0;
transition: opacity 120ms ease;
background: #ffffff;
color: #111827;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 0.4rem 0.6rem;
font-size: 0.75rem;
line-height: 1.1rem;
white-space: nowrap;
pointer-events: none;
z-index: 50;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.15);
}
.dark .chart-tooltip {
background: #111827;
color: #e5e7eb;
border-color: #374151;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
@media (min-width: 640px) {
.chart-container {
height: 240px;
}
}
@media (min-width: 1024px) {
.chart-container {
height: 260px;
}
}
.chart-label {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
margin-top: 0.5rem;
}
.chart-title {
font-weight: 600;
font-size: 0.875rem;
color: rgb(17 24 39);
}
.dark .chart-title {
color: white;
}
.chart-subtitle {
font-size: 0.75rem;
color: rgb(107 114 128);
text-align: left;
margin-top: 0.25rem;
}
.dark .chart-subtitle {
color: rgb(156 163 175);
}
</style>
<div class="mb-4 flex flex-wrap items-center gap-2">
<x-filament::button
size="sm"
:color="$range === '5m' ? 'primary' : 'gray'"
wire:click="setRange('5m')"
>
{{ __('Last 5 minutes') }}
</x-filament::button>
<x-filament::button
size="sm"
:color="$range === '30m' ? 'primary' : 'gray'"
wire:click="setRange('30m')"
>
{{ __('Last 30 minutes') }}
</x-filament::button>
<x-filament::button
size="sm"
:color="$range === 'day' ? 'primary' : 'gray'"
wire:click="setRange('day')"
>
{{ __('Last day') }}
</x-filament::button>
<x-filament::button
size="sm"
:color="$range === 'week' ? 'primary' : 'gray'"
wire:click="setRange('week')"
>
{{ __('Last week') }}
</x-filament::button>
<x-filament::button
size="sm"
:color="$range === 'month' ? 'primary' : 'gray'"
wire:click="setRange('month')"
>
{{ __('Last month') }}
</x-filament::button>
</div>
@if(!empty($data['warning']))
<div class="mb-3 text-xs text-warning-600 dark:text-warning-400">
{{ __('Agent unavailable, showing local metrics.') }}
</div>
@endif
{{-- Main Charts Grid (3 columns) --}}
<div class="server-charts-grid">
{{-- CPU Usage Chart --}}
<div class="chart-card">
<div
x-data="{
chart: null,
labels: @js($historyLabels),
series: @js($historyLoad),
ioWaitSeries: @js($historyIoWait),
value: {{ $load1 }},
ioWaitValue: {{ $ioWait }},
maxValue: {{ $cpuCores }},
maxPoints: {{ $historyPoints }},
intervalSeconds: {{ $historyIntervalSeconds }},
labelFormat: @js($historyLabelFormat),
tooltipBound: false,
tooltipEl: null,
init() {
if (!this.labels.length || !this.series.length) {
this.seedSeries();
}
this.initChart();
Livewire.on('server-charts-range-changed', (data) => {
const payload = data[0] ?? data;
if (payload?.label_format) {
this.labelFormat = payload.label_format;
}
if (payload?.interval_seconds) {
this.intervalSeconds = payload.interval_seconds;
}
this.applyHistory(payload?.history, payload?.history_points);
});
Livewire.on('server-charts-updated', (data) => {
const payload = data[0] ?? data;
if (payload?.history) {
this.applyHistory(payload.history, payload.history_points);
return;
}
const newValue = payload?.load;
const ioWaitValue = payload?.iowait;
const label = payload?.label ?? null;
if (newValue !== undefined) {
this.pushPoint(newValue, ioWaitValue, label);
}
});
},
applyHistory(history, points) {
if (!history) {
return;
}
this.labels = history.labels ?? [];
this.series = history.load ?? [];
this.ioWaitSeries = history.iowait ?? [];
if (this.ioWaitSeries.length !== this.labels.length) {
this.ioWaitSeries = this.labels.map(() => this.ioWaitValue);
}
this.maxPoints = points ?? (this.labels.length || this.maxPoints);
if (history.label_format) {
this.labelFormat = history.label_format;
}
if (history.interval_seconds) {
this.intervalSeconds = history.interval_seconds;
}
this.updateChart();
},
seedSeries() {
const now = new Date();
for (let i = this.maxPoints - 1; i >= 0; i--) {
const stamp = new Date(now.getTime() - (i * this.intervalSeconds * 1000));
this.labels.push(this.formatTime(stamp));
this.series.push(this.value);
this.ioWaitSeries.push(this.ioWaitValue);
}
},
formatTime(date) {
const pad = (value) => String(value).padStart(2, '0');
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = months[date.getMonth()];
const day = pad(date.getDate());
switch (this.labelFormat) {
case 'H:i:s':
return `${hours}:${minutes}:${seconds}`;
case 'H:00':
return `${hours}:00`;
case 'M d H:00':
return `${month} ${day} ${hours}:00`;
case 'M d':
return `${month} ${day}`;
default:
return `${hours}:${minutes}:${seconds}`;
}
},
pushPoint(value, ioWaitValue, label) {
const nextLabel = label ?? this.formatTime(new Date());
const nextIoWait = ioWaitValue ?? this.ioWaitValue;
if (this.labels.length && this.labels[this.labels.length - 1] === nextLabel) {
this.series[this.series.length - 1] = value;
this.ioWaitSeries[this.ioWaitSeries.length - 1] = nextIoWait;
this.updateChart();
return;
}
this.series.push(value);
this.ioWaitSeries.push(nextIoWait);
this.labels.push(nextLabel);
if (this.series.length > this.maxPoints) {
this.series.shift();
this.ioWaitSeries.shift();
this.labels.shift();
}
this.updateChart();
},
axisInterval() {
return this.labels.length > 0 ? Math.max(0, Math.floor(this.labels.length / 6)) : 0;
},
updateChart() {
if (!this.chart) {
return;
}
const ratio = this.maxValue > 0 ? (this.series[this.series.length - 1] / this.maxValue) * 100 : this.series[this.series.length - 1];
const color = ratio >= 90 ? '#ef4444' : (ratio >= 70 ? '#f59e0b' : '#22c55e');
const areaColor = ratio >= 90 ? 'rgba(239,68,68,0.15)' : (ratio >= 70 ? 'rgba(245,158,11,0.15)' : 'rgba(34,197,94,0.15)');
const ioWaitColor = '#38bdf8';
const loadPeak = this.series.length ? Math.max(...this.series) : 0;
const loadMax = Math.max(this.maxValue, loadPeak);
this.chart.setOption({
xAxis: { data: this.labels, axisLabel: { interval: this.axisInterval() } },
yAxis: [
{ min: 0, max: loadMax },
{ min: 0, max: 100 }
],
series: [
{
name: 'Load',
data: this.series,
yAxisIndex: 0,
lineStyle: { color: color },
itemStyle: { color: color },
areaStyle: { color: areaColor }
},
{
name: 'IO wait',
data: this.ioWaitSeries,
yAxisIndex: 1,
lineStyle: { width: 2, type: 'dashed', color: ioWaitColor },
itemStyle: { color: ioWaitColor },
symbolSize: 4,
showSymbol: true
}
]
});
},
bindTooltip() {
if (this.tooltipBound) {
return;
}
this.tooltipBound = true;
this.setupTooltip();
const dom = this.$refs.chart;
const handleMove = (event) => {
const rect = dom.getBoundingClientRect();
const x = (event.clientX ?? 0) - rect.left;
const y = (event.clientY ?? 0) - rect.top;
if (Number.isNaN(x) || Number.isNaN(y)) {
return;
}
const width = rect.width || 1;
const ratio = Math.min(1, Math.max(0, x / width));
const index = Math.round(ratio * (this.series.length - 1));
this.showTooltip(index, x, y);
};
dom.addEventListener('mousemove', handleMove);
dom.addEventListener('mouseleave', () => this.hideTooltip());
},
setupTooltip() {
if (this.tooltipEl) {
return;
}
this.tooltipEl = document.createElement('div');
this.tooltipEl.className = 'chart-tooltip';
this.$refs.chart.appendChild(this.tooltipEl);
},
showTooltip(index, x, y) {
if (!this.tooltipEl || index < 0 || index >= this.labels.length) {
return;
}
const label = this.labels[index] ?? '';
const value = Number(this.series[index] ?? 0).toFixed(2);
const ioWaitValue = Number(this.ioWaitSeries[index] ?? 0).toFixed(1);
this.tooltipEl.innerHTML = `${label}<br>Load: ${value}<br>IO wait: ${ioWaitValue}%`;
this.tooltipEl.style.opacity = '1';
const rect = this.$refs.chart.getBoundingClientRect();
const tooltipRect = this.tooltipEl.getBoundingClientRect();
let left = x + 12;
let top = y - tooltipRect.height - 12;
if (left + tooltipRect.width > rect.width) {
left = x - tooltipRect.width - 12;
}
if (top < 0) {
top = y + 12;
}
this.tooltipEl.style.transform = `translate(${left}px, ${top}px)`;
},
hideTooltip() {
if (!this.tooltipEl) {
return;
}
this.tooltipEl.style.opacity = '0';
},
initChart() {
if (typeof window.echarts === 'undefined') {
setTimeout(() => this.initChart(), 100);
return;
}
const isDark = document.documentElement.classList.contains('dark');
const ratio = this.maxValue > 0 ? (this.value / this.maxValue) * 100 : this.value;
const color = ratio >= 90 ? '#ef4444' : (ratio >= 70 ? '#f59e0b' : '#22c55e');
const areaColor = ratio >= 90 ? 'rgba(239,68,68,0.15)' : (ratio >= 70 ? 'rgba(245,158,11,0.15)' : 'rgba(34,197,94,0.15)');
const ioWaitColor = '#38bdf8';
this.chart = echarts.init(this.$refs.chart);
this.chart.setOption({
grid: { left: 36, right: 36, top: 24, bottom: 28 },
tooltip: { show: false },
xAxis: {
type: 'category',
boundaryGap: false,
data: this.labels,
axisLabel: {
color: isDark ? '#9ca3af' : '#6b7280',
fontSize: 10,
interval: this.axisInterval()
},
axisLine: { lineStyle: { color: isDark ? '#374151' : '#e5e7eb' } },
axisTick: { show: false }
},
yAxis: [
{
type: 'value',
min: 0,
max: (value) => Math.max(this.maxValue, value.max),
axisLabel: { color: isDark ? '#9ca3af' : '#6b7280', fontSize: 10 },
splitLine: { lineStyle: { color: isDark ? '#374151' : '#f3f4f6' } }
},
{
type: 'value',
min: 0,
max: 100,
axisLabel: { color: isDark ? '#9ca3af' : '#6b7280', fontSize: 10, formatter: '{value}%' },
splitLine: { show: false },
axisLine: { lineStyle: { color: isDark ? '#374151' : '#e5e7eb' } },
axisTick: { show: false }
}
],
series: [
{
name: 'Load',
type: 'line',
data: this.series,
smooth: true,
showSymbol: true,
symbolSize: 4,
lineStyle: { width: 2, color: color },
itemStyle: { color: color },
areaStyle: { color: areaColor }
},
{
name: 'IO wait',
type: 'line',
data: this.ioWaitSeries,
smooth: true,
showSymbol: true,
symbolSize: 4,
yAxisIndex: 1,
lineStyle: { width: 2, type: 'dashed', color: ioWaitColor },
itemStyle: { color: ioWaitColor }
}
]
});
this.bindTooltip();
window.addEventListener('resize', () => this.chart?.resize());
},
destroy() {
this.chart?.dispose();
}
}"
wire:ignore
>
<div x-ref="chart" class="chart-container"></div>
</div>
<div class="chart-label">
<x-filament::icon icon="heroicon-o-chart-bar" class="h-5 w-5 text-primary-500" />
<span class="chart-title">{{ __('Load') }}</span>
</div>
<div class="chart-subtitle">{{ $cpuCores }} {{ __('cores') }} · {{ __('Load') }}: {{ $load1 }} / {{ $load5 }} / {{ $load15 }}</div>
</div>
{{-- Memory Usage Chart --}}
<div class="chart-card">
<div
x-data="{
chart: null,
labels: @js($historyLabels),
series: @js($historyMemory),
swapSeries: @js($historySwap),
value: {{ $memUsage }},
swapValue: {{ $memory['swap_usage'] ?? 0 }},
hasSwap: {{ $hasSwap ? 'true' : 'false' }},
maxValue: 100,
maxPoints: {{ $historyPoints }},
intervalSeconds: {{ $historyIntervalSeconds }},
labelFormat: @js($historyLabelFormat),
tooltipBound: false,
init() {
if (!this.labels.length || !this.series.length) {
this.seedSeries();
}
this.initChart();
Livewire.on('server-charts-range-changed', (data) => {
const payload = data[0] ?? data;
if (payload?.label_format) {
this.labelFormat = payload.label_format;
}
if (payload?.interval_seconds) {
this.intervalSeconds = payload.interval_seconds;
}
this.applyHistory(payload?.history, payload?.history_points);
});
Livewire.on('server-charts-updated', (data) => {
const payload = data[0] ?? data;
if (payload?.history) {
this.applyHistory(payload.history, payload.history_points);
return;
}
const newValue = payload?.memory;
const swapValue = payload?.swap;
const label = payload?.label ?? null;
if (newValue !== undefined) {
this.pushPoint(newValue, swapValue, label);
}
});
},
applyHistory(history, points) {
if (!history) {
return;
}
this.labels = history.labels ?? [];
this.series = history.memory ?? [];
this.swapSeries = history.swap ?? [];
if (this.hasSwap && this.swapSeries.length !== this.labels.length) {
this.swapSeries = this.labels.map(() => 0);
}
this.maxPoints = points ?? (this.labels.length || this.maxPoints);
this.updateChart();
},
seedSeries() {
const now = new Date();
for (let i = this.maxPoints - 1; i >= 0; i--) {
const stamp = new Date(now.getTime() - (i * this.intervalSeconds * 1000));
this.labels.push(this.formatTime(stamp));
this.series.push(this.value);
if (this.hasSwap) {
this.swapSeries.push(this.swapValue);
}
}
},
formatTime(date) {
const pad = (value) => String(value).padStart(2, '0');
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = months[date.getMonth()];
const day = pad(date.getDate());
switch (this.labelFormat) {
case 'H:i:s':
return `${hours}:${minutes}:${seconds}`;
case 'H:00':
return `${hours}:00`;
case 'M d H:00':
return `${month} ${day} ${hours}:00`;
case 'M d':
return `${month} ${day}`;
default:
return `${hours}:${minutes}:${seconds}`;
}
},
pushPoint(value, swapValue, label) {
const nextLabel = label ?? this.formatTime(new Date());
const nextSwap = swapValue ?? this.swapValue ?? 0;
if (this.labels.length && this.labels[this.labels.length - 1] === nextLabel) {
this.series[this.series.length - 1] = value;
if (this.hasSwap) {
this.swapSeries[this.swapSeries.length - 1] = nextSwap;
}
this.updateChart();
return;
}
this.series.push(value);
this.labels.push(nextLabel);
if (this.hasSwap) {
this.swapSeries.push(nextSwap);
}
if (this.series.length > this.maxPoints) {
this.series.shift();
this.labels.shift();
if (this.hasSwap) {
this.swapSeries.shift();
}
}
this.updateChart();
},
axisInterval() {
return this.labels.length > 0 ? Math.max(0, Math.floor(this.labels.length / 6)) : 0;
},
updateChart() {
if (!this.chart) {
return;
}
const value = this.series[this.series.length - 1];
const color = value >= 90 ? '#ef4444' : (value >= 70 ? '#f59e0b' : '#22c55e');
const areaColor = value >= 90 ? 'rgba(239,68,68,0.15)' : (value >= 70 ? 'rgba(245,158,11,0.15)' : 'rgba(34,197,94,0.15)');
const seriesOptions = [{
name: 'Memory',
data: this.series,
lineStyle: { color: color },
itemStyle: { color: color },
areaStyle: { color: areaColor }
}];
if (this.hasSwap) {
seriesOptions.push({
name: 'Swap',
data: this.swapSeries,
lineStyle: { color: '#3b82f6', type: 'dashed' },
itemStyle: { color: '#3b82f6' },
areaStyle: { color: 'rgba(59,130,246,0.12)' }
});
}
this.chart.setOption({
xAxis: { data: this.labels, axisLabel: { interval: this.axisInterval() } },
series: seriesOptions
});
},
bindTooltip() {
if (this.tooltipBound) {
return;
}
this.tooltipBound = true;
this.setupTooltip();
const dom = this.$refs.chart;
const handleMove = (event) => {
const rect = dom.getBoundingClientRect();
const x = (event.clientX ?? 0) - rect.left;
const y = (event.clientY ?? 0) - rect.top;
if (Number.isNaN(x) || Number.isNaN(y)) {
return;
}
const width = rect.width || 1;
const ratio = Math.min(1, Math.max(0, x / width));
const index = Math.round(ratio * (this.series.length - 1));
this.showTooltip(index, x, y);
};
dom.addEventListener('mousemove', handleMove);
dom.addEventListener('mouseleave', () => this.hideTooltip());
},
setupTooltip() {
if (this.tooltipEl) {
return;
}
this.tooltipEl = document.createElement('div');
this.tooltipEl.className = 'chart-tooltip';
this.$refs.chart.appendChild(this.tooltipEl);
},
showTooltip(index, x, y) {
if (!this.tooltipEl || index < 0 || index >= this.labels.length) {
return;
}
const label = this.labels[index] ?? '';
const memoryValue = Number(this.series[index] ?? 0).toFixed(1);
let lines = `${label}<br>Memory: ${memoryValue}%`;
if (this.hasSwap) {
const swapValue = Number(this.swapSeries[index] ?? 0).toFixed(1);
lines += `<br>Swap: ${swapValue}%`;
}
this.tooltipEl.innerHTML = lines;
this.tooltipEl.style.opacity = '1';
const rect = this.$refs.chart.getBoundingClientRect();
const tooltipRect = this.tooltipEl.getBoundingClientRect();
let left = x + 12;
let top = y - tooltipRect.height - 12;
if (left + tooltipRect.width > rect.width) {
left = x - tooltipRect.width - 12;
}
if (top < 0) {
top = y + 12;
}
this.tooltipEl.style.transform = `translate(${left}px, ${top}px)`;
},
hideTooltip() {
if (!this.tooltipEl) {
return;
}
this.tooltipEl.style.opacity = '0';
},
initChart() {
if (typeof window.echarts === 'undefined') {
setTimeout(() => this.initChart(), 100);
return;
}
const isDark = document.documentElement.classList.contains('dark');
const color = this.value >= 90 ? '#ef4444' : (this.value >= 70 ? '#f59e0b' : '#22c55e');
const areaColor = this.value >= 90 ? 'rgba(239,68,68,0.15)' : (this.value >= 70 ? 'rgba(245,158,11,0.15)' : 'rgba(34,197,94,0.15)');
const seriesOptions = [{
name: 'Memory',
type: 'line',
data: this.series,
smooth: true,
showSymbol: true,
symbolSize: 4,
lineStyle: { width: 2, color: color },
itemStyle: { color: color },
areaStyle: { color: areaColor }
}];
if (this.hasSwap) {
seriesOptions.push({
name: 'Swap',
type: 'line',
data: this.swapSeries,
smooth: true,
showSymbol: true,
symbolSize: 4,
lineStyle: { width: 2, color: '#3b82f6', type: 'dashed' },
itemStyle: { color: '#3b82f6' },
areaStyle: { color: 'rgba(59,130,246,0.12)' }
});
}
this.chart = echarts.init(this.$refs.chart);
this.chart.setOption({
grid: { left: 36, right: 16, top: 24, bottom: 28 },
tooltip: { show: false },
xAxis: {
type: 'category',
boundaryGap: false,
data: this.labels,
axisLabel: {
color: isDark ? '#9ca3af' : '#6b7280',
fontSize: 10,
interval: this.axisInterval()
},
axisLine: { lineStyle: { color: isDark ? '#374151' : '#e5e7eb' } },
axisTick: { show: false }
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { color: isDark ? '#9ca3af' : '#6b7280', fontSize: 10 },
splitLine: { lineStyle: { color: isDark ? '#374151' : '#f3f4f6' } }
},
series: seriesOptions
});
this.bindTooltip();
window.addEventListener('resize', () => this.chart?.resize());
},
destroy() {
this.chart?.dispose();
}
}"
wire:ignore
>
<div x-ref="chart" class="chart-container"></div>
</div>
<div class="chart-label">
<x-filament::icon icon="heroicon-o-server" class="h-5 w-5 text-info-500" />
<span class="chart-title">{{ __('Memory') }}</span>
</div>
<div class="chart-subtitle">
{{ number_format($memUsedGB, 1) }} GB {{ __('used') }} / {{ number_format($memTotalGB, 1) }} GB {{ __('total') }}
@if($hasSwap)
· {{ __('Swap') }} {{ number_format($swapUsedGB, 1) }} GB / {{ number_format($swapTotalGB, 1) }} GB
@endif
</div>
</div>
{{-- Disk Usage Chart --}}
@if(!empty($diskMounts))
<div class="chart-card">
<div
x-data="{
chart: null,
mounts: @js($diskMounts),
usedSeries: @js($diskUsedSeries),
freeSeries: @js($diskFreeSeries),
totals: @js($diskTotals),
labels: {
used: @js(__('used')),
free: @js(__('free')),
total: @js(__('total')),
},
tooltipBound: false,
tooltipEl: null,
init() {
this.initChart();
},
withOpacity(color, opacity) {
const hex = color.replace('#', '');
const bigint = parseInt(hex, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
},
bindTooltip() {
if (this.tooltipBound) {
return;
}
this.tooltipBound = true;
this.setupTooltip();
const dom = this.$refs.chart;
const handleMove = (event) => {
const rect = dom.getBoundingClientRect();
const x = (event.clientX ?? 0) - rect.left;
const y = (event.clientY ?? 0) - rect.top;
if (Number.isNaN(x) || Number.isNaN(y)) {
return;
}
const width = rect.width || 1;
const ratio = Math.min(1, Math.max(0, x / width));
const index = Math.round(ratio * (this.mounts.length - 1));
this.showTooltip(index, x, y);
};
dom.addEventListener('mousemove', handleMove);
dom.addEventListener('mouseleave', () => this.hideTooltip());
},
setupTooltip() {
if (this.tooltipEl) {
return;
}
this.tooltipEl = document.createElement('div');
this.tooltipEl.className = 'chart-tooltip';
this.$refs.chart.appendChild(this.tooltipEl);
},
showTooltip(index, x, y) {
if (!this.tooltipEl || index < 0 || index >= this.mounts.length) {
return;
}
const mount = this.mounts[index] ?? '';
const used = Number(this.usedSeries[index] ?? 0);
const free = Number(this.freeSeries[index] ?? 0);
const total = Number(this.totals[mount] ?? (used + free));
this.tooltipEl.innerHTML = `<strong>${mount}</strong><br>${used.toFixed(2)} GB ${this.labels.used}<br>${free.toFixed(2)} GB ${this.labels.free}<br>${total.toFixed(2)} GB ${this.labels.total}`;
this.tooltipEl.style.opacity = '1';
const rect = this.$refs.chart.getBoundingClientRect();
const tooltipRect = this.tooltipEl.getBoundingClientRect();
let left = x + 12;
let top = y - tooltipRect.height - 12;
if (left + tooltipRect.width > rect.width) {
left = x - tooltipRect.width - 12;
}
if (top < 0) {
top = y + 12;
}
this.tooltipEl.style.transform = `translate(${left}px, ${top}px)`;
},
hideTooltip() {
if (!this.tooltipEl) {
return;
}
this.tooltipEl.style.opacity = '0';
},
initChart() {
if (typeof window.echarts === 'undefined') {
setTimeout(() => this.initChart(), 100);
return;
}
const isDark = document.documentElement.classList.contains('dark');
this.chart = echarts.init(this.$refs.chart);
this.chart.setOption({
grid: { left: 48, right: 16, top: 24, bottom: 32 },
tooltip: { show: false },
xAxis: {
type: 'category',
boundaryGap: true,
data: this.mounts,
axisLabel: {
color: isDark ? '#9ca3af' : '#6b7280',
fontSize: 10,
},
axisLine: { lineStyle: { color: isDark ? '#374151' : '#e5e7eb' } },
axisTick: { show: false }
},
yAxis: {
type: 'value',
min: 0,
axisLabel: { color: isDark ? '#9ca3af' : '#6b7280', fontSize: 10, formatter: '{value} GB' },
splitLine: { lineStyle: { color: isDark ? '#374151' : '#f3f4f6' } }
},
series: [
{
name: 'Used',
type: 'bar',
stack: 'disk',
data: this.usedSeries,
barMaxWidth: 32,
itemStyle: { color: '#f59e0b' },
},
{
name: 'Free',
type: 'bar',
stack: 'disk',
data: this.freeSeries,
barMaxWidth: 32,
itemStyle: { color: this.withOpacity('#22c55e', 0.35) },
}
]
});
this.bindTooltip();
window.addEventListener('resize', () => this.chart?.resize());
},
destroy() {
this.chart?.dispose();
}
}"
wire:ignore
>
<div x-ref="chart" class="chart-container"></div>
</div>
<div class="chart-label">
<x-filament::icon icon="heroicon-o-circle-stack" class="h-5 w-5 text-warning-500" />
<span class="chart-title">{{ __('Disk Usage') }}</span>
</div>
<div class="chart-subtitle">{{ number_format($diskUsedTotalGB, 1) }} GB {{ __('used') }} / {{ number_format($diskTotalGB, 1) }} GB {{ __('total') }}</div>
</div>
@endif
</div>
</x-filament-widgets::widget>