mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Merge pull request #2005 from rcourtman/maintainer/20260909T120326Z
Keep organization controls visible and deliver critical alert escalations
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user