const path = require('path');
const dotenv = require('dotenv');
const { Client, GatewayIntentBits, EmbedBuilder } = require('discord.js');
const { fetch } = require('undici');
const fs = require('fs');
const yaml = require('js-yaml');
const envPath = path.resolve(__dirname, '..', '.env');
const envResult = dotenv.config({ path: envPath });
if (envResult.error) {
dotenv.config();
}
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 RESOLVED_CONFIG_PATH = path.isAbsolute(CONFIG_PATH)
? CONFIG_PATH
: path.resolve(__dirname, '..', CONFIG_PATH);
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL || '60', 10);
const STICKY_MESSAGE = String(process.env.STICKY_MESSAGE || 'true').toLowerCase() === 'true';
const PEAKS_PATH = process.env.PEAKS_PATH || './peaks.json';
const RESOLVED_PEAKS_PATH = path.isAbsolute(PEAKS_PATH)
? PEAKS_PATH
: path.resolve(__dirname, '..', PEAKS_PATH);
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(RESOLVED_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(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&/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 extractAtomLink(text) {
if (!text || typeof text !== 'string') return null;
const match = text.match(/]*href=['"]([^'"]+)['"][^>]*>/i);
if (match && match[1]) return decodeXmlEntities(match[1].trim());
return extractTag(text, 'link');
}
function parseRss(text) {
const channelMatch = /]*>([\s\S]*?)<\/channel>/i.exec(text);
const feedMatch = /]*>([\s\S]*?)<\/feed>/i.exec(text);
const channelText = channelMatch ? channelMatch[1] : (feedMatch ? feedMatch[1] : text);
const items = [];
const itemRegex = /- ]*>([\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') || extractTag(item, 'content:encoded'),
author: extractTag(item, 'author'),
pubDate: extractTag(item, 'pubDate') || extractTag(item, 'published') || extractTag(item, 'updated'),
guid: extractTag(item, 'guid') || extractTag(item, 'id'),
updated: extractTag(item, 'updated'),
published: extractTag(item, 'published'),
});
}
if (!items.length && feedMatch) {
const entryRegex = /]*>([\s\S]*?)<\/entry>/gi;
let entryMatch;
while ((entryMatch = entryRegex.exec(feedMatch[1]))) {
const entry = entryMatch[1];
const fullEntry = entryMatch[0];
items.push({
title: extractTag(entry, 'title'),
link: extractAtomLink(fullEntry),
description: extractTag(entry, 'summary') || extractTag(entry, 'content'),
author: extractTag(entry, 'author'),
pubDate: extractTag(entry, 'published') || extractTag(entry, 'updated'),
guid: extractTag(entry, 'id'),
updated: extractTag(entry, 'updated'),
published: extractTag(entry, 'published'),
});
}
}
return {
rss: {
channel: {
title: extractTag(channelText, 'title'),
link: extractAtomLink(channelMatch ? channelMatch[0] : (feedMatch ? feedMatch[0] : channelText)),
description: extractTag(channelText, 'description') || extractTag(channelText, 'subtitle'),
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);
if (!out.pubDate) {
if (out.published) out.pubDate = stripHtml(out.published);
else if (out.updated) out.pubDate = stripHtml(out.updated);
}
if (out.published) out.published = stripHtml(out.published);
if (out.updated) out.updated = stripHtml(out.updated);
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) {
const err = new Error(`HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
const text = await resp.text();
return parseRss(text);
} finally {
clearTimeout(timer);
}
}
async function gatherRssItems(urls, path, dataCache, timeoutMs) {
const items = [];
for (const url of urls) {
if (!url) continue;
let cached = dataCache.get(`rss:${url}`);
if (!cached) {
cached = await fetchRssWithTimeout(url, timeoutMs);
dataCache.set(`rss:${url}`, cached);
}
const rssPath = 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);
}
}
return items;
}
function aggregateRssItems(items, aggregate) {
if (aggregate === 'count') {
return items.length;
}
if (aggregate === 'latest_title' || aggregate === 'latest_pubdate') {
let latest = null;
for (const entry of items) {
const date = entry && 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) return undefined;
if (aggregate === 'latest_title') {
return latest.entry.title || undefined;
}
if (latest.timestamp) {
return new Date(latest.timestamp).toISOString();
}
return latest.entry.pubDate || undefined;
}
return items.map(entry => entry.title).filter(Boolean).join(', ');
}
function extractFirstCommitMessageFromDescription(description) {
if (!description || typeof description !== 'string') return null;
const liMatch = description.match(/]*>([\s\S]*?)<\/li>/i);
if (!liMatch) return null;
const liContent = liMatch[1];
const anchorMatch = liContent.match(/]*>([\s\S]*?)<\/a>/i);
const text = anchorMatch ? anchorMatch[1] : liContent;
const cleaned = stripHtml(text).replace(/\s+/g, ' ').trim();
return cleaned || null;
}
function buildCommitInfo(prefix, branch, commit) {
if (!commit) return null;
const message = (commit.summary || commit.message || '').trim();
const title = message || prefix;
let location = prefix || '';
if (branch) {
location = location ? `${location} (${branch})` : branch;
}
const detailParts = [];
if (location) detailParts.push(location);
if (commit.author) detailParts.push(`par ${commit.author}`);
const details = detailParts.join(' — ');
return { title, details };
}
function computeGiteaFallbackAggregate(items, aggregate) {
if (aggregate === 'count') return items.length;
let latest = null;
for (const entry of items) {
if (!entry) continue;
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) return undefined;
if (aggregate === 'latest_pubdate') {
if (latest.timestamp) {
return new Date(latest.timestamp).toISOString();
}
return latest.entry.pubDate || undefined;
}
if (aggregate === 'latest_title') {
const title = stripHtml(latest.entry.title || '');
const message = extractFirstCommitMessageFromDescription(latest.entry.description) || '';
if (title || message) {
const primary = message || title;
const secondary = title && title !== primary ? title : (message && message !== primary ? message : '');
const info = { title: primary };
if (secondary) info.details = secondary;
return info;
}
return undefined;
}
return items.map(entry => entry.title).filter(Boolean).join(', ');
}
function normalizeGiteaRepos(repos, defaultOwner) {
if (!repos) return [];
const list = Array.isArray(repos) ? repos : [repos];
const out = [];
for (const entry of list) {
if (!entry) continue;
if (typeof entry === 'string') {
const trimmed = entry.trim();
if (!trimmed) continue;
if (trimmed.includes('/')) {
const [owner, repo] = trimmed.split('/', 2);
if (owner && repo) {
out.push({
owner,
repo,
branch: null,
displayName: `${owner}/${repo}`,
});
}
} else if (defaultOwner) {
out.push({
owner: defaultOwner,
repo: trimmed,
branch: null,
displayName: `${defaultOwner}/${trimmed}`,
});
}
} else if (typeof entry === 'object') {
const owner = entry.owner || defaultOwner;
const repo = entry.repo;
if (!owner || !repo) continue;
out.push({
owner,
repo,
branch: entry.branch || null,
displayName: entry.display_name || entry.displayName || `${owner}/${repo}`,
});
}
}
return out;
}
function resolveGiteaToken(info) {
if (info && typeof info === 'object') {
if (info.token) return info.token;
if (info.token_env && process.env[info.token_env]) return process.env[info.token_env];
}
return process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN || null;
}
function shouldUseGiteaFallback(error) {
const status = error && typeof error === 'object' ? error.status : null;
return status === 401 || status === 403 || status === 404;
}
function pickGiteaCommitTimestamp(commit) {
const candidates = [
commit?.commit?.author?.date,
commit?.commit?.committer?.date,
commit?.author?.date,
commit?.committer?.date,
];
for (const candidate of candidates) {
if (!candidate) continue;
const ts = Date.parse(candidate);
if (!Number.isNaN(ts)) return ts;
}
return null;
}
async function fetchGiteaRepoSummary({ baseUrl, owner, repo, branch, token, timeoutMs }) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const url = new URL(`/api/v1/repos/${owner}/${repo}/commits`, baseUrl);
url.searchParams.set('limit', '1');
if (branch) url.searchParams.set('sha', branch);
const headers = {};
if (token) headers.Authorization = `token ${token}`;
const resp = await fetch(url, { signal: ctrl.signal, headers });
clearTimeout(timer);
if (!resp.ok) {
const err = new Error(`HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
const text = await resp.text();
let commits = [];
if (text.trim()) {
try {
commits = JSON.parse(text);
} catch {
commits = [];
}
}
const totalHeader = resp.headers.get('x-total-count');
const totalCommits = totalHeader && /^\d+$/.test(totalHeader) ? Number(totalHeader) : null;
const latestCommitRaw = Array.isArray(commits) && commits.length ? commits[0] : null;
const latestCommit = latestCommitRaw
? {
sha: latestCommitRaw.sha || latestCommitRaw.id || null,
message: latestCommitRaw.commit?.message || latestCommitRaw.message || '',
summary: (() => {
const msg = latestCommitRaw.commit?.message || latestCommitRaw.message || '';
return msg ? msg.split(/\r?\n/)[0].trim() : '';
})(),
author:
latestCommitRaw.commit?.author?.name ||
latestCommitRaw.author?.login ||
latestCommitRaw.author?.username ||
latestCommitRaw.author?.name ||
latestCommitRaw.commit?.committer?.name ||
null,
url: latestCommitRaw.html_url || latestCommitRaw.url || latestCommitRaw.commit?.url || null,
timestamp: pickGiteaCommitTimestamp(latestCommitRaw),
}
: null;
return { totalCommits, latestCommit };
} 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 ``;
}
}
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 = await gatherRssItems(urls, info.path, dataCache, context.timeoutMs);
value = aggregateRssItems(items, info.aggregate);
break;
}
case 'gitea_multi': {
const baseUrlRaw = info.base_url || svc.health_url || '';
if (!baseUrlRaw) throw new Error('URL manquante pour gitea_multi');
const baseUrl = baseUrlRaw.replace(/\/+$/, '');
const repoEntries = normalizeGiteaRepos(info.repos, info.owner);
if (!repoEntries.length) throw new Error('Dépôts manquants pour gitea_multi');
const authToken = resolveGiteaToken(info);
const summaries = [];
let fetchError = null;
for (const repoEntry of repoEntries) {
const cacheKey = `gitea:${baseUrl}:${repoEntry.owner}/${repoEntry.repo}:${repoEntry.branch || ''}`;
let cached = dataCache.get(cacheKey);
if (!cached) {
try {
cached = await fetchGiteaRepoSummary({
baseUrl,
owner: repoEntry.owner,
repo: repoEntry.repo,
branch: repoEntry.branch,
token: authToken,
timeoutMs: context.timeoutMs,
});
dataCache.set(cacheKey, cached);
} catch (err) {
fetchError = err;
break;
}
}
summaries.push({ repo: repoEntry, summary: cached });
}
if (fetchError) {
const fallbackUrls = Array.isArray(info.fallback_urls) ? info.fallback_urls.filter(Boolean) : [];
if (fallbackUrls.length && shouldUseGiteaFallback(fetchError)) {
const items = await gatherRssItems(fallbackUrls, info.fallback_path || info.path, dataCache, context.timeoutMs);
value = computeGiteaFallbackAggregate(items, info.aggregate);
break;
}
throw fetchError;
}
if (info.aggregate === 'count') {
let total = 0;
for (const { summary } of summaries) {
if (summary.totalCommits !== null && summary.totalCommits !== undefined) {
total += summary.totalCommits;
} else {
total = null;
break;
}
}
value = total !== null ? total : undefined;
} else if (info.aggregate === 'latest_title' || info.aggregate === 'latest_pubdate') {
let latest = null;
for (const { repo, summary } of summaries) {
const commit = summary.latestCommit;
if (!commit) continue;
const timestamp = commit.timestamp;
if (timestamp === null || timestamp === undefined) continue;
if (!latest || timestamp > latest.timestamp) {
latest = { repo, commit, timestamp };
}
}
if (!latest) {
value = undefined;
} else if (info.aggregate === 'latest_title') {
const prefix = latest.repo.displayName || `${latest.repo.owner}/${latest.repo.repo}`;
value = buildCommitInfo(prefix, latest.repo.branch || null, latest.commit);
} else {
value = new Date(latest.timestamp).toISOString();
}
} else {
value = summaries
.map(({ repo, summary }) => {
const message = summary.latestCommit?.message;
if (!message) return null;
const firstLine = stripHtml(message.split(/\r?\n/)[0]);
const prefix = repo.displayName || `${repo.owner}/${repo.repo}`;
return `${prefix}: ${firstLine}`;
})
.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',
mare_authorized_connections_peak: 'Pic de connexions',
mare_authorized_connections_highwater: 'Pic de connexions',
mare_authorized_connections_max: 'Pic de connexions',
mare_authorized_connections_record: 'Pic de connexions',
_peak_authorized_connections: 'Pic de connexions',
};
const connectionPeaks = new Map();
function loadConnectionPeaks() {
try {
if (!fs.existsSync(RESOLVED_PEAKS_PATH)) return;
const text = fs.readFileSync(RESOLVED_PEAKS_PATH, 'utf8');
if (!text.trim()) return;
const data = JSON.parse(text);
if (data && typeof data === 'object') {
for (const [k, v] of Object.entries(data)) {
const n = asFiniteNumber(v);
if (n !== null) connectionPeaks.set(k, n);
}
}
} catch (e) {
console.warn('Unable to load peaks file:', e.message || e);
}
}
let peaksSaveTimer = null;
function scheduleSaveConnectionPeaks() {
if (peaksSaveTimer) clearTimeout(peaksSaveTimer);
peaksSaveTimer = setTimeout(() => {
try {
const obj = Object.fromEntries(connectionPeaks.entries());
fs.writeFileSync(RESOLVED_PEAKS_PATH, JSON.stringify(obj, null, 2), 'utf8');
} catch (e) {
console.warn('Unable to write peaks file:', e.message || e);
}
}, 300);
}
function updateConnectionPeak(name, value) {
const n = asFiniteNumber(value);
if (n === null) return;
const prev = connectionPeaks.get(name);
const next = prev !== undefined ? Math.max(prev, n) : n;
if (prev !== next) {
connectionPeaks.set(name, next);
scheduleSaveConnectionPeaks();
}
}
loadConnectionPeaks();
{
const key = "Serveur d'identification";
const prev = connectionPeaks.get(key);
const seeded = prev !== undefined ? Math.max(prev, 70) : 70;
if (prev !== seeded) {
connectionPeaks.set(key, seeded);
scheduleSaveConnectionPeaks();
}
}
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 asFiniteNumber(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const num = Number(trimmed);
if (Number.isFinite(num)) return num;
}
return null;
}
function pickPeakMetric(primary, fallback) {
const candidates = [
'mare_authorized_connections_peak',
'mare_authorized_connections_highwater',
'mare_authorized_connections_max',
'mare_authorized_connections_record',
];
for (const key of candidates) {
if (primary && Object.prototype.hasOwnProperty.call(primary, key)) {
const val = asFiniteNumber(primary[key]);
if (val !== null) return { key, value: val };
}
}
for (const key of candidates) {
if (fallback && Object.prototype.hasOwnProperty.call(fallback, key)) {
const val = asFiniteNumber(fallback[key]);
if (val !== null) return { key, value: val };
}
}
return null;
}
function formatMetrics(name, allMetrics, metricsByService, peakStore = null) {
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 usersConnectedRaw = allMetrics.mare_authorized_connections ?? fallbackMetrics.mare_authorized_connections;
const usersRegisteredRaw = allMetrics.mare_users_registered ?? fallbackMetrics.mare_users_registered;
entries.push([
'mare_users_registered',
usersRegisteredRaw !== undefined ? usersRegisteredRaw : 'N/A',
]);
let peakEntry = pickPeakMetric(allMetrics, fallbackMetrics);
if (peakStore) {
if (peakEntry) {
updateConnectionPeak(name, peakEntry.value);
} else {
const numericConnected = asFiniteNumber(usersConnectedRaw);
if (numericConnected !== null) {
const prev = peakStore.get ? peakStore.get(name) : undefined;
const nextPeak = prev !== undefined ? Math.max(prev, numericConnected) : numericConnected;
updateConnectionPeak(name, nextPeak);
peakEntry = { key: '_peak_authorized_connections', value: nextPeak };
} else {
const stored = peakStore.get ? peakStore.get(name) : undefined;
if (stored !== undefined && stored !== null) {
peakEntry = { key: '_peak_authorized_connections', value: stored };
}
}
}
}
if (peakEntry) {
entries.push([peakEntry.key, peakEntry.value]);
}
entries.push([
'mare_authorized_connections',
usersConnectedRaw !== undefined ? usersConnectedRaw : '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') {
const hasTitle = Object.prototype.hasOwnProperty.call(value, 'title');
const hasDetails = Object.prototype.hasOwnProperty.call(value, 'details');
if (hasTitle || hasDetails) {
const parts = [];
if (hasTitle && value.title !== undefined && value.title !== null && value.title !== '') {
parts.push(formatExtraValue(value.title));
}
if (hasDetails && value.details !== undefined && value.details !== null && value.details !== '') {
parts.push(formatExtraValue(value.details));
}
return parts.join(' — ');
}
return Object.values(value)
.filter(v => v !== undefined && v !== null && v !== '')
.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 = ``;
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, connectionPeaks).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);