feature: Add editable hostname and operating system
Release / Build and Push Docker Image (push) Successful in 4m56s

This commit is contained in:
Keith Solomon
2026-04-14 07:42:39 -05:00
parent 1d9e98872c
commit d10e533793
8 changed files with 192 additions and 7 deletions
+1
View File
@@ -6,3 +6,4 @@ __pycache__/
.git
.gitignore
data/
codex-session.txt
+3 -3
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Set image name
shell: bash
run: echo "IMAGE_NAME=git.keithsolomon.net/${GITHUB_REPOSITORY_OWNER,,}/NetTrak" >> "$GITHUB_ENV"
run: echo "IMAGE_NAME=git.keithsolomon.net/${GITHUB_REPOSITORY_OWNER,,}/nettrak" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
@@ -28,8 +28,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: git.keithsolomon.net
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.pyc
.env
data/
codex-session.txt
+11
View File
@@ -22,9 +22,11 @@ CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL UNIQUE,
hostname TEXT,
custom_hostname TEXT,
mac TEXT,
vendor TEXT,
os_name TEXT,
custom_os_name TEXT,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
@@ -61,6 +63,15 @@ def init_db() -> None:
db_file.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(DB_PATH) as conn:
conn.executescript(SCHEMA)
ensure_column(conn, "devices", "custom_hostname", "TEXT")
ensure_column(conn, "devices", "custom_os_name", "TEXT")
def ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
existing = conn.execute(f"PRAGMA table_info({table})").fetchall()
column_names = {row[1] for row in existing}
if column not in column_names:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
@contextmanager
+13
View File
@@ -19,6 +19,7 @@ from .service import (
fetch_scans,
mark_missing_devices,
scan_state,
update_device_identity,
upsert_host,
)
@@ -60,6 +61,18 @@ def api_device(device_id: int) -> dict:
return device
@app.patch("/api/devices/{device_id}")
def api_update_device(device_id: int, payload: dict[str, Any]) -> dict:
device = update_device_identity(
device_id,
str(payload.get("hostname") or ""),
str(payload.get("os_name") or ""),
)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
return device
@app.get("/api/scans")
def api_scans(limit: int = 20) -> list[dict]:
return fetch_scans(limit=limit)
+33 -2
View File
@@ -238,7 +238,12 @@ def fetch_devices() -> list[dict]:
with get_conn() as conn:
rows = conn.execute(
"""
SELECT id, ip, hostname, os_name, mac, vendor, is_active, first_seen, last_seen
SELECT id, ip,
COALESCE(custom_hostname, hostname) AS hostname,
COALESCE(custom_os_name, os_name) AS os_name,
custom_hostname,
custom_os_name,
mac, vendor, is_active, first_seen, last_seen
FROM devices
ORDER BY is_active DESC, ip ASC
"""
@@ -250,7 +255,14 @@ def fetch_device(device_id: int) -> dict | None:
with get_conn() as conn:
device = conn.execute(
"""
SELECT id, ip, hostname, os_name, mac, vendor, is_active, first_seen, last_seen
SELECT id, ip,
COALESCE(custom_hostname, hostname) AS hostname,
COALESCE(custom_os_name, os_name) AS os_name,
custom_hostname,
custom_os_name,
hostname AS detected_hostname,
os_name AS detected_os_name,
mac, vendor, is_active, first_seen, last_seen
FROM devices
WHERE id = ?
""",
@@ -282,6 +294,25 @@ def fetch_device(device_id: int) -> dict | None:
return result
def update_device_identity(device_id: int, hostname: str | None, os_name: str | None) -> dict | None:
normalized_hostname = (hostname or "").strip() or None
normalized_os_name = (os_name or "").strip() or None
with get_conn() as conn:
updated = conn.execute(
"""
UPDATE devices
SET custom_hostname = ?, custom_os_name = ?
WHERE id = ?
""",
(normalized_hostname, normalized_os_name, device_id),
)
if updated.rowcount == 0:
return None
return fetch_device(device_id)
def fetch_scans(limit: int = 20) -> list[dict]:
with get_conn() as conn:
rows = conn.execute(
+86 -2
View File
@@ -12,6 +12,8 @@ const scanProgressDetail = document.getElementById('scanProgressDetail');
let devices = [];
let selectedDeviceId = null;
let selectedDevice = null;
let saveInFlight = false;
function setStatus(msg) {
statusText.textContent = msg;
@@ -128,19 +130,40 @@ function renderDeviceList() {
}
function renderMachineInfo(d) {
selectedDevice = d;
machineInfoEl.classList.remove('empty');
machineInfoEl.innerHTML = `
<form id="machineInfoForm" class="machine-info-form">
<div class="info-grid">
<div class="info-card"><div class="label">Hostname</div><div class="value">${d.hostname || '-'}</div></div>
<div class="info-card info-card-edit">
<div class="label">Hostname</div>
<input id="hostnameInput" class="inline-input" type="text" value="${escapeAttr(d.hostname || '')}" placeholder="${escapeAttr(d.detected_hostname || 'Hostname')}" />
<div class="subvalue">Detected: ${escapeHtml(d.detected_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 info-card-edit">
<div class="label">Operating System</div>
<input id="osNameInput" class="inline-input" type="text" value="${escapeAttr(d.os_name || '')}" placeholder="${escapeAttr(d.detected_os_name || 'Operating system')}" />
<div class="subvalue">Detected: ${escapeHtml(d.detected_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>
<div class="machine-info-actions">
<button id="saveDeviceBtn" type="submit" class="secondary-btn">Save Changes</button>
<button id="resetDeviceBtn" type="button" class="ghost-btn">Reset to Detected</button>
</div>
</form>
`;
const form = document.getElementById('machineInfoForm');
const resetBtn = document.getElementById('resetDeviceBtn');
form.addEventListener('submit', saveDeviceEdits);
resetBtn.addEventListener('click', resetDeviceEdits);
}
function renderPorts(ports) {
@@ -200,6 +223,67 @@ async function loadDevice(deviceId) {
renderPorts(d.ports || []);
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function escapeAttr(value) {
return escapeHtml(value);
}
async function saveDeviceEdits(event) {
event.preventDefault();
if (!selectedDevice || saveInFlight) return;
const hostname = document.getElementById('hostnameInput')?.value ?? '';
const osName = document.getElementById('osNameInput')?.value ?? '';
const saveBtn = document.getElementById('saveDeviceBtn');
saveInFlight = true;
if (saveBtn) saveBtn.disabled = true;
setStatus(`Saving edits for ${selectedDevice.ip}...`);
try {
const updated = await api(`/api/devices/${selectedDevice.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname, os_name: osName }),
});
selectedDevice = updated;
updateDeviceInList(updated);
renderMachineInfo(updated);
setStatus(`Saved hostname and operating system for ${updated.ip}.`);
} catch (err) {
setStatus(`Failed saving device changes: ${err.message}`);
} finally {
saveInFlight = false;
if (saveBtn) saveBtn.disabled = false;
}
}
function resetDeviceEdits() {
if (!selectedDevice) return;
const hostnameInput = document.getElementById('hostnameInput');
const osNameInput = document.getElementById('osNameInput');
if (hostnameInput) hostnameInput.value = '';
if (osNameInput) osNameInput.value = '';
setStatus('Manual overrides cleared in the form. Save to apply.');
}
function updateDeviceInList(updatedDevice) {
devices = devices.map((device) => (device.id === updatedDevice.id ? { ...device, ...updatedDevice } : device));
devices.sort((a, b) => {
if (a.is_active !== b.is_active) return b.is_active - a.is_active;
return compareIpNumeric(a, b);
});
renderDeviceList();
}
async function runScan() {
const subnet = subnetInput.value.trim();
if (!subnet) return;
+44
View File
@@ -81,6 +81,11 @@ button {
font-weight: 600;
}
button:disabled {
cursor: wait;
opacity: 0.75;
}
.scan-progress-card {
border: 1px solid var(--border);
border-radius: 10px;
@@ -206,6 +211,12 @@ button {
background: rgba(255,255,255,0.02);
}
.info-card-edit {
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
color: var(--muted);
font-size: 0.78rem;
@@ -218,6 +229,38 @@ button {
word-break: break-word;
}
.subvalue {
color: var(--muted);
font-size: 0.78rem;
}
.inline-input {
width: 100%;
min-width: 0;
background: rgba(10, 17, 24, 0.75);
}
.machine-info-form {
display: grid;
gap: 12px;
}
.machine-info-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.secondary-btn {
background: linear-gradient(180deg, var(--accent), var(--accent-2));
}
.ghost-btn {
border: 1px solid var(--border);
background: rgba(255,255,255,0.03);
color: var(--text);
}
details.port {
border: 1px solid var(--border);
border-radius: 10px;
@@ -263,4 +306,5 @@ summary {
.toolbar { min-width: 0; }
.controls { flex-direction: column; align-items: stretch; }
input { min-width: 0; }
.machine-info-actions { flex-direction: column; }
}