mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(nodes): mesh awareness + node detail page
- agent reports tailscale/netbird/zerotier/wireguard interface IPs at register (authoritative — absent interfaces clear stored state) - backend stores under node_agents.metadata.mesh, merged on re-register - node detail page shows mesh addresses under the primary IP Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
1361868946
commit
7cb5e10eb6
7 files changed
+84
-5
No files matched your search
+4
-3
@@ -260,9 +260,10 @@ and wired refs, one click or one CLI call.
|
||||
served at `/api/agents/download/{linux-amd64,linux-arm64}` (install
|
||||
script falls back to GH releases). `cli/commands/nodes_add.go`,
|
||||
`api/assets/install-agent.sh`, `agents.go:ServeInstallScript`/`ServeAgentBinary`.
|
||||
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
|
||||
zt0 IPs at register; backend stores + displays. Optional
|
||||
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
|
||||
- [x] **Mesh awareness** — agent reports tailscale/netbird/wireguard/
|
||||
zerotier interface IPs at register (`mesh` field, authoritative on
|
||||
re-register), stored in `node_agents.metadata.mesh`, shown on the node
|
||||
detail page. `TAILSCALE_AUTH_KEY` enroll-at-provision still open.
|
||||
- [x] **Node detail page** — `/nodes/:id` (admin): heartbeat telemetry
|
||||
(cpu/mem sparklines over 1h/24h/7d via `GET /agents/:id/metrics`),
|
||||
containers, command history, rename, auto-prune toggle, prune-now,
|
||||
|
||||
@@ -167,6 +167,8 @@ func register(ctx context.Context, client *http.Client, cfg agentConfig) (string
|
||||
"ip_address": cfg.IP,
|
||||
"port": cfg.Port,
|
||||
"capabilities": detectCapabilities(),
|
||||
// Always send mesh so a removed interface clears stored state.
|
||||
"mesh": detectMeshInterfaces(),
|
||||
}
|
||||
var response registerResponse
|
||||
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
|
||||
@@ -372,6 +374,42 @@ func detectCapabilities() capabilities {
|
||||
}
|
||||
}
|
||||
|
||||
// detectMeshInterfaces reports overlay-network interface IPs (tailscale,
|
||||
// netbird, zerotier, wireguard) so the backend can display reachable
|
||||
// mesh addresses alongside the primary IP.
|
||||
func detectMeshInterfaces() map[string]string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mesh := map[string]string{}
|
||||
for _, iface := range ifaces {
|
||||
name := strings.ToLower(iface.Name)
|
||||
isMesh := strings.HasPrefix(name, "tailscale") || strings.HasPrefix(name, "ts.") ||
|
||||
strings.HasPrefix(name, "netbird") || strings.HasPrefix(name, "wt") ||
|
||||
strings.HasPrefix(name, "zt") || strings.HasPrefix(name, "wg")
|
||||
if !isMesh || iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ip, _, err := net.ParseCIDR(addr.String())
|
||||
if err != nil || ip.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
mesh[iface.Name] = v4.String()
|
||||
} else if _, exists := mesh[iface.Name]; !exists {
|
||||
mesh[iface.Name] = ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return mesh
|
||||
}
|
||||
|
||||
func collectResources(ip string) nodeResources {
|
||||
mem := readMemory()
|
||||
storage := readStorage("/")
|
||||
|
||||
@@ -331,6 +331,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
Port int `json:"port" binding:"required"`
|
||||
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
Mesh map[string]string `json:"mesh"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -345,12 +346,28 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
metadata := map[string]interface{}{}
|
||||
// req.Mesh is authoritative when present (newer agents always send it,
|
||||
// possibly empty); nil means an older agent — keep any stored value.
|
||||
if req.Mesh != nil {
|
||||
metadata["mesh"] = req.Mesh
|
||||
}
|
||||
|
||||
// Re-registering an existing host updates it in place.
|
||||
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
|
||||
Hostname: req.Hostname,
|
||||
IpAddress: req.IPAddress,
|
||||
}); err == nil {
|
||||
if existing.Metadata.Valid && len(existing.Metadata.RawMessage) > 0 {
|
||||
var prev map[string]interface{}
|
||||
if err := json.Unmarshal(existing.Metadata.RawMessage, &prev); err == nil {
|
||||
for k, v := range prev {
|
||||
if _, ok := metadata[k]; !ok {
|
||||
metadata[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
|
||||
ID: existing.ID,
|
||||
Name: req.Name,
|
||||
@@ -362,7 +379,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
Capabilities: rawJSON(req.Capabilities),
|
||||
Resources: existing.Resources,
|
||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
Metadata: existing.Metadata,
|
||||
Metadata: rawJSON(metadata),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||
@@ -412,7 +429,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
Capabilities: rawJSON(req.Capabilities),
|
||||
Resources: rawJSON(resources),
|
||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
Metadata: rawJSON(map[string]interface{}{}),
|
||||
Metadata: rawJSON(metadata),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
|
||||
|
||||
@@ -156,6 +156,11 @@ export function NodeDetailPage() {
|
||||
{agent.version ? ` · v${agent.version}` : ''}
|
||||
{agent.lastHeartbeat ? ` · heartbeat ${formatRelative(agent.lastHeartbeat)}` : ''}
|
||||
</p>
|
||||
{meshAddresses(agent).length > 0 && (
|
||||
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)]">
|
||||
mesh: {meshAddresses(agent).map(([iface, ip]) => `${iface}=${ip}`).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
|
||||
@@ -312,6 +317,13 @@ export function NodeDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function meshAddresses(agent: { metadata: Record<string, unknown> }): [string, string][] {
|
||||
const mesh = agent.metadata?.mesh;
|
||||
if (!mesh || typeof mesh !== 'object' || Array.isArray(mesh)) return [];
|
||||
return Object.entries(mesh as Record<string, unknown>)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '');
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="panel p-4">
|
||||
|
||||
@@ -12026,6 +12026,10 @@ export interface components {
|
||||
capabilities: components["schemas"]["AgentCapabilities"];
|
||||
/** @description May also be sent via the X-Agent-Token header */
|
||||
auth_token?: string;
|
||||
/** @description Overlay-network interface IPs (tailscale/netbird/zerotier/wireguard). Stored under agent metadata.mesh; authoritative — send an empty object when no mesh interfaces exist. */
|
||||
mesh?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
AgentHeartbeat: {
|
||||
agent_id?: string;
|
||||
|
||||
@@ -342,6 +342,7 @@ export type NodeAgentEntity = {
|
||||
capabilities: AgentCapabilities;
|
||||
resources: NodeResources;
|
||||
autoPrune: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
lastHeartbeat?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
@@ -1078,6 +1079,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
|
||||
},
|
||||
lastHeartbeat: agent.last_heartbeat,
|
||||
autoPrune: agent.auto_prune ?? false,
|
||||
metadata: (agent.metadata ?? {}) as Record<string, unknown>,
|
||||
createdAt: agent.created_at,
|
||||
updatedAt: agent.updated_at,
|
||||
};
|
||||
|
||||
@@ -9130,6 +9130,11 @@ components:
|
||||
auth_token:
|
||||
type: string
|
||||
description: May also be sent via the X-Agent-Token header
|
||||
mesh:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Overlay-network interface IPs (tailscale/netbird/zerotier/wireguard). Stored under agent metadata.mesh; authoritative — send an empty object when no mesh interfaces exist.
|
||||
|
||||
AgentHeartbeat:
|
||||
type: object
|
||||
|
||||
Reference in new issue
Block a user