Skip to content

NeoServer Custom Metrics: the neoserver_metrics JSON Protocol

Feed NeoServer's dashboard from your own script: install a neoserver_metrics command that prints JSON, monitor routers, BusyBox and NAS devices, or serve many machines from one SSH gateway.

11 min read

NeoServer normally builds its dashboard by running standard commands such as df, top and reads from /proc. Some systems don’t have those, or print them in an unusual format: routers, BusyBox devices, NAS appliances, minimal Linux distributions. Custom metrics fixes this. You install one command, neoserver_metrics, that prints a JSON document. NeoServer then shows that data instead of collecting its own.

The same command also lets one SSH gateway report metrics for many machines, including hosts NeoServer cannot reach directly.

Your script collects the numbers and decides how they are measured. NeoServer checks them, shows them, and draws the history charts. The iPhone and iPad app, the Mac app and the Server Status widget all use the same protocol.

Quick reference

ItemRule
Command nameneoserver_metrics, on the SSH user’s PATH or defined as a shell function
Argument$1 = the server’s Name in NeoServer
OutputOne JSON object on stdout, at most 2 MB
Exit code0 on success; anything else is treated as an error
Protocol version"version": 1
Sizes and trafficBytes; rates in bytes per second
Percentages0 to 100 (18.5 means 18.5%)
FallbackCommand missing → built-in collection. Command present but failing → error, no fallback
Host shellPOSIX shell (Linux, macOS, BSD). Native Windows SSH hosts still use built-in collection

How it works

Every time NeoServer refreshes, it checks whether neoserver_metrics exists for the SSH user:

  • The command exists: NeoServer runs only neoserver_metrics '<server name>' and reads its JSON. Built-in commands and the older hooks such as neo_get_df and neo_get_temp are skipped.
  • The command doesn’t exist: NeoServer collects metrics the usual way, so servers without the command are unaffected.

The server’s Startup Script runs first. You can use it to extend PATH or to define neoserver_metrics as a shell function. The startup script must not call exit.

The only argument is the Name you gave the server in NeoServer. It is not the SSH address, the system hostname or an internal ID. Spaces, quotes, non-Latin characters and shell symbols in the name arrive as one argument and are never executed. If the Name field is empty, the argument is an empty string.

Set up a single server

  1. Write a script that prints the JSON described below and install it as an executable:
#!/bin/sh
# /usr/local/bin/neoserver_metrics
# Print the latest sample produced by your collector.
exec cat /var/lib/neoserver-metrics/latest.json
chmod +x /usr/local/bin/neoserver_metrics
neoserver_metrics 'Office Router'; echo "exit=$?"
  1. Run it as the same user NeoServer logs in with, and check that the exit code is 0 and that stdout contains only JSON.
  2. Pull to refresh in NeoServer. The dashboard now shows your data.

Keep the command fast, because the normal SSH timeout still applies. A good pattern is to collect in the background, for example with cron or a loop, and have neoserver_metrics print the cached result. Write each new sample to a temporary file first, then rename it over the old one. This way NeoServer never reads a half-written file.

Write diagnostics to stderr or to your own log file, never to stdout. Some SSH setups merge both streams, and any extra text on stdout breaks the JSON.

Monitor many machines through one gateway

You can let one machine answer for a whole fleet. Add one server in NeoServer for each target. Give every entry the same host, port and credentials, all pointing at the gateway, and a different Name, such as Office Router and Home NAS. Each entry refreshes on its own and keeps its own history and disk selection.

On the gateway, use the name to pick the matching data:

#!/bin/sh
# /usr/local/bin/neoserver_metrics on the gateway
case "$1" in
    'Office Router') exec cat /var/lib/neoserver-metrics/office-router.json ;;
    'Home NAS')      exec cat /var/lib/neoserver-metrics/home-nas.json ;;
    *) printf '%s\n' "Unknown metrics target: $1" >&2; exit 2 ;;
esac

The cache files can come from anywhere: SNMP polling, a monitoring API, or a script on each target that pushes its sample to the gateway. Targets don’t need SSH, and a target can be any system, including Windows.

Keep these rules in mind:

  • Names must be unique among the targets of one gateway. If you rename a server in NeoServer, update the case rules too.
  • Return a non-zero exit code for unknown names. Never fall back to the gateway’s own metrics, or one server would quietly show another machine’s data.
  • hostname in the JSON is only a display value. It is not used for routing.
  • Only dashboard metrics are routed. The terminal, files, containers, process list and vnStat history still connect to the gateway itself.

JSON format

