✨feature: Add editable hostname and operating system
Release / Build and Push Docker Image (push) Successful in 4m56s
Release / Build and Push Docker Image (push) Successful in 4m56s
This commit is contained in:
@@ -6,3 +6,4 @@ __pycache__/
|
||||
.git
|
||||
.gitignore
|
||||
data/
|
||||
codex-session.txt
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,3 +2,4 @@ __pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
data/
|
||||
codex-session.txt
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user