feat : ajout page monitoring

This commit is contained in:
2026-03-06 15:26:51 +01:00
parent 993524aa72
commit 8b5d4e9655
21 changed files with 11098 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

54
AGENTS.md Normal file
View File

@@ -0,0 +1,54 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is a Nuxt 4 application.
- `pages/`: route pages (for example `pages/index.vue`).
- `components/`: reusable Vue components (for example `DiskSidebarWidget.vue`, `ApiStatusBubble.vue`).
- `layouts/`: shared page shells (`layouts/default.vue`).
- `server/api/`: Nitro server endpoints (for example `disk.get.ts`, `version-status.get.ts`).
- `assets/css/`: global styles and theme tokens (`main.css`, `malio.css`).
- `public/`: static files served as-is.
Keep feature logic close to usage: UI in `components/`, page composition in `pages/`, backend checks in `server/api/`.
## Build, Test, and Development Commands
Use npm scripts from `package.json`:
- `npm run dev`: start local dev server with hot reload.
- `npm run build`: production build (client + server).
- `npm run preview`: run the built app locally.
- `npm run generate`: static generation when needed.
There is no dedicated test script currently. At minimum, run `npm run build` before opening a PR.
## Coding Style & Naming Conventions
- Language: TypeScript + Vue SFCs (`<script setup lang="ts">`).
- Indentation: 2 spaces.
- Prefer double quotes in TS files to match current codebase.
- Components: PascalCase file names (`ApiStatusBubble.vue`).
- API handlers: kebab-case + HTTP suffix (`version-status.get.ts`).
- Use Tailwind utility classes and project color tokens (`text-m-error`, `bg-m-success`, `text-m-primary`).
## Testing Guidelines
No automated framework is configured yet. Use these checks:
- Build validation: `npm run build`.
- Manual smoke test: open `/`, verify API status cards refresh and disk sidebar renders.
- For new endpoints, validate response shape in browser/network tab or `curl`.
## Commit & Pull Request Guidelines
Git history is not available in this workspace snapshot, so use this convention:
- Commit format: `type(scope): short summary`.
- Example: `feat(api): add labeled multi-endpoint status check`.
- Types: `feat`, `fix`, `refactor`, `style`, `docs`, `chore`.
PRs should include:
- What changed and why.
- Affected paths (e.g. `server/api/version-status.get.ts`).
- UI screenshots for visual changes.
- Verification steps (commands run, expected result).
## Security & Configuration Notes
- Do not hardcode secrets in source.
- Prefer environment variables for private endpoints/tokens.
- Keep external API checks server-side (`server/api/*`) to avoid exposing sensitive details in the client.
Ne lance pas de build si je le dit pas

5
app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

27
assets/css/main.css Normal file
View File

@@ -0,0 +1,27 @@
@import "tailwindcss";
@import "./malio.css";
@theme {
--color-panel: #0f172a;
--color-panel-soft: #1e293b;
--color-m-primary: rgb(var(--m-primary));
--color-m-secondary: rgb(var(--m-secondary));
--color-m-tertiary: rgb(var(--m-tertiary));
--color-m-border: rgb(var(--m-border));
--color-m-text: rgb(var(--m-text));
--color-m-muted: rgb(var(--m-muted));
--color-m-bg: rgb(var(--m-bg));
--color-m-error: rgb(var(--m-error));
--color-m-success: rgb(var(--m-success));
/* Alias pour la faute de frappe courante "m-succes" */
--color-m-succes: rgb(var(--m-success));
}
@layer utilities {
.bg-grid {
background-image:
linear-gradient(to right, rgba(148, 163, 184, 0.12) 1px, transparent 1px),
linear-gradient(to bottom, rgba(148, 163, 184, 0.12) 1px, transparent 1px);
background-size: 32px 32px;
}
}

19
assets/css/malio.css Normal file
View File

@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Couleurs en RGB “space separated” pour Tailwind */
--m-primary: 34 39 131; /* Couleur principal*/
--m-secondary: 48 73 152; /* Couleur secondaire */
--m-tertiary: 243 244 248; /* Couleur tertiaire (background) */
--m-border: 203 213 225; /* Couleur des bordures */
--m-text: 15 23 42; /* Couleur du texte */
--m-muted: 100 116 139; /* Couleur pour les éléments désactivés ou secondaires */
--m-bg: 243 244 248; /* Couleur de fond générale */
--m-error: 212 0 21; /* rouge pour les erreurs */
--m-success: 15 200 73; /* vert pour les succès */
}
}

