12 Commits

Author SHA1 Message Date
cb0d2c80cf fix: readme discord 2026-03-19 10:48:40 +01:00
d593d3f0e2 fix: status app 2026-03-19 10:24:41 +01:00
66a6a8caf0 fix: status app 2026-03-19 09:37:55 +01:00
6aa85ac683 fix: systeme metrics chart 2026-03-19 09:29:28 +01:00
403bc91f33 fix: systeme metrics chart 2026-03-18 11:36:59 +01:00
0a73c5cb37 fix: use recette status log 2026-03-18 10:33:18 +01:00
c12387ac94 fix: t-005 a t-0029 correctif 2026-03-18 09:00:11 +01:00
bdb65a09ff fix: t-001 a t-005 correctif 2026-03-17 15:33:36 +01:00
13457ceb5a fix: readme
All checks were successful
Release / release (push) Successful in 26s
2026-03-17 14:26:49 +01:00
f30d75141d Merge pull request 'fix: resolve production runtime issues' (#19) from fix/prod-runtime-issues into develop
Some checks failed
Release / release (push) Has been cancelled
Reviewed-on: #19
2026-03-17 12:52:21 +00:00
semantic-release-bot
99a5758f05 chore(release): 1.4.0 2026-03-17 09:27:06 +00:00
7261f9f0e9 Merge pull request 'feat: add check backup' (#18) from feat/440-add-section-check-backup into develop
All checks were successful
Release / release (push) Successful in 27s
Reviewed-on: #18
2026-03-17 09:26:41 +00:00
34 changed files with 674 additions and 277 deletions

View File

@@ -10,14 +10,16 @@ BACKUPS_REMOTE_HOST=
BACKUPS_REMOTE_ROOT= BACKUPS_REMOTE_ROOT=
BACKUPS_MAX_FILES= BACKUPS_MAX_FILES=
# DISK_COMMAND_REMOTE et DISK_COMMAND_LOCAL pour les commandes de vérification de l'espace disque # Paramètres utilisés pour construire les commandes disque et backup
DISK_COMMAND_REMOTE= DISK_REMOTE_HOST=
DISK_COMMAND_LOCAL= DISK_LOCAL_SCRIPT_DIR=
DISK_REMOTE_SCRIPT_DIR=
# BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE, BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE et BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN pour les commandes de backup et de vérification des statuts RECETTE_SCRIPTS_DIR=
BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE= VAULTWARDEN_SSH_HOST=
BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE= VAULTWARDEN_SCRIPTS_DIR=
BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN=
# 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
#Mettre à true pour que les cookies d'authentification soient sécurisés (HTTPS uniquement)
AUTH_COOKIE_SECURE=

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

@@ -1,3 +1,19 @@
# [1.4.0](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.3.1...v1.4.0) (2026-03-17)
### Bug Fixes
* lint ([69c192c](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/69c192c35ad2a743d01b96d834f509b2b1f0b4e6))
* readme ([5184e26](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/5184e26293ef23944e874f4e938f1cc89ec85f82))
* use env ([f7ac255](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/f7ac255820ca5a1fded47a6b0071d85c7d3c4214))
* use env only ([829ac07](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/829ac07d38e81225017b3c6a33c3f34882ca02d1))
* use env only ([e13e1eb](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/e13e1eb3dd48c1b5a6f2fe0347e43dea60e4406f))
### Features
* add check backup ([5495e18](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/5495e18173c0778c6eaba4ae1eb8c30ea46bbef7))
## [1.3.1](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.3.0...v1.3.1) (2026-03-16) ## [1.3.1](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.3.0...v1.3.1) (2026-03-16)

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
@@ -95,6 +92,19 @@ Consequence visible :
- si `API_SECRET_KEY` est vide, les appels API sont refusés avec `401 Unauthorized` - si `API_SECRET_KEY` est vide, les appels API sont refusés avec `401 Unauthorized`
- l'application web pose aussi un cookie HTTP-only via `server/middleware/auth-cookie.ts` pour réutiliser ce secret coté navigateur - l'application web pose aussi un cookie HTTP-only via `server/middleware/auth-cookie.ts` pour réutiliser ce secret coté navigateur
## Securite
Le comportement actuel du projet repose sur une hypothèse d'exposition très forte.
- `server/middleware/auth-cookie.ts` pose automatiquement le cookie `api_auth_token` à tout visiteur qui charge l'interface web
- ce cookie permet ensuite d'accéder aux routes `/api/*` protégées par `API_SECRET_KEY`
- il n'existe pas de login utilisateur ni de contrôle d'identité distinct dans le dépôt
Conséquence :
- `Supervisor` doit être déployé uniquement sur un réseau de confiance, derrière un VPN, une restriction d'IP, un proxy d'authentification ou un autre contrôle d'accès externe
- si l'application est exposée publiquement sans protection supplémentaire, ce mécanisme ne constitue pas une authentification suffisante
### SSH pour les backups ### SSH pour les backups
Les fonctionnalités de backup utilisent `ssh` avec les options `BatchMode=yes` et `ConnectTimeout=5` dans `server/utils/ssh.ts`. Cela implique un accès sans saisie interactive de mot de passe. Les fonctionnalités de backup utilisent `ssh` avec les options `BatchMode=yes` et `ConnectTimeout=5` dans `server/utils/ssh.ts`. Cela implique un accès sans saisie interactive de mot de passe.
@@ -123,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

@@ -15,7 +15,6 @@
--color-m-success: rgb(var(--m-success)); --color-m-success: rgb(var(--m-success));
--color-m-accent: rgb(var(--m-accent)); --color-m-accent: rgb(var(--m-accent));
--color-m-warning: rgb(var(--m-warning)); --color-m-warning: rgb(var(--m-warning));
--color-m-succes: rgb(var(--m-success));
--font-display: "Outfit", system-ui, sans-serif; --font-display: "Outfit", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace; --font-mono: "JetBrains Mono", "Fira Code", monospace;
} }
@@ -124,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

