3 Commits

Author SHA1 Message Date
Keith Solomon 98a424b929 Update sidebar
Release / Build and Push Docker Image (push) Successful in 53s
2026-04-18 12:20:33 -05:00
Keith Solomon b0aa6ff9a8 📄 docs: Update readme 2026-04-14 08:30:57 -05:00
Keith Solomon d246267c9d feature: Add compose file for pre-build image 2026-04-14 08:21:44 -05:00
6 changed files with 199 additions and 30 deletions
+14 -1
View File
@@ -1,6 +1,7 @@
# NetTrak # NetTrak
NetTrak is a Dockerized network inventory web app that scans a subnet and catalogs: NetTrak is a Dockerized network inventory web app that scans a subnet and catalogs:
- Devices discovered on the network - Devices discovered on the network
- Open ports per device - Open ports per device
- Service fingerprint details from `nmap` - Service fingerprint details from `nmap`
@@ -29,17 +30,28 @@ Results are persisted in SQLite for change tracking (new/updated/missing devices
## Run With Docker Compose ## Run With Docker Compose
Use the provided `docker-compose.yml` to run the app:
```bash ```bash
docker compose up --build docker compose up -d
``` ```
Then open: `http://localhost:1337` Then open: `http://localhost:1337`
Database file is stored at `./data/nettrak.db` via a bind mount. Database file is stored at `./data/nettrak.db` via a bind mount.
## For a fresh build (for example to change the internal port)
```bash
git clone https://git.keithsolomon.net/keith/NetTrak
cd NetTrak
docker compose -f docker-compose-build.yml up --build
```
## Configuration ## Configuration
Environment variables: Environment variables:
- `NETTRAK_DB_PATH` (default: `/data/nettrak.db`) - `NETTRAK_DB_PATH` (default: `/data/nettrak.db`)
- `NETTRAK_SUBNET` (default: `192.168.2.0/24`) - `NETTRAK_SUBNET` (default: `192.168.2.0/24`)
- `NETTRAK_TOP_PORTS` (default: `100`) - `NETTRAK_TOP_PORTS` (default: `100`)
@@ -69,6 +81,7 @@ network_mode: host
NetTrak can optionally annotate host ports that are published by Docker containers on the scan host. NetTrak can optionally annotate host ports that are published by Docker containers on the scan host.
To enable: To enable:
- set `NETTRAK_ENABLE_DOCKER_INSIGHTS=1` - set `NETTRAK_ENABLE_DOCKER_INSIGHTS=1`
- mount the Docker socket: - mount the Docker socket:
+79 -19
View File
@@ -14,6 +14,8 @@ let devices = [];
let selectedDeviceId = null; let selectedDeviceId = null;
let selectedDevice = null; let selectedDevice = null;
let saveInFlight = false; let saveInFlight = false;
let activeSectionOpen = true;
let inactiveSectionOpen = false;
function setStatus(msg) { function setStatus(msg) {
statusText.textContent = msg; statusText.textContent = msg;
@@ -105,6 +107,21 @@ function compareIpNumeric(a, b) {
return String(a.ip || '').localeCompare(String(b.ip || '')); return String(a.ip || '').localeCompare(String(b.ip || ''));
} }
function compareDevices(a, b) {
const aName = String(a.hostname || '').trim().toLocaleLowerCase();
const bName = String(b.hostname || '').trim().toLocaleLowerCase();
if (aName && bName) {
const byName = aName.localeCompare(bName);
if (byName !== 0) return byName;
return compareIpNumeric(a, b);
}
if (aName) return -1;
if (bName) return 1;
return compareIpNumeric(a, b);
}
function renderDeviceList() { function renderDeviceList() {
deviceListEl.innerHTML = ''; deviceListEl.innerHTML = '';
@@ -113,20 +130,69 @@ function renderDeviceList() {
return; return;
} }
devices.forEach((d) => { const activeDevices = devices.filter((device) => device.is_active);
const li = document.createElement('li'); const inactiveDevices = devices.filter((device) => !device.is_active);
li.className = `device-item ${selectedDeviceId === d.id ? 'active' : ''}`;
li.innerHTML = ` deviceListEl.appendChild(createSection('Active Machines', activeDevices, activeSectionOpen, true));
<div>${deviceTitle(d)}</div> deviceListEl.appendChild(createSection('Inactive Machines', inactiveDevices, inactiveSectionOpen, false));
<div class="meta">${d.os_name || 'OS unknown'} | ${d.is_active ? 'Active' : 'Missing'}</div> attachDeviceListHandlers();
`; }
li.addEventListener('click', () => {
selectedDeviceId = d.id; function createSection(title, sectionDevices, isOpen, isActiveSection) {
const sectionItem = document.createElement('li');
sectionItem.className = 'device-section-item';
const details = document.createElement('details');
details.className = 'device-section';
details.dataset.section = isActiveSection ? 'active' : 'inactive';
details.open = isOpen;
const itemsHtml = sectionDevices.length
? sectionDevices.map((device) => `
<li class="device-item ${selectedDeviceId === device.id ? 'active' : ''}" data-device-id="${device.id}">
<div>${escapeHtml(deviceTitle(device))}</div>
<div class="meta">${escapeHtml(device.os_name || 'OS unknown')} | ${device.is_active ? 'Active' : 'Missing'}</div>
</li>
`).join('')
: '<li class="device-empty">No machines in this section.</li>';
details.innerHTML = `
<summary class="device-section-summary">
<span>${escapeHtml(title)}</span>
<span class="device-section-count">${sectionDevices.length}</span>
</summary>
<ul class="device-section-list">${itemsHtml}</ul>
`;
sectionItem.appendChild(details);
return sectionItem;
}
function attachDeviceListHandlers() {
deviceListEl.querySelectorAll('.device-item[data-device-id]').forEach((item) => {
item.addEventListener('click', () => {
const deviceId = Number(item.dataset.deviceId);
if (!deviceId) return;
selectedDeviceId = deviceId;
renderDeviceList(); renderDeviceList();
loadDevice(d.id); loadDevice(deviceId);
}); });
deviceListEl.appendChild(li);
}); });
const activeSection = deviceListEl.querySelector('.device-section[data-section="active"]');
const inactiveSection = deviceListEl.querySelector('.device-section[data-section="inactive"]');
if (activeSection) {
activeSection.addEventListener('toggle', () => {
activeSectionOpen = activeSection.open;
});
}
if (inactiveSection) {
inactiveSection.addEventListener('toggle', () => {
inactiveSectionOpen = inactiveSection.open;
});
}
} }
function renderMachineInfo(d) { function renderMachineInfo(d) {
@@ -201,10 +267,7 @@ Headers:\n${headers}
async function loadDevices() { async function loadDevices() {
devices = await api('/api/devices'); devices = await api('/api/devices');
devices.sort((a, b) => { devices.sort(compareDevices);
if (a.is_active !== b.is_active) return b.is_active - a.is_active;
return compareIpNumeric(a, b);
});
renderDeviceList(); renderDeviceList();
if (!selectedDeviceId && devices.length) { if (!selectedDeviceId && devices.length) {
@@ -277,10 +340,7 @@ function resetDeviceEdits() {
function updateDeviceInList(updatedDevice) { function updateDeviceInList(updatedDevice) {
devices = devices.map((device) => (device.id === updatedDevice.id ? { ...device, ...updatedDevice } : device)); devices = devices.map((device) => (device.id === updatedDevice.id ? { ...device, ...updatedDevice } : device));
devices.sort((a, b) => { devices.sort(compareDevices);
if (a.is_active !== b.is_active) return b.is_active - a.is_active;
return compareIpNumeric(a, b);
});
renderDeviceList(); renderDeviceList();
} }
+55
View File
@@ -164,6 +164,51 @@ button:disabled {
overflow: auto; overflow: auto;
} }
.device-section-item {
list-style: none;
margin-bottom: 8px;
}
.device-section {
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(255,255,255,0.015);
overflow: hidden;
}
.device-section-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 11px 12px;
cursor: pointer;
font-weight: 600;
list-style: none;
user-select: none;
}
.device-section-summary::-webkit-details-marker {
display: none;
}
.device-section-count {
min-width: 28px;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: rgba(255,255,255,0.03);
color: var(--muted);
font-size: 0.78rem;
text-align: center;
}
.device-section-list {
list-style: none;
margin: 0;
padding: 0 6px 6px;
border-top: 1px solid rgba(255,255,255,0.04);
}
.device-item { .device-item {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 10px; border-radius: 10px;
@@ -173,6 +218,16 @@ button:disabled {
cursor: pointer; cursor: pointer;
} }
.device-section-list .device-item:first-child {
margin-top: 6px;
}
.device-empty {
padding: 12px 10px;
color: var(--muted);
font-size: 0.84rem;
}
.device-item:hover { border-color: #33516b; } .device-item:hover { border-color: #33516b; }
.device-item.active { border-color: var(--accent); background: rgba(93,196,255,0.08); } .device-item.active { border-color: var(--accent); background: rgba(93,196,255,0.08); }
+1
View File
@@ -0,0 +1 @@
codex resume 019ccf04-af34-7883-a705-2802dd142306
+37
View File
@@ -0,0 +1,37 @@
services:
nettrak:
container_name: NetTrak
restart: unless-stopped
build: .
environment:
- NETTRAK_DB_PATH=/data/nettrak.db
# Set this to your local subnet. Example for common home network:
- NETTRAK_SUBNET=192.168.2.0/24
- NETTRAK_TOP_PORTS=100
# Optional explicit port set/range. Example catches most app ports:
- NETTRAK_PORT_SPEC=1-10000
- NETTRAK_SCAN_WORKERS=12
- NETTRAK_PORT_PROBE_TIMEOUT=0.4
- NETTRAK_ENABLE_OS_DETECTION=0
- NETTRAK_ENABLE_DOCKER_INSIGHTS=1
# Set this if Docker published ports are bound to 0.0.0.0 and host IP cannot be inferred.
- NETTRAK_DOCKER_HOST_IP=192.168.2.23
# For best host discovery on Linux, use host mode.
# If you do that, remove the `ports` section and ensure port 1337 is free.
# NOTE: If you want/need to change the port, you have to rebuild the image to update the EXPOSE instruction (see docker-compose-build.yml).
# network_mode: host
# Helps nmap discover MAC addresses/OS details in containerized runs.
cap_add:
- NET_ADMIN
- NET_RAW
ports:
- "1337:1337"
volumes:
- ./data:/data
# Optional: mount Docker socket to include container-published host ports in results.
# - /var/run/docker.sock:/var/run/docker.sock:ro
+13 -10
View File
@@ -1,35 +1,38 @@
services: services:
nettrak: nettrak:
container_name: NetTrak container_name: NetTrak
build: .
restart: unless-stopped restart: unless-stopped
image: git.keithsolomon.net/keith/nettrak:latest
environment: environment:
- NETTRAK_DB_PATH=/data/nettrak.db - NETTRAK_DB_PATH=/data/nettrak.db
# Set this to your local subnet. Example for common home network:
- NETTRAK_SUBNET=192.168.2.0/24 - NETTRAK_SUBNET=192.168.2.0/24
- NETTRAK_TOP_PORTS=100 - NETTRAK_TOP_PORTS=100
# Optional explicit port set/range. Example catches 8989 and many app ports: # Optional explicit port set/range. Example catches most app ports:
# - NETTRAK_PORT_SPEC=1-10000 - NETTRAK_PORT_SPEC=1-10000
- NETTRAK_SCAN_WORKERS=12 - NETTRAK_SCAN_WORKERS=12
- NETTRAK_PORT_PROBE_TIMEOUT=0.4 - NETTRAK_PORT_PROBE_TIMEOUT=0.4
- NETTRAK_ENABLE_OS_DETECTION=0 - NETTRAK_ENABLE_OS_DETECTION=0
- NETTRAK_ENABLE_DOCKER_INSIGHTS=0 - NETTRAK_ENABLE_DOCKER_INSIGHTS=1
# Set this if Docker published ports are bound to 0.0.0.0 and host IP cannot be inferred. # Set this if Docker published ports are bound to 0.0.0.0 and host IP cannot be inferred.
# - NETTRAK_DOCKER_HOST_IP=192.168.2.10 - NETTRAK_DOCKER_HOST_IP=192.168.2.23
# For best host discovery on Linux, you can switch to host mode. # For best host discovery on Linux, use host mode.
# If you do that, remove the `ports` section and ensure APP_PORT is free. # If you do that, remove the `ports` section and ensure port 1337 is free.
# network_mode: host # NOTE: If you want/need to change the port, you have to rebuild the image to update the EXPOSE instruction (see docker-compose-build.yml).
#network_mode: host
# Helps nmap discover MAC addresses/OS details in containerized runs. # Helps nmap discover MAC addresses/OS details in containerized runs.
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- NET_RAW - NET_RAW
ports: # ports:
- "1337:1337" - "1337:1337"
volumes: volumes:
# Store the database on the host for persistence across container restarts and easy access.
- ./data:/data - ./data:/data
# Optional: mount Docker socket to include container-published host ports in results. # Optional: mount Docker socket to include container-published host ports in results.
# - /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro