chore: initial import
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
dist/
|
||||
@@ -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`.
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = '<tr><td colspan="4">Loading...</td></tr>';
|
||||
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 = `
|
||||
<td>${r.name}</td>
|
||||
<td><span class=\"status ${statusClass}\">${activeLine}</span></td>
|
||||
<td>${portsText}</td>
|
||||
<td>${r.parsed?.pid || ''}</td>
|
||||
<td class=\"controls\">
|
||||
<button data-name=\"${r.name}\" class=\"view-logs\">View Logs</button>
|
||||
<button data-name=\"${r.name}\" class=\"view-status\">Raw Status</button>
|
||||
</td>
|
||||
`;
|
||||
table.appendChild(tr);
|
||||
}
|
||||
wireButtons();
|
||||
} catch (err) {
|
||||
table.innerHTML = `<tr><td colspan="4">Error: ${err}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Service Dashboard</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 20px; }
|
||||
header { display: flex; align-items: center; gap: 12px; }
|
||||
.status { padding: 6px 10px; border-radius: 6px; background: #f5f5f5; }
|
||||
.ok { color: #0a7b22; }
|
||||
.bad { color: #b00020; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 16px; }
|
||||
th, td { border-bottom: 1px solid #ddd; padding: 8px; }
|
||||
.controls { display: flex; gap: 8px; }
|
||||
textarea { width: 100%; height: 200px; }
|
||||
.row { display: flex; gap: 8px; margin-top: 16px; }
|
||||
input[type=text] { flex: 1; padding: 8px; }
|
||||
button { padding: 8px 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Service Dashboard</h1>
|
||||
<button id="refresh">Refresh Status</button>
|
||||
<label style="margin-left:16px; display:flex; align-items:center; gap:6px;">
|
||||
<input type="checkbox" id="autoRefreshToggle" /> Auto-refresh
|
||||
</label>
|
||||
<select id="autoRefreshInterval" aria-label="Auto-refresh interval" style="margin-left:8px;">
|
||||
<option value="5">5s</option>
|
||||
<option value="10" selected>10s</option>
|
||||
<option value="30">30s</option>
|
||||
<option value="60">60s</option>
|
||||
</select>
|
||||
</header>
|
||||
|
||||
<section class="row">
|
||||
<input id="serviceName" type="text" placeholder="e.g. my-app.service" />
|
||||
<button id="addService">Add Service</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Status</th>
|
||||
<th>Ports</th>
|
||||
<th>Details</th>
|
||||
<th>Logs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="servicesTable"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Logs</h3>
|
||||
<textarea id="logs" readonly></textarea>
|
||||
</section>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
"idea-feedback.service",
|
||||
"job-info.service",
|
||||
"job-info-test.service",
|
||||
"excel-listener.service",
|
||||
"job-form.service"
|
||||
]
|
||||
+157
@@ -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<string, string> {
|
||||
const lines = text.split('\n');
|
||||
const out: Record<string, string> = {};
|
||||
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<number[]> {
|
||||
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<number[]> {
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user