feat: Phase 1 cockpit — net I/O, CPU temp, Gitea commit, keyboard shortcuts, CRIT aurora shift

server.js: add net delta (rx/tx KB/s), CPU temp, /api/gitea-commit proxy.
station-b: net/temp in Ubuntu PC panel, ecosystem bar with latest Gitea
commit, CRIT aurora hue shift (gradual 3s transition to red, then back),
keyboard shortcuts R=refresh B=Design-A F=fullscreen.
station-a: same additions except canvas CRIT effect.
This commit is contained in:
posimai 2026-04-02 16:45:45 +09:00
parent f726b4b9af
commit 465c943e0a
3 changed files with 214 additions and 10 deletions

View File

@ -53,6 +53,49 @@ app.get('/api/sessions/:id', requireLocal, (req, res) => {
res.type('text/plain').send(fs.readFileSync(file, 'utf8')); res.type('text/plain').send(fs.readFileSync(file, 'utf8'));
}); });
// ── ネットワーク I/O デルタ計算 ────────────────────────────────
let _netPrev = null, _netPrevTime = null;
function getNetDelta() {
try {
const raw = fs.readFileSync('/proc/net/dev', 'utf8');
const now = Date.now();
let rx = 0, tx = 0;
for (const line of raw.trim().split('\n').slice(2)) {
const parts = line.trim().split(/\s+/);
const iface = parts[0].replace(':', '');
if (iface === 'lo') continue;
rx += parseInt(parts[1]) || 0;
tx += parseInt(parts[9]) || 0;
}
let result = null;
if (_netPrev && _netPrevTime) {
const dt = (now - _netPrevTime) / 1000;
result = {
rx_kbps: Math.max(0, Math.round((rx - _netPrev.rx) / dt / 1024)),
tx_kbps: Math.max(0, Math.round((tx - _netPrev.tx) / dt / 1024)),
};
}
_netPrev = { rx, tx }; _netPrevTime = now;
return result;
} catch (_) { return null; }
}
// ── CPU 温度 (/sys/class/thermal/) ─────────────────────────────
function getCpuTemp() {
try {
const zones = fs.readdirSync('/sys/class/thermal/').filter(z => z.startsWith('thermal_zone'));
for (const zone of zones) {
try {
const type = fs.readFileSync(`/sys/class/thermal/${zone}/type`, 'utf8').trim();
if (['x86_pkg_temp','cpu-thermal','acpitz'].includes(type) || type.startsWith('cpu')) {
return Math.round(parseInt(fs.readFileSync(`/sys/class/thermal/${zone}/temp`, 'utf8')) / 1000);
}
} catch (_) {}
}
return Math.round(parseInt(fs.readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8')) / 1000);
} catch (_) { return null; }
}
// ── ヘルス & メトリクス API (/api/health) ────────────────────── // ── ヘルス & メトリクス API (/api/health) ──────────────────────
// Atlas など外部から参照される。CORS ヘッダーを付与して Vercel 上の Atlas からも取得可能にする // Atlas など外部から参照される。CORS ヘッダーを付与して Vercel 上の Atlas からも取得可能にする
function getCpuSample() { function getCpuSample() {
@ -107,6 +150,8 @@ app.get('/api/health', (req, res) => {
mem_used_mb: Math.round((total - mem) / 1024 / 1024), mem_used_mb: Math.round((total - mem) / 1024 / 1024),
mem_total_mb: Math.round(total / 1024 / 1024), mem_total_mb: Math.round(total / 1024 / 1024),
disk, disk,
net: getNetDelta(),
cpu_temp_c: getCpuTemp(),
active_sessions: wss.clients ? wss.clients.size : 0, active_sessions: wss.clients ? wss.clients.size : 0,
node_version: process.version, node_version: process.version,
platform: os.platform(), platform: os.platform(),
@ -115,6 +160,26 @@ app.get('/api/health', (req, res) => {
}, 100); }, 100);
}); });
// ── Gitea 最新コミット (/api/gitea-commit) ─────────────────────
app.get('/api/gitea-commit', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
try {
const token = process.env.GITEA_TOKEN || '';
const headers = token ? { Authorization: `token ${token}` } : {};
const r = await fetch('http://100.76.7.3:3000/api/v1/repos/mai/posimai-root/commits?limit=1', {
headers, signal: AbortSignal.timeout(3000),
});
const data = await r.json();
const c = data[0];
res.json({
sha: c.sha.slice(0, 7),
message: c.commit.message.split('\n')[0].slice(0, 60),
author: c.commit.author.name,
date: c.commit.author.date,
});
} catch (e) { res.json({ error: e.message }); }
});
// ── サービス死活チェックプロキシ (/api/check?url=...) ────────── // ── サービス死活チェックプロキシ (/api/check?url=...) ──────────
// ブラウザの mixed-content 制限を回避するためサーバー側から HTTP チェック // ブラウザの mixed-content 制限を回避するためサーバー側から HTTP チェック
app.get('/api/check', async (req, res) => { app.get('/api/check', async (req, res) => {

View File

@ -158,7 +158,13 @@
#stream-ticker-inner { display:inline-block;animation:ticker 60s linear infinite; } #stream-ticker-inner { display:inline-block;animation:ticker 60s linear infinite; }
@keyframes ticker { from{transform:translateX(0)} to{transform:translateX(-50%)} } @keyframes ticker { from{transform:translateX(0)} to{transform:translateX(-50%)} }
#bottom { display:flex;align-items:center;justify-content:space-between;padding-top:12px;border-top:1px solid var(--border); } #bottom { display:flex;flex-direction:column;gap:6px;padding-top:10px;border-top:1px solid var(--border); }
#ecosystem-bar { display:flex;align-items:center;gap:14px;font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text3);overflow:hidden; }
.eco-item { display:flex;align-items:center;gap:5px;flex-shrink:0; }
.eco-dot { width:5px;height:5px;border-radius:50%;background:var(--text3); }
.eco-dot.ok { background:var(--ok); }
.eco-dot.crit { background:var(--crit); }
#bottom-row { display:flex;align-items:center;justify-content:space-between; }
.bottom-brand { font-size:12px;color:var(--text3);font-weight:500;letter-spacing:0.04em; } .bottom-brand { font-size:12px;color:var(--text3);font-weight:500;letter-spacing:0.04em; }
.bottom-brand span { color:var(--accent); } .bottom-brand span { color:var(--accent); }
.bottom-links { display:flex;gap:7px; } .bottom-links { display:flex;gap:7px; }
@ -207,11 +213,15 @@
<div class="load-chip"><div class="load-chip-label">15m</div><div class="load-chip-val" id="load-15"></div></div> <div class="load-chip"><div class="load-chip-label">15m</div><div class="load-chip-val" id="load-15"></div></div>
</div> </div>
</div> </div>
<div class="metric-item" id="net-row" style="display:none">
<div class="metric-header-row"><span class="metric-label">Network</span><span class="metric-val" id="net-val"></span></div>
</div>
<div class="stat-grid"> <div class="stat-grid">
<div class="stat-card"><div class="stat-label">Uptime</div><div class="stat-val" id="uptime-val"></div></div> <div class="stat-card"><div class="stat-label">Uptime</div><div class="stat-val" id="uptime-val"></div></div>
<div class="stat-card"><div class="stat-label">Sessions</div><div class="stat-val" id="sessions-val"></div></div> <div class="stat-card"><div class="stat-label">Sessions</div><div class="stat-val" id="sessions-val"></div></div>
<div class="stat-card"><div class="stat-label">Node.js</div><div class="stat-val" id="node-val"></div></div> <div class="stat-card"><div class="stat-label">Node.js</div><div class="stat-val" id="node-val"></div></div>
<div class="stat-card"><div class="stat-label">Platform</div><div class="stat-val" id="platform-val"></div></div> <div class="stat-card"><div class="stat-label">Platform</div><div class="stat-val" id="platform-val"></div></div>
<div class="stat-card" id="temp-card" style="display:none"><div class="stat-label">CPU Temp</div><div class="stat-val" id="temp-val"></div></div>
</div> </div>
<a class="open-btn" href="/" target="_blank" rel="noopener"><i data-lucide="terminal"></i>posimai-dev を開く</a> <a class="open-btn" href="/" target="_blank" rel="noopener"><i data-lucide="terminal"></i>posimai-dev を開く</a>
</div> </div>
@ -290,14 +300,21 @@
</div> </div>
</div> </div>
<div id="bottom"> <div id="bottom">
<div class="bottom-brand">posimai<span>-station</span> <span style="font-size:10px;color:var(--violet);margin-left:4px">B</span></div> <div id="ecosystem-bar">
<div class="bottom-links"> <div class="eco-item"><div class="eco-dot" id="eco-gitea-dot"></div><span id="eco-commit">commit: —</span></div>
<a class="bottom-link" href="/station" rel="noopener"><i data-lucide="monitor"></i>Design A</a> <div class="eco-item" style="color:var(--border);font-size:8px">|</div>
<a class="bottom-link" href="/" target="_blank" rel="noopener"><i data-lucide="terminal"></i>dev</a> <div class="eco-item" id="eco-temp-item" style="display:none"><span id="eco-temp-label">temp: —</span></div>
<a class="bottom-link" href="https://posimai-atlas.vercel.app" target="_blank" rel="noopener"><i data-lucide="network"></i>atlas</a> </div>
<a class="bottom-link" href="https://posimai.soar-enrich.com" target="_blank" rel="noopener"><i data-lucide="layout-dashboard"></i>dashboard</a> <div id="bottom-row">
<div class="bottom-brand">posimai<span>-station</span> <span style="font-size:10px;color:var(--violet);margin-left:4px">B</span></div>
<div class="bottom-links">
<a class="bottom-link" href="/station" rel="noopener"><i data-lucide="monitor"></i>Design A</a>
<a class="bottom-link" href="/" target="_blank" rel="noopener"><i data-lucide="terminal"></i>dev</a>
<a class="bottom-link" href="https://posimai-atlas.vercel.app" target="_blank" rel="noopener"><i data-lucide="network"></i>atlas</a>
<a class="bottom-link" href="https://posimai.soar-enrich.com" target="_blank" rel="noopener"><i data-lucide="layout-dashboard"></i>dashboard</a>
</div>
<div id="refresh-countdown">次の更新まで <span id="countdown">30</span>s</div>
</div> </div>
<div id="refresh-countdown">次の更新まで <span id="countdown">30</span>s</div>
</div> </div>
</div> </div>
<script> <script>
@ -405,6 +422,28 @@ async function fetchHealth(){
document.getElementById('platform-val').textContent=data.platform||'—'; document.getElementById('platform-val').textContent=data.platform||'—';
document.getElementById('hostname').textContent=data.hostname||'ubuntu-pc'; document.getElementById('hostname').textContent=data.hostname||'ubuntu-pc';
// Network I/O
if(data.net){
const netRow=document.getElementById('net-row');
if(netRow) netRow.style.display='';
const rx=data.net.rx_kbps,tx=data.net.tx_kbps;
const fmt=v=>v>=1024?`${(v/1024).toFixed(1)}MB/s`:`${v}KB/s`;
document.getElementById('net-val').textContent=`↓${fmt(rx)} ↑${fmt(tx)}`;
}
// CPU Temp
if(data.cpu_temp_c!=null){
const tc=document.getElementById('temp-card');
const ti=document.getElementById('eco-temp-item');
if(tc) tc.style.display='';
if(ti) ti.style.display='';
const t=data.cpu_temp_c;
const tcol=t>=80?'var(--crit)':t>=70?'var(--warn)':'var(--ok)';
document.getElementById('temp-val').textContent=`${t}°C`;
document.getElementById('temp-val').style.color=tcol;
document.getElementById('eco-temp-label').textContent=`temp: ${t}°C`;
document.getElementById('eco-temp-label').style.color=tcol;
}
updateRing('ring-cpu-fill','ring-cpu-val', cpuPct, 301.6,'#22D3EE','#FB923C','#F87171'); updateRing('ring-cpu-fill','ring-cpu-val', cpuPct, 301.6,'#22D3EE','#FB923C','#F87171');
updateRing('ring-mem-fill','ring-mem-val', memPct, 188.5,'#A78BFA','#FB923C','#F87171'); updateRing('ring-mem-fill','ring-mem-val', memPct, 188.5,'#A78BFA','#FB923C','#F87171');
updateRing('ring-disk-fill','ring-disk-val',diskPct,188.5,'#4ADE80','#FB923C','#F87171'); updateRing('ring-disk-fill','ring-disk-val',diskPct,188.5,'#4ADE80','#FB923C','#F87171');
@ -424,6 +463,7 @@ async function fetchHealth(){
else if(loadAvg[0]>cpuCount)alerts.push({level:'warn',msg:`Load ${loadAvg[0].toFixed(2)}`}); else if(loadAvg[0]>cpuCount)alerts.push({level:'warn',msg:`Load ${loadAvg[0].toFixed(2)}`});
setAlerts(alerts); setAlerts(alerts);
const hasCrit=alerts.some(a=>a.level==='crit'),hasWarn=alerts.some(a=>a.level==='warn'); const hasCrit=alerts.some(a=>a.level==='crit'),hasWarn=alerts.some(a=>a.level==='warn');
window._systemCrit = hasCrit; // canvas aurora hue shift
document.getElementById('status-dot').className=hasCrit?'crit':hasWarn?'warn':'ok'; document.getElementById('status-dot').className=hasCrit?'crit':hasWarn?'warn':'ok';
updateStream(data); updateStream(data);
@ -618,6 +658,41 @@ if(window.lucide)lucide.createIcons();
runRefresh(); runRefresh();
window.addEventListener('resize',renderSparklines); window.addEventListener('resize',renderSparklines);
// ── Gitea 最新コミット ─────────────────────────────────────────
async function fetchGiteaCommit(){
try{
const r=await fetch('/api/gitea-commit',{signal:AbortSignal.timeout(4000)});
const d=await r.json();
if(d.error||!d.sha) return;
const dot=document.getElementById('eco-gitea-dot');
const el=document.getElementById('eco-commit');
if(dot) dot.className='eco-dot ok';
if(el){
const ago=timeAgo(d.date);
el.textContent=`${d.sha} ${d.message} (${ago})`;
}
}catch(_){}
}
function timeAgo(iso){
const diff=Math.floor((Date.now()-new Date(iso))/60000);
if(diff<1)return 'just now';
if(diff<60)return `${diff}m ago`;
if(diff<1440)return `${Math.floor(diff/60)}h ago`;
return `${Math.floor(diff/1440)}d ago`;
}
fetchGiteaCommit();
setInterval(fetchGiteaCommit, 120000); // 2分ごと
// ── キーボードショートカット ────────────────────────────────────
document.addEventListener('keydown', e => {
if(e.key==='r'||e.key==='R'){ runRefresh(); }
if(e.key==='b'||e.key==='B'){ window.location.href='/station'; }
if(e.key==='f'||e.key==='F'){
if(!document.fullscreenElement) document.documentElement.requestFullscreen().catch(()=>{});
else document.exitFullscreen();
}
});
// ── Binary curtain aurora canvas ───────────────────────────────────────────── // ── Binary curtain aurora canvas ─────────────────────────────────────────────
(function initBinaryCurtain(){ (function initBinaryCurtain(){
const canvas = document.getElementById('binary-canvas'); const canvas = document.getElementById('binary-canvas');
@ -643,16 +718,24 @@ window.addEventListener('resize',renderSparklines);
resize(); resize();
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
let t = 0; let t = 0;
// CRIT hue shift: 0=normal, 1=full red. Transitions gradually.
let _critProgress = 0;
function getBandColor(x, t){ function getBandColor(x, t){
const xf = x / canvas.width; const xf = x / canvas.width;
let best = BANDS[0], bestDist = Infinity; let best = BANDS[0], bestDist = Infinity;
BANDS.forEach(b => { const bx=b.x+Math.sin(t*b.speed+b.phase)*0.12; const dist=Math.abs(xf-bx); if(dist<bestDist){bestDist=dist;best=b;} }); BANDS.forEach(b => { const bx=b.x+Math.sin(t*b.speed+b.phase)*0.12; const dist=Math.abs(xf-bx); if(dist<bestDist){bestDist=dist;best=b;} });
const bx = best.x + Math.sin(t*best.speed+best.phase)*0.12; const bx = best.x + Math.sin(t*best.speed+best.phase)*0.12;
const alpha = Math.max(0, 1 - Math.abs(xf-bx)/0.22); const alpha = Math.max(0, 1 - Math.abs(xf-bx)/0.22);
return { hue: best.hue, sat: best.sat, alpha }; // Lerp hue toward red (hue=10) when CRIT — subtle, takes ~3s to fully shift
const hue = Math.round(best.hue + (10 - best.hue) * _critProgress);
const sat = Math.round(best.sat + (100 - best.sat) * _critProgress * 0.5);
return { hue, sat, alpha };
} }
function draw(){ function draw(){
t++; t++;
// Gradually shift critProgress toward target (0 or 1)
const critTarget = window._systemCrit ? 1 : 0;
_critProgress += (critTarget - _critProgress) * 0.008; // ~3s transition
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = `${FONT_SIZE}px 'JetBrains Mono', monospace`; ctx.font = `${FONT_SIZE}px 'JetBrains Mono', monospace`;
cols.forEach((col, i) => { cols.forEach((col, i) => {

View File

@ -165,7 +165,12 @@
#stream-ticker-inner { display:inline-block;animation:ticker 60s linear infinite; } #stream-ticker-inner { display:inline-block;animation:ticker 60s linear infinite; }
@keyframes ticker { from{transform:translateX(0)} to{transform:translateX(-50%)} } @keyframes ticker { from{transform:translateX(0)} to{transform:translateX(-50%)} }
#bottom { display:flex;align-items:center;justify-content:space-between;padding-top:12px;border-top:1px solid var(--border); } #bottom { display:flex;flex-direction:column;gap:6px;padding-top:10px;border-top:1px solid var(--border); }
#ecosystem-bar { display:flex;align-items:center;gap:14px;font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text3);overflow:hidden; }
.eco-item { display:flex;align-items:center;gap:5px;flex-shrink:0; }
.eco-dot { width:5px;height:5px;border-radius:50%;background:var(--text3); }
.eco-dot.ok { background:var(--ok); }
#bottom-row { display:flex;align-items:center;justify-content:space-between; }
.bottom-brand { font-size:12px;color:var(--text3);font-weight:500;letter-spacing:0.04em; } .bottom-brand { font-size:12px;color:var(--text3);font-weight:500;letter-spacing:0.04em; }
.bottom-brand span { color:var(--accent); } .bottom-brand span { color:var(--accent); }
.bottom-links { display:flex;gap:7px; } .bottom-links { display:flex;gap:7px; }
@ -218,11 +223,15 @@
<div class="load-chip"><div class="load-chip-label">15m</div><div class="load-chip-val" id="load-15"></div></div> <div class="load-chip"><div class="load-chip-label">15m</div><div class="load-chip-val" id="load-15"></div></div>
</div> </div>
</div> </div>
<div class="metric-item" id="net-row" style="display:none">
<div class="metric-header-row"><span class="metric-label">Network</span><span class="metric-val" id="net-val"></span></div>
</div>
<div class="stat-grid"> <div class="stat-grid">
<div class="stat-card"><div class="stat-label">Uptime</div><div class="stat-val" id="uptime-val"></div></div> <div class="stat-card"><div class="stat-label">Uptime</div><div class="stat-val" id="uptime-val"></div></div>
<div class="stat-card"><div class="stat-label">Sessions</div><div class="stat-val" id="sessions-val"></div></div> <div class="stat-card"><div class="stat-label">Sessions</div><div class="stat-val" id="sessions-val"></div></div>
<div class="stat-card"><div class="stat-label">Node.js</div><div class="stat-val" id="node-val"></div></div> <div class="stat-card"><div class="stat-label">Node.js</div><div class="stat-val" id="node-val"></div></div>
<div class="stat-card"><div class="stat-label">Platform</div><div class="stat-val" id="platform-val"></div></div> <div class="stat-card"><div class="stat-label">Platform</div><div class="stat-val" id="platform-val"></div></div>
<div class="stat-card" id="temp-card" style="display:none"><div class="stat-label">CPU Temp</div><div class="stat-val" id="temp-val"></div></div>
</div> </div>
<a class="open-btn" href="/" target="_blank" rel="noopener"><i data-lucide="terminal"></i>posimai-dev を開く</a> <a class="open-btn" href="/" target="_blank" rel="noopener"><i data-lucide="terminal"></i>posimai-dev を開く</a>
</div> </div>
@ -301,6 +310,10 @@
</div> </div>
</div> </div>
<div id="bottom"> <div id="bottom">
<div id="ecosystem-bar">
<div class="eco-item"><div class="eco-dot" id="eco-gitea-dot"></div><span id="eco-commit">commit: —</span></div>
</div>
<div id="bottom-row">
<div class="bottom-brand">posimai<span>-station</span></div> <div class="bottom-brand">posimai<span>-station</span></div>
<div class="bottom-links"> <div class="bottom-links">
<a class="bottom-link" href="/station-b" rel="noopener"><i data-lucide="monitor"></i>Design B</a> <a class="bottom-link" href="/station-b" rel="noopener"><i data-lucide="monitor"></i>Design B</a>
@ -309,6 +322,7 @@
<a class="bottom-link" href="https://posimai.soar-enrich.com" target="_blank" rel="noopener"><i data-lucide="layout-dashboard"></i>dashboard</a> <a class="bottom-link" href="https://posimai.soar-enrich.com" target="_blank" rel="noopener"><i data-lucide="layout-dashboard"></i>dashboard</a>
</div> </div>
<div id="refresh-countdown">次の更新まで <span id="countdown">30</span>s</div> <div id="refresh-countdown">次の更新まで <span id="countdown">30</span>s</div>
</div>
</div> </div>
</div> </div>
<script> <script>
@ -416,6 +430,21 @@ async function fetchHealth(){
document.getElementById('platform-val').textContent=data.platform||'—'; document.getElementById('platform-val').textContent=data.platform||'—';
document.getElementById('hostname').textContent=data.hostname||'ubuntu-pc'; document.getElementById('hostname').textContent=data.hostname||'ubuntu-pc';
if(data.net){
const netRow=document.getElementById('net-row');
if(netRow) netRow.style.display='';
const rx=data.net.rx_kbps,tx=data.net.tx_kbps;
const fmt=v=>v>=1024?`${(v/1024).toFixed(1)}MB/s`:`${v}KB/s`;
document.getElementById('net-val').textContent=`↓${fmt(rx)} ↑${fmt(tx)}`;
}
if(data.cpu_temp_c!=null){
const tc=document.getElementById('temp-card');
if(tc) tc.style.display='';
const t=data.cpu_temp_c;
document.getElementById('temp-val').textContent=`${t}°C`;
document.getElementById('temp-val').style.color=t>=80?'var(--crit)':t>=70?'var(--warn)':'var(--ok)';
}
updateRing('ring-cpu-fill','ring-cpu-val', cpuPct, 301.6,'#22D3EE','#FB923C','#F87171'); updateRing('ring-cpu-fill','ring-cpu-val', cpuPct, 301.6,'#22D3EE','#FB923C','#F87171');
updateRing('ring-mem-fill','ring-mem-val', memPct, 188.5,'#A78BFA','#FB923C','#F87171'); updateRing('ring-mem-fill','ring-mem-val', memPct, 188.5,'#A78BFA','#FB923C','#F87171');
updateRing('ring-disk-fill','ring-disk-val',diskPct,188.5,'#4ADE80','#FB923C','#F87171'); updateRing('ring-disk-fill','ring-disk-val',diskPct,188.5,'#4ADE80','#FB923C','#F87171');
@ -628,6 +657,33 @@ buildServiceCards();
if(window.lucide)lucide.createIcons(); if(window.lucide)lucide.createIcons();
runRefresh(); runRefresh();
window.addEventListener('resize',renderSparklines); window.addEventListener('resize',renderSparklines);
async function fetchGiteaCommit(){
try{
const r=await fetch('/api/gitea-commit',{signal:AbortSignal.timeout(4000)});
const d=await r.json();
if(d.error||!d.sha) return;
const dot=document.getElementById('eco-gitea-dot');
const el=document.getElementById('eco-commit');
if(dot) dot.className='eco-dot ok';
if(el){
const diff=Math.floor((Date.now()-new Date(d.date))/60000);
const ago=diff<1?'just now':diff<60?`${diff}m ago`:diff<1440?`${Math.floor(diff/60)}h ago`:`${Math.floor(diff/1440)}d ago`;
el.textContent=`${d.sha} ${d.message} (${ago})`;
}
}catch(_){}
}
fetchGiteaCommit();
setInterval(fetchGiteaCommit, 120000);
document.addEventListener('keydown', e => {
if(e.key==='r'||e.key==='R'){ runRefresh(); }
if(e.key==='b'||e.key==='B'){ window.location.href='/station-b'; }
if(e.key==='f'||e.key==='F'){
if(!document.fullscreenElement) document.documentElement.requestFullscreen().catch(()=>{});
else document.exitFullscreen();
}
});
</script> </script>
</body> </body>
</html> </html>