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
|
served at `/api/agents/download/{linux-amd64,linux-arm64}` (install
|
||||||
script falls back to GH releases). `cli/commands/nodes_add.go`,
|
script falls back to GH releases). `cli/commands/nodes_add.go`,
|
||||||
`api/assets/install-agent.sh`, `agents.go:ServeInstallScript`/`ServeAgentBinary`.
|
`api/assets/install-agent.sh`, `agents.go:ServeInstallScript`/`ServeAgentBinary`.
|
||||||
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
|
- [x] **Mesh awareness** — agent reports tailscale/netbird/wireguard/
|
||||||
zt0 IPs at register; backend stores + displays. Optional
|
zerotier interface IPs at register (`mesh` field, authoritative on
|
||||||
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
|
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
|
- [x] **Node detail page** — `/nodes/:id` (admin): heartbeat telemetry
|
||||||
(cpu/mem sparklines over 1h/24h/7d via `GET /agents/:id/metrics`),
|
(cpu/mem sparklines over 1h/24h/7d via `GET /agents/:id/metrics`),
|
||||||
containers, command history, rename, auto-prune toggle, prune-now,
|
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,
|
"ip_address": cfg.IP,
|
||||||
"port": cfg.Port,
|
"port": cfg.Port,
|
||||||
"capabilities": detectCapabilities(),
|
"capabilities": detectCapabilities(),
|
||||||
|
// Always send mesh so a removed interface clears stored state.
|
||||||
|
"mesh": detectMeshInterfaces(),
|
||||||
}
|
}
|
||||||
var response registerResponse
|
var response registerResponse
|
||||||
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
|
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 {
|
func collectResources(ip string) nodeResources {
|
||||||
mem := readMemory()
|
mem := readMemory()
|
||||||
storage := readStorage("/")
|
storage := readStorage("/")
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
Port int `json:"port" binding:"required"`
|
Port int `json:"port" binding:"required"`
|
||||||
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
||||||
AuthToken string `json:"auth_token"`
|
AuthToken string `json:"auth_token"`
|
||||||
|
Mesh map[string]string `json:"mesh"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -345,12 +346,28 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := c.Request.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.
|
// Re-registering an existing host updates it in place.
|
||||||
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
|
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
|
||||||
Hostname: req.Hostname,
|
Hostname: req.Hostname,
|
||||||
IpAddress: req.IPAddress,
|
IpAddress: req.IPAddress,
|
||||||
}); err == nil {
|
}); 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{
|
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@@ -362,7 +379,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
Capabilities: rawJSON(req.Capabilities),
|
Capabilities: rawJSON(req.Capabilities),
|
||||||
Resources: existing.Resources,
|
Resources: existing.Resources,
|
||||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
Metadata: existing.Metadata,
|
Metadata: rawJSON(metadata),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
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),
|
Capabilities: rawJSON(req.Capabilities),
|
||||||
Resources: rawJSON(resources),
|
Resources: rawJSON(resources),
|
||||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
Metadata: rawJSON(map[string]interface{}{}),
|
Metadata: rawJSON(metadata),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ export function NodeDetailPage() {
|
|||||||
{agent.version ? ` · v${agent.version}` : ''}
|
{agent.version ? ` · v${agent.version}` : ''}
|
||||||
{agent.lastHeartbeat ? ` · heartbeat ${formatRelative(agent.lastHeartbeat)}` : ''}
|
{agent.lastHeartbeat ? ` · heartbeat ${formatRelative(agent.lastHeartbeat)}` : ''}
|
||||||
</p>
|
</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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
|
<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 }) {
|
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="panel p-4">
|
<div className="panel p-4">
|
||||||
|
|||||||
@@ -12026,6 +12026,10 @@ export interface components {
|
|||||||
capabilities: components["schemas"]["AgentCapabilities"];
|
capabilities: components["schemas"]["AgentCapabilities"];
|
||||||
/** @description May also be sent via the X-Agent-Token header */
|
/** @description May also be sent via the X-Agent-Token header */
|
||||||
auth_token?: string;
|
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: {
|
AgentHeartbeat: {
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ export type NodeAgentEntity = {
|
|||||||
capabilities: AgentCapabilities;
|
capabilities: AgentCapabilities;
|
||||||
resources: NodeResources;
|
resources: NodeResources;
|
||||||
autoPrune: boolean;
|
autoPrune: boolean;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
lastHeartbeat?: string;
|
lastHeartbeat?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
@@ -1078,6 +1079,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
|
|||||||
},
|
},
|
||||||
lastHeartbeat: agent.last_heartbeat,
|
lastHeartbeat: agent.last_heartbeat,
|
||||||
autoPrune: agent.auto_prune ?? false,
|
autoPrune: agent.auto_prune ?? false,
|
||||||
|
metadata: (agent.metadata ?? {}) as Record<string, unknown>,
|
||||||
createdAt: agent.created_at,
|
createdAt: agent.created_at,
|
||||||
updatedAt: agent.updated_at,
|
updatedAt: agent.updated_at,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9130,6 +9130,11 @@ components:
|
|||||||
auth_token:
|
auth_token:
|
||||||
type: string
|
type: string
|
||||||
description: May also be sent via the X-Agent-Token header
|
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:
|
AgentHeartbeat:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
Reference in new issue
Block a user