1er commit, mise en service

This commit is contained in:
2025-09-28 11:00:47 +02:00
parent be15fde570
commit 863666b009
8 changed files with 772 additions and 1 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
.env
.env.*

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Informations Discord
DISCORD_TOKEN=
STATUS_GUILD_ID=
STATUS_CHANNEL_ID=
ADMIN_USER_ID=
# Configuration bot
CONFIG_PATH=
REFRESH_INTERVAL=
STICKY_MESSAGE=

6
Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM node:20-slim
WORKDIR /app
COPY package.json ./
RUN npm install --only=prod
COPY . .
CMD ["npm", "start"]

View File

@@ -1,3 +1,3 @@
# UmbraMonitor
Bot Discord qui indique l'état des différents services Umbra
Bot Discord de monitoring pour l'infrastructure Umbra. Il vérifie périodiquement le serveur de synchronisation, le serveur de fichiers, le serveur d'identification ainsi que les endpoints externes (dépôt Dalamud, serveur Git) et publie un message « sticky » dans un canal Discord dédié.

49
config-example.yml Normal file
View File

@@ -0,0 +1,49 @@
# Services
services:
- name: "Serveur de synchronisation"
health_url: ""
metrics_url: ""
timeout_seconds: 5
- name: "Serveur de fichier"
health_url: ""
metrics_url: ""
timeout_seconds: 5
- name: "Serveur d'identification"
health_url: ""
metrics_url: ""
timeout_seconds: 5
- name: "Dépôt Dalamud"
health_url: ""
metrics_url: ""
timeout_seconds: 5
info:
- label: "Version plugin"
source: "health_json"
path: "0.AssemblyVersion"
- name: "Serveur Git"
health_url: ""
metrics_url: ""
timeout_seconds: 5
info:
- label: "Commits totaux"
source: "rss_multi"
urls:
- ""
path: "rss.channel.item"
aggregate: "count"
- label: "Dernier commit"
source: "rss_multi"
urls:
- ""
path: "rss.channel.item"
aggregate: "latest_title"
- label: "Publié le"
source: "rss_multi"
urls:
- ""
path: "rss.channel.item"
aggregate: "latest_pubdate"

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
umbra-monitor-bot-js:
build: .
container_name: umbra-monitor-bot-js
restart: unless-stopped
env_file: .env
volumes:
- ./config.yml:/app/config.yml:ro

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "umbra-monitor-bot",
"version": "1.0.0",
"description": "Discord Monitoring pour les services Umbra",
"main": "src/bot.js",
"type": "commonjs",
"scripts": {
"start": "node src/bot.js"
},
"dependencies": {
"discord.js": "^14.15.3",
"dotenv": "^16.4.5",
"js-yaml": "^4.1.0",
"undici": "^6.19.8"
}
}

674
src/bot.js Normal file
View File

