feat: extend /health endpoint with OS metrics for Station cockpit
This commit is contained in:
parent
465c943e0a
commit
8d9f4e22b0
60
server.js
60
server.js
|
|
@ -20,6 +20,8 @@ const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const os = require('os');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
// ── Auth: WebAuthn (ESM dynamic import) ─────────────────────────────
|
// ── Auth: WebAuthn (ESM dynamic import) ─────────────────────────────
|
||||||
let webauthn = null;
|
let webauthn = null;
|
||||||
|
|
@ -181,6 +183,15 @@ function extractSource(url) {
|
||||||
} catch { return 'unknown'; }
|
} catch { return 'unknown'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 文字コード正規化 ─────────────────────────
|
||||||
|
function normalizeCharset(raw) {
|
||||||
|
const e = (raw || '').toLowerCase().replace(/['"]/g, '').trim();
|
||||||
|
if (['shift_jis', 'shift-jis', 'sjis', 'x-sjis', 'ms_kanji', 'ms932', 'windows-31j', 'csshiftjis'].includes(e)) return 'shift_jis';
|
||||||
|
if (['euc-jp', 'euc_jp', 'x-euc-jp', 'cseucpkdfmtjapanese'].includes(e)) return 'euc-jp';
|
||||||
|
if (e.startsWith('utf')) return 'utf-8';
|
||||||
|
return 'utf-8';
|
||||||
|
}
|
||||||
|
|
||||||
// ── OGP フェッチ ───────────────────────────
|
// ── OGP フェッチ ───────────────────────────
|
||||||
async function fetchMeta(url) {
|
async function fetchMeta(url) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -191,15 +202,27 @@ async function fetchMeta(url) {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const buffer = await res.arrayBuffer();
|
const buffer = await res.arrayBuffer();
|
||||||
|
|
||||||
// 文字コード判定(先頭2000バイトからcharsetを探す)
|
// 文字コード判定: 1) Content-Typeヘッダー優先 2) HTMLメタタグ確認
|
||||||
const headSnippet = new TextDecoder('utf-8', { fatal: false }).decode(buffer.slice(0, 2000));
|
// iso-8859-1はバイト値0-255をロスレスでデコードするためcharset検出に最適
|
||||||
let encoding = 'utf-8';
|
let encoding = 'utf-8';
|
||||||
const charsetMatch = headSnippet.match(/charset=["']?(shift_jis|euc-jp|utf-8)["']?/i);
|
const contentType = res.headers.get('content-type') || '';
|
||||||
if (charsetMatch && charsetMatch[1]) {
|
const ctMatch = contentType.match(/charset=([^\s;]+)/i);
|
||||||
encoding = charsetMatch[1].toLowerCase();
|
if (ctMatch) {
|
||||||
|
encoding = normalizeCharset(ctMatch[1]);
|
||||||
|
}
|
||||||
|
if (encoding === 'utf-8') {
|
||||||
|
const headSnippet = new TextDecoder('iso-8859-1').decode(new Uint8Array(buffer).slice(0, 2000));
|
||||||
|
const metaMatch = headSnippet.match(/charset=["']?([^"'\s;>]+)/i);
|
||||||
|
if (metaMatch) encoding = normalizeCharset(metaMatch[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = new TextDecoder(encoding).decode(buffer);
|
let html;
|
||||||
|
try {
|
||||||
|
html = new TextDecoder(encoding).decode(buffer);
|
||||||
|
} catch {
|
||||||
|
// TextDecoder が対象エンコーディング非対応の場合は UTF-8 フォールバック
|
||||||
|
html = new TextDecoder('utf-8', { fatal: false }).decode(buffer);
|
||||||
|
}
|
||||||
const doc = parse(html);
|
const doc = parse(html);
|
||||||
|
|
||||||
const og = (p) => doc.querySelector(`meta[property="${p}"]`)?.getAttribute('content') || '';
|
const og = (p) => doc.querySelector(`meta[property="${p}"]`)?.getAttribute('content') || '';
|
||||||
|
|
@ -571,9 +594,30 @@ async function initDB() {
|
||||||
function buildRouter() {
|
function buildRouter() {
|
||||||
const r = express.Router();
|
const r = express.Router();
|
||||||
|
|
||||||
// ヘルスチェック
|
// ヘルスチェック(Station コックピット向けに拡張)
|
||||||
r.get('/health', (req, res) => {
|
r.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', gemini: !!genAI, users: Object.values(KEY_MAP).length });
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
const mem = os.freemem(), total = os.totalmem();
|
||||||
|
let disk = null;
|
||||||
|
try {
|
||||||
|
const df = execSync('df -B1 / 2>/dev/null', { timeout: 2000 }).toString();
|
||||||
|
const p = df.trim().split('\n')[1].split(/\s+/);
|
||||||
|
disk = { total_gb: Math.round(parseInt(p[1])/1e9*10)/10, used_gb: Math.round(parseInt(p[2])/1e9*10)/10, use_pct: Math.round(parseInt(p[2])/parseInt(p[1])*100) };
|
||||||
|
} catch(_) {}
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
gemini: !!genAI,
|
||||||
|
users: Object.values(KEY_MAP).length,
|
||||||
|
hostname: os.hostname(),
|
||||||
|
uptime_s: Math.floor(os.uptime()),
|
||||||
|
load_avg: os.loadavg().map(l => Math.round(l * 100) / 100),
|
||||||
|
mem_used_mb: Math.round((total - mem) / 1024 / 1024),
|
||||||
|
mem_total_mb: Math.round(total / 1024 / 1024),
|
||||||
|
disk,
|
||||||
|
platform: os.platform(),
|
||||||
|
node_version: process.version,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 認証テスト (UI用)
|
// 認証テスト (UI用)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue