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=
# Paramètres utilisés pour construire les commandes disque et backup
DISK_REMOTE_HOST=malio-b
DISK_LOCAL_SCRIPT_DIR=/home/malio/Malio-ops/CheckStorage
DISK_REMOTE_SCRIPT_DIR=/home/malio-b/Malio-ops/CheckStorage
RECETTE_SCRIPTS_DIR=/home/malio/Malio-ops/RecetteScripts
VAULTWARDEN_SSH_HOST=bitwarden
VAULTWARDEN_SCRIPTS_DIR=/home/matt/vaultwarden/Malio-ops/BackupVaultWarden
DISK_REMOTE_HOST=
DISK_LOCAL_SCRIPT_DIR=
DISK_REMOTE_SCRIPT_DIR=
RECETTE_SCRIPTS_DIR=
VAULTWARDEN_SSH_HOST=
VAULTWARDEN_SCRIPTS_DIR=
# A quelle heure les backups doivent être effectués (format 24h)
BACKUPS_HOUR=19

View File

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

View File

@@ -22,7 +22,6 @@ Sur Linux, installer Docker et nvm.
Suivre la documentation suivante :
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.
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_ROOT` : dossier racine des sauvegardes sur l'hôte distant
- `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_COMMAND_LOCAL` : commande shell utilisée pour la verification disque locale
- `BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE` : commande a exécuter pour le script "Backup BDD recette"
- `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"
- `DISK_REMOTE_HOST` : commande shell utilisée pour la verification disque distante
- `DISK_REMOTE_SCRIPT_DIR` : dossier des scripts de vérification disque distante
- `DISK_LOCAL_SCRIPT_DIR` : commande shell utilisée pour la verification disque locale
- `BACKUPS_HOUR` : heure attendue des sauvegardes pour le contrôle de fraicheur
### 4. Installer les dépendances
@@ -82,7 +79,7 @@ npm install
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
@@ -136,7 +133,7 @@ Usage :
- `npm run dev` : lance l'application en développement
- `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 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;
}
}
* {
scrollbar-width: thin;
scrollbar-color: rgb(var(--m-border)) rgb(var(--m-bg));
}
@keyframes fade-in-up {
from {
opacity: 0;

View File

@@ -31,7 +31,6 @@
</template>
<script setup lang="ts">
import { ref } from "vue"
import { Icon as IconifyIcon } from "@iconify/vue"
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 { 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,
server: false
})

View File

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

View File

@@ -47,7 +47,6 @@
<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"
interface StatusRow {

View File

@@ -50,9 +50,6 @@
</template>
<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"
interface StatusRow {

View File

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

View File

@@ -45,7 +45,6 @@
</template>
<script setup lang="ts">
import {computed} from "vue"
import type { SystemMetrics } from "~/types/system";
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) {
if (!contentDisposition) {
return fallback
@@ -31,20 +15,6 @@ function getDownloadFileName(contentDisposition: string | null, fallback: string
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 function apiRequest(input: RequestInfo | URL, init: RequestInit = {}) {
@@ -78,12 +48,5 @@ export async function downloadApiFile(url: string, fileNameFallback: string) {
}
export function withApiAuth(init: RequestInit = {}) {
// Fusionne le header d'auth avec d'eventuels headers deja fournis.
return {
...init,
headers: {
...useApiAuthHeader(),
...toHeadersObject(init.headers)
}
}
return { ...init }
}

View File

@@ -50,7 +50,7 @@
<div class="sidebar-divider"/>
<div class="status-card">
<p class="status-label">Environnement</p>
<p class="status-value">Production</p>
<p class="status-value">{{ environmentLabel }}</p>
<p class="status-description">
Acces rapide au monitoring, aux sauvegardes et aux cartes systeme.
</p>
@@ -123,7 +123,7 @@
<div class="sidebar-divider"/>
<div class="status-card">
<p class="status-label">Environnement</p>
<p class="status-value">Production</p>
<p class="status-value">{{ environmentLabel }}</p>
<p class="status-description">
Navigation rapide vers les vues principales de supervision.
</p>
@@ -153,6 +153,7 @@ const {
public: {appVersion}
} = useRuntimeConfig()
const isMenuOpen = ref(false)
const environmentLabel = import.meta.dev ? "Developpement" : "Production"
const navItems = [
{
to: "/",
@@ -169,6 +170,13 @@ const navItems = [
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>
<style scoped>

View File

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

200
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
<template>
<NuxtLayout name="default">
<div class="dashboard-container">
<header class="dashboard-header">
<div>
@@ -62,15 +61,12 @@
</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
@@ -192,7 +188,7 @@ const runScript = async () => {
const loadSystemMetrics = async () => {
try {
systemMetrics.value = await $fetch<SystemMetrics>("/api/system")
systemMetrics.value = await apiFetch<SystemMetrics>("/api/system")
} catch {
systemMetrics.value = null
} finally {

View File

@@ -30,27 +30,23 @@ function parseLines(output: string): string[] {
.filter(Boolean)
}
function quoteDir(pathValue: string) {
return shellQuote(pathValue)
}
async function listRemoteFiles(remoteDir: string): Promise<string[]> {
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)
}
async function listRemoteDirs(remoteRoot: string): Promise<string[]> {
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)
}
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
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)
return files[0] || null

View File

@@ -4,10 +4,9 @@ import {
resolveFolderRemoteDir
} from "../utils/ssh.ts"
import {process} from "std-env";
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 = {
name: string
@@ -141,7 +140,7 @@ export default defineEventHandler(async () => {
latestBackupAt: null,
backupDate: null,
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) {
console.error("Erreur Discord messages:", error)

View File

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

View File

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

View File

@@ -4,9 +4,11 @@ export default defineEventHandler(async (event) => {
let received = 0
for await (const chunk of req) {
if (received > MAX_UPLOAD_BYTES) throw createError({ statusCode: 413, statusMessage: "Fichier trop volumineux" })
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
}
const secureCookie = process.env.AUTH_COOKIE_SECURE === "true"
const secureCookie = runtimeConfig.authCookieSecure
setCookie(event, "api_auth_token", expectedToken, {
httpOnly: true,

View File

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