docs: add Fleet Inventory and SaaS auth/orgs specs + plans
This commit is contained in:
@@ -0,0 +1,653 @@
|
||||
# Fleet Inventory Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Agents collect CPU/RAM/swap/disk/partition inventory and report it to the server via a new `ReportInventory` RPC; the server stores the latest snapshot per server and the UI displays it.
|
||||
|
||||
**Architecture:** New unary gRPC `ReportInventory` (mirrors existing `ReportUpdates`). Agent runs a 30s metrics ticker (CPU/RAM/swap usage) and, every 15 min, a full static collection (disks, partitions, CPU model, kernel). Server upserts an embedded `inventory` sub-doc on the `servers` document with merge rules that preserve static fields between slow ticks.
|
||||
|
||||
**Tech Stack:** Go (gin, mongo-driver v2, hand-written JSON-codec gRPC), `/proc` readers, Next.js 16 + react-query + Tailwind.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **No tests this iteration.** Verify with `go build ./...`, `go vet ./...`, `npm run build`.
|
||||
- gRPC uses a JSON codec: edit **both** `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go` identically, plus `proto/vantage/v1/vantage.proto` as documentation. No codegen. Mirror the existing `ReportUpdates` RPC wiring exactly (service interface, `_Vantage_*_Handler`, client method, `Vantage_ServiceDesc`).
|
||||
- Mongo: `db.Col("servers")`, `context.WithTimeout`. Follow `server/internal/services/servers.go`.
|
||||
- Agent already runs as root; `/proc` is readable. Linux is primary; Windows collectors may return empty.
|
||||
- Module path `github.com/mrhid6/vantage`.
|
||||
- Do not add heavy dependencies; implement `/proc` parsing directly.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Inventory model + gRPC messages
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/models/server.go`
|
||||
- Modify: `proto/vantage/v1/vantage.proto`
|
||||
- Modify: `server/internal/grpc/pb/vantage.pb.go`
|
||||
- Modify: `agent/internal/grpc/pb/vantage.pb.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `models.Inventory` (+ `CPUInfo`, `MemInfo`, `Partition`) and `Server.Inventory *Inventory`. pb structs `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse`. Service method `ReportInventory` on both client and server interfaces.
|
||||
|
||||
- [ ] **Step 1: Add model structs**
|
||||
|
||||
In `server/internal/models/server.go` add (keep the existing `import "time"`):
|
||||
|
||||
```go
|
||||
type CPUInfo struct {
|
||||
Model string `bson:"model,omitempty" json:"model,omitempty"`
|
||||
Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
|
||||
UsagePct float64 `bson:"usage_pct" json:"usage_pct"`
|
||||
Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
|
||||
}
|
||||
|
||||
type MemInfo struct {
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
|
||||
}
|
||||
|
||||
type Partition struct {
|
||||
Device string `bson:"device" json:"device"`
|
||||
Mountpoint string `bson:"mountpoint" json:"mountpoint"`
|
||||
Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
|
||||
}
|
||||
|
||||
type Inventory struct {
|
||||
CPU CPUInfo `bson:"cpu" json:"cpu"`
|
||||
Memory MemInfo `bson:"memory" json:"memory"`
|
||||
SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
|
||||
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
|
||||
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
|
||||
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
|
||||
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
|
||||
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Add to the `Server` struct: `Inventory *Inventory \`bson:"inventory,omitempty" json:"inventory,omitempty"\``.
|
||||
|
||||
- [ ] **Step 2: Document RPC in proto**
|
||||
|
||||
In `proto/vantage/v1/vantage.proto`, add to the service: `rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);` and the messages `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse` per spec §4.
|
||||
|
||||
- [ ] **Step 3: Add pb structs + RPC wiring (server pb)**
|
||||
|
||||
In `server/internal/grpc/pb/vantage.pb.go` add the message structs:
|
||||
|
||||
```go
|
||||
type CPUReport struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
UsagePct float64 `json:"usage_pct"`
|
||||
Load1 float64 `json:"load1,omitempty"`
|
||||
}
|
||||
type MemReport struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type PartitionReport struct {
|
||||
Device string `json:"device"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Fstype string `json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type InventoryReport struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
IncludeStatic bool `json:"include_static"`
|
||||
CPU *CPUReport `json:"cpu,omitempty"`
|
||||
Memory *MemReport `json:"memory,omitempty"`
|
||||
SwapTotal uint64 `json:"swap_total"`
|
||||
SwapUsed uint64 `json:"swap_used"`
|
||||
Partitions []PartitionReport `json:"partitions,omitempty"`
|
||||
Kernel string `json:"kernel,omitempty"`
|
||||
}
|
||||
type InventoryReportResponse struct{}
|
||||
```
|
||||
|
||||
Then mirror the `ReportUpdates` RPC plumbing for `ReportInventory`. Locate every `ReportUpdates` reference in this file and add the parallel `ReportInventory`:
|
||||
- `VantageServer` interface: add `ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)`.
|
||||
- `UnimplementedVantageServer`: add the stub returning `Unimplemented`.
|
||||
- `VantageClient` interface + `keyManagerClient`: add the client method `Invoke`-ing `/vantage.v1.Vantage/ReportInventory`.
|
||||
- `Vantage_ServiceDesc.Methods`: add `{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler}`.
|
||||
- Add `_Vantage_ReportInventory_Handler` copied from `_Vantage_ReportUpdates_Handler` with types swapped.
|
||||
|
||||
- [ ] **Step 4: Mirror pb structs + wiring (agent pb)**
|
||||
|
||||
Apply the identical additions to `agent/internal/grpc/pb/vantage.pb.go`.
|
||||
|
||||
- [ ] **Step 5: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && cd ../agent && go build ./...`
|
||||
Expected: both succeed.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/models/server.go proto/vantage/v1/vantage.proto server/internal/grpc/pb/vantage.pb.go agent/internal/grpc/pb/vantage.pb.go
|
||||
git commit -m "feat(proto): add ReportInventory RPC and inventory model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Server handler + store service
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/services/inventory.go`
|
||||
- Modify: `server/internal/grpc/server.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pb.InventoryReport` (T1), `db.Col("servers")`.
|
||||
- Produces: `services.StoreInventory(serverID string, r *pb.InventoryReport) error`; gRPC method `(*vantageServer).ReportInventory`.
|
||||
|
||||
- [ ] **Step 1: Write the store service**
|
||||
|
||||
```go
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// StoreInventory upserts the latest inventory snapshot onto the server document.
|
||||
// Metrics fields update every call; static fields only when r.IncludeStatic.
|
||||
func StoreInventory(serverID string, r *pb.InventoryReport) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
set := bson.M{"inventory.metrics_at": now}
|
||||
if r.CPU != nil {
|
||||
set["inventory.cpu.usage_pct"] = r.CPU.UsagePct
|
||||
set["inventory.cpu.load1"] = r.CPU.Load1
|
||||
}
|
||||
if r.Memory != nil {
|
||||
set["inventory.memory.used_bytes"] = r.Memory.UsedBytes
|
||||
}
|
||||
set["inventory.swap_used_bytes"] = r.SwapUsed
|
||||
|
||||
if r.IncludeStatic {
|
||||
set["inventory.static_at"] = now
|
||||
set["inventory.swap_total_bytes"] = r.SwapTotal
|
||||
set["inventory.kernel"] = r.Kernel
|
||||
if r.CPU != nil {
|
||||
set["inventory.cpu.model"] = r.CPU.Model
|
||||
set["inventory.cpu.cores"] = r.CPU.Cores
|
||||
}
|
||||
if r.Memory != nil {
|
||||
set["inventory.memory.total_bytes"] = r.Memory.TotalBytes
|
||||
}
|
||||
parts := make([]bson.M, 0, len(r.Partitions))
|
||||
for _, p := range r.Partitions {
|
||||
parts = append(parts, bson.M{
|
||||
"device": p.Device, "mountpoint": p.Mountpoint, "fstype": p.Fstype,
|
||||
"total_bytes": p.TotalBytes, "used_bytes": p.UsedBytes,
|
||||
})
|
||||
}
|
||||
set["inventory.partitions"] = parts
|
||||
}
|
||||
|
||||
_, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, bson.M{"$set": set})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the gRPC handler**
|
||||
|
||||
In `server/internal/grpc/server.go`, add (mirroring the existing `ReportUpdates` handler that validates the agent token):
|
||||
|
||||
```go
|
||||
func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
if err := services.StoreInventory(srv.ServerID, req); err != nil {
|
||||
log.Printf("store inventory for %s: %v", srv.ServerID, err)
|
||||
}
|
||||
return &pb.InventoryReportResponse{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Confirm `status`, `codes`, `log` are already imported in the file (they are, used by other handlers).
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/services/inventory.go server/internal/grpc/server.go
|
||||
git commit -m "feat(server): store inventory and handle ReportInventory RPC"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Agent collectors
|
||||
|
||||
**Files:**
|
||||
- Create: `agent/internal/inventory/collect_linux.go`
|
||||
- Create: `agent/internal/inventory/collect_other.go`
|
||||
- Create: `agent/internal/inventory/inventory.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `inventory.Collect(includeStatic bool) *pb.InventoryReport`.
|
||||
|
||||
- [ ] **Step 1: Common entry (`inventory.go`)**
|
||||
|
||||
```go
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
// Collect gathers metrics always and static hardware info when includeStatic.
|
||||
// Platform specifics are provided by collect_linux.go / collect_other.go.
|
||||
func Collect(includeStatic bool) *pb.InventoryReport {
|
||||
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
|
||||
collect(r, includeStatic)
|
||||
return r
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Linux collector (`collect_linux.go`)**
|
||||
|
||||
Build-tagged `//go:build linux`. Implement `collect(r *pb.InventoryReport, includeStatic bool)`:
|
||||
- CPU usage: read `/proc/stat` first line twice ~100ms apart, compute `1 - idleDelta/totalDelta` × 100 → `r.CPU.UsagePct`.
|
||||
- Load: first field of `/proc/loadavg` → `r.CPU.Load1`.
|
||||
- Mem/swap: parse `/proc/meminfo` (`MemTotal`, `MemAvailable`, `SwapTotal`, `SwapFree`; used = total − available; swap used = swaptotal − swapfree) → `r.Memory.*`, `r.SwapUsed`, and on static `r.SwapTotal`.
|
||||
- Static only: `/proc/cpuinfo` (`model name`, count `processor` lines) → `r.CPU.Model/Cores`; `/proc/meminfo MemTotal` → `r.Memory.TotalBytes`; kernel via `syscall.Uname` or read `/proc/sys/kernel/osrelease` → `r.Kernel`; partitions from `/proc/mounts` filtered to fstypes in {ext4,xfs,btrfs,zfs,vfat,ntfs} then `syscall.Statfs` for total/used → `r.Partitions`.
|
||||
|
||||
```go
|
||||
//go:build linux
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
r.CPU.UsagePct = cpuUsage()
|
||||
r.CPU.Load1 = load1()
|
||||
memTotal, memAvail, swapTotal, swapFree := meminfo()
|
||||
if memTotal > memAvail {
|
||||
r.Memory.UsedBytes = memTotal - memAvail
|
||||
}
|
||||
if swapTotal > swapFree {
|
||||
r.SwapUsed = swapTotal - swapFree
|
||||
}
|
||||
if includeStatic {
|
||||
r.Memory.TotalBytes = memTotal
|
||||
r.SwapTotal = swapTotal
|
||||
r.CPU.Model, r.CPU.Cores = cpuStatic()
|
||||
r.Kernel = kernel()
|
||||
r.Partitions = partitions()
|
||||
}
|
||||
}
|
||||
|
||||
func readProc(path string) string { b, _ := os.ReadFile(path); return string(b) }
|
||||
|
||||
func cpuSample() (idle, total uint64) {
|
||||
f, err := os.Open("/proc/stat")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
if sc.Scan() {
|
||||
fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ...
|
||||
for i, v := range fields[1:] {
|
||||
n, _ := strconv.ParseUint(v, 10, 64)
|
||||
total += n
|
||||
if i == 3 { // idle
|
||||
idle = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func cpuUsage() float64 {
|
||||
i1, t1 := cpuSample()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
i2, t2 := cpuSample()
|
||||
dt := float64(t2 - t1)
|
||||
if dt <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (1 - float64(i2-i1)/dt) * 100
|
||||
}
|
||||
|
||||
func load1() float64 {
|
||||
fields := strings.Fields(readProc("/proc/loadavg"))
|
||||
if len(fields) > 0 {
|
||||
v, _ := strconv.ParseFloat(fields[0], 64)
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func meminfo() (total, avail, swapTotal, swapFree uint64) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
kb, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
b := kb * 1024
|
||||
switch strings.TrimSuffix(fields[0], ":") {
|
||||
case "MemTotal":
|
||||
total = b
|
||||
case "MemAvailable":
|
||||
avail = b
|
||||
case "SwapTotal":
|
||||
swapTotal = b
|
||||
case "SwapFree":
|
||||
swapFree = b
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func cpuStatic() (model string, cores int) {
|
||||
f, err := os.Open("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, "processor") {
|
||||
cores++
|
||||
} else if strings.HasPrefix(line, "model name") && model == "" {
|
||||
if i := strings.Index(line, ":"); i >= 0 {
|
||||
model = strings.TrimSpace(line[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func kernel() string {
|
||||
return strings.TrimSpace(readProc("/proc/sys/kernel/osrelease"))
|
||||
}
|
||||
|
||||
func partitions() []pb.PartitionReport {
|
||||
allowed := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "vfat": true, "ntfs": true, "ext3": true}
|
||||
f, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
var out []pb.PartitionReport
|
||||
seen := map[string]bool{}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 3 || !allowed[fields[2]] || seen[fields[1]] {
|
||||
continue
|
||||
}
|
||||
seen[fields[1]] = true
|
||||
var st syscall.Statfs_t
|
||||
if syscall.Statfs(fields[1], &st) != nil {
|
||||
continue
|
||||
}
|
||||
total := st.Blocks * uint64(st.Bsize)
|
||||
free := st.Bavail * uint64(st.Bsize)
|
||||
out = append(out, pb.PartitionReport{
|
||||
Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
|
||||
TotalBytes: total, UsedBytes: total - free,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Non-linux stub (`collect_other.go`)**
|
||||
|
||||
```go
|
||||
//go:build !linux
|
||||
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
// collect is a no-op best-effort stub on non-Linux platforms.
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd agent && go build ./... && go vet ./...`
|
||||
Expected: success (build both native and, if convenient, `GOOS=windows go build ./...`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add agent/internal/inventory/
|
||||
git commit -m "feat(agent): /proc-based inventory collectors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Agent client method + scheduler
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/internal/grpc/client.go`
|
||||
- Modify: the agent main loop (`agent/cmd/main.go` or `agent/internal/sync/sync.go` — wherever the poll loop/tickers live).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `inventory.Collect` (T3), pb (T1).
|
||||
- Produces: `(*Client).ReportInventory(report *pb.InventoryReport) error`; a running ticker that reports metrics every 30s and static every 15 min.
|
||||
|
||||
- [ ] **Step 1: Add client method**
|
||||
|
||||
In `agent/internal/grpc/client.go`, mirroring `ReportUpdates`:
|
||||
|
||||
```go
|
||||
func (c *Client) ReportInventory(report *pb.InventoryReport) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, err := c.client.ReportInventory(ctx, report)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
The report already carries `ServerId`/`AgentToken`; ensure the caller sets them (see Step 2).
|
||||
|
||||
- [ ] **Step 2: Add the scheduler to the agent loop**
|
||||
|
||||
Find where the agent starts its poll loop (the goroutine that calls `SyncKeys`/`ReportUpdates`). Add a parallel inventory ticker. `serverID`, `agentToken`, and the `*Client` are in scope there:
|
||||
|
||||
```go
|
||||
go func() {
|
||||
tick := 0
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
report := func(static bool) {
|
||||
r := inventory.Collect(static)
|
||||
r.ServerId = serverID
|
||||
r.AgentToken = agentToken
|
||||
if err := client.ReportInventory(r); err != nil {
|
||||
log.Printf("report inventory: %v", err)
|
||||
}
|
||||
}
|
||||
report(true) // send a full snapshot on startup
|
||||
for range t.C {
|
||||
tick++
|
||||
report(tick%30 == 0) // every 30th tick = 15 min → include static
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
Add imports `"github.com/mrhid6/vantage/agent/internal/inventory"`, `time`, `log` if missing. Match variable names to the actual loop (e.g. the client may be named `c`).
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd agent && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add agent/internal/grpc/client.go agent/
|
||||
git commit -m "feat(agent): schedule inventory reporting (30s metrics, 15m static)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Frontend — inventory panel on server detail
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api.ts` (extend the `Server`/server-detail type with `inventory`)
|
||||
- Modify: `web/app/servers/[id]/page.tsx` (add panel; enable polling)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: server-detail query.
|
||||
|
||||
- [ ] **Step 1: Add the inventory type**
|
||||
|
||||
In `web/lib/api.ts`, add and attach to the server type used by the detail page:
|
||||
|
||||
```ts
|
||||
export interface Inventory {
|
||||
cpu: { model?: string; cores?: number; usage_pct: number; load1?: number };
|
||||
memory: { total_bytes: number; used_bytes: number };
|
||||
swap_total_bytes: number;
|
||||
swap_used_bytes: number;
|
||||
partitions?: { device: string; mountpoint: string; fstype?: string; total_bytes: number; used_bytes: number }[];
|
||||
kernel?: string;
|
||||
metrics_at?: string;
|
||||
static_at?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Add `inventory?: Inventory;` to the server detail interface.
|
||||
|
||||
- [ ] **Step 2: Add a `formatBytes` helper + Inventory panel**
|
||||
|
||||
In `web/app/servers/[id]/page.tsx`, add a helper and a panel component. Enable polling on the server-detail `useQuery` with `refetchInterval: 30000`.
|
||||
|
||||
```tsx
|
||||
function formatBytes(n: number): string {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(n) / Math.log(1024));
|
||||
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
|
||||
}
|
||||
|
||||
function UsageBar({ used, total }: { used: number; total: number }) {
|
||||
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
|
||||
return (
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-2">
|
||||
<div className={`h-full rounded-full ${pct > 90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryPanel({ inv }: { inv: Inventory }) {
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-text-primary">Inventory</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">CPU</span><span className="text-text-primary">{inv.cpu.usage_pct.toFixed(0)}%</span></div>
|
||||
<UsageBar used={inv.cpu.usage_pct} total={100} />
|
||||
<p className="mt-1 text-xs text-text-secondary">{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">Memory</span><span className="text-text-primary">{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}</span></div>
|
||||
<UsageBar used={inv.memory.used_bytes} total={inv.memory.total_bytes} />
|
||||
<div className="mb-1 mt-3 flex justify-between text-sm"><span className="text-text-secondary">Swap</span><span className="text-text-primary">{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}</span></div>
|
||||
<UsageBar used={inv.swap_used_bytes} total={inv.swap_total_bytes} />
|
||||
</div>
|
||||
</div>
|
||||
{inv.partitions && inv.partitions.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-medium text-text-secondary">Partitions</h3>
|
||||
<div className="space-y-3">
|
||||
{inv.partitions.map((p) => (
|
||||
<div key={p.mountpoint}>
|
||||
<div className="mb-1 flex justify-between text-xs">
|
||||
<span className="font-mono text-text-primary">{p.mountpoint}</span>
|
||||
<span className="text-text-secondary">{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}</span>
|
||||
</div>
|
||||
<UsageBar used={p.used_bytes} total={p.total_bytes} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{inv.kernel && <p className="mt-4 text-xs text-text-secondary">Kernel {inv.kernel}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Render `{server.inventory && <InventoryPanel inv={server.inventory} />}` in the page body (ensure `Card`, `Inventory` are imported). Match how the page currently reads the server object.
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/lib/api.ts web/app/servers/[id]/page.tsx
|
||||
git commit -m "feat(web): inventory panel on server detail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: End-to-end manual verification
|
||||
|
||||
- [ ] **Step 1: Build all**
|
||||
|
||||
Run: `cd server && go build ./... && cd ../agent && go build ./... && cd ../web && npm run build`
|
||||
Expected: all succeed.
|
||||
|
||||
- [ ] **Step 2: Smoke (if environment available)**
|
||||
|
||||
With server + Mongo + a connected Linux agent: within ~30s the server detail page shows CPU %, RAM/swap bars; within 15 min (or on agent restart, which sends a full snapshot immediately) partitions, CPU model and kernel appear. Confirm metrics update roughly every 30s.
|
||||
|
||||
- [ ] **Step 3: Commit any fixes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: fleet inventory verification fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** §3 model → T1; §4 RPC → T1; §5 collectors + scheduler → T3, T4; §6 handler/store → T2; §7 frontend → T5. Split cadence (30s metrics / 15m static) in T4 scheduler; merge rules preserving static in T2 `StoreInventory`. Tests omitted per Global Constraints.
|
||||
- **Startup snapshot:** agent sends `Collect(true)` immediately so static fields populate without waiting 15 min.
|
||||
- **Types consistent:** `InventoryReport` field names identical across proto, both pb files, store service, and TS interface (`usage_pct`, `used_bytes`, `total_bytes`, `swap_*`).
|
||||
- **Follow-ups (out of scope):** time-series history, usage alerting, Windows collectors, servers-list CPU/RAM badges.
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user