Initial commit
This commit is contained in:
177
app/static/app.js
Normal file
177
app/static/app.js
Normal file
@@ -0,0 +1,177 @@
|
||||
const deviceListEl = document.getElementById('deviceList');
|
||||
const machineInfoEl = document.getElementById('machineInfo');
|
||||
const portsListEl = document.getElementById('portsList');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const scanBtn = document.getElementById('scanBtn');
|
||||
const subnetInput = document.getElementById('subnetInput');
|
||||
|
||||
let devices = [];
|
||||
let selectedDeviceId = null;
|
||||
|
||||
function setStatus(msg) {
|
||||
statusText.textContent = msg;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, options);
|
||||
if (!res.ok) {
|
||||
let detail = `${res.status}`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data.detail) detail = data.detail;
|
||||
} catch (_) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '-';
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
function deviceTitle(d) {
|
||||
return d.hostname ? `${d.hostname} (${d.ip})` : d.ip;
|
||||
}
|
||||
|
||||
function renderDeviceList() {
|
||||
deviceListEl.innerHTML = '';
|
||||
|
||||
if (!devices.length) {
|
||||
deviceListEl.innerHTML = '<li class="device-item">No devices discovered yet.</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
devices.forEach((d) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = `device-item ${selectedDeviceId === d.id ? 'active' : ''}`;
|
||||
li.innerHTML = `
|
||||
<div>${deviceTitle(d)}</div>
|
||||
<div class="meta">${d.os_name || 'OS unknown'} | ${d.is_active ? 'Active' : 'Missing'}</div>
|
||||
`;
|
||||
li.addEventListener('click', () => {
|
||||
selectedDeviceId = d.id;
|
||||
renderDeviceList();
|
||||
loadDevice(d.id);
|
||||
});
|
||||
deviceListEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMachineInfo(d) {
|
||||
machineInfoEl.classList.remove('empty');
|
||||
machineInfoEl.innerHTML = `
|
||||
<div class="info-grid">
|
||||
<div class="info-card"><div class="label">Hostname</div><div class="value">${d.hostname || '-'}</div></div>
|
||||
<div class="info-card"><div class="label">IP Address</div><div class="value">${d.ip}</div></div>
|
||||
<div class="info-card"><div class="label">MAC Address</div><div class="value">${d.mac || '-'}</div></div>
|
||||
<div class="info-card"><div class="label">Vendor</div><div class="value">${d.vendor || '-'}</div></div>
|
||||
<div class="info-card"><div class="label">Operating System</div><div class="value">${d.os_name || '-'}</div></div>
|
||||
<div class="info-card"><div class="label">Status</div><div class="value">${d.is_active ? 'Active' : 'Not Seen in Last Scan'}</div></div>
|
||||
<div class="info-card"><div class="label">First Seen</div><div class="value">${formatDate(d.first_seen)}</div></div>
|
||||
<div class="info-card"><div class="label">Last Seen</div><div class="value">${formatDate(d.last_seen)}</div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPorts(ports) {
|
||||
portsListEl.innerHTML = '';
|
||||
portsListEl.classList.remove('empty');
|
||||
|
||||
if (!ports.length) {
|
||||
portsListEl.classList.add('empty');
|
||||
portsListEl.textContent = 'No ports recorded for this machine.';
|
||||
return;
|
||||
}
|
||||
|
||||
ports.forEach((p) => {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'port';
|
||||
const svc = [p.service, p.product, p.version].filter(Boolean).join(' ');
|
||||
const headers = Object.entries(p.headers || {})
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n') || 'No headers captured';
|
||||
|
||||
details.innerHTML = `
|
||||
<summary>${p.port}/${p.protocol} - ${p.state}${svc ? ` - ${svc}` : ''}</summary>
|
||||
<div class="port-body">
|
||||
Service: ${svc || 'Unknown'}
|
||||
Extra: ${p.extra_info || '-'}
|
||||
Banner: ${p.banner || '-'}
|
||||
First Seen: ${formatDate(p.first_seen)}
|
||||
Last Seen: ${formatDate(p.last_seen)}
|
||||
Headers:\n${headers}
|
||||
</div>
|
||||
`;
|
||||
portsListEl.appendChild(details);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
devices = await api('/api/devices');
|
||||
renderDeviceList();
|
||||
|
||||
if (!selectedDeviceId && devices.length) {
|
||||
selectedDeviceId = devices[0].id;
|
||||
renderDeviceList();
|
||||
}
|
||||
|
||||
if (selectedDeviceId) {
|
||||
await loadDevice(selectedDeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevice(deviceId) {
|
||||
const d = await api(`/api/devices/${deviceId}`);
|
||||
renderMachineInfo(d);
|
||||
renderPorts(d.ports || []);
|
||||
}
|
||||
|
||||
async function runScan() {
|
||||
const subnet = subnetInput.value.trim();
|
||||
if (!subnet) return;
|
||||
|
||||
scanBtn.disabled = true;
|
||||
setStatus(`Starting scan on ${subnet}...`);
|
||||
|
||||
try {
|
||||
const result = await api(`/api/scans/run?subnet=${encodeURIComponent(subnet)}`, { method: 'POST' });
|
||||
setStatus(`Scan #${result.scan_id} running on ${result.subnet}. Refreshing automatically...`);
|
||||
await pollUntilComplete(result.scan_id);
|
||||
} catch (err) {
|
||||
setStatus(`Scan failed to start: ${err.message}`);
|
||||
} finally {
|
||||
scanBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollUntilComplete(scanId) {
|
||||
for (let i = 0; i < 240; i += 1) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const scans = await api('/api/scans?limit=1');
|
||||
const latest = scans[0];
|
||||
if (!latest || latest.id !== scanId) continue;
|
||||
|
||||
if (latest.status === 'running') {
|
||||
setStatus(`Scan #${scanId} is running...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
setStatus(`Scan #${scanId} ${latest.status} (${latest.host_count} hosts).`);
|
||||
await loadDevices();
|
||||
return;
|
||||
}
|
||||
setStatus(`Scan #${scanId} is taking longer than expected.`);
|
||||
}
|
||||
|
||||
scanBtn.addEventListener('click', runScan);
|
||||
|
||||
(async function init() {
|
||||
setStatus('Loading inventory...');
|
||||
try {
|
||||
await loadDevices();
|
||||
setStatus('Ready.');
|
||||
} catch (err) {
|
||||
setStatus(`Failed loading data: ${err.message}`);
|
||||
}
|
||||
})();
|
||||
48
app/static/index.html
Normal file
48
app/static/index.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NetTrak</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="title-wrap">
|
||||
<h1>NetTrak</h1>
|
||||
<p>Network inventory and port intelligence</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="subnetInput" type="text" value="192.168.2.0/24" aria-label="Subnet" />
|
||||
<button id="scanBtn">Run Scan</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<aside class="pane left-pane">
|
||||
<div class="pane-head">
|
||||
<h2>Discovered Machines</h2>
|
||||
</div>
|
||||
<ul id="deviceList" class="device-list"></ul>
|
||||
</aside>
|
||||
|
||||
<section class="right-stack">
|
||||
<section class="pane detail-pane">
|
||||
<div class="pane-head"><h2>Machine Info</h2></div>
|
||||
<div id="machineInfo" class="machine-info empty">Select a machine from the left pane.</div>
|
||||
</section>
|
||||
|
||||
<section class="pane ports-pane">
|
||||
<div class="pane-head"><h2>Open Ports</h2></div>
|
||||
<div id="portsList" class="ports-list empty">No machine selected.</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="statusbar">
|
||||
<span id="statusText">Ready.</span>
|
||||
</footer>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
214
app/static/styles.css
Normal file
214
app/static/styles.css
Normal file
@@ -0,0 +1,214 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0a1118;
|
||||
--panel: #111b24;
|
||||
--panel-2: #182531;
|
||||
--text: #d9e5ef;
|
||||
--muted: #8fa4b6;
|
||||
--accent: #5dc4ff;
|
||||
--accent-2: #2f9bd5;
|
||||
--ok: #6fdd8b;
|
||||
--warn: #f2b84d;
|
||||
--danger: #f67a7a;
|
||||
--border: #253648;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at 90% 0%, #193149 0%, transparent 40%),
|
||||
radial-gradient(circle at 10% 100%, #0f2a3a 0%, transparent 38%),
|
||||
var(--bg);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(7, 13, 19, 0.85);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.title-wrap h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.title-wrap p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
input, button {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: linear-gradient(180deg, var(--accent), var(--accent-2));
|
||||
border: none;
|
||||
color: #03101a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.layout {
|
||||
height: calc(100vh - 128px);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 300px 1fr;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(160deg, var(--panel), var(--panel-2));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane-head {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pane-head h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.left-pane { overflow: hidden; }
|
||||
|
||||
.device-list {
|
||||
list-style: none;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.device-item {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 6px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-item:hover { border-color: #33516b; }
|
||||
.device-item.active { border-color: var(--accent); background: rgba(93,196,255,0.08); }
|
||||
|
||||
.device-item .meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.right-stack {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.machine-info, .ports-list {
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.info-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-top: 5px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
details.port {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.port-body {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.empty { color: var(--muted); }
|
||||
|
||||
.statusbar {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 250px 1fr;
|
||||
height: auto;
|
||||
min-height: calc(100vh - 128px);
|
||||
}
|
||||
|
||||
.right-stack {
|
||||
grid-template-rows: 1fr 1fr;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.controls { flex-direction: column; align-items: stretch; }
|
||||
input { min-width: 0; }
|
||||
}
|
||||
Reference in New Issue
Block a user