diff --git a/.dockerignore b/.dockerignore index 5785c00..6e122cd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,4 @@ __pycache__/ .git .gitignore data/ +codex-session.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffb671b..24b2faa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index d51f033..4b2ab9d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.pyc .env data/ +codex-session.txt diff --git a/app/db.py b/app/db.py index 533a9df..5ad68cd 100644 --- a/app/db.py +++ b/app/db.py @@ -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 diff --git a/app/main.py b/app/main.py index b58062c..720f1d6 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/service.py b/app/service.py index 529e686..6424213 100644 --- a/app/service.py +++ b/app/service.py @@ -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( diff --git a/app/static/app.js b/app/static/app.js index 8c32d59..c368079 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -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 = ` +
-
Hostname
${d.hostname || '-'}
+
+
Hostname
+ +
Detected: ${escapeHtml(d.detected_hostname || '-')}
+
IP Address
${d.ip}
MAC Address
${d.mac || '-'}
Vendor
${d.vendor || '-'}
-
Operating System
${d.os_name || '-'}
+
+
Operating System
+ +
Detected: ${escapeHtml(d.detected_os_name || '-')}
+
Status
${d.is_active ? 'Active' : 'Not Seen in Last Scan'}
First Seen
${formatDate(d.first_seen)}
Last Seen
${formatDate(d.last_seen)}
+
+ + +
+
`; + + 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; diff --git a/app/static/styles.css b/app/static/styles.css index d23c44c..1980e3c 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -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; } }