346 lines
8.3 KiB
Vue
346 lines
8.3 KiB
Vue
<template>
|
|
<NuxtLayout name="default">
|
|
<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>
|
|
</NuxtLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { apiFetch } from "~/composables/useApiAuth"
|
|
import type { SystemMetrics } from "~/types/system";
|
|
|
|
definePageMeta({layout: false})
|
|
|
|
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 $fetch<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>
|