Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions internal/console/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ async function loadData() {
setTableMessage('table-enrollments', 4, 'Restricted to administrators.');
}
renderNodesTable(data.enrolled_nodes || []);
renderServicesTable(data.node_catalog || {}, buildLabelsByPeer(data.enrolled_nodes || []));
renderRoutersTable(data.active_routers || []);
renderRouterTopography(data.active_routers || []);
renderBootstrapTokensTable(data.bootstrap_tokens || []);
Expand Down Expand Up @@ -332,16 +333,49 @@ function renderUsersTable(users) {
`).join('');
}

// Renders an operator-declared labels map (e.g. {component: "stvv", role:
// "producer"}, from sam-node.yaml's labels: key) as a compact key=value
// list - the closest thing to a node mnemonic that exists today, since SAM
// has no dedicated name/alias field. Returns '' if there are none.
function formatLabels(labels) {
const entries = Object.entries(labels || {});
if (entries.length === 0) {
return '';
}
return entries.map(([k, v]) => `${k}=${v}`).join(', ');
}

// A peer ID cell: the raw ID (still the real, authoritative identifier)
// with its operator-declared labels shown underneath when present.
function peerCell(peerID, labels) {
const labelText = formatLabels(labels);
const sub = labelText
? `<div class="cell-subtext">${escapeHTML(labelText)}</div>`
: '';
return `<code>${escapeHTML(peerID)}</code>${sub}`;
}

// Builds a peer ID -> labels lookup from the enrolled_nodes list, so other
// tables (e.g. Services) can show the same labels next to a bare peer ID
// without a second fetch.
function buildLabelsByPeer(nodes) {
const byPeer = {};
for (const node of nodes || []) {
byPeer[node.PeerID] = node.Labels || {};
}
return byPeer;
}

function renderNodesTable(nodes) {
const tbody = document.getElementById('table-nodes');
if (nodes.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" class="text-center">No enrolled nodes found</td></tr>`;
return;
}

tbody.innerHTML = nodes.map(node => `
<tr>
<td><code>${escapeHTML(node.PeerID)}</code></td>
<td>${peerCell(node.PeerID, node.Labels)}</td>
<td>${escapeHTML(node.Role)}</td>
<td>${escapeHTML(node.OwnerID)}</td>
<td>
Expand All @@ -353,6 +387,41 @@ function renderNodesTable(nodes) {
`).join('');
}

// Service type is a protobuf enum (SERVICE_TYPE_MCP = 1, SERVICE_TYPE_INFERENCE = 2,
// SERVICE_TYPE_A2A = 3); plain encoding/json on the Go side emits the bare
// int, not the enum name, and omits it entirely (omitempty) if it's ever 0.
const SERVICE_TYPE_NAMES = { 1: 'mcp', 2: 'inference', 3: 'a2a' };