View File

@@ -0,0 +1,50 @@
<template>
<section class="flex flex-col items-center p-4">
<p class="text-center text-xl font-semibold uppercase">{{ hostName }}</p>
<div class="relative h-[140px] w-[140px]" :class="statusColorClass">
<svg class="h-full w-full -rotate-90" viewBox="0 0 120 120" aria-label="Pourcentage restant">
<circle
class="fill-none stroke-[rgba(255,255,255,0.22)] [stroke-width:10]"
cx="60"
cy="60"
:r="chartRadius"
/>
<circle
class="fill-none stroke-[currentColor] [stroke-linecap:round] [stroke-width:10] transition-[stroke-dashoffset] duration-300"
cx="60"
cy="60"
:r="chartRadius"
:style="{ strokeDasharray: `${chartCircumference}`, strokeDashoffset: `${chartOffset}` }"
/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<strong class="text-2xl leading-none">{{ remainingPercentText }}</strong>
</div>
</div>
<p class="mt-1 text-center text-sm font-semibold">{{ usedText }} / {{ totalText }}</p>
</section>
</template>
<script setup lang="ts">
defineProps<{
hostName: string
statusColorClass: string
chartRadius: number
chartCircumference: number
chartOffset: number
remainingPercentText: string
usedText: string
totalText: string
}>()
</script>
<style scoped>
.m-success {
color: rgb(var(--m-success));
}
.m-error {
color: rgb(var(--m-error));
}
</style>

131
components/Speedtest.vue Normal file
View File

@@ -0,0 +1,131 @@
<template>
<div class="bg-m-secondary w-[509px] h-[184px] mx-4 rounded-md shadow-md/50 shadow-black p-2">
<div class="mb-2 flex items-center justify-between">
<p class="font-bold text-3xl text-m-tertiary">
Speedtest
</p>
<IconifyIcon
icon="mdi:reload"
class="bg-m-tertiary text-2xl text-black rounded-md shadow-md/50 mr-1 cursor-pointer"
@Click="runTests"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div class="flex items-center justify-center">
<IconifyIcon
icon="mdi:download"
class="text-m-primary text-2xl mt-2 ml-1"
/>
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
download
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<span class="text-4xl">
{{ download !== null ? `${download}` : "..." }}
</span>
<p class="font-bold text-xl leading-tight">
Mbps
</p>
</div>
</div>
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div class="flex items-center justify-center">
<IconifyIcon
icon="mdi:upload"
class="text-m-primary text-2xl mt-2 ml-1"
/>
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
upload
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<span class="text-4xl">
{{ upload !== null ? `${upload}` : "..." }}
</span>
<p class="font-bold text-xl leading-tight">
Mbps
</p>
</div>
</div>
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div class="flex items-center justify-center">
<IconifyIcon
icon="mdi:wifi"
class="text-m-primary text-2xl mt-2 ml-1"
/>
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
ping
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<span class="text-4xl">
{{ ping !== null ? `${ping}` : "..." }}
</span>
<p class="font-bold text-xl leading-tight">
Ms
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {onMounted, ref} from "vue";
import {Icon as IconifyIcon} from "@iconify/vue"
const ping = ref<number | null>(null)
const download = ref<number | null>(null)
const upload = ref<number | null>(null)
async function testDownload() {
const start = performance.now()
const res = await fetch('/api/download')
const blob = await res.blob()
const end = performance.now()
const size = blob.size
const seconds = (end - start) / 1000
download.value = Math.round((size * 8) / seconds / 1000000)
}
async function testUpload() {
const size = 5 * 1024 * 1024
const data = new Uint8Array(size)
const start = performance.now()
await fetch('/api/upload', {
method: 'POST',
body: data
})
const end = performance.now()
const seconds = (end - start) / 1000
upload.value = Math.round((size * 8) / seconds / 1000000)
}
async function testPing() {
const start = performance.now()
await fetch('/api/ping')
const end = performance.now()
ping.value = Math.round(end - start)
}
async function runTests() {
await testDownload()
await testUpload()
await testPing()
}
onMounted(() => {
runTests()
})
</script>

98
components/StatusSite.vue Normal file
View File