@@ -77,7 +77,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"
import { Icon as IconifyIcon } from "@iconify/vue" import { Icon as IconifyIcon } from "@iconify/vue"
import { apiFetch } from "~/composables/useApiAuth" import { apiFetch } from "~/composables/useApiAuth"
@@ -108,6 +107,12 @@ type ScriptResult = {
downloadFolders: string[] downloadFolders: string[]
} }
type ApiErrorLike = {
data?: {
statusMessage?: string
}
}
const emit = defineEmits<{ const emit = defineEmits<{
result: [payload: ScriptResult] result: [payload: ScriptResult]
}>() }>()
@@ -116,7 +121,6 @@ const active = ref<string | null>(null)
const loading = ref(true) const loading = ref(true)
const runningKey = ref<string | null>(null) const runningKey = ref<string | null>(null)
const scripts = ref<BackupScript[]>([]) const scripts = ref<BackupScript[]>([])
const output = ref<string>("")
const message = ref<string>("") const message = ref<string>("")
const isError = ref(false) const isError = ref(false)
@@ -125,7 +129,6 @@ const statusClass = computed(() => (isError.value ? "status-error" : "status-suc
const loadScripts = async () => { const loadScripts = async () => {
loading.value = true loading.value = true
message.value = "" message.value = ""
output.value = ""
isError.value = false isError.value = false
emit("result", { emit("result", {
key: null, key: null,
@@ -156,7 +159,6 @@ const loadScripts = async () => {
const runScript = async (key: string) => { const runScript = async (key: string) => {
active.value = key active.value = key
runningKey.value = key runningKey.value = key
output.value = ""
message.value = "" message.value = ""
isError.value = false isError.value = false
@@ -165,30 +167,25 @@ const runScript = async (key: string) => {
method: "POST", method: "POST",
body: { key } body: { key }
}) })
const resultOutput = data.output || "Aucune sortie retournee."
message.value = `${data.label} execute avec succes` message.value = `${data.label} execute avec succes`
output.value = data.output || "Aucune sortie retournee."
emit("result", { emit("result", {
key: data.key, key: data.key,
label: data.label, label: data.label,
output: output.value, output: resultOutput,
isError: false, isError: false,
downloadFolders: data.downloadFolders || [] downloadFolders: data.downloadFolders || []
}) })
} catch (error: unknown) { } catch (error: unknown) {
isError.value = true const errorMessage =
const statusMessage =
typeof error === "object" && typeof error === "object" &&
error !== null && error !== null &&
"data" in error && "data" in error &&
typeof error.data === "object" && typeof (error as ApiErrorLike).data?.statusMessage === "string"
error.data !== null && ? (error as ApiErrorLike).data?.statusMessage
"statusMessage" in error.data &&
typeof error.data.statusMessage === "string"
? error.data.statusMessage
: null : null
message.value = statusMessage || "Erreur execution script" message.value = errorMessage || "Erreur execution script"
output.value = ""
emit("result", { emit("result", {
key, key,
label: scripts.value.find((item) => item.key === key)?.label || key, label: scripts.value.find((item) => item.key === key)?.label || key,

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

@@ -24,25 +24,32 @@
v-else v-else
:key="`${row.label}-${row.url}`" :key="`${row.label}-${row.url}`"
class="status-row" class="status-row"
:class="row.status === 200 ? 'row-ok' : 'row-error'" :class="row.ok ? 'row-ok' : 'row-error'"
> >
<div class="flex items-center gap-3"> <div class="status-copy">
<span class="status-dot" :class="row.status === 200 ? 'dot-ok' : 'dot-error'" /> <div class="flex items-center gap-3">
<span class="font-display text-sm font-semibold text-m-text"> <span class="status-dot" :class="row.ok ? 'dot-ok' : 'dot-error'" />
{{ row.label }} <span class="font-display text-sm font-semibold text-m-text">
{{ row.label }}
</span>
</div>
<p class="status-detail">
{{ row.detail }}
</p>
</div>
<div class="status-meta">
<span class="font-mono text-xs" :class="row.ok ? 'text-m-success' : 'text-m-error'">
{{ statusLabel(row) }}
</span>
<span class="status-time">
{{ formatCheckedAt(row.checkedAt) }}
</span> </span>
</div> </div>
<span class="font-mono text-xs" :class="row.status === 200 ? 'text-m-success' : 'text-m-error'">
{{ statusLabel(row.status) }}
</span>
</div> </div>
</div> </div>
</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 {
@@ -51,6 +58,7 @@ interface StatusRow {
ok: boolean ok: boolean
status: number status: number
checkedAt: string checkedAt: string
detail: string
error?: string error?: string
} }
@@ -58,6 +66,10 @@ interface StatusResponse {
results: StatusRow[] results: StatusRow[]
} }
interface BackupScriptRunResponse {
ok: boolean
}
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
endpoint?: string endpoint?: string
@@ -72,12 +84,27 @@ const props = withDefaults(
const rows = ref<StatusRow[]>([]) const rows = ref<StatusRow[]>([])
const loading = ref(true) const loading = ref(true)
const initialized = ref(false) const initialized = ref(false)
let timer: ReturnType<typeof setInterval> | null = null let statusTimer: ReturnType<typeof setInterval> | null = null
let scriptTimer: ReturnType<typeof setInterval> | null = null
const statusLabel = (status: number) => { const statusLabel = (row: StatusRow) => {
if (status === 200) return "HTTP 200" if (row.ok) return "OK"
if (status === 0) return "Injoignable" if (row.status === 0) return "DOWN"
return `KO (${status})` return `KO (${row.status})`
}
const formatCheckedAt = (checkedAt: string) => {
const date = new Date(checkedAt)
if (Number.isNaN(date.getTime())) {
return checkedAt
}
return date.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
})
} }
const checkStatus = async () => { const checkStatus = async () => {
@@ -95,6 +122,7 @@ const checkStatus = async () => {
ok: false, ok: false,
status: 0, status: 0,
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
detail: "Lecture du statut impossible",
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
} }
] ]
@@ -104,15 +132,34 @@ 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(() => { onMounted(() => {
checkStatus() checkStatus()
timer = setInterval(checkStatus, props.refreshMs) runStatusScript()
statusTimer = setInterval(checkStatus, props.refreshMs)
scriptTimer = setInterval(runStatusScript, 5 * 60 * 1000)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (timer) { if (statusTimer) {
clearInterval(timer) clearInterval(statusTimer)
timer = null statusTimer = null
}
if (scriptTimer) {
clearInterval(scriptTimer)
scriptTimer = null
} }
}) })
</script> </script>
@@ -149,6 +196,7 @@ onBeforeUnmount(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
min-height: 3.2rem; min-height: 3.2rem;
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
border-radius: 14px; border-radius: 14px;
@@ -157,6 +205,30 @@ onBeforeUnmount(() => {
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.status-copy {
min-width: 0;
}
.status-detail {
margin: 0.35rem 0 0;
color: rgb(var(--m-muted));
font-size: 0.78rem;
line-height: 1.4;
}
.status-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
flex-shrink: 0;
}
.status-time {
font-size: 0.72rem;
color: rgb(var(--m-muted));
}
.row-ok { .row-ok {
border-color: rgb(var(--m-success) / 0.08); border-color: rgb(var(--m-success) / 0.08);
} }

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,51 +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
}
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 {
Authorization: `Bearer ${token}`
}
}
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 = {}) {
@@ -53,38 +5,16 @@ export function apiRequest(input: RequestInfo | URL, init: RequestInit = {}) {
} }
export async function downloadApiFile(url: string, fileNameFallback: string) { 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") const link = document.createElement("a")
link.href = objectUrl link.href = url
link.download = fileName link.download = fileNameFallback
link.style.display = "none" link.style.display = "none"
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
link.remove() link.remove()
URL.revokeObjectURL(objectUrl)
} }
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>
@@ -146,7 +146,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 logoSrc from '~/assets/LOGO_CARRE_BLANC.png' import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
@@ -154,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: "/",
@@ -170,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,13 +20,13 @@ 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: {
link: [ link: [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com ", crossorigin: "" }, { rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
{ {
rel: "stylesheet", rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800;900&display=swap" href: "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800;900&display=swap"
@@ -35,13 +35,15 @@ 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,
discordChannelId: process.env.DISCORD_CHANNEL_ID,
public: { public: {
appVersion: getRepoVersion(), appVersion: getRepoVersion()
apiKey: process.env.API_SECRET_KEY
} }
}, },
vite: { vite: {
plugins: [tailwindcss()] plugins: [tailwindcss()]
} }
}) })

208
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,10 @@
{ {
"name": "disk-monitor", "name": "supervisor",
"type": "module", "type": "module",
"private": true, "private": true,
"engines": {
"node": ">=20"
},
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"dev": "nuxt dev", "dev": "nuxt dev",
@@ -13,14 +16,13 @@
}, },
"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",
"@semantic-release/github": "^12.0.6",
"@semantic-release/release-notes-generator": "^14.1.0", "@semantic-release/release-notes-generator": "^14.1.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"semantic-release": "^25.0.3", "semantic-release": "^25.0.3",

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,16 +61,12 @@
</div> </div>
</div> </div>
</div> </div>
</NuxtLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, onMounted, ref} from "vue"
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
@@ -193,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 {
@@ -334,14 +329,6 @@ onBeforeUnmount(() => {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.backup-selector {
order: 2;
}
.backup-list-mobile {
order: 3;
}
.speedtest-card-mobile { .speedtest-card-mobile {
order: 4; order: 4;
} }

View File

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

View File

@@ -3,12 +3,10 @@ import {
shellQuote, shellQuote,
resolveFolderRemoteDir, resolveFolderRemoteDir,
REMOTE_ROOT, REMOTE_ROOT,
isSafeFolder,
} from "../utils/ssh.ts" } from "../utils/ssh.ts"
import {process} from "std-env"; const MAX_FILES_PER_FOLDER = Math.max(1, Number(process.env.BACKUPS_MAX_FILES) || 50)
const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES)
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
function isMissingPathError(error: unknown): boolean { function isMissingPathError(error: unknown): boolean {
@@ -32,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

@@ -0,0 +1,29 @@
# 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

@@ -1,6 +1,7 @@
export default defineEventHandler(async () => { export default defineEventHandler(async (event) => {
const token = process.env.DISCORD_BOT_TOKEN const config = useRuntimeConfig(event)
const channel = process.env.DISCORD_CHANNEL_ID const token = config.discordBotToken
const channel = config.discordChannelId
if (!token || !channel) { if (!token || !channel) {
throw createError({ throw createError({
@@ -19,7 +20,11 @@ export default defineEventHandler(async () => {
} }
) )
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

@@ -1,38 +1,49 @@
import { exec } from "child_process" import { execFile } from "node:child_process"
type DiskSource = { type DiskSource = {
key: string key: "remote" | "local"
label: string label: string
}
type CommandSpec = {
command: string command: string
args?: string[] args: string[]
cwd?: string
} }
const diskSources: DiskSource[] = [ const diskSources: DiskSource[] = [
{ {
key: "remote", key: "remote",
label: "Serveur distant", label: "Serveur distant"
command: "ssh",
args: []
}, },
{ {
key: "local", key: "local",
label: "Machine locale", label: "Machine locale"
command: "bash",
args: []
} }
] ]
function getEnvCommand(source: DiskSource) { function getCommand(source: DiskSource): CommandSpec {
const envKey = `DISK_COMMAND_${source.key.toUpperCase()}` const localScriptDir = process.env.DISK_LOCAL_SCRIPT_DIR || "/home/malio/Malio-ops/CheckStorage"
const legacyEnvKey = const remoteHost = process.env.DISK_REMOTE_HOST || "malio-b"
source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : "" const remoteScriptDir = process.env.DISK_REMOTE_SCRIPT_DIR || "/home/malio-b/Malio-ops/CheckStorage"
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || null if (source.key === "local") {
return {
command: "bash",
args: ["check-storage.sh"],
cwd: localScriptDir
}
}
return {
command: "ssh",
args: [remoteHost, `cd ${remoteScriptDir} && ./check-storage.sh`]
}
} }
function runShellCommand(command: string): Promise<string> { function runCommand({ command, args, cwd }: CommandSpec): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => { execFile(command, args, { cwd }, (error, stdout, stderr) => {
if (error) { if (error) {
reject(stderr || error.message) reject(stderr || error.message)
return return
@@ -46,12 +57,7 @@ export default defineEventHandler(async () => {
const results = await Promise.all( const results = await Promise.all(
diskSources.map(async (source) => { diskSources.map(async (source) => {
try { try {
const envCommand = getEnvCommand(source) const output = await runCommand(getCommand(source))
if (!envCommand) {
throw new Error(`Commande disque manquante pour ${source.key}`)
}
const output = await runShellCommand(envCommand)
return { return {
key: source.key, key: source.key,
label: source.label, label: source.label,

View File

@@ -3,11 +3,11 @@ import {
shellQuote, shellQuote,
resolveFolderRemoteDir, resolveFolderRemoteDir,
REMOTE_HOST, REMOTE_HOST,
isSafeFolder,
isSafeFile
} from "../utils/ssh.ts" } from "../utils/ssh.ts"
import { spawn } from "node:child_process" import { spawn } from "node:child_process"
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> { async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
const output = await runSsh(`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`) const output = await runSsh(`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`)
const fileName = output.trim() const fileName = output.trim()
@@ -30,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" })
@@ -45,7 +48,7 @@ export default defineEventHandler(async (event) => {
} }
const fileName = await getLatestRemoteFile(remoteDir) const fileName = await getLatestRemoteFile(remoteDir)
if (!fileName) { if (!fileName || !isSafeFile(fileName)) {
continue continue
} }
@@ -93,6 +96,6 @@ export default defineEventHandler(async (event) => {
console.error(`Erreur archive SSH (${code}): ${stderr}`) console.error(`Erreur archive SSH (${code}): ${stderr}`)
} }
}) })
event.node.res.on("close", () => child.kill())
return sendStream(event, child.stdout) return sendStream(event, child.stdout)
}) })

View File

@@ -3,13 +3,11 @@ import {
shellQuote, shellQuote,
resolveFolderRemoteDir, resolveFolderRemoteDir,
REMOTE_HOST, REMOTE_HOST,
isSafeFolder,
isSafeFile
} from "../utils/ssh.ts" } from "../utils/ssh.ts"
import { spawn } from "node:child_process" import { spawn } from "node:child_process"
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const isSafeFile = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
function buildContentDisposition(fileName: string) { function buildContentDisposition(fileName: string) {
const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_") const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}` return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
@@ -20,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" })
} }
@@ -61,6 +63,6 @@ export default defineEventHandler(async (event) => {
console.error(`Erreur téléchargement SSH (${code}): ${stderr}`) console.error(`Erreur téléchargement SSH (${code}): ${stderr}`)
} }
}) })
event.node.res.on("close", () => child.kill())
return sendStream(event, child.stdout) return sendStream(event, child.stdout)
}) })

View File

@@ -1,11 +1,14 @@
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const req = event.node.req const req = event.node.req
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024 // 100MB
let received = 0 let received = 0
for await (const chunk of req) { for await (const chunk of req) {
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

@@ -1,33 +1,160 @@
import targets from "../config/version-status-targets.json" import { readFile } from "node:fs/promises"
import { join } from "node:path"
type StatusEntry = {
checkedAt: string
status: "OK" | "DOWN"
host: string
detail: string
}
type StatusResult = {
label: string
url: string
ok: boolean
status: number
checkedAt: string
detail: string
}
const DEFAULT_RECETTE_SCRIPTS_DIR = "/home/malio/Malio-ops/RecetteScripts"
function parseEnvFile(content: string) {
const values: Record<string, string> = {}
for (const rawLine of content.split("\n")) {
const line = rawLine.trim()
if (!line || line.startsWith("#")) {
continue
}
const separatorIndex = line.indexOf("=")
if (separatorIndex === -1) {
continue
}
const key = line.slice(0, separatorIndex).trim()
const value = line.slice(separatorIndex + 1).trim()
if (!key) {
continue
}
values[key] = value.replace(/^['"]|['"]$/g, "")
}
return values
}
function getLogFileName(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `app_health_${year}-${month}-${day}.log`
}
function parseStatusLine(line: string): StatusEntry | null {
const parts = line.split(" | ")
if (parts.length < 4) {
return null
}
const [checkedAt, status, host, ...detailParts] = parts
if ((status !== "OK" && status !== "DOWN") || !host) {
return null
}
return {
checkedAt,
status,
host,
detail: detailParts.join(" | ")
}
}
function buildStatusResult(entry: StatusEntry): StatusResult {
return {
label: entry.host,
url: `http://${entry.host}/`,
ok: entry.status === "OK",
status: entry.status === "OK" ? 200 : 0,
checkedAt: entry.checkedAt,
detail: entry.detail
}
}
export default defineEventHandler(async () => { export default defineEventHandler(async () => {
const results = await Promise.all( const recetteScriptsDir = process.env.RECETTE_SCRIPTS_DIR || DEFAULT_RECETTE_SCRIPTS_DIR
targets.map(async (target) => { const envFilePath = join(recetteScriptsDir, ".env")
try {
const response = await fetch(target.url, {
method: "GET",
headers: { Accept: "application/json" }
})
return { try {
label: target.label, const envFileContent = await readFile(envFilePath, "utf8")
url: target.url, const envValues = parseEnvFile(envFileContent)
ok: response.status === 200, const logDir = envValues.APP_LOG_DIR
status: response.status,
checkedAt: new Date().toISOString() if (!logDir) {
} throw createError({
} catch (error) { statusCode: 500,
return { statusMessage: "Variable APP_LOG_DIR manquante"
label: target.label, })
url: target.url, }
ok: false,
status: 0, const logFilePath = join(logDir, getLogFileName(new Date()))
checkedAt: new Date().toISOString(), const logFileContent = await readFile(logFilePath, "utf8")
error: error instanceof Error ? error.message : String(error) const latestEntriesByHost = new Map<string, StatusEntry>()
}
for (const line of logFileContent.split("\n")) {
const entry = parseStatusLine(line)
if (!entry) {
continue
} }
})
)
return { results } latestEntriesByHost.set(entry.host, entry)
}
const configuredHosts = (envValues.APP_URLS || "")
.split(/\s+/)
.map((host) => host.trim())
.filter(Boolean)
const orderedResults = configuredHosts
.map((host) => latestEntriesByHost.get(host))
.filter((entry): entry is StatusEntry => Boolean(entry))
.map(buildStatusResult)
const remainingResults = Array.from(latestEntriesByHost.entries())
.filter(([host]) => !configuredHosts.includes(host))
.map(([, entry]) => buildStatusResult(entry))
const results = [...orderedResults, ...remainingResults]
if (results.length === 0) {
throw createError({
statusCode: 503,
statusMessage: "Aucun statut disponible"
})
}
return {
results
}
} catch (error) {
console.error("Erreur lecture status recette:", error)
if (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
"statusMessage" in error
) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: "Erreur lors de l'opération"
})
}
}) })

View File

@@ -1,5 +0,0 @@
[
{ "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" }
]

View File

@@ -1,3 +1,9 @@
// SECURITE:
// Ce middleware pose automatiquement le cookie d'authentification pour tout
// visiteur de l'interface web. Ce comportement repose sur l'hypothèse que
// Supervisor n'est exposé qu'à un réseau de confiance ou derrière un contrôle
// d'accès externe. Si l'application devient publiquement accessible, ce
// mécanisme ne constitue pas une authentification utilisateur.
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const path = event.path || event.node.req.url || "" const path = event.path || event.node.req.url || ""
@@ -16,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
@@ -25,7 +27,21 @@ export const backupScripts: BackupScript[] = [
} }
] ]
const getDefaultBackupScriptCommands = (): Record<string, string> => {
const recetteScriptsDir = process.env.RECETTE_SCRIPTS_DIR || "/home/malio/Malio-ops/RecetteScripts"
const vaultwardenHost = process.env.VAULTWARDEN_SSH_HOST || "bitwarden"
const vaultwardenScriptsDir =
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-vaultwarden":
`ssh ${shellQuote(vaultwardenHost)} "cd ${shellQuote(vaultwardenScriptsDir)} && bash backup-vaultwarden.sh"`
}
}
export function getBackupScriptCommand(key: string) { export function getBackupScriptCommand(key: string) {
const envKey = `BACKUP_SCRIPT_COMMAND_${key.toUpperCase().replace(/-/g, "_")}` const envKey = `BACKUP_SCRIPT_COMMAND_${key.toUpperCase().replace(/-/g, "_")}`
return process.env[envKey] || null return process.env[envKey] || getDefaultBackupScriptCommands()[key] || null
} }

View File

@@ -1,20 +1,23 @@
import { execFile } from "node:child_process" import {execFile} from "node:child_process"
import {process} from "std-env";
import folderMap from "#server/config/backup-folders.json"; import folderMap from "#server/config/backup-folders.json";
export const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST export const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST
export const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups" export const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
export const FOLDER_MAP = folderMap as Record<string, string> export const FOLDER_MAP = folderMap as Record<string, string>
export const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
export const isSafeFile = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
export const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'` export const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
export function runSsh(command: string): Promise<string> { export function runSsh(command: string): Promise<string> {
if (!REMOTE_HOST) {
return Promise.reject(new Error("BACKUPS_REMOTE_HOST is not configured"))
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile( execFile(
"ssh", "ssh",
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command], ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
{ maxBuffer: 10 * 1024 * 1024 }, {maxBuffer: 10 * 1024 * 1024},
(error, stdout, stderr) => { (error, stdout, stderr) => {
if (error) { if (error) {
reject(stderr || error.message) reject(stderr || error.message)
@@ -42,7 +45,7 @@ export async function resolveFolderRemoteDir(folderName: string): Promise<string
return direct return direct
} }
const nested = `${REMOTE_ROOT}/bdd_recette/${folderName}` const nested = `${REMOTE_ROOT}/bdd-recette/${folderName}`
if (await remoteDirExists(nested)) { if (await remoteDirExists(nested)) {
return nested return nested
} }