Files
Supervisor/pages/index.vue
kevin 92ed9b040f Merge branch 'develop' into feat/add-module-lint
# Conflicts:
#	components/BackupRun.vue
#	composables/useApiAuth.ts
#	pages/index.vue
#	server/api/disk.get.ts
2026-03-13 10:03:18 +01:00

284 lines
6.6 KiB
Vue

<template>
<NuxtLayout name="default">
<div class="dashboard-container">
<header class="dashboard-header">
<div>
<h1 class="font-display text-3xl font-bold tracking-tight text-m-text">
Monitoring
</h1>
</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>
<div class="content-aside">
<MessageDiscord class="animate-fade-in-up" style="animation-delay: 300ms" />
</div>
</div>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import {computed, onMounted, ref} from "vue"
definePageMeta({layout: false})
import { apiFetch } from "~/composables/useApiAuth"
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 chartRadius = 52
const chartCircumference = 2 * Math.PI * chartRadius
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 (error) {
rawResults.value = [
{
key: "error",
label: "Source indisponible",
ok: false,
output: "Erreur lors de l'opération"
}
]
} finally {
loading.value = false
}
}
onMounted(() => {
runScript()
})
</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.5rem;
border-bottom: 1px solid rgba(80, 140, 255, 0.08);
}
.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: 12px;
background: rgb(var(--m-secondary));
padding: 0.75rem;
}
.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;
}
.grid-left,
.grid-middle,
.grid-right {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@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 {
grid-template-columns: 1fr;
}
.backup-selector {
order: 2;
}
.backup-list-mobile {
order: 3;
}
.speedtest-card-mobile {
order: 4;
}
.content-aside {
grid-column: auto;
order: 5;
}
}
</style>