General rules

  • Unknown fields are ignored, so you can add your own extras.
  • Numbers must be JSON numbers. Strings with units, booleans, NaN, Infinity and negative values are all rejected. The only exception is protocols.tcp.maxConn, which may be -1.
  • Most numbers can be at most 9,000,000,000,000,000. Temperatures can be at most 1000 °C, and uptimeSeconds at most 1,000,000,000,000.
  • Strings can be at most 4096 UTF-8 bytes. Arrays can hold at most 256 objects.
  • If a required field is missing or null, the whole sample is rejected. A missing optional number becomes 0, a missing string becomes empty, and a missing array becomes empty.
  • Some screens show a missing optional detail as 0, which looks like a real reading. Leave out objects you can’t measure, such as gpus, io or a temperature, rather than sending zeros.
  • Byte counts, network rates and IOPS are rounded down to whole numbers. CPU percentages and disk read/write rates keep their decimals.
  • timestamp is the Unix time of the sample in seconds; decimals are allowed. Increase it with every new sample. Sending the same cached sample twice is fine, but a sample older than the previous one may be left out of the iOS history chart.

Top level

FieldRequiredMeaning
versionYesAlways 1
timestampYesSample time in Unix seconds, greater than 0
hostnameYesDisplay name of the target, not empty
osYesDisplay text for the target’s operating system, not empty
uptimeSecondsYesTarget uptime in seconds
cpuYesCPU usage and core count
memoryYesPhysical memory
disksYesDisk list; use [] when there are none
networkYesNetwork interfaces; use {"interfaces": []} when there are none
loadNoone, five, fifteen: 1, 5 and 15-minute load averages
swapNototalBytes, usedBytes; used can’t exceed total
protocolsNoCumulative IP, ICMP, TCP and UDP counters
gpusNoGPU list

CPU

FieldRequiredNotes
usagePercentYesTotal usage, 0 to 100
coreCountYesInteger, 1 to 65536
modelNoCPU model text
temperatureCelsiusNoHidden when missing, null or 0; NeoServer doesn’t use the gateway’s own sensor instead
coresNoOne object per core, in order (cpu0, cpu1, …). If present, it must have exactly coreCount items

Each object, the main cpu object and every item in cores, can also carry breakdown percentages: userPercent, systemPercent, nicePercent, idlePercent, iowaitPercent, irqPercent, softirqPercent, stealPercent, guestPercent and guestNicePercent. idlePercent defaults to 100 - usagePercent, and the others default to 0. Each core object needs its own usagePercent.

NeoServer shows these percentages as they are and doesn’t compare them with the previous sample, so the very first refresh already shows correct values.

Memory

FieldRequiredNotes
totalBytesYesPhysical memory
usedBytesYesShown as “used” exactly as given; no Linux formula is applied
bufferBytesNoDefault 0
cacheBytesNoDefault 0; used + buffer + cache can’t exceed total
availableBytesNoDefaults to total − used; can’t exceed total

Free memory is shown as total − used − buffer − cache. Count “used” the way you want it to appear.

Disks

Each disk needs name, mount, totalBytes, usedBytes and availableBytes. Optional fields are type and usedPercent.

  • name is a stable, unique ID such as /dev/sda1. A repeated name rejects the whole sample, so that no disk is counted twice or dropped without notice. If several mount points or links lead to the same partition, list it once.
  • mount is the mount point and must not be empty.
  • Neither usedBytes nor availableBytes can exceed totalBytes. They don’t have to add up to the total, so filesystem reserved space is fine.
  • usedPercent defaults to used ÷ total × 100, or 0 when total is 0. A single disk shows your value. The overall gauge always adds up the selected disks.
  • Disks from JSON are never filtered by type, device or path. Paths like /tmp/mnt/SSD_120GB show up as given. Disks you hide in Select Disk stay hidden, and they are also left out of the capacity and I/O totals.

The optional inodes object holds total and used; NeoServer works out the free count and percentage.

The optional io object turns on I/O details for the disk. NeoServer shows these values as they are:

FieldUnit
readBytesPerSecond, writeBytesPerSecondBytes per second, decimals allowed
readBytes, writeBytesTotal bytes since boot (not sectors)
readIOPS, writeIOPSOperations per second
readWaitMilliseconds, writeWaitMillisecondsWait time in milliseconds

Network

interfaces is required. Each interface needs:

  • name: unique and not empty
  • receivedBytes, sentBytes: total bytes since boot
  • receiveBytesPerSecond, sendBytesPerSecond: the current rate

Optional counters: rxPackets, rxErrors, rxDrops, rxFifo, rxFrame, rxCompressed, rxMulticast, txPackets, txErrors, txDrops, txFifo, txColls, txCarrier, txCompressed.

If the list isn’t empty, defaultInterface must name one of its interfaces. That interface is shown on the overview card. If you picked a Default Network Interface for this server in NeoServer and the JSON includes it, your pick wins.

Protocol counters

protocols can contain ip, icmp, tcp and udp objects. Each holds totals since boot under the field names below. Missing counters show as 0.

  • ip: forwarding, defaultTTL, inReceives, inHdrErrors, inAddrErrors, forwDatagrams, inUnknownProtos, inDiscards, inDelivers, outRequests, outDiscards, outNoRoutes, reasmTimeout, reasmReqds, reasmOKs, reasmFails, fragOKs, fragFails, fragCreates
  • icmp: inMsgs, inErrors, inCsumErrors, inDestUnreachs, inTimeExcds, inParmProbs, inSrcQuenchs, inRedirects, inEchos, inEchoReps, inTimestamps, inTimestampReps, inAddrMasks, inAddrMaskReps, plus the matching out… fields (outMsgs through outAddrMaskReps)
  • tcp: rtoAlgorithm, rtoMin, rtoMax, maxConn, activeOpens, passiveOpens, attemptFails, estabResets, currEstab, inSegs, outSegs, retransSegs, inErrs, outRsts
  • udp: inDatagrams, noPorts, inErrors, outDatagrams, rcvbufErrors, sndbufErrors, inCsumErrors, ignoredMulti

