feat(device-inventory): add hooks system and DNS updater hook

- Add Hook interface (filter + execute contract) in server/hooks/hook.h
- Add HookRunner in server/hooks/hook_runner.h: spawns a detached thread
  per matching hook, with try/catch protection against crashes
- Add DnsUpdaterHook in server/hooks/dns_updater_hook.{h,cpp}:
  triggers on server name changes, logs in to Technitium, deletes the
  old A record (ignores 404), and adds the new A record
  Config via env vars: TECHNITIUM_HOST/PORT/USER/PASS/ZONE, DNS_TTL
- Add Database::get_nics_for_server() to resolve a server's IPv4 address
- Wire HookRunner into InventoryServer; cmd_edit_server now fires hooks
  with before/after Server snapshots
- Update CMakeLists.txt to include dns_updater_hook.cpp
- Document env vars in Dockerfile

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Dan V 2026-03-24 14:58:36 +01:00
commit e1a482c500
25 changed files with 4597 additions and 0 deletions

View file

@ -0,0 +1,33 @@
cmake_minimum_required(VERSION 3.16)
project(device-inventory VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Compiler warnings
if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
add_compile_options(-Wall -Wextra -Wpedantic)
endif()
# Server executable
add_executable(inventory-server
src/server/main.cpp
src/server/database.cpp
src/server/server.cpp
src/server/hooks/dns_updater_hook.cpp
)
target_include_directories(inventory-server PRIVATE src)
target_link_libraries(inventory-server PRIVATE pthread)
# Client executable
add_executable(inventory-cli
src/client/main.cpp
src/client/client.cpp
src/client/discovery.cpp
)
target_include_directories(inventory-cli PRIVATE src)
install(TARGETS inventory-server inventory-cli
RUNTIME DESTINATION bin
)

View file

@ -0,0 +1,25 @@
FROM ubuntu:24.04 AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
cmake make g++ && \
rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \
cmake --build build --parallel
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
libstdc++6 && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /var/lib/inventory
COPY --from=builder /src/build/inventory-server /usr/local/bin/
EXPOSE 9876
VOLUME ["/var/lib/inventory"]
# DNS updater hook env vars (set at runtime do not bake secrets into the image):
# TECHNITIUM_HOST (default: 192.168.2.193)
# TECHNITIUM_PORT (default: 5380)
# TECHNITIUM_USER (default: admin)
# TECHNITIUM_PASS (required for DNS updates)
# TECHNITIUM_ZONE (default: homelab)
# DNS_TTL (default: 300)
CMD ["inventory-server", "--port", "9876", "--db", "/var/lib/inventory/data.db"]

View file

@ -0,0 +1,15 @@
FROM ubuntu:24.04 AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
cmake make g++ && \
rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \
cmake --build build --parallel
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
dmidecode iproute2 util-linux pciutils libstdc++6 && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /src/build/inventory-cli /usr/local/bin/
ENTRYPOINT ["inventory-cli"]

View file

@ -0,0 +1,138 @@
# device-inventory
A lightweight hardware inventory tracker built in C++17.
Uses a **client/server** model: the server owns the file-backed database and
exposes a TCP interface on localhost; the CLI client connects to it to run
commands.
---
## Architecture
```
┌──────────────┐ TCP (localhost:9876) ┌─────────────────────┐
│ inventory-cli│ ─────────────────────► │ inventory-server │
│ (CLI client)│ ◄───────────────────── │ database ► file.db │
└──────────────┘ └─────────────────────┘
```
* **inventory-server** loads the inventory file on start, accepts one
command per connection, and flushes changes to disk after every mutation.
* **inventory-cli** a thin CLI that translates subcommands into protocol
messages, connects to the server, and pretty-prints the response.
---
## Build
Requires CMake ≥ 3.16 and a C++17-capable compiler.
```bash
cd device-inventory
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
```
Binaries end up in `build/inventory-server` and `build/inventory-cli`.
---
## Running
### Start the server
```bash
./build/inventory-server # uses inventory.db in CWD
./build/inventory-server --port 9876 --db /var/lib/inventory/data.db
```
The server reloads the file every time it restarts. On the very first run the
file is created automatically.
### Use the CLI
```bash
# Shorthand for the examples below
cli="./build/inventory-cli"
```
---
## Commands
### Servers
```bash
# Add a server
$cli add-server "web01" "192.168.1.10" "Rack A" "Primary web server"
# List all servers
$cli list-servers
# Edit a field (fields: name | hostname | location | description)
$cli edit-server 1 hostname "10.0.0.1"
$cli edit-server 1 description "Retired do not use"
```
### Part types
```bash
# Add a part type
$cli add-part-type "RAM" "Memory modules"
$cli add-part-type "SSD" "Solid-state storage"
# List all part types
$cli list-part-types
# Edit a field (fields: name | description)
$cli edit-part-type 1 description "DDR4/DDR5 memory"
```
### Parts
```bash
# Add a part to a server
# add-part <server_id> <part_type_id> <name> <serial> <description>
$cli add-part 1 1 "16 GB DDR4" "SN-MEM-001" "DIMM slot A1"
$cli add-part 1 2 "Samsung 870 EVO 1 TB" "SN-SSD-004" "Primary OS drive"
# List parts of a server
$cli list-parts 1
# Edit a field (fields: name | serial | description | server_id | part_type_id)
$cli edit-part 2 serial "SN-SSD-005"
$cli edit-part 2 server_id 3
```
---
## Database file format
Plain text, one record per line safe to inspect or diff with standard tools.
```
# device-inventory database
META|<next_server_id>|<next_part_type_id>|<next_part_id>
S|<id>|<name>|<hostname>|<location>|<description>
PT|<id>|<name>|<description>
P|<id>|<server_id>|<part_type_id>|<name>|<serial>|<description>
```
Pipe characters and backslashes inside field values are escaped with `\|`
and `\\` respectively.
---
## Wire protocol
Commands are newline-terminated lines; fields are separated by ASCII SOH
(`0x01`). Each connection carries exactly one request/response pair.
```
Client → Server: COMMAND\x01arg1\x01arg2\n
Server → Client: OK\n
field1\x01field2\x01...\n ← 0 or more data rows
END\n
-- or --
ERR <message>\n
```

View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Build all device-inventory images and load them onto the required k8s nodes.
# Run this from any machine with Docker and SSH access to the nodes.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODE2_IP="192.168.2.195" # kube-node-2 (server + web-ui + cli CronJob)
NODE3_IP="192.168.2.196" # kube-node-3 (cli CronJob only)
echo "=== Building inventory-server:latest ==="
docker build -t inventory-server:latest -f "$SCRIPT_DIR/Dockerfile" "$SCRIPT_DIR"
echo "=== Building inventory-cli:latest ==="
docker build -t inventory-cli:latest -f "$SCRIPT_DIR/Dockerfile.cli" "$SCRIPT_DIR"
echo "=== Building inventory-web-ui:latest ==="
docker build -t inventory-web-ui:latest -f "$SCRIPT_DIR/web-ui/Dockerfile" "$SCRIPT_DIR/web-ui"
echo "=== Loading images onto kube-node-2 ($NODE2_IP) ==="
for img in inventory-server:latest inventory-web-ui:latest inventory-cli:latest; do
echo "$img"
docker save "$img" | ssh "dan@$NODE2_IP" "sudo ctr --namespace k8s.io images import -"
done
echo "=== Loading images onto kube-node-3 ($NODE3_IP) ==="
for img in inventory-server:latest inventory-web-ui:latest inventory-cli:latest; do
echo "$img"
docker save "$img" | ssh "dan@$NODE3_IP" "sudo ctr --namespace k8s.io images import -"
done
echo ""
echo "=== All images loaded. Apply the manifest with: ==="
echo " kubectl apply -f /home/dan/homelab/deployment/infrastructure/device-inventory.yaml"

View file

@ -0,0 +1,19 @@
need the following changes:
models: create a template class for parts.
All parts have a partId, nullable serialnumber, last-updated (unix ts in seconds)
Specialized classes:
Memory stick - speed, size, manufacturer, single/dual/quadruple channel, others
Memory slot - allowed speed, allowed size, installed memory stick
cpu - name, manufacturer, speed, cores, threads
cpu slot - form factor, installed cpu
disk - manufacturer, age (since production if possible), generation, model, connection type, connection speed, disk speed, disk size, type (ssd, hdd, virtual, etc), current partitions sizes, partition ids (like sda1 for /dev/sda), and which vms or servers have access to it, by hostname and id.
network card - manufacturer, model, age, connection type, connection speed, mac address, ip address(es), whether it's manual or dhcp
The cli should:
1. be able to extract all this information and submit to server. Must also be able to edit any of the fields. Must be able to add/remove parts from servers.
2. be able to add/edit/remove servers and part types (like "memory stick", "cpu", etc)
3. be able to list all servers, part types, and parts of a server.
4. be able to just discover and update one part at a time, or all at once. Like `./inventory-cli discover --part-type memory` -> reports back to server all data about memory sticks, memory slots, etc.
deployment:
this will be deployed on all servers, and it will need to report back every day.

View file

@ -0,0 +1,218 @@
#include "client/client.h"
#include "common/protocol.h"
#include <arpa/inet.h>
#include <ctime>
#include <cstring>
#include <iostream>
#include <netdb.h>
#include <set>
#include <sstream>
#include <sys/socket.h>
#include <unistd.h>
Client::Client(std::string host, uint16_t port)
: host_(std::move(host)), port_(port) {}
// ── transact ──────────────────────────────────────────────────────────────────
bool Client::transact(const std::string& line, std::string& response) {
struct addrinfo hints{}, *res = nullptr;
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
std::string port_str = std::to_string(port_);
if (::getaddrinfo(host_.c_str(), port_str.c_str(), &hints, &res) != 0) {
std::cerr << "error: cannot resolve " << host_ << "\n";
return false;
}
int fd = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (fd < 0) { ::freeaddrinfo(res); perror("socket"); return false; }
if (::connect(fd, res->ai_addr, res->ai_addrlen) < 0) {
::freeaddrinfo(res); ::close(fd); perror("connect"); return false;
}
::freeaddrinfo(res);
std::string data = line + "\n";
if (::send(fd, data.c_str(), data.size(), 0) < 0) {
perror("send"); ::close(fd); return false;
}
response.clear();
char buf[4096];
while (true) {
ssize_t n = ::recv(fd, buf, sizeof(buf), 0);
if (n <= 0) break;
response.append(buf, static_cast<size_t>(n));
}
::close(fd);
return true;
}
// ── send ──────────────────────────────────────────────────────────────────────
int Client::send(const std::vector<std::string>& tokens) {
if (tokens.empty()) { std::cerr << "error: empty command\n"; return 1; }
std::string line;
for (size_t i = 0; i < tokens.size(); ++i) {
if (i) line += FS;
line += tokens[i];
}
std::string response;
if (!transact(line, response)) {
std::cerr << "error: could not reach server at "
<< host_ << ":" << port_ << "\n";
return 1;
}
std::istringstream ss(response);
std::string status_line;
if (!std::getline(ss, status_line)) {
std::cerr << "error: empty response from server\n";
return 1;
}
if (!status_line.empty() && status_line.back() == '\r')
status_line.pop_back();
const std::string& cmd = tokens[0];
if (status_line == RESP_OK) {
std::string data_line;
int row_count = 0;
static const std::set<std::string> silent_ok = {
CMD_EDIT_SERVER, CMD_REMOVE_SERVER,
CMD_EDIT_PART_TYPE, CMD_REMOVE_PART_TYPE,
CMD_EDIT_PART, CMD_REMOVE_PART
};
while (std::getline(ss, data_line)) {
if (!data_line.empty() && data_line.back() == '\r')
data_line.pop_back();
if (data_line == RESP_END) break;
auto fields = split(data_line, FS);
print_row(cmd, fields);
++row_count;
}
if (silent_ok.count(cmd)) {
std::cout << "Done.\n";
} else if (row_count == 0 &&
(cmd == CMD_LIST_SERVERS ||
cmd == CMD_LIST_PARTS ||
cmd == CMD_LIST_PART_TYPES)) {
std::cout << "(none)\n";
}
return 0;
} else if (status_line.size() >= 3 &&
status_line.substr(0, 3) == RESP_ERR) {
std::string msg = status_line.size() > 4 ? status_line.substr(4) : "";
std::cerr << "server error: " << msg << "\n";
return 1;
}
std::cerr << "error: unexpected response: " << status_line << "\n";
return 1;
}
// ── helpers ───────────────────────────────────────────────────────────────────
namespace {
static const std::set<std::string> kListFields = {
K_IPS, K_PARTITION_IDS, K_PARTITION_SIZES, K_VM_HOSTNAMES, K_VM_SERVER_IDS
};
// Convert unix-seconds string to "YYYY-MM-DD HH:MM UTC"
static std::string fmt_time(const std::string& unix_s) {
if (unix_s.empty() || unix_s == "0") return "unknown";
time_t t = static_cast<time_t>(std::stoll(unix_s));
struct tm* tm = gmtime(&t);
if (!tm) return unix_s;
char buf[32];
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M UTC", tm);
return buf;
}
// Join an LS-encoded field as "a, b, c"
static std::string fmt_list(const std::string& val) {
auto parts = split(val, LS);
std::string out;
for (size_t i = 0; i < parts.size(); ++i) {
if (i) out += ", ";
out += parts[i];
}
return out;
}
} // anonymous namespace
// ── print_row ─────────────────────────────────────────────────────────────────
void print_row(const std::string& cmd, const std::vector<std::string>& f) {
// ── Servers ───────────────────────────────────────────────────────────────
if (cmd == CMD_LIST_SERVERS || cmd == CMD_ADD_SERVER) {
// fields: id | name | hostname | location | description
if (f.size() < 5) {
for (auto& x : f) std::cout << x << " ";
std::cout << "\n";
return;
}
std::cout << "[" << f[0] << "] " << f[1]
<< " (" << f[2] << ")"
<< " loc: " << f[3]
<< "\n " << f[4] << "\n";
return;
}
// ── Part types ────────────────────────────────────────────────────────────
if (cmd == CMD_LIST_PART_TYPES || cmd == CMD_ADD_PART_TYPE) {
// fields: id | name | description
if (f.size() < 3) {
for (auto& x : f) std::cout << x << " ";
std::cout << "\n";
return;
}
std::cout << "[" << f[0] << "] " << f[1]
<< " \u2014 " << f[2] << "\n";
return;
}
// ── Parts ─────────────────────────────────────────────────────────────────
// Row: type | part_id | server_id | serial | last_updated | k1 | v1 | k2 | v2 ...
if (cmd == CMD_LIST_PARTS || cmd == CMD_ADD_PART ||
cmd == CMD_UPSERT_PART || cmd == CMD_GET_PART) {
if (f.size() < 5) {
for (auto& x : f) std::cout << x << " ";
std::cout << "\n";
return;
}
const std::string& part_type = f[0];
const std::string& part_id = f[1];
const std::string& server_id = f[2];
const std::string& serial = f[3];
const std::string& last_updated = f[4];
std::cout << "[" << part_id << "] " << part_type
<< " server:#" << server_id
<< " serial: " << (serial.empty() || serial == "NULL" ? "(none)" : serial)
<< " updated: " << fmt_time(last_updated) << "\n";
for (size_t i = 5; i + 1 < f.size(); i += 2) {
const std::string& key = f[i];
const std::string& val = f[i + 1];
if (kListFields.count(key)) {
std::cout << " " << key << ": " << fmt_list(val) << "\n";
} else {
std::cout << " " << key << ": " << val << "\n";
}
}
return;
}
// ── Fallback ──────────────────────────────────────────────────────────────
for (size_t i = 0; i < f.size(); ++i) {
if (i) std::cout << " ";
std::cout << f[i];
}
std::cout << "\n";
}

View file

@ -0,0 +1,27 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
// ── Client ────────────────────────────────────────────────────────────────────
// Connects to the inventory server, sends one command, pretty-prints the response.
class Client {
public:
Client(std::string host, uint16_t port);
// Join tokens with FS, send to server, parse and print the response.
// Returns 0 on success, 1 on error.
int send(const std::vector<std::string>& tokens);
// Raw round-trip: send line, receive full response string.
// Returns false on network error.
bool transact(const std::string& line, std::string& response);
private:
std::string host_;
uint16_t port_;
};
// Pretty-print one data row from the server, keyed by originating command.
void print_row(const std::string& cmd, const std::vector<std::string>& fields);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
#pragma once
#include <map>
#include <string>
#include <vector>
// ── DiscoveredPart ────────────────────────────────────────────────────────────
// One hardware component detected on the local machine, ready to send to the
// inventory server via CMD_UPSERT_PART.
struct DiscoveredPart {
std::string type_name; // PTYPE_* constant, e.g. "memory_stick"
std::map<std::string, std::string> kv; // field-key → value (list fields use LS)
};
// ── Discovery ─────────────────────────────────────────────────────────────────
// Queries OS tools (dmidecode, lsblk, ip, sysctl, …) to detect hardware.
// All methods are non-throwing: failures produce empty results + stderr warnings.
class Discovery {
public:
// Detect all supported part types.
std::vector<DiscoveredPart> discover_all();
// Detect parts of a single type (pass a PTYPE_* constant).
std::vector<DiscoveredPart> discover(const std::string& type_name);
private:
std::vector<DiscoveredPart> discover_memory_sticks();
std::vector<DiscoveredPart> discover_memory_slots();
std::vector<DiscoveredPart> discover_cpus();
std::vector<DiscoveredPart> discover_cpu_slots();
std::vector<DiscoveredPart> discover_disks();
std::vector<DiscoveredPart> discover_nics();
// Run a shell command, return trimmed stdout (≤64 KB). "" on failure.
static std::string run_cmd(const std::string& cmd);
// Parse dmidecode -t <type_num> output into a vector of field-map blocks.
static std::vector<std::map<std::string, std::string>>
parse_dmi(const std::string& type_num);
};

View file

@ -0,0 +1,286 @@
#include "client/client.h"
#include "client/discovery.h"
#include "common/models.h"
#include "common/protocol.h"
#include <algorithm>
#include <iostream>
#include <map>
#include <set>
#include <string>
#include <vector>
// ── usage ─────────────────────────────────────────────────────────────────────
static void usage() {
std::cout <<
R"(Usage: inventory-cli [--host HOST] [--port PORT] <command> [args...]
Global options:
--host HOST Server hostname (default: 127.0.0.1)
--port PORT Server port (default: 9876)
Server management:
add-server <name> <hostname> <location> <description>
list-servers
edit-server <id> <field> <value>
fields: name | hostname | location | description
remove-server <id>
Part type management:
add-part-type <name> <description>
list-part-types
edit-part-type <id> <field> <value>
fields: name | description
remove-part-type <id>
Part management (manual):
add-part <type> <server_id> [key=value ...]
upsert-part <type> <server_id> [key=value ...]
edit-part <part_id> [key=value ...]
remove-part <part_id>
list-parts <server_id> [--type <type>]
get-part <part_id>
Types: memory_stick | memory_slot | cpu | cpu_slot | disk | nic
Keys: any K_* field from the protocol (e.g. serial, name, manufacturer, ...)
Example: add-part cpu 1 name="Intel Xeon" cores=8 threads=16 speed_ghz=3.2
Discovery:
discover <server_id> [--type <type>] [--dry-run]
Auto-detect hardware on this machine and register with server.
--type Only discover one part type (memory_stick|memory_slot|cpu|cpu_slot|disk|nic)
--dry-run Print discovered parts without sending to server
)";
}
// ── parse key=value args into token pairs ─────────────────────────────────────
// Returns false and prints an error if any arg is malformed.
static bool parse_kv_args(int argc, char* argv[], int start,
std::vector<std::string>& tokens) {
// Use ordered map to preserve insertion order (use vector of pairs)
std::vector<std::pair<std::string,std::string>> kv_seen;
std::map<std::string, size_t> kv_index; // key → index in kv_seen
for (int j = start; j < argc; ++j) {
std::string arg = argv[j];
size_t eq = arg.find('=');
if (eq == std::string::npos) {
std::cerr << "error: expected key=value, got: " << arg << "\n";
return false;
}
std::string k = arg.substr(0, eq);
std::string v = arg.substr(eq + 1);
auto it = kv_index.find(k);
if (it != kv_index.end()) {
// Append to existing value with LS for list fields
auto& existing = kv_seen[it->second].second;
existing += LS;
existing += v;
} else {
kv_index[k] = kv_seen.size();
kv_seen.emplace_back(k, v);
}
}
for (auto& [k, v] : kv_seen) {
tokens.push_back(k);
tokens.push_back(v);
}
return true;
}
// ── discover subcommand ───────────────────────────────────────────────────────
static int cmd_discover(Client& client, const std::string& server_id,
const std::string& filter_type, bool dry_run) {
Discovery disc;
std::vector<DiscoveredPart> parts;
if (filter_type.empty()) {
parts = disc.discover_all();
} else {
parts = disc.discover(filter_type);
}
int submitted = 0;
for (auto& p : parts) {
if (dry_run) {
std::cout << "[dry-run] type: " << p.type_name;
auto sit = p.kv.find(K_SERIAL);
if (sit != p.kv.end() && !sit->second.empty() && sit->second != "NULL")
std::cout << " serial: " << sit->second;
std::cout << "\n";
for (auto& [k, v] : p.kv) {
std::cout << " " << k << ": " << v << "\n";
}
} else {
std::vector<std::string> tokens;
tokens.push_back(CMD_UPSERT_PART);
tokens.push_back(p.type_name);
tokens.push_back(server_id);
for (auto& [k, v] : p.kv) {
tokens.push_back(k);
tokens.push_back(v);
}
int rc = client.send(tokens);
if (rc == 0) ++submitted;
}
}
std::cout << "Discovered " << parts.size() << " parts";
if (!dry_run)
std::cout << ", submitted " << submitted << " to server";
std::cout << "\n";
return 0;
}
// ── main ──────────────────────────────────────────────────────────────────────
int main(int argc, char* argv[]) {
std::string host = "127.0.0.1";
uint16_t port = DEFAULT_PORT;
int i = 1;
for (; i < argc; ++i) {
std::string arg = argv[i];
if ((arg == "--host" || arg == "-H") && i + 1 < argc) {
host = argv[++i];
} else if ((arg == "--port" || arg == "-p") && i + 1 < argc) {
port = static_cast<uint16_t>(std::stoul(argv[++i]));
} else if (arg == "--help" || arg == "-h") {
usage(); return 0;
} else {
break;
}
}
if (i >= argc) { usage(); return 1; }
std::string sub = argv[i++];
// ── Server management commands ────────────────────────────────────────────
if (sub == "add-server") {
if (i + 3 >= argc) {
std::cerr << "usage: add-server <name> <hostname> <location> <description>\n";
return 1;
}
std::vector<std::string> tokens = {
CMD_ADD_SERVER, argv[i], argv[i+1], argv[i+2], argv[i+3]
};
return Client(host, port).send(tokens);
}
if (sub == "list-servers") {
return Client(host, port).send({CMD_LIST_SERVERS});
}
if (sub == "edit-server") {
if (i + 2 >= argc) {
std::cerr << "usage: edit-server <id> <field> <value>\n"; return 1;
}
return Client(host, port).send({CMD_EDIT_SERVER, argv[i], argv[i+1], argv[i+2]});
}
if (sub == "remove-server") {
if (i >= argc) { std::cerr << "usage: remove-server <id>\n"; return 1; }
return Client(host, port).send({CMD_REMOVE_SERVER, argv[i]});
}
// ── Part type commands ────────────────────────────────────────────────────
if (sub == "add-part-type") {
if (i + 1 >= argc) {
std::cerr << "usage: add-part-type <name> <description>\n"; return 1;
}
return Client(host, port).send({CMD_ADD_PART_TYPE, argv[i], argv[i+1]});
}
if (sub == "list-part-types") {
return Client(host, port).send({CMD_LIST_PART_TYPES});
}
if (sub == "edit-part-type") {
if (i + 2 >= argc) {
std::cerr << "usage: edit-part-type <id> <field> <value>\n"; return 1;
}
return Client(host, port).send({CMD_EDIT_PART_TYPE, argv[i], argv[i+1], argv[i+2]});
}
if (sub == "remove-part-type") {
if (i >= argc) { std::cerr << "usage: remove-part-type <id>\n"; return 1; }
return Client(host, port).send({CMD_REMOVE_PART_TYPE, argv[i]});
}
// ── Part commands ─────────────────────────────────────────────────────────
if (sub == "add-part" || sub == "upsert-part") {
const std::string proto_cmd = (sub == "add-part") ? CMD_ADD_PART : CMD_UPSERT_PART;
if (i + 1 >= argc) {
std::cerr << "usage: " << sub << " <type> <server_id> [key=value ...]\n";
return 1;
}
std::vector<std::string> tokens = {proto_cmd, argv[i], argv[i+1]};
if (!parse_kv_args(argc, argv, i + 2, tokens)) return 1;
return Client(host, port).send(tokens);
}
if (sub == "edit-part") {
if (i >= argc) {
std::cerr << "usage: edit-part <part_id> [key=value ...]\n"; return 1;
}
std::vector<std::string> tokens = {CMD_EDIT_PART, argv[i]};
if (!parse_kv_args(argc, argv, i + 1, tokens)) return 1;
return Client(host, port).send(tokens);
}
if (sub == "remove-part") {
if (i >= argc) { std::cerr << "usage: remove-part <part_id>\n"; return 1; }
return Client(host, port).send({CMD_REMOVE_PART, argv[i]});
}
if (sub == "list-parts") {
if (i >= argc) { std::cerr << "usage: list-parts <server_id> [--type <type>]\n"; return 1; }
std::string server_id = argv[i++];
std::string type_filter;
while (i < argc) {
std::string a = argv[i++];
if (a == "--type" && i < argc) type_filter = argv[i++];
}
std::vector<std::string> tokens = {CMD_LIST_PARTS, server_id};
if (!type_filter.empty()) tokens.push_back(type_filter);
return Client(host, port).send(tokens);
}
if (sub == "get-part") {
if (i >= argc) { std::cerr << "usage: get-part <part_id>\n"; return 1; }
return Client(host, port).send({CMD_GET_PART, argv[i]});
}
// ── Discovery ─────────────────────────────────────────────────────────────
if (sub == "discover") {
if (i >= argc) {
std::cerr << "usage: discover <server_id> [--type <type>] [--dry-run]\n";
return 1;
}
std::string server_id = argv[i++];
std::string filter_type;
bool dry_run = false;
while (i < argc) {
std::string a = argv[i++];
if (a == "--type" && i < argc) {
filter_type = argv[i++];
} else if (a == "--dry-run") {
dry_run = true;
} else {
std::cerr << "error: unknown discover option: " << a << "\n";
return 1;
}
}
Client client(host, port);
return cmd_discover(client, server_id, filter_type, dry_run);
}
std::cerr << "error: unknown command '" << sub << "'\n\n";
usage();
return 1;
}

