From ddb13d0e5991f1a4dd696ce3746d23e16fcb6aff Mon Sep 17 00:00:00 2001 From: aewing Date: Mon, 22 Dec 2025 21:22:10 +0000 Subject: [PATCH] chore: initial import --- .gitignore | 6 ++ README.md | 27 ++++++++ bun.lock | 26 ++++++++ package.json | 15 +++++ public/app.js | 91 +++++++++++++++++++++++++++ public/index.html | 64 +++++++++++++++++++ services.json | 7 +++ src/server.ts | 157 ++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 393 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 services.json create mode 100644 src/server.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b8384a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.DS_Store +.env +.env.* +*.log +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..13f5964 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Service Dashboard (Local Utility) + +A lightweight local web UI to quickly check statuses of systemd services and add more to the list. + +- No daemon/service; run manually when needed. +- Uses Bun + Hono. + +## Quick Start + +```bash +cd Utility/service-dashboard +bun install +bun run src/server.ts +``` + +Open http://localhost:7010 in your browser. + +## Default Services + +Pre-seeded with: +- idea-feedback.service +- job-info.service +- job-info-test.service +- excel-listener.service +- job-form.service + +You can add/remove services in the UI; they persist to `services.json`. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..1c70fa1 --- /dev/null +++ b/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "service-dashboard", + "dependencies": { + "hono": "^4.10.8", + }, + "devDependencies": { + "@types/bun": "latest", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + + "hono": ["hono@4.11.1", "", {}, "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1636564 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "service-dashboard", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun run src/server.ts" + }, + "dependencies": { + "hono": "^4.10.8" + }, + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..417dfa4 --- /dev/null +++ b/public/app.js @@ -0,0 +1,91 @@ +async function fetchJSON(url, opts) { + const res = await fetch(url, opts); + return res.json(); +} + +async function loadStatuses() { + const table = document.getElementById('servicesTable'); + table.innerHTML = 'Loading...'; + try { + const data = await fetchJSON('/api/statuses'); + table.innerHTML = ''; + for (const r of data.results || []) { + const tr = document.createElement('tr'); + const activeLine = r.parsed?.active || 'Unknown'; + const isRunning = /Active:\s+active/i.test(activeLine) && /\(running\)/i.test(activeLine); + const isFailed = /Active:\s+(failed|inactive)/i.test(activeLine) || /\(dead\)/i.test(activeLine); + const statusClass = isRunning ? 'ok' : (isFailed ? 'bad' : ''); + const portsText = Array.isArray(r.ports) && r.ports.length ? r.ports.join(', ') : ''; + tr.innerHTML = ` + ${r.name} + ${activeLine} + ${portsText} + ${r.parsed?.pid || ''} + + + + + `; + table.appendChild(tr); + } + wireButtons(); + } catch (err) { + table.innerHTML = `Error: ${err}`; + } +} + +function wireButtons() { + document.querySelectorAll('.view-logs').forEach(btn => { + btn.addEventListener('click', async () => { + const name = btn.getAttribute('data-name'); + const data = await fetchJSON(`/api/logs?name=${encodeURIComponent(name)}&n=100`); + document.getElementById('logs').value = data.logs || JSON.stringify(data, null, 2); + }); + }); + document.querySelectorAll('.view-status').forEach(btn => { + btn.addEventListener('click', async () => { + const name = btn.getAttribute('data-name'); + const data = await fetchJSON(`/api/status?name=${encodeURIComponent(name)}`); + document.getElementById('logs').value = data.raw || JSON.stringify(data, null, 2); + }); + }); +} + +async function addService() { + const nameInput = document.getElementById('serviceName'); + const name = nameInput.value.trim(); + if (!name.endsWith('.service')) { + alert('Service name must end with .service'); + return; + } + const data = await fetchJSON('/api/services', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + nameInput.value = ''; + await loadStatuses(); +} + +let autoRefreshTimer = null; + +function updateAutoRefresh() { + const enabled = document.getElementById('autoRefreshToggle').checked; + const intervalSec = Number(document.getElementById('autoRefreshInterval').value || '10'); + if (autoRefreshTimer) { + clearInterval(autoRefreshTimer); + autoRefreshTimer = null; + } + if (enabled) { + autoRefreshTimer = setInterval(loadStatuses, intervalSec * 1000); + } +} + +window.addEventListener('DOMContentLoaded', () => { + document.getElementById('refresh').addEventListener('click', loadStatuses); + document.getElementById('addService').addEventListener('click', addService); + document.getElementById('autoRefreshToggle').addEventListener('change', updateAutoRefresh); + document.getElementById('autoRefreshInterval').addEventListener('change', updateAutoRefresh); + loadStatuses(); + updateAutoRefresh(); +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..1b49bc9 --- /dev/null +++ b/public/index.html @@ -0,0 +1,64 @@ + + + + + + Service Dashboard + + + +
+

Service Dashboard

+ + + +
+ +
+ + +
+ +
+ + + + + + + + + + + +
ServiceStatusPortsDetailsLogs
+
+ +
+

Logs

