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:
commit
e1a482c500
25 changed files with 4597 additions and 0 deletions
33
services/device-inventory/CMakeLists.txt
Normal file
33
services/device-inventory/CMakeLists.txt
Normal 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
|
||||||
|
)
|
||||||
25
services/device-inventory/Dockerfile
Normal file
25
services/device-inventory/Dockerfile
Normal 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"]
|
||||||
15
services/device-inventory/Dockerfile.cli
Normal file
15
services/device-inventory/Dockerfile.cli
Normal 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"]
|
||||||
138
services/device-inventory/README.md
Normal file
138
services/device-inventory/README.md
Normal 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
|
||||||
|
```
|
||||||
34
services/device-inventory/build-and-load.sh
Executable file
34
services/device-inventory/build-and-load.sh
Executable 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"
|
||||||
19
services/device-inventory/instructions.md
Normal file
19
services/device-inventory/instructions.md
Normal 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.
|
||||||
218
services/device-inventory/src/client/client.cpp
Normal file
218
services/device-inventory/src/client/client.cpp
Normal 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";
|
||||||
|
}
|
||||||
27
services/device-inventory/src/client/client.h
Normal file
27
services/device-inventory/src/client/client.h
Normal 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);
|
||||||
1127
services/device-inventory/src/client/discovery.cpp
Normal file
1127
services/device-inventory/src/client/discovery.cpp
Normal file
File diff suppressed because it is too large
Load diff
41
services/device-inventory/src/client/discovery.h
Normal file
41
services/device-inventory/src/client/discovery.h
Normal 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);
|
||||||
|
};
|
||||||
286
services/device-inventory/src/client/main.cpp
Normal file
286
services/device-inventory/src/client/main.cpp
Normal 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;
|
||||||
|
}
|
||||||
131
services/device-inventory/src/common/models.h
Normal file
131
services/device-inventory/src/common/models.h
Normal 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";
|
||||||
166
services/device-inventory/src/common/protocol.h
Normal file
166
services/device-inventory/src/common/protocol.h
Normal 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;
|
||||||
|
}
|
||||||
915
services/device-inventory/src/server/database.cpp
Normal file
915
services/device-inventory/src/server/database.cpp
Normal 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;
|
||||||
|
}
|
||||||
98
services/device-inventory/src/server/database.h
Normal file
98
services/device-inventory/src/server/database.h
Normal 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;
|
||||||
|
};
|
||||||
257
services/device-inventory/src/server/hooks/dns_updater_hook.cpp
Normal file
257
services/device-inventory/src/server/hooks/dns_updater_hook.cpp
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
25
services/device-inventory/src/server/hooks/hook.h
Normal file
25
services/device-inventory/src/server/hooks/hook.h
Normal 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;
|
||||||
|
};
|
||||||
55
services/device-inventory/src/server/hooks/hook_runner.h
Normal file
55
services/device-inventory/src/server/hooks/hook_runner.h
Normal 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_;
|
||||||
|
};
|
||||||
57
services/device-inventory/src/server/main.cpp
Normal file
57
services/device-inventory/src/server/main.cpp
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
278
services/device-inventory/src/server/server.cpp
Normal file
278
services/device-inventory/src/server/server.cpp
Normal 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);
|
||||||
|
}
|
||||||
55
services/device-inventory/src/server/server.h
Normal file
55
services/device-inventory/src/server/server.h
Normal 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);
|
||||||
|
};
|
||||||
|
|
||||||
70
services/device-inventory/src/test/inv_test.sh
Executable file
70
services/device-inventory/src/test/inv_test.sh
Executable 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 ==="
|
||||||
11
services/device-inventory/web-ui/Dockerfile
Normal file
11
services/device-inventory/web-ui/Dockerfile
Normal 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"]
|
||||||
437
services/device-inventory/web-ui/main.go
Normal file
437
services/device-inventory/web-ui/main.go
Normal 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,'"')+')">'
|
||||||
|
+ '<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)+' · '+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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadServers();
|
||||||
|
setInterval(loadServers, 60000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
Loading…
Add table
Reference in a new issue