View file

@ -0,0 +1,131 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
// ─────────────────────────────────────────────────────────────────────────────
// Server
// ─────────────────────────────────────────────────────────────────────────────
struct Server {
uint32_t id = 0;
std::string name;
std::string hostname;
std::string location;
std::string description;
};
// ─────────────────────────────────────────────────────────────────────────────
// PartType user-defined label / category, e.g. "memory_stick", "cpu", …
// ─────────────────────────────────────────────────────────────────────────────
struct PartType {
uint32_t id = 0;
std::string name; // slug used in protocol, e.g. "memory_stick"
std::string description;
};
// ─────────────────────────────────────────────────────────────────────────────
// PartBase common fields for every specialized part
// ─────────────────────────────────────────────────────────────────────────────
struct PartBase {
uint32_t part_id = 0;
uint32_t server_id = 0;
std::optional<std::string> serial_number; // nullable
int64_t last_updated = 0; // unix seconds
};
// ─────────────────────────────────────────────────────────────────────────────
// Specialized part structs
// ─────────────────────────────────────────────────────────────────────────────
// channel_config: "single" | "dual" | "quad"
struct MemoryStick : PartBase {
uint32_t speed_mhz = 0;
uint64_t size_mb = 0;
std::string manufacturer;
std::string channel_config;
std::string other_info;
};
struct MemorySlot : PartBase {
uint32_t allowed_speed_mhz = 0;
uint64_t allowed_size_mb = 0;
std::optional<uint32_t> installed_stick_id; // ref to MemoryStick.part_id
};
struct CPU : PartBase {
std::string name;
std::string manufacturer;
double speed_ghz = 0.0;
uint32_t cores = 0;
uint32_t threads = 0;
};
struct CPUSlot : PartBase {
std::string form_factor; // "LGA1700", "AM5", …
std::optional<uint32_t> installed_cpu_id; // ref to CPU.part_id
};
// connection_type: "SATA" | "NVMe" | "SAS" | "USB" | "virtual" | …
// disk_type: "ssd" | "hdd" | "nvme" | "virtual" | …
struct Disk : PartBase {
std::string manufacturer;
std::string model;
std::string generation; // e.g. "PCIe 4.0"
std::string connection_type;
uint64_t connection_speed_mbps = 0;
uint64_t disk_speed_mbps = 0;
uint64_t disk_size_gb = 0;
std::string disk_type;
int32_t age_years = -1; // -1 = unknown
std::vector<std::string> partition_ids; // ["sda1","sda2",…]
std::vector<uint64_t> partition_sizes_gb;
std::vector<std::string> vm_hostnames; // hosts/VMs with disk access
std::vector<uint32_t> vm_server_ids; // inventory server ids
};
// connection_type: "ethernet" | "wifi" | "infiniband" | …
struct NetworkCard : PartBase {
std::string manufacturer;
std::string model;
int32_t age_years = -1; // -1 = unknown
std::string connection_type;
uint64_t connection_speed_mbps = 0;
std::string mac_address;
std::vector<std::string> ip_addresses; // ["192.168.1.2/24",…]
bool dhcp = false;
};
// ─────────────────────────────────────────────────────────────────────────────
// In-memory inventory
// ─────────────────────────────────────────────────────────────────────────────
struct Inventory {
std::vector<Server> servers;
std::vector<PartType> part_types;
std::vector<MemoryStick> memory_sticks;
std::vector<MemorySlot> memory_slots;
std::vector<CPU> cpus;
std::vector<CPUSlot> cpu_slots;
std::vector<Disk> disks;
std::vector<NetworkCard> network_cards;
uint32_t next_server_id = 1;
uint32_t next_part_type_id = 1;
uint32_t next_part_id = 1; // shared counter across all part types
};
// ─────────────────────────────────────────────────────────────────────────────
// Part-type name constants (used in protocol and CLI)
// ─────────────────────────────────────────────────────────────────────────────
constexpr const char* PTYPE_MEMORY_STICK = "memory_stick";
constexpr const char* PTYPE_MEMORY_SLOT = "memory_slot";
constexpr const char* PTYPE_CPU = "cpu";
constexpr const char* PTYPE_CPU_SLOT = "cpu_slot";
constexpr const char* PTYPE_DISK = "disk";
constexpr const char* PTYPE_NIC = "nic";

