Ubiquiti Unifi Client Utility

The Ubiquiti Unifi DHCP Client listing is awesome for managing the addresses granted to the various systems on your Unifi Gateway. I advocate the use of DHCP Address reservations managed by the Unifi gateway rather than assigning static addresses on individual systems. The reason for this is because DHCP address reservations provide centralized management and make it easy to keep track of your address assignments.

The problem is that the Unifi client listing is not always comprehensive enough to properly manage all the clients assigned on your Unifi Gateway. That is why I have developed a utility to provide better visibility for those of you using Unifi gateways.

I call this the “UniFi Client Reservation Manager”. This is a read-only web based utility written in Javascript to provide greater visibility into your DHCP client address assignments.

Let’s create an Incus container in which to create the application.

incus launch images:ubuntu/24.04 UnifiClients -p default -p bridgeprofile -c boot.autostart=true

Move inside the container.

incus shell UnifiClients

Accept the updates and install dependencies.

apt update && apt upgrade -y
apt install openssh-server nano net-tools nodejs npm  -y

Add a user account and grant it sudo privilege.

adduser scott
usermod -aG sudo scott

Log into the user account.

su - scott

Create an application folder and move into it.

mkdir -p ~/unifi-client-manager
cd ~/unifi-client-manager

Create a configuration file.

nano config.json

Insert the following into the file.

{
  "unifiBaseUrl": "https://n.n.n.n",
  "username": "YOUR_UNIFI_USERNAME",
  "password": "YOUR_UNIFI_PASSWORD",
  "site": "default",
  "verifySsl": false,
  "pollIntervalMs": 60000,
  "port": 3000
}

Substitute a Unifi local username and password for an account on your unifi gateway in the file above. Also change the n.n.n.n to be the gateway address of your Unifi gateway or the address of your cloud key. Yes, it should be https and not http. Save the file with CTRL O and enter and exit the nano editor with a CTRL X.

Create a javascript.

nano server.js

Insert the following into the file.

const express = require('express');
const fs = require('fs');
const path = require('path');
const { Agent } = require('undici');

const configPath = path.join(__dirname, 'config.json');
if (!fs.existsSync(configPath)) {
  console.error('Missing config.json');
  process.exit(1);
}

const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const app = express();

app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));

const PORT = Number(config.port || 3000);
const POLL_INTERVAL_MS = Number(config.pollIntervalMs || 15000);

const fetchDispatcher = new Agent({
  connect: {
    rejectUnauthorized: Boolean(config.verifySsl)
  }
});

let latestClients = [];
let lastRefresh = null;
let lastError = null;
let refreshInProgress = false;

function parseSetCookie(headers) {
  if (typeof headers.getSetCookie === 'function') {
    const cookies = headers.getSetCookie();
    if (Array.isArray(cookies) && cookies.length > 0) {
      return cookies.map(c => c.split(';')[0]).join('; ');
    }
  }

  const rawCookie = headers.get('set-cookie');
  if (!rawCookie) return '';

  return rawCookie
    .split(/,(?=[^;]+=[^;]+)/)
    .map(c => c.split(';')[0].trim())
    .join('; ');
}

function ipToNumber(ip) {
  if (!ip) return 0;
  const parts = ip.split('.').map(Number);
  if (parts.length !== 4 || parts.some(n => Number.isNaN(n))) return 0;
  return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
}

function formatLastSeenFromTs(ts) {
  const n = Number(ts || 0);
  if (!n) return 'Unknown';

  const ageSeconds = Math.max(0, Math.floor(Date.now() / 1000) - n);

  if (ageSeconds < 60) return 'Now';
  if (ageSeconds < 3600) return `${Math.floor(ageSeconds / 60)}m ago`;
  if (ageSeconds < 86400) return `${Math.floor(ageSeconds / 3600)}h ago`;
  return `${Math.floor(ageSeconds / 86400)}d ago`;
}

function normalizeMac(mac) {
  return String(mac || '').trim().toUpperCase();
}

function chooseName(...values) {
  for (const v of values) {
    if (v !== undefined && v !== null && String(v).trim() !== '') {
      return String(v).trim();
    }
  }
  return '';
}

async function unifiLogin() {
  const loginUrl = `${config.unifiBaseUrl.replace(/\/$/, '')}/api/auth/login`;

  const response = await fetch(loginUrl, {
    method: 'POST',
    dispatcher: fetchDispatcher,
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    body: JSON.stringify({
      username: config.username,
      password: config.password
    })
  });

  if (!response.ok) {
    const text = await response.text();
    throw new Error(`UniFi login failed: ${response.status} ${text}`);
  }

  const cookie = parseSetCookie(response.headers);
  if (!cookie) {
    throw new Error('UniFi login succeeded but no session cookie was returned');
  }

  return cookie;
}

