fix: systeme metrics chart

This commit is contained in:
2026-03-19 09:29:28 +01:00
parent 403bc91f33
commit 6aa85ac683
26 changed files with 272 additions and 120 deletions

View File

@@ -11,12 +11,12 @@ BACKUPS_REMOTE_ROOT=
BACKUPS_MAX_FILES= BACKUPS_MAX_FILES=
# Paramètres utilisés pour construire les commandes disque et backup # Paramètres utilisés pour construire les commandes disque et backup
DISK_REMOTE_HOST=malio-b DISK_REMOTE_HOST=
DISK_LOCAL_SCRIPT_DIR=/home/malio/Malio-ops/CheckStorage DISK_LOCAL_SCRIPT_DIR=
DISK_REMOTE_SCRIPT_DIR=/home/malio-b/Malio-ops/CheckStorage DISK_REMOTE_SCRIPT_DIR=
RECETTE_SCRIPTS_DIR=/home/malio/Malio-ops/RecetteScripts RECETTE_SCRIPTS_DIR=
VAULTWARDEN_SSH_HOST=bitwarden VAULTWARDEN_SSH_HOST=
VAULTWARDEN_SCRIPTS_DIR=/home/matt/vaultwarden/Malio-ops/BackupVaultWarden VAULTWARDEN_SCRIPTS_DIR=
# A quelle heure les backups doivent être effectués (format 24h) # A quelle heure les backups doivent être effectués (format 24h)
BACKUPS_HOUR=19 BACKUPS_HOUR=19

View File

@@ -21,4 +21,8 @@ jobs:
- run: npm ci - run: npm ci
- run: npm run lint
- run: npm run build
- run: npx semantic-release - run: npx semantic-release

View File

@@ -22,7 +22,6 @@ Sur Linux, installer Docker et nvm.
Suivre la documentation suivante : Suivre la documentation suivante :
https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux
### Installation du projet
Une fois les prérequis installés, cloner le dépôt puis installer les dépendances. Une fois les prérequis installés, cloner le dépôt puis installer les dépendances.
Les étapes ci-dessous sont celles qui sont réellement supportées par le depot. Les étapes ci-dessous sont celles qui sont réellement supportées par le depot.
@@ -63,11 +62,9 @@ Les variables visibles dans le depot sont :
- `BACKUPS_REMOTE_HOST` : hôte SSH cible pour les operations distantes - `BACKUPS_REMOTE_HOST` : hôte SSH cible pour les operations distantes
- `BACKUPS_REMOTE_ROOT` : dossier racine des sauvegardes sur l'hôte distant - `BACKUPS_REMOTE_ROOT` : dossier racine des sauvegardes sur l'hôte distant
- `BACKUPS_MAX_FILES` : nombre maximal de fichiers retournés par dossier de backup - `BACKUPS_MAX_FILES` : nombre maximal de fichiers retournés par dossier de backup
- `DISK_COMMAND_REMOTE` : commande shell utilisée pour la verification disque distante - `DISK_REMOTE_HOST` : commande shell utilisée pour la verification disque distante
- `DISK_COMMAND_LOCAL` : commande shell utilisée pour la verification disque locale - `DISK_REMOTE_SCRIPT_DIR` : dossier des scripts de vérification disque distante
- `BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE` : commande a exécuter pour le script "Backup BDD recette" - `DISK_LOCAL_SCRIPT_DIR` : commande shell utilisée pour la verification disque locale
- `BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE` : commande à exécuter pour le script "Check statut recette"
- `BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN` : commande à exécuter pour le script "Backup vault warden"
- `BACKUPS_HOUR` : heure attendue des sauvegardes pour le contrôle de fraicheur - `BACKUPS_HOUR` : heure attendue des sauvegardes pour le contrôle de fraicheur
### 4. Installer les dépendances ### 4. Installer les dépendances
@@ -82,7 +79,7 @@ npm install
npm run dev npm run dev
``` ```
Par défaut, l'application Nuxt sera accessible sûr <http://localhost:3000>. Par défaut, l'application Nuxt sera accessible sur <http://localhost:3000>.
## Configuration necessaire ## Configuration necessaire
@@ -136,7 +133,7 @@ Usage :
- `npm run dev` : lance l'application en développement - `npm run dev` : lance l'application en développement
- `npm run build` : construit l'application pour la production - `npm run build` : construit l'application pour la production
- `npm run generate` : généré une sortie statique si ce mode est compatible avec votre usage - `npm run generate` : generee une sortie statique si ce mode est compatible avec votre usage
- `npm run preview` : prévisualisé le build Nuxt - `npm run preview` : prévisualisé le build Nuxt
- `npm run lint` : execute ESLint - `npm run lint` : execute ESLint
- `npm run lint:fix` : applique les corrections ESLint automatiques : collecte périodique CPU, mémoire et réseau - `npm run lint:fix` : applique les corrections ESLint automatiques