View file

@ -0,0 +1,166 @@
#pragma once
#include <sstream>
#include <string>
#include <vector>
// ─────────────────────────────────────────────────────────────────────────────
// Network
// ─────────────────────────────────────────────────────────────────────────────
constexpr uint16_t DEFAULT_PORT = 9876;
// ─────────────────────────────────────────────────────────────────────────────
// Wire-format separators
// ─────────────────────────────────────────────────────────────────────────────
// FS (field separator) SOH 0x01 splits top-level tokens in a command line
constexpr char FS = '\x01';
// LS (list separator) used to encode lists inside a single field value
// e.g. ip_addresses field: "192.168.1.2/24\x02fe80::1/64"
constexpr char LS = '\x02';
// ─────────────────────────────────────────────────────────────────────────────
// Wire format
//
// Client → Server (one line per request):
// COMMAND<FS>arg1<FS>arg2…<newline>
//
// Server → Client:
// OK<newline>
// <data-row-line><newline> ← 0 or more rows
// END<newline>
// or
// ERR <message><newline>
//
// Key-value part commands:
// ADD_PART <type><FS>server_id<FS><int><FS>key1<FS>val1<FS>key2<FS>val2…
// UPSERT_PART <type><FS>server_id<FS><int><FS>key1<FS>val1…
// (insert or update matching an existing part by serial/mac)
// EDIT_PART <part_id><FS>key1<FS>val1<FS>key2<FS>val2…
// REMOVE_PART <part_id>
// LIST_PARTS <server_id> [optional: <FS><type>]
// GET_PART <part_id>
//
// List field values are encoded as val1<LS>val2<LS>val3
// (empty string encodes an empty list)
//
// Null / absent optional fields are sent as the literal string "NULL"
// ─────────────────────────────────────────────────────────────────────────────
// Server management
constexpr const char* CMD_ADD_SERVER = "ADD_SERVER";
constexpr const char* CMD_LIST_SERVERS = "LIST_SERVERS";
constexpr const char* CMD_EDIT_SERVER = "EDIT_SERVER";
constexpr const char* CMD_REMOVE_SERVER = "REMOVE_SERVER";
// Part type (label) management
constexpr const char* CMD_ADD_PART_TYPE = "ADD_PART_TYPE";
constexpr const char* CMD_LIST_PART_TYPES = "LIST_PART_TYPES";
constexpr const char* CMD_EDIT_PART_TYPE = "EDIT_PART_TYPE";
constexpr const char* CMD_REMOVE_PART_TYPE = "REMOVE_PART_TYPE";
// Typed part management
constexpr const char* CMD_ADD_PART = "ADD_PART";
constexpr const char* CMD_UPSERT_PART = "UPSERT_PART"; // for discovery
constexpr const char* CMD_EDIT_PART = "EDIT_PART";
constexpr const char* CMD_REMOVE_PART = "REMOVE_PART";
constexpr const char* CMD_LIST_PARTS = "LIST_PARTS";
constexpr const char* CMD_GET_PART = "GET_PART";
// Response sentinels
constexpr const char* RESP_OK = "OK";
constexpr const char* RESP_ERR = "ERR";
constexpr const char* RESP_END = "END";
// ─────────────────────────────────────────────────────────────────────────────
// Field keys for each specialized part type
// (shared between client serializer and server deserializer)
// ─────────────────────────────────────────────────────────────────────────────
// PartBase common keys
constexpr const char* K_SERIAL = "serial";
constexpr const char* K_LAST_UPDATED = "last_updated";
// MemoryStick
constexpr const char* K_SPEED_MHZ = "speed_mhz";
constexpr const char* K_SIZE_MB = "size_mb";
constexpr const char* K_MANUFACTURER = "manufacturer";
constexpr const char* K_CHANNEL_CONFIG = "channel_config";
constexpr const char* K_OTHER_INFO = "other_info";
// MemorySlot
constexpr const char* K_ALLOWED_SPEED = "allowed_speed_mhz";
constexpr const char* K_ALLOWED_SIZE = "allowed_size_mb";
constexpr const char* K_INSTALLED_STICK = "installed_stick_id";
// CPU
constexpr const char* K_NAME = "name";
constexpr const char* K_SPEED_GHZ = "speed_ghz";
constexpr const char* K_CORES = "cores";
constexpr const char* K_THREADS = "threads";
// CPUSlot
constexpr const char* K_FORM_FACTOR = "form_factor";
constexpr const char* K_INSTALLED_CPU = "installed_cpu_id";
// Disk
constexpr const char* K_MODEL = "model";
constexpr const char* K_GENERATION = "generation";
constexpr const char* K_CONN_TYPE = "connection_type";
constexpr const char* K_CONN_SPEED = "connection_speed_mbps";
constexpr const char* K_DISK_SPEED = "disk_speed_mbps";
constexpr const char* K_DISK_SIZE = "disk_size_gb";
constexpr const char* K_DISK_TYPE = "disk_type";
constexpr const char* K_AGE_YEARS = "age_years";
constexpr const char* K_PARTITION_IDS = "partition_ids"; // LS-separated
constexpr const char* K_PARTITION_SIZES = "partition_sizes_gb"; // LS-separated
constexpr const char* K_VM_HOSTNAMES = "vm_hostnames"; // LS-separated
constexpr const char* K_VM_SERVER_IDS = "vm_server_ids"; // LS-separated
// NetworkCard
constexpr const char* K_AGE_YEARS_NIC = "age_years"; // same key, different struct
constexpr const char* K_SPEED_MBPS = "connection_speed_mbps";
constexpr const char* K_MAC = "mac_address";
constexpr const char* K_IPS = "ip_addresses"; // LS-separated
constexpr const char* K_DHCP = "dhcp";
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
inline std::vector<std::string> split(const std::string& s, char delim) {
std::vector<std::string> parts;
std::stringstream ss(s);
std::string token;
while (std::getline(ss, token, delim))
parts.push_back(token);
return parts;
}
inline std::string join(const std::vector<std::string>& v, char delim) {
std::string out;
for (size_t i = 0; i < v.size(); ++i) {
if (i) out += delim;
out += v[i];
}
return out;
}
inline std::string join_u64(const std::vector<uint64_t>& v, char delim) {
std::string out;
for (size_t i = 0; i < v.size(); ++i) {
if (i) out += delim;
out += std::to_string(v[i]);
}
return out;
}
inline std::string join_u32(const std::vector<uint32_t>& v, char delim) {
std::string out;
for (size_t i = 0; i < v.size(); ++i) {
if (i) out += delim;
out += std::to_string(v[i]);
}
return out;
}

View file