async function fetchJson(url, cookie) {
  const response = await fetch(url, {
    method: 'GET',
    dispatcher: fetchDispatcher,
    headers: {
      Cookie: cookie,
      Accept: 'application/json'
    }
  });

  if (!response.ok) {
    const text = await response.text();
    throw new Error(`UniFi fetch failed: ${response.status} ${text}`);
  }

  return response.json();
}

function buildClientFromMerged(userClient, activeClient) {
  const user = userClient || {};
  const active = activeClient || {};

  const mac = normalizeMac(chooseName(active.mac, user.mac));
  const ip = chooseName(active.ip, user.ip, user.fixed_ip);
  const clientName = chooseName(user.name, active.name);
  const dnsName = chooseName(active.hostname, user.hostname, active.dns_name, user.dns_name);

  const network = chooseName(
    user.network,
    user.network_name,
    active.network,
    active.network_name,
    user.essid,
    active.essid
  );

  const reserved =
    Boolean(user.use_fixedip) ||
    Boolean(user.fixed_ip) ||
    Boolean(user.is_fixed_ip) ||
    Boolean(user.is_reserved) ||
    Boolean(user.reserved) ||
    Boolean(active.use_fixedip) ||
    Boolean(active.fixed_ip) ||
    Boolean(active.is_fixed_ip) ||
    Boolean(active.is_reserved) ||
    Boolean(active.reserved);

  let connectionType = 'unknown';
  if (active.is_wired === true || user.is_wired === true) connectionType = 'wired';
  else if (active.is_wired === false || user.is_wired === false) connectionType = 'wifi';
  else if (active.sw_mac || user.sw_mac) connectionType = 'wired';
  else if (active.ap_mac || user.ap_mac || active.ap_name || user.ap_name) connectionType = 'wifi';

  let uplink = '';
  if (connectionType === 'wired') {
    const swName = chooseName(active.sw_name, user.sw_name, active.switch_name, user.switch_name);
    const swPort = chooseName(active.sw_port, user.sw_port, active.port, user.port);
    if (swName && swPort) uplink = `${swName} / Port ${swPort}`;
    else if (swPort) uplink = `Port ${swPort}`;
    else if (swName) uplink = swName;
  } else if (connectionType === 'wifi') {
    uplink = chooseName(
      active.ap_name,
      user.ap_name,
      active.radio_name,
      user.radio_name,
      active.radio,
      user.radio
    );
  }

  const online = Boolean(activeClient);
  const lastSeenTs = Number(chooseName(active.last_seen, user.last_seen, 0));

  return {
    client_name: clientName,
    dns_name: dnsName,
    ip,
    reserved,
    mac,
    network,
    connection_type: connectionType,
    uplink,
    last_seen: online ? 'Now' : formatLastSeenFromTs(lastSeenTs),
    last_seen_ts: lastSeenTs,
    status: online ? 'online' : 'offline',
    raw_user: user,
    raw_active: active
  };
}

async function fetchClientsFromUnifi() {
  const cookie = await unifiLogin();
  const base = config.unifiBaseUrl.replace(/\/$/, '');

  const userUrl = `${base}/proxy/network/api/s/${config.site}/rest/user`;
  const staUrl = `${base}/proxy/network/api/s/${config.site}/stat/sta`;

  const [userJson, staJson] = await Promise.all([
    fetchJson(userUrl, cookie),
    fetchJson(staUrl, cookie)
  ]);

  const userRows = Array.isArray(userJson.data) ? userJson.data : [];
  const activeRows = Array.isArray(staJson.data) ? staJson.data : [];

  const userMap = new Map();
  const activeMap = new Map();

  for (const row of userRows) {
    const mac = normalizeMac(row.mac);
    if (mac) userMap.set(mac, row);
  }

  for (const row of activeRows) {
    const mac = normalizeMac(row.mac);
    if (mac) activeMap.set(mac, row);
  }

  const allMacs = new Set([...userMap.keys(), ...activeMap.keys()]);
  const merged = [];

  for (const mac of allMacs) {
    merged.push(buildClientFromMerged(userMap.get(mac), activeMap.get(mac)));
  }

  return merged.sort((a, b) => {
    const an = a.client_name ? a.client_name.toLowerCase() : '~~~~';
    const bn = b.client_name ? b.client_name.toLowerCase() : '~~~~';
    if (an < bn) return -1;
    if (an > bn) return 1;
    return ipToNumber(a.ip) - ipToNumber(b.ip);
  });
}

