From 4742159bb32e097e0ffd80552d8e937d84302064 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:31:32 +0100 Subject: [PATCH 1/2] Let agents without a command channel reduce their authority Deploy enrollment, Proxmox auto-registration and pre-v6.1.1 installs leave an agent's token bound to the node's hostname and to no agent ID until the agent registers a command channel. An agent whose token lacks agent:exec, or that never connects that channel, never gets an ID on its token, and the safe-profile migration's authority reduction, which matches that ID, refuses it for as long as the agent runs. A report the monitor resolved under such a token now records the server-resolved host ID on it when the report presented that same ID, which in the same run is the ID the agent's command registration presents. The shared binding policy must first-bind, migrate or backfill that ID for the reported hostname, the token must run command sessions in the report's organization, and the binding version makes the ID immutable. Scopes, runtime role and command policy are untouched, so a recorded identity grants nothing; command registration still needs agent:exec and the same ID. When the presented and resolved IDs differ, nothing is recorded and the command channel decides as before. As with a command registration, the first identity recorded is final, so an agent later restarted under a different --agent-id is refused under that ID on the same token. --- .../v6/internal/subsystems/agent-lifecycle.md | 27 ++ .../v6/internal/subsystems/api-contracts.md | 8 + .../internal/subsystems/security-privacy.md | 34 ++ .../internal/subsystems/storage-recovery.md | 11 + internal/api/agent_exec_token_binding.go | 82 ++++ internal/api/agent_ingest.go | 28 ++ internal/api/agentbinding/policy.go | 47 ++ internal/api/agentbinding/policy_test.go | 94 ++++ internal/api/contract_test.go | 79 ++++ internal/api/router.go | 1 + internal/api/security_regression_test.go | 413 ++++++++++++++++++ .../monitoring/monitor_host_agents_test.go | 60 +++ 12 files changed, 884 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index dec04753a8..625a5b24f4 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -8248,6 +8248,33 @@ in `internal/api/deploy_handlers_test.go` replays that sequence: the identity enrollment handed out, or the agent's own, then the acknowledged identity after a restart. +### Reports name the agent on a hostname-bound token + +Deploy enrollment, Proxmox auto-registration, and pre-v6.1.1 installs leave a +token bound to the node's hostname and to no agent ID until a command +registration names the agent. An agent whose token lacks `agent:exec`, or that +never connects the command channel, never names itself that way, and the +safe-profile authority reduction refuses it, because it matches the agent ID +on the token. Every applied report therefore offers its identity: when the +agent presented the same ID Pulse resolved and acknowledges in `agentId` +(which the agent persists and the installer sends to the reduction), that ID +is recorded on a token that has none, under the reporting-side rule in the +security-privacy contract. The report grants no command authority, and when +the presented and resolved IDs differ the token is left to the command +channel's first registration. The first identity recorded is final, as it is +for a command registration, so an agent later restarted under a different +configured `--agent-id` is refused under that ID on the same token. +`TestContractDeployEnrolledCollectorReducesItsAuthorityAfterItsFirstReport` +in `internal/api/contract_test.go` replays enrollment without commands, the +first report, and the reduction, which was refused before it; +`TestReportedIdentityIsFinalForALaterReconfiguredAgent` in +`internal/api/security_regression_test.go` pins the final-identity trade-off; +and `TestApplyHostReportStampsTheTokenOnlyOnHostsItHolds` in +`internal/monitoring/monitor_host_agents_test.go` pins the host properties the +report path relies on: an applied report carries its token's ID, a report the +ordering watermark rejects returns the stored host and hostname, and mock +mode's discard acknowledgement carries no token. + ### Alert push routing does not create an agent lifecycle channel The shared API router may classify an external-probe outage for specialized diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 8026e32a35..767a622371 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -483,6 +483,14 @@ collector baseline, removes every scope outside the exact collector allowlist from that token, persists before success, restores the full token inventory on persistence failure, and invalidates only the exact matching collector session after persistence. +A token bound only to a hostname carries no agent ID for that match until +something names the agent. `POST /api/agents/agent/report` names it: when the +reporting agent presented the same ID the response acknowledges in `agentId`, +that ID is recorded on a hostname-bound token that has none, so a collector +that never connected the command channel can still reduce its authority. The +report request and response shapes are unchanged. +`TestContractDeployEnrolledCollectorReducesItsAuthorityAfterItsFirstReport` +in `internal/api/contract_test.go` pins report, then reduction. ## Canonical Files diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 7ee7c0afba..e9c82794b3 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -1475,6 +1475,40 @@ the agent config gate consumes the same decision. `TestVersionlessDeployPlaceholderCannotBeMovedTwice`, and `TestCurrentDeployTokenNeverMovesOnceBound` in `internal/api/security_regression_test.go` pin that boundary. +Authenticated reports also name the agent on a token bound only to its +hostname, so a token without `agent:exec`, or whose agent never connects the +command channel, still carries the identity the safe-profile authority +reduction matches. After `/api/agents/agent/report` applies a report, +`recordReportedAgentIdentity` in `internal/api/agent_exec_token_binding.go` +writes the server-resolved host ID as `bound_agent_id`, with `bound_at` and the +current binding version, only when the token has a `bound_hostname` and no +`bound_agent_id`, the report presented exactly the ID Pulse resolved for it +(in the same run, the ID the agent's command registration presents), and +`agentbinding.EvaluateReportedIdentity` finds that the shared `Evaluate` +decision would first-bind, legacy-migrate, or backfill that ID for the +reported hostname. The token must also run command sessions in the request's +organization, be bound to at most one organization, and carry a collector, +legacy full-trust, or no runtime role, and the host must be one the monitor +resolved and holds under that token, so mock mode's discarded, unresolved +acknowledgements never qualify. The path never changes scopes, runtime role, +command policy, or `bound_hostname`, and never moves a recorded ID or repairs +a deploy placeholder. The binding version makes the recorded ID immutable to +every later registration, and a recorded identity grants no execution +authority: command admission still requires `agent:exec` and the same ID. +As with a command registration, the first identity recorded is final: an +agent later restarted under a different configured `--agent-id`, or replaying +a report buffered by an earlier run, meets a token already bound to its first +reported identity and is refused under the new one. When the presented and +resolved IDs differ (a configured agent ID, a continuity fork), nothing is +recorded and the command channel's first registration decides as before; +reconciling that disagreement belongs to command-channel identity +convergence. A token bound to no hostname is left to command-channel first +use. The write runs against the live record under the token lock command +admission holds, and a failed save restores the previous metadata. +`TestEvaluateReportedIdentityRecordsOnlyWhatCommandRegistrationWouldBind` in +`internal/api/agentbinding/policy_test.go`, and the `TestReportedIdentity*` +tests and `TestReportHandlerOffersOnlyQualifyingLiveReportsToTheRecorder` in +`internal/api/security_regression_test.go`, pin that boundary. Pulse-minted install tokens now carry a server-authored command-policy intent in addition to first-use binding metadata. That intent grants no independent authority: first-report convergence requires the same shared binding decision diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 929e8c1c85..7909a2bab7 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6486,6 +6486,17 @@ write never leaves a half-moved token. This adds no backup, restore, retention, recovery authority. `internal/api/security_regression_test.go` pins the persisted binding. +### Reported agent identity is one persisted token transition + +Recording a report's agent identity on a hostname-bound token writes +`bound_agent_id`, `bound_at`, and the binding version through the existing +API-token persistence, under the token lock, in one save. When that save +fails, the previous metadata is restored, the report itself is still +acknowledged, and the next report retries, so a failed write never leaves a +half-bound token. This adds no backup, restore, retention, snapshot, or +recovery authority. `TestReportedIdentityRestoresTheTokenWhenTheSaveFails` in +`internal/api/security_regression_test.go` pins the restore and the retry. + ### Destination severity policy preserves notification recovery identity Email, webhook, Apprise, and Relay minimum-severity floors persist through diff --git a/internal/api/agent_exec_token_binding.go b/internal/api/agent_exec_token_binding.go index 765525a805..b81045ed07 100644 --- a/internal/api/agent_exec_token_binding.go +++ b/internal/api/agent_exec_token_binding.go @@ -347,6 +347,88 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri return agentexec.AgentAdmission{}, false } +// recordReportedAgentIdentity is the reporting-side identity binding. After an +// authenticated report is applied, a token bound to the report's hostname but +// to no agent ID records the server-resolved host ID when the agent presented +// that same ID (agentbinding.EvaluateReportedIdentity). Without it, a token +// that lacks agent:exec, or whose agent never connects the command channel, +// never names its agent, and the collector-authority reduction, which needs +// that name, can never run for it. +// +// Only bound_agent_id, bound_at and the binding version are written, the +// binding version so the recorded ID is immutable to every later +// registration. Scopes, runtime role and command policy are left alone: a +// recorded identity grants no execution authority, and a command registration +// still needs agent:exec and the same identity. It runs under the token lock +// that command admission holds, against the live record, so the two paths +// cannot race each other into different identities. A failed save restores +// the previous metadata and the next report retries. +func (r *Router) recordReportedAgentIdentity(organizationID, tokenID, presentedID, hostID, hostname string) { + if r == nil || r.config == nil { + return + } + organizationID = strings.TrimSpace(organizationID) + if organizationID == "" { + organizationID = "default" + } + tokenID = strings.TrimSpace(tokenID) + hostID = strings.TrimSpace(hostID) + if tokenID == "" || hostID == "" { + return + } + + config.Mu.Lock() + defer config.Mu.Unlock() + for index := range r.config.APITokens { + record := &r.config.APITokens[index] + if record.ID != tokenID { + continue + } + if record.IsExpired() { + return + } + // The host ID was resolved in the request's organization. Record it only + // on a token whose command sessions would run in that same organization, + // derived exactly as command admission derives it. + tokenOrganizationID := "default" + if orgs := record.GetBoundOrgs(); len(orgs) == 1 && strings.TrimSpace(orgs[0]) != "" { + tokenOrganizationID = strings.TrimSpace(orgs[0]) + } + if tokenOrganizationID != organizationID { + return + } + if !agentbinding.EvaluateReportedIdentity(record, presentedID, hostID, hostname) { + return + } + previousMetadata := snapshotAgentExecMetadata( + record.Metadata, + "bound_agent_id", + "bound_at", + agentExecBindingVersionKey, + ) + record.Metadata["bound_agent_id"] = hostID + record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339) + record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion + if r.persistence != nil { + if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { + restoreAgentExecMetadata(record.Metadata, previousMetadata) + log.Warn(). + Err(err). + Str("token_id", tokenID). + Str("agent_id", hostID). + Msg("Failed to persist reported agent identity on its token; the next report retries") + return + } + } + log.Info(). + Str("token_id", tokenID). + Str("agent_id", hostID). + Str("hostname", strings.TrimSpace(record.Metadata["bound_hostname"])). + Msg("Bound hostname-bound agent token to the agent identity its reports resolve to") + return + } +} + func (r *Router) validateAgentExecSession(admission agentexec.AgentAdmission) bool { if r == nil || r.config == nil { return false diff --git a/internal/api/agent_ingest.go b/internal/api/agent_ingest.go index 5d43fa882c..b3b13a0d6b 100644 --- a/internal/api/agent_ingest.go +++ b/internal/api/agent_ingest.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding" "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" @@ -44,6 +45,10 @@ type UnifiedAgentHandlers struct { // check. Empty (the default) omits it from acks. serverVersion string + // recordReportedIdentity records a report's server-resolved identity on + // its hostname-bound token. Nil (the default) records nothing. + recordReportedIdentity func(organizationID, tokenID, presentedID, hostID, hostname string) + configFetchAudits *agentConfigFetchAuditTracker } @@ -54,6 +59,12 @@ func (h *UnifiedAgentHandlers) SetServerVersion(version string) { h.serverVersion = strings.TrimSpace(version) } +// SetReportedIdentityRecorder supplies the canonical token-store binding that +// records the agent identity a report resolves to. Call once at wiring time. +func (h *UnifiedAgentHandlers) SetReportedIdentityRecorder(record func(organizationID, tokenID, presentedID, hostID, hostname string)) { + h.recordReportedIdentity = record +} + func trimUnifiedAgentRoutePath(path string) string { for _, prefix := range []string{agentRoutePrefix, legacyHostRoutePrefix} { if strings.HasPrefix(path, prefix) { @@ -96,6 +107,23 @@ func (h *UnifiedAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Reque return } + // Name the agent on a token bound only to its hostname, so the token + // carries an identity even if this agent never registers a command channel. + // Only a host the monitor resolved and holds under this token carries its + // token ID; mock mode's discard acknowledgement echoes the presented ID + // unresolved and carries none, whenever mock mode flips. The hostname is + // the report's own, which is what the agent's command registration + // presents: a report the ordering watermark rejects returns the stored + // host, whose hostname may come from a different report. The request's + // token snapshot screens out tokens that are already bound or do not + // qualify without taking the exclusive token lock on every report; the + // recorder decides again on the live record. + reportedHostname := strings.TrimSpace(report.Host.Hostname) + if h.recordReportedIdentity != nil && tokenRecord != nil && host.TokenID == tokenRecord.ID && + agentbinding.EvaluateReportedIdentity(tokenRecord, report.Agent.ID, host.ID, reportedHostname) { + h.recordReportedIdentity(GetOrgID(r.Context()), tokenRecord.ID, report.Agent.ID, host.ID, reportedHostname) + } + // A freshly generated install token is the server's durable record of the // operator's command-policy choice. Project that choice onto the stable host // identity before returning remote config, otherwise an old false override diff --git a/internal/api/agentbinding/policy.go b/internal/api/agentbinding/policy.go index 4e85dc44b3..eb940183de 100644 --- a/internal/api/agentbinding/policy.go +++ b/internal/api/agentbinding/policy.go @@ -108,6 +108,53 @@ func Evaluate(record *config.APITokenRecord, requestedID, requestedHost string) } } +// EvaluateReportedIdentity reports whether an authenticated agent report may +// record the reporting agent's identity on its token. It covers only a token +// bound to a hostname but not yet to an agent ID, which is how deploy +// enrollment, Proxmox auto-registration and pre-v6.1.1 installs leave it until +// a command registration, so a token without agent:exec, or whose agent never +// connects the command channel, would otherwise never name its agent. +// +// The identity the agent presented in the report must equal the identity the +// server resolved for it. In the same run that is the ID the agent's command +// registration presents, so recording it binds what the command channel would +// bind, and a disagreement (a configured agent ID, a continuity fork) records +// nothing. The token must also take a first bind, legacy migration, or +// backfill under Evaluate, the command channel's own decision, so a token +// command registration would refuse records nothing either. As with a command +// registration, the first identity recorded is final: an agent later restarted +// under a different configured ID, or replaying a report buffered by an +// earlier run, meets a bound token. Bound IDs never move, deploy placeholders +// are never repaired, hostnames never change, and only credentials that report +// under a single organization qualify. +func EvaluateReportedIdentity(record *config.APITokenRecord, presentedID, resolvedID, reportedHost string) bool { + if record == nil { + return false + } + presentedID = strings.TrimSpace(presentedID) + resolvedID = strings.TrimSpace(resolvedID) + if resolvedID == "" || presentedID != resolvedID { + return false + } + if strings.TrimSpace(record.Metadata["bound_agent_id"]) != "" || + strings.TrimSpace(record.Metadata["bound_hostname"]) == "" { + return false + } + if len(record.GetBoundOrgs()) > 1 { + return false + } + switch strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey]) { + case "", agenttokens.CredentialKindLegacyFullTrust, agenttokens.CredentialKindMonitoringCollector: + default: + return false + } + // With no bound ID and a bound hostname, Evaluate admits only through a + // first bind, a legacy migration or an ID backfill, never a rebind or a + // placeholder repair; the explicit check keeps that true if Evaluate grows. + decision := Evaluate(record, resolvedID, reportedHost) + return decision.Admit && (decision.FirstBind || decision.LegacyMigrate || decision.BackfillID) +} + // historicalDeployPlaceholder reports a runtime token that deploy enrollment // bound to the agent- identity it used to invent. No agent keeps // that identity past its first acknowledged report, so such a token may move diff --git a/internal/api/agentbinding/policy_test.go b/internal/api/agentbinding/policy_test.go index d7500c2ee4..55d8d6bae4 100644 --- a/internal/api/agentbinding/policy_test.go +++ b/internal/api/agentbinding/policy_test.go @@ -160,3 +160,97 @@ func TestEvaluateCurrentDeployTokenBackfillsTheAgentIdentity(t *testing.T) { t.Fatalf("current deploy token admitted another host: %+v", decision) } } + +// A report may name the agent on a token bound only to its hostname, but only +// with the identity the command channel would bind for that same agent, and +// never by moving, repairing, or rehosting a binding. +func TestEvaluateReportedIdentityRecordsOnlyWhatCommandRegistrationWouldBind(t *testing.T) { + deployToken := func() *config.APITokenRecord { + return &config.APITokenRecord{OrgID: "default", Metadata: map[string]string{ + "bound_hostname": "delly2", + "deploy_job_id": "dep_1", + VersionKey: Version, + DeployIdentityKey: DeployIdentityAgent, + }} + } + autoRegistered := func() *config.APITokenRecord { + return &config.APITokenRecord{OrgID: "default", Metadata: map[string]string{ + "install_type": "pve", + "issued_via": IssuedViaConfig, + "bound_hostname": "pve1", + agenttokens.RuntimeRoleMetadataKey: agenttokens.CredentialKindMonitoringCollector, + }} + } + legacyHostnameOnly := func() *config.APITokenRecord { + return &config.APITokenRecord{Metadata: map[string]string{"bound_hostname": "node.example"}} + } + + for _, test := range []struct { + name string + record *config.APITokenRecord + host string + }{ + {name: "deploy runtime token", record: deployToken(), host: "delly2"}, + {name: "deploy runtime token on an equivalent hostname", record: deployToken(), host: "delly2.lan"}, + {name: "Proxmox auto-registered collector", record: autoRegistered(), host: "pve1"}, + {name: "pre-v6.1.1 hostname-only token", record: legacyHostnameOnly(), host: "NODE.example"}, + } { + if !EvaluateReportedIdentity(test.record, "machine-id", "machine-id", test.host) { + t.Fatalf("%s: report identity not recorded", test.name) + } + if EvaluateReportedIdentity(test.record, "configured-id", "machine-id", test.host) { + t.Fatalf("%s: recorded the resolved identity although the agent presented another", test.name) + } + if EvaluateReportedIdentity(test.record, "", "", test.host) { + t.Fatalf("%s: recorded an empty identity", test.name) + } + } + + with := func(record *config.APITokenRecord, mutate func(*config.APITokenRecord)) *config.APITokenRecord { + mutate(record) + return record + } + for _, test := range []struct { + name string + record *config.APITokenRecord + host string + }{ + {name: "another hostname", record: deployToken(), host: "other-node"}, + {name: "an already bound ID", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) { + r.Metadata["bound_agent_id"] = "other-machine" + })}, + {name: "an ID already recorded", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) { + r.Metadata["bound_agent_id"] = "machine-id" + })}, + // Evaluate would legacy-migrate this one on command registration; the + // report path never moves an ID. + {name: "a versionless token already bound to an ID", host: "node.example", record: with(legacyHostnameOnly(), func(r *config.APITokenRecord) { + r.Metadata["bound_agent_id"] = "other-machine" + })}, + {name: "a historical deploy placeholder", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) { + r.Metadata["bound_agent_id"] = "agent-delly2" + delete(r.Metadata, DeployIdentityKey) + })}, + {name: "a token bound to no hostname", host: "node.example", record: &config.APITokenRecord{OrgID: "default", Metadata: map[string]string{ + "install_type": "host", + "issued_via": IssuedViaConfig, + }}}, + {name: "a token bound to several organizations", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) { + r.OrgID = "" + r.OrgIDs = []string{"org-a", "org-b"} + })}, + {name: "an action-runner credential", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) { + r.Metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindActionRunner + })}, + {name: "an unsupported runtime role", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) { + r.Metadata[agenttokens.RuntimeRoleMetadataKey] = "future-role" + })}, + } { + if EvaluateReportedIdentity(test.record, "machine-id", "machine-id", test.host) { + t.Fatalf("%s: report identity recorded", test.name) + } + } + if EvaluateReportedIdentity(nil, "machine-id", "machine-id", "delly2") { + t.Fatal("nil token recorded an identity") + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 82360b0096..c7dea26e53 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -17,6 +17,7 @@ import ( "path/filepath" "reflect" "regexp" + "slices" "sort" "strings" "sync/atomic" @@ -37,6 +38,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/unified" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding" "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" "github.com/rcourtman/pulse-go-rewrite/internal/api/alerting" "github.com/rcourtman/pulse-go-rewrite/internal/api/chartapi" @@ -25390,3 +25392,80 @@ func TestRouterPlatformConnectionAlertsFollowRuntime(t *testing.T) { } t.Log("real TrueNAS inventory recovery reaches the ledger and clears only after monitor confirmations") } + +func TestContractDeployEnrolledCollectorReducesItsAuthorityAfterItsFirstReport(t *testing.T) { + h, store := newEnrollTestHandlers(t) + jobID, targetID := seedEnrollJobAndTarget(t, store, deploy.TargetEnrolling) + bootstrap := mintTestBootstrapToken(t, h.config, jobID, targetID, "pve-node2") + h.persistence = config.NewConfigPersistence(h.config.DataPath) + + req := httptest.NewRequest(http.MethodPost, "/api/agents/agent/enroll", enrollJSON(t, "pve-node2")) + attachAPITokenRecord(req, bootstrap) + rr := httptest.NewRecorder() + h.HandleEnroll(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("enroll status = %d: %s", rr.Code, rr.Body.String()) + } + var enrolledResp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &enrolledResp); err != nil { + t.Fatalf("decode enroll: %v", err) + } + runtimeToken, _ := enrolledResp["runtimeToken"].(string) + runtimeTokenID, _ := enrolledResp["runtimeTokenId"].(string) + if runtimeToken == "" || runtimeTokenID == "" { + t.Fatalf("enrollment returned no runtime token: %v", enrolledResp) + } + enrolled := reportIdentityToken(t, h.config, runtimeTokenID) + if enrolled.HasScope(config.ScopeAgentExec) || enrolled.Metadata["bound_agent_id"] != "" { + t.Fatalf("precondition: enrolled token = %+v, want hostname-bound without agent:exec", enrolled) + } + + router := newReportIdentityRouter(t, h.config) + if code := postCollectorReduction(t, router, runtimeToken, reportIdentityMachineID, "pve-node2"); code != http.StatusForbidden { + t.Fatalf("reduction before any report = %d, want 403: the token names no agent yet", code) + } + + ack := postReportIdentityReport(t, router, runtimeToken, + reportIdentityReport(reportIdentityMachineID, reportIdentityMachineID, "pve-node2")) + if ack != reportIdentityMachineID { + t.Fatalf("acknowledged agentId = %q, want %q", ack, reportIdentityMachineID) + } + + bound := reportIdentityToken(t, h.config, runtimeTokenID) + if got := bound.Metadata["bound_agent_id"]; got != ack { + t.Fatalf("bound_agent_id after the first report = %q, want the acknowledged %q", got, ack) + } + if got := bound.Metadata["bound_hostname"]; got != "pve-node2" { + t.Fatalf("bound_hostname = %q, want it untouched", got) + } + if got := bound.Metadata[agentExecBindingVersionKey]; got != agentExecBindingVersion { + t.Fatalf("binding version = %q, want %q", got, agentExecBindingVersion) + } + if got := bound.Metadata[agentbinding.DeployIdentityKey]; got != agentbinding.DeployIdentityAgent { + t.Fatalf("deploy identity marker = %q, want it untouched", got) + } + if !slices.Equal(bound.Scopes, enrolled.Scopes) { + t.Fatalf("scopes after the report = %v, want unchanged %v", bound.Scopes, enrolled.Scopes) + } + if got := bound.Metadata[agenttokens.RuntimeRoleMetadataKey]; got != enrolled.Metadata[agenttokens.RuntimeRoleMetadataKey] { + t.Fatalf("runtime role after the report = %q, want unchanged %q", got, enrolled.Metadata[agenttokens.RuntimeRoleMetadataKey]) + } + persisted, err := config.NewConfigPersistence(h.config.DataPath).LoadAPITokens() + if err != nil { + t.Fatalf("load persisted tokens: %v", err) + } + if len(persisted) != 1 || persisted[0].Metadata["bound_agent_id"] != ack { + t.Fatalf("persisted tokens = %+v, want the runtime token bound to %s", persisted, ack) + } + if _, ok := router.admitAgentExecToken(runtimeToken, ack, "pve-node2"); ok { + t.Fatal("recording the agent identity granted command execution to a token without agent:exec") + } + + if code := postCollectorReduction(t, router, runtimeToken, ack, "pve-node2"); code != http.StatusNoContent { + t.Fatalf("reduction with the acknowledged identity = %d, want 204", code) + } + reduced := reportIdentityToken(t, h.config, runtimeTokenID) + if got := reduced.Metadata[agenttokens.RuntimeRoleMetadataKey]; got != agenttokens.CredentialKindMonitoringCollector { + t.Fatalf("runtime role after reduction = %q, want %q", got, agenttokens.CredentialKindMonitoringCollector) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 082251c472..ceec1ce918 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -503,6 +503,7 @@ func (r *Router) setupRoutes() { r.kubernetesAgentHandlers = NewKubernetesAgentHandlers(r.mtMonitor, r.monitor, r.wsHub) r.unifiedAgentHandlers = NewUnifiedAgentHandlers(r.mtMonitor, r.monitor, r.wsHub) r.unifiedAgentHandlers.SetServerVersion(r.serverVersion) + r.unifiedAgentHandlers.SetReportedIdentityRecorder(r.recordReportedAgentIdentity) r.kubernetesAgentHandlers.SetRecoveryIngestor(r.recoveryHandlers) r.resourceHandlers = NewResourceHandlers(r.config) r.resourceHandlers.SetOperatorStateChanged(func(_ string, resourceID string) { diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 67f979cf5b..53794d9fb7 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -22,10 +22,12 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding" "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery" pulsews "github.com/rcourtman/pulse-go-rewrite/internal/websocket" + agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) @@ -5633,3 +5635,414 @@ func TestCurrentDeployTokenNeverMovesOnceBound(t *testing.T) { t.Fatal("a current deploy token moved after its first binding") } } + +const reportIdentityMachineID = "49a2dbb3-3cc8-43b7-ad57-eb1711142454" + +// reportIdentityReport is a report as pulse-agent sends it: the host carries + +// the machine-derived ID and the agent block the ID it presents everywhere, + +// including on command registration. + +func reportIdentityReport(presentedID, machineID, hostname string) agentshost.Report { + return agentshost.Report{ + Agent: agentshost.AgentInfo{ + ID: presentedID, + Version: "6.5.0", + Type: "unified", + IntervalSeconds: 30, + Hostname: hostname, + }, + Host: agentshost.HostInfo{ + ID: machineID, + Hostname: hostname, + MachineID: machineID, + Platform: "linux", + OSName: "debian", + }, + Timestamp: time.Now().UTC(), + } +} + +func newReportIdentityRouter(t *testing.T, cfg *config.Config) *Router { + t.Helper() + monitor, err := monitoring.New(cfg) + if err != nil { + t.Fatalf("new monitor: %v", err) + } + t.Cleanup(monitor.Stop) + return NewRouter(cfg, monitor, nil, nil, func() error { return nil }, "6.5.0") +} + +func reportIdentityToken(t *testing.T, cfg *config.Config, tokenID string) config.APITokenRecord { + t.Helper() + config.Mu.Lock() + defer config.Mu.Unlock() + for index := range cfg.APITokens { + if cfg.APITokens[index].ID == tokenID { + return cfg.APITokens[index].Clone() + } + } + t.Fatalf("token %s not found", tokenID) + return config.APITokenRecord{} +} + +func postReportIdentityReport(t *testing.T, router *Router, token string, report agentshost.Report) string { + t.Helper() + code, payload := issue1753PostReport(t, router, token, report) + if code != http.StatusOK { + t.Fatalf("report rejected with %d (%v)", code, payload) + } + ack, _ := payload["agentId"].(string) + return ack +} + +func postCollectorReduction(t *testing.T, router *Router, token, agentID, hostname string) int { + t.Helper() + body, err := json.Marshal(collectorAuthorityReductionRequest{AgentID: agentID, Hostname: hostname}) + if err != nil { + t.Fatalf("marshal reduction: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/agents/collector/reduce-authority", bytes.NewReader(body)) + req.Header.Set("X-API-Token", token) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + return rec.Code +} + +// A deployed agent enrolled without commands never registers a command + +// channel. Its runtime token stayed bound to the node's hostname alone, so the + +// safe-profile migration's authority reduction, which needs the agent's ID on + +// the token, was refused with "Collector credential binding mismatch" for as + +// long as the agent ran. The first acknowledged report now names the agent. + +func autoRegisteredInstallToken(t *testing.T, raw, hostname string, enableCommands bool) config.APITokenRecord { + t.Helper() + role := agenttokens.CredentialKindMonitoringCollector + if enableCommands { + role = agenttokens.CredentialKindLegacyFullTrust + } + record := newTokenRecord(t, raw, agenttokens.ProxmoxScopes(enableCommands), map[string]string{ + "install_type": "pve", + "issued_via": agentbinding.IssuedViaConfig, + "bound_hostname": hostname, + "bound_at": time.Now().UTC().Format(time.RFC3339), + agenttokens.RuntimeRoleMetadataKey: role, + }) + record.OrgID = "default" + return record +} + +// The report path and the command channel bind the same identity, and once + +// either has named the agent the token never takes another identity, whatever + +// later reports resolve. + +func TestReportedIdentityMatchesCommandRegistrationAndNeverMoves(t *testing.T) { + const raw = "report-identity-pve1-token.12345678" + record := autoRegisteredInstallToken(t, raw, "pve1", true) + cfg := newTestConfigWithTokens(t, record) + router := newReportIdentityRouter(t, cfg) + + postReportIdentityReport(t, router, raw, reportIdentityReport("machine-a", "machine-a", "pve1")) + bound := reportIdentityToken(t, cfg, record.ID) + if got := bound.Metadata["bound_agent_id"]; got != "machine-a" { + t.Fatalf("bound_agent_id = %q, want machine-a", got) + } + if got := bound.Metadata[agenttokens.RuntimeRoleMetadataKey]; got != agenttokens.CredentialKindLegacyFullTrust { + t.Fatalf("runtime role = %q, want it untouched", got) + } + // Without the binding version a versionless token would still accept any + // identity on its hostname through the legacy migration. + if got := bound.Metadata[agentExecBindingVersionKey]; got != agentExecBindingVersion { + t.Fatalf("binding version = %q, want %q so the recorded ID is immutable", got, agentExecBindingVersion) + } + if _, ok := router.admitAgentExecToken(raw, "machine-b", "pve1"); ok { + t.Fatal("another identity's command registration moved the identity the report recorded") + } + if _, ok := router.admitAgentExecToken(raw, "machine-a", "pve1"); !ok { + t.Fatal("the reporting agent's own command registration was refused") + } + + // Another machine presenting this token under the same hostname reports + // under its own identity, but the token stays with the agent it named. + issue1753PostReport(t, router, raw, reportIdentityReport("machine-b", "machine-b", "pve1")) + if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "machine-a" { + t.Fatalf("bound_agent_id after another machine reported = %q, want machine-a", got) + } + if _, ok := router.admitAgentExecToken(raw, "machine-b", "pve1"); ok { + t.Fatal("a second identity was admitted on a token the report path had already bound") + } + + // A token the command channel bound first keeps that identity even when the + // server resolves this agent's reports to another one. + const deployRaw = "report-identity-deploy-token.12345678" + deployRecord := newTokenRecord(t, deployRaw, agenttokens.HostScopes(true), map[string]string{ + "bound_hostname": "pve-node3", + "deploy_job_id": "dep_1", + agentExecBindingVersionKey: agentExecBindingVersion, + agentbinding.DeployIdentityKey: agentbinding.DeployIdentityAgent, + }) + deployRecord.OrgID = "default" + config.Mu.Lock() + cfg.UpsertAPIToken(deployRecord) + config.Mu.Unlock() + if _, ok := router.admitAgentExecToken(deployRaw, "configured-id", "pve-node3"); !ok { + t.Fatal("first command registration refused") + } + ack := postReportIdentityReport(t, router, deployRaw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve-node3")) + if ack == "configured-id" { + t.Fatalf("precondition: the server resolved the presented ID %q", ack) + } + if got := reportIdentityToken(t, cfg, deployRecord.ID).Metadata["bound_agent_id"]; got != "configured-id" { + t.Fatalf("bound_agent_id = %q, want the command channel's configured-id", got) + } +} + +// As with a command registration, the first identity a token records is + +// final. An agent that reported under its machine ID with commands off, then + +// restarts under a configured --agent-id with commands on, meets a token + +// already bound to the machine ID, exactly as it would had its first run + +// registered a command channel. + +func TestReportedIdentityIsFinalForALaterReconfiguredAgent(t *testing.T) { + const raw = "report-identity-reconfigured-token.12345678" + record := autoRegisteredInstallToken(t, raw, "pve9", true) + cfg := newTestConfigWithTokens(t, record) + router := newReportIdentityRouter(t, cfg) + + postReportIdentityReport(t, router, raw, reportIdentityReport(reportIdentityMachineID, reportIdentityMachineID, "pve9")) + ack := postReportIdentityReport(t, router, raw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve9")) + if ack != reportIdentityMachineID { + t.Fatalf("precondition: acknowledged agentId = %q, want the machine ID", ack) + } + if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != reportIdentityMachineID { + t.Fatalf("bound_agent_id = %q, want the first reported identity", got) + } + if _, ok := router.admitAgentExecToken(raw, "configured-id", "pve9"); ok { + t.Fatal("a reconfigured identity moved a token the report path had bound") + } + if _, ok := router.admitAgentExecToken(raw, reportIdentityMachineID, "pve9"); !ok { + t.Fatal("the recorded identity's command registration was refused") + } +} + +// An agent started with a configured --agent-id presents that ID on its + +// command channel, while Pulse resolves its reports to the machine ID. The + +// report path cannot tell which identity the token should carry, so it records + +// nothing and leaves the command channel's first registration unchanged. + +func TestReportedIdentityRecordsNothingWhenTheResolvedIdentityIsNotThePresentedOne(t *testing.T) { + const raw = "report-identity-configured-token.12345678" + record := newTokenRecord(t, raw, agenttokens.HostScopes(true), map[string]string{ + "bound_hostname": "pve-node4", + "deploy_job_id": "dep_1", + agentExecBindingVersionKey: agentExecBindingVersion, + agentbinding.DeployIdentityKey: agentbinding.DeployIdentityAgent, + }) + record.OrgID = "default" + cfg := newTestConfigWithTokens(t, record) + router := newReportIdentityRouter(t, cfg) + + ack := postReportIdentityReport(t, router, raw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve-node4")) + if ack != reportIdentityMachineID { + t.Fatalf("precondition: acknowledged agentId = %q, want the machine ID", ack) + } + if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" { + t.Fatalf("bound_agent_id = %q, want nothing recorded while the identities disagree", got) + } + if _, ok := router.admitAgentExecToken(raw, "configured-id", "pve-node4"); !ok { + t.Fatal("the agent's command registration under its configured ID was refused") + } +} + +// A report the ordering watermark rejects is answered with the stored host, + +// whose hostname came from another report. The binding must be judged on the + +// rejected report's own hostname, the one its agent's command registration + +// presents, never on the stored one. + +func TestReportedIdentityJudgesAReplayedReportByItsOwnHostname(t *testing.T) { + const raw = "report-identity-replay-token.12345678" + record := newTokenRecord(t, raw, agenttokens.HostScopes(false), map[string]string{ + "bound_hostname": "node.site-a", + "deploy_job_id": "dep_1", + agentExecBindingVersionKey: agentExecBindingVersion, + agentbinding.DeployIdentityKey: agentbinding.DeployIdentityAgent, + }) + record.OrgID = "default" + cfg := newTestConfigWithTokens(t, record) + router := newReportIdentityRouter(t, cfg) + + start := time.Now().UTC().Add(-time.Minute) + retired := reportIdentityReport(reportIdentityMachineID, reportIdentityMachineID, "node.site-b") + retired.Timestamp = start + postReportIdentityReport(t, router, raw, retired) + newer := reportIdentityReport("configured-id", reportIdentityMachineID, "node") + newer.Timestamp = start.Add(30 * time.Second) + postReportIdentityReport(t, router, raw, newer) + if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" { + t.Fatalf("precondition: bound_agent_id = %q before the replay", got) + } + + postReportIdentityReport(t, router, raw, retired) + if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" { + t.Fatalf("bound_agent_id = %q from a replayed report whose own hostname the token refuses", got) + } +} + +// A failed save leaves the token exactly as it was and the next report + +// records the identity. + +func TestReportedIdentityRestoresTheTokenWhenTheSaveFails(t *testing.T) { + const raw = "report-identity-save-token.12345678" + record := autoRegisteredInstallToken(t, raw, "pve5", false) + cfg := newTestConfigWithTokens(t, record) + router := newReportIdentityRouter(t, cfg) + + blocked := filepath.Join(t.TempDir(), "blocked-state") + failing := config.NewConfigPersistence(blocked) + if err := os.RemoveAll(blocked); err != nil { + t.Fatalf("remove persistence directory: %v", err) + } + if err := os.WriteFile(blocked, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("create persistence blocker: %v", err) + } + working := router.persistence + router.persistence = failing + + report := reportIdentityReport("machine-e", "machine-e", "pve5") + postReportIdentityReport(t, router, raw, report) + unchanged := reportIdentityToken(t, cfg, record.ID) + for _, key := range []string{"bound_agent_id", agentExecBindingVersionKey} { + if _, present := unchanged.Metadata[key]; present { + t.Fatalf("%s = %q after a failed save, want it absent", key, unchanged.Metadata[key]) + } + } + if got := unchanged.Metadata["bound_at"]; got != record.Metadata["bound_at"] { + t.Fatalf("bound_at = %q after a failed save, want %q", got, record.Metadata["bound_at"]) + } + + router.persistence = working + postReportIdentityReport(t, router, raw, report) + retried := reportIdentityToken(t, cfg, record.ID) + if got := retried.Metadata["bound_agent_id"]; got != "machine-e" { + t.Fatalf("bound_agent_id after the retry = %q, want machine-e", got) + } + if got := retried.Metadata[agentExecBindingVersionKey]; got != agentExecBindingVersion { + t.Fatalf("binding version after the retry = %q, want %q", got, agentExecBindingVersion) + } +} + +// The ID is resolved in the request's organization, so only a token whose + +// command sessions run in that organization records it. + +func TestReportedIdentityRequiresTheTokensOwnOrganization(t *testing.T) { + const raw = "report-identity-org-token.12345678" + record := autoRegisteredInstallToken(t, raw, "pve6", false) + record.OrgID = "org-a" + cfg := newTestConfigWithTokens(t, record) + router := &Router{config: cfg, persistence: config.NewConfigPersistence(cfg.DataPath)} + + router.recordReportedAgentIdentity("default", record.ID, "machine-f", "machine-f", "pve6") + if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" { + t.Fatalf("bound_agent_id = %q from another organization's report", got) + } + router.recordReportedAgentIdentity("org-a", record.ID, "machine-f", "machine-f", "pve6") + if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "machine-f" { + t.Fatalf("bound_agent_id = %q, want machine-f from the token's own organization", got) + } +} + +// A token that expired after it authenticated the report records nothing. + +func TestReportedIdentityIgnoresAnExpiredToken(t *testing.T) { + record := autoRegisteredInstallToken(t, "report-identity-expired-token.12345678", "pve8", false) + expired := time.Now().Add(-time.Minute) + record.ExpiresAt = &expired + cfg := newTestConfigWithTokens(t, record) + router := &Router{config: cfg, persistence: config.NewConfigPersistence(cfg.DataPath)} + + router.recordReportedAgentIdentity("default", record.ID, "machine-h", "machine-h", "pve8") + if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" { + t.Fatalf("bound_agent_id = %q on an expired token", got) + } +} + +// The handler offers the recorder only reports the monitor applied under the + +// token, and only when the token snapshot qualifies, so a token that never will + +// (here, an agent presenting a configured ID) does not take the exclusive token + +// lock on every report. Mock mode discards reports and acknowledges the + +// presented ID unresolved, so it must not record anything on a real token. + +func TestReportHandlerOffersOnlyQualifyingLiveReportsToTheRecorder(t *testing.T) { + const raw = "report-identity-mock-token.12345678" + record := autoRegisteredInstallToken(t, raw, "pve7", false) + cfg := newTestConfigWithTokens(t, record) + monitor, err := monitoring.New(cfg) + if err != nil { + t.Fatalf("new monitor: %v", err) + } + defer monitor.Stop() + handler := NewUnifiedAgentHandlers(nil, monitor, nil) + type recorded struct{ organizationID, tokenID, presentedID, hostID, hostname string } + var calls []recorded + handler.SetReportedIdentityRecorder(func(organizationID, tokenID, presentedID, hostID, hostname string) { + calls = append(calls, recorded{organizationID, tokenID, presentedID, hostID, hostname}) + }) + post := func(presentedID string) { + t.Helper() + body, err := json.Marshal(reportIdentityReport(presentedID, "machine-g", "pve7")) + if err != nil { + t.Fatalf("marshal report: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/agents/agent/report", bytes.NewReader(body)) + attachAPITokenRecord(req, &record) + rec := httptest.NewRecorder() + handler.HandleReport(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("report status = %d: %s", rec.Code, rec.Body.String()) + } + } + + post("configured-id") + if len(calls) != 0 { + t.Fatalf("recorder offered a report whose presented ID was not resolved: %+v", calls) + } + post("machine-g") + want := recorded{"default", record.ID, "machine-g", "machine-g", "pve7"} + if len(calls) != 1 || calls[0] != want { + t.Fatalf("recorder calls = %+v, want one call %+v", calls, want) + } + + previous := mock.IsMockEnabled() + if err := mock.SetEnabled(true); err != nil { + t.Fatalf("enable mock mode: %v", err) + } + t.Cleanup(func() { _ = mock.SetEnabled(previous) }) + post("machine-g") + if len(calls) != 1 { + t.Fatalf("recorder calls in mock mode = %+v, want none beyond the live report", calls[1:]) + } +} diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index b7d3708922..f14c2d1d55 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -5641,6 +5641,66 @@ func TestMockModeDiscardsRealHostReports(t *testing.T) { }) } +// Report ingest records an agent's identity on its token only for a host the +// monitor resolved and holds under that token, judged on the report's own +// hostname. That rests on three properties of the returned host, pinned here: +// an applied report carries the token's ID, a report the ordering watermark +// rejects returns the stored host with the stored hostname rather than its +// own, and mock mode's discard acknowledgement carries no token at all. +func TestApplyHostReportStampsTheTokenOnlyOnHostsItHolds(t *testing.T) { + token := &config.APITokenRecord{ID: "token-a", Name: "agent token"} + newMonitor := func(t *testing.T) *Monitor { + t.Helper() + return &Monitor{ + state: models.NewState(), + resourceStore: unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil)), + hostContinuityStore: config.NewHostContinuityStore(t.TempDir(), nil), + rateTracker: NewRateTracker(), + } + } + report := func(hostname string, at time.Time) agentshost.Report { + return agentshost.Report{ + Agent: agentshost.AgentInfo{ID: "machine-a", Version: "6.5.0", IntervalSeconds: 30}, + Host: agentshost.HostInfo{ID: "machine-a", MachineID: "machine-a", Hostname: hostname, Platform: "linux"}, + Timestamp: at, + } + } + + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, false) + t.Cleanup(func() { _ = mock.SetEnabled(previous) }) + + m := newMonitor(t) + start := time.Now().UTC().Add(-time.Minute) + first := report("node.site-b", start) + applied, err := m.ApplyHostReport(first, token) + if err != nil { + t.Fatalf("ApplyHostReport: %v", err) + } + if applied.ID != "machine-a" || applied.TokenID != token.ID || applied.Hostname != "node.site-b" { + t.Fatalf("applied host = %q token %q hostname %q, want machine-a under %q as node.site-b", applied.ID, applied.TokenID, applied.Hostname, token.ID) + } + if _, err := m.ApplyHostReport(report("node", start.Add(30*time.Second)), token); err != nil { + t.Fatalf("ApplyHostReport newer: %v", err) + } + replayed, err := m.ApplyHostReport(first, token) + if err != nil { + t.Fatalf("ApplyHostReport replay: %v", err) + } + if replayed.TokenID != token.ID || replayed.Hostname != "node" { + t.Fatalf("replayed report returned token %q hostname %q, want the stored host under %q as node", replayed.TokenID, replayed.Hostname, token.ID) + } + + mustSetMockEnabled(t, true) + discarded, err := newMonitor(t).ApplyHostReport(report("node", time.Now().UTC()), token) + if err != nil { + t.Fatalf("mock mode ApplyHostReport: %v", err) + } + if discarded.ID != "machine-a" || discarded.TokenID != "" { + t.Fatalf("mock acknowledgement = %q token %q, want the echoed ID with no token", discarded.ID, discarded.TokenID) + } +} + // The agent-authored privilege profile must survive ingest into model state // exactly, and a report without one must not invent a profile. func TestApplyHostReportCarriesAgentPrivilegeProfile(t *testing.T) { From bf7f53d8b0385142e05073118bd5b3801fc223e6 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:55:22 +0100 Subject: [PATCH 2/2] Paint the backgrounds the dead bg-base class dropped Tailwind 4 emits nothing for a colour it does not know, and the `base` colour alias was removed on purpose because it also coloured the `text-base` font-size utility. Every remaining `bg-base` (about 85 uses) therefore rendered transparent. On the page background that looked the same, but command blocks, previews and status pills inside surface cards lost their wells, the segmented filter track hid its selected option, and the alert bulk-selection bar showed white text on a see-through pill. Each use now gets what it was meant to have, checked live in light and dark: - a new `bg-page` utility (the page backdrop, not a theme colour) for wells inside a surface card: command and JSON blocks, the branding header preview, setup wizard wells, the segmented track; - `bg-surface` where the block sits on a surface-alt body (resource drawer JSON, correlation and policy cards) or floats (the bulk-selection bar), `bg-surface-alt` for the muted Card tone, and `hover:bg-surface-hover` for drawer buttons whose hover did nothing; - no class at all on page shells that sit directly on body. Terminal-coloured command text that assumed a dark well gets light-mode shades so it stays readable on the restored light well. An architecture guard fails if a runtime class uses `bg-base` again or a theme colour named `base` returns. Contract-Neutral: Restores intended backgrounds for a Tailwind class that generated no CSS; no API, data or behaviour contract changes beyond the frontend-primitives note on the new bg-page utility. --- .../subsystems/frontend-primitives.md | 11 ++ .../435f865dac7a10df.json | 134 ++++++++++++++++++ frontend-modern/src/App.tsx | 6 +- frontend-modern/src/AppLayout.tsx | 2 +- .../src/__tests__/App.architecture.test.ts | 63 +++++--- .../src/components/Alerts/ResourceTable.tsx | 8 +- .../src/components/AppBootstrapStatus.tsx | 2 +- .../src/components/ErrorBoundary.tsx | 2 +- .../Infrastructure/ResourceChangeSummary.tsx | 2 +- .../ResourceCorrelationSummary.tsx | 2 +- .../ResourceDetailDrawerDebugTab.tsx | 6 +- .../ResourceDetailDrawerOverviewTab.tsx | 18 +-- .../ResourceDetailDrawerSupportDisclosure.tsx | 2 +- .../Infrastructure/ResourcePolicySummary.tsx | 2 +- frontend-modern/src/components/Login.tsx | 6 +- .../AIProviderConfigurationSection.tsx | 2 +- .../Settings/AgentIntegrationsPanel.tsx | 22 +-- .../Settings/BackupTransferDialogs.tsx | 2 +- .../Settings/BrandingSettingsCard.tsx | 2 +- .../components/Settings/CopyCommandBlock.tsx | 2 +- .../InfrastructureAgentDoctorPage.tsx | 14 +- .../InfrastructureInstallerSection.tsx | 2 +- .../Settings/NodeModalSetupGuideSection.tsx | 8 +- .../Settings/QuickSecuritySetup.tsx | 8 +- .../components/Settings/SSOProvidersPanel.tsx | 2 +- .../Settings/SuggestProfileModal.tsx | 4 +- .../Settings/UpdateInstallGuide.tsx | 4 +- ...updatesSettingsModel.branchcov0712.test.ts | 4 +- ...pdatesSettingsModel.branchcov0712c.test.ts | 2 +- .../Settings/updatesSettingsModel.ts | 6 +- .../components/SetupWizard/SetupWizard.tsx | 6 +- .../SetupWizard/steps/SecurityStep.tsx | 6 +- .../SetupWizard/steps/WelcomeStep.tsx | 8 +- .../components/patrol/RunToolCallTrace.tsx | 4 +- .../src/components/shared/Card.tsx | 5 +- .../components/shared/CommandPaletteModal.tsx | 3 +- .../src/components/shared/EmptyState.tsx | 2 +- .../shared/filterButtonGroupModel.ts | 2 +- .../alerts/AlertDeadManDestinationSection.tsx | 7 +- frontend-modern/src/index.css | 9 ++ 40 files changed, 290 insertions(+), 112 deletions(-) create mode 100644 frontend-modern/browser-verification/435f865dac7a10df.json diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 44938d4c99..46bc482d17 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2427,6 +2427,17 @@ not a replacement status card, CTA band, or page-local nested card. shared owner must also cap concurrent numeric animations and snap overflow readouts to their current target so a realtime estate update cannot make animation cost grow with the number of visible resources. + Background colours come from the `@theme inline` surface tokens in + `frontend-modern/src/index.css` and the `bg-page` utility beside them. + `bg-page` paints the page backdrop (`--color-bg-base`) for a well that + should read as the page behind a surface card: command and JSON blocks, + the branding header preview, and the segmented filter track. A block that + sits directly on a `bg-surface-alt` body, such as resource drawer content, + uses `bg-surface` instead. No theme colour may be named `base`: it also + colours the `text-base` font-size utility, and without it Tailwind 4 + renders `bg-base` as nothing, which left those wells and the alert + selection bar transparent. `src/__tests__/App.architecture.test.ts` fails + if either comes back. Shared primitives must not reintroduce app-shell monitored-system capacity banners. Monitored-system grouping and ledger presentation belongs in the owned settings surfaces, while commercial plan explanation belongs in diff --git a/frontend-modern/browser-verification/435f865dac7a10df.json b/frontend-modern/browser-verification/435f865dac7a10df.json new file mode 100644 index 0000000000..3d6e87cc26 --- /dev/null +++ b/frontend-modern/browser-verification/435f865dac7a10df.json @@ -0,0 +1,134 @@ +{ + "version": 1, + "base_sha": "8d77c0e2772fe7c023f4be963721c4c7709c57e0", + "verified_at": "2026-10-05T22:54:59Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/App.tsx", + "frontend-modern/src/AppLayout.tsx", + "frontend-modern/src/components/Alerts/ResourceTable.tsx", + "frontend-modern/src/components/AppBootstrapStatus.tsx", + "frontend-modern/src/components/ErrorBoundary.tsx", + "frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx", + "frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx", + "frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx", + "frontend-modern/src/components/Login.tsx", + "frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx", + "frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx", + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx", + "frontend-modern/src/components/Settings/BrandingSettingsCard.tsx", + "frontend-modern/src/components/Settings/CopyCommandBlock.tsx", + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx", + "frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx", + "frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx", + "frontend-modern/src/components/Settings/QuickSecuritySetup.tsx", + "frontend-modern/src/components/Settings/SSOProvidersPanel.tsx", + "frontend-modern/src/components/Settings/SuggestProfileModal.tsx", + "frontend-modern/src/components/Settings/UpdateInstallGuide.tsx", + "frontend-modern/src/components/Settings/updatesSettingsModel.ts", + "frontend-modern/src/components/SetupWizard/SetupWizard.tsx", + "frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx", + "frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx", + "frontend-modern/src/components/patrol/RunToolCallTrace.tsx", + "frontend-modern/src/components/shared/Card.tsx", + "frontend-modern/src/components/shared/CommandPaletteModal.tsx", + "frontend-modern/src/components/shared/EmptyState.tsx", + "frontend-modern/src/components/shared/filterButtonGroupModel.ts", + "frontend-modern/src/features/alerts/AlertDeadManDestinationSection.tsx", + "frontend-modern/src/index.css" + ], + "content_sha256": { + "frontend-modern/src/App.tsx": "00a405e01fc87c3f738fb744876e16f13e68d50cb8f31d469f040c637a6f59f0", + "frontend-modern/src/AppLayout.tsx": "3cb3f11a0f39adcd33cef84e7e99451395909313f1898383aa9fcc221dcb0f49", + "frontend-modern/src/components/Alerts/ResourceTable.tsx": "760ff1b760ac8c910582c5b81900a15788fa1ec5a08edd989c54cfafd0a55f49", + "frontend-modern/src/components/AppBootstrapStatus.tsx": "f33978b847c89626e24698dbe5c27db587425a4968573eb75a92f2f75b35f726", + "frontend-modern/src/components/ErrorBoundary.tsx": "ac334eb9bd8aebdb8d8c5fe62e82c399d393a1c670f30f600457278bf98ca45e", + "frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx": "4f3f035d2c87e562680dcf61c88cfbc0bb4f2daced31c0bda8427194fe25d992", + "frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx": "cf77b97ce7bfce0aa642668f4f0123a9e592196e7b5e83a9791ef8e95dca33e5", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx": "77ebc85ed5b7de3624c87846288dadc887b5555bac325b98f347246222d7b4c4", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "63192be4ab8de0e2aa920207cec14d593571f282d8bde1c8951a2d1413c00fc8", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx": "c0f3aaac14865c0944969b2fbfc898bd311def49f25db6938ccbadac9bc822e3", + "frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx": "7d87ab5e6ea2948160bf275b6d9fec7ff4ee38ce457f75b85d24c1dbb2c95203", + "frontend-modern/src/components/Login.tsx": "0790413c0199b2bd0e0fcb9dc53cfa0fc6ac84717acbfa4f5140e725cf3d2ff2", + "frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx": "686708cbbddce209b141e1f9b4c60b0ad57079432be77e384c0d0c4e88dbf7e4", + "frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx": "45b3473d4a67cf505651c483616a0724085bbaa2f3f1e53c2021c5cfda2d6d0c", + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "a873e334d4eaf5b25bc31ba8922b07119fe16da169b91966c591b8f3a60cf06b", + "frontend-modern/src/components/Settings/BrandingSettingsCard.tsx": "c36f0f518c9674eb05c5050e2e23b9598673a5adf00d039c143cf180a8f21f7f", + "frontend-modern/src/components/Settings/CopyCommandBlock.tsx": "084835c7beb84d0800f9b5590fcb899f7e6088513fc1251b2a74f15e2225b411", + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "6064372d8a07257b222888e47c075e5428a6a9392ffbd257963855d0fbe31d38", + "frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx": "9787d5c0615787f6e0a3fc1caefc3ebef93f4618f6f74b4b2470a0fa0c40e37f", + "frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx": "0b8b4600e20bcbd5235a5a7fecffd99c9752d8417955b23c8ce7be473f18042c", + "frontend-modern/src/components/Settings/QuickSecuritySetup.tsx": "8884d86ac12281a5805c33ef608fb0f86f6a61166a6eb72edcc2412b477781b5", + "frontend-modern/src/components/Settings/SSOProvidersPanel.tsx": "2f56e9f1635b34581457c00a60a49166caceeeed2209a187d032704b3d2ca716", + "frontend-modern/src/components/Settings/SuggestProfileModal.tsx": "b54c5c7449fe824f1ba13676bd3bac151bc0b6a880d831b211480dc41f6806d1", + "frontend-modern/src/components/Settings/UpdateInstallGuide.tsx": "65877c2b8a4706721881545dd4f769a41485d3c8df2ede5f9022c337c77d6e92", + "frontend-modern/src/components/Settings/updatesSettingsModel.ts": "7c8cbb83097f65bb1e17fbab859fd0711f870bb0aaa1e9c91fcc71cfd165bb49", + "frontend-modern/src/components/SetupWizard/SetupWizard.tsx": "c96e980aec912bfae0976132168121d45fe2ba8b8e92af9c998e6cf7d69edf56", + "frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx": "2ae173c4fc65b7aa528d1ca4196d085547081c9742e4f504d00a0baeccbc20b1", + "frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx": "4849c5fe13442131a5c6427d81f3c366c94e48a9701f41d1c927476b7af17dbc", + "frontend-modern/src/components/patrol/RunToolCallTrace.tsx": "88b4bedf474d2fa23f56d59db0c746c83e004b920489c86bb6827d50133a7366", + "frontend-modern/src/components/shared/Card.tsx": "03e4ff8a482b6dd086eb4b2765fa517edc229c02ff27105b0e572523c07a7d5f", + "frontend-modern/src/components/shared/CommandPaletteModal.tsx": "8e0a8aaa1bbd2151ddb578e65c1230ef710b2666647cec3ab55a27fc23ac28c3", + "frontend-modern/src/components/shared/EmptyState.tsx": "ee47b2b011fd67fc79913be1c86d8526ffaea8b49716965e0b8f358ebbc2dd74", + "frontend-modern/src/components/shared/filterButtonGroupModel.ts": "a180ecf16e3bb84dadbd6f59a036190960082a527272551648fa9a6e9dd168ab", + "frontend-modern/src/features/alerts/AlertDeadManDestinationSection.tsx": "57b1d0468a61a3e382a72649cb3fd5764e7d00e25f529fa156706bb54121e099", + "frontend-modern/src/index.css": "8ee3a5cfffdf960243238f037147d817a950c65cadb5d36fdc59d58d3837572b" + }, + "routes": [ + "/settings/pulse-intelligence/assistant", + "/settings/infrastructure/agent-doctor", + "/settings/system-recovery", + "/standalone/machines", + "/proxmox/overview", + "/alerts/notifications", + "/alerts/thresholds", + "/alerts/schedule", + "/settings/system-updates", + "/settings", + "/machines", + "/settings/security/api", + "/patrol", + "/settings/system-general", + "/settings/pulse-intelligence/provider", + "/settings/infrastructure", + "/settings/security-sso" + ], + "viewports": [ + { + "width": 1440, + "height": 1000 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (backend 7781, Vite 5281). Every capture was taken on main and on the change in the same session by swapping the files under Vite, in light and dark", + "External agents connector setup with every details panel open: install and client-config command blocks, the capability chip, the access-model panels and their lists were transparent on the white card; they now read as slate-100 wells in light and slate-900 wells in dark", + "Agent Doctor command blocks, the export-configuration options box, the Ollama pull command, the agent installer commands, the Proxmox node setup-guide command and the SAML metadata XML preview: transparent before, page-coloured wells after", + "Update install guide for systemd and Docker (version response set to that deployment type): the wells now paint, and the green and blue command text gained light-mode shades, because green-400 on a light well measured about 1.6:1", + "Dead Man's Switch: the \"Not configured\" pill had no fill and read as plain text; it is now a white chip with a border ring in light and a ringed chip in dark. The detail grid is a recessed well", + "Alert threshold bulk-selection bar after ticking two storage rows: before, light mode showed white count text on a see-through pill (the count was invisible) and dark mode showed the table through it; after, it is an opaque bg-surface pill with readable text in both themes", + "Command palette shortcut chips read as keycaps; the palette input already painted bg-surface, so its redundant class was removed", + "Patrol mode segmented control (ai_autofix capability supplied): the selected option was a white chip on a white track; the track is now page-coloured and the selection stands out in both themes", + "Branding header preview (white_label capability supplied): the preview header bar now sits on a page-coloured backdrop instead of disappearing into the card", + "Muted Card tone (API token hint, alert configuration summary): slate-100 in light, unchanged in dark. The unused glass tone was removed", + "Machine drawer Debug tab: JSON blocks sit on the drawer's bg-surface-alt body, so they use bg-surface like the drawer's other cards (white on slate-100 in light, flat in dark as before)", + "Setup wizard (security status set to no authentication, bootstrap validation fulfilled with a dummy value): Welcome command wells with legible text and copy buttons, and the Security step wells and footer band, in both themes", + "Login, wizard root, app scroll shell and the framed 404 empty state dropped a class that generated no CSS: the Login page and the empty-state element are pixel-identical before and after, and the shells sit directly on body, which already paints the page colour", + "Hover-only drawer buttons, measured with their exact classes in the live drawer: hover:bg-base never changed the colour; hover:bg-surface-hover goes to rgb(248,250,252) in light and rgb(51,65,85) in dark", + "Not reachable on the mock stack and judged from their containers: the drawer change-history filters, change reason and metadata, the correlation and change summary cards and the coloured drawer buttons (the compact drawer fetches no history in mock), Quick Security Setup (unauthenticated sessions go to the wizard), the agent-profile suggestion JSON, the Patrol tool-call trace, other node-modal tabs, the needs-setup nav tab, the bootstrap/error/init shells, and ResourcePolicySummary, which has no production caller", + "Phone at 390: Dead Man's Switch grid, External agents command block, update steps and wizard command wells render the same wells in both themes" + ], + "interactions": [ + "Opened connector setup and expanded every details panel; opened the machine drawer and its Debug tab; opened the command palette with Cmd/Ctrl+K", + "Expanded all threshold groups and ticked two storage rows to raise the bulk-edit bar", + "Opened Create Backup, Add infrastructure > Install Pulse Agent > Generate token, Add infrastructure > Proxmox VE with an endpoint entered, Add SAML > Preview metadata, and the Ollama provider section", + "Advanced the setup wizard to the Security step; showed the Login page via the logged-out flag", + "Hovered the old and new drawer button classes in light and dark; repeated the key surfaces at 390px" + ] +} diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 42a975de6d..b2ee82ea2b 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -527,7 +527,7 @@ function App() { +
Initializing...
} @@ -559,7 +559,7 @@ function App() { {/* Main content area - shrinks when AI panel is open, scrolls independently */}
} > -
+
{props.children}
diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 330044bf00..917a12d94c 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -694,7 +694,7 @@ export function AppLayout(props: AppLayoutProps) { return `${baseClasses} bg-surface text-blue-600 dark:text-blue-400 border-border border-b border-b-surface shadow-xs font-semibold`; } if (needsSetup()) { - return `${baseClasses} text-muted opacity-70 bg-base hover:bg-surface-hover`; + return `${baseClasses} text-muted opacity-70 hover:bg-surface-hover`; } return `${baseClasses} text-muted hover:text-base-content hover:bg-surface-hover`; }; diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index e9d9cc865b..ea0251af87 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -1007,30 +1007,30 @@ describe('App architecture', () => { }); }); +function collectFrontendSources(dir: string): Array<{ path: string; source: string }> { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) { + return collectFrontendSources(entryPath); + } + // Only runtime sources matter; a test may quote an old shape in order to + // assert against it, as the guards below do. + if (!/\.(css|ts|tsx)$/.test(entry.name)) { + return []; + } + if (entryPath.includes('__tests__') || /\.(test|spec)\./.test(entry.name)) { + return []; + } + return [{ path: entryPath, source: readFileSync(entryPath, 'utf8') }]; + }); +} + describe('mobile bottom navigation clearance', () => { const NAV_HEIGHT_VARIABLE = '--pulse-mobile-nav-height'; // Matches the shape every drifted copy used, spaced or not. The lookbehind // keeps the declared 2.5rem fallback from matching its own guard. const HARDCODED_BAR_HEIGHT = /(? { - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const entryPath = join(dir, entry.name); - if (entry.isDirectory()) { - return collectFrontendSources(entryPath); - } - // Only runtime sources matter; a test may quote the old shape in order - // to assert against it, as this one does. - if (!/\.(css|ts|tsx)$/.test(entry.name)) { - return []; - } - if (entryPath.includes('__tests__') || /\.(test|spec)\./.test(entry.name)) { - return []; - } - return [{ path: entryPath, source: readFileSync(entryPath, 'utf8') }]; - }); - } - it('declares the bar height as a single custom property', () => { expect(appStylesSource).toContain(NAV_HEIGHT_VARIABLE); }); @@ -1050,6 +1050,33 @@ describe('mobile bottom navigation clearance', () => { }); }); +describe('page background utility', () => { + // Tailwind 4 emits nothing for a colour it does not know, so `bg-base` + // rendered transparent without any error: command blocks, the alert + // selection bar and segmented-control tracks all lost their backgrounds. + // A theme colour named `base` is not the fix either, because it also gives + // the `text-base` font-size utility a colour and paints text in the page + // colour. The page backdrop is the bg-page utility. + const DEAD_BASE_BACKGROUND = /(? { + expect(appStylesSource).toMatch( + /@utility bg-page \{\s*background-color: var\(--color-bg-base\);\s*\}/, + ); + expect(appStylesSource).not.toMatch(/--color-base\s*:/); + }); + + it('keeps every runtime class off the bg-base utility that renders nothing', () => { + const offenders = collectFrontendSources(join(process.cwd(), 'src')) + .filter(({ source }) => DEAD_BASE_BACKGROUND.test(source.replace(COMMENTS, '$1'))) + .map(({ path }) => path.slice(process.cwd().length + 1)); + + expect(offenders).toEqual([]); + }); +}); + describe('Docker phone update reflow boundary', () => { it('scopes label wrapping to narrow Docker cells without restoring a nested scrollport', () => { const phoneStyles = appStylesSource.slice(appStylesSource.indexOf('/* Phone update states')); diff --git a/frontend-modern/src/components/Alerts/ResourceTable.tsx b/frontend-modern/src/components/Alerts/ResourceTable.tsx index 38cca6f3f0..284837fbd9 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.tsx @@ -145,9 +145,9 @@ export function ResourceTable(props: ResourceTableProps) { 0 && props.onBulkEdit}> -
- - {selectedIds().size} selected +
+ + {selectedIds().size} selected
@@ -473,7 +473,7 @@ export const ResourceDetailDrawerOverviewTab: Component drawer.setTimelineKindFilter( @@ -489,7 +489,7 @@ export const ResourceDetailDrawerOverviewTab: Component drawer.setTimelineSourceTypeFilter( @@ -505,7 +505,7 @@ export const ResourceDetailDrawerOverviewTab: Component drawer.setTimelineSourceAdapterFilter( @@ -618,12 +618,12 @@ export const ResourceDetailDrawerOverviewTab: Component
-
+
{change.reason}
-
+
Metadata @@ -867,7 +867,7 @@ export const ResourceDetailDrawerOverviewTab: Component {drawer.showDockerUpdateControls() ? 'Hide actions' : 'Show actions'} @@ -1114,7 +1114,7 @@ export const ResourceDetailDrawerOverviewTab: Component drawer.setShowPbsJobDetail((value) => !value)} - class="inline-flex items-center rounded-md border border-indigo-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-indigo-700 transition-colors hover:bg-base dark:border-indigo-700 dark:text-indigo-300" + class="inline-flex items-center rounded-md border border-indigo-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-indigo-700 transition-colors hover:bg-surface-hover dark:border-indigo-700 dark:text-indigo-300" > {drawer.showPbsJobDetail() ? 'Hide jobs' : 'Show jobs'} @@ -1242,7 +1242,7 @@ export const ResourceDetailDrawerOverviewTab: Component drawer.setShowPmgMailFlowDetail((value) => !value)} - class="inline-flex items-center rounded-md border border-rose-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-rose-700 transition-colors hover:bg-base dark:border-rose-700 dark:text-rose-300" + class="inline-flex items-center rounded-md border border-rose-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-rose-700 transition-colors hover:bg-surface-hover dark:border-rose-700 dark:text-rose-300" > {drawer.showPmgMailFlowDetail() ? 'Hide mail flow' : 'Show mail flow'} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx index 6e047607b7..a1f90f8ce2 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx @@ -48,7 +48,7 @@ export const ResourceDetailDrawerSupportDisclosure: Component< onClick={props.onToggle} class={ props.buttonClass ?? - 'inline-flex min-h-11 shrink-0 items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[10px] font-medium text-base-content transition-colors hover:bg-base sm:min-h-0' + 'inline-flex min-h-11 shrink-0 items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[10px] font-medium text-base-content transition-colors hover:bg-surface-hover sm:min-h-0' } > {props.expanded ? props.hideLabel : props.showLabel} diff --git a/frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx b/frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx index 582a4f8b31..93e03a566f 100644 --- a/frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx @@ -24,7 +24,7 @@ export const ResourcePolicySummary: Component = (pro return ( {(value) => ( -
+

diff --git a/frontend-modern/src/components/Login.tsx b/frontend-modern/src/components/Login.tsx index d783c6a03c..1d9e14e6c7 100644 --- a/frontend-modern/src/components/Login.tsx +++ b/frontend-modern/src/components/Login.tsx @@ -344,7 +344,7 @@ export const Login: Component = (props) => { +

Checking authentication...

@@ -379,7 +379,7 @@ export const Login: Component = (props) => { > +

Loading setup...

@@ -438,7 +438,7 @@ const LoginForm: Component<{ } = props; return ( -
+
{/* Demo Credentials Banner */} diff --git a/frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx b/frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx index 066f8ccc46..dcb876753e 100644 --- a/frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx +++ b/frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx @@ -297,7 +297,7 @@ export const AIProviderConfigurationSection: Component

{suggestedModelNote()}

diff --git a/frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx b/frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx index 5dff1e4314..2087e78c30 100644 --- a/frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx +++ b/frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx @@ -309,7 +309,7 @@ export const AgentIntegrationsPanel: Component = () => {
@@ -319,7 +319,7 @@ export const AgentIntegrationsPanel: Component = () => {
@@ -353,7 +353,7 @@ export const AgentIntegrationsPanel: Component = () => {
@@ -371,7 +371,7 @@ export const AgentIntegrationsPanel: Component = () => {
@@ -395,7 +395,7 @@ export const AgentIntegrationsPanel: Component = () => { {(posture) => (
@@ -411,7 +411,7 @@ export const AgentIntegrationsPanel: Component = () => { )} 0}> -
+
Patrol access model @@ -432,7 +432,7 @@ export const AgentIntegrationsPanel: Component = () => { {(badge) => ( - + {badge} )} @@ -449,7 +449,7 @@ export const AgentIntegrationsPanel: Component = () => {
-
+
Live manifest details @@ -468,7 +468,7 @@ export const AgentIntegrationsPanel: Component = () => {
-
    +
      {(prompt) => (
    • @@ -548,7 +548,7 @@ export const AgentIntegrationsPanel: Component = () => { {(summary) => ( {summary.code} @@ -591,7 +591,7 @@ export const AgentIntegrationsPanel: Component = () => {

      {section.description}

-
    +
      {(cap) => (
    • diff --git a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx index eee2faea0d..fbd3163b33 100644 --- a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx +++ b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx @@ -45,7 +45,7 @@ export const BackupTransferDialogs: Component = (pro
      -
      +
      -
      +

      Header preview

      diff --git a/frontend-modern/src/components/Settings/CopyCommandBlock.tsx b/frontend-modern/src/components/Settings/CopyCommandBlock.tsx index 1f60bddc8e..e48e15c0fc 100644 --- a/frontend-modern/src/components/Settings/CopyCommandBlock.tsx +++ b/frontend-modern/src/components/Settings/CopyCommandBlock.tsx @@ -11,7 +11,7 @@ interface CopyCommandBlockProps { const DEFAULT_CONTAINER_CLASS = 'relative group'; const DEFAULT_CODE_CLASS = - 'block rounded-md border border-border bg-base p-3 font-mono text-sm text-base-content'; + 'block rounded-md border border-border bg-page p-3 font-mono text-sm text-base-content'; export function CopyCommandBlock(props: CopyCommandBlockProps) { const handleCopy = () => { diff --git a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx index ec9aa1bf5b..329edd39a5 100644 --- a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx @@ -654,7 +654,7 @@ export const InfrastructureAgentDoctorPage: Component -
      +                                      
                                               
                                                 {buildSafeCollectorInspectCommand({
                                                   pulseUrl: operations.selectedAgentUrl(),
      @@ -682,7 +682,7 @@ export const InfrastructureAgentDoctorPage: Component
      -                                      
      +                                      
                                               
                                                 {buildSafeCollectorApplyCommand({
                                                   pulseUrl: operations.selectedAgentUrl(),
      @@ -767,7 +767,7 @@ export const InfrastructureAgentDoctorPage: Component void copyCommand(tokenFileCommand())}
                                                 label="Copy private token-file command"
                                               />
      -                                        
      +                                        
                                                 {tokenFileCommand()}
                                               
      @@ -782,7 +782,7 @@ export const InfrastructureAgentDoctorPage: Component -
      +                                        
                                                 {reveal().token}
                                               
      @@ -796,7 +796,7 @@ export const InfrastructureAgentDoctorPage: Component void copyCommand(installCommand())} label="Copy action-runner installer command" /> -
      +                                        
                                                 {installCommand()}
                                               
      @@ -914,7 +914,7 @@ export const InfrastructureAgentDoctorPage: Component -
      +                                    
                                             {command()}
                                           
      -
      +                                          
                                                   
                                                     {operations.getPlatformUninstallCommand(
                                                       entry.platform,
      diff --git a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx
      index 5c1e32eeae..a44b51fb27 100644
      --- a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx
      +++ b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx
      @@ -715,7 +715,7 @@ export const InfrastructureInstallerSection: Component
      -                                
      +                                
                                         {copyCommand()}
                                       
      diff --git a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx index cff9e37b00..11079ba4f6 100644 --- a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx +++ b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx @@ -165,7 +165,7 @@ export const NodeModalSetupGuideSection: Component Run this command on your Proxmox VE node:

      -
      +
      -
      +
      Password Setup
      @@ -390,7 +390,7 @@ Important:
      -
      +
      Username
      @@ -406,7 +406,7 @@ Important:
      -
      +
      Password
      @@ -422,7 +422,7 @@ Important:
      -
      +
      API token
      diff --git a/frontend-modern/src/components/Settings/SSOProvidersPanel.tsx b/frontend-modern/src/components/Settings/SSOProvidersPanel.tsx index 38606de496..0853a41a39 100644 --- a/frontend-modern/src/components/Settings/SSOProvidersPanel.tsx +++ b/frontend-modern/src/components/Settings/SSOProvidersPanel.tsx @@ -940,7 +940,7 @@ export const SSOProvidersPanel: Component = (props) => { Copy
      -
      +              
                       {metadataPreview()?.xml}
                     
      diff --git a/frontend-modern/src/components/Settings/SuggestProfileModal.tsx b/frontend-modern/src/components/Settings/SuggestProfileModal.tsx index 59ef76cc9d..79c8f33e3a 100644 --- a/frontend-modern/src/components/Settings/SuggestProfileModal.tsx +++ b/frontend-modern/src/components/Settings/SuggestProfileModal.tsx @@ -525,8 +525,8 @@ export const SuggestProfileModal: Component = (props)
      Raw JSON
      -
      -
      +                        
      +
                                   {JSON.stringify(sugg().config, null, 2)}
                                 
      diff --git a/frontend-modern/src/components/Settings/UpdateInstallGuide.tsx b/frontend-modern/src/components/Settings/UpdateInstallGuide.tsx index 81fadd3b1d..e615c10a86 100644 --- a/frontend-modern/src/components/Settings/UpdateInstallGuide.tsx +++ b/frontend-modern/src/components/Settings/UpdateInstallGuide.tsx @@ -57,7 +57,7 @@ function InstallStep(props: { step: UpdateInstallStep; index: number }) { containerClass="ml-0 sm:ml-8 relative group" codeClass={ props.step.commandCodeClass ?? - 'block rounded-md border border-border bg-base p-3 font-mono text-sm text-green-400' + 'block rounded-md border border-border bg-page p-3 font-mono text-sm text-green-700 dark:text-green-400' } /> @@ -181,7 +181,7 @@ export const UpdateInstallGuide: Component = (props) =>

      Not using Compose?{' '} diff --git a/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712.test.ts b/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712.test.ts index ef808ed5a4..d51154749d 100644 --- a/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712.test.ts @@ -54,9 +54,9 @@ const makeDockerUpdate = (overrides: Partial = {}): Docker }); const PRO_STEP_CODE_CLASS = - 'block rounded-md border border-border bg-base p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-all'; + 'block rounded-md border border-border bg-page p-3 font-mono text-sm text-green-700 dark:text-green-400 whitespace-pre-wrap break-all'; const SYSTEMD_DOWNLOAD_CODE_CLASS = - 'block rounded-md border border-border bg-base p-3 font-mono text-sm text-base-content whitespace-pre-wrap break-all'; + 'block rounded-md border border-border bg-page p-3 font-mono text-sm text-base-content whitespace-pre-wrap break-all'; // ---- buildIdleDockerComposeCommand ----------------------------------------- // Single return path; assert the exact constant. diff --git a/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712c.test.ts b/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712c.test.ts index bb39fdc2ef..a216fee840 100644 --- a/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712c.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712c.test.ts @@ -52,7 +52,7 @@ const makeDockerUpdate = (overrides: Partial = {}): Docker }); const PRO_STEP_CODE_CLASS = - 'block rounded-md border border-border bg-base p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-all'; + 'block rounded-md border border-border bg-page p-3 font-mono text-sm text-green-700 dark:text-green-400 whitespace-pre-wrap break-all'; // ---- buildUpdateInstallGuide (complementary branch-coverage) ---------------- diff --git a/frontend-modern/src/components/Settings/updatesSettingsModel.ts b/frontend-modern/src/components/Settings/updatesSettingsModel.ts index 4efc9ca918..46629dc86d 100644 --- a/frontend-modern/src/components/Settings/updatesSettingsModel.ts +++ b/frontend-modern/src/components/Settings/updatesSettingsModel.ts @@ -70,14 +70,14 @@ function buildProDockerUpdateSteps(dockerUpdate: DockerUpdateCommands): UpdateIn title: 'Pull the new Pulse Pro image (pinned to this release’s digest)', command: dockerUpdate.composePullCommand, commandCodeClass: - 'block rounded-md border border-border bg-base p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-all', + 'block rounded-md border border-border bg-page p-3 font-mono text-sm text-green-700 dark:text-green-400 whitespace-pre-wrap break-all', }, { id: 'docker-pro-up', title: 'Recreate the container on the new image', command: dockerUpdate.composeUpCommand, commandCodeClass: - 'block rounded-md border border-border bg-base p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-all', + 'block rounded-md border border-border bg-page p-3 font-mono text-sm text-green-700 dark:text-green-400 whitespace-pre-wrap break-all', }, ]; if (dockerUpdate.loginCommand) { @@ -192,7 +192,7 @@ export function buildUpdateInstallGuide( title: 'Download and extract the new version', command: systemdDownloadCommand, commandCodeClass: - 'block rounded-md border border-border bg-base p-3 font-mono text-sm text-base-content whitespace-pre-wrap break-all', + 'block rounded-md border border-border bg-page p-3 font-mono text-sm text-base-content whitespace-pre-wrap break-all', }, { id: 'systemd-start', title: 'Start the service', command: 'sudo systemctl start pulse' }, ], diff --git a/frontend-modern/src/components/SetupWizard/SetupWizard.tsx b/frontend-modern/src/components/SetupWizard/SetupWizard.tsx index 31f303501a..decd560ced 100644 --- a/frontend-modern/src/components/SetupWizard/SetupWizard.tsx +++ b/frontend-modern/src/components/SetupWizard/SetupWizard.tsx @@ -67,11 +67,7 @@ export const SetupWizard: Component = (props) => { ]); return ( -

      +
      {/* Background decoration */} diff --git a/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx b/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx index 9bd832abf9..35716c367c 100644 --- a/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx +++ b/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx @@ -229,7 +229,7 @@ export const SecurityStep: Component = (props) => {
      -
      +
      setShareUsageStatistics(!shareUsageStatistics())} @@ -238,7 +238,7 @@ export const SecurityStep: Component = (props) => { />
      -
      +
      {t('setup.security.nextScreen.title')}
      @@ -250,7 +250,7 @@ export const SecurityStep: Component = (props) => {
      {/* Actions */} -
      +
      diff --git a/frontend-modern/src/components/patrol/RunToolCallTrace.tsx b/frontend-modern/src/components/patrol/RunToolCallTrace.tsx index d1bc4ec145..0ecf3ac165 100644 --- a/frontend-modern/src/components/patrol/RunToolCallTrace.tsx +++ b/frontend-modern/src/components/patrol/RunToolCallTrace.tsx @@ -200,7 +200,7 @@ export const RunToolCallTrace: Component = (props) => {
      Input
      -
      +                              
                                       {truncate(call.input, 500)}
                                     
      @@ -208,7 +208,7 @@ export const RunToolCallTrace: Component = (props) => {
      Output
      -
      +                              
                                       {truncate(call.output, 500)}
                                     
      diff --git a/frontend-modern/src/components/shared/Card.tsx b/frontend-modern/src/components/shared/Card.tsx index b281bb01a1..f84b58505b 100644 --- a/frontend-modern/src/components/shared/Card.tsx +++ b/frontend-modern/src/components/shared/Card.tsx @@ -1,6 +1,6 @@ import { JSX, splitProps, mergeProps } from 'solid-js'; -type Tone = 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger' | 'card' | 'glass'; +type Tone = 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger' | 'card'; type Padding = 'none' | 'sm' | 'md' | 'lg'; export type CardProps = { @@ -12,13 +12,12 @@ export type CardProps = { const toneClassMap: Record = { default: 'bg-surface', - muted: 'bg-base', + muted: 'bg-surface-alt', info: 'bg-blue-50 dark:bg-blue-900/25', success: 'bg-emerald-50 dark:bg-emerald-900/25', warning: 'bg-amber-50 dark:bg-amber-900/25', danger: 'bg-red-50 dark:bg-red-900/25', card: 'bg-surface', - glass: 'bg-base', }; const paddingClassMap: Record = { diff --git a/frontend-modern/src/components/shared/CommandPaletteModal.tsx b/frontend-modern/src/components/shared/CommandPaletteModal.tsx index c155a5283b..22aac7f70a 100644 --- a/frontend-modern/src/components/shared/CommandPaletteModal.tsx +++ b/frontend-modern/src/components/shared/CommandPaletteModal.tsx @@ -39,7 +39,6 @@ export function CommandPaletteModal(props: CommandPaletteModalProps) { onKeyDown={commandPalette.handleInputKeyDown} placeholder="Type a command or search..." class="w-full" - inputClass="bg-base" clearOnFocusedEscape={false} shortcutHint="Cmd+K" role="combobox" @@ -84,7 +83,7 @@ export function CommandPaletteModal(props: CommandPaletteModalProps) {
      - + {command.shortcut} diff --git a/frontend-modern/src/components/shared/EmptyState.tsx b/frontend-modern/src/components/shared/EmptyState.tsx index 10aa30da90..982c331e74 100644 --- a/frontend-modern/src/components/shared/EmptyState.tsx +++ b/frontend-modern/src/components/shared/EmptyState.tsx @@ -34,7 +34,7 @@ export function EmptyState(props: EmptyStateProps) { const containerClass = [ 'flex w-full animate-fade-in flex-col', variant === 'framed' - ? 'rounded-md border border-dashed border-border bg-base px-6 py-10 sm:px-8 sm:py-16' + ? 'rounded-md border border-dashed border-border px-6 py-10 sm:px-8 sm:py-16' : 'px-4 py-8', alignment === 'center' ? 'items-center text-center' : 'items-start text-left', local.class ?? '', diff --git a/frontend-modern/src/components/shared/filterButtonGroupModel.ts b/frontend-modern/src/components/shared/filterButtonGroupModel.ts index c406f6fb7b..043e8f4ec9 100644 --- a/frontend-modern/src/components/shared/filterButtonGroupModel.ts +++ b/frontend-modern/src/components/shared/filterButtonGroupModel.ts @@ -39,7 +39,7 @@ const groupClassByVariant: Record = { 'inline-flex items-center gap-1 bg-surface-hover rounded-md p-0.5 ring-1 ring-border-subtle overflow-x-auto scrollbar-hide', prominent: 'grid grid-cols-1 gap-2', segmented: - 'flex items-center gap-1 rounded-md border border-border bg-base p-1 shadow-inner overflow-x-auto scrollbar-hide', + 'flex items-center gap-1 rounded-md border border-border bg-page p-1 shadow-inner overflow-x-auto scrollbar-hide', }; const labelClassByVariant: Partial> = { diff --git a/frontend-modern/src/features/alerts/AlertDeadManDestinationSection.tsx b/frontend-modern/src/features/alerts/AlertDeadManDestinationSection.tsx index c04ad89bd7..3dc701bdd4 100644 --- a/frontend-modern/src/features/alerts/AlertDeadManDestinationSection.tsx +++ b/frontend-modern/src/features/alerts/AlertDeadManDestinationSection.tsx @@ -15,7 +15,10 @@ interface AlertDeadManDestinationSectionProps { const REDACTED_PING_URL = '***REDACTED***'; const statusPresentation: Record = { - disabled: { label: 'Not configured', class: 'bg-base text-muted' }, + disabled: { + label: 'Not configured', + class: 'bg-surface text-muted ring-1 ring-inset ring-border', + }, starting: { label: 'Starting', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/25 dark:text-blue-100', @@ -153,7 +156,7 @@ export function AlertDeadManDestinationSection(props: AlertDeadManDestinationSec {(current) => ( -
      +
      Last success
      diff --git a/frontend-modern/src/index.css b/frontend-modern/src/index.css index e2b4d75377..1a512157dc 100644 --- a/frontend-modern/src/index.css +++ b/frontend-modern/src/index.css @@ -97,6 +97,15 @@ } } +/* The page backdrop as a background, for wells inside a surface card (code + blocks, previews, segmented-control tracks) that should read as the page + behind the card. It is a utility rather than a theme colour on purpose: a + colour named `base` also gave `text-base` a colour and painted text in the + page colour, and without that colour the old class rendered nothing. */ +@utility bg-page { + background-color: var(--color-bg-base); +} + @utility animate-slideDown { animation: slideDown 0.3s ease-out; }