diff --git a/www/html/Admin/admin.css b/www/html/Admin/admin.css index e9682a3..59b52a4 100644 --- a/www/html/Admin/admin.css +++ b/www/html/Admin/admin.css @@ -2931,3 +2931,153 @@ code { .ev-pool-pill.rar-common { background: #c8d2e0; } .ev-pool-pill.rar-rare { background: #7fb6ff; } .ev-pool-pill.rar-legendary { background: #ffcf6b; } + +/* ============================================================ + Admin redesign (v38) — Sidebar layout + ระบบ CPU/RAM + ============================================================ */ + +/* App shell ภายใน: [ sidebar | content ] */ +#panel-app { + display: grid; + grid-template-columns: 256px minmax(0, 1fr); + gap: 1.1rem; + align-items: start; +} + +/* ---- Sidebar (เดิมเป็นแถบแท็บแนวนอน) ---- */ +#panel-app .admin-tabs { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + gap: 0.16rem; + margin: 0; + padding: 0.55rem 0.5rem 0.7rem; + overflow-x: visible; + overflow-y: auto; + max-height: calc(100vh - 88px); + position: sticky; + top: 12px; + background: rgba(8, 11, 20, 0.55); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + scroll-snap-type: none; +} +#panel-app .admin-tabs::-webkit-scrollbar { width: 7px; height: 7px; } + +/* ช่องค้นหาเมนู (sticky บนสุดของ sidebar) */ +.admin-nav-search { + position: sticky; top: 0; z-index: 3; + padding: 0.1rem 0.2rem 0.5rem; + background: linear-gradient(180deg, rgba(8,11,20,0.97) 70%, rgba(8,11,20,0)); +} +.admin-nav-search input { + width: 100%; box-sizing: border-box; font: inherit; font-size: 0.84rem; + padding: 0.5rem 0.7rem; border-radius: 9px; + border: 1px solid var(--border); background: rgba(0,0,0,0.28); color: var(--text); +} +.admin-nav-search input::placeholder { color: var(--muted); } +.admin-nav-search input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); } +.admin-nav-empty { padding: 0.5rem 0.55rem; color: var(--muted); font-size: 0.8rem; } + +/* แท็บ → แถวเต็มความกว้างแนวตั้ง */ +#panel-app .admin-tabs .tab { + width: 100%; + flex: 0 0 auto; + flex-direction: row; + align-items: center; + gap: 0.5rem; + min-height: 2.45rem; + padding: 0.48rem 0.7rem; + border-radius: 9px; + scroll-snap-align: none; +} +#panel-app .admin-tabs .tab .tab-desc { display: none; } +#panel-app .admin-tabs .tab .tab-label { font-size: 0.85rem; line-height: 1.25; } +#panel-app .admin-tabs .tab .tab-heading-row { align-items: center; } +#panel-app .admin-tabs .tab.is-active { + background: var(--accent-dim); + border-color: var(--border-strong); + box-shadow: inset 3px 0 0 var(--accent); +} + +/* หัวข้อกลุ่ม (separator) — กดยุบ/ขยายได้ */ +#panel-app .admin-tabs .admin-tab-sep { + flex: 0 0 auto; + width: 100%; + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0.55rem 0 0.1rem; + padding: 0.32rem 0.45rem; + cursor: pointer; + border-radius: 7px; + user-select: none; +} +#panel-app .admin-tabs .admin-tab-sep:hover { background: rgba(255,255,255,0.04); } +#panel-app .admin-tabs .admin-tab-sep span { font-size: 0.63rem; } +#panel-app .admin-tabs .admin-tab-sep::after { display: none; } +#panel-app .admin-tabs .admin-tab-sep::before { + content: '▾'; order: 2; margin-left: auto; + font-size: 0.58rem; color: var(--muted); transition: transform 0.15s; +} +#panel-app .admin-tabs .admin-tab-sep.is-collapsed::before { transform: rotate(-90deg); } +#panel-app .admin-tabs .tab.is-group-hidden, +#panel-app .admin-tabs .tab.is-search-hidden { display: none; } + +/* เนื้อหา */ +.admin-content { min-width: 0; } +.admin-content > .tab-panel { margin-top: 0; } + +.admin-sidebar-backdrop { display: none; } + +/* ---- แถบสถานะ CPU/RAM (บน header) ---- */ +.admin-sys-stats { + display: flex; align-items: center; gap: 0.85rem; margin-left: auto; + padding: 0.38rem 0.75rem; border-radius: 12px; + border: 1px solid var(--border); background: rgba(0,0,0,0.24); +} +.admin-sys-stats[hidden] { display: none; } +.sysstat { display: flex; flex-direction: column; gap: 0.2rem; min-width: 92px; } +.sysstat--up { min-width: auto; gap: 0.12rem; align-items: flex-start; } +.sysstat-head { display: flex; justify-content: space-between; gap: 0.5rem; align-items: baseline; } +.sysstat-label { font-size: 0.58rem; font-weight: 700; letter-spacing: 0.09em; color: var(--muted); } +.sysstat-val { font-size: 0.82rem; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; } +.sysstat-bar { height: 5px; border-radius: 3px; background: rgba(255,255,255,0.09); overflow: hidden; } +.sysstat-bar i { display: block; height: 100%; width: 0; border-radius: 3px; background: linear-gradient(90deg, #4ade80, #22c55e); transition: width 0.45s ease, background 0.3s; } +.sysstat-bar i.is-warn { background: linear-gradient(90deg, #fbbf24, #f59e0b); } +.sysstat-bar i.is-crit { background: linear-gradient(90deg, #fb7185, #ef4444); } + +/* ปุ่มแฮมเบอร์เกอร์ (มือถือ) */ +.admin-sidebar-toggle { + display: none; flex-direction: column; justify-content: center; gap: 4px; + width: 40px; height: 40px; padding: 0 9px; margin-right: 0.5rem; + border: 1px solid var(--border); border-radius: 10px; background: rgba(0,0,0,0.25); cursor: pointer; +} +.admin-sidebar-toggle span { display: block; height: 2px; border-radius: 2px; background: var(--text); } + +/* ---- Responsive: มือถือ/แท็บเล็ต → sidebar เป็น off-canvas ---- */ +@media (max-width: 980px) { + #panel-app { grid-template-columns: 1fr; } + #panel-app .admin-tabs { + position: fixed; top: 0; left: 0; bottom: 0; z-index: 220; + width: 272px; max-height: 100vh; border-radius: 0; + transform: translateX(-102%); transition: transform 0.22s ease; + background: rgba(8, 11, 20, 0.98); + } + .admin-shell.sidebar-open #panel-app .admin-tabs { transform: translateX(0); } + .admin-shell.sidebar-open .admin-sidebar-backdrop { + display: block; position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.55); + } + .admin-sidebar-toggle:not([hidden]) { display: inline-flex; } + .admin-sys-stats { gap: 0.5rem; padding: 0.3rem 0.55rem; } + .admin-sys-stats .sysstat { min-width: 64px; } + .admin-sys-stats .sysstat--up, + .admin-sys-stats .sysstat--node { display: none; } +} +@media (max-width: 680px) { + .admin-sys-stats .sysstat--disk { display: none; } +} +@media (max-width: 560px) { + .admin-sys-stats .sysstat--ram { display: none; } + .admin-brand-text .admin-tagline { display: none; } +} diff --git a/www/html/Admin/admin.js b/www/html/Admin/admin.js index e7927d9..be67fea 100644 --- a/www/html/Admin/admin.js +++ b/www/html/Admin/admin.js @@ -4026,7 +4026,92 @@ var p = el(id); if (p) p.hidden = id !== name; }); - el('top-actions').hidden = name !== 'panel-app'; + var inApp = name === 'panel-app'; + el('top-actions').hidden = !inApp; + var stats = el('admin-sys-stats'); + if (stats) stats.hidden = !inApp; + var burger = el('btn-sidebar-toggle'); + if (burger) burger.hidden = !inApp; + if (inApp) startSystemStats(); + else stopSystemStats(); + } + + /* ===== ระบบ: ดึง CPU/RAM ของเซิร์ฟเวอร์มาแสดง (อัปเดตทุก 5 วิ) ===== */ + var __sysStatsTimer = null; + function fmtBytes(n) { + if (!(n > 0)) return '0'; + var u = ['B', 'KB', 'MB', 'GB', 'TB']; + var i = Math.floor(Math.log(n) / Math.log(1024)); + i = Math.max(0, Math.min(u.length - 1, i)); + return (n / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0) + ' ' + u[i]; + } + function fmtUptime(sec) { + sec = Math.max(0, Math.floor(sec || 0)); + var d = Math.floor(sec / 86400); sec -= d * 86400; + var h = Math.floor(sec / 3600); sec -= h * 3600; + var m = Math.floor(sec / 60); + if (d > 0) return d + 'd ' + h + 'h'; + if (h > 0) return h + 'h ' + m + 'm'; + return m + 'm'; + } + function setBarTone(bar, pct) { + if (!bar) return; + bar.classList.remove('is-warn', 'is-crit'); + if (pct >= 85) bar.classList.add('is-crit'); + else if (pct >= 65) bar.classList.add('is-warn'); + } + function renderSystemStats(s) { + if (!s || !s.ok) return; + var cpuPct = (s.cpu && typeof s.cpu.percent === 'number') ? s.cpu.percent + : (s.cpu ? s.cpu.loadPercent : 0); + var cpuVal = el('sysstat-cpu-val'), cpuBar = el('sysstat-cpu-bar'); + if (cpuVal) cpuVal.textContent = cpuPct + '%'; + if (cpuBar) { cpuBar.style.width = cpuPct + '%'; setBarTone(cpuBar, cpuPct); } + var memPct = s.mem ? s.mem.usedPercent : 0; + var ramVal = el('sysstat-ram-val'), ramBar = el('sysstat-ram-bar'); + if (ramVal && s.mem) ramVal.textContent = memPct + '% ' + fmtBytes(s.mem.used) + '/' + fmtBytes(s.mem.total); + if (ramBar) { ramBar.style.width = memPct + '%'; setBarTone(ramBar, memPct); } + // HDD / ดิสก์ + var diskWrap = el('sysstat-disk-val') ? el('sysstat-disk-val').closest('.sysstat') : null; + if (s.disk) { + var dPct = s.disk.usedPercent; + var dVal = el('sysstat-disk-val'), dBar = el('sysstat-disk-bar'); + if (dVal) dVal.textContent = dPct + '% ' + fmtBytes(s.disk.used) + '/' + fmtBytes(s.disk.total); + if (dBar) { dBar.style.width = dPct + '%'; setBarTone(dBar, dPct); } + if (diskWrap) diskWrap.hidden = false; + } else if (diskWrap) { + diskWrap.hidden = true; // อ่านดิสก์ไม่ได้ (เช่น Node เก่า) → ซ่อน + } + // Node ที่ใช้อยู่ (RAM ของ process เกม) + if (s.node) { + var nPct = typeof s.node.rssPercent === 'number' ? s.node.rssPercent : 0; + var nVal = el('sysstat-node-val'), nBar = el('sysstat-node-bar'); + if (nVal) nVal.textContent = fmtBytes(s.node.rss) + (s.node.version ? ' ' + s.node.version : ''); + if (nBar) { nBar.style.width = Math.min(100, nPct) + '%'; setBarTone(nBar, nPct); } + } + var upVal = el('sysstat-up-val'); + if (upVal && s.uptime) upVal.textContent = fmtUptime(s.uptime.os); + var box = el('admin-sys-stats'); + if (box && s.cpu) box.title = 'CPU ' + cpuPct + '% (load ' + s.cpu.load1 + ', ' + s.cpu.cores + ' cores) · RAM ' + + memPct + '%' + + (s.disk ? ' · HDD ' + s.disk.usedPercent + '% (' + fmtBytes(s.disk.free) + ' ว่าง)' : '') + + (s.node ? ' · Node ' + fmtBytes(s.node.rss) + ' ' + (s.node.version || '') : '') + + ' · uptime ' + fmtUptime(s.uptime && s.uptime.os) + ' · ' + (s.hostname || ''); + } + function pollSystemStats() { + fetch('/Game/api/system-stats', { cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (s) { if (s) renderSystemStats(s); }) + .catch(function () { /* เงียบไว้ ไม่รบกวน admin */ }); + } + function startSystemStats() { + if (__sysStatsTimer) return; + pollSystemStats(); + setTimeout(pollSystemStats, 1200); // ครั้งที่สองให้ได้ค่า CPU% (ครั้งแรก server คืน null) + __sysStatsTimer = setInterval(pollSystemStats, 5000); + } + function stopSystemStats() { + if (__sysStatsTimer) { clearInterval(__sysStatsTimer); __sysStatsTimer = null; } } // ===== Evidence Cards admin (รูปอย่างเดียว · 15 คดี · rarity จากเลขไฟล์) ===== @@ -4485,6 +4570,84 @@ }); }); + /* ===== Sidebar: ค้นหาเมนู / ยุบกลุ่ม / เปิด-ปิดบนมือถือ ===== */ + (function setupAdminSidebar() { + var shell = document.querySelector('.admin-shell'); + var nav = document.querySelector('.admin-tabs'); + if (!nav) return; + var seps = Array.prototype.slice.call(nav.querySelectorAll('.admin-tab-sep')); + var tabs = Array.prototype.slice.call(nav.querySelectorAll('.tab')); + + function isMobile() { return window.matchMedia('(max-width: 980px)').matches; } + function closeSidebar() { + if (shell) shell.classList.remove('sidebar-open'); + var b = el('btn-sidebar-toggle'); if (b) b.setAttribute('aria-expanded', 'false'); + var bd = el('admin-sidebar-backdrop'); if (bd) bd.hidden = true; + } + function openSidebar() { + if (shell) shell.classList.add('sidebar-open'); + var b = el('btn-sidebar-toggle'); if (b) b.setAttribute('aria-expanded', 'true'); + var bd = el('admin-sidebar-backdrop'); if (bd) bd.hidden = false; + } + var burger = el('btn-sidebar-toggle'); + if (burger) burger.addEventListener('click', function () { + if (shell && shell.classList.contains('sidebar-open')) closeSidebar(); else openSidebar(); + }); + var backdrop = el('admin-sidebar-backdrop'); + if (backdrop) backdrop.addEventListener('click', closeSidebar); + nav.addEventListener('click', function (e) { + var t = e.target && e.target.closest ? e.target.closest('.tab') : null; + if (t && isMobile()) closeSidebar(); + }); + + /* ยุบ/ขยายกลุ่ม: ซ่อนแท็บที่อยู่หลัง separator จนถึง separator ถัดไป */ + function applyGroupVisibility() { + seps.forEach(function (sep) { + var collapsed = sep.classList.contains('is-collapsed'); + var n = sep.nextElementSibling; + while (n && !n.classList.contains('admin-tab-sep')) { + if (n.classList && n.classList.contains('tab')) n.classList.toggle('is-group-hidden', collapsed); + n = n.nextElementSibling; + } + }); + } + function toggleGroup(sep) { + sep.classList.toggle('is-collapsed'); + applyGroupVisibility(); + } + seps.forEach(function (sep) { + sep.setAttribute('role', 'button'); + sep.setAttribute('tabindex', '0'); + sep.addEventListener('click', function () { toggleGroup(sep); }); + sep.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleGroup(sep); } + }); + }); + + /* ค้นหาเมนู */ + var search = el('admin-nav-search-input'); + var emptyMsg = el('admin-nav-empty'); + if (search) search.addEventListener('input', function () { + var q = search.value.trim().toLowerCase(); + if (q) { + var anyVisible = false; + tabs.forEach(function (t) { + t.classList.remove('is-group-hidden'); + var hit = (t.textContent || '').toLowerCase().indexOf(q) >= 0; + t.classList.toggle('is-search-hidden', !hit); + if (hit) anyVisible = true; + }); + seps.forEach(function (s) { s.style.display = 'none'; }); + if (emptyMsg) emptyMsg.hidden = anyVisible; + } else { + tabs.forEach(function (t) { t.classList.remove('is-search-hidden'); }); + seps.forEach(function (s) { s.style.display = ''; }); + applyGroupVisibility(); + if (emptyMsg) emptyMsg.hidden = true; + } + }); + })(); + function bindEmbedPanelFullscreen(btnId, panelId) { var btn = el(btnId); var panel = el(panelId); diff --git a/www/html/Admin/index.html b/www/html/Admin/index.html index 8a042c7..016e47a 100644 --- a/www/html/Admin/index.html +++ b/www/html/Admin/index.html @@ -10,13 +10,16 @@ - +
+
@@ -24,6 +27,28 @@

JD JUSTICE DIVERS

+ - + diff --git a/www/html/Game/server.js b/www/html/Game/server.js index 7a6345f..6b32fcf 100644 --- a/www/html/Game/server.js +++ b/www/html/Game/server.js @@ -299,6 +299,81 @@ function qbLeaderboardGet(roomId, name) { } loadQbLeaderboard(); +/* ===== System stats (CPU / RAM) สำหรับหน้า Admin ===== */ +const os = require('os'); +let __prevCpuSample = null; +function __cpuSample() { + const cpus = os.cpus() || []; + let idle = 0, total = 0; + for (const c of cpus) { + for (const k in c.times) total += c.times[k]; + idle += c.times.idle; + } + return { idle, total }; +} +/** CPU% เฉลี่ยช่วงเวลาระหว่างสองครั้งที่เรียก (admin poll ~ทุก 5 วิ) — ครั้งแรกคืน null */ +function __cpuPercent() { + const cur = __cpuSample(); + const prev = __prevCpuSample; + __prevCpuSample = cur; + if (!prev) return null; + const idleD = cur.idle - prev.idle; + const totalD = cur.total - prev.total; + if (totalD <= 0) return null; + return Math.max(0, Math.min(100, Math.round((1 - idleD / totalD) * 100))); +} +__cpuSample(); // เก็บ baseline แรก +function getDiskStats() { + try { + if (typeof fs.statfsSync !== 'function') return null; + const s = fs.statfsSync(__dirname); + const total = s.blocks * s.bsize; + const free = s.bfree * s.bsize; // ว่างจริงทั้งหมด + const avail = s.bavail * s.bsize; // ว่างที่ผู้ใช้ทั่วไปใช้ได้ + const used = total - free; + if (!(total > 0)) return null; + return { total, free: avail, used, usedPercent: Math.round((used / total) * 100) }; + } catch (e) { return null; } +} +function getSystemStats() { + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const load = os.loadavg(); + const cores = (os.cpus() || []).length || 1; + const cpuPct = __cpuPercent(); + const mu = process.memoryUsage(); + return { + ok: true, + cpu: { + percent: cpuPct, // 0-100 หรือ null (ครั้งแรก) + cores, + load1: Math.round(load[0] * 100) / 100, + load5: Math.round(load[1] * 100) / 100, + load15: Math.round(load[2] * 100) / 100, + loadPercent: Math.max(0, Math.min(100, Math.round((load[0] / cores) * 100))), + }, + mem: { + total: totalMem, + free: freeMem, + used: usedMem, + usedPercent: Math.round((usedMem / totalMem) * 100), + }, + disk: getDiskStats(), + node: { + rss: mu.rss, + heapUsed: mu.heapUsed, + rssPercent: Math.round((mu.rss / totalMem) * 100), + version: process.version, + uptime: Math.round(process.uptime()), + }, + uptime: { os: Math.round(os.uptime()), process: Math.round(process.uptime()) }, + hostname: os.hostname(), + platform: os.platform(), + ts: Date.now(), + }; +} + /** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */ const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; const maps = new Map(); @@ -4563,6 +4638,18 @@ const server = http.createServer((req, res) => { return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); } const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (quizSettingsUrlPath === BASE_PATH + '/api/system-stats') { + if (req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Pragma: 'no-cache', + }); + return res.end(JSON.stringify(getSystemStats())); + } + res.writeHead(405); + return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + } if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') { if (req.method === 'GET') { res.writeHead(200, {