async function refreshClients() {
  if (refreshInProgress) return;
  refreshInProgress = true;

  try {
    const clients = await fetchClientsFromUnifi();
    latestClients = clients;
    lastRefresh = new Date().toISOString();
    lastError = null;
    console.log(`Refreshed ${clients.length} clients at ${lastRefresh}`);
  } catch (err) {
    lastError = err.stack || err.message || String(err);
    console.error('Refresh failed FULL ERROR:\n', lastError);
  } finally {
    refreshInProgress = false;
  }
}

function csvEscape(value) {
  const s = String(value ?? '');
  if (s.includes(',') || s.includes('"') || s.includes('\n')) {
    return `"${s.replace(/"/g, '""')}"`;
  }
  return s;
}

async function unifiApiRequest(method, url, cookie, body = null) {
  const options = {
    method,
    dispatcher: fetchDispatcher,
    headers: {
      Cookie: cookie,
      Accept: 'application/json'
    }
  };

  if (body !== null) {
    options.headers['Content-Type'] = 'application/json';
    options.body = JSON.stringify(body);
  }

  const response = await fetch(url, options);
  const text = await response.text();

  if (!response.ok) {
    throw new Error(`UniFi API request failed: ${response.status} ${text}`);
  }

  if (!text) return {};
  try {
    return JSON.parse(text);
  } catch {
    return { raw: text };
  }
}

app.get('/api/health', (req, res) => {
  res.json({
    ok: true,
    refreshedAt: lastRefresh,
    error: lastError
  });
});

app.get('/api/clients', (req, res) => {
  res.json(latestClients);
});

app.get('/api/debug/raw-clients', (req, res) => {
  const limit = Math.max(1, Math.min(20, Number(req.query.limit || 5)));
  res.json({
    count: latestClients.length,
    sample: latestClients.slice(0, limit)
  });
});

app.get('/api/export/reservations.csv', (req, res) => {
  const headers = ['client_name', 'mac', 'ip', 'reserved', 'network'];
  const lines = [headers.join(',')];

  for (const row of latestClients) {
    lines.push([
      csvEscape(row.client_name),
      csvEscape(row.mac),
      csvEscape(row.ip),
      csvEscape(String(row.reserved)),
      csvEscape(row.network)
    ].join(','));
  }

  res.setHeader('Content-Type', 'text/csv; charset=utf-8');
  res.setHeader('Content-Disposition', 'attachment; filename="unifi-reservations.csv"');
  res.send(lines.join('\n'));
});

app.post('/api/client/:mac/toggle-reservation', async (req, res) => {
  try {
    const mac = normalizeMac(req.params.mac);
    const client = latestClients.find(c => normalizeMac(c.mac) === mac);

    if (!client) {
      return res.status(404).json({ error: 'Client not found' });
    }

    const cookie = await unifiLogin();
    const base = config.unifiBaseUrl.replace(/\/$/, '');
    const url = `${base}/proxy/network/api/s/${config.site}/cmd/stamgr`;

    let body;

    if (client.reserved) {
      body = {
        cmd: 'unset-fixed-ip',
        mac
      };
    } else {
      if (!client.ip) {
        return res.status(400).json({
          error: 'Client has no IP to reserve'
        });
      }

      body = {
        cmd: 'set-fixed-ip',
        mac,
        ip: client.ip
      };
    }

    await unifiApiRequest('POST', url, cookie, body);

    console.log(`Reservation toggled for ${mac}`);

    await refreshClients();

    res.json({
      ok: true,
      mac
    });
  } catch (err) {
    console.error('Toggle reservation failed:\n', err.stack || err.message || String(err));
    res.status(500).json({ error: err.message || String(err) });
  }
});

app.delete('/api/client/:mac', async (req, res) => {
  try {
    const mac = normalizeMac(req.params.mac);
    const cookie = await unifiLogin();
    const base = config.unifiBaseUrl.replace(/\/$/, '');

    const url = `${base}/proxy/network/api/s/${config.site}/cmd/stamgr`;
    await unifiApiRequest('POST', url, cookie, {
      cmd: 'forget-sta',
      mac
    });

    console.log(`Forgot client ${mac}`);

    await refreshClients();

    res.json({
      ok: true,
      mac
    });
  } catch (err) {
    console.error('Delete client failed:\n', err.stack || err.message || String(err));
    res.status(500).json({ error: err.message || String(err) });
  }
});

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'index.html'));
});

refreshClients();
setInterval(refreshClients, POLL_INTERVAL_MS);