function renderServicesTable(nodeCatalog, labelsByPeer) {
const tbody = document.getElementById('table-services');
const peerIDs = Object.keys(nodeCatalog || {});
const rows = [];
for (const peerID of peerIDs) {
const entry = nodeCatalog[peerID] || {};
const services = entry.services || [];
for (const svc of services) {
if (svc) {
rows.push({ peerID, reportedAt: entry.reported_at, svc });
}
}
Comment thread
fer-marino marked this conversation as resolved.
}

if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="text-center">No nodes have reported any services yet</td></tr>`;
return;
}

tbody.innerHTML = rows.map(({ peerID, reportedAt, svc }) => `
<tr>
<td>${escapeHTML(svc.name || '')}</td>
<td>${escapeHTML(SERVICE_TYPE_NAMES[svc.type] || 'unknown')}</td>
<td>${escapeHTML(svc.description || '')}</td>
<td>${peerCell(peerID, (labelsByPeer || {})[peerID])}</td>
<td>${reportedAt ? escapeHTML(new Date(reportedAt).toLocaleString()) : '-'}</td>
</tr>
`).join('');
}

function getStatusBadge(status) {
if (status === 0 || status === 'ENROLLMENT_STATUS_PENDING') {
return `<span class="badge badge-pending">Pending</span>`;
Expand Down
30 changes: 30 additions & 0 deletions internal/console/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ <h2>SAM Console</h2>
<svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
Nodes
</a>
<a href="#services" class="nav-item" data-target="services">
<svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
Services
</a>
<a href="#enrollments" class="nav-item" data-target="enrollments">
<svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
Enrollments
Expand Down Expand Up @@ -199,6 +203,32 @@ <h2>Enrolled Nodes</h2>
</div>
</section>

<!-- Services View: self-reported by each node (see /nodes/catalog);
a live-status cache, not authoritative, so ReportedAt matters. -->
<section id="view-services" class="view-section">
<div class="section-header">
<h2>Mesh Services</h2>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Service</th>
<th>Type</th>
<th>Description</th>
<th>Node ID</th>
<th>Reported At</th>
</tr>
</thead>
<tbody id="table-services">
<tr><td colspan="5" class="text-center">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</section>

<!-- Enrollments View -->
<section id="view-enrollments" class="view-section">
<div class="section-header">
Expand Down
7 changes: 7 additions & 0 deletions internal/console/public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,13 @@ body {
border-bottom: none;
}

/* An operator-declared label line under a bare peer ID (see peerCell in
app.js) - a class, not an inline style, so CSP style-src can stay strict. */
.cell-subtext {
color: var(--text-secondary);
font-size: 0.85em;
}

.text-center {
text-align: center;
}
Expand Down
141 changes: 141 additions & 0 deletions internal/controlplane/catalog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package controlplane

import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strings"
"time"

"github.com/google/sam/api"
"github.com/google/sam/internal/identity"
)

// nodeCatalogEntry is what HandleNodeCatalog caches per reporting peer.
type nodeCatalogEntry struct {
Services []*api.ServiceInfo `json:"services"`
ReportedAt time.Time `json:"reported_at"`
}

// nodeCatalogRequest is HandleNodeCatalog's request body. The reporting
// peer's identity comes from its verified Biscuit, never from this body -
// a node can only ever report on itself.
type nodeCatalogRequest struct {
Services []*api.ServiceInfo `json:"services"`
}

// catalogSnapshot returns a stable copy of the current node service catalog
// cache, safe to range over or marshal without holding catalogMu.
func (s *Server) catalogSnapshot() map[string]nodeCatalogEntry {
s.catalogMu.RLock()
defer s.catalogMu.RUnlock()
snap := make(map[string]nodeCatalogEntry, len(s.catalog))
for k, v := range s.catalog {
snap[k] = v
}
return snap
}

// HandleNodeCatalog HTTP POST /nodes/catalog - a node self-reports the
// services it currently has registered locally (the same data
// list_local_services already answers on the node itself), so the control
// plane can show mesh-wide service topology without needing to be a DHT
// participant or open a P2P connection to every enrolled node itself.
//
// This is a live-status cache, not authoritative state: a node that goes
// offline without ever reporting an empty catalog just leaves its last
// report in place until ReportedAt visibly goes stale. Good enough for an
// admin-facing "what's running where" view; not a substitute for the real
// per-request Biscuit authorization every actual service call still goes
// through independently.
func (s *Server) HandleNodeCatalog(w http.ResponseWriter, r *http.Request) {
Comment thread
fer-marino marked this conversation as resolved.
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Missing node Biscuit token in Authorization header", http.StatusUnauthorized)
return
}
biscuitBytes, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Bearer "))
if err != nil {
http.Error(w, "Malformed base64 token", http.StatusBadRequest)
return
}

ctx := r.Context()
validKeys, err := s.store.GetAllValidKeys(ctx)
if err != nil {
logger.Errorf("Failed to retrieve valid signing keys: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var trustedKeys []ed25519.PublicKey
for _, k := range validKeys {
trustedKeys = append(trustedKeys, k.Public)
}

peerID, err := identity.VerifyAndExtractPeerID(trustedKeys, biscuitBytes, s.config.BiscuitTimeout)
if err != nil {
logger.Warnw("Invalid biscuit presented to /nodes/catalog", "error", err)
http.Error(w, "Invalid biscuit: "+err.Error(), http.StatusUnauthorized)
return
}

nodeRecord, err := s.store.GetNode(ctx, peerID.String())
if err != nil || nodeRecord == nil || nodeRecord.CheckAdmission(time.Now()) != nil {
http.Error(w, "Node not enrolled or not admitted", http.StatusUnauthorized)
return
}

r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
defer func() { _ = r.Body.Close() }()

var req nodeCatalogRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid JSON body", http.StatusBadRequest)
return
}

// A malformed report (e.g. {"services": [null]}) unmarshals into a nil
// element rather than failing - filter those out so a bad report from one
// node can't crash rendering for every node's entry in the console.
var validServices []*api.ServiceInfo
for _, svc := range req.Services {
if svc != nil {
validServices = append(validServices, svc)
}
}

s.catalogMu.Lock()
s.catalog[peerID.String()] = nodeCatalogEntry{
Services: validServices,
ReportedAt: time.Now(),
}
s.catalogMu.Unlock()
Comment thread
fer-marino marked this conversation as resolved.

w.WriteHeader(http.StatusNoContent)
}
Loading
Loading