View File

@@ -123,7 +123,10 @@
background-clip: text; background-clip: text;
} }
} }
* {
scrollbar-width: thin;
scrollbar-color: rgb(var(--m-border)) rgb(var(--m-bg));
}
@keyframes fade-in-up { @keyframes fade-in-up {
from { from {
opacity: 0; opacity: 0;

View File

@@ -31,7 +31,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"
import { Icon as IconifyIcon } from "@iconify/vue" import { Icon as IconifyIcon } from "@iconify/vue"
import backupOptions from "~/server/config/backup-options.json" import backupOptions from "~/server/config/backup-options.json"

View File

@@ -1,8 +1,14 @@
<script setup> <script setup lang="ts">
import {Icon as IconifyIcon} from "@iconify/vue" import {Icon as IconifyIcon} from "@iconify/vue"
import { apiFetch } from "~/composables/useApiAuth" import { apiFetch } from "~/composables/useApiAuth"
const { data: messages, error } = await useFetch('/api/discord/messages', { interface DiscordMessage {
id: string
content: string
author: { username: string }
}
const { data: messages, error } = await useFetch<DiscordMessage[]>('/api/discord/messages', {
$fetch: apiFetch, $fetch: apiFetch,
server: false server: false
}) })

View File

@@ -44,7 +44,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref} from "vue"
import {Icon as IconifyIcon} from "@iconify/vue" import {Icon as IconifyIcon} from "@iconify/vue"
import { apiRequest } from "~/composables/useApiAuth" import { apiRequest } from "~/composables/useApiAuth"
@@ -66,9 +65,14 @@ async function testDownload() {
if (!res.ok) { if (!res.ok) {
throw new Error(`HTTP ${res.status}`) throw new Error(`HTTP ${res.status}`)
} }
const blob = await res.blob()
const end = performance.now() const end = performance.now()
const size = blob.size const reader = res.body!.getReader()
let size = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
size += value.length
}
const seconds = (end - start) / 1000 const seconds = (end - start) / 1000
download.value = Math.round((size * 8) / seconds / 1000000) download.value = Math.round((size * 8) / seconds / 1000000)
} }
@@ -88,7 +92,7 @@ async function testUpload() {
async function testPing() { async function testPing() {
const start = performance.now() const start = performance.now()
const response = await fetch('/api/ping') const response = await apiRequest('/api/ping')
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}`) throw new Error(`HTTP ${response.status}`)
} }

View File

@@ -47,7 +47,6 @@
<script setup lang="ts"> <script setup lang="ts">
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue" import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue" import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
import {onBeforeUnmount, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth" import { apiFetch } from "~/composables/useApiAuth"
interface StatusRow { interface StatusRow {

View File

@@ -50,9 +50,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
import {onBeforeUnmount, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth" import { apiFetch } from "~/composables/useApiAuth"
interface StatusRow { interface StatusRow {

View File

@@ -139,7 +139,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"
import type { SystemMetrics } from "~/types/system" import type { SystemMetrics } from "~/types/system"
type MetricKey = "cpu" | "ram" type MetricKey = "cpu" | "ram"

View File

@@ -45,7 +45,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed} from "vue"
import type { SystemMetrics } from "~/types/system"; import type { SystemMetrics } from "~/types/system";
const props = defineProps<{ const props = defineProps<{

View File

@@ -1,19 +1,3 @@
function toHeadersObject(headers?: HeadersInit): Record<string, string> {
if (!headers) {
return {}
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries())
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers)
}
return { ...headers }
}
function getDownloadFileName(contentDisposition: string | null, fallback: string) { function getDownloadFileName(contentDisposition: string | null, fallback: string) {
if (!contentDisposition) { if (!contentDisposition) {
return fallback return fallback
@@ -31,20 +15,6 @@ function getDownloadFileName(contentDisposition: string | null, fallback: string
return fallback return fallback
} }
export function useApiAuthHeader() {
const runtimeConfig = useRuntimeConfig()
const token = runtimeConfig.public.apiSecretKey
if (!token) {
return {}
}
// Tous les appels frontend vers /api/* reutilisent ce header commun.
return {
}
}
export const apiFetch = $fetch.create({}) export const apiFetch = $fetch.create({})
export function apiRequest(input: RequestInfo | URL, init: RequestInit = {}) { export function apiRequest(input: RequestInfo | URL, init: RequestInit = {}) {
@@ -78,12 +48,5 @@ export async function downloadApiFile(url: string, fileNameFallback: string) {
} }
export function withApiAuth(init: RequestInit = {}) { export function withApiAuth(init: RequestInit = {}) {
// Fusionne le header d'auth avec d'eventuels headers deja fournis. return { ...init }
return {
...init,
headers: {
...useApiAuthHeader(),
...toHeadersObject(init.headers)
}
}
} }

View File

@@ -50,7 +50,7 @@
<div class="sidebar-divider"/> <div class="sidebar-divider"/>
<div class="status-card"> <div class="status-card">
<p class="status-label">Environnement</p> <p class="status-label">Environnement</p>
<p class="status-value">Production</p> <p class="status-value">{{ environmentLabel }}</p>
<p class="status-description"> <p class="status-description">
Acces rapide au monitoring, aux sauvegardes et aux cartes systeme. Acces rapide au monitoring, aux sauvegardes et aux cartes systeme.
</p> </p>
@@ -123,7 +123,7 @@
<div class="sidebar-divider"/> <div class="sidebar-divider"/>
<div class="status-card"> <div class="status-card">
<p class="status-label">Environnement</p> <p class="status-label">Environnement</p>
<p class="status-value">Production</p> <p class="status-value">{{ environmentLabel }}</p>
<p class="status-description"> <p class="status-description">
Navigation rapide vers les vues principales de supervision. Navigation rapide vers les vues principales de supervision.
</p> </p>
@@ -153,6 +153,7 @@ const {
public: {appVersion} public: {appVersion}
} = useRuntimeConfig() } = useRuntimeConfig()
const isMenuOpen = ref(false) const isMenuOpen = ref(false)
const environmentLabel = import.meta.dev ? "Developpement" : "Production"
const navItems = [ const navItems = [
{ {
to: "/", to: "/",
@@ -169,6 +170,13 @@ const navItems = [
icon: "mdi:database-arrow-up-outline" icon: "mdi:database-arrow-up-outline"
} }
] ]
onMounted(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") isMenuOpen.value = false
}
document.addEventListener("keydown", handler)
onBeforeUnmount(() => document.removeEventListener("keydown", handler))
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -20,7 +20,7 @@ const getRepoVersion = () => {
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2025-07-15", compatibilityDate: "2025-07-15",
devtools: { enabled: true }, devtools: { enabled: process.env.NODE_ENV !== "production" },
css: ["~/assets/css/main.css"], css: ["~/assets/css/main.css"],
app: { app: {
head: { head: {
@@ -35,12 +35,12 @@ export default defineNuxtConfig({
} }
}, },
runtimeConfig: { runtimeConfig: {
authCookieSecure: process.env.AUTH_COOKIE_SECURE === "true",
apiSecretKey: process.env.API_SECRET_KEY, apiSecretKey: process.env.API_SECRET_KEY,
discordBotToken: process.env.DISCORD_BOT_TOKEN, discordBotToken: process.env.DISCORD_BOT_TOKEN,
discordChannelId: process.env.DISCORD_CHANNEL_ID, discordChannelId: process.env.DISCORD_CHANNEL_ID,
public: { public: {
appVersion: getRepoVersion(), appVersion: getRepoVersion()
apiKey: process.env.API_SECRET_KEY
} }
}, },
vite: { vite: {

200
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,10 +16,10 @@
}, },
"dependencies": { "dependencies": {
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"@nuxt/eslint": "^1.15.2",
"nuxt": "^4.3.1" "nuxt": "^4.3.1"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint": "^1.15.2",
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",

View File

@@ -1,5 +1,4 @@
<template> <template>
<NuxtLayout name="default">
<div class="dashboard-container"> <div class="dashboard-container">
<header class="dashboard-header"> <header class="dashboard-header">
<div class="header-copy"> <div class="header-copy">
@@ -105,16 +104,11 @@
</section> </section>
</div> </div>
</div> </div>
</NuxtLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"
import BackupRun from "~/components/BackupRun.vue"
import { apiFetch, downloadApiFile } from "~/composables/useApiAuth" import { apiFetch, downloadApiFile } from "~/composables/useApiAuth"
definePageMeta({ layout: false })
type ScriptResult = { type ScriptResult = {
key: string | null key: string | null
label: string label: string

View File

@@ -1,5 +1,4 @@
<template> <template>
<NuxtLayout name="default">
<div class="dashboard-container"> <div class="dashboard-container">
<header class="dashboard-header"> <header class="dashboard-header">
<div> <div>
@@ -62,15 +61,12 @@
</div> </div>
</div> </div>
</div> </div>
</NuxtLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { apiFetch } from "~/composables/useApiAuth" import { apiFetch } from "~/composables/useApiAuth"
import type { SystemMetrics } from "~/types/system"; import type { SystemMetrics } from "~/types/system";
definePageMeta({layout: false})
type DiskSourceResult = { type DiskSourceResult = {
key: string key: string
label: string label: string
@@ -192,7 +188,7 @@ const runScript = async () => {
const loadSystemMetrics = async () => { const loadSystemMetrics = async () => {
try { try {
systemMetrics.value = await $fetch<SystemMetrics>("/api/system") systemMetrics.value = await apiFetch<SystemMetrics>("/api/system")
} catch { } catch {
systemMetrics.value = null systemMetrics.value = null
} finally { } finally {

View File

@@ -30,27 +30,23 @@ function parseLines(output: string): string[] {
.filter(Boolean) .filter(Boolean)
} }
function quoteDir(pathValue: string) {
return shellQuote(pathValue)
}
async function listRemoteFiles(remoteDir: string): Promise<string[]> { async function listRemoteFiles(remoteDir: string): Promise<string[]> {
const output = await runSsh( const output = await runSsh(
`cd ${quoteDir(remoteDir)} && ls -1A | sort -r | head -n ${MAX_FILES_PER_FOLDER}` `cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n ${MAX_FILES_PER_FOLDER}`
) )
return parseLines(output) return parseLines(output)
} }
async function listRemoteDirs(remoteRoot: string): Promise<string[]> { async function listRemoteDirs(remoteRoot: string): Promise<string[]> {
const output = await runSsh( const output = await runSsh(
`cd ${quoteDir(remoteRoot)} && for d in */; do [ -d "$d" ] && printf '%s\n' "\${d%/}"; done` `cd ${shellQuote(remoteRoot)} && for d in */; do [ -d "$d" ] && printf '%s\n' "\${d%/}"; done`
) )
return parseLines(output) return parseLines(output)
} }
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> { async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
const output = await runSsh( const output = await runSsh(
`cd ${quoteDir(remoteDir)} && ls -1A | sort -r | head -n 1` `cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`
) )
const files = parseLines(output) const files = parseLines(output)
return files[0] || null return files[0] || null

View File

@@ -4,10 +4,9 @@ import {
resolveFolderRemoteDir resolveFolderRemoteDir
} from "../utils/ssh.ts" } from "../utils/ssh.ts"
import {process} from "std-env";
import backupOptions from "../config/backup-options.json" import backupOptions from "../config/backup-options.json"
export const BACKUP_HOUR = process.env.BACKUPS_HOUR export const BACKUP_HOUR = Number(process.env.BACKUPS_HOUR) || 19
type BackupTarget = { type BackupTarget = {
name: string name: string
@@ -141,7 +140,7 @@ export default defineEventHandler(async () => {
latestBackupAt: null, latestBackupAt: null,
backupDate: null, backupDate: null,
expectedBackupDate: expectedDateKey, expectedBackupDate: expectedDateKey,
error: error instanceof Error ? error.message : String(error) error: "Erreur lors de la verification"
} }
} }
}) })

View File

@@ -20,7 +20,11 @@ export default defineEventHandler(async (event) => {
} }
) )
return messages return (messages as any[]).map((m) => ({
id: m.id,
content: m.content,
author: { username: m.author?.username ?? "Inconnu" }
}))
} catch (error) { } catch (error) {
console.error("Erreur Discord messages:", error) console.error("Erreur Discord messages:", error)

View File

@@ -3,7 +3,8 @@ import {
shellQuote, shellQuote,
resolveFolderRemoteDir, resolveFolderRemoteDir,
REMOTE_HOST, REMOTE_HOST,
isSafeFolder isSafeFolder,
isSafeFile
} from "../utils/ssh.ts" } from "../utils/ssh.ts"
import { spawn } from "node:child_process" import { spawn } from "node:child_process"
@@ -29,6 +30,9 @@ export default defineEventHandler(async (event) => {
if (folderNames.length === 0) { if (folderNames.length === 0) {
throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" }) throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" })
} }
if (!REMOTE_HOST) {
throw createError({ statusCode: 503, statusMessage: "Service non configure" })
}
if (folderNames.some((folder) => !isSafeFolder(folder))) { if (folderNames.some((folder) => !isSafeFolder(folder))) {
throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" }) throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" })
@@ -44,10 +48,7 @@ export default defineEventHandler(async (event) => {
} }
const fileName = await getLatestRemoteFile(remoteDir) const fileName = await getLatestRemoteFile(remoteDir)
if (!fileName || !isSafeFolder(fileName)) { if (!fileName || !isSafeFile(fileName)) {
continue
}
if (!fileName) {
continue continue
} }

View File

@@ -18,6 +18,10 @@ export default defineEventHandler(async (event) => {
const folderName = typeof folder === "string" ? folder : null const folderName = typeof folder === "string" ? folder : null
const fileName = typeof file === "string" ? file : null const fileName = typeof file === "string" ? file : null
if (!REMOTE_HOST) {
throw createError({ statusCode: 503, statusMessage: "Service non configure" })
}
if (!folderName || !fileName) { if (!folderName || !fileName) {
throw createError({ statusCode: 400, statusMessage: "Paramètres manquants" }) throw createError({ statusCode: 400, statusMessage: "Paramètres manquants" })
} }

View File

@@ -4,9 +4,11 @@ export default defineEventHandler(async (event) => {
let received = 0 let received = 0
for await (const chunk of req) { for await (const chunk of req) {
if (received > MAX_UPLOAD_BYTES) throw createError({ statusCode: 413, statusMessage: "Fichier trop volumineux" })
received += chunk.length received += chunk.length
if (received > MAX_UPLOAD_BYTES) {
event.node.res.destroy()
throw createError({statusCode: 413, statusMessage: "Fichier trop volumineux"})
}
} }
return {received}
return { received } })
})

View File

@@ -22,7 +22,7 @@ export default defineEventHandler((event) => {
return return
} }
const secureCookie = process.env.AUTH_COOKIE_SECURE === "true" const secureCookie = runtimeConfig.authCookieSecure
setCookie(event, "api_auth_token", expectedToken, { setCookie(event, "api_auth_token", expectedToken, {
httpOnly: true, httpOnly: true,

View File

@@ -1,3 +1,5 @@
import { shellQuote } from "./ssh"
export type BackupScript = { export type BackupScript = {
key: string key: string
label: string label: string
@@ -32,10 +34,10 @@ const getDefaultBackupScriptCommands = (): Record<string, string> => {
process.env.VAULTWARDEN_SCRIPTS_DIR || "/home/matt/vaultwarden/Malio-ops/BackupVaultWarden" process.env.VAULTWARDEN_SCRIPTS_DIR || "/home/matt/vaultwarden/Malio-ops/BackupVaultWarden"
return { return {
"backup-bdd-recette": `cd ${recetteScriptsDir} && bash backup-bdd-recette.sh`, "backup-bdd-recette": `cd ${shellQuote(recetteScriptsDir)} && bash backup-bdd-recette.sh`,
"check-statut-recette": `cd ${recetteScriptsDir} && bash check-statut-recette.sh`, "check-statut-recette": `cd ${shellQuote(recetteScriptsDir)} && bash check-statut-recette.sh`,
"backup-vaultwarden": "backup-vaultwarden":
`ssh ${vaultwardenHost} "cd ${vaultwardenScriptsDir} && bash backup-vaultwarden.sh"` `ssh ${shellQuote(vaultwardenHost)} "cd ${shellQuote(vaultwardenScriptsDir)} && bash backup-vaultwarden.sh"`
} }
} }