Files
Supervisor/pages/index.vue
2026-03-19 09:29:28 +01:00

342 lines
8.2 KiB
Vue

<template>
<div class="dashboard-container">
<header class="dashboard-header">
<div>
<p class="section-kicker">Operations</p>
<h1 class="font-display text-3xl font-bold tracking-tight text-m-text">
Monitoring
</h1>
<p class="header-description">
Visualisez l'etat des applications, des sauvegardes et des ressources systeme depuis une vue unique.
</p>
</div>
</header>
<div class="content-grid">
<div class="content-main">
<section class="storage-section">
<div class="storage-section-header">
<h2 class="font-display text-xl font-semibold text-m-text">Stockage</h2>
<span class="font-mono text-[10px] uppercase tracking-widest text-m-muted">Volumes</span>
</div>
<div class="storage-grid">
<DiagramStorage
v-for="(item, idx) in diagramItems"
:key="item.key"
:item="item"
:style="{ animationDelay: `${idx * 150}ms` }"
class="animate-fade-in-up"
/>
</div>
</section>
<div class="dashboard-grid">
<div class="grid-left">
<StatusSite class="animate-fade-in-up" style="animation-delay: 100ms" />
</div>
<div class="grid-middle">
<SpeedTest class="animate-fade-in-up speedtest-card-mobile" style="animation-delay: 150ms" />
</div>
</div>
<div class="metrics-row">
<SystemMetricsChart
:metrics="systemMetrics"
:loading="systemLoading"
class="animate-fade-in-up"
style="animation-delay: 200ms"
/>
<SystemResources
:metrics="systemMetrics"
:loading="systemLoading"
class="animate-fade-in-up"
style="animation-delay: 225ms"
/>
</div>
</div>
<div class="content-aside">
<MessageDiscord class="animate-fade-in-up" style="animation-delay: 300ms" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { apiFetch } from "~/composables/useApiAuth"
import type { SystemMetrics } from "~/types/system";
type DiskSourceResult = {
key: string
label: string
ok: boolean
output: string
}
type DiskApiResponse = {
results: DiskSourceResult[]
}
type DiagramItem = {
key: string
loading: boolean
hostName: string
statusColorClass: string
chartRadius: number
chartCircumference: number
chartOffset: number
remainingPercentText: string
usedText: string
totalText: string
}
const rawResults = ref<DiskSourceResult[]>([])
const loading = ref(false)
const systemMetrics = ref<SystemMetrics | null>(null)
const systemLoading = ref(true)
const chartRadius = 52
const chartCircumference = 2 * Math.PI * chartRadius
let systemTimer: ReturnType<typeof setInterval> | null = null
const getHostName = (output: string, fallback: string) => {
const hostMatch = output.match(/Name:\s*(.+)/i)
return hostMatch?.[1]?.trim() || fallback
}
const getDiskValues = (output: string) => {
if (!output || output.startsWith("Erreur:")) return null
const availableLine = output
.split("\n")
.find((line) => line.toLowerCase().includes("espace disponible"))
const usageLine = output
.split("\n")
.find((line) => line.toLowerCase().includes("espace utilise / espace total"))
const availableRaw = availableLine?.match(/:\s*(\d+(?:[.,]\d+)?)\s*GB/i)?.[1]
const usedRaw = usageLine?.match(/:\s*(\d+(?:[.,]\d+)?)\s*GB/i)?.[1]
const totalRaw = usageLine?.match(/\/\s*(\d+(?:[.,]\d+)?)\s*GB/i)?.[1]
const availableGb = availableRaw ? Number.parseFloat(availableRaw.replace(",", ".")) : null
const usedGb = usedRaw ? Number.parseFloat(usedRaw.replace(",", ".")) : null
const totalGb = totalRaw ? Number.parseFloat(totalRaw.replace(",", ".")) : null
if (
availableGb === null ||
usedGb === null ||
totalGb === null ||
!Number.isFinite(availableGb) ||
!Number.isFinite(usedGb) ||
!Number.isFinite(totalGb) ||
totalGb <= 0
) {
return null
}
return {availableGb, usedGb, totalGb}
}
const diagramItems = computed<DiagramItem[]>(() => {
return rawResults.value.map((result) => {
const diskValues = getDiskValues(result.output)
const remainingPercent =
diskValues === null
? null
: Math.max(
0,
Math.min(100, Math.round((diskValues.availableGb / diskValues.totalGb) * 100))
)
return {
key: result.key,
loading: loading.value,
hostName: getHostName(result.output, result.label),
statusColorClass:
remainingPercent !== null && remainingPercent <= 30 ? "status-error" : "status-success",
chartRadius,
chartCircumference,
chartOffset: chartCircumference - ((remainingPercent ?? 0) / 100) * chartCircumference,
remainingPercentText:
loading.value ? "..." : remainingPercent === null ? "--%" : `${remainingPercent}%`,
usedText: loading.value ? "..." : diskValues ? `${diskValues.usedGb.toFixed(2)} GB` : "--",
totalText: loading.value ? "..." : diskValues ? `${diskValues.totalGb.toFixed(2)} GB` : "--"
}
})
})
const runScript = async () => {
loading.value = true
rawResults.value = []
try {
const output = await apiFetch<DiskApiResponse>("/api/disk")
rawResults.value = output.results
} catch {
rawResults.value = [
{
key: "error",
label: "Source indisponible",
ok: false,
output: "Erreur lors de l'opération"
}
]
} finally {
loading.value = false
}
}
const loadSystemMetrics = async () => {
try {
systemMetrics.value = await apiFetch<SystemMetrics>("/api/system")
} catch {
systemMetrics.value = null
} finally {
systemLoading.value = false
}
}
onMounted(() => {
runScript()
loadSystemMetrics()
systemTimer = setInterval(loadSystemMetrics, 2000)
})
onBeforeUnmount(() => {
if (systemTimer) {
clearInterval(systemTimer)
systemTimer = null
}
})
</script>
<style scoped>
.dashboard-container {
padding: 2rem 2.5rem;
}
.dashboard-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid rgba(80, 140, 255, 0.1);
}
.section-kicker {
margin: 0 0 0.45rem;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgb(var(--m-accent));
}
.header-description {
max-width: 62ch;
margin-top: 0.9rem;
color: rgb(var(--m-muted));
line-height: 1.65;
}
.storage-section {
margin-bottom: 1.5rem;
}
.storage-section-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.storage-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
border-radius: 18px;
background:
linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border: 1px solid rgb(var(--m-border) / 0.32);
padding: 0.85rem;
}
.content-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 1.5rem;
align-items: start;
}
.content-main {
min-width: 0;
}
.content-aside {
min-width: 0;
}
.dashboard-grid {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.metrics-row {
margin-top: 1.5rem;
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 1.5rem;
align-items: start;
}
.grid-left,
.grid-middle,
.grid-right {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
@media (max-width: 1180px) {
.content-grid {
grid-template-columns: 1fr;
}
.content-aside {
grid-column: 1 / -1;
}
}
@media (max-width: 820px) {
.dashboard-container {
padding: 4.5rem 1.25rem 1.25rem;
}
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.storage-grid,
.content-grid,
.dashboard-grid,
.metrics-row {
grid-template-columns: 1fr;
}
.speedtest-card-mobile {
order: 4;
}
.content-aside {
grid-column: auto;
order: 5;
}
}
</style>