@ -0,0 +1,915 @@
#include "server/database.h"
#include "common/protocol.h"
#include <algorithm>
#include <ctime>
#include <fstream>
#include <iostream>
#include <sstream>
// ─────────────────────────────────────────────────────────────────────────────
// Escape / unescape: protect '|', '\n', '\' in pipe-delimited fields
// ─────────────────────────────────────────────────────────────────────────────
std::string Database::escape(const std::string& s) {
std::string out;
out.reserve(s.size() + 4);
for (char c : s) {
if (c == '\\') out += "\\\\";
else if (c == '|') out += "\\|";
else if (c == '\n') out += "\\n";
else out += c;
}
return out;
}
std::string Database::unescape(const std::string& s) {
std::string out;
out.reserve(s.size());
for (size_t i = 0; i < s.size(); ++i) {
if (s[i] == '\\' && i + 1 < s.size()) {
++i;
if (s[i] == '\\') out += '\\';
else if (s[i] == '|') out += '|';
else if (s[i] == 'n') out += '\n';
else { out += '\\'; out += s[i]; }
} else {
out += s[i];
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// split_pipe: split on '|' respecting backslash escapes
// ─────────────────────────────────────────────────────────────────────────────
static std::vector<std::string> split_pipe(const std::string& line) {
std::vector<std::string> fields;
std::string cur;
for (size_t i = 0; i < line.size(); ++i) {
if (line[i] == '\\' && i + 1 < line.size()) {
cur += line[i];
cur += line[++i];
} else if (line[i] == '|') {
fields.push_back(cur);
cur.clear();
} else {
cur += line[i];
}
}
fields.push_back(cur);
return fields;
}
// ─────────────────────────────────────────────────────────────────────────────
// List helpers (LS separator)
// ─────────────────────────────────────────────────────────────────────────────
static std::string ls_join_u64(const std::vector<uint64_t>& v) {
return join_u64(v, LS);
}
static std::string ls_join_u32(const std::vector<uint32_t>& v) {
return join_u32(v, LS);
}
static std::vector<std::string> ls_split(const std::string& s) {
if (s.empty()) return {};
return split(s, LS);
}
static std::vector<uint64_t> ls_split_u64(const std::string& s) {
std::vector<uint64_t> out;
for (const auto& p : split(s, LS))
if (!p.empty()) out.push_back(std::stoull(p));
return out;
}
static std::vector<uint32_t> ls_split_u32(const std::string& s) {
std::vector<uint32_t> out;
for (const auto& p : split(s, LS))
if (!p.empty()) out.push_back(static_cast<uint32_t>(std::stoul(p)));
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// PartBase helpers
// ─────────────────────────────────────────────────────────────────────────────
static std::string base_serial(const PartBase& p) {
return p.serial_number.has_value() ? p.serial_number.value() : "NULL";
}
static void apply_base(PartBase& p, const std::map<std::string,std::string>& kv) {
if (auto it = kv.find(K_SERIAL); it != kv.end()) {
if (it->second == "NULL") p.serial_number = std::nullopt;
else p.serial_number = it->second;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Per-type apply helpers (static free functions)
// ─────────────────────────────────────────────────────────────────────────────
static void apply_memory_stick(MemoryStick& m, const std::map<std::string,std::string>& kv) {
apply_base(m, kv);
if (auto it = kv.find(K_SPEED_MHZ); it != kv.end()) m.speed_mhz = static_cast<uint32_t>(std::stoul(it->second));
if (auto it = kv.find(K_SIZE_MB); it != kv.end()) m.size_mb = std::stoull(it->second);
if (auto it = kv.find(K_MANUFACTURER); it != kv.end()) m.manufacturer = it->second;
if (auto it = kv.find(K_CHANNEL_CONFIG); it != kv.end()) m.channel_config = it->second;
if (auto it = kv.find(K_OTHER_INFO); it != kv.end()) m.other_info = it->second;
}
static void apply_memory_slot(MemorySlot& m, const std::map<std::string,std::string>& kv) {
apply_base(m, kv);
if (auto it = kv.find(K_ALLOWED_SPEED); it != kv.end()) m.allowed_speed_mhz = static_cast<uint32_t>(std::stoul(it->second));
if (auto it = kv.find(K_ALLOWED_SIZE); it != kv.end()) m.allowed_size_mb = std::stoull(it->second);
if (auto it = kv.find(K_INSTALLED_STICK); it != kv.end()) {
if (it->second == "NULL") m.installed_stick_id = std::nullopt;
else m.installed_stick_id = static_cast<uint32_t>(std::stoul(it->second));
}
}
static void apply_cpu(CPU& c, const std::map<std::string,std::string>& kv) {
apply_base(c, kv);
if (auto it = kv.find(K_NAME); it != kv.end()) c.name = it->second;
if (auto it = kv.find(K_MANUFACTURER); it != kv.end()) c.manufacturer = it->second;
if (auto it = kv.find(K_SPEED_GHZ); it != kv.end()) c.speed_ghz = std::stod(it->second);
if (auto it = kv.find(K_CORES); it != kv.end()) c.cores = static_cast<uint32_t>(std::stoul(it->second));
if (auto it = kv.find(K_THREADS); it != kv.end()) c.threads = static_cast<uint32_t>(std::stoul(it->second));
}
static void apply_cpu_slot(CPUSlot& c, const std::map<std::string,std::string>& kv) {
apply_base(c, kv);
if (auto it = kv.find(K_FORM_FACTOR); it != kv.end()) c.form_factor = it->second;
if (auto it = kv.find(K_INSTALLED_CPU); it != kv.end()) {
if (it->second == "NULL") c.installed_cpu_id = std::nullopt;
else c.installed_cpu_id = static_cast<uint32_t>(std::stoul(it->second));
}
}
static void apply_disk(Disk& d, const std::map<std::string,std::string>& kv) {
apply_base(d, kv);
if (auto it = kv.find(K_MANUFACTURER); it != kv.end()) d.manufacturer = it->second;
if (auto it = kv.find(K_MODEL); it != kv.end()) d.model = it->second;
if (auto it = kv.find(K_GENERATION); it != kv.end()) d.generation = it->second;
if (auto it = kv.find(K_CONN_TYPE); it != kv.end()) d.connection_type = it->second;
if (auto it = kv.find(K_CONN_SPEED); it != kv.end()) d.connection_speed_mbps = std::stoull(it->second);
if (auto it = kv.find(K_DISK_SPEED); it != kv.end()) d.disk_speed_mbps = std::stoull(it->second);
if (auto it = kv.find(K_DISK_SIZE); it != kv.end()) d.disk_size_gb = std::stoull(it->second);
if (auto it = kv.find(K_DISK_TYPE); it != kv.end()) d.disk_type = it->second;
if (auto it = kv.find(K_AGE_YEARS); it != kv.end()) d.age_years = std::stoi(it->second);
if (auto it = kv.find(K_PARTITION_IDS); it != kv.end()) d.partition_ids = ls_split(it->second);
if (auto it = kv.find(K_PARTITION_SIZES); it != kv.end()) d.partition_sizes_gb = ls_split_u64(it->second);
if (auto it = kv.find(K_VM_HOSTNAMES); it != kv.end()) d.vm_hostnames = ls_split(it->second);
if (auto it = kv.find(K_VM_SERVER_IDS); it != kv.end()) d.vm_server_ids = ls_split_u32(it->second);
}
static void apply_nic(NetworkCard& n, const std::map<std::string,std::string>& kv) {
apply_base(n, kv);
if (auto it = kv.find(K_MANUFACTURER); it != kv.end()) n.manufacturer = it->second;
if (auto it = kv.find(K_MODEL); it != kv.end()) n.model = it->second;
if (auto it = kv.find(K_AGE_YEARS); it != kv.end()) n.age_years = std::stoi(it->second);
if (auto it = kv.find(K_CONN_TYPE); it != kv.end()) n.connection_type = it->second;
if (auto it = kv.find(K_CONN_SPEED); it != kv.end()) n.connection_speed_mbps = std::stoull(it->second);
if (auto it = kv.find(K_MAC); it != kv.end()) n.mac_address = it->second;
if (auto it = kv.find(K_IPS); it != kv.end()) n.ip_addresses = ls_split(it->second);
if (auto it = kv.find(K_DHCP); it != kv.end()) n.dhcp = (it->second == "1" || it->second == "true");
}
// ─────────────────────────────────────────────────────────────────────────────
// Wire-row serializers
// type_name<FS>part_id<FS>server_id<FS>serial_or_NULL<FS>last_updated<FS>k1<FS>v1...
// ─────────────────────────────────────────────────────────────────────────────
static std::string base_prefix(const char* type_name, const PartBase& p) {
return std::string(type_name)
+ FS + std::to_string(p.part_id)
+ FS + std::to_string(p.server_id)
+ FS + base_serial(p)
+ FS + std::to_string(p.last_updated);
}
std::string Database::serialize_memory_stick(const MemoryStick& m) const {
return base_prefix(PTYPE_MEMORY_STICK, m)
+ FS + K_SPEED_MHZ + FS + std::to_string(m.speed_mhz)
+ FS + K_SIZE_MB + FS + std::to_string(m.size_mb)
+ FS + K_MANUFACTURER + FS + m.manufacturer
+ FS + K_CHANNEL_CONFIG + FS + m.channel_config
+ FS + K_OTHER_INFO + FS + m.other_info
+ "\n";
}
std::string Database::serialize_memory_slot(const MemorySlot& m) const {
std::string installed = m.installed_stick_id.has_value()
? std::to_string(m.installed_stick_id.value()) : "NULL";
return base_prefix(PTYPE_MEMORY_SLOT, m)
+ FS + K_ALLOWED_SPEED + FS + std::to_string(m.allowed_speed_mhz)
+ FS + K_ALLOWED_SIZE + FS + std::to_string(m.allowed_size_mb)
+ FS + K_INSTALLED_STICK + FS + installed
+ "\n";
}
std::string Database::serialize_cpu(const CPU& c) const {
return base_prefix(PTYPE_CPU, c)
+ FS + K_NAME + FS + c.name
+ FS + K_MANUFACTURER + FS + c.manufacturer
+ FS + K_SPEED_GHZ + FS + std::to_string(c.speed_ghz)
+ FS + K_CORES + FS + std::to_string(c.cores)
+ FS + K_THREADS + FS + std::to_string(c.threads)
+ "\n";
}
std::string Database::serialize_cpu_slot(const CPUSlot& c) const {
std::string installed = c.installed_cpu_id.has_value()
? std::to_string(c.installed_cpu_id.value()) : "NULL";
return base_prefix(PTYPE_CPU_SLOT, c)
+ FS + K_FORM_FACTOR + FS + c.form_factor
+ FS + K_INSTALLED_CPU + FS + installed
+ "\n";
}
std::string Database::serialize_disk(const Disk& d) const {
// Build per-list string: escape each element, join with LS
auto esc_join = [](const std::vector<std::string>& v) -> std::string {
std::string out;
for (size_t i = 0; i < v.size(); ++i) {
if (i) out += LS;
// escape element (pipe/backslash could appear in partition ids or hostnames)
for (char c : v[i]) {
if (c == '\\') out += "\\\\";
else if (c == '|') out += "\\|";
else out += c;
}
}
return out;
};
return base_prefix(PTYPE_DISK, d)
+ FS + K_MANUFACTURER + FS + d.manufacturer
+ FS + K_MODEL + FS + d.model
+ FS + K_GENERATION + FS + d.generation
+ FS + K_CONN_TYPE + FS + d.connection_type
+ FS + K_CONN_SPEED + FS + std::to_string(d.connection_speed_mbps)
+ FS + K_DISK_SPEED + FS + std::to_string(d.disk_speed_mbps)
+ FS + K_DISK_SIZE + FS + std::to_string(d.disk_size_gb)
+ FS + K_DISK_TYPE + FS + d.disk_type
+ FS + K_AGE_YEARS + FS + std::to_string(d.age_years)
+ FS + K_PARTITION_IDS + FS + esc_join(d.partition_ids)
+ FS + K_PARTITION_SIZES + FS + ls_join_u64(d.partition_sizes_gb)
+ FS + K_VM_HOSTNAMES + FS + esc_join(d.vm_hostnames)
+ FS + K_VM_SERVER_IDS + FS + ls_join_u32(d.vm_server_ids)
+ "\n";
}
std::string Database::serialize_nic(const NetworkCard& n) const {
auto esc_join = [](const std::vector<std::string>& v) -> std::string {
std::string out;
for (size_t i = 0; i < v.size(); ++i) {
if (i) out += LS;
for (char c : v[i]) {
if (c == '\\') out += "\\\\";
else if (c == '|') out += "\\|";
else out += c;
}
}
return out;
};
return base_prefix(PTYPE_NIC, n)
+ FS + K_MANUFACTURER + FS + n.manufacturer
+ FS + K_MODEL + FS + n.model
+ FS + K_AGE_YEARS + FS + std::to_string(n.age_years)
+ FS + K_CONN_TYPE + FS + n.connection_type
+ FS + K_CONN_SPEED + FS + std::to_string(n.connection_speed_mbps)
+ FS + K_MAC + FS + n.mac_address
+ FS + K_IPS + FS + esc_join(n.ip_addresses)
+ FS + K_DHCP + FS + (n.dhcp ? "1" : "0")
+ "\n";
}
// ─────────────────────────────────────────────────────────────────────────────
// Constructor
// ─────────────────────────────────────────────────────────────────────────────
Database::Database(std::string path) : path_(std::move(path)) {}
// ─────────────────────────────────────────────────────────────────────────────
// alloc_part_id — caller must hold mu_
// ─────────────────────────────────────────────────────────────────────────────
uint32_t Database::alloc_part_id() {
return inv_.next_part_id++;
}
// ─────────────────────────────────────────────────────────────────────────────
// save_nolock / save
// ─────────────────────────────────────────────────────────────────────────────
bool Database::save_nolock() {
std::ofstream f(path_, std::ios::trunc);
if (!f.is_open()) {
std::cerr << "[db] cannot open " << path_ << " for writing\n";
return false;
}
f << "# device-inventory database\n";
f << "META|" << inv_.next_server_id << "|"
<< inv_.next_part_type_id << "|"
<< inv_.next_part_id << "\n";
for (const auto& s : inv_.servers) {
f << "S|" << s.id
<< "|" << escape(s.name)
<< "|" << escape(s.hostname)
<< "|" << escape(s.location)
<< "|" << escape(s.description) << "\n";
}
for (const auto& pt : inv_.part_types) {
f << "PT|" << pt.id
<< "|" << escape(pt.name)
<< "|" << escape(pt.description) << "\n";
}
// Helper lambda: write LS-joined list of escaped strings as a single pipe field
auto write_str_list = [](std::ofstream& out, const std::vector<std::string>& v) {
for (size_t i = 0; i < v.size(); ++i) {
if (i) out << static_cast<char>(LS);
// escape individual elements (protect | and \ within each element)
for (char c : v[i]) {
if (c == '\\') out << "\\\\";
else if (c == '|') out << "\\|";
else out << c;
}
}
};
for (const auto& m : inv_.memory_sticks) {
f << "MS|" << m.part_id << "|" << m.server_id
<< "|" << escape(base_serial(m))
<< "|" << m.last_updated
<< "|" << m.speed_mhz
<< "|" << m.size_mb
<< "|" << escape(m.manufacturer)
<< "|" << escape(m.channel_config)
<< "|" << escape(m.other_info) << "\n";
}
for (const auto& m : inv_.memory_slots) {
std::string inst = m.installed_stick_id.has_value()
? std::to_string(m.installed_stick_id.value()) : "NULL";
f << "MSLOT|" << m.part_id << "|" << m.server_id
<< "|" << escape(base_serial(m))
<< "|" << m.last_updated
<< "|" << m.allowed_speed_mhz
<< "|" << m.allowed_size_mb
<< "|" << inst << "\n";
}
for (const auto& c : inv_.cpus) {
f << "CPU|" << c.part_id << "|" << c.server_id
<< "|" << escape(base_serial(c))
<< "|" << c.last_updated
<< "|" << escape(c.name)
<< "|" << escape(c.manufacturer)
<< "|" << c.speed_ghz
<< "|" << c.cores
<< "|" << c.threads << "\n";
}
for (const auto& c : inv_.cpu_slots) {
std::string inst = c.installed_cpu_id.has_value()
? std::to_string(c.installed_cpu_id.value()) : "NULL";
f << "CPUSLOT|" << c.part_id << "|" << c.server_id
<< "|" << escape(base_serial(c))
<< "|" << c.last_updated
<< "|" << escape(c.form_factor)
<< "|" << inst << "\n";
}
for (const auto& d : inv_.disks) {
f << "DISK|" << d.part_id << "|" << d.server_id
<< "|" << escape(base_serial(d))
<< "|" << d.last_updated
<< "|" << escape(d.manufacturer)
<< "|" << escape(d.model)
<< "|" << escape(d.generation)
<< "|" << escape(d.connection_type)
<< "|" << d.connection_speed_mbps
<< "|" << d.disk_speed_mbps
<< "|" << d.disk_size_gb
<< "|" << escape(d.disk_type)
<< "|" << d.age_years << "|";
write_str_list(f, d.partition_ids);
f << "|";
// partition_sizes_gb: numeric, no escape needed
for (size_t i = 0; i < d.partition_sizes_gb.size(); ++i) {
if (i) f << static_cast<char>(LS);
f << d.partition_sizes_gb[i];
}
f << "|";
write_str_list(f, d.vm_hostnames);
f << "|";
for (size_t i = 0; i < d.vm_server_ids.size(); ++i) {
if (i) f << static_cast<char>(LS);
f << d.vm_server_ids[i];
}
f << "\n";
}
for (const auto& n : inv_.network_cards) {
f << "NIC|" << n.part_id << "|" << n.server_id
<< "|" << escape(base_serial(n))
<< "|" << n.last_updated
<< "|" << escape(n.manufacturer)
<< "|" << escape(n.model)
<< "|" << n.age_years
<< "|" << escape(n.connection_type)
<< "|" << n.connection_speed_mbps
<< "|" << escape(n.mac_address) << "|";
write_str_list(f, n.ip_addresses);
f << "|" << (n.dhcp ? 1 : 0) << "\n";
}
return true;
}
bool Database::save() {
std::lock_guard<std::mutex> lk(mu_);
return save_nolock();
}
// ─────────────────────────────────────────────────────────────────────────────
// load
// ─────────────────────────────────────────────────────────────────────────────
bool Database::load() {
std::lock_guard<std::mutex> lk(mu_);
std::ifstream f(path_);
if (!f.is_open()) {
inv_ = Inventory{};
return true;
}
Inventory fresh;
std::string line;
while (std::getline(f, line)) {
if (line.empty() || line[0] == '#') continue;
auto flds = split_pipe(line);
if (flds.empty()) continue;
const auto& rec = flds[0];
auto get = [&](size_t i) -> std::string {
return i < flds.size() ? unescape(flds[i]) : "";
};
auto getu32 = [&](size_t i) -> uint32_t {
return i < flds.size() ? static_cast<uint32_t>(std::stoul(flds[i])) : 0;
};
auto getu64 = [&](size_t i) -> uint64_t {
return i < flds.size() ? std::stoull(flds[i]) : 0ULL;
};
auto geti64 = [&](size_t i) -> int64_t {
return i < flds.size() ? std::stoll(flds[i]) : 0LL;
};
// serial field: raw flds[i] (not unescaped via get) but compare to "NULL"
auto get_serial = [&](size_t i) -> std::optional<std::string> {
if (i >= flds.size() || flds[i] == "NULL") return std::nullopt;
return unescape(flds[i]);
};
auto get_opt_u32 = [&](size_t i) -> std::optional<uint32_t> {
if (i >= flds.size() || flds[i] == "NULL") return std::nullopt;
return static_cast<uint32_t>(std::stoul(flds[i]));
};
if (rec == "META" && flds.size() >= 4) {
fresh.next_server_id = getu32(1);
fresh.next_part_type_id = getu32(2);
fresh.next_part_id = getu32(3);
} else if (rec == "S" && flds.size() >= 6) {
Server s;
s.id = getu32(1);
s.name = get(2);
s.hostname = get(3);
s.location = get(4);
s.description = get(5);
fresh.servers.push_back(std::move(s));
} else if (rec == "PT" && flds.size() >= 4) {
PartType pt;
pt.id = getu32(1);
pt.name = get(2);
pt.description = get(3);
fresh.part_types.push_back(std::move(pt));
} else if (rec == "MS" && flds.size() >= 10) {
MemoryStick m;
m.part_id = getu32(1);
m.server_id = getu32(2);
m.serial_number = get_serial(3);
m.last_updated = geti64(4);
m.speed_mhz = getu32(5);
m.size_mb = getu64(6);
m.manufacturer = get(7);
m.channel_config = get(8);
m.other_info = get(9);
fresh.memory_sticks.push_back(std::move(m));
} else if (rec == "MSLOT" && flds.size() >= 8) {
MemorySlot m;
m.part_id = getu32(1);
m.server_id = getu32(2);
m.serial_number = get_serial(3);
m.last_updated = geti64(4);
m.allowed_speed_mhz = getu32(5);
m.allowed_size_mb = getu64(6);
m.installed_stick_id = get_opt_u32(7);
fresh.memory_slots.push_back(std::move(m));
} else if (rec == "CPU" && flds.size() >= 10) {
CPU c;
c.part_id = getu32(1);
c.server_id = getu32(2);
c.serial_number = get_serial(3);
c.last_updated = geti64(4);
c.name = get(5);
c.manufacturer = get(6);
c.speed_ghz = flds.size() > 7 ? std::stod(flds[7]) : 0.0;
c.cores = getu32(8);
c.threads = getu32(9);
fresh.cpus.push_back(std::move(c));
} else if (rec == "CPUSLOT" && flds.size() >= 7) {
CPUSlot c;
c.part_id = getu32(1);
c.server_id = getu32(2);
c.serial_number = get_serial(3);
c.last_updated = geti64(4);
c.form_factor = get(5);
c.installed_cpu_id = get_opt_u32(6);
fresh.cpu_slots.push_back(std::move(c));
} else if (rec == "DISK" && flds.size() >= 18) {
Disk d;
d.part_id = getu32(1);
d.server_id = getu32(2);
d.serial_number = get_serial(3);
d.last_updated = geti64(4);
d.manufacturer = get(5);
d.model = get(6);
d.generation = get(7);
d.connection_type = get(8);
d.connection_speed_mbps = getu64(9);
d.disk_speed_mbps = getu64(10);
d.disk_size_gb = getu64(11);
d.disk_type = get(12);
d.age_years = flds.size() > 13 ? std::stoi(flds[13]) : -1;
// Note: flds[14..17] have NOT been through unescape() yet (we used raw flds)
// Re-split using the raw (escaped) field and unescape each element
auto uesc_split_raw = [](const std::string& raw) -> std::vector<std::string> {
if (raw.empty()) return {};
std::vector<std::string> out;
for (const auto& elem : split(raw, LS)) {
std::string u;
for (size_t i = 0; i < elem.size(); ++i) {
if (elem[i] == '\\' && i+1 < elem.size()) {
++i;
if (elem[i] == '\\') u += '\\';
else if (elem[i] == '|') u += '|';
else if (elem[i] == 'n') u += '\n';
else { u += '\\'; u += elem[i]; }
} else u += elem[i];
}
out.push_back(u);
}
return out;
};
d.partition_ids = uesc_split_raw(flds.size() > 14 ? flds[14] : "");
d.partition_sizes_gb = ls_split_u64(flds.size() > 15 ? flds[15] : "");
d.vm_hostnames = uesc_split_raw(flds.size() > 16 ? flds[16] : "");
d.vm_server_ids = ls_split_u32(flds.size() > 17 ? flds[17] : "");
fresh.disks.push_back(std::move(d));
} else if (rec == "NIC" && flds.size() >= 13) {
NetworkCard n;
n.part_id = getu32(1);
n.server_id = getu32(2);
n.serial_number = get_serial(3);
n.last_updated = geti64(4);
n.manufacturer = get(5);
n.model = get(6);
n.age_years = flds.size() > 7 ? std::stoi(flds[7]) : -1;
n.connection_type = get(8);
n.connection_speed_mbps= getu64(9);
n.mac_address = get(10);
// ip_addresses: LS separated in flds[11]
{
auto& raw = flds[11];
for (auto& p : split(raw, LS))
n.ip_addresses.push_back(unescape(p));
}
n.dhcp = (flds.size() > 12 && flds[12] == "1");
fresh.network_cards.push_back(std::move(n));
}
}
inv_ = std::move(fresh);
return true;
}
// ─────────────────────────────────────────────────────────────────────────────
// Servers
// ─────────────────────────────────────────────────────────────────────────────
Server Database::add_server(std::string name, std::string hostname,
std::string location, std::string description) {
std::lock_guard<std::mutex> lk(mu_);
Server s;
s.id = inv_.next_server_id++;
s.name = std::move(name);
s.hostname = std::move(hostname);
s.location = std::move(location);
s.description = std::move(description);
inv_.servers.push_back(s);
save_nolock();
return s;
}
std::vector<Server> Database::list_servers() const {
std::lock_guard<std::mutex> lk(mu_);
return inv_.servers;
}
std::optional<Server> Database::get_server(uint32_t id) const {
std::lock_guard<std::mutex> lk(mu_);
for (const auto& s : inv_.servers)
if (s.id == id) return s;
return std::nullopt;
}
bool Database::edit_server(uint32_t id, const std::string& field, const std::string& value) {
std::lock_guard<std::mutex> lk(mu_);
for (auto& s : inv_.servers) {
if (s.id != id) continue;
if (field == "name") s.name = value;
else if (field == "hostname") s.hostname = value;
else if (field == "location") s.location = value;
else if (field == "description") s.description = value;
else return false;
save_nolock();
return true;
}
return false;
}
bool Database::remove_server(uint32_t id) {
std::lock_guard<std::mutex> lk(mu_);
auto& v = inv_.servers;
auto it = std::find_if(v.begin(), v.end(), [id](const Server& s){ return s.id == id; });
if (it == v.end()) return false;
v.erase(it);
save_nolock();
return true;
}
// ─────────────────────────────────────────────────────────────────────────────
// Part types
// ─────────────────────────────────────────────────────────────────────────────
PartType Database::add_part_type(std::string name, std::string description) {
std::lock_guard<std::mutex> lk(mu_);
PartType pt;
pt.id = inv_.next_part_type_id++;
pt.name = std::move(name);
pt.description = std::move(description);
inv_.part_types.push_back(pt);
save_nolock();
return pt;
}
std::vector<PartType> Database::list_part_types() const {
std::lock_guard<std::mutex> lk(mu_);
return inv_.part_types;
}
std::optional<PartType> Database::get_part_type(uint32_t id) const {
std::lock_guard<std::mutex> lk(mu_);
for (const auto& pt : inv_.part_types)
if (pt.id == id) return pt;
return std::nullopt;
}
bool Database::edit_part_type(uint32_t id, const std::string& field, const std::string& value) {
std::lock_guard<std::mutex> lk(mu_);
for (auto& pt : inv_.part_types) {
if (pt.id != id) continue;
if (field == "name") pt.name = value;
else if (field == "description") pt.description = value;
else return false;
save_nolock();
return true;
}
return false;
}
bool Database::remove_part_type(uint32_t id) {
std::lock_guard<std::mutex> lk(mu_);
auto& v = inv_.part_types;
auto it = std::find_if(v.begin(), v.end(), [id](const PartType& pt){ return pt.id == id; });
if (it == v.end()) return false;
v.erase(it);
save_nolock();
return true;
}
// ─────────────────────────────────────────────────────────────────────────────
// add_part_nolock — caller must hold mu_
// ─────────────────────────────────────────────────────────────────────────────
uint32_t Database::add_part_nolock(const std::string& type_name, uint32_t server_id,
const std::map<std::string,std::string>& kv) {
uint32_t pid = alloc_part_id();
int64_t now = static_cast<int64_t>(time(nullptr));
if (type_name == PTYPE_MEMORY_STICK) {
MemoryStick m; m.part_id = pid; m.server_id = server_id; m.last_updated = now;
apply_memory_stick(m, kv);
inv_.memory_sticks.push_back(std::move(m));
} else if (type_name == PTYPE_MEMORY_SLOT) {
MemorySlot m; m.part_id = pid; m.server_id = server_id; m.last_updated = now;
apply_memory_slot(m, kv);
inv_.memory_slots.push_back(std::move(m));
} else if (type_name == PTYPE_CPU) {
CPU c; c.part_id = pid; c.server_id = server_id; c.last_updated = now;
apply_cpu(c, kv);
inv_.cpus.push_back(std::move(c));
} else if (type_name == PTYPE_CPU_SLOT) {
CPUSlot c; c.part_id = pid; c.server_id = server_id; c.last_updated = now;
apply_cpu_slot(c, kv);
inv_.cpu_slots.push_back(std::move(c));
} else if (type_name == PTYPE_DISK) {
Disk d; d.part_id = pid; d.server_id = server_id; d.last_updated = now;
apply_disk(d, kv);
inv_.disks.push_back(std::move(d));
} else if (type_name == PTYPE_NIC) {
NetworkCard n; n.part_id = pid; n.server_id = server_id; n.last_updated = now;
apply_nic(n, kv);
inv_.network_cards.push_back(std::move(n));
} else {
// Unknown type — no-op, reclaim the id by decrementing
--inv_.next_part_id;
return 0;
}
save_nolock();
return pid;
}
// ─────────────────────────────────────────────────────────────────────────────
// add_part (public, locks)
// ─────────────────────────────────────────────────────────────────────────────
uint32_t Database::add_part(const std::string& type_name, uint32_t server_id,
const std::map<std::string,std::string>& kv) {
std::lock_guard<std::mutex> lk(mu_);
return add_part_nolock(type_name, server_id, kv);
}
// ─────────────────────────────────────────────────────────────────────────────
// upsert_part
// ─────────────────────────────────────────────────────────────────────────────
uint32_t Database::upsert_part(const std::string& type_name, uint32_t server_id,
const std::map<std::string,std::string>& kv) {
std::lock_guard<std::mutex> lk(mu_);
int64_t now = static_cast<int64_t>(time(nullptr));
if (type_name == PTYPE_NIC) {
auto mac_it = kv.find(K_MAC);
if (mac_it != kv.end() && !mac_it->second.empty()) {
for (auto& n : inv_.network_cards) {
if (n.server_id == server_id && n.mac_address == mac_it->second) {
apply_nic(n, kv);
n.last_updated = now;
save_nolock();
return n.part_id;
}
}
}
} else {
auto ser_it = kv.find(K_SERIAL);
if (ser_it != kv.end() && ser_it->second != "NULL" && !ser_it->second.empty()) {
const std::string& serial = ser_it->second;
// Search in the collection matching type_name
auto try_update = [&](auto& vec) -> uint32_t {
for (auto& p : vec) {
if (p.server_id == server_id && p.serial_number.has_value()
&& p.serial_number.value() == serial) {
if constexpr (std::is_same_v<std::decay_t<decltype(p)>, MemoryStick>)
apply_memory_stick(p, kv);
else if constexpr (std::is_same_v<std::decay_t<decltype(p)>, MemorySlot>)
apply_memory_slot(p, kv);
else if constexpr (std::is_same_v<std::decay_t<decltype(p)>, CPU>)
apply_cpu(p, kv);
else if constexpr (std::is_same_v<std::decay_t<decltype(p)>, CPUSlot>)
apply_cpu_slot(p, kv);
else if constexpr (std::is_same_v<std::decay_t<decltype(p)>, Disk>)
apply_disk(p, kv);
p.last_updated = now;
save_nolock();
return p.part_id;
}
}
return 0u;
};
uint32_t found = 0;
if (type_name == PTYPE_MEMORY_STICK) found = try_update(inv_.memory_sticks);
else if (type_name == PTYPE_MEMORY_SLOT) found = try_update(inv_.memory_slots);
else if (type_name == PTYPE_CPU) found = try_update(inv_.cpus);
else if (type_name == PTYPE_CPU_SLOT) found = try_update(inv_.cpu_slots);
else if (type_name == PTYPE_DISK) found = try_update(inv_.disks);
if (found) return found;
}
}
// Not found — insert
return add_part_nolock(type_name, server_id, kv);
}
// ─────────────────────────────────────────────────────────────────────────────
// edit_part
// ─────────────────────────────────────────────────────────────────────────────
bool Database::edit_part(uint32_t part_id, const std::map<std::string,std::string>& kv) {
std::lock_guard<std::mutex> lk(mu_);
int64_t now = static_cast<int64_t>(time(nullptr));
auto try_edit = [&](auto& vec, auto apply_fn) -> bool {
for (auto& p : vec) {
if (p.part_id != part_id) continue;
apply_fn(p, kv);
p.last_updated = now;
save_nolock();
return true;
}
return false;
};
if (try_edit(inv_.memory_sticks, apply_memory_stick)) return true;
if (try_edit(inv_.memory_slots, apply_memory_slot)) return true;
if (try_edit(inv_.cpus, apply_cpu)) return true;
if (try_edit(inv_.cpu_slots, apply_cpu_slot)) return true;
if (try_edit(inv_.disks, apply_disk)) return true;
if (try_edit(inv_.network_cards, apply_nic)) return true;
return false;
}
// ─────────────────────────────────────────────────────────────────────────────
// remove_part
// ─────────────────────────────────────────────────────────────────────────────
bool Database::remove_part(uint32_t part_id) {
std::lock_guard<std::mutex> lk(mu_);
auto try_remove = [&](auto& vec) -> bool {
auto it = std::find_if(vec.begin(), vec.end(),
[part_id](const auto& p){ return p.part_id == part_id; });
if (it == vec.end()) return false;
vec.erase(it);
return true;
};
bool removed = try_remove(inv_.memory_sticks)
|| try_remove(inv_.memory_slots)
|| try_remove(inv_.cpus)
|| try_remove(inv_.cpu_slots)
|| try_remove(inv_.disks)
|| try_remove(inv_.network_cards);
if (removed) save_nolock();
return removed;
}
// ─────────────────────────────────────────────────────────────────────────────
// list_parts / get_part_row
// ─────────────────────────────────────────────────────────────────────────────
std::vector<std::string> Database::list_parts(uint32_t server_id,
const std::string& type_filter) const {
std::lock_guard<std::mutex> lk(mu_);
std::vector<std::string> rows;
bool all = type_filter.empty();
if (all || type_filter == PTYPE_MEMORY_STICK)
for (const auto& m : inv_.memory_sticks)
if (m.server_id == server_id) rows.push_back(serialize_memory_stick(m));
if (all || type_filter == PTYPE_MEMORY_SLOT)
for (const auto& m : inv_.memory_slots)
if (m.server_id == server_id) rows.push_back(serialize_memory_slot(m));
if (all || type_filter == PTYPE_CPU)
for (const auto& c : inv_.cpus)
if (c.server_id == server_id) rows.push_back(serialize_cpu(c));
if (all || type_filter == PTYPE_CPU_SLOT)
for (const auto& c : inv_.cpu_slots)
if (c.server_id == server_id) rows.push_back(serialize_cpu_slot(c));
if (all || type_filter == PTYPE_DISK)
for (const auto& d : inv_.disks)
if (d.server_id == server_id) rows.push_back(serialize_disk(d));
if (all || type_filter == PTYPE_NIC)
for (const auto& n : inv_.network_cards)
if (n.server_id == server_id) rows.push_back(serialize_nic(n));
return rows;
}
std::string Database::get_part_row(uint32_t part_id) const {
std::lock_guard<std::mutex> lk(mu_);
for (const auto& m : inv_.memory_sticks)
if (m.part_id == part_id) return serialize_memory_stick(m);
for (const auto& m : inv_.memory_slots)
if (m.part_id == part_id) return serialize_memory_slot(m);
for (const auto& c : inv_.cpus)
if (c.part_id == part_id) return serialize_cpu(c);
for (const auto& c : inv_.cpu_slots)
if (c.part_id == part_id) return serialize_cpu_slot(c);
for (const auto& d : inv_.disks)
if (d.part_id == part_id) return serialize_disk(d);
for (const auto& n : inv_.network_cards)
if (n.part_id == part_id) return serialize_nic(n);
return "";
}
// ─────────────────────────────────────────────────────────────────────────────
// get_nics_for_server
// ─────────────────────────────────────────────────────────────────────────────
std::vector<NetworkCard> Database::get_nics_for_server(uint32_t server_id) const {
std::lock_guard<std::mutex> lk(mu_);
std::vector<NetworkCard> result;
for (const auto& n : inv_.network_cards)
if (n.server_id == server_id) result.push_back(n);
return result;
}

View file

@ -0,0 +1,98 @@
#pragma once
#include <cstdint>
#include <map>
#include <mutex>
#include <optional>
#include <string>
#include <vector>
#include "common/models.h"
// ─────────────────────────────────────────────────────────────────────────────
// Database
// Thread-safe, file-backed inventory store.
//
// File format (text, one record per line):
// # device-inventory database
// META|next_server_id|next_part_type_id|next_part_id
// S|id|name|hostname|location|description
// PT|id|name|description
// MS|part_id|server_id|serial_or_NULL|last_updated|speed_mhz|size_mb|manufacturer|channel_config|other_info
// MSLOT|part_id|server_id|serial_or_NULL|last_updated|allowed_speed_mhz|allowed_size_mb|installed_stick_id_or_NULL
// CPU|part_id|server_id|serial_or_NULL|last_updated|name|manufacturer|speed_ghz|cores|threads
// CPUSLOT|part_id|server_id|serial_or_NULL|last_updated|form_factor|installed_cpu_id_or_NULL
// DISK|part_id|server_id|serial_or_NULL|last_updated|manufacturer|model|generation|conn_type|conn_speed|disk_speed|disk_size|disk_type|age_years|partition_ids_LS|partition_sizes_LS|vm_hostnames_LS|vm_server_ids_LS
// NIC|part_id|server_id|serial_or_NULL|last_updated|manufacturer|model|age_years|conn_type|conn_speed|mac|ips_LS|dhcp
//
// Lists within a field use LS ('\x02'). Pipe and backslash in text fields are
// escaped via escape()/unescape().
// ─────────────────────────────────────────────────────────────────────────────
class Database {
public:
explicit Database(std::string path);
// Persist current inventory to disk (acquires lock)
bool save();
// Load (or reload) inventory from disk
bool load();
// ── Servers ───────────────────────────────────────────────────────────────
Server add_server(std::string name, std::string hostname,
std::string location, std::string description);
std::vector<Server> list_servers() const;
std::optional<Server> get_server(uint32_t id) const;
bool edit_server(uint32_t id, const std::string& field,
const std::string& value);
bool remove_server(uint32_t id);
// ── Part types (labels) ───────────────────────────────────────────────────
PartType add_part_type(std::string name, std::string description);
std::vector<PartType> list_part_types() const;
std::optional<PartType> get_part_type(uint32_t id) const;
bool edit_part_type(uint32_t id, const std::string& field,
const std::string& value);
bool remove_part_type(uint32_t id);
// ── Typed parts ───────────────────────────────────────────────────────────
// kv is a map of field-key → field-value strings (K_* constants from protocol.h)
uint32_t add_part(const std::string& type_name, uint32_t server_id,
const std::map<std::string,std::string>& kv);
// Insert or update: for nic match by mac, for others by serial
uint32_t upsert_part(const std::string& type_name, uint32_t server_id,
const std::map<std::string,std::string>& kv);
bool edit_part(uint32_t part_id, const std::map<std::string,std::string>& kv);
bool remove_part(uint32_t part_id);
// Wire-row format: type_name<FS>part_id<FS>server_id<FS>serial_or_NULL<FS>last_updated<FS>k1<FS>v1...
std::vector<std::string> list_parts(uint32_t server_id,
const std::string& type_filter = "") const;
std::string get_part_row(uint32_t part_id) const; // empty = not found
// Returns all NetworkCard records belonging to the given server.
std::vector<NetworkCard> get_nics_for_server(uint32_t server_id) const;
private:
std::string path_;
Inventory inv_;
mutable std::mutex mu_;
// Internal save without acquiring lock (caller must hold mu_)
bool save_nolock();
// Internal add_part without acquiring lock
uint32_t add_part_nolock(const std::string& type_name, uint32_t server_id,
const std::map<std::string,std::string>& kv);
uint32_t alloc_part_id(); // call while holding mu_
static std::string escape(const std::string& s);
static std::string unescape(const std::string& s);
static void apply_base(PartBase& p, const std::map<std::string,std::string>& kv);
// Per-type wire-row serializers
std::string serialize_memory_stick(const MemoryStick& m) const;
std::string serialize_memory_slot (const MemorySlot& m) const;
std::string serialize_cpu (const CPU& c) const;
std::string serialize_cpu_slot (const CPUSlot& c) const;
std::string serialize_disk (const Disk& d) const;
std::string serialize_nic (const NetworkCard& n) const;
};

View file

@ -0,0 +1,257 @@
#include "server/hooks/dns_updater_hook.h"
#include "server/database.h"
#include <cstdlib>
#include <sstream>
#include <stdexcept>
#include <vector>
// ─────────────────────────────────────────────────────────────────────────────
// Config
// ─────────────────────────────────────────────────────────────────────────────
DnsUpdaterHook::Config DnsUpdaterHook::load_config() {
auto env = [](const char* key, const char* def) -> std::string {
const char* v = std::getenv(key);
return (v && *v) ? v : def;
};
Config c;
c.host = env("TECHNITIUM_HOST", "192.168.2.193");
c.port = std::stoi(env("TECHNITIUM_PORT", "5380"));
c.user = env("TECHNITIUM_USER", "admin");
c.pass = env("TECHNITIUM_PASS", "");
c.zone = env("TECHNITIUM_ZONE", "homelab");
c.ttl = std::stoi(env("DNS_TTL", "300"));
return c;
}
// ─────────────────────────────────────────────────────────────────────────────
// execute
// ─────────────────────────────────────────────────────────────────────────────
void DnsUpdaterHook::execute(const Server& old_server, const Server& new_server) {
Config cfg = load_config();
if (cfg.pass.empty()) {
std::cerr << "[dns-updater] TECHNITIUM_PASS is not set skipping\n";
return;
}
// Resolve the server's IP address from its NICs.
std::string ip;
{
auto nics = db_.get_nics_for_server(new_server.id);
for (const auto& nic : nics) {
for (const auto& addr : nic.ip_addresses) {
// Strip CIDR suffix if present (e.g. "192.168.2.100/24" → "192.168.2.100")
auto slash = addr.find('/');
std::string candidate = (slash != std::string::npos)
? addr.substr(0, slash) : addr;
// Accept only IPv4
struct in_addr dummy{};
if (::inet_pton(AF_INET, candidate.c_str(), &dummy) == 1) {
ip = candidate;
break;
}
}
if (!ip.empty()) break;
}
}
if (ip.empty()) {
std::cerr << "[dns-updater] No IPv4 address found for server '"
<< new_server.name << "' skipping DNS update\n";
return;
}
// ── 1. Login ──────────────────────────────────────────────────────────────
std::string login_path = "/api/user/login?user=" + url_encode(cfg.user)
+ "&pass=" + url_encode(cfg.pass);
std::string login_resp = http_get(cfg.host, cfg.port, login_path);
std::string status = json_get(login_resp, "status");
if (status != "ok") {
std::cerr << "[dns-updater] Login failed: " << login_resp << "\n";
return;
}
std::string token = json_get(login_resp, "token");
if (token.empty()) {
std::cerr << "[dns-updater] No token in login response: " << login_resp << "\n";
return;
}
std::cout << "[dns-updater] Logged in to Technitium\n";
// ── 2. Delete old record (ignore errors record may not exist) ───────────
std::string old_domain = old_server.name + "." + cfg.zone;
std::string del_body = "token=" + url_encode(token)
+ "&domain=" + url_encode(old_domain)
+ "&zone=" + url_encode(cfg.zone)
+ "&type=A"
+ "&ipAddress=" + url_encode(ip);
std::string del_resp = http_post(cfg.host, cfg.port,
"/api/zones/records/delete", del_body);
std::string del_status = json_get(del_resp, "status");
if (del_status != "ok") {
// Log but continue record may simply not exist yet
std::cerr << "[dns-updater] Delete old record returned: " << del_resp << "\n";
} else {
std::cout << "[dns-updater] Deleted DNS record: " << old_domain << "\n";
}
// ── 3. Add new record ─────────────────────────────────────────────────────
std::string new_domain = new_server.name + "." + cfg.zone;
std::string add_body = "token=" + url_encode(token)
+ "&domain=" + url_encode(new_domain)
+ "&zone=" + url_encode(cfg.zone)
+ "&type=A"
+ "&ttl=" + std::to_string(cfg.ttl)
+ "&ipAddress=" + url_encode(ip);
std::string add_resp = http_post(cfg.host, cfg.port,
"/api/zones/records/add", add_body);
std::string add_status = json_get(add_resp, "status");
if (add_status != "ok") {
std::cerr << "[dns-updater] Add new record failed: " << add_resp << "\n";
return;
}
std::cout << "[dns-updater] Added DNS record: " << new_domain
<< " -> " << ip << "\n";
}
// ─────────────────────────────────────────────────────────────────────────────
// HTTP helpers
// ─────────────────────────────────────────────────────────────────────────────
int DnsUpdaterHook::connect_to(const std::string& host, int port) {
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo* res = nullptr;
std::string port_str = std::to_string(port);
int rc = ::getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res);
if (rc != 0)
throw std::runtime_error(std::string("getaddrinfo: ") + ::gai_strerror(rc));
int fd = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (fd < 0) {
::freeaddrinfo(res);
throw std::runtime_error("socket() failed");
}
if (::connect(fd, res->ai_addr, res->ai_addrlen) < 0) {
::close(fd);
::freeaddrinfo(res);
throw std::runtime_error("connect() to " + host + ":" + port_str + " failed");
}
::freeaddrinfo(res);
return fd;
}
static std::string read_http_response(int fd) {
std::string raw;
char buf[4096];
ssize_t n;
while ((n = ::recv(fd, buf, sizeof(buf), 0)) > 0)
raw.append(buf, static_cast<size_t>(n));
// Return only the body (after the blank line separating headers from body)
auto pos = raw.find("\r\n\r\n");
if (pos != std::string::npos) return raw.substr(pos + 4);
pos = raw.find("\n\n");
if (pos != std::string::npos) return raw.substr(pos + 2);
return raw;
}
std::string DnsUpdaterHook::http_get(const std::string& host, int port,
const std::string& path) {
int fd = connect_to(host, port);
std::ostringstream req;
req << "GET " << path << " HTTP/1.0\r\n"
<< "Host: " << host << ":" << port << "\r\n"
<< "Connection: close\r\n"
<< "\r\n";
std::string r = req.str();
::send(fd, r.c_str(), r.size(), 0);
std::string body = read_http_response(fd);
::close(fd);
return body;
}
std::string DnsUpdaterHook::http_post(const std::string& host, int port,
const std::string& path,
const std::string& body) {
int fd = connect_to(host, port);
std::ostringstream req;
req << "POST " << path << " HTTP/1.0\r\n"
<< "Host: " << host << ":" << port << "\r\n"
<< "Content-Type: application/x-www-form-urlencoded\r\n"
<< "Content-Length: " << body.size() << "\r\n"
<< "Connection: close\r\n"
<< "\r\n"
<< body;
std::string r = req.str();
::send(fd, r.c_str(), r.size(), 0);
std::string resp = read_http_response(fd);
::close(fd);
return resp;
}
// ─────────────────────────────────────────────────────────────────────────────
// url_encode
// ─────────────────────────────────────────────────────────────────────────────
std::string DnsUpdaterHook::url_encode(const std::string& s) {
static const char hex[] = "0123456789ABCDEF";
std::string out;
out.reserve(s.size());
for (unsigned char c : s) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
out += static_cast<char>(c);
} else {
out += '%';
out += hex[c >> 4];
out += hex[c & 0xf];
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// json_get naive but sufficient for Technitium's flat responses
// ─────────────────────────────────────────────────────────────────────────────
std::string DnsUpdaterHook::json_get(const std::string& json, const std::string& key) {
// Look for "key":"value" or "key": "value"
std::string needle = "\"" + key + "\"";
auto pos = json.find(needle);
if (pos == std::string::npos) return {};
pos = json.find(':', pos + needle.size());
if (pos == std::string::npos) return {};
// Skip whitespace
++pos;
while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) ++pos;
if (pos >= json.size()) return {};
if (json[pos] == '"') {
// Quoted string value
++pos;
std::string val;
while (pos < json.size() && json[pos] != '"') {
if (json[pos] == '\\' && pos + 1 < json.size()) ++pos; // skip escape
val += json[pos++];
}
return val;
}
// Unquoted value (number, bool, null) read until delimiter
std::string val;
while (pos < json.size() && json[pos] != ',' && json[pos] != '}' &&
json[pos] != '\n' && json[pos] != '\r') {
val += json[pos++];
}
// Trim trailing whitespace
while (!val.empty() && (val.back() == ' ' || val.back() == '\t'))
val.pop_back();
return val;
}

View file

@ -0,0 +1,79 @@
#pragma once
#include "server/hooks/hook.h"
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <netdb.h>
#include <sstream>
#include <stdexcept>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
// ─────────────────────────────────────────────────────────────────────────────
// DnsUpdaterHook
//
// Fires whenever a server's *name* changes. Updates Technitium DNS by:
// 1. Logging in → GET /api/user/login?user=…&pass=… → token
// 2. Deleting the old A record (404 errors are silently ignored)
// 3. Adding the new A record
//
// The server's IP is derived from the first NIC ip_address that belongs to the
// server record. This hook receives the old and new Server structs; to look up
// IP addresses it needs access to the Database pass a const ref in the ctor.
//
// Config is supplied via environment variables so no secrets land in source:
// TECHNITIUM_HOST (default: 192.168.2.193)
// TECHNITIUM_PORT (default: 5380)
// TECHNITIUM_USER (default: admin)
// TECHNITIUM_PASS (required hook logs an error and skips if unset)
// TECHNITIUM_ZONE (default: homelab)
// DNS_TTL (default: 300)
// ─────────────────────────────────────────────────────────────────────────────
class Database; // forward declaration full header included in .cpp
class DnsUpdaterHook : public Hook {
public:
explicit DnsUpdaterHook(const Database& db) : db_(db) {}
// Trigger only when the server name changes.
bool filter(const Server& old_server, const Server& new_server) override {
return old_server.name != new_server.name;
}
void execute(const Server& old_server, const Server& new_server) override;
private:
const Database& db_;
// Resolve config from environment, falling back to defaults.
struct Config {
std::string host;
int port;
std::string user;
std::string pass;
std::string zone;
int ttl;
};
static Config load_config();
// Percent-encode a string for use in a URL query parameter.
static std::string url_encode(const std::string& s);
// Open a TCP connection; throws on failure.
static int connect_to(const std::string& host, int port);
// Send an HTTP/1.0 GET request and return the full response body.
static std::string http_get(const std::string& host, int port,
const std::string& path);
// Send an HTTP/1.0 POST request (application/x-www-form-urlencoded body)
// and return the full response body.
static std::string http_post(const std::string& host, int port,
const std::string& path,
const std::string& body);
// Minimal JSON value extractor finds "key":"value" or "key":value.
static std::string json_get(const std::string& json, const std::string& key);
};

View file

@ -0,0 +1,25 @@
#pragma once
#include "common/models.h"
// ─────────────────────────────────────────────────────────────────────────────
// Hook interface
//
// Each hook decides whether it needs to fire (filter) and then performs its
// side-effect (execute). Both functions receive the state of the Server record
// before and after the mutation.
//
// Hooks are invoked from HookRunner::fire(), which spawns a detached thread per
// hook and wraps execute() in a try/catch so a crashing hook cannot affect the
// server.
// ─────────────────────────────────────────────────────────────────────────────
struct Hook {
virtual ~Hook() = default;
// Return true if this hook should run for the given change.
virtual bool filter(const Server& old_server, const Server& new_server) = 0;
// Perform the side-effect. Called in a dedicated thread; exceptions are
// caught and logged by HookRunner.
virtual void execute(const Server& old_server, const Server& new_server) = 0;
};

View file

@ -0,0 +1,55 @@
#pragma once
#include "server/hooks/hook.h"
#include <iostream>
#include <memory>
#include <thread>
#include <vector>
// ─────────────────────────────────────────────────────────────────────────────
// HookRunner
//
// Owns a list of Hook instances. Call fire() after every server mutation to
// run every matching hook in its own detached thread.
// ─────────────────────────────────────────────────────────────────────────────
class HookRunner {
public:
void add(std::unique_ptr<Hook> hook) {
hooks_.push_back(std::move(hook));
}
// Call after a server record is mutated. For each registered hook:
// 1. Calls filter() on the calling thread (cheap, no I/O).
// 2. If filter returns true, spawns a detached thread that calls
// execute() and catches any exception.
void fire(const Server& old_server, const Server& new_server) {
for (auto& hook : hooks_) {
bool should_run = false;
try {
should_run = hook->filter(old_server, new_server);
} catch (const std::exception& e) {
std::cerr << "[hooks] filter() threw: " << e.what() << "\n";
} catch (...) {
std::cerr << "[hooks] filter() threw unknown exception\n";
}
if (!should_run) continue;
// Capture raw pointer hook lifetime is tied to HookRunner which
// outlives the server process, so this is safe.
Hook* raw = hook.get();
std::thread([raw, old_server, new_server]() {
try {
raw->execute(old_server, new_server);
} catch (const std::exception& e) {
std::cerr << "[hooks] execute() threw: " << e.what() << "\n";
} catch (...) {
std::cerr << "[hooks] execute() threw unknown exception\n";
}
}).detach();
}
}
private:
std::vector<std::unique_ptr<Hook>> hooks_;
};

View file

@ -0,0 +1,57 @@
#include "server/server.h"
#include "server/hooks/dns_updater_hook.h"
#include "common/protocol.h"
#include <csignal>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
static constexpr const char* VERSION = "1.0.0";
static InventoryServer* g_server = nullptr;
static void on_signal(int) {
std::cout << "\n[server] Shutting down...\n";
if (g_server) g_server->stop();
}
int main(int argc, char* argv[]) {
uint16_t port = DEFAULT_PORT;
std::string db_path = "inventory.db";
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if ((arg == "--port" || arg == "-p") && i + 1 < argc)
port = static_cast<uint16_t>(std::stoul(argv[++i]));
else if ((arg == "--db" || arg == "-d") && i + 1 < argc)
db_path = argv[++i];
else if (arg == "--help" || arg == "-h") {
std::cout << "inventory-server v" << VERSION << "\n"
<< "Usage: inventory-server [options]\n\n"
<< "Options:\n"
<< " --port, -p PORT TCP port to listen on (default: "
<< DEFAULT_PORT << ")\n"
<< " --db, -d PATH Path to the inventory database file"
" (default: inventory.db)\n"
<< " --help, -h Show this help\n";
return 0;
}
}
std::signal(SIGINT, on_signal);
std::signal(SIGTERM, on_signal);
std::cout << "inventory-server v" << VERSION << "\n"
<< "[server] Database: " << db_path << "\n";
InventoryServer server(port, db_path);
g_server = &server;
server.hooks().add(std::make_unique<DnsUpdaterHook>(server.db()));
server.run();
std::cout << "[server] Bye\n";
return 0;
}

View file

@ -0,0 +1,278 @@
#include "server/server.h"
#include "common/protocol.h"
#include <arpa/inet.h>
#include <csignal>
#include <cstring>
#include <iostream>
#include <map>
#include <netinet/in.h>
#include <sstream>
#include <sys/socket.h>
#include <thread>
#include <unistd.h>
// ─────────────────────────────────────────────────────────────────────────────
// Constructor / Destructor
// ─────────────────────────────────────────────────────────────────────────────
InventoryServer::InventoryServer(uint16_t port, std::string db_path)
: port_(port), db_(std::move(db_path)) {}
InventoryServer::~InventoryServer() { stop(); }
// ─────────────────────────────────────────────────────────────────────────────
// run
// ─────────────────────────────────────────────────────────────────────────────
void InventoryServer::run() {
if (!db_.load()) {
std::cerr << "[server] Failed to load database aborting\n";
return;
}
std::cout << "[server] Inventory loaded\n";
listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd_ < 0) { perror("socket"); return; }
int opt = 1;
::setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(port_);
if (::bind(listen_fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
perror("bind"); ::close(listen_fd_); listen_fd_ = -1; return;
}
if (::listen(listen_fd_, 32) < 0) {
perror("listen"); ::close(listen_fd_); listen_fd_ = -1; return;
}
running_ = true;
std::cout << "[server] Listening on 127.0.0.1:" << port_ << "\n";
while (running_) {
sockaddr_in client_addr{};
socklen_t len = sizeof(client_addr);
int client_fd = ::accept(listen_fd_,
reinterpret_cast<sockaddr*>(&client_addr), &len);
if (client_fd < 0) {
if (running_) perror("accept");
break;
}
std::thread([this, client_fd]() { handle_client(client_fd); }).detach();
}
}
void InventoryServer::stop() {
running_ = false;
if (listen_fd_ >= 0) { ::close(listen_fd_); listen_fd_ = -1; }
}
// ─────────────────────────────────────────────────────────────────────────────
// handle_client
// ─────────────────────────────────────────────────────────────────────────────
void InventoryServer::handle_client(int fd) {
std::string line;
while (true) {
char buf[4096];
ssize_t n = ::recv(fd, buf, sizeof(buf), 0);
if (n <= 0) break;
line.append(buf, static_cast<size_t>(n));
if (line.find('\n') != std::string::npos) break;
}
while (!line.empty() && (line.back() == '\n' || line.back() == '\r'))
line.pop_back();
std::vector<std::string> tokens = split(line, FS);
std::string response = dispatch(tokens);
::send(fd, response.c_str(), response.size(), 0);
::close(fd);
}
// ─────────────────────────────────────────────────────────────────────────────
// Response helpers
// ─────────────────────────────────────────────────────────────────────────────
static std::string ok(const std::string& body = "") {
if (body.empty())
return std::string(RESP_OK) + "\n" + RESP_END + "\n";
return std::string(RESP_OK) + "\n" + body + RESP_END + "\n";
}
static std::string err(const std::string& msg) {
return std::string(RESP_ERR) + " " + msg + "\n";
}
// ─────────────────────────────────────────────────────────────────────────────
// dispatch
// ─────────────────────────────────────────────────────────────────────────────
std::string InventoryServer::dispatch(const std::vector<std::string>& t) {
if (t.empty()) return err("empty command");
const std::string& cmd = t[0];
if (cmd == CMD_ADD_SERVER) return cmd_add_server(t);
else if (cmd == CMD_LIST_SERVERS) return cmd_list_servers(t);
else if (cmd == CMD_EDIT_SERVER) return cmd_edit_server(t);
else if (cmd == CMD_REMOVE_SERVER) return cmd_remove_server(t);
else if (cmd == CMD_ADD_PART_TYPE) return cmd_add_part_type(t);
else if (cmd == CMD_LIST_PART_TYPES) return cmd_list_part_types(t);
else if (cmd == CMD_EDIT_PART_TYPE) return cmd_edit_part_type(t);
else if (cmd == CMD_REMOVE_PART_TYPE) return cmd_remove_part_type(t);
else if (cmd == CMD_ADD_PART) return cmd_add_part(t);
else if (cmd == CMD_UPSERT_PART) return cmd_upsert_part(t);
else if (cmd == CMD_EDIT_PART) return cmd_edit_part(t);
else if (cmd == CMD_REMOVE_PART) return cmd_remove_part(t);
else if (cmd == CMD_LIST_PARTS) return cmd_list_parts(t);
else if (cmd == CMD_GET_PART) return cmd_get_part(t);
return err("unknown command: " + cmd);
}
// ─────────────────────────────────────────────────────────────────────────────
// Server handlers
// ─────────────────────────────────────────────────────────────────────────────
std::string InventoryServer::cmd_add_server(const std::vector<std::string>& t) {
if (t.size() < 5) return err("usage: ADD_SERVER <name> <hostname> <location> <description>");
auto s = db_.add_server(t[1], t[2], t[3], t[4]);
std::ostringstream ss;
ss << s.id << FS << s.name << FS << s.hostname << FS
<< s.location << FS << s.description << "\n";
return ok(ss.str());
}
std::string InventoryServer::cmd_list_servers(const std::vector<std::string>&) {
auto servers = db_.list_servers();
std::ostringstream ss;
for (const auto& s : servers)
ss << s.id << FS << s.name << FS << s.hostname << FS
<< s.location << FS << s.description << "\n";
return ok(ss.str());
}
std::string InventoryServer::cmd_edit_server(const std::vector<std::string>& t) {
if (t.size() < 4) return err("usage: EDIT_SERVER <id> <field> <value>");
uint32_t id = static_cast<uint32_t>(std::stoul(t[1]));
auto before = db_.get_server(id);
if (!before) return err("server not found or invalid field");
if (!db_.edit_server(id, t[2], t[3]))
return err("server not found or invalid field");
auto after = db_.get_server(id);
if (after) hooks_.fire(*before, *after);
return ok();
}
std::string InventoryServer::cmd_remove_server(const std::vector<std::string>& t) {
if (t.size() < 2) return err("usage: REMOVE_SERVER <id>");
uint32_t id = static_cast<uint32_t>(std::stoul(t[1]));
if (!db_.remove_server(id))
return err("server not found");
return ok();
}
// ─────────────────────────────────────────────────────────────────────────────
// Part type handlers
// ─────────────────────────────────────────────────────────────────────────────
std::string InventoryServer::cmd_add_part_type(const std::vector<std::string>& t) {
if (t.size() < 3) return err("usage: ADD_PART_TYPE <name> <description>");
auto pt = db_.add_part_type(t[1], t[2]);
std::ostringstream ss;
ss << pt.id << FS << pt.name << FS << pt.description << "\n";
return ok(ss.str());
}
std::string InventoryServer::cmd_list_part_types(const std::vector<std::string>&) {
auto pts = db_.list_part_types();
std::ostringstream ss;
for (const auto& pt : pts)
ss << pt.id << FS << pt.name << FS << pt.description << "\n";
return ok(ss.str());
}
std::string InventoryServer::cmd_edit_part_type(const std::vector<std::string>& t) {
if (t.size() < 4) return err("usage: EDIT_PART_TYPE <id> <field> <value>");
uint32_t id = static_cast<uint32_t>(std::stoul(t[1]));
if (!db_.edit_part_type(id, t[2], t[3]))
return err("part type not found or invalid field");
return ok();
}
std::string InventoryServer::cmd_remove_part_type(const std::vector<std::string>& t) {
if (t.size() < 2) return err("usage: REMOVE_PART_TYPE <id>");
uint32_t id = static_cast<uint32_t>(std::stoul(t[1]));
if (!db_.remove_part_type(id))
return err("part type not found");
return ok();
}
// ─────────────────────────────────────────────────────────────────────────────
// Part handlers — helpers
// ─────────────────────────────────────────────────────────────────────────────
// Parse key/value pairs from tokens starting at offset `start`
static std::map<std::string,std::string> parse_kv(const std::vector<std::string>& t,
size_t start) {
std::map<std::string,std::string> kv;
for (size_t i = start; i + 1 < t.size(); i += 2)
kv[t[i]] = t[i + 1];
return kv;
}
// ─────────────────────────────────────────────────────────────────────────────
// Part handlers
// ─────────────────────────────────────────────────────────────────────────────
std::string InventoryServer::cmd_add_part(const std::vector<std::string>& t) {
// ADD_PART <type> <server_id> key1 val1 key2 val2 …
if (t.size() < 3) return err("usage: ADD_PART <type> <server_id> [key val ...]");
const std::string& type_name = t[1];
uint32_t server_id = static_cast<uint32_t>(std::stoul(t[2]));
auto kv = parse_kv(t, 3);
uint32_t pid = db_.add_part(type_name, server_id, kv);
if (pid == 0) return err("unknown part type: " + type_name);
std::string row = db_.get_part_row(pid);
return ok(row.empty() ? "" : row);
}
std::string InventoryServer::cmd_upsert_part(const std::vector<std::string>& t) {
// UPSERT_PART <type> <server_id> key1 val1 key2 val2 …
if (t.size() < 3) return err("usage: UPSERT_PART <type> <server_id> [key val ...]");
const std::string& type_name = t[1];
uint32_t server_id = static_cast<uint32_t>(std::stoul(t[2]));
auto kv = parse_kv(t, 3);
uint32_t pid = db_.upsert_part(type_name, server_id, kv);
if (pid == 0) return err("unknown part type: " + type_name);
std::string row = db_.get_part_row(pid);
return ok(row.empty() ? "" : row);
}
std::string InventoryServer::cmd_edit_part(const std::vector<std::string>& t) {
// EDIT_PART <part_id> key1 val1 key2 val2 …
if (t.size() < 4) return err("usage: EDIT_PART <part_id> key val [key val ...]");
uint32_t part_id = static_cast<uint32_t>(std::stoul(t[1]));
auto kv = parse_kv(t, 2);
if (!db_.edit_part(part_id, kv))
return err("part not found");
return ok();
}
std::string InventoryServer::cmd_remove_part(const std::vector<std::string>& t) {
if (t.size() < 2) return err("usage: REMOVE_PART <part_id>");
uint32_t part_id = static_cast<uint32_t>(std::stoul(t[1]));
if (!db_.remove_part(part_id))
return err("part not found");
return ok();
}
std::string InventoryServer::cmd_list_parts(const std::vector<std::string>& t) {
// LIST_PARTS <server_id> [type_filter]
if (t.size() < 2) return err("usage: LIST_PARTS <server_id> [type]");
uint32_t server_id = static_cast<uint32_t>(std::stoul(t[1]));
std::string type_filter = (t.size() >= 3) ? t[2] : "";
auto rows = db_.list_parts(server_id, type_filter);
std::ostringstream ss;
for (const auto& row : rows) ss << row;
return ok(ss.str());
}
std::string InventoryServer::cmd_get_part(const std::vector<std::string>& t) {
if (t.size() < 2) return err("usage: GET_PART <part_id>");
uint32_t part_id = static_cast<uint32_t>(std::stoul(t[1]));
std::string row = db_.get_part_row(part_id);
if (row.empty()) return err("part not found");
return ok(row);
}

View file

@ -0,0 +1,55 @@
#pragma once
#include <atomic>
#include <string>
#include <vector>
#include "server/database.h"
#include "server/hooks/hook_runner.h"
// ─────────────────────────────────────────────────────────────────────────────
// InventoryServer
// Listens on TCP 127.0.0.1:<port>, spawns a thread per connection.
// Each connection reads one command line, writes one response, then closes.
// ─────────────────────────────────────────────────────────────────────────────
class InventoryServer {
public:
InventoryServer(uint16_t port, std::string db_path);
~InventoryServer();
void run(); // blocking returns only when stop() is called
void stop();
HookRunner& hooks() { return hooks_; }
const Database& db() const { return db_; }
private:
uint16_t port_;
Database db_;
HookRunner hooks_;
int listen_fd_ = -1;
std::atomic<bool> running_{false};
void handle_client(int client_fd);
std::string dispatch(const std::vector<std::string>& tokens);
// ── Server handlers ───────────────────────────────────────────────────────
std::string cmd_add_server (const std::vector<std::string>& t);
std::string cmd_list_servers (const std::vector<std::string>& t);
std::string cmd_edit_server (const std::vector<std::string>& t);
std::string cmd_remove_server (const std::vector<std::string>& t);
// ── Part type handlers ────────────────────────────────────────────────────
std::string cmd_add_part_type (const std::vector<std::string>& t);
std::string cmd_list_part_types (const std::vector<std::string>& t);
std::string cmd_edit_part_type (const std::vector<std::string>& t);
std::string cmd_remove_part_type (const std::vector<std::string>& t);
// ── Part handlers ─────────────────────────────────────────────────────────
std::string cmd_add_part (const std::vector<std::string>& t);
std::string cmd_upsert_part (const std::vector<std::string>& t);
std::string cmd_edit_part (const std::vector<std::string>& t);
std::string cmd_remove_part (const std::vector<std::string>& t);
std::string cmd_list_parts (const std::vector<std::string>& t);
std::string cmd_get_part (const std::vector<std::string>& t);
};

View file

@ -0,0 +1,70 @@
#!/bin/bash
set -e
cd /Users/dan/work/homelab/services/device-inventory
lsof -ti :9876 | xargs kill -9 2>/dev/null || true
rm -f /tmp/inv2.db
./build/inventory-server --db /tmp/inv2.db --port 9876 &
SRV=$!
sleep 0.4
CLI="./build/inventory-cli"
echo "=== add server ==="
$CLI add-server "web01" "192.168.1.10" "Rack A" "Primary web server"
echo "=== list servers ==="
$CLI list-servers
echo "=== add part types ==="
$CLI add-part-type "cpu" "CPU processors"
$CLI add-part-type "nic" "Network interface cards"
$CLI add-part-type "disk" "Storage disks"
echo "=== list part types ==="
$CLI list-part-types
echo "=== add cpu ==="
$CLI add-part cpu 1 name="Intel Xeon E5-2690" manufacturer=Intel speed_ghz=3.2 cores=8 threads=16
echo "=== add nic (multiple IPs) ==="
$CLI add-part nic 1 manufacturer=Intel model="I210" connection_type=ethernet \
connection_speed_mbps=1000 mac_address=aa:bb:cc:dd:ee:ff \
ip_addresses=192.168.1.10/24 ip_addresses=10.0.0.1/24 dhcp=false
echo "=== add disk ==="
$CLI add-part disk 1 manufacturer=Samsung model="870 EVO" disk_type=ssd \
connection_type=SATA disk_size_gb=500 serial=S4EVNX0R123456 \
partition_ids=sda1 partition_ids=sda2 partition_sizes_gb=50 partition_sizes_gb=450
echo "=== list all parts ==="
$CLI list-parts 1
echo "=== list only cpu ==="
$CLI list-parts 1 --type cpu
echo "=== edit part 1 (cpu speed) ==="
$CLI edit-part 1 speed_ghz=3.5
echo "=== get part 1 ==="
$CLI get-part 1
echo "=== edit server ==="
$CLI edit-server 1 hostname "10.0.0.100"
echo "=== remove nic (part 2) ==="
$CLI remove-part 2
echo "=== list parts after remove ==="
$CLI list-parts 1
echo "=== discover dry-run ==="
$CLI discover 1 --dry-run 2>&1 | head -40
echo ""
echo "=== DB file ==="
cat /tmp/inv2.db
kill $SRV 2>/dev/null
echo ""
echo "=== DONE ==="

View file

@ -0,0 +1,11 @@
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY main.go .
RUN go mod init inventory-web-ui && \
go build -o inventory-web-ui .
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /src/inventory-web-ui /usr/local/bin/
EXPOSE 8080
CMD ["inventory-web-ui"]

View file

@ -0,0 +1,437 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"strings"
"time"
)
const (
fieldSep = "\x01"
listSep = "\x02"
)
var serverAddr string
func init() {
host := os.Getenv("INVENTORY_HOST")
if host == "" {
host = "inventory-server"
}
port := os.Getenv("INVENTORY_PORT")
if port == "" {
port = "9876"
}
serverAddr = host + ":" + port
}
// sendCommand sends a protocol command and returns the response rows.
func sendCommand(tokens []string) ([][]string, error) {
conn, err := net.DialTimeout("tcp", serverAddr, 5*time.Second)
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", serverAddr, err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(15 * time.Second))
line := strings.Join(tokens, fieldSep) + "\n"
if _, err := conn.Write([]byte(line)); err != nil {
return nil, fmt.Errorf("write: %w", err)
}
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
if !scanner.Scan() {
return nil, fmt.Errorf("no response from server")
}
status := scanner.Text()
if strings.HasPrefix(status, "ERR") {
msg := ""
if len(status) > 4 {
msg = status[4:]
}
return nil, fmt.Errorf("server: %s", msg)
}
if status != "OK" {
return nil, fmt.Errorf("unexpected status: %s", status)
}
var rows [][]string
for scanner.Scan() {
row := scanner.Text()
if row == "END" {
break
}
rows = append(rows, strings.Split(row, fieldSep))
}
return rows, scanner.Err()
}
// ── Domain types ─────────────────────────────────────────────────────────────
type Server struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Location string `json:"location"`
Description string `json:"description"`
}
type PartType struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
type Part struct {
ID string `json:"id"`
Type string `json:"type"`
ServerID string `json:"server_id"`
Serial string `json:"serial"`
LastUpdated int64 `json:"last_updated"`
Fields map[string]string `json:"fields"`
}
// ── Query helpers ─────────────────────────────────────────────────────────────
func listServers() ([]Server, error) {
rows, err := sendCommand([]string{"LIST_SERVERS"})
if err != nil {
return nil, err
}
var out []Server
for _, f := range rows {
if len(f) < 5 {
continue
}
out = append(out, Server{ID: f[0], Name: f[1], Hostname: f[2], Location: f[3], Description: f[4]})
}
return out, nil
}
func listPartTypes() ([]PartType, error) {
rows, err := sendCommand([]string{"LIST_PART_TYPES"})
if err != nil {
return nil, err
}
var out []PartType
for _, f := range rows {
if len(f) < 3 {
continue
}
out = append(out, PartType{ID: f[0], Name: f[1], Description: f[2]})
}
return out, nil
}
func listParts(serverID string) ([]Part, error) {
rows, err := sendCommand([]string{"LIST_PARTS", serverID})
if err != nil {
return nil, err
}
var out []Part
for _, f := range rows {
if len(f) < 5 {
continue
}
fields := make(map[string]string)
for i := 5; i+1 < len(f); i += 2 {
val := strings.ReplaceAll(f[i+1], listSep, ", ")
fields[f[i]] = val
}
var ts int64
fmt.Sscanf(f[4], "%d", &ts)
out = append(out, Part{Type: f[0], ID: f[1], ServerID: f[2], Serial: f[3], LastUpdated: ts, Fields: fields})
}
return out, nil
}
// ── HTTP handlers ─────────────────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
mux.HandleFunc("/api/servers", func(w http.ResponseWriter, r *http.Request) {
servers, err := listServers()
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if servers == nil {
servers = []Server{}
}
writeJSON(w, servers)
})
mux.HandleFunc("/api/part-types", func(w http.ResponseWriter, r *http.Request) {
pts, err := listPartTypes()
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if pts == nil {
pts = []PartType{}
}
writeJSON(w, pts)
})
// /api/servers/{id}/parts
mux.HandleFunc("/api/servers/", func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
// parts = ["api", "servers", "{id}", "parts"]
if len(parts) != 4 || parts[3] != "parts" {
http.NotFound(w, r)
return
}
serverID := parts[2]
plist, err := listParts(serverID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if plist == nil {
plist = []Part{}
}
writeJSON(w, plist)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, dashboardHTML)
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("inventory-web-ui listening on :%s, backend: %s", port, serverAddr)
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}
// ── Embedded dashboard ────────────────────────────────────────────────────────
const dashboardHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Inventory</title>
<style>
:root {
--bg: #0f1117; --surface: #1a1d27; --surface2: #23263a;
--border: #2e3250; --accent: #6c8bef; --accent2: #a78bfa;
--text: #e2e8f0; --muted: #8892aa; --danger: #f87171;
--cpu: #f97316; --mem: #22c55e; --disk: #3b82f6; --nic: #a855f7;
--slot: #64748b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; }
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 14px 24px; display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 1.25rem; font-weight: 600; color: var(--text); }
header span.badge { background: var(--accent); color: #fff; font-size: 0.7rem; padding: 2px 8px; border-radius: 12px; font-weight: 700; letter-spacing: .05em; }
.layout { display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 53px); }
.sidebar { background: var(--surface); border-right: 1px solid var(--border); overflow-y: auto; padding: 16px 0; }
.sidebar h2 { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); padding: 0 16px 10px; }
.server-item { padding: 10px 16px; cursor: pointer; border-left: 3px solid transparent; transition: background .15s, border-color .15s; }
.server-item:hover { background: var(--surface2); }
.server-item.active { background: var(--surface2); border-left-color: var(--accent); }
.server-item .sname { font-weight: 600; font-size: 0.9rem; }
.server-item .shost { font-size: 0.75rem; color: var(--muted); margin-top: 2px; }
.server-item .sloc { font-size: 0.7rem; color: var(--muted); margin-top: 1px; }
.main { overflow-y: auto; padding: 24px; }
.main-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--muted); font-size: 0.95rem; }
.server-header { margin-bottom: 20px; }
.server-header h2 { font-size: 1.4rem; font-weight: 700; }
.server-header p { color: var(--muted); font-size: 0.85rem; margin-top: 4px; }
.part-group { margin-bottom: 28px; }
.part-group-title { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot-cpu { background: var(--cpu); }
.dot-mem { background: var(--mem); }
.dot-disk { background: var(--disk); }
.dot-nic { background: var(--nic); }
.dot-slot { background: var(--slot); }
.parts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
.part-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px; }
.part-card .part-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
.part-card .part-id { font-size: 0.7rem; color: var(--muted); }
.part-card .part-serial { font-size: 0.75rem; color: var(--muted); margin-top: 2px; margin-bottom: 10px; }
.part-card .field { display: flex; justify-content: space-between; font-size: 0.78rem; padding: 4px 0; border-bottom: 1px solid var(--border); gap: 8px; }
.part-card .field:last-child { border-bottom: none; }
.part-card .fk { color: var(--muted); flex-shrink: 0; }
.part-card .fv { color: var(--text); text-align: right; word-break: break-word; }
.part-card .updated { font-size: 0.7rem; color: var(--muted); margin-top: 8px; text-align: right; }
.loading { color: var(--muted); font-size: 0.9rem; }
.error-msg { color: var(--danger); font-size: 0.85rem; background: rgba(248,113,113,.1); border: 1px solid rgba(248,113,113,.3); border-radius: 6px; padding: 10px 14px; }
.count { font-size: 0.7rem; color: var(--muted); background: var(--surface2); border-radius: 12px; padding: 1px 7px; }
@media(max-width:700px){ .layout{grid-template-columns:1fr} .sidebar{height:auto;max-height:200px} }
</style>
</head>
<body>
<header>
<h1>Device Inventory</h1>
<span class="badge">HOMELAB</span>
<span id="status" style="margin-left:auto;font-size:.75rem;color:var(--muted)"></span>
</header>
<div class="layout">
<nav class="sidebar">
<h2>Servers</h2>
<div id="server-list"><p style="padding:12px 16px;color:var(--muted);font-size:.85rem">Loading</p></div>
</nav>
<main class="main" id="main">
<div class="main-empty"> Select a server to view its hardware inventory</div>
</main>
</div>
<script>
const TYPE_COLORS = {
cpu:'cpu', cpu_slot:'slot',
memory_stick:'mem', memory_slot:'slot',
disk:'disk', nic:'nic',
};
const TYPE_LABELS = {
cpu:'CPUs', cpu_slot:'CPU Slots',
memory_stick:'Memory Sticks', memory_slot:'Memory Slots',
disk:'Disks', nic:'Network Cards',
};
const TYPE_ORDER = ['cpu','cpu_slot','memory_stick','memory_slot','disk','nic'];
let currentServer = null;
function fmtTime(ts) {
if (!ts) return 'unknown';
return new Date(ts * 1000).toLocaleString(undefined, {year:'numeric',month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
}
function labelFor(k) {
const map = {
speed_mhz:'Speed (MHz)', size_mb:'Size (MB)', manufacturer:'Manufacturer',
channel_config:'Channel Config', other_info:'Other Info',
allowed_speed_mhz:'Max Speed (MHz)', allowed_size_mb:'Max Size (MB)', installed_stick_id:'Installed Stick',
name:'Name', speed_ghz:'Speed (GHz)', cores:'Cores', threads:'Threads',
form_factor:'Form Factor', installed_cpu_id:'Installed CPU',
model:'Model', generation:'Generation', connection_type:'Connection', connection_speed_mbps:'Conn Speed (Mbps)',
disk_speed_mbps:'Disk Speed (Mbps)', disk_size_gb:'Size (GB)', disk_type:'Type',
age_years:'Age (years)', partition_ids:'Partitions', partition_sizes_gb:'Partition Sizes (GB)',
vm_hostnames:'Used by VMs', vm_server_ids:'VM Server IDs',
mac_address:'MAC', ip_addresses:'IP Addresses', dhcp:'DHCP',
};
return map[k] || k.replace(/_/g,' ');
}
async function loadServers() {
try {
const res = await fetch('/api/servers');
if (!res.ok) throw new Error(await res.text());
const servers = await res.json();
document.getElementById('status').textContent = servers.length + ' server' + (servers.length!==1?'s':'');
const list = document.getElementById('server-list');
if (!servers.length) {
list.innerHTML = '<p style="padding:12px 16px;color:var(--muted);font-size:.85rem">No servers registered yet.</p>';
return;
}
list.innerHTML = servers.map(s =>
'<div class="server-item" data-id="'+s.id+'" onclick="selectServer('+JSON.stringify(s).replace(/"/g,'&quot;')+')">'
+ '<div class="sname">'+esc(s.name)+'</div>'
+ '<div class="shost">'+esc(s.hostname)+'</div>'
+ '<div class="sloc">'+esc(s.location)+'</div>'
+ '</div>'
).join('');
} catch(e) {
document.getElementById('server-list').innerHTML =
'<p style="padding:12px 16px;color:var(--danger);font-size:.8rem">'+esc(String(e))+'</p>';
}
}
async function selectServer(server) {
currentServer = server.id;
document.querySelectorAll('.server-item').forEach(el => {
el.classList.toggle('active', el.dataset.id === server.id);
});
const main = document.getElementById('main');
main.innerHTML = '<div class="loading">Loading hardware</div>';
try {
const res = await fetch('/api/servers/'+server.id+'/parts');
if (!res.ok) throw new Error(await res.text());
const parts = await res.json();
renderParts(main, server, parts);
} catch(e) {
main.innerHTML = '<div class="error-msg">'+esc(String(e))+'</div>';
}
}
function renderParts(main, server, parts) {
const byType = {};
for (const p of parts) { (byType[p.type] = byType[p.type]||[]).push(p); }
const types = TYPE_ORDER.filter(t => byType[t]);
const extra = Object.keys(byType).filter(t => !TYPE_ORDER.includes(t));
let html = '<div class="server-header">'
+ '<h2>'+esc(server.name)+'</h2>'
+ '<p>'+esc(server.hostname)+' &nbsp;·&nbsp; '+esc(server.location)+'</p>'
+ (server.description ? '<p style="margin-top:4px;font-size:.8rem;color:var(--muted)">'+esc(server.description)+'</p>' : '')
+ '</div>';
if (!parts.length) {
html += '<p style="color:var(--muted);font-size:.9rem">No parts discovered yet. Run the daily CronJob or use the CLI to add parts.</p>';
main.innerHTML = html;
return;
}
for (const type of [...types, ...extra]) {
const color = TYPE_COLORS[type] || 'slot';
const label = TYPE_LABELS[type] || type;
html += '<div class="part-group"><div class="part-group-title"><span class="dot dot-'+color+'"></span>'
+ label + ' <span class="count">'+byType[type].length+'</span></div>'
+ '<div class="parts-grid">';
for (const p of byType[type]) {
html += '<div class="part-card">'
+ '<div class="part-header"><span class="part-id">#'+esc(p.id)+'</span></div>';
if (p.serial && p.serial !== 'NULL') {
html += '<div class="part-serial">S/N: '+esc(p.serial)+'</div>';
}
const skip = new Set(['serial','last_updated']);
for (const [k,v] of Object.entries(p.fields||{})) {
if (skip.has(k) || !v || v==='NULL' || v==='0') continue;
html += '<div class="field"><span class="fk">'+esc(labelFor(k))+'</span><span class="fv">'+esc(v)+'</span></div>';
}
if (p.last_updated) {
html += '<div class="updated">Updated '+fmtTime(p.last_updated)+'</div>';
}
html += '</div>';
}
html += '</div></div>';
}
main.innerHTML = html;
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
loadServers();
setInterval(loadServers, 60000);
</script>
</body>
</html>`