app.listen(PORT, () => {
  console.log(`UniFi Client Manager listening on http://0.0.0.0:${PORT}`);
});

Save the file with a CTRL O and enter and a CTRL X to exit the editor.

Edit the HTML code for the web page.

nano index.html

Insert the following into the file.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>UniFi Client Reservation Manager</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    * { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: Arial, Helvetica, sans-serif;
      background: #0f172a;
      color: #e5e7eb;
    }
    .wrap {
      width: 100%;
      max-width: 1800px;
      margin: 0 auto;
      padding: 20px;
    }
    .toolbar, .filters, .meta-bar {
      display: flex;
      gap: 10px;
      flex-wrap: wrap;
      align-items: center;
      margin-bottom: 14px;
    }
    .toolbar {
      justify-content: space-between;
    }
    .toolbar-left h1 {
      margin: 0;
      font-size: 28px;
    }
    .sub {
      color: #94a3b8;
      margin-top: 6px;
    }
    input, select, button {
      background: #111827;
      color: #fff;
      border: 1px solid #374151;
      border-radius: 10px;
      padding: 10px 12px;
      font-size: 14px;
    }
    button {
      cursor: pointer;
    }
    .table-wrap {
      border: 1px solid #374151;
      border-radius: 12px;
      overflow: auto;
      max-height: 75vh;
      background: #111827;
    }
    table {
      width: 100%;
      min-width: 1450px;
      border-collapse: collapse;
      table-layout: fixed;
    }
    th, td {
      padding: 10px;
      border-bottom: 1px solid #1f2937;
      text-align: left;
      vertical-align: top;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    th {
      position: sticky;
      top: 0;
      background: #1f2937;
      cursor: pointer;
      user-select: none;
    }
    .th-inner {
      position: relative;
      padding-right: 10px;
    }
    .resize-handle {
      position: absolute;
      top: -10px;
      right: -6px;
      width: 12px;
      height: 40px;
      cursor: col-resize;
      z-index: 10;
    }
    .mono {
      font-family: monospace;
    }
    .pill {
      display: inline-block;
      border-radius: 999px;
      padding: 3px 8px;
      font-weight: 700;
      font-size: 12px;
    }
    .pill-red { background: #dc2626; color: #fff; }
    .pill-amber { background: #f59e0b; color: #fff; }
    .pill-blue { background: #2563eb; color: #fff; }
    .muted { color: #94a3b8; }
    .status-online { color: #86efac; }
    .status-offline { color: #fca5a5; }
    tr.selected {
      background: #1e293b !important;
      outline: 2px solid #3b82f6;
    }
    .help {
      margin-bottom: 12px;
      color: #94a3b8;
      font-size: 13px;
    }
    .summary-cards {
      display: flex;
      gap: 10px;
      flex-wrap: wrap;
      margin-bottom: 14px;
    }
    .summary-card {
      background: #111827;
      border: 1px solid #374151;
      border-radius: 12px;
      padding: 12px 14px;
      min-width: 170px;
    }
    .summary-card .label {
      color: #94a3b8;
      font-size: 12px;
      margin-bottom: 6px;
    }
    .summary-card .value {
      font-size: 24px;
      font-weight: 700;
    }
    .meta-bar {
      justify-content: space-between;
    }
    .meta-left, .meta-right {
      display: flex;
      gap: 10px;
      flex-wrap: wrap;
      align-items: center;
    }
    .badge {
      background: #111827;
      border: 1px solid #374151;
      border-radius: 10px;
      padding: 10px 12px;
      color: #94a3b8;
      font-size: 14px;
    }
    .badge strong {
      color: #e5e7eb;
    }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="toolbar">
      <div class="toolbar-left">
        <h1>UniFi Client Reservation Manager</h1>
        <div class="sub">ScottiBYTE Enterprise Consulting Services</div>
      </div>
      <div class="toolbar-right">
        <button id="refreshBtn">Refresh</button>
        <button id="exportBtn">Export Reservations CSV</button>
      </div>
    </div>

    <div class="help">
      Keyboard: ↑ ↓ to move through rows. Use filters to identify stale clients and stale reservations.
    </div>

    <div class="summary-cards">
      <div class="summary-card">
        <div class="label">Total Clients</div>
        <div class="value" id="cardTotal">0</div>
      </div>
      <div class="summary-card">
        <div class="label">Online</div>
        <div class="value" id="cardOnline">0</div>
      </div>
      <div class="summary-card">
        <div class="label">Offline</div>
        <div class="value" id="cardOffline">0</div>
      </div>
      <div class="summary-card">
        <div class="label">Reserved but Offline</div>
        <div class="value" id="cardReservedOffline">0</div>
      </div>
      <div class="summary-card">
        <div class="label">Offline > 90 Days</div>
        <div class="value" id="cardOffline90">0</div>
      </div>
    </div>

    <div class="meta-bar">
      <div class="meta-left">
        <div class="badge">Last updated: <strong id="lastUpdated">Never</strong></div>
        <div class="badge">Next auto-refresh: <strong id="nextRefresh">--</strong></div>
        <div class="badge">Auto-refresh: <strong id="autoRefreshState">Running</strong></div>
      </div>
      <div class="meta-right">
        <label for="refreshInterval" class="muted">Refresh every</label>
        <select id="refreshInterval">
          <option value="15000">15 seconds</option>
          <option value="30000">30 seconds</option>
          <option value="60000" selected>60 seconds</option>
          <option value="120000">2 minutes</option>
          <option value="300000">5 minutes</option>
        </select>
      </div>
    </div>

    <div class="filters">
      <input id="search" placeholder="Search by client name, DNS, IP, MAC, network, uplink" style="min-width: 340px;">
      <select id="statusFilter">
        <option value="all">All Statuses</option>
        <option value="online">Online</option>
        <option value="offline">Offline</option>
      </select>
      <select id="reservationFilter">
        <option value="all">All Reservation States</option>
        <option value="reserved">Reserved</option>
        <option value="dynamic">Dynamic</option>
      </select>
      <select id="connectionFilter">
        <option value="all">All Connections</option>
        <option value="wired">Wired</option>
        <option value="wifi">Wi-Fi</option>
        <option value="unknown">Unknown</option>
      </select>
      <select id="networkFilter">
        <option value="all">All Networks</option>
      </select>
      <select id="cleanupFilter">
        <option value="all">All Cleanup Views</option>
        <option value="reserved_offline">Reserved but Offline</option>
        <option value="offline_30">Offline > 30 Days</option>
        <option value="offline_90">Offline > 90 Days</option>
        <option value="offline_365">Offline > 365 Days</option>
      </select>
      <span id="summary" class="muted"></span>
    </div>

    <div class="table-wrap">
      <table id="clientTable">
        <thead>
          <tr>
            <th data-key="client_name" style="width: 260px;"><div class="th-inner">Client Name<div class="resize-handle"></div></div></th>
            <th data-key="dns_name" style="width: 180px;"><div class="th-inner">DNS Name<div class="resize-handle"></div></div></th>
            <th data-key="ip" style="width: 130px;"><div class="th-inner">IP<div class="resize-handle"></div></div></th>
            <th data-key="reserved" style="width: 110px;"><div class="th-inner">Reserved<div class="resize-handle"></div></div></th>
            <th data-key="mac" style="width: 160px;"><div class="th-inner">MAC<div class="resize-handle"></div></div></th>
            <th data-key="network" style="width: 210px;"><div class="th-inner">Network<div class="resize-handle"></div></div></th>
            <th data-key="connection_type" style="width: 110px;"><div class="th-inner">Type<div class="resize-handle"></div></div></th>
            <th data-key="uplink" style="width: 130px;"><div class="th-inner">Uplink<div class="resize-handle"></div></div></th>
            <th data-key="last_seen" style="width: 120px;"><div class="th-inner">Last Seen<div class="resize-handle"></div></div></th>
            <th data-key="status" style="width: 120px;"><div class="th-inner">Status<div class="resize-handle"></div></div></th>
          </tr>
        </thead>
        <tbody id="tbody"></tbody>
      </table>
    </div>
  </div>

  <script>
    let clients = [];
    let sortKey = 'client_name';
    let sortDir = 'asc';
    let selectedIndex = -1;

    let refreshIntervalMs = 60000;
    let autoRefreshTimer = null;
    let countdownTimer = null;
    let nextRefreshAt = null;
    let searchPauseUntil = 0;
    let isLoading = false;
    let lastUpdatedAt = null;

    const tbody = document.getElementById('tbody');
    const search = document.getElementById('search');
    const statusFilter = document.getElementById('statusFilter');
    const reservationFilter = document.getElementById('reservationFilter');
    const connectionFilter = document.getElementById('connectionFilter');
    const networkFilter = document.getElementById('networkFilter');
    const cleanupFilter = document.getElementById('cleanupFilter');
    const summary = document.getElementById('summary');
    const refreshIntervalSelect = document.getElementById('refreshInterval');

    const cardTotal = document.getElementById('cardTotal');
    const cardOnline = document.getElementById('cardOnline');
    const cardOffline = document.getElementById('cardOffline');
    const cardReservedOffline = document.getElementById('cardReservedOffline');
    const cardOffline90 = document.getElementById('cardOffline90');

    const lastUpdatedEl = document.getElementById('lastUpdated');
    const nextRefreshEl = document.getElementById('nextRefresh');
    const autoRefreshStateEl = document.getElementById('autoRefreshState');

    function ipToNumber(ip) {
      const parts = (ip || '0.0.0.0').split('.').map(Number);
      if (parts.length !== 4 || parts.some(n => Number.isNaN(n))) return 0;
      return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
    }

    function daysOffline(row) {
      if (row.status === 'online') return 0;
      const ts = Number(row.last_seen_ts || 0);
      if (!ts) return Number.POSITIVE_INFINITY;
      const now = Math.floor(Date.now() / 1000);
      return Math.floor((now - ts) / 86400);
    }

    function matchesCleanupView(row, view) {
      const days = daysOffline(row);

      switch (view) {
        case 'reserved_offline':
          return row.status === 'offline' && row.reserved;
        case 'offline_30':
          return row.status === 'offline' && days > 30;
        case 'offline_90':
          return row.status === 'offline' && days > 90;
        case 'offline_365':
          return row.status === 'offline' && days > 365;
        default:
          return true;
      }
    }

    function getSortValue(row, key) {
      switch (key) {
        case 'client_name': return row.client_name ? row.client_name.toLowerCase() : '~~~~';
        case 'dns_name': return row.dns_name ? row.dns_name.toLowerCase() : '~~~~';
        case 'ip': return ipToNumber(row.ip);
        case 'reserved': return row.reserved ? 0 : 1;
        case 'mac': return (row.mac || '').toLowerCase();
        case 'network': return (row.network || '').toLowerCase();
        case 'connection_type': return (row.connection_type || '').toLowerCase();
        case 'uplink': return (row.uplink || '').toLowerCase();
        case 'last_seen':
          return row.status === 'online' ? Number.MAX_SAFE_INTEGER : (row.last_seen_ts || 0);
        case 'status':
          return row.status === 'online' ? 0 : 1;
        default:
          return (row[key] || '').toString().toLowerCase();
      }
    }

    function filteredRows() {
      const q = search.value.trim().toLowerCase();

      const rows = clients.filter(c => {
        const matchesSearch =
          !q ||
          (c.client_name || '').toLowerCase().includes(q) ||
          (c.dns_name || '').toLowerCase().includes(q) ||
          (c.ip || '').toLowerCase().includes(q) ||
          (c.mac || '').toLowerCase().includes(q) ||
          (c.network || '').toLowerCase().includes(q) ||
          (c.uplink || '').toLowerCase().includes(q);

        const matchesStatus = statusFilter.value === 'all' || c.status === statusFilter.value;
        const matchesReservation =
          reservationFilter.value === 'all' ||
          (reservationFilter.value === 'reserved' && c.reserved) ||
          (reservationFilter.value === 'dynamic' && !c.reserved);
        const matchesConnection =
          connectionFilter.value === 'all' || c.connection_type === connectionFilter.value;
        const matchesNetwork =
          networkFilter.value === 'all' || c.network === networkFilter.value;
        const matchesCleanup =
          cleanupFilter.value === 'all' || matchesCleanupView(c, cleanupFilter.value);

        return matchesSearch && matchesStatus && matchesReservation && matchesConnection && matchesNetwork && matchesCleanup;
      });

      rows.sort((a, b) => {
        const av = getSortValue(a, sortKey);
        const bv = getSortValue(b, sortKey);
        if (av < bv) return sortDir === 'asc' ? -1 : 1;
        if (av > bv) return sortDir === 'asc' ? 1 : -1;
        return 0;
      });

      return rows;
    }

    function clampSelectedIndex(rows) {
      if (!rows.length) {
        selectedIndex = -1;
        return;
      }
      if (selectedIndex < 0) selectedIndex = 0;
      if (selectedIndex >= rows.length) selectedIndex = rows.length - 1;
    }

    function updateSummaryCards() {
      const total = clients.length;
      const online = clients.filter(c => c.status === 'online').length;
      const offline = clients.filter(c => c.status === 'offline').length;
      const reservedOffline = clients.filter(c => c.status === 'offline' && c.reserved).length;
      const offline90 = clients.filter(c => c.status === 'offline' && daysOffline(c) > 90).length;

      cardTotal.textContent = total;
      cardOnline.textContent = online;
      cardOffline.textContent = offline;
      cardReservedOffline.textContent = reservedOffline;
      cardOffline90.textContent = offline90;
    }

    function render() {
      const rows = filteredRows();
      clampSelectedIndex(rows);

      tbody.innerHTML = rows.map((c, i) => {
        const staleDays = daysOffline(c);
        const showOfflineAge = c.status === 'offline' && Number.isFinite(staleDays) && staleDays > 30;

        return `
          <tr class="${i === selectedIndex ? 'selected' : ''}">
            <td title="${escapeHtml(c.client_name || '')}">
              ${c.client_name ? escapeHtml(c.client_name) : '<span class="pill pill-red">Unnamed Client</span>'}
            </td>
            <td title="${escapeHtml(c.dns_name || '')}">
              ${c.dns_name ? escapeHtml(c.dns_name) : '<span class="pill pill-amber">No DNS</span>'}
            </td>
            <td class="mono" title="${escapeHtml(c.ip || '')}">
              ${escapeHtml(c.ip || '')}
            </td>
            <td>
              ${c.reserved ? '✅' : '❌'}
              ${c.status === 'offline' && c.reserved ? ' <span class="pill pill-red">Stale</span>' : ''}
            </td>
            <td class="mono" title="${escapeHtml(c.mac || '')}">
              ${escapeHtml(c.mac || '')}
            </td>
            <td title="${escapeHtml(c.network || '')}">
              ${escapeHtml(c.network || '')}
            </td>
            <td>
              ${c.connection_type === 'wifi' ? '📶 Wi-Fi' : c.connection_type === 'wired' ? '🔌 Wired' : '❔ Unknown'}
            </td>
            <td title="${escapeHtml(c.uplink || '')}">
              ${escapeHtml(c.uplink || '')}
            </td>
            <td>
              ${escapeHtml(c.last_seen || '')}
              ${showOfflineAge ? ` <span class="pill pill-blue">${staleDays}d</span>` : ''}
            </td>
            <td class="status-${escapeHtml(c.status || 'offline')}">
              ${c.status === 'online' ? '🟢 Online' : '⚫ Offline'}
            </td>
          </tr>
        `;
      }).join('');

      summary.textContent = `Showing ${rows.length} client${rows.length === 1 ? '' : 's'}`;
    }

    function escapeHtml(s) {
      return String(s ?? '')
        .replaceAll('&', '&amp;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll("'", '&#39;');
    }

    function formatDateTime(date) {
      if (!date) return 'Never';
      return date.toLocaleString();
    }

    function formatCountdown(ms) {
      if (ms <= 0) return 'due now';
      const totalSeconds = Math.ceil(ms / 1000);
      const minutes = Math.floor(totalSeconds / 60);
      const seconds = totalSeconds % 60;
      if (minutes > 0) return `${minutes}m ${seconds}s`;
      return `${seconds}s`;
    }

    function isSearchPauseActive() {
      return Date.now() < searchPauseUntil;
    }

    function updateRefreshStatus() {
      lastUpdatedEl.textContent = formatDateTime(lastUpdatedAt);

      if (isSearchPauseActive()) {
        autoRefreshStateEl.textContent = 'Paused while typing';
        nextRefreshEl.textContent = `${formatCountdown(searchPauseUntil - Date.now())} pause`;
        return;
      }

      autoRefreshStateEl.textContent = isLoading ? 'Refreshing now' : 'Running';

      if (!nextRefreshAt) {
        nextRefreshEl.textContent = '--';
        return;
      }

      nextRefreshEl.textContent = formatCountdown(nextRefreshAt - Date.now());
    }

    function scheduleAutoRefresh() {
      if (autoRefreshTimer) clearTimeout(autoRefreshTimer);
      nextRefreshAt = Date.now() + refreshIntervalMs;

      autoRefreshTimer = setTimeout(async () => {
        if (isSearchPauseActive()) {
          scheduleAutoRefresh();
          updateRefreshStatus();
          return;
        }

        await loadClients();
        scheduleAutoRefresh();
        updateRefreshStatus();
      }, refreshIntervalMs);

      updateRefreshStatus();
    }

    function startCountdownTimer() {
      if (countdownTimer) clearInterval(countdownTimer);
      countdownTimer = setInterval(updateRefreshStatus, 1000);
    }

    async function loadClients() {
      if (isLoading) return;
      isLoading = true;
      updateRefreshStatus();

      try {
        const res = await fetch('/api/clients');
        clients = await res.json();

        const currentNetwork = networkFilter.value;
        const networks = [...new Set(clients.map(c => c.network).filter(Boolean))].sort();

        networkFilter.innerHTML = '<option value="all">All Networks</option>' +
          networks.map(n => `<option value="${escapeHtml(n)}">${escapeHtml(n)}</option>`).join('');

        if ([...networkFilter.options].some(o => o.value === currentNetwork)) {
          networkFilter.value = currentNetwork;
        }

        lastUpdatedAt = new Date();
        updateSummaryCards();
        render();
      } finally {
        isLoading = false;
        updateRefreshStatus();
      }
    }

    function scrollToSelected() {
      const row = document.querySelector('tr.selected');
      if (row) row.scrollIntoView({ block: 'nearest' });
    }

    document.querySelectorAll('th[data-key]').forEach(th => {
      th.addEventListener('click', (e) => {
        if (e.target.classList.contains('resize-handle')) return;

        const key = th.dataset.key;
        if (sortKey === key) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
        else {
          sortKey = key;
          sortDir = 'asc';
        }
        render();
      });
    });

    [statusFilter, reservationFilter, connectionFilter, networkFilter, cleanupFilter]
      .forEach(el => el.addEventListener('input', () => {
        selectedIndex = 0;
        render();
      }));

    search.addEventListener('input', () => {
      selectedIndex = 0;
      searchPauseUntil = Date.now() + 10000;
      updateRefreshStatus();
      render();
    });

    refreshIntervalSelect.addEventListener('change', () => {
      refreshIntervalMs = Number(refreshIntervalSelect.value || 60000);
      scheduleAutoRefresh();
    });

    document.getElementById('refreshBtn').addEventListener('click', async () => {
      await loadClients();
      scheduleAutoRefresh();
    });

    document.getElementById('exportBtn').addEventListener('click', () => {
      window.location.href = '/api/export/reservations.csv';
    });

    document.addEventListener('keydown', async (e) => {
      const tag = document.activeElement?.tagName?.toLowerCase();
      if (tag === 'input' || tag === 'select' || tag === 'textarea') return;

      const rows = filteredRows();
      if (!rows.length) return;

      if (e.key === 'ArrowDown') {
        e.preventDefault();
        selectedIndex = Math.min(selectedIndex + 1, rows.length - 1);
        render();
        scrollToSelected();
      }

      if (e.key === 'ArrowUp') {
        e.preventDefault();
        selectedIndex = Math.max(selectedIndex - 1, 0);
        render();
        scrollToSelected();
      }
    });

    document.querySelectorAll('.resize-handle').forEach(handle => {
      let startX = 0;
      let startWidth = 0;
      let th = null;

      handle.addEventListener('mousedown', (e) => {
        e.preventDefault();
        e.stopPropagation();

        th = e.target.closest('th');
        startX = e.pageX;
        startWidth = th.offsetWidth;

        function onMouseMove(ev) {
          const newWidth = Math.max(60, startWidth + (ev.pageX - startX));
          th.style.width = `${newWidth}px`;
        }

        function onMouseUp() {
          document.removeEventListener('mousemove', onMouseMove);
          document.removeEventListener('mouseup', onMouseUp);
        }

        document.addEventListener('mousemove', onMouseMove);
        document.addEventListener('mouseup', onMouseUp);
      });
    });

    loadClients().then(() => {
      scheduleAutoRefresh();
      startCountdownTimer();
    });
  </script>
</body>
</html>

Save the file with a CTRL O and Enter and CTRL X to exit the nano editor.

Initialize the node package manager.

npm init -y

Install the required node packages.

npm install express undici@5

At this point you can test the application with:

node server.js

Now go to your web browser and visit the address of the Incus container in which we built this application at port 3000. So, in my case:

http://172.16.1.112:3000/

If that worked (and it should) do a CTRL C and proceed with the remaining steps.

Create a systemd service to run the utility.

sudo nano /etc/systemd/system/unifi-client-manager.service

Insert the following into the file.

[Unit]
Description=UniFi Client Reservation Manager
After=network.target

[Service]
User=scott
WorkingDirectory=/home/scott/unifi-client-manager
ExecStart=/usr/bin/node /home/scott/unifi-client-manager/server.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target

Replace “scott” in the three locations in the file above with your username and then CTRL O and enter to save the file and CTRL X to exit the editor.

Define and start the service we just created.

sudo systemctl daemon-reload
sudo systemctl enable --now unifi-client-manager
sudo systemctl status unifi-client-manager --no-pager

You should have the following file structure.

You should set a DHCP address reservation in your Unifi Client listing for the application.

You can then use the IP address of your container at port 3000 to access the application.

At this point, you will have a completely operational UniFi Client Reservation Manager”.

Enjoy!