+ +
+ + + + diff --git a/services.json b/services.json new file mode 100644 index 0000000..7cf4be7 --- /dev/null +++ b/services.json @@ -0,0 +1,7 @@ +[ + "idea-feedback.service", + "job-info.service", + "job-info-test.service", + "excel-listener.service", + "job-form.service" +] diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..cdf8e2f --- /dev/null +++ b/src/server.ts @@ -0,0 +1,157 @@ +import { Hono } from 'hono'; +import { serveStatic } from 'hono/bun'; +import { readFileSync, writeFileSync } from 'fs'; +import { spawn } from 'bun'; + +const app = new Hono(); +const PORT = 7010; +const SERVICES_PATH = './services.json'; + +function readServices(): string[] { + try { + const raw = readFileSync(SERVICES_PATH, 'utf-8'); + const list = JSON.parse(raw); + if (Array.isArray(list)) return list as string[]; + } catch {} + return []; +} + +function writeServices(list: string[]) { + writeFileSync(SERVICES_PATH, JSON.stringify(list, null, 2)); +} + +async function execCmd(cmd: string, args: string[], timeoutMs = 4000): Promise<{ ok: boolean; stdout: string; stderr: string }>{ + const p = spawn({ cmd: [cmd, ...args], stdout: 'pipe', stderr: 'pipe' }); + const timer = setTimeout(() => { + try { p.kill(); } catch {} + }, timeoutMs); + const stdout = await new Response(p.stdout).text(); + const stderr = await new Response(p.stderr).text(); + await p.exited; + clearTimeout(timer); + return { ok: p.exitCode === 0, stdout, stderr }; +} + +function parseSystemctlStatus(text: string): Record { + const lines = text.split('\n'); + const out: Record = {}; + for (const l of lines) { + if (l.trim().startsWith('Loaded:')) out['loaded'] = l.trim(); + if (l.trim().startsWith('Active:')) out['active'] = l.trim(); + if (l.trim().startsWith('Main PID:')) out['pid'] = l.trim(); + if (l.includes('CGroup:')) out['cgroup'] = l.trim(); + } + return out; +} + +function extractMainPid(text: string): number | null { + const m = text.match(/Main PID:\s+(\d+)/); + return m ? Number(m[1]) : null; +} + +async function getServicePids(name: string): Promise { + const res = await execCmd('systemctl', ['show', name, '-p', 'MainPID', '-p', 'ControlGroup']); + const out = res.stdout || res.stderr || ''; + const main = out.match(/MainPID=(\d+)/); + const cg = out.match(/ControlGroup=(.+)/); + const pids: number[] = []; + if (main) { + const v = Number(main[1]); + if (!Number.isNaN(v) && v > 0) pids.push(v); + } + if (cg && cg[1]) { + const cgPath = `/sys/fs/cgroup${cg[1].trim()}/cgroup.procs`; + try { + const contents = readFileSync(cgPath, 'utf-8'); + for (const line of contents.split('\n')) { + const v = Number(line.trim()); + if (!Number.isNaN(v) && v > 0 && !pids.includes(v)) pids.push(v); + } + } catch { + // ignore + } + } + return pids; +} + +async function getPortsByPids(pids: number[]): Promise { + if (!pids.length) return []; + const res = await execCmd('ss', ['-lntp']); + if (!res.ok && !res.stdout) return []; + const lines = (res.stdout || '').split('\n'); + const ports: number[] = []; + for (const line of lines) { + const pidMatch = line.match(/pid=(\d+)/); + if (pidMatch) { + const pid = Number(pidMatch[1]); + if (pids.includes(pid)) { + const m = line.match(/\S*:(\d+)/); + if (m) { + const p = Number(m[1]); + if (!Number.isNaN(p) && !ports.includes(p)) ports.push(p); + } + } + } + } + return ports; +} + +app.get('/api/services', (c) => { + return c.json({ services: readServices() }); +}); + +app.post('/api/services', async (c) => { + try { + const body = await c.req.json(); + const name = String(body?.name || '').trim(); + if (!name || !name.endsWith('.service')) { + return c.json({ error: 'Service name must end with .service' }, 400); + } + const list = readServices(); + if (!list.includes(name)) { + list.push(name); + writeServices(list); + } + return c.json({ services: list }); + } catch (err: any) { + return c.json({ error: err?.message || String(err) }, 400); + } +}); + +app.get('/api/status', async (c) => { + const name = c.req.query('name'); + if (!name) return c.json({ error: 'name required' }, 400); + const res = await execCmd('systemctl', ['--no-pager', 'status', name]); + const parsed = parseSystemctlStatus(res.stdout || res.stderr); + return c.json({ name, ok: res.ok, parsed, raw: res.stdout || res.stderr }); +}); + +app.get('/api/statuses', async (c) => { + const list = readServices(); + const results = await Promise.all(list.map(async (name) => { + const res = await execCmd('systemctl', ['--no-pager', 'status', name]); + const raw = res.stdout || res.stderr; + const parsed = parseSystemctlStatus(raw); + const pids = await getServicePids(name); + const ports = await getPortsByPids(pids); + const pid = extractMainPid(raw); + return { name, ok: res.ok, parsed, ports, pid }; + })); + return c.json({ results }); +}); + +app.get('/api/logs', async (c) => { + const name = c.req.query('name'); + const n = Number(c.req.query('n') || 100); + if (!name) return c.json({ error: 'name required' }, 400); + const res = await execCmd('journalctl', ['-u', name, '-n', String(n), '--no-pager']); + return c.json({ name, ok: res.ok, logs: res.stdout || res.stderr }); +}); + +// Static frontend +app.use('/*', serveStatic({ root: './public' })); + +export default { + port: PORT, + fetch: app.fetch, +};