GPUs

Each GPU needs a unique id and a productName. Everything else is optional:

  • usagePercent, encoderPercent, decoderPercent, fanPercent: 0 to 100
  • temperatureCelsius
  • memory: totalBytes, usedBytes; memory usage is calculated from these two values
  • power: state (text), drawWatts, limitWatts
  • Descriptive text: productBrand, productArchitecture, serial, uuid, minorNumber, vbiosVersion, multigpuBoard, boardId, gpuPartNumber, gpuModuleId, performanceState
  • processes: each item needs pid and name; it can also have type, gpuInstanceId, computeInstanceId and usedMemoryBytes

GPUs don’t have to be NVIDIA, and you don’t need any nvidia-smi output.

Complete example

This sample describes a router with an external SSD. The numbers are for illustration only. A real script must send its own measurements and a new timestamp each time.

{
  "version": 1,
  "timestamp": 1792022400,
  "hostname": "router-office",
  "os": "ASUSWRT-Merlin 3004.388.12_2",
  "uptimeSeconds": 86461,
  "cpu": {
    "model": "Router CPU",
    "coreCount": 2,
    "usagePercent": 18.5,
    "userPercent": 12,
    "systemPercent": 6.5,
    "temperatureCelsius": 52,
    "cores": [{"usagePercent": 17}, {"usagePercent": 20}]
  },
  "load": {"one": 0.25, "five": 0.15, "fifteen": 0.1},
  "memory": {
    "totalBytes": 524288000,
    "usedBytes": 209715200,
    "availableBytes": 314572800,
    "bufferBytes": 10485760,
    "cacheBytes": 104857600
  },
  "swap": {"totalBytes": 1073741824, "usedBytes": 1048576},
  "disks": [{
    "name": "/dev/sda1",
    "type": "ext4",
    "mount": "/tmp/mnt/SSD_120GB",
    "totalBytes": 117609832448,
    "usedBytes": 71489859584,
    "availableBytes": 40101621760,
    "usedPercent": 64,
    "inodes": {"total": 100000, "used": 2000},
    "io": {
      "readBytesPerSecond": 1048576,
      "writeBytesPerSecond": 524288,
      "readBytes": 1073741825,
      "writeBytes": 536870913,
      "readIOPS": 50,
      "writeIOPS": 25,
      "readWaitMilliseconds": 2,
      "writeWaitMilliseconds": 3
    }
  }],
  "network": {
    "defaultInterface": "br0",
    "interfaces": [{
      "name": "br0",
      "receivedBytes": 1073741824,
      "sentBytes": 536870912,
      "receiveBytesPerSecond": 102400,
      "sendBytesPerSecond": 51200,
      "rxPackets": 10000,
      "txPackets": 8000,
      "rxErrors": 1,
      "txErrors": 0
    }]
  },
  "protocols": {"tcp": {"currEstab": 8, "inSegs": 1000, "outSegs": 800, "retransSegs": 2}}
}

The smallest valid document needs only the required fields:

{
  "version": 1,
  "timestamp": 1792022400,
  "hostname": "nas",
  "os": "BusyBox Linux",
  "uptimeSeconds": 3600,
  "cpu": {"coreCount": 1, "usagePercent": 5},
  "memory": {"totalBytes": 268435456, "usedBytes": 67108864},
  "disks": [],
  "network": {"interfaces": []}
}

Troubleshooting

When something is wrong, the server shows an error instead of metrics. Work through these checks:

  1. Run the command by hand. Log in as the same SSH user, with the same startup script, and run neoserver_metrics 'Your Server Name'. Check that the exit code is 0 and that stdout contains only the JSON.
  2. Validate the JSON. Pipe the output through a JSON checker such as python3 -m json.tool or jq .. Make sure every size is in bytes and every percentage is between 0 and 100.
  3. Read the log. Open Settings → Logs and look for Custom metrics failed. The message names the field that was rejected (for example metrics.disks[0].usedBytes is out of range) or gives the command’s exit status. The log never contains the full metrics data.
  4. Check the gateway name. In gateway mode, the name in the case rule must match the server’s Name in NeoServer exactly, including case and spaces.
  5. A disk is missing. Check the disks array first, then Select Disk, where the disk may be hidden. JSON disks are never filtered automatically.
  6. The overview shows the wrong interface. Check defaultInterface, and also the server’s Default Network Interface setting.
  7. You want built-in metrics back. Remove neoserver_metrics from PATH, or stop defining the shell function. Once the command no longer exists, the next refresh uses built-in collection again.