@@ -0,0 +1,674 @@
require('dotenv').config();
const { Client, GatewayIntentBits, EmbedBuilder } = require('discord.js');
const { fetch } = require('undici');
const fs = require('fs');
const yaml = require('js-yaml');
const DISCORD_TOKEN = process.env.DISCORD_TOKEN;
const STATUS_GUILD_ID = process.env.STATUS_GUILD_ID;
const STATUS_CHANNEL_ID = process.env.STATUS_CHANNEL_ID;
const ADMIN_USER_ID = process.env.ADMIN_USER_ID;
const CONFIG_PATH = process.env.CONFIG_PATH || './config.yml';
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL || '60', 10);
const STICKY_MESSAGE = String(process.env.STICKY_MESSAGE || 'true').toLowerCase() === 'true';
const requiredEnv = {
DISCORD_TOKEN,
STATUS_GUILD_ID,
STATUS_CHANNEL_ID,
ADMIN_USER_ID,
};
for (const [name, value] of Object.entries(requiredEnv)) {
if (!value) {
console.error(`Missing ${name} in .env`);
process.exit(1);
}
}
function loadConfig() {
const text = fs.readFileSync(CONFIG_PATH, 'utf8');
return yaml.load(text);
}
function parseProm(text) {
const out = {};
for (const line of text.split(/\r?\n/)) {
if (!line || line.startsWith('#')) continue;
const m = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)\s+([0-9eE+\-\.]+)$/);
if (m) {
const [, name, val] = m;
const num = Number(val);
if (!Number.isNaN(num)) out[name] = num;
}
}
return out;
}
function pathToTokens(path) {
if (!path) return [];
return path
.split('.')
.flatMap(segment => segment
.split(/\[(\d+)\]/)
.filter(Boolean)
.map(token => (/^\d+$/.test(token) ? Number(token) : token))
);
}
function getValueAtPath(obj, path) {
if (!path) return obj;
const tokens = pathToTokens(path);
let current = obj;
for (const token of tokens) {
if (current === null || current === undefined) return undefined;
current = current[token];
}
return current;
}
async function fetchJsonWithTimeout(url, timeoutMs) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const resp = await fetch(url, { signal: ctrl.signal });
clearTimeout(timer);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const text = await resp.text();
const trimmed = text.trim();
const json = trimmed ? JSON.parse(trimmed) : null;
return { json, headers: resp.headers };
} finally {
clearTimeout(timer);
}
}
function decodeXmlEntities(str) {
if (!str || typeof str !== 'string') return str;
return str
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
}
function extractTag(text, tag) {
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i');
const match = regex.exec(text);
return match ? decodeXmlEntities(match[1].trim()) : null;
}
function parseRss(text) {
const channelMatch = /<channel>([\s\S]*?)<\/channel>/i.exec(text);
const channelText = channelMatch ? channelMatch[1] : text;
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/gi;
let itemMatch;
while ((itemMatch = itemRegex.exec(channelText))) {
const item = itemMatch[1];
items.push({
title: extractTag(item, 'title'),
link: extractTag(item, 'link'),
description: extractTag(item, 'description'),
author: extractTag(item, 'author'),
pubDate: extractTag(item, 'pubDate'),
guid: extractTag(item, 'guid'),
});
}
return {
rss: {
channel: {
title: extractTag(channelText, 'title'),
link: extractTag(channelText, 'link'),
description: extractTag(channelText, 'description'),
item: items,
},
},
};
}
function normalizeRssItem(item) {
if (!item) return null;
if (typeof item === 'string') {
return { title: stripHtml(item), pubDate: null };
}
if (typeof item === 'object') {
const out = { ...item };
if (out.title) out.title = stripHtml(out.title);
if (out.pubDate) out.pubDate = stripHtml(out.pubDate);
return out;
}
return null;
}
async function fetchRssWithTimeout(url, timeoutMs) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const resp = await fetch(url, { signal: ctrl.signal });
clearTimeout(timer);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const text = await resp.text();
return parseRss(text);
} finally {
clearTimeout(timer);
}
}
function applyInfoTransform(info, value) {
if (value === undefined || value === null) return value;
if (!info || typeof info !== 'object') return value;
if (info.transform === 'short_sha') {
return String(value).slice(0, 7);
}
if (info.transform === 'discord_time') {
const date = new Date(value);
if (!Number.isNaN(date.getTime())) {
const unixSeconds = Math.floor(date.getTime() / 1000);
return `<t:${unixSeconds}:f>`;
}
}
if (info.transform === 'first_line') {
return String(value).split(/\r?\n/)[0];
}
if (info.slice && Number.isInteger(info.slice) && info.slice > 0) {
return String(value).slice(0, info.slice);
}
return value;
}
async function collectExtras(svc, context) {
const extras = [];
const infoEntries = Array.isArray(svc.info) ? svc.info : [];
if (!infoEntries.length) return extras;
const dataCache = new Map();
for (const info of infoEntries) {
if (!info || typeof info !== 'object') continue;
const label = formatExtraLabel(info.label || info.path || info.source || 'Info');
const fallback = info.fallback !== undefined ? info.fallback : 'N/A';
let value;
try {
switch (info.source) {
case 'health_json':
value = getValueAtPath(context.healthJson, info.path);
break;
case 'metrics':
value = getValueAtPath(context.metrics, info.path);
break;
case 'json': {
const targetUrl = info.url || svc.health_url || svc.metrics_url;
if (!targetUrl) throw new Error('URL manquante pour la source json');
let cached = dataCache.get(`json:${targetUrl}`);
if (!cached) {
cached = await fetchJsonWithTimeout(targetUrl, context.timeoutMs);
dataCache.set(`json:${targetUrl}`, cached);
}
if (info.header && cached.headers) {
const headerVal = cached.headers.get(info.header);
if (headerVal !== null) {
value = /^\d+$/.test(headerVal) ? Number(headerVal) : headerVal;
}
}
if (value === undefined) {
value = getValueAtPath(cached.json, info.path);
}
break;
}
case 'rss': {
const targetUrl = info.url || svc.health_url;
if (!targetUrl) throw new Error('URL manquante pour la source rss');
let cached = dataCache.get(`rss:${targetUrl}`);
if (!cached) {
cached = await fetchRssWithTimeout(targetUrl, context.timeoutMs);
dataCache.set(`rss:${targetUrl}`, cached);
}
value = getValueAtPath(cached, info.path);
break;
}
case 'rss_multi': {
const urls = Array.isArray(info.urls) && info.urls.length ? info.urls : (info.url ? [info.url] : []);
if (!urls.length) throw new Error('URLs manquantes pour rss_multi');
const items = [];
for (const url of urls) {
let cached = dataCache.get(`rss:${url}`);
if (!cached) {
cached = await fetchRssWithTimeout(url, context.timeoutMs);
dataCache.set(`rss:${url}`, cached);
}
const rssPath = info.path || 'rss.channel.item';
const pathVal = getValueAtPath(cached, rssPath);
if (Array.isArray(pathVal)) {
for (const entry of pathVal) {
const norm = normalizeRssItem(entry);
if (norm) items.push(norm);
}
} else {
const norm = normalizeRssItem(pathVal);
if (norm) items.push(norm);
}
}
if (info.aggregate === 'count') {
value = items.length;
} else if (info.aggregate === 'latest_title' || info.aggregate === 'latest_pubdate') {
let latest = null;
for (const entry of items) {
const date = entry.pubDate ? new Date(entry.pubDate) : null;
const timestamp = date && !Number.isNaN(date.getTime()) ? date.getTime() : null;
if (!latest || (timestamp !== null && timestamp > latest.timestamp)) {
latest = { entry, timestamp };
}
}
if (!latest) {
value = undefined;
} else if (info.aggregate === 'latest_title') {
value = latest.entry.title || undefined;
} else {
if (latest.timestamp) {
const unixSeconds = Math.floor(latest.timestamp / 1000);
value = `<t:${unixSeconds}:f>`;
} else {
value = undefined;
}
}
} else {
value = items.map(entry => entry.title).filter(Boolean).join(', ');
}
break;
}
default:
value = undefined;
}
} catch (err) {
console.warn(`Info extraction failed for ${label}:`, err);
value = fallback;
}
if (value === undefined || value === null || value === '') {
value = fallback;
} else {
value = applyInfoTransform(info, value);
}
extras.push({ label, value });
}
return extras;
}
const metricNameOverrides = {
initialized_connections: 'Connexions actives',
user_push: 'Clients source',
user_push_to: 'Clients destinataires',
mare_users_registered: 'Comptes enregistrés',
mare_pairs: 'Paires actives',
mare_groups: 'Syncshells actives',
mare_authorized_connections: 'Utilisateurs connectés',
accounts_created: 'Comptes créés',
mare_files_size: 'Poids total',
mare_files: 'Fichiers stockés',
ping_ms: 'Ping',
server_ping_ms: 'Ping',
mare_ping_ms: 'Ping',
};
function pickKeyMetrics(name, all) {
const perService = {
'Serveur de synchronisation': [
'initialized_connections',
'user_push',
'user_push_to',
'mare_pairs',
'mare_groups',
],
'Serveur de fichier': ['mare_files', 'mare_files_size'],
"Serveur d'identification": [],
};
const selectedKeys = perService[name] || [];
const result = {};
for (const key of selectedKeys) {
if (key in all) result[key] = all[key];
}
if (Object.keys(result).length) return result;
return all;
}
function pickFallbackService(name) {
if (name === "Serveur d'identification") return 'Serveur de synchronisation';
return null;
}
function choosePingMetric(allMetrics) {
if ('ping_ms' in allMetrics) return ['ping_ms', allMetrics.ping_ms];
if ('server_ping_ms' in allMetrics) return ['server_ping_ms', allMetrics.server_ping_ms];
if ('mare_ping_ms' in allMetrics) return ['mare_ping_ms', allMetrics.mare_ping_ms];
return null;
}
function formatMetrics(name, allMetrics, metricsByService) {
const selected = pickKeyMetrics(name, allMetrics);
const entries = [];
const skipKeys = new Set(['ping_ms', 'server_ping_ms', 'mare_ping_ms']);
const fallbackName = pickFallbackService(name);
const fallbackMetrics = fallbackName ? (metricsByService[fallbackName] || {}) : {};
if (name === "Serveur d'identification") {
const usersConnected = allMetrics.mare_authorized_connections ?? fallbackMetrics.mare_authorized_connections;
const usersRegistered = allMetrics.mare_users_registered ?? fallbackMetrics.mare_users_registered;
entries.push([
'mare_authorized_connections',
usersConnected !== undefined ? usersConnected : 'N/A',
]);
entries.push([
'mare_users_registered',
usersRegistered !== undefined ? usersRegistered : 'N/A',
]);
} else {
for (const key of Object.keys(selected)) {
if (skipKeys.has(key)) continue;
entries.push([key, selected[key]]);
}
}
if ('process_start_time_seconds' in allMetrics) {
entries.push(['_uptime', computeUptime(allMetrics.process_start_time_seconds)]);
}
const pingEntry = choosePingMetric(allMetrics) || (fallbackName ? choosePingMetric(fallbackMetrics) : null);
if (pingEntry) entries.unshift(pingEntry);
return entries.filter(([, v]) => v !== undefined);
}
function prettyMetricName(key) {
if (key === '_uptime') return 'Temps de fonctionnement';
if (key === '_version') return 'Version attendue';
if (metricNameOverrides[key]) return metricNameOverrides[key];
return key.replace(/^mare_(gauge|counter)_/, '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function prettyMetricValue(key, value) {
if (key === '_uptime') return value;
if (key === '_version') return String(value);
if (typeof value === 'string') return value;
if (['ping_ms', 'server_ping_ms', 'mare_ping_ms'].includes(key)) {
return `${Math.round(Number(value))} ms`;
}
const byteKeys = [
'process_virtual_memory_bytes',
'process_working_set_bytes',
'process_private_memory_bytes',
'files_request_size',
'files_size',
'files_size_cold',
'mare_files_size',
];
if (byteKeys.includes(key) && typeof value === 'number') {
return humanFileSize(value);
}
if (Number.isInteger(value)) return formatNumber(value);
return Number(value.toFixed(2));
}
function stripHtml(text) {
if (typeof text !== 'string') return text;
return text.replace(/<(?!t:)[^>]*>/g, '').trim();
}
function formatExtraValue(value) {
if (Array.isArray(value)) {
return value.map(v => formatExtraValue(v)).join(', ');
}
if (value && typeof value === 'object') {
return Object.values(value).map(v => formatExtraValue(v)).join(', ');
}
if (typeof value === 'string') {
value = stripHtml(value);
}
const formatted = prettyMetricValue('_extra', value);
if (typeof formatted === 'number') return formatted.toString();
return formatted;
}
function formatExtraLabel(label) {
if (!label) return '';
switch (label) {
case 'rss.channel.item[0].title':
return 'Dernier commit';
case 'rss.channel.item[0].pubDate':
return 'Publié';
default:
return label;
}
}
function humanFileSize(bytes) {
if (!Number.isFinite(bytes)) return bytes;
const units = ['o', 'Ko', 'Mo', 'Go', 'To', 'Po'];
let idx = 0;
let val = bytes;
while (val >= 1024 && idx < units.length - 1) {
val /= 1024;
idx += 1;
}
return `${val.toFixed(val >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
}
function formatNumber(value) {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
function computeUptime(startSeconds) {
const now = Date.now() / 1000;
const diff = Math.max(0, now - Number(startSeconds));
if (diff < 60) return '<1m';
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
const minutes = Math.floor((diff % 3600) / 60);
if (days > 0) return `${days}j ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
async function checkService(svc) {
const res = { healthy: null, http_code: null, error: null, metrics: {}, metrics_error: null, ping_ms: null, extras: [] };
const timeoutMs = Math.max(1000, Math.floor((svc.timeout_seconds || 5) * 1000));
const infoEntries = Array.isArray(svc.info) ? svc.info : [];
const wantsHealthJson = infoEntries.some(info => info && info.source === 'health_json');
let healthJson = null;
// health
if (svc.health_url) {
try {
const ctrl = new AbortController();
const started = Date.now();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
const r = await fetch(svc.health_url, { signal: ctrl.signal });
clearTimeout(t);
res.http_code = r.status;
res.healthy = r.status === 200;
res.ping_ms = Date.now() - started;
if (r.status === 200 && wantsHealthJson) {
try {
const text = await r.text();
const trimmed = text.trim();
if (trimmed) healthJson = JSON.parse(trimmed);
} catch {
healthJson = null;
}
}
} catch (e) {
const cause = e && typeof e === 'object' ? e.cause : null;
const extra = cause && typeof cause === 'object' ? (cause.code || cause.errno || cause.syscall || cause.message) : null;
res.error = extra ? `${e.message || e} (${extra})` : String(e.message || e);
res.healthy = false;
}
}
// metrics
if (svc.metrics_url) {
try {
const ctrl = new AbortController();
const started = Date.now();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
const r = await fetch(svc.metrics_url, { signal: ctrl.signal });
clearTimeout(t);
if (r.status === 200) {
const txt = await r.text();
const all = parseProm(txt);
res.metrics = all;
if (res.ping_ms === null) res.ping_ms = Date.now() - started;
} else {
res.metrics_error = `HTTP ${r.status}`;
}
} catch (e) {
const cause = e && typeof e === 'object' ? e.cause : null;
const extra = cause && typeof cause === 'object' ? (cause.code || cause.errno || cause.syscall || cause.message) : null;
res.metrics_error = extra ? `${e.message || e} (${extra})` : String(e.message || e);
}
}
try {
res.extras = await collectExtras(svc, { healthJson, metrics: res.metrics, timeoutMs });
} catch (e) {
res.extras = [{ label: 'Infos', value: `Erreur: ${e.message || e}` }];
}
return res;
}
function buildEmbed(results) {
const ok = results.filter(r => r.status.healthy === true || r.status.healthy === null).length;
const total = results.length;
const now = new Date();
const unixSeconds = Math.floor(now.getTime() / 1000);
const discordTimestamp = `<t:${unixSeconds}:f>`;
const emb = new EmbedBuilder()
.setTitle('État du système')
.setDescription(`**${ok}/${total} services nominal**\nDernière actualisation : ${discordTimestamp}`)
.setColor(0x8D37C0)
const metricsByService = {};
for (const r of results) {
const base = { ...(r.status.metrics || {}) };
if (typeof r.status.ping_ms === 'number') base.ping_ms = r.status.ping_ms;
metricsByService[r.name || 'Unnamed'] = base;
}
for (const r of results) {
const { name, status } = r;
const healthy = status.healthy;
const icon = healthy === true ? '🟢' : (healthy === null ? '🟡' : '🔴');
let value = `${icon} **${name}**`;
if (status.http_code !== null && status.http_code !== undefined && status.http_code !== 200) {
value += ` — HTTP ${status.http_code}`;
}
if (status.error) value += `\n\`health:\` ${status.error}`;
if (status.metrics_error) value += `\n\`metrics:\` ${status.metrics_error}`;
const m = { ...(status.metrics || {}) };
if (typeof status.ping_ms === 'number') m.ping_ms = status.ping_ms;
const entries = formatMetrics(name, m, metricsByService).slice(0, 6);
if (entries.length) {
const pretty = entries.map(([k, v]) => `- ${prettyMetricName(k)}: ${prettyMetricValue(k, v)}`).join('\n');
value += `\n${pretty}`;
}
const extras = status.extras || [];
if (extras.length) {
const extraPretty = extras.map(({ label, value: extraVal }) => `- ${label}: ${formatExtraValue(extraVal)}`).join('\n');
value += `\n${extraPretty}`;
}
emb.addFields({ name: '\u200b', value, inline: false });
}
return emb;
}
async function runChecks(services) {
const out = [];
for (const svc of services) {
const status = await checkService(svc);
out.push({ name: svc.name || 'Unnamed', status });
}
return out;
}
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
let pinnedMessageId = null;
let statusChannel = null;
async function ensureStatusChannel(client) {
if (statusChannel && statusChannel.isTextBased() && statusChannel.guildId === STATUS_GUILD_ID) {
return statusChannel;
}
statusChannel = await client.channels.fetch(STATUS_CHANNEL_ID).catch(() => null);
if (!statusChannel || !statusChannel.isTextBased() || statusChannel.guildId !== STATUS_GUILD_ID) {
console.error('Unable to resolve status channel in the configured guild.');
statusChannel = null;
}
if (statusChannel && STICKY_MESSAGE && !pinnedMessageId) {
try {
const pinned = await statusChannel.messages.fetchPins();
const existing = pinned.find((msg) => msg.author.id === client.user.id);
if (existing) pinnedMessageId = existing.id;
} catch {}
}
return statusChannel;
}
async function publishStatus(client) {
const channel = await ensureStatusChannel(client);
if (!channel) return;
const cfg = loadConfig();
const results = await runChecks(cfg.services || []);
const embed = buildEmbed(results);
console.log(`[refresh] ${new Date().toISOString()} — services: ${results.length}`);
if (STICKY_MESSAGE && pinnedMessageId) {
try {
const msg = await channel.messages.fetch(pinnedMessageId);
await msg.edit({ embeds: [embed], allowedMentions: { parse: [] } });
return;
} catch {
pinnedMessageId = null;
}
}
const sent = await channel.send({ embeds: [embed], allowedMentions: { parse: [] } });
if (STICKY_MESSAGE) {
pinnedMessageId = sent.id;
try { await sent.pin(); } catch {}
}
}
client.once('clientReady', async () => {
console.log(`Logged in as ${client.user.tag}`);
try {
await publishStatus(client);
} catch (e) {
console.error('Initial publish failed:', e);
}
setInterval(async () => {
try {
await publishStatus(client);
} catch (e) {
console.error('Periodic update failed:', e);
}
}, Math.max(10, REFRESH_INTERVAL) * 1000);
});
client.login(DISCORD_TOKEN);