diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 7814f77b9..0744178a3 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -195,6 +195,15 @@ source is available. The browser may present that state as a setup checklist and deep-link into tenant install/reporting surfaces, but it must not infer cross-client readiness from health alone or mint tenant-owned agent, alert, or report configuration from Pulse Account. +MSP account UI may present those same workspace entries as clients, but that +language is portal presentation only. Bootstrap and dashboard payloads continue +to expose account `kind`, `workspaces`, tenant IDs, role IDs, setup facts, and +member roles with the stable workspace field names. Mixed-account views must +scope MSP client wording to MSP account surfaces and keep Cloud or self-hosted +workspace wording scoped to their own account surfaces. Provider setup +templates are collapsible guidance over the account payload; they must not +replace tenant-local setup facts, become a readiness source, or require a +payload shape change when the portal presents compact client rows. 1. `frontend-modern/src/api/agentProfiles.ts` shared with `agent-lifecycle`: the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary. 2. `frontend-modern/src/api/ai.ts` shared with `ai-runtime`: the AI frontend client is both an AI runtime control surface and a canonical API payload contract boundary. @@ -4875,7 +4884,7 @@ The PDF output owns three additional rendered sections gated on `ReportData.Narrative`: an executive prose paragraph between the deterministic health card and the deterministic Quick Stats table, a `Period-over-period changes` section after Recommended Actions, and a -muted provenance footer (`Narrative generated by Pulse Assistant. +muted provenance footer (`Pulse Assistant narrative. Verify against the data tables in this report.`) when `Narrative.Source` is `ai`. Charts, stats tables, alert lists, storage and disk sections must remain deterministic and rendered from the diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 602a88313..8d872ae64 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -345,6 +345,16 @@ or other self-hosted uncapped continuity plans. check remains `Review` ahead of setup counts. Local MSP onboarding previews should be scenario-backed portal bootstrap data, not static screenshots, so they stay grounded in the real portal shape as the bundle changes. + MSP account surfaces may call account workspaces `clients`, but that is a + customer-facing portal vocabulary choice over the same tenant/workspace + lifecycle. Mixed-account Pulse Account sessions must label account surfaces + clearly enough that MSP client wording, Cloud workspace wording, access role + explanations, and lifecycle controls do not bleed across accounts. Read-only + account members must not see destructive client lifecycle controls. Repeated + customer hostnames are acceptable only because agent ingest and monitors stay + tenant-isolated; an MSP flow that lets two clients report `pve1` must keep + those reports in separate tenant workspaces rather than centralizing host + identity in the provider account. Portal workspace payloads are live-workspace surfaces, not registry history dumps: tenants in `deleting` or `deleted` state must stay hidden from the browser bootstrap, `/api/portal/dashboard`, and workspace-detail API so @@ -1050,7 +1060,7 @@ on the activation payload shape. That same license-server transport boundary now treats Patrol quickstart bootstrap as retired, not as a mixed-version runtime extension point. `pulse-pro/license-server` must not register `/v1/quickstart/*` routes, parse -OpenAI/OpenRouter quickstart env, or create new quickstart ledger tables. The +model-provider quickstart env, or create new quickstart ledger tables. The Pulse runtime must not call `POST /v1/quickstart/bootstrap`, must not persist quickstart-backed AI config, and must not mint hosted-model tokens from hosted or self-hosted billing state. Historical quickstart credit fields may remain diff --git a/internal/api/unified_agent_handlers_test.go b/internal/api/unified_agent_handlers_test.go index 90265a02d..ffab87690 100644 --- a/internal/api/unified_agent_handlers_test.go +++ b/internal/api/unified_agent_handlers_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -98,6 +99,106 @@ func TestUnifiedAgentHandlers_HandleReport(t *testing.T) { } } +func TestUnifiedAgentHandlers_HandleReportIsolatesDuplicateHostnamesAcrossTenants(t *testing.T) { + baseDir := t.TempDir() + cfg := &config.Config{ + DataPath: baseDir, + ConfigPath: baseDir, + } + persistence := config.NewMultiTenantPersistence(baseDir) + for _, orgID := range []string{"client-a", "client-b"} { + if _, err := persistence.GetPersistence(orgID); err != nil { + t.Fatalf("create tenant persistence %s: %v", orgID, err) + } + } + + mtm := monitoring.NewMultiTenantMonitor(cfg, persistence, nil) + t.Cleanup(mtm.Stop) + handler := NewUnifiedAgentHandlers(mtm, nil, nil) + + report := agentshost.Report{ + Agent: agentshost.AgentInfo{ + ID: "agent-pve1", + Version: "1.0.0", + }, + Host: agentshost.HostInfo{ + ID: "machine-pve1", + MachineID: "machine-pve1", + Hostname: "pve1", + Platform: "linux", + }, + Timestamp: time.Now().UTC(), + } + + postTenantReport := func(t *testing.T, orgID string, record config.APITokenRecord) string { + t.Helper() + body, err := json.Marshal(report) + if err != nil { + t.Fatalf("marshal report: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/agents/agent/report", bytes.NewReader(body)) + req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, orgID)) + attachAPITokenRecord(req, &record) + rec := httptest.NewRecorder() + + handler.HandleReport(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("tenant %s report status = %d, want 200: %s", orgID, rec.Code, rec.Body.String()) + } + + var resp struct { + AgentID string `json:"agentId"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode tenant %s report response: %v", orgID, err) + } + if resp.AgentID == "" { + t.Fatalf("tenant %s report response omitted agentId", orgID) + } + return resp.AgentID + } + + tokenA := newTokenRecord(t, "tenant-a-agent-token-123.12345678", []string{config.ScopeAgentReport}, nil) + tokenA.OrgID = "client-a" + tokenB := newTokenRecord(t, "tenant-b-agent-token-123.12345678", []string{config.ScopeAgentReport}, nil) + tokenB.OrgID = "client-b" + + agentA := postTenantReport(t, "client-a", tokenA) + agentB := postTenantReport(t, "client-b", tokenB) + if agentA != agentB { + t.Fatalf("expected duplicate tenants to keep the same local agent id, got %q and %q", agentA, agentB) + } + + monitorA, ok := mtm.PeekMonitor("client-a") + if !ok { + t.Fatal("expected client-a monitor to be initialized") + } + monitorB, ok := mtm.PeekMonitor("client-b") + if !ok { + t.Fatal("expected client-b monitor to be initialized") + } + defaultMonitor, ok := mtm.PeekMonitor("default") + if !ok { + t.Fatal("expected default monitor fallback to be initialized") + } + + for orgID, monitor := range map[string]*monitoring.Monitor{ + "client-a": monitorA, + "client-b": monitorB, + } { + hosts := monitor.GetLiveHostsSnapshot() + if len(hosts) != 1 { + t.Fatalf("tenant %s hosts = %#v, want exactly one isolated host", orgID, hosts) + } + if hosts[0].ID != "machine-pve1" || hosts[0].Hostname != "pve1" { + t.Fatalf("tenant %s host = %#v, want duplicate pve1 identity isolated inside tenant", orgID, hosts[0]) + } + } + if hosts := defaultMonitor.GetLiveHostsSnapshot(); len(hosts) != 0 { + t.Fatalf("default tenant should not receive client reports, got %#v", hosts) + } +} + func TestUnifiedAgentHandlers_HandleReport_AllowsNewHostsWithCapsRetired(t *testing.T) { setMaxMonitoredSystemsLicenseForTests(t, 1) diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index abd41c5ba..c8bb34587 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "52971d344a87d4b96d86db8184561dc150ee2cc408b1e3395c67912ad1f5e5bd", + "source_hash": "dc3aa37a68d30708d2f82b9da0da70470a2b2b3611223a477becaaf4aa2db381", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index c2b758042..ae85fd3df 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -318,6 +318,9 @@ header .logout-btn:hover, padding-top: 20px; border-top: 1px solid var(--border); } +.account-surface-header .portal-section-copy { + margin: 4px 0 0; +} .account-content-panel { display: block; } @@ -378,7 +381,7 @@ header .logout-btn:hover, .workspace-row-primary { display: flex; flex-direction: column; - gap: 1px; + gap: 2px; min-width: 0; } .workspace-row-heading { @@ -389,12 +392,18 @@ header .logout-btn:hover, } .workspace-name { margin: 0; + min-width: 0; + max-width: 100%; font-size: 14px; font-weight: 600; color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .workspace-meta { display: flex; + flex-wrap: wrap; gap: 6px; } .workspace-meta-item { @@ -406,11 +415,6 @@ header .logout-btn:hover, margin-right: 6px; color: var(--border); } -.workspace-row-note { - margin: 0; - font-size: 12px; - color: var(--ink-secondary); -} .workspace-row-status-cell { display: flex; align-items: center; @@ -1687,26 +1691,29 @@ header .logout-btn:hover, border-radius: var(--radius); background: var(--bg); } -.workspace-template-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} -.workspace-template-heading h3 { - margin: 0; +.workspace-template-summary { + cursor: pointer; font-size: 14px; + font-weight: 600; color: var(--ink); } -.workspace-template-heading p { - margin: 3px 0 0; +.workspace-template-summary::marker, +.workspace-template-summary::-webkit-details-marker { + color: var(--ink-muted); +} +.workspace-template-summary-meta { + margin-left: 8px; + color: var(--ink-secondary); + font-size: 12px; + font-weight: 400; +} +.workspace-template-intro { + margin: 10px 0 0; color: var(--ink-secondary); font-size: 12px; } -.workspace-template-heading > span { - flex: 0 0 auto; - color: var(--ink-secondary); - font-size: 12px; +.workspace-template-details[open] .workspace-template-grid { + margin-top: 10px; } .workspace-template-grid { display: grid; @@ -1953,9 +1960,6 @@ header .logout-btn:hover, .workspace-setup-queue-actions { justify-content: flex-start; } - .workspace-template-heading { - flex-direction: column; - } .workspace-template-grid, .workspace-setup-guide { grid-template-columns: 1fr; diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index b1abfa791..1cae20c1a 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -20,16 +20,16 @@ return role || "Member"; } } - function portalRoleCapabilityCopy(role) { + function portalRoleCapabilityCopy(role, clientLanguage = false) { switch (normalizePortalRole(role)) { case "owner": - return "Full account control, including billing, access control, and workspace control."; + return clientLanguage ? "Full account control, including billing, access control, and client control." : "Full account control, including billing, access control, and workspace control."; case "admin": - return "Can manage workspaces and billing for this account."; + return clientLanguage ? "Can manage clients and billing for this account." : "Can manage workspaces and billing for this account."; case "tech": - return "Can manage workspaces without billing ownership."; + return clientLanguage ? "Can manage clients without billing ownership." : "Can manage workspaces without billing ownership."; case "read_only": - return "Can review workspace status without making control-plane changes."; + return clientLanguage ? "Can review client status without making control-plane changes." : "Can review workspace status without making control-plane changes."; case "member": return "Has access through the account roster."; default: @@ -629,7 +629,7 @@ group.appendChild(btn); return group; } - function renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob) { + function renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob, clientLanguage) { var showActionColumn = canManage && activeJob === "remove"; var row = document.createElement("div"); row.className = "access-member-row" + (showActionColumn ? "" : " access-member-row-readonly"); @@ -654,7 +654,7 @@ identity.appendChild(topline); var caption = document.createElement("div"); caption.className = "access-member-caption"; - caption.textContent = (member.state || "active") === "pending" ? "Invitation pending acceptance." : portalRoleCapabilityCopy(member.role); + caption.textContent = (member.state || "active") === "pending" ? "Invitation pending acceptance." : portalRoleCapabilityCopy(member.role, clientLanguage); identity.appendChild(caption); row.appendChild(identity); row.appendChild(renderAccessRoleControl(accountID, member, isOwner, canManage, activeJob)); @@ -703,6 +703,7 @@ var actorRole = section.getAttribute("data-actor-role") || ""; var isOwner = actorRole === "owner"; var canManage = section.getAttribute("data-can-manage") === "true"; + var clientLanguage = section.getAttribute("data-client-language") === "true"; var activeJob = canManage ? entry.activeAccessJob : ""; section.classList.toggle("visible", entry.accessVisible); renderAccessStats(accountID, entry, canManage); @@ -749,7 +750,7 @@ renderAccessRosterHead(roster, activeJob, canManage); for (var i = 0; i < entry.accessQuery.data.length; i += 1) { var member = entry.accessQuery.data[i]; - roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob)); + roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob, clientLanguage)); } } function renderAccountUI(accountState, accounts, accountAPIBasePath = "") { @@ -2792,9 +2793,6 @@ if (createdLabel) { metaParts.push('Created ' + escapeHTML(createdLabel) + ""); } - if (workspace.last_health_check && status === "healthy") { - metaParts.push('Checked recently'); - } var openAction = ""; if (state === "active") { openAction = '
"; @@ -2807,7 +2805,7 @@ if (account.can_manage && (state === "active" || state === "suspended" || state === "failed")) { manageAction = '"; } - return '

' + escapeHTML(workspace.display_name) + '

' + metaParts.join("") + '
' + escapeHTML(workspaceRowNote(workspace)) + '
' + setupBadgeHTML(workspace) + '
' + healthBadgeHTML(workspace) + '
' + openAction + installAction + manageAction + "
"; + return '

' + escapeHTML(workspace.display_name) + "

" + (metaParts.length ? '
' + metaParts.join("") + "
" : "") + '
' + setupBadgeHTML(workspace) + '
' + healthBadgeHTML(workspace) + '
' + openAction + installAction + manageAction + "
"; } function renderWorkspaceHandoffForm(accountID, workspaceID, accountAPIBasePath, label, buttonClassName = "btn-secondary btn-compact") { if (!accountAPIBasePath) { @@ -2882,6 +2880,13 @@ var guide = workspaceSetupGuide(workspace); return guide.diagnostics.length ? guide.diagnostics[0] : workspaceSetupNextStep(workspace); } + function workspaceSummaryStatusCopy(workspace, clientLanguage) { + if (!clientLanguage) return workspaceStatusCopy(workspace); + var state = String(workspace.state || ""); + if (state === "suspended") return "This client is suspended."; + if (state === "failed") return "This client is in a failed state."; + return workspaceStatusCopy(workspace); + } function workspaceSummaryContext(entry, includeAccountName, note) { if (!includeAccountName) return note; return entry.account.name + " \xB7 " + note; @@ -2910,7 +2915,7 @@ if (attention.length) { var attentionEntry = attention[0]; title = "Review " + attentionEntry.workspace.display_name; - description = workspaceSummaryContext(attentionEntry, accounts.length > 1, workspaceStatusCopy(attentionEntry.workspace)); + description = workspaceSummaryContext(attentionEntry, accounts.length > 1, workspaceSummaryStatusCopy(attentionEntry.workspace, clientLanguage)); primaryAction = renderWorkspaceAnchorAction( workspaceRowAnchorID(attentionEntry.account.id, attentionEntry.workspace.id), clientLanguage ? "Review client" : "Review workspace", @@ -2920,7 +2925,7 @@ } else if (suspended.length) { var suspendedEntry = suspended[0]; title = "Review " + suspendedEntry.workspace.display_name; - description = workspaceSummaryContext(suspendedEntry, accounts.length > 1, workspaceStatusCopy(suspendedEntry.workspace)); + description = workspaceSummaryContext(suspendedEntry, accounts.length > 1, workspaceSummaryStatusCopy(suspendedEntry.workspace, clientLanguage)); primaryAction = renderWorkspaceAnchorAction( workspaceRowAnchorID(suspendedEntry.account.id, suspendedEntry.workspace.id), clientLanguage ? "Review client" : "Review workspace", @@ -3039,7 +3044,7 @@ }); if (!templateAccount || !templateAccount.setup_templates || !templateAccount.setup_templates.length) return ""; var template = templateAccount.setup_templates[0]; - return '

' + escapeHTML(template.title || "Provider setup template") + "

Use the same onboarding shape for each client, then finish the tenant-owned configuration inside that client workspace.

" + escapeHTML(templateAccount.name) + '
Agent naming' + escapeHTML(template.agent_naming) + "
Alert routing" + escapeHTML(template.alert_routing) + "
Reports" + escapeHTML(template.reporting) + "
Access" + escapeHTML(template.access) + "
"; + return '
' + escapeHTML(template.title || "Provider setup template") + '' + escapeHTML(templateAccount.name) + '

Use the same onboarding shape for each client, then finish the tenant-owned configuration inside that client workspace.

Agent naming' + escapeHTML(template.agent_naming) + "
Alert routing" + escapeHTML(template.alert_routing) + "
Reports" + escapeHTML(template.reporting) + "
Access" + escapeHTML(template.access) + "
"; } function renderWorkspaceSetupQueue(entries, accountAPIBasePath, clientLanguage = false) { var setupNeeded = setupNeededWorkspaceEntries(entries); @@ -3072,6 +3077,10 @@ var clientLanguage = accountsUseClientLanguage(accounts); return '

' + escapeHTML(clientLanguage ? "Clients" : "Workspaces") + "

" + escapeHTML(workspaceSectionHeaderCopy(accounts, entries)) + "

" + renderFactLine("workspace-summary-facts", renderWorkspaceSummaryFacts(accounts, entries)) + renderWorkspaceSummaryInline(accounts, entries, context.accountAPIBasePath, showSelfHostedCommercial) + renderProviderSetupTemplates(accounts) + renderWorkspaceSetupQueue(entries, context.accountAPIBasePath, clientLanguage) + "
"; } + function renderAccountSurfaceHeader(account, showHeader) { + if (!showHeader) return ""; + return '

' + escapeHTML(account.name) + '

' + escapeHTML(accountKindLabel(account) + " \xB7 " + portalRoleLabel(account.role)) + "

"; + } function renderNoHostedWorkspacesSection() { return '

No hosted workspaces are attached to this account. Use Billing for self-hosted subscriptions and licenses.

'; } @@ -3108,12 +3117,18 @@ return '
' + workspaceHTML + "
"; } function renderAccountAccessSection(account) { + var clientLanguage = accountUsesClientLanguage(account); + var accessRoleCopy = { + admin: clientLanguage ? "Client control, billing, and roster management." : "Workspace control, billing, and roster management.", + tech: clientLanguage ? "Client control without billing or roster ownership." : "Workspace control without billing or roster ownership.", + readOnly: clientLanguage ? "Review client status without control-plane changes." : "Review access without control-plane changes." + }; var accessTaskStrip = account.can_manage ? '
' : renderSectionContextChips(["View roster", "Owner or admin required"]); - var accessRoleGuide = '

' + (account.can_manage ? "Choose the smallest role" : "Role meanings") + "

" + (account.can_manage ? "Match each person to the narrowest role that still lets them do the job they own." : "Use these role meanings to understand what each person on this roster can do.") + '

OwnerFull account, billing, and access control.
AdminWorkspace control, billing, and roster management.
TechWorkspace control without billing or roster ownership.
Read-onlyReview access without control-plane changes.
'; + var accessRoleGuide = '

' + (account.can_manage ? "Choose the smallest role" : "Role meanings") + "

" + (account.can_manage ? "Match each person to the narrowest role that still lets them do the job they own." : "Use these role meanings to understand what each person on this roster can do.") + '

OwnerFull account, billing, and access control.
Admin' + escapeHTML(accessRoleCopy.admin) + '
Tech' + escapeHTML(accessRoleCopy.tech) + '
Read-only' + escapeHTML(accessRoleCopy.readOnly) + "
"; var accessInvitePanel = account.can_manage ? '

Invite people

Add one person with the minimum role they need on this account.

' : ""; var accessChangeRolePanel = '

Change roles on the roster

Use the role column in the roster to change one person at a time. Keep each person on the smallest role they need.

' + accessRoleGuide; var accessRemovePanel = '

Remove stale access

Use removal only when this person should no longer be on this hosted account. Owners may still be protected when they are the last owner.

Pick the exact personUse the roster to remove one account member at a time.
Keep current owners safeThe last owner cannot be removed until another owner exists.
'; - return '
' + (!account.can_manage ? '

' + escapeHTML("Review who has access. An owner or admin must make changes.") + "

" : "") + '
' + (account.can_manage ? '" : "") + '
' + (account.can_manage ? '
' + accessTaskStrip + "
" : "") + '
Loading\u2026
'; + return '
' + (!account.can_manage ? '

' + escapeHTML("Review who has access. An owner or admin must make changes.") + "

" : "") + '
' + (account.can_manage ? '" : "") + '
' + (account.can_manage ? '
' + accessTaskStrip + "
" : "") + '
Loading\u2026
'; } function renderHostedBillingCards(accounts, showSelfHostedCommercial) { var hostedBillingAccounts = accounts.filter(function(account) { @@ -3175,11 +3190,11 @@ var billingNote = hosted ? showSelfHostedCommercial ? "Hosted billing by account. Self-hosted purchases stay separate below." : "Hosted billing by account." : "Self-hosted subscriptions, licenses, refunds, and privacy requests."; var selfHostedBillingEscalationCopy = hosted ? "Escalate with the same hosted billing action or self-hosted path and the exact failed step." : "Escalate with the same self-hosted billing path and the exact failed step."; var workspacesContent = accounts.length ? accounts.map(function(account) { - return '
' + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + "
"; + return '
' + renderAccountSurfaceHeader(account, accounts.length > 1) + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + "
"; }).join("") : renderNoHostedWorkspacesSection(); var workspaceSummaryContent = hosted ? renderWorkspaceSummarySection(context) : ""; var accessContent = accounts.length ? accounts.map(function(account) { - return '
' + renderAccountAccessSection(account) + "
"; + return '
' + renderAccountSurfaceHeader(account, accounts.length > 1) + renderAccountAccessSection(account) + "
"; }).join("") : renderNoHostedAccessSection(); var selfHostedBillingLeadCopy = showSelfHostedCommercial ? "Use self-hosted billing only for self-hosted purchases." : "Pulse Account owns the commercial handoff for self-hosted upgrades from the app."; var selfHostedBillingActionsHTML = renderSelfHostedUpgradeActionRow(context); diff --git a/internal/cloudcp/portal/frontend/dev.mjs b/internal/cloudcp/portal/frontend/dev.mjs index cf5b66903..147ed1fbf 100644 --- a/internal/cloudcp/portal/frontend/dev.mjs +++ b/internal/cloudcp/portal/frontend/dev.mjs @@ -9,7 +9,7 @@ import { createPortalBuildOptions, frontendRoot } from './build_config.mjs'; const scenarioCookieName = 'pulse_portal_preview_scenario'; const previewHost = process.env.PULSE_PORTAL_PREVIEW_HOST || '127.0.0.1'; const previewPort = Number(process.env.PULSE_PORTAL_PREVIEW_PORT || '8765'); -const previewScenarios = ['managed', 'readonly', 'selfhosted', 'empty', 'onboarding']; +const previewScenarios = ['managed', 'readonly', 'selfhosted', 'empty', 'onboarding', 'mixed']; const previewFaviconSVG = fs.readFileSync(path.join(frontendRoot, '..', '..', 'favicon.svg'), 'utf8'); const previewFaviconHref = '/favicon.svg?v=' + createHash('sha256').update(previewFaviconSVG).digest('hex').slice(0, 16); @@ -204,6 +204,77 @@ function buildScenarioTemplate(name) { }; } + if (name === 'mixed') { + return { + ...base, + accounts: [ + { + id: 'acct_mixed_msp', + name: 'Provider Account', + kind: 'msp', + kind_label: 'MSP', + role: 'owner', + can_manage: true, + has_billing: true, + setup_templates: standardSetupTemplates(), + workspaces: [ + { + id: 'ws_mixed_client', + display_name: 'Acme Dental', + state: 'active', + healthy: true, + health_status: 'healthy', + setup_status: 'install_agents', + agent_count: 0, + agent_token_count: 1, + unused_agent_token_count: 1, + alert_route_count: 0, + disabled_alert_route_count: 0, + report_schedule_count: 0, + disabled_report_schedule_count: 0, + created_at: iso('2026-05-28T10:00:00Z'), + }, + ], + members: [ + { email: 'owner@example.com', role: 'owner', user_id: 'u_owner' }, + { email: 'helpdesk@example.com', role: 'tech', user_id: 'u_helpdesk' }, + ], + }, + { + id: 'acct_mixed_cloud', + name: 'Hosted Ops', + kind: 'cloud', + kind_label: 'Cloud', + role: 'admin', + can_manage: true, + has_billing: true, + workspaces: [ + { + id: 'ws_mixed_cloud', + display_name: 'Operations Workspace', + state: 'active', + healthy: true, + health_status: 'healthy', + setup_status: 'ready', + agent_count: 2, + agent_token_count: 2, + unused_agent_token_count: 0, + alert_route_count: 1, + disabled_alert_route_count: 0, + report_schedule_count: 1, + disabled_report_schedule_count: 0, + created_at: iso('2026-05-30T10:00:00Z'), + }, + ], + members: [ + { email: 'admin@example.com', role: 'admin', user_id: 'u_admin' }, + { email: 'viewer@example.com', role: 'read_only', user_id: 'u_viewer' }, + ], + }, + ], + }; + } + return { ...base, accounts: [{ @@ -800,11 +871,11 @@ function routeAccountAPI(request, response, url, bootstrap, scenario) { return item.id === resourceID; }); if (!workspace) { - sendJSON(response, 404, { error: 'Workspace not found.' }); + sendJSON(response, 404, { error: 'Client workspace not found.' }); return; } if (workspace.state !== 'active') { - sendJSON(response, 409, { error: 'Workspace is not active.' }); + sendJSON(response, 409, { error: 'Client workspace is not active.' }); return; } response.writeHead(303, { @@ -855,7 +926,7 @@ function routeAccountAPI(request, response, url, bootstrap, scenario) { return item.id === resourceID; }); if (!workspace) { - sendJSON(response, 404, { error: 'Workspace not found.' }); + sendJSON(response, 404, { error: 'Client workspace not found.' }); return; } if (String(body.state || '').trim() === 'suspended') { @@ -1212,6 +1283,7 @@ server.listen(previewPort, previewHost, function() { console.log('[portal-preview] readonly -> http://' + previewHost + ':' + String(previewPort) + '/?scenario=readonly'); console.log('[portal-preview] selfhosted -> http://' + previewHost + ':' + String(previewPort) + '/?scenario=selfhosted'); console.log('[portal-preview] empty -> http://' + previewHost + ':' + String(previewPort) + '/?scenario=empty'); + console.log('[portal-preview] mixed -> http://' + previewHost + ':' + String(previewPort) + '/?scenario=mixed'); }); async function shutdown(signal) { diff --git a/internal/cloudcp/portal/frontend/src/account_roles.test.ts b/internal/cloudcp/portal/frontend/src/account_roles.test.ts index 1ca3336df..7660b7c5f 100644 --- a/internal/cloudcp/portal/frontend/src/account_roles.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_roles.test.ts @@ -12,5 +12,7 @@ describe('account roles', function() { it('returns product copy for read-only operators', function() { expect(portalRoleCapabilityCopy('read_only')).toContain('review workspace status'); + expect(portalRoleCapabilityCopy('read_only', true)).toContain('review client status'); + expect(portalRoleCapabilityCopy('tech', true)).toContain('manage clients'); }); }); diff --git a/internal/cloudcp/portal/frontend/src/account_roles.ts b/internal/cloudcp/portal/frontend/src/account_roles.ts index 4caa9d514..dda38cc76 100644 --- a/internal/cloudcp/portal/frontend/src/account_roles.ts +++ b/internal/cloudcp/portal/frontend/src/account_roles.ts @@ -20,16 +20,24 @@ export function portalRoleLabel(role: string): string { } } -export function portalRoleCapabilityCopy(role: string): string { +export function portalRoleCapabilityCopy(role: string, clientLanguage = false): string { switch (normalizePortalRole(role)) { case 'owner': - return 'Full account control, including billing, access control, and workspace control.'; + return clientLanguage + ? 'Full account control, including billing, access control, and client control.' + : 'Full account control, including billing, access control, and workspace control.'; case 'admin': - return 'Can manage workspaces and billing for this account.'; + return clientLanguage + ? 'Can manage clients and billing for this account.' + : 'Can manage workspaces and billing for this account.'; case 'tech': - return 'Can manage workspaces without billing ownership.'; + return clientLanguage + ? 'Can manage clients without billing ownership.' + : 'Can manage workspaces without billing ownership.'; case 'read_only': - return 'Can review workspace status without making control-plane changes.'; + return clientLanguage + ? 'Can review client status without making control-plane changes.' + : 'Can review workspace status without making control-plane changes.'; case 'member': return 'Has access through the account roster.'; default: diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts index 1a2e2c469..a0f1b643e 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts @@ -209,6 +209,66 @@ describe('account runtime', function() { expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe(''); }); + it('routes suspended MSP client deletion through the management panel', async function() { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}))); + var confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + deps.store.setBootstrap({ + authenticated: true, + email: 'owner@example.com', + accounts: [{ + id: 'acct_1', + name: 'Acme MSP', + kind: 'msp', + kind_label: 'MSP', + role: 'owner', + can_manage: true, + has_billing: true, + members: [], + workspaces: [{ + id: 'ws_3', + display_name: 'Paused Client', + state: 'suspended', + healthy: false, + health_status: 'unhealthy', + }], + }], + }); + + document.body.innerHTML = + '
' + + '
' + + '
' + + '' + + '
' + + '' + + '
' + + '
' + + '
'; + + runtime.selectWorkspace('acct_1', 'ws_3'); + expect(document.getElementById('workspace-management-action-acct_1')?.textContent).toContain('Delete client'); + + await runtime.manageWorkspaceAction('acct_1', 'ws_3', 'delete', 'Paused Client'); + await flushAsync(); + + expect(confirmSpy).toHaveBeenCalledWith('Delete client "Paused Client"?'); + expect(fetch).toHaveBeenCalledWith( + '/api/accounts/acct_1/tenants/ws_3', + expect.objectContaining({ method: 'DELETE' }) + ); + expect(deps.showToast).toHaveBeenCalledWith('Deleted client.'); + expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe(''); + }); + it('reveals the setup panel when a workspace job opens below the viewport', function() { deps.store.setBootstrap({ authenticated: true, diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts index d3ace8595..acc730349 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts @@ -255,6 +255,33 @@ describe('account view', function() { expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('1'); }); + it('uses client language for MSP access role capability copy', function() { + document.body.innerHTML = + '
' + + '
' + + '
' + + '
'; + + renderAccessSection( + 'acct_1', + createEntry({ + accessVisible: true, + accessQuery: { + status: 'ready', + error: '', + data: [ + { email: 'tech@example.com', role: 'tech', user_id: 'u2' }, + { email: 'viewer@example.com', role: 'read_only', user_id: 'u3' }, + ], + }, + }) + ); + + expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Can manage clients without billing ownership.'); + expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Can review client status without making control-plane changes.'); + expect(document.getElementById('access-list-acct_1')?.textContent).not.toContain('Can manage workspaces'); + }); + it('renders access roster as view-only when the account is not manageable', function() { document.body.innerHTML = '
' + diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index 4966dc340..7abd25d92 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -397,7 +397,7 @@ function renderAccessMemberAction(accountID: string, member: PortalAccessMember, return group; } -function renderAccessMemberRow(accountID: string, member: PortalAccessMember, isOwner: boolean, canManage: boolean, activeJob: PortalAccessJob): HTMLElement { +function renderAccessMemberRow(accountID: string, member: PortalAccessMember, isOwner: boolean, canManage: boolean, activeJob: PortalAccessJob, clientLanguage: boolean): HTMLElement { var showActionColumn = canManage && activeJob === 'remove'; var row = document.createElement('div'); row.className = 'access-member-row' + (showActionColumn ? '' : ' access-member-row-readonly'); @@ -431,7 +431,7 @@ function renderAccessMemberRow(accountID: string, member: PortalAccessMember, is caption.className = 'access-member-caption'; caption.textContent = (member.state || 'active') === 'pending' ? 'Invitation pending acceptance.' - : portalRoleCapabilityCopy(member.role); + : portalRoleCapabilityCopy(member.role, clientLanguage); identity.appendChild(caption); row.appendChild(identity); @@ -494,6 +494,7 @@ export function renderAccessSection(accountID: string, entry: PortalAccountUIEnt var actorRole = section.getAttribute('data-actor-role') || ''; var isOwner = actorRole === 'owner'; var canManage = section.getAttribute('data-can-manage') === 'true'; + var clientLanguage = section.getAttribute('data-client-language') === 'true'; var activeJob = canManage ? entry.activeAccessJob : ''; section.classList.toggle('visible', entry.accessVisible); renderAccessStats(accountID, entry, canManage); @@ -544,7 +545,7 @@ export function renderAccessSection(accountID: string, entry: PortalAccountUIEnt renderAccessRosterHead(roster, activeJob, canManage); for (var i = 0; i < entry.accessQuery.data.length; i += 1) { var member = entry.accessQuery.data[i]; - roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob)); + roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob, clientLanguage)); } } diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 0fc03fd6e..4b97091f4 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -224,8 +224,7 @@ describe('shell view', function() { expect(html).toContain('Gamma Workspace'); expect(html).toContain('Unhealthy'); expect(html).toContain('Checking'); - expect(html).toContain('This workspace is in a failed state.'); - expect(html).toContain('Health check pending'); + expect(html).toContain('This client is in a failed state.'); expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff'); expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff?target_path=%2Fsettings%2Finfrastructure%3Fadd%3Dlinux-host'); expect(html).toContain('Open client'); @@ -248,9 +247,10 @@ describe('shell view', function() { expect(html).toContain('id="access-detail-acct_1" hidden'); expect(html).toContain('Choose the smallest role'); expect(html).toContain('Full account, billing, and access control.'); - expect(html).toContain('Workspace control, billing, and roster management.'); - expect(html).toContain('Workspace control without billing or roster ownership.'); - expect(html).toContain('Review access without control-plane changes.'); + expect(html).toContain('Client control, billing, and roster management.'); + expect(html).toContain('Client control without billing or roster ownership.'); + expect(html).toContain('Review client status without control-plane changes.'); + expect(html).not.toContain('Workspace control, billing, and roster management.'); expect(html).toContain('data-can-manage="true"'); expect(html).toContain('Remove stale access'); expect(html).toContain('data-action="workspace-action"'); @@ -396,6 +396,71 @@ describe('shell view', function() { expect(html).not.toContain('Services'); }); + it('labels mixed account surfaces and keeps MSP client copy scoped', function() { + var html = renderAuthenticatedPortalHTML( + createContext({ + bootstrap: createBootstrap({ + accounts: [ + { + id: 'acct_msp_mixed', + name: 'Provider Account', + kind: 'msp', + kind_label: 'MSP', + role: 'owner', + can_manage: true, + has_billing: true, + members: [], + workspaces: [ + { + id: 'ws_client', + display_name: 'Acme Dental', + state: 'active', + healthy: true, + health_status: 'healthy', + }, + ], + }, + { + id: 'acct_cloud_mixed', + name: 'Hosted Ops', + kind: 'cloud', + kind_label: 'Cloud', + role: 'admin', + can_manage: true, + has_billing: true, + members: [], + workspaces: [ + { + id: 'ws_ops', + display_name: 'Operations Workspace', + state: 'active', + healthy: true, + health_status: 'healthy', + agent_count: 1, + alert_route_count: 1, + report_schedule_count: 1, + }, + ], + }, + ], + }), + }) + ); + + expect(html).toContain('data-shell-section="workspaces">Workspaces'); + expect(html).toContain('

Provider Account

'); + expect(html).toContain('MSP account · Owner'); + expect(html).toContain('

Hosted Ops

'); + expect(html).toContain('Cloud account · Admin'); + expect(html).toContain('data-client-language="true"'); + expect(html).toContain('data-client-language="false"'); + expect(html).toContain('Add client'); + expect(html).toContain('Open client'); + expect(html).toContain('Open workspace'); + expect(html).toContain('Client control, billing, and roster management.'); + expect(html).toContain('Workspace control, billing, and roster management.'); + }); + it('renders one simple account context strip without summary facts', function() { var html = renderAuthenticatedPortalHTML( createContext({ @@ -795,6 +860,8 @@ describe('shell view', function() { ); expect(html).toContain('Suspended'); + expect(html).toContain('This client is suspended.'); + expect(html).not.toContain('This workspace is suspended.'); expect(html).toContain('Add client'); expect(html).toContain('Paused Workspace'); }); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index d14ebc81d..0023af4b7 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -472,9 +472,6 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor if (createdLabel) { metaParts.push('Created ' + escapeHTML(createdLabel) + ''); } - if (workspace.last_health_check && status === 'healthy') { - metaParts.push('Checked recently'); - } var openAction = ''; if (state === 'active') { @@ -511,9 +508,8 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor '
' + '
' + '

' + escapeHTML(workspace.display_name) + '

' + - '
' + metaParts.join('') + '
' + '
' + - '
' + escapeHTML(workspaceRowNote(workspace)) + '
' + + (metaParts.length ? '
' + metaParts.join('') + '
' : '') + '
' + '
' + setupBadgeHTML(workspace) + @@ -641,6 +637,14 @@ function workspaceSetupDiagnosticsLine(workspace: PortalWorkspaceSummary): strin return guide.diagnostics.length ? guide.diagnostics[0] : workspaceSetupNextStep(workspace); } +function workspaceSummaryStatusCopy(workspace: PortalWorkspaceSummary, clientLanguage: boolean): string { + if (!clientLanguage) return workspaceStatusCopy(workspace); + var state = String(workspace.state || ''); + if (state === 'suspended') return 'This client is suspended.'; + if (state === 'failed') return 'This client is in a failed state.'; + return workspaceStatusCopy(workspace); +} + function workspaceSummaryContext(entry: WorkspaceSummaryEntry, includeAccountName: boolean, note: string): string { if (!includeAccountName) return note; return entry.account.name + ' · ' + note; @@ -684,7 +688,7 @@ function renderWorkspaceSummaryDecision( if (attention.length) { var attentionEntry = attention[0]; title = 'Review ' + attentionEntry.workspace.display_name; - description = workspaceSummaryContext(attentionEntry, accounts.length > 1, workspaceStatusCopy(attentionEntry.workspace)); + description = workspaceSummaryContext(attentionEntry, accounts.length > 1, workspaceSummaryStatusCopy(attentionEntry.workspace, clientLanguage)); primaryAction = renderWorkspaceAnchorAction( workspaceRowAnchorID(attentionEntry.account.id, attentionEntry.workspace.id), clientLanguage ? 'Review client' : 'Review workspace', @@ -704,7 +708,7 @@ function renderWorkspaceSummaryDecision( } else if (suspended.length) { var suspendedEntry = suspended[0]; title = 'Review ' + suspendedEntry.workspace.display_name; - description = workspaceSummaryContext(suspendedEntry, accounts.length > 1, workspaceStatusCopy(suspendedEntry.workspace)); + description = workspaceSummaryContext(suspendedEntry, accounts.length > 1, workspaceSummaryStatusCopy(suspendedEntry.workspace, clientLanguage)); primaryAction = renderWorkspaceAnchorAction( workspaceRowAnchorID(suspendedEntry.account.id, suspendedEntry.workspace.id), clientLanguage ? 'Review client' : 'Review workspace', @@ -880,19 +884,19 @@ function renderProviderSetupTemplates(accounts: PortalAccountSummary[]): string var template = templateAccount.setup_templates[0]; return ( '
' + - '
' + - '
' + - '

' + escapeHTML(template.title || 'Provider setup template') + '

' + - '

Use the same onboarding shape for each client, then finish the tenant-owned configuration inside that client workspace.

' + + '
' + + '' + + '' + escapeHTML(template.title || 'Provider setup template') + '' + + '' + escapeHTML(templateAccount.name) + '' + + '' + + '

Use the same onboarding shape for each client, then finish the tenant-owned configuration inside that client workspace.

' + + '
' + + '
Agent naming' + escapeHTML(template.agent_naming) + '
' + + '
Alert routing' + escapeHTML(template.alert_routing) + '
' + + '
Reports' + escapeHTML(template.reporting) + '
' + + '
Access' + escapeHTML(template.access) + '
' + '
' + - '' + escapeHTML(templateAccount.name) + '' + - '
' + - '
' + - '
Agent naming' + escapeHTML(template.agent_naming) + '
' + - '
Alert routing' + escapeHTML(template.alert_routing) + '
' + - '
Reports' + escapeHTML(template.reporting) + '
' + - '
Access' + escapeHTML(template.access) + '
' + - '
' + + '' + '
' ); } @@ -994,6 +998,18 @@ function renderAccountBlockHeader( ); } +function renderAccountSurfaceHeader(account: PortalAccountSummary, showHeader: boolean): string { + if (!showHeader) return ''; + return ( + '' + ); +} + function renderNoHostedWorkspacesSection(): string { return ( '
'; var accessInvitePanel = account.can_manage @@ -1344,6 +1366,8 @@ function renderAccountAccessSection(account: PortalAccountSummary): string { escapeAttr(account.role) + '" data-can-manage="' + escapeAttr(account.can_manage ? 'true' : 'false') + + '" data-client-language="' + + escapeAttr(clientLanguage ? 'true' : 'false') + '">' + (!account.can_manage ? '

' + escapeHTML('Review who has access. An owner or admin must make changes.') + '

' @@ -1551,6 +1575,7 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string ? accounts.map(function(account) { return ( '
' + + renderAccountSurfaceHeader(account, accounts.length > 1) + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + '
' ); @@ -1561,6 +1586,7 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string ? accounts.map(function(account) { return ( '
' + + renderAccountSurfaceHeader(account, accounts.length > 1) + renderAccountAccessSection(account) + '
' ); diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index e8e21f87c..25500cbaf 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -293,6 +293,10 @@ header .logout-btn:hover, border-top: 1px solid var(--border); } +.account-surface-header .portal-section-copy { + margin: 4px 0 0; +} + .account-content-panel { display: block; } .account-content-panel-workspaces, @@ -357,7 +361,7 @@ header .logout-btn:hover, .workspace-row-primary { display: flex; flex-direction: column; - gap: 1px; + gap: 2px; min-width: 0; } @@ -370,13 +374,19 @@ header .logout-btn:hover, .workspace-name { margin: 0; + min-width: 0; + max-width: 100%; font-size: 14px; font-weight: 600; color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .workspace-meta { display: flex; + flex-wrap: wrap; gap: 6px; } @@ -391,12 +401,6 @@ header .logout-btn:hover, color: var(--border); } -.workspace-row-note { - margin: 0; - font-size: 12px; - color: var(--ink-secondary); -} - .workspace-row-status-cell { display: flex; align-items: center; @@ -1524,29 +1528,33 @@ header .logout-btn:hover, background: var(--bg); } -.workspace-template-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.workspace-template-heading h3 { - margin: 0; +.workspace-template-summary { + cursor: pointer; font-size: 14px; + font-weight: 600; color: var(--ink); } -.workspace-template-heading p { - margin: 3px 0 0; +.workspace-template-summary::marker, +.workspace-template-summary::-webkit-details-marker { + color: var(--ink-muted); +} + +.workspace-template-summary-meta { + margin-left: 8px; + color: var(--ink-secondary); + font-size: 12px; + font-weight: 400; +} + +.workspace-template-intro { + margin: 10px 0 0; color: var(--ink-secondary); font-size: 12px; } -.workspace-template-heading > span { - flex: 0 0 auto; - color: var(--ink-secondary); - font-size: 12px; +.workspace-template-details[open] .workspace-template-grid { + margin-top: 10px; } .workspace-template-grid { @@ -1754,9 +1762,6 @@ header .logout-btn:hover, .workspace-setup-queue-actions { justify-content: flex-start; } - .workspace-template-heading { - flex-direction: column; - } .workspace-template-grid, .workspace-setup-guide { grid-template-columns: 1fr; diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index f12ee6133..e0b0abd6a 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 318, + "line": 327, "heading_line": 112, } ],