diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 60ac40224..12300fe0e 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -8311,3 +8311,13 @@ Related command targets do not retarget an alert. The funded OpenRouter `openai/gpt-6-astra` route has explicitly reviewed, tiered cost estimates dated 2026-09-07. Unreviewed aliases stay unknown. Its history-explanation qualification is distinct from the earlier Gemini lab matrix and from wider rollout evidence. + +### Compact shell Assistant coexistence + +The mobile Assistant launcher remains in shared header flow. That header and +its controls may wrap at compact widths so an entitled Organization selector +does not displace session actions or the connection indicator beyond the page. +Keep desktop launcher placement and existing visibility decisions unchanged; +wrapping must not introduce entitlement reads or alter Assistant authority. +AppLayout component and architecture regressions preserve these boundaries; +real-backend phone geometry proof is retained by the Organization mobile audit. diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 4f3717b24..49dc1123c 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -813,6 +813,23 @@ local command or REST URL. `TestHostCustomSensorAlertLifecycle` and `internal/alerts/host_unraid_lifecycle_test.go` pin creation, recovery, opt-out, and cleanup. +Health-assessment warning→critical transitions dispatch the updated incident +through the normal acknowledgement, snooze, activation and flapping policy; +unchanged severity and critical→warning transitions do not dispatch. The shared +health-assessment caller opts into the configured per-incident hourly limit for +initial, refired and escalation notifications, so repeated severity oscillations +cannot bypass that budget. Incident severity still updates when delivery is +suppressed. This applies to all callers of the shared assessment adapter +(custom sensors, host storage/RAID and storage ZFS pool/device health), not other +stateful alert families. No delivery-policy bypass or separate sensor callback +is introduced. +`TestHostCustomSensorEscalationDelivery` in +`internal/alerts/host_unraid_lifecycle_test.go` pins the running-host callback, +stable incident identity and suppression/no-noise cases; +`TestHealthAssessmentEscalationDelivery` in +`internal/alerts/canonical_stateful_test.go` pins shared ZFS escalation and +hourly-budget exhaustion across warning/critical oscillation. + The alert resource-incident panel (`frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx`) dropped its "Open in Infrastructure / Workloads / Storage / Recovery" diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 4bedecd99..1c13c6278 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3462,3 +3462,13 @@ A failed reconnect admission request retains the current tenant's last valid facet. The synchronous organisation-switch reset still clears outgoing admission before requesting the new tenant, including when that request fails. This is navigation continuity, not an authorization or entitlement decision. + +### Compact entitled Organization chrome + +An entitled self-hosted Organization selector must not push session controls +outside a compact viewport. The shared header may wrap on phones while retaining +desktop placement; this must not change organization scope, feature gates or +activation semantics. The offline mobile audit explicitly activates the default +context before it creates and activates isolated organizations, using the normal +authenticated API. Its route/overflow checks and AppLayout regression cover this +boundary without granting Community private RBAC or changing CI tier membership. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d3cf8d562..96ac5dcd5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7280,3 +7280,17 @@ must not clear a background history filter or invalidate its return-focus target Ordinary search shortcuts resume when the background becomes interactive again. When Assistant is already open, a desktop-to-phone transition must retain that destination rather than reopening the underlying history drawer above it. + +### Compact Organization header wrapping + +The shared header and its controls must wrap below the small-screen breakpoint +instead of forcing the document wider when an entitled Organization selector +joins the session controls. Keep the selector, kiosk/logout controls and +connection indicator available; do not hide overflow to conceal an inaccessible +action. Desktop grid placement remains unchanged. + +Proof: `AppLayout.test.tsx` pins wrapping and retained logout semantics. The +signed-offline `05-settings-mobile-audit.spec.ts` exercises real Organization, +Access and Sharing routes at 320px and 390px, with app-shell width and full-scroll +assertions; header screenshots retain the compact layout. This is independent +of private RBAC implementation and hosted probation acceptance. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index da66077c5..b0f550971 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,39 +1,42 @@ { "version": 1, - "base_sha": "6f3547bc5fbb10ddf313a8cd2f184b6359173a59", - "verified_at": "2026-09-08T22:29:31.643744Z", + "base_sha": "14a109444c6d1cad420797ea30f279e77b6fc517", + "verified_at": "2026-09-09T10:42:06.278902Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Alerts/useWebhookConfigState.ts" + "frontend-modern/src/AppLayout.tsx" ], "content_sha256": { - "frontend-modern/src/components/Alerts/useWebhookConfigState.ts": "14f0dceb069fe52b9a47100c50998ae53a2c5c8a29a524e342f7458546cb4a19" + "frontend-modern/src/AppLayout.tsx": "71cbc527fae0dbae3891174bb8a140d90f50917405d6c15a3b0d8ae7dcb31644" }, "routes": [ - "/qualification (real WebhookConfigForm and hook; synthetic callbacks)" + "/settings/organization", + "/settings/organization/access", + "/settings/organization/sharing" ], "viewports": [ { - "width": 1440, - "height": 900 - }, - { - "width": 900, - "height": 900 + "width": 320, + "height": 568 }, { "width": 390, - "height": 900 + "height": 844 + }, + { + "width": 1280, + "height": 720 } ], "states": [ - "Unsaved manually entered Pushover aliases", - "Test callback payload", - "Save callback payload" + "Signed entitled default and created organizations; non-demo Community backend", + "Access invitation and outgoing sharing fixture", + "Desktop incoming share pending then accepted" ], "interactions": [ - "Click Test then Add Webhook at each width; assert both canonical token/user payloads, no page errors or horizontal overflow." + "Chromium and mobile WebKit: switch organization; audit three routes at both phone widths; reach shell bottom and assert page/shell width within one pixel", + "Desktop Chromium Scenario 6: verify owner identity relationships, editor/viewer roles and pending approval; click Accept and verify accepted state via API" ], - "notes": "Real component with Vite dev transform in Chromium, not production build or installed/provider delivery. Phone screenshot visually inspected; synthetic credentials only. Screenshots /tmp/pulse-webhook-parity.", - "command": "pulse-heavy-run -- node scripts/check-webhook-test-save-parity.mjs" + "command": "pulse-heavy-run -- bash -c \"cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=true node scripts/with-offline-entitlements.mjs node scripts/run-playwright.mjs tests/05-settings-mobile-audit.spec.ts --project=chromium --project=mobile-safari --grep settings/organization --workers=1 --retries=0\"; separate diagnostic config Scenario 6 desktop run", + "notes": "Exact actual mobile grep: settings/organization(/(sharing|access))?$ . Six mobile route tests passed (53.0s); desktop sharing scenario passed (3.8s). Source-built real Community backend, ephemeral loopback signing issuer, no mock mode or signature bypass. Phone Chromium/WebKit and desktop acceptance screenshots inspected. Evidence retained in Web20260909T103517Z outcome directory browser-proof, org-fixed.log and scenario6-desktop.log. Not private RBAC, hosted probation clearance or release qualification." } diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index c00ce6a70..33c7fb981 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -741,7 +741,7 @@ export function AppLayout(props: AppLayoutProps) { />
{renderAssistantLauncher(AI_CHAT_MOBILE_LAUNCHER_BUTTON_CLASS)} diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 00ba82307..1c7578ad0 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -571,6 +571,28 @@ describe('App architecture', () => { expect(retiredRouteNavigations).toEqual([]); }); + it('keeps compact organization chrome presentational and its offline audit explicitly entitled', () => { + expect(appLayoutSource).toContain(''); + expect(appLayoutSource).toContain('selectedOrgId={props.activeOrgID()}'); + expect(appLayoutSource).toContain('onChange={props.onSwitchOrg}'); + expect(appLayoutSource).not.toContain('/api/license/activate'); + const audit = readFileSync( + join(integrationTestsDir, '05-settings-mobile-audit.spec.ts'), + 'utf8', + ); + const preparation = audit.slice( + audit.indexOf('const prepareOrganizationAuditFixture'), + audit.indexOf('const test = base.extend'), + ); + const activation = preparation.indexOf('await activateOfflineOrganization(page)'); + expect(activation).toBeGreaterThan( + preparation.indexOf('await ensureSessionAuthenticated(page)'), + ); + expect(activation).toBeLessThan(preparation.indexOf('await createOrg(page')); + expect(activation).toBeGreaterThan(0); + expect(preparation).toContain('process.env.PULSE_E2E_OFFLINE_ACTIVATION_KEY'); + }); + it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => { expect(appLayoutSource).toContain('export function AppLayout(props: AppLayoutProps)'); expect(appLayoutSource).toContain( diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index 6d25667ee..67110a4f0 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -510,6 +510,18 @@ describe('AppLayout navigation icons', () => { expect(launcher.closest('.header-controls')).toBeInTheDocument(); }); + it('allows mobile header controls to wrap without hiding organization or session actions', () => { + setViewportWidth(320); + const { container } = renderLayout(); + expect(container.querySelector('.header')).toHaveClass('flex-wrap', 'sm:flex-nowrap'); + expect(container.querySelector('.header-controls')).toHaveClass( + 'max-w-full', + 'flex-wrap', + 'sm:flex-nowrap', + ); + expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument(); + }); + it('preserves the Assistant edge launcher on desktop', () => { setViewportWidth(1440); renderLayout(); diff --git a/internal/alerts/canonical_stateful_test.go b/internal/alerts/canonical_stateful_test.go index 7920c9563..8bf50a00e 100644 --- a/internal/alerts/canonical_stateful_test.go +++ b/internal/alerts/canonical_stateful_test.go @@ -279,3 +279,47 @@ func TestStoragePolicyAliasesLegacyIdentity(t *testing.T) { }) } } + +// The shared assessment path also owns ZFS health, not only custom sensors. +func TestHealthAssessmentEscalationDelivery(t *testing.T) { + m := newTestManager(t) + cfg := m.GetConfig() + cfg.Enabled = true + cfg.ActivationState = ActivationActive + cfg.FlappingEnabled = false + cfg.Schedule.MaxAlertsHour = 2 + m.UpdateConfig(cfg) + var delivered []AlertLevel + m.SetAlertCallback(func(a *Alert) { delivered = append(delivered, a.Level) }) + resourceID := "storage-1/zfs-pool:tank" + params := canonicalHealthAssessmentAlertParams{ + SpecID: resourceID + "-health", Signal: "zfs_pool", Codes: zfsPoolAssessmentCodes, + AlertID: buildCanonicalStateID(resourceID, resourceID+"-health"), + AlertType: "zfs-pool-state", SpecResourceID: resourceID, ResourceID: resourceID, + ResourceName: "tank", ResourceType: unifiedresources.ResourceTypeStorage, + } + observe := func(severity storagehealth.RiskLevel) { + t.Helper() + params.Reasons = []storagehealth.Reason{{Code: "zfs_pool_state", Severity: severity, Summary: "pool health"}} + if _, ok := m.syncCanonicalHealthAssessmentAlert(params); !ok { + t.Fatal("assessment rejected") + } + } + observe(storagehealth.RiskWarning) + observe(storagehealth.RiskWarning) + observe(storagehealth.RiskCritical) + observe(storagehealth.RiskCritical) + observe(storagehealth.RiskWarning) + if len(delivered) != 2 || delivered[0] != AlertLevelWarning || delivered[1] != AlertLevelCritical { + t.Fatalf("initial/escalated deliveries = %v", delivered) + } + // The two admitted notifications exhaust the configured hourly budget; + // oscillating back to critical must not bypass it. + observe(storagehealth.RiskCritical) + if len(delivered) != 2 { + t.Fatalf("rate limit bypassed: %v", delivered) + } + if a := testRequireActiveAlert(t, m, params.AlertID); a.Level != AlertLevelCritical { + t.Fatalf("rate-limited incident failed to update: %v", a.Level) + } +} diff --git a/internal/alerts/health_assessment.go b/internal/alerts/health_assessment.go index 852c27efc..b25cddcc1 100644 --- a/internal/alerts/health_assessment.go +++ b/internal/alerts/health_assessment.go @@ -187,6 +187,7 @@ func (m *Manager) syncCanonicalHealthAssessmentAlert(params canonicalHealthAsses return alertspecs.EvaluationResult{}, false } + severity := storageHealthAssessmentSeverity(params.Reasons) now := time.Now() return m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{ Spec: spec, @@ -194,7 +195,7 @@ func (m *Manager) syncCanonicalHealthAssessmentAlert(params canonicalHealthAsses ObservedAt: now, HealthAssessment: &alertspecs.HealthAssessmentEvidence{ Signal: params.Signal, - Severity: storageHealthAssessmentSeverity(params.Reasons), + Severity: severity, Codes: storageHealthReasonCodes(params.Reasons), }, }, @@ -209,5 +210,9 @@ func (m *Manager) syncCanonicalHealthAssessmentAlert(params canonicalHealthAsses AddToRecent: true, AddToHistory: true, MessageBuilder: params.MessageBuilder, + // Health assessments have warning/critical firing levels. Only upward + // transitions notify; unchanged observations and downgrades stay quiet. + NotifyOnSeverityChange: severity == alertspecs.AlertSeverityCritical, + RateLimit: true, }) } diff --git a/internal/alerts/host_unraid_lifecycle_test.go b/internal/alerts/host_unraid_lifecycle_test.go index 14b244e6d..3388516e3 100644 --- a/internal/alerts/host_unraid_lifecycle_test.go +++ b/internal/alerts/host_unraid_lifecycle_test.go @@ -207,3 +207,101 @@ func hasAlertType(alerts []Alert, alertType string) bool { } return false } + +func TestHostCustomSensorEscalationDelivery(t *testing.T) { + for _, policy := range []string{"ready", "acknowledged", "snoozed", "rate-limited", "flapping", "inactive"} { + t.Run(policy, func(t *testing.T) { + m := newTestManager(t) + cfg := m.GetConfig() + cfg.Enabled = true + cfg.ActivationState = ActivationActive + cfg.FlappingEnabled = false + cfg.Schedule.MaxAlertsHour = 0 + m.UpdateConfig(cfg) + var delivered []AlertLevel + m.SetAlertCallback(func(a *Alert) { + if a.Type == "custom-sensor" { + delivered = append(delivered, a.Level) + } + }) + host := models.Host{ID: "sensor-host", Hostname: "sensor-host", Sensors: models.HostSensorSummary{ + Custom: []models.HostCustomSensorMetric{{ID: "probe", Name: "Probe", Status: "warning", ObservedAt: time.Now()}}, + }} + m.CheckHost(host) + if len(delivered) != 1 || delivered[0] != AlertLevelWarning { + t.Fatalf("initial delivery = %v", delivered) + } + var id string + for _, a := range m.GetActiveAlerts() { + if a.Type == "custom-sensor" { + id = a.ID + } + } + if id == "" { + t.Fatal("missing custom sensor incident") + } + switch policy { + case "acknowledged": + if err := m.AcknowledgeAlert(id, "tester"); err != nil { + t.Fatal(err) + } + case "snoozed": + if err := m.SnoozeAlert(id, "tester", time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + case "rate-limited": + a := testRequireActiveAlert(t, m, id) + m.mu.Lock() + m.config.Schedule.MaxAlertsHour = 1 + m.alertRateLimit[canonicalTrackingKeyForAlert(a)] = []time.Time{time.Now()} + m.mu.Unlock() + case "flapping": + a := testRequireActiveAlert(t, m, id) + m.mu.Lock() + m.config.FlappingEnabled = true + m.suppressedUntil[canonicalTrackingKeyForAlert(a)] = time.Now().Add(time.Hour) + m.mu.Unlock() + case "inactive": + cfg.ActivationState = ActivationPending + m.UpdateConfig(cfg) + } + // Repeated warning observations must not resend. + m.CheckHost(host) + host.Sensors.Custom[0].Status = "critical" + m.CheckHost(host) + want := 1 + if policy == "ready" { + want = 2 + } + if len(delivered) != want { + t.Fatalf("warning->critical deliveries = %v, want %d callbacks", delivered, want) + } + if policy == "ready" && delivered[1] != AlertLevelCritical { + t.Fatalf("escalation = %v", delivered) + } + active := m.GetActiveAlerts() + found := false + for _, a := range active { + if a.Type == "custom-sensor" { + found = true + if a.ID != id || a.Level != AlertLevelCritical { + t.Fatalf("updated incident = %+v", a) + } + if policy == "acknowledged" && !a.Acknowledged { + t.Fatal("acknowledgement lost") + } + } + } + if !found { + t.Fatal("critical incident missing") + } + m.CheckHost(host) + host.Sensors.Custom[0].Status = "warning" + m.CheckHost(host) + m.CheckHost(host) + if len(delivered) != want { + t.Fatalf("unchanged/downgrade noise: %v", delivered) + } + }) + } +} diff --git a/tests/integration/tests/05-settings-mobile-audit.spec.ts b/tests/integration/tests/05-settings-mobile-audit.spec.ts index 1d2fe20e8..28fb41dee 100644 --- a/tests/integration/tests/05-settings-mobile-audit.spec.ts +++ b/tests/integration/tests/05-settings-mobile-audit.spec.ts @@ -5,6 +5,8 @@ import { fileURLToPath } from "node:url"; import { test as base, expect } from "@playwright/test"; import { apiRequest, + activateOfflineOrganization, + ensureSessionAuthenticated, createAuthenticatedStorageState, createOrg, } from "./helpers"; @@ -98,6 +100,11 @@ const prepareOrganizationAuditFixture = async ( if (!(ORGANIZATION_SETTINGS_ROUTES as readonly string[]).includes(route)) return []; + if (process.env.PULSE_E2E_OFFLINE_ACTIVATION_KEY) { + await ensureSessionAuthenticated(page); + await activateOfflineOrganization(page); + } + await page.goto("/settings/infrastructure", { waitUntil: "domcontentloaded", }); @@ -255,6 +262,11 @@ test.describe("Settings mobile optimization audit", () => { audit.pageWidth, `Mobile overflow on ${route} at ${viewport.width}px (viewport=${audit.viewportWidth}, page=${audit.pageWidth}, offenders=${JSON.stringify(audit.offenders)})`, ).toBeLessThanOrEqual(audit.viewportWidth + 1); + if ((ORGANIZATION_SETTINGS_ROUTES as readonly string[]).includes(route)) { + // Retain the header as well as the bottom-of-content width proof. + await page.locator(".app-scroll-shell").evaluate((element) => { element.scrollTop = 0; }); + await page.screenshot({ path: test.info().outputPath(`organization-${viewport.width}.png`) }); + } } } finally { if (createdOrgIDs.length > 0) {