NeoServer ManualCustom Metrics
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.
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
| Item | Rule |
|---|---|
| Command name | neoserver_metrics, on the SSH user’s PATH or defined as a shell function |
| Argument | $1 = the server’s Name in NeoServer |
| Output | One JSON object on stdout, at most 2 MB |
| Exit code | 0 on success; anything else is treated as an error |
| Protocol version | "version": 1 |
| Sizes and traffic | Bytes; rates in bytes per second |
| Percentages | 0 to 100 (18.5 means 18.5%) |
| Fallback | Command missing → built-in collection. Command present but failing → error, no fallback |
| Host shell | POSIX 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 asneo_get_dfandneo_get_tempare 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
- 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=$?"
- Run it as the same user NeoServer logs in with, and check that the exit code is
0and that stdout contains only JSON. - 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
caserules 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.
hostnamein 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,Infinityand negative values are all rejected. The only exception isprotocols.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
uptimeSecondsat 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 becomes0, 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 asgpus,ioor 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.
timestampis 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
| Field | Required | Meaning |
|---|---|---|
version | Yes | Always 1 |
timestamp | Yes | Sample time in Unix seconds, greater than 0 |
hostname | Yes | Display name of the target, not empty |
os | Yes | Display text for the target’s operating system, not empty |
uptimeSeconds | Yes | Target uptime in seconds |
cpu | Yes | CPU usage and core count |
memory | Yes | Physical memory |
disks | Yes | Disk list; use [] when there are none |
network | Yes | Network interfaces; use {"interfaces": []} when there are none |
load | No | one, five, fifteen: 1, 5 and 15-minute load averages |
swap | No | totalBytes, usedBytes; used can’t exceed total |
protocols | No | Cumulative IP, ICMP, TCP and UDP counters |
gpus | No | GPU list |
CPU
| Field | Required | Notes |
|---|---|---|
usagePercent | Yes | Total usage, 0 to 100 |
coreCount | Yes | Integer, 1 to 65536 |
model | No | CPU model text |
temperatureCelsius | No | Hidden when missing, null or 0; NeoServer doesn’t use the gateway’s own sensor instead |
cores | No | One 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
| Field | Required | Notes |
|---|---|---|
totalBytes | Yes | Physical memory |
usedBytes | Yes | Shown as “used” exactly as given; no Linux formula is applied |
bufferBytes | No | Default 0 |
cacheBytes | No | Default 0; used + buffer + cache can’t exceed total |
availableBytes | No | Defaults 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.
nameis 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.mountis the mount point and must not be empty.- Neither
usedBytesnoravailableBytescan exceedtotalBytes. They don’t have to add up to the total, so filesystem reserved space is fine. usedPercentdefaults 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_120GBshow 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:
| Field | Unit |
|---|---|
readBytesPerSecond, writeBytesPerSecond | Bytes per second, decimals allowed |
readBytes, writeBytes | Total bytes since boot (not sectors) |
readIOPS, writeIOPS | Operations per second |
readWaitMilliseconds, writeWaitMilliseconds | Wait time in milliseconds |
Network
interfaces is required. Each interface needs:
name: unique and not emptyreceivedBytes,sentBytes: total bytes since bootreceiveBytesPerSecond,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 matchingout…fields (outMsgsthroughoutAddrMaskReps) - 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 100temperatureCelsiusmemory:totalBytes,usedBytes; memory usage is calculated from these two valuespower:state(text),drawWatts,limitWatts- Descriptive text:
productBrand,productArchitecture,serial,uuid,minorNumber,vbiosVersion,multigpuBoard,boardId,gpuPartNumber,gpuModuleId,performanceState processes: each item needspidandname; it can also havetype,gpuInstanceId,computeInstanceIdandusedMemoryBytes
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:
- 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 is0and that stdout contains only the JSON. - Validate the JSON. Pipe the output through a JSON checker such as
python3 -m json.toolorjq .. Make sure every size is in bytes and every percentage is between 0 and 100. - Read the log. Open Settings → Logs and look for
Custom metrics failed. The message names the field that was rejected (for examplemetrics.disks[0].usedBytes is out of range) or gives the command’s exit status. The log never contains the full metrics data. - Check the gateway name. In gateway mode, the name in the
caserule must match the server’s Name in NeoServer exactly, including case and spaces. - A disk is missing. Check the
disksarray first, then Select Disk, where the disk may be hidden. JSON disks are never filtered automatically. - The overview shows the wrong interface. Check
defaultInterface, and also the server’s Default Network Interface setting. - You want built-in metrics back. Remove
neoserver_metricsfromPATH, or stop defining the shell function. Once the command no longer exists, the next refresh uses built-in collection again.