@@ -0,0 +1,98 @@
<template>
<div class="bg-m-secondary w-[250px] h-auto rounded-md mx-4 shadow-md/50 shadow-black pb-4">
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3">
Status
</p>
<div
class="bg-m-tertiary w-[200px] h-auto rounded-md shadow-md/50 shadow-m-black mx-[25px] mb-3"
v-for="row in rows"
:key="`${row.label}-${row.url}`"
>
<p class="font-bold text-xl text-m-text mt-2 mx-2 mb-1">
{{ row.label }}
</p>
<div class="mx-2 flex items-center">
<span
class="inline-block h-[24px] w-[24px] rounded-full mr-2"
:class="statusClass(row.status)"
/>
<span class="font-semibold text-lg">
{{ statusLabel(row.status) }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {onBeforeUnmount, onMounted, ref} from "vue"
interface StatusRow {
label: string
url: string
ok: boolean
status: number
checkedAt: string
error?: string
}
interface StatusResponse {
results: StatusRow[]
}
const props = withDefaults(
defineProps<{
endpoint?: string
refreshMs?: number
}>(),
{
endpoint: "/api/version-status",
refreshMs: 30000
}
)
const rows = ref<StatusRow[]>([])
let timer: ReturnType<typeof setInterval> | null = null
const statusClass = (status: number) => {
if (status === 200) return "bg-m-success"
if (status === 0) return "bg-m-error"
return "bg-m-error"
}
const statusLabel = (status: number) => {
if (status === 200) return "HTTP 200"
if (status === 0) return "Injoignable"
return `KO (HTTP ${status})`
}
const checkStatus = async () => {
try {
const data = await $fetch<StatusResponse>(props.endpoint)
rows.value = data.results
} catch (error) {
rows.value = [
{
label: "Erreur",
url: props.endpoint,
ok: false,
status: 0,
checkedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error)
}
]
}
}
onMounted(() => {
checkStatus()
timer = setInterval(checkStatus, props.refreshMs)
})
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>

41
layouts/default.vue Normal file
View File

@@ -0,0 +1,41 @@
<template>
<div class="page-layout">
<aside class="sidebar">
<div class="flex items-center gap-3 px-14 py-4">
<div class="avatar">
<div class="h-[155px] w-[155px]">
<img
:src="logoSrc"
alt="Logo Malio"
class="h-full w-full object-contain"
/>
</div>
</div>
</div>
<slot name="sidebar" />
</aside>
<main class="content">
<slot />
</main>
</div>
</template>
<script setup lang="ts">
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
</script>
<style scoped>
.page-layout {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
background-color: rgb(var(--m-primary));
color: white;
}
.content {
background-color: rgb(var(--m-tertiary));
}
</style>

11
nuxt.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import tailwindcss from "@tailwindcss/vite"
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
css: ["~/assets/css/main.css"],
vite: {
plugins: [tailwindcss()]
}
})

10312
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "disk-monitor",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@iconify/vue": "^5.0.0",
"nuxt": "^4.3.1",
"vue": "^3.5.29",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"tailwindcss": "^4.2.1"
}
}

163
pages/index.vue Normal file
View File

