✨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
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
data/
|
data/
|
||||||
|
codex-session.txt
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Set image name
|
- name: Set image name
|
||||||
shell: bash
|
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
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -28,8 +28,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.keithsolomon.net
|
registry: git.keithsolomon.net
|
||||||
username: ${{ github.actor }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
data/
|
data/
|
||||||
|
codex-session.txt
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ip TEXT NOT NULL UNIQUE,
|
ip TEXT NOT NULL UNIQUE,
|
||||||
hostname TEXT,
|
hostname TEXT,
|
||||||
|
custom_hostname TEXT,
|
||||||
mac TEXT,
|
mac TEXT,
|
||||||
vendor TEXT,
|
vendor TEXT,
|
||||||
os_name TEXT,
|
os_name TEXT,
|
||||||
|
custom_os_name TEXT,
|
||||||
first_seen TEXT NOT NULL,
|
first_seen TEXT NOT NULL,
|
||||||
last_seen TEXT NOT NULL,
|
last_seen TEXT NOT NULL,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -61,6 +63,15 @@ def init_db() -> None:
|
|||||||
db_file.parent.mkdir(parents=True, exist_ok=True)
|
db_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with sqlite3.connect(DB_PATH) as conn:
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
conn.executescript(SCHEMA)
|
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
|
@contextmanager
|
||||||
|
|||||||
+13
@@ -19,6 +19,7 @@ from .service import (
|
|||||||
fetch_scans,
|
fetch_scans,
|
||||||
mark_missing_devices,
|
mark_missing_devices,
|
||||||
scan_state,
|
scan_state,
|
||||||
|
update_device_identity,
|
||||||
upsert_host,
|
upsert_host,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,6 +61,18 @@ def api_device(device_id: int) -> dict:
|
|||||||
return device
|
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")
|
@app.get("/api/scans")
|
||||||
def api_scans(limit: int = 20) -> list[dict]:
|
def api_scans(limit: int = 20) -> list[dict]:
|
||||||
return fetch_scans(limit=limit)
|
return fetch_scans(limit=limit)
|
||||||
|
|||||||
+33
-2
@@ -238,7 +238,12 @@ def fetch_devices() -> list[dict]:
|
|||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
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
|
FROM devices
|
||||||
ORDER BY is_active DESC, ip ASC
|
ORDER BY is_active DESC, ip ASC
|
||||||
"""
|
"""
|
||||||
@@ -250,7 +255,14 @@ def fetch_device(device_id: int) -> dict | None:
|
|||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
device = conn.execute(
|
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
|
FROM devices
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
@@ -282,6 +294,25 @@ def fetch_device(device_id: int) -> dict | None:
|
|||||||
return result
|
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]:
|
def fetch_scans(limit: int = 20) -> list[dict]:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|||||||
+86
-2
@@ -12,6 +12,8 @@ const scanProgressDetail = document.getElementById('scanProgressDetail');
|
|||||||
|
|
||||||
let devices = [];
|
let devices = [];
|
||||||
let selectedDeviceId = null;
|
let selectedDeviceId = null;
|
||||||
|
let selectedDevice = null;
|
||||||
|
let saveInFlight = false;
|
||||||
|
|
||||||
function setStatus(msg) {
|
function setStatus(msg) {
|
||||||
statusText.textContent = msg;
|
statusText.textContent = msg;
|
||||||
@@ -128,19 +130,40 @@ function renderDeviceList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderMachineInfo(d) {
|
function renderMachineInfo(d) {
|
||||||
|
selectedDevice = d;
|
||||||
machineInfoEl.classList.remove('empty');
|
machineInfoEl.classList.remove('empty');
|
||||||
machineInfoEl.innerHTML = `
|
machineInfoEl.innerHTML = `
|
||||||
|
<form id="machineInfoForm" class="machine-info-form">
|
||||||
<div class="info-grid">
|
<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">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">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">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">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">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 class="info-card"><div class="label">Last Seen</div><div class="value">${formatDate(d.last_seen)}</div></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) {
|
function renderPorts(ports) {
|
||||||
@@ -200,6 +223,67 @@ async function loadDevice(deviceId) {
|
|||||||
renderPorts(d.ports || []);
|
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() {
|
async function runScan() {
|
||||||
const subnet = subnetInput.value.trim();
|
const subnet = subnetInput.value.trim();
|
||||||
if (!subnet) return;
|
if (!subnet) return;
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ button {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.scan-progress-card {
|
.scan-progress-card {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -206,6 +211,12 @@ button {
|
|||||||
background: rgba(255,255,255,0.02);
|
background: rgba(255,255,255,0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-card-edit {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -218,6 +229,38 @@ button {
|
|||||||
word-break: break-word;
|
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 {
|
details.port {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -263,4 +306,5 @@ summary {
|
|||||||
.toolbar { min-width: 0; }
|
.toolbar { min-width: 0; }
|
||||||
.controls { flex-direction: column; align-items: stretch; }
|
.controls { flex-direction: column; align-items: stretch; }
|
||||||
input { min-width: 0; }
|
input { min-width: 0; }
|
||||||
|
.machine-info-actions { flex-direction: column; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user