Merge pull request #2005 from rcourtman/maintainer/20260909T120326Z

Keep organization controls visible and deliver critical alert escalations
This commit is contained in:
pulse-triage[bot]
2026-09-09 13:23:02 +00:00
committed by GitHub
12 changed files with 268 additions and 21 deletions
@@ -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.
@@ -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"
@@ -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.
@@ -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.
+21 -18
View File
@@ -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."
}
+2 -2
View File
@@ -741,7 +741,7 @@ export function AppLayout(props: AppLayoutProps) {
/>
</Show>
<div
class={`header mb-1 flex items-center gap-1 sm:mb-3 sm:gap-2 ${
class={`header mb-1 flex flex-wrap items-center gap-1 sm:flex-nowrap sm:mb-3 sm:gap-2 ${
kioskMode()
? 'fixed top-0 left-0 right-0 z-50 justify-end bg-surface shadow-sm'
: 'justify-between sm:grid sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-0'
@@ -808,7 +808,7 @@ export function AppLayout(props: AppLayoutProps) {
</div>
</Show>
<div
class={`header-controls flex items-center gap-1 sm:gap-2 ${kioskMode() ? '' : 'justify-end sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end'}`}
class={`header-controls flex max-w-full flex-wrap items-center gap-1 sm:flex-nowrap sm:gap-2 ${kioskMode() ? '' : 'justify-end sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end'}`}
>
<Show when={assistantLauncherVisible() && viewport.isBelow('lg')}>
{renderAssistantLauncher(AI_CHAT_MOBILE_LAUNCHER_BUTTON_CLASS)}
@@ -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('<Show when={props.showOrgSwitcher()}>');
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(
@@ -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();
@@ -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)
}
}
+6 -1
View File
@@ -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,
})
}
@@ -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)
}
})
}
}
@@ -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) {