@@ -0,0 +1,163 @@
<template>
<NuxtLayout name="default">
<template #sidebar>
<div class="flex flex-col gap-">
<DiagramStorage
v-for="item in diagramItems"
:key="item.key"
:host-name="item.hostName"
:status-color-class="item.statusColorClass"
:chart-radius="chartRadius"
:chart-circumference="chartCircumference"
:chart-offset="item.chartOffset"
:remaining-percent-text="item.remainingPercentText"
:used-text="item.usedText"
:total-text="item.totalText"
/>
</div>
</template>
<p class="font-bold text-4xl my-6 mx-4">Écran de monitoring</p>
<div class="flex">
<StatusSite/>
<Speedtest/>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import Speedtest from "~/components/Speedtest.vue";
definePageMeta({layout: false})
import {computed, onMounted, ref} from "vue"
type SourceKey = "remote" | "local"
type DiskCommandResult = { ok: boolean; output: string }
type DiskApiResponse = {
remote?: string | DiskCommandResult
local?: string | DiskCommandResult
}
const rawResults = ref<Record<SourceKey, string>>({
remote: "",
local: ""
})
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 getOutputText = (entry: unknown) => {
if (typeof entry === "string") return entry
if (entry && typeof entry === "object" && "output" in entry) {
const output = (entry as DiskCommandResult).output
return typeof output === "string" ? output : String(output)
}
return ""
}
const diagramItems = computed(() => {
return [
{ key: "remote" as const, fallbackHost: "Serveur distant", output: rawResults.value.remote },
{ key: "local" as const, fallbackHost: "Machine locale", output: rawResults.value.local }
].map((item) => {
const diskValues = getDiskValues(item.output)
const remainingPercent =
diskValues === null
? null
: Math.max(
0,
Math.min(100, Math.round((diskValues.availableGb / diskValues.totalGb) * 100))
)
const chartOffset = chartCircumference - ((remainingPercent ?? 0) / 100) * chartCircumference
const statusColorClass =
remainingPercent !== null && remainingPercent <= 30 ? "m-error" : "m-success"
return {
key: item.key,
hostName: getHostName(item.output, item.fallbackHost),
statusColorClass,
chartOffset,
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 = {
remote: "",
local: ""
}
try {
const output = await $fetch<DiskApiResponse | string>("/api/disk")
if (typeof output === "string") {
rawResults.value = {
remote: output,
local: "Erreur: sortie locale indisponible"
}
return
}
rawResults.value = {
remote: getOutputText(output.remote),
local: getOutputText(output.local)
}
} catch (error) {
const message = `Erreur: ${error instanceof Error ? error.message : String(error)}`
rawResults.value = {
remote: message,
local: message
}
} finally {
loading.value = false
}
}
onMounted(() => {
runScript()
})
</script>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

45
server/api/disk.get.ts Normal file
View File

@@ -0,0 +1,45 @@
import { exec } from "child_process"
const remoteCommand =
process.env.DISK_REMOTE_COMMAND ||
"ssh malio-b@192.168.0.179 'cd /home/malio-b/Scripts-Serveur && bash check_storage.sh && exit'"
const localCommand =
process.env.DISK_LOCAL_COMMAND ||
"bash /home/kevin/check_storage.sh"
function runCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout)
})
})
}
export default defineEventHandler(async () => {
const [remoteResult, localResult] = await Promise.allSettled([
runCommand(remoteCommand),
runCommand(localCommand)
])
return {
remote: {
ok: remoteResult.status === "fulfilled",
output:
remoteResult.status === "fulfilled"
? remoteResult.value
: `Erreur: ${String(remoteResult.reason)}`
},
local: {
ok: localResult.status === "fulfilled",
output:
localResult.status === "fulfilled"
? localResult.value
: `Erreur: ${String(localResult.reason)}`
}
}
})

View File

@@ -0,0 +1,25 @@
import { Readable } from "node:stream"
export default defineEventHandler((event) => {
const size = 128 * 1024 * 1024
let sent = 0
const stream = new Readable({
read(chunkSize) {
if (sent >= size) {
this.push(null)
return
}
const remaining = size - sent
const chunk = Buffer.alloc(Math.min(chunkSize, remaining), "a")
sent += chunk.length
this.push(chunk)
}
})
setHeader(event, "Content-Type", "application/octet-stream")
setHeader(event, "Content-Length", size)
return stream
})

3
server/api/ping.get.ts Normal file
View File

@@ -0,0 +1,3 @@
export default defineEventHandler(() => {
return { ok: true }
})

11
server/api/upload.post.ts Normal file
View File

@@ -0,0 +1,11 @@
export default defineEventHandler(async (event) => {
const req = event.node.req
let received = 0
for await (const chunk of req) {
received += chunk.length
}
return { received }
})

View File

@@ -0,0 +1,37 @@
export default defineEventHandler(async () => {
const targets = [
{ label: "Ferme", url: "http://ferme.malio-dev.fr/api/version" },
{ label: "SIRH", url: "http://sirh.malio-dev.fr/api/version" },
{ label: "Inventory", url: "http://inventory.malio-dev.fr/api/health" },
]
const results = await Promise.all(
targets.map(async (target) => {
try {
const response = await fetch(target.url, {
method: "GET",
headers: { Accept: "application/json" }
})
return {
label: target.label,
url: target.url,
ok: response.status === 200,
status: response.status,
checkedAt: new Date().toISOString()
}
} catch (error) {
return {
label: target.label,
url: target.url,
ok: false,
status: 0,
checkedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error)
}
}
})
)
return { results }
})

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}