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:
Tomas DvorakandDevin committed 2026-10-09 12:24:28 +02:00
1 parent 1361868946
commit 7cb5e10eb6
7 files changed
+84 -5

No files matched your search

@@ -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">
+4
View File
@@ -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;
+2
View File
@@ -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,
};