3 Commits

Author SHA1 Message Date
semantic-release-bot
d0a3f73989 chore(release): 1.4.2 2026-03-18 12:49:19 +00:00
659f22f15b Merge pull request 'fix/status-recette-log' (#20) from fix/status-recette-log into develop
All checks were successful
Release / release (push) Successful in 33s
Reviewed-on: #20
2026-03-18 12:48:49 +00:00
semantic-release-bot
f07ca784b1 chore(release): 1.4.1 2026-03-17 13:27:36 +00:00
28 changed files with 177 additions and 332 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=
DISK_LOCAL_SCRIPT_DIR=
DISK_REMOTE_SCRIPT_DIR=
RECETTE_SCRIPTS_DIR=
VAULTWARDEN_SSH_HOST=
VAULTWARDEN_SCRIPTS_DIR=
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
# A quelle heure les backups doivent être effectués (format 24h)
BACKUPS_HOUR=19

View File

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

View File

@@ -1,3 +1,21 @@
## [1.4.2](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.4.1...v1.4.2) (2026-03-18)
### Bug Fixes
* systeme metrics chart ([403bc91](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/403bc91f33ca2253d698531e8c6bf0c28b40f5c8))
* t-001 a t-005 correctif ([bdb65a0](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/bdb65a09ff247f8fb3d22913a3426a89fad1d177))
* t-005 a t-0029 correctif ([c12387a](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/c12387ac947cde677e78fe77d914a904795d404c))
* use recette status log ([0a73c5c](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/0a73c5cb37c557568647684382440d95de7bf3ab))
## [1.4.1](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.4.0...v1.4.1) (2026-03-17)
### Bug Fixes
* readme ([13457ce](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/13457ceb5a74686cd7a5e4180a87f130d1e2f73d))
* resolve production runtime issues ([8886e8b](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/8886e8b7dfe4fb6c9f90f3be7f2a64e23dd7cb3c))
# [1.4.0](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.3.1...v1.4.0) (2026-03-17)

View File

@@ -22,6 +22,7 @@ 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.
@@ -62,9 +63,11 @@ 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_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
- `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"
- `BACKUPS_HOUR` : heure attendue des sauvegardes pour le contrôle de fraicheur
### 4. Installer les dépendances
@@ -79,7 +82,7 @@ npm install
npm run dev
```
Par défaut, l'application Nuxt sera accessible sur <http://localhost:3000>.
Par défaut, l'application Nuxt sera accessible sûr <http://localhost:3000>.
## Configuration necessaire
@@ -133,7 +136,7 @@ Usage :
- `npm run dev` : lance l'application en développement
- `npm run build` : construit l'application pour la production
- `npm run generate` : generee une sortie statique si ce mode est compatible avec votre usage
- `npm run generate` : généré 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
- `npm run lint:fix` : applique les corrections ESLint automatiques : collecte périodique CPU, mémoire et réseau

View File

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

View File

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

View File

@@ -47,6 +47,7 @@
<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,6 +50,9 @@
</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 {
@@ -66,10 +69,6 @@ interface StatusResponse {
results: StatusRow[]
}
interface BackupScriptRunResponse {
ok: boolean
}
const props = withDefaults(
defineProps<{
endpoint?: string
@@ -84,8 +83,7 @@ const props = withDefaults(
const rows = ref<StatusRow[]>([])
const loading = ref(true)
const initialized = ref(false)
let statusTimer: ReturnType<typeof setInterval> | null = null
let scriptTimer: ReturnType<typeof setInterval> | null = null
let timer: ReturnType<typeof setInterval> | null = null
const statusLabel = (row: StatusRow) => {
if (row.ok) return "OK"
@@ -132,34 +130,15 @@ const checkStatus = async () => {
}
}
const runStatusScript = async () => {
try {
await apiFetch<BackupScriptRunResponse>("/api/backup-script", {
method: "POST",
body: { key: "check-statut-recette" }
})
await checkStatus()
} catch (error) {
console.error("Erreur execution check statut recette:", error)
}
}
onMounted(() => {
checkStatus()
runStatusScript()
statusTimer = setInterval(checkStatus, props.refreshMs)
scriptTimer = setInterval(runStatusScript, 5 * 60 * 1000)
timer = setInterval(checkStatus, props.refreshMs)
})
onBeforeUnmount(() => {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
if (scriptTimer) {
clearInterval(scriptTimer)
scriptTimer = null
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>

View File

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

View File

@@ -1,3 +1,50 @@
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
}
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1])
}
const asciiMatch = contentDisposition.match(/filename="([^"]+)"/i)
if (asciiMatch?.[1]) {
return asciiMatch[1]
}
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 = {}) {
@@ -5,16 +52,38 @@ export function apiRequest(input: RequestInfo | URL, init: RequestInit = {}) {
}
export async function downloadApiFile(url: string, fileNameFallback: string) {
// Les telechargements passent aussi par fetch pour pouvoir recuperer
// le contenu et le nom de fichier renvoye par l'API.
const response = await apiRequest(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
const fileName = getDownloadFileName(
response.headers.get("content-disposition"),
fileNameFallback
)
const link = document.createElement("a")
link.href = url
link.download = fileNameFallback
link.href = objectUrl
link.download = fileName
link.style.display = "none"
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(objectUrl)
}
export function withApiAuth(init: RequestInit = {}) {
return { ...init }
// Fusionne le header d'auth avec d'eventuels headers deja fournis.
return {
...init,
headers: {
...useApiAuthHeader(),
...toHeadersObject(init.headers)
}
}
}

View File

@@ -50,7 +50,7 @@
<div class="sidebar-divider"/>
<div class="status-card">
<p class="status-label">Environnement</p>
<p class="status-value">{{ environmentLabel }}</p>
<p class="status-value">Production</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">{{ environmentLabel }}</p>
<p class="status-value">Production</p>
<p class="status-description">
Navigation rapide vers les vues principales de supervision.
</p>
@@ -153,7 +153,6 @@ const {
public: {appVersion}
} = useRuntimeConfig()
const isMenuOpen = ref(false)
const environmentLabel = import.meta.dev ? "Developpement" : "Production"
const navItems = [
{
to: "/",
@@ -170,13 +169,6 @@ 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: process.env.NODE_ENV !== "production" },
devtools: { enabled: true },
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()
appVersion: getRepoVersion(),
apiKey: process.env.API_SECRET_KEY
}
},
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,4 +1,5 @@
<template>
<NuxtLayout name="default">
<div class="dashboard-container">
<header class="dashboard-header">
<div class="header-copy">
@@ -104,11 +105,16 @@
</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,4 +1,5 @@
<template>
<NuxtLayout name="default">
<div class="dashboard-container">
<header class="dashboard-header">
<div>
@@ -61,12 +62,15 @@
</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
@@ -188,7 +192,7 @@ const runScript = async () => {
const loadSystemMetrics = async () => {
try {
systemMetrics.value = await apiFetch<SystemMetrics>("/api/system")
systemMetrics.value = await $fetch<SystemMetrics>("/api/system")
} catch {
systemMetrics.value = null
} finally {

View File

@@ -30,23 +30,27 @@ 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 ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n ${MAX_FILES_PER_FOLDER}`
`cd ${quoteDir(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 ${shellQuote(remoteRoot)} && for d in */; do [ -d "$d" ] && printf '%s\n' "\${d%/}"; done`
`cd ${quoteDir(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 ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`
`cd ${quoteDir(remoteDir)} && ls -1A | sort -r | head -n 1`
)
const files = parseLines(output)
return files[0] || null

View File

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

View File

@@ -1,29 +0,0 @@
# Créer un bot discord
Allez sur le portail des développeurs Discord : https://discord.com/developers/applications
1. Cliquez sur "New Application" et donnez un nom à votre application.
2. Dans le menu de gauche, cliquez sur "Bot" puis sur "Add Bot".
3. Vous pouvez personnaliser votre bot en lui donnant un nom, une image de profil, etc.
4. Sous la section "Token", cliquez sur "Copy" pour copier le token de votre bot. Gardez ce token secret, car il permet à quiconque de contrôler votre bot.
5. Dans L'onglet "Installation", on peut décocher "Installation pour un utlisateur" si vous voulez installer le bot que sur des serveurs.
6. Tout en bas dans "Paramètres d'installation par défaut", cliquez sur "applications.commands" et sélectionnez "bot" pour donner les permissions nécessaires à votre bot.
7. Ensuite donner les permissions que vous souhaitez à votre bot en cochant les cases correspondantes. Pour l'utilisation pour Supervisor cocher "Voir les anciens messages"
8. Enregistrez les modifications.
9. Puis vous pouvez copier le d'installation et le copier pour inviter le bot au discord

View File

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

View File

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

View File

@@ -18,10 +18,6 @@ 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,11 +4,9 @@ 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 = runtimeConfig.authCookieSecure
const secureCookie = process.env.AUTH_COOKIE_SECURE === "true"
setCookie(event, "api_auth_token", expectedToken, {
httpOnly: true,

View File

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