997 lines
47 KiB
PHP
997 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();
|
|
const padding = 8;
|
|
let left = x + 12;
|
|
let top = y - tooltipRect.height - 12;
|
|
if (left + tooltipRect.width + padding > rect.width) {
|
|
left = x - tooltipRect.width - 12;
|
|
}
|
|
if (top < padding) {
|
|
top = y + 12;
|
|
}
|
|
left = Math.min(Math.max(padding, left), rect.width - tooltipRect.width - padding);
|
|
top = Math.min(Math.max(padding, top), rect.height - tooltipRect.height - padding);
|
|
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();
|
|
const padding = 8;
|
|
let left = x + 12;
|
|
let top = y - tooltipRect.height - 12;
|
|
if (left + tooltipRect.width + padding > rect.width) {
|
|
left = x - tooltipRect.width - 12;
|
|
}
|
|
if (top < padding) {
|
|
top = y + 12;
|
|
}
|
|
left = Math.min(Math.max(padding, left), rect.width - tooltipRect.width - padding);
|
|
top = Math.min(Math.max(padding, top), rect.height - tooltipRect.height - padding);
|
|
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();
|
|
const padding = 8;
|
|
let left = x + 12;
|
|
let top = y - tooltipRect.height - 12;
|
|
if (left + tooltipRect.width + padding > rect.width) {
|
|
left = x - tooltipRect.width - 12;
|
|
}
|
|
if (top < padding) {
|
|
top = y + 12;
|
|
}
|
|
left = Math.min(Math.max(padding, left), rect.width - tooltipRect.width - padding);
|
|
top = Math.min(Math.max(padding, top), rect.height - tooltipRect.height - padding);
|
|
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>
|