## Summary - implement Spec 192 across the targeted Filament record, detail, and edit pages with explicit action-surface inventory and guard coverage - add the focused Spec 192 browser smoke, feature tests, and spec artifacts under `specs/192-record-header-discipline` - improve unhandled promise rejection diagnostics by correlating 419s to the underlying Livewire request URL - disable panel-wide database notification polling on the admin, tenant, and system panels and cover the mitigation with focused tests ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php tests/Feature/Filament/FilamentNotificationsAssetsTest.php tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php tests/Feature/Filament/AdminSmokeTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - manual integrated-browser verification of the Spec 192 surfaces and the notification-polling mitigation ## Notes - Livewire v4 / Filament v5 compliance remains unchanged. - Provider registration stays in `bootstrap/providers.php`. - No Global Search behavior was expanded. - No destructive action confirmation semantics were relaxed. - The full test suite was not run in this PR. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #226
392 lines
13 KiB
JavaScript
392 lines
13 KiB
JavaScript
(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
if (window.__tenantpilotUnhandledRejectionLoggerApplied) {
|
|
return;
|
|
}
|
|
|
|
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
|
|
|
|
const recentKeys = new Map();
|
|
const recentTransportFailures = [];
|
|
|
|
const cleanupRecentKeys = (nowMs) => {
|
|
for (const [key, timestampMs] of recentKeys.entries()) {
|
|
if (nowMs - timestampMs > 5_000) {
|
|
recentKeys.delete(key);
|
|
}
|
|
}
|
|
};
|
|
|
|
const cleanupRecentTransportFailures = (nowMs) => {
|
|
while (recentTransportFailures.length > 0 && nowMs - recentTransportFailures[0].timestampMs > 15_000) {
|
|
recentTransportFailures.shift();
|
|
}
|
|
};
|
|
|
|
const normalizeUrl = (value) => {
|
|
if (typeof value !== 'string' || value === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return new URL(value, window.location.href).href;
|
|
} catch {
|
|
return value;
|
|
}
|
|
};
|
|
|
|
const toBodySnippet = (body) => {
|
|
if (typeof body !== 'string' || body === '') {
|
|
return null;
|
|
}
|
|
|
|
return body.slice(0, 1_000);
|
|
};
|
|
|
|
const recordTransportFailure = ({ requestUrl, method, status, body, transportType }) => {
|
|
const nowMs = Date.now();
|
|
|
|
cleanupRecentTransportFailures(nowMs);
|
|
|
|
recentTransportFailures.push({
|
|
requestUrl: normalizeUrl(requestUrl),
|
|
method: typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET',
|
|
status: Number.isFinite(status) ? status : null,
|
|
bodySnippet: toBodySnippet(body),
|
|
transportType: typeof transportType === 'string' && transportType !== '' ? transportType : 'unknown',
|
|
timestampMs: nowMs,
|
|
});
|
|
|
|
if (recentTransportFailures.length > 30) {
|
|
recentTransportFailures.shift();
|
|
}
|
|
};
|
|
|
|
const resolveTransportMetadata = (reason) => {
|
|
if (reason === null || typeof reason !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const directRequestUrl = typeof reason.requestUrl === 'string'
|
|
? normalizeUrl(reason.requestUrl)
|
|
: (typeof reason.url === 'string' ? normalizeUrl(reason.url) : null);
|
|
|
|
if (directRequestUrl) {
|
|
return {
|
|
requestUrl: directRequestUrl,
|
|
method: typeof reason.method === 'string' ? reason.method.toUpperCase() : null,
|
|
transportType: 'reason',
|
|
};
|
|
}
|
|
|
|
if (!isTransportEnvelope(reason)) {
|
|
return null;
|
|
}
|
|
|
|
const nowMs = Date.now();
|
|
const reasonBodySnippet = toBodySnippet(reason.body);
|
|
|
|
cleanupRecentTransportFailures(nowMs);
|
|
|
|
for (let index = recentTransportFailures.length - 1; index >= 0; index -= 1) {
|
|
const candidate = recentTransportFailures[index];
|
|
|
|
if (nowMs - candidate.timestampMs > 5_000) {
|
|
break;
|
|
}
|
|
|
|
if (candidate.status !== reason.status) {
|
|
continue;
|
|
}
|
|
|
|
if (reasonBodySnippet !== null && candidate.bodySnippet !== null && candidate.bodySnippet !== reasonBodySnippet) {
|
|
continue;
|
|
}
|
|
|
|
return {
|
|
requestUrl: candidate.requestUrl,
|
|
method: candidate.method,
|
|
transportType: candidate.transportType,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const extractRequestMetadata = (input, init) => {
|
|
if (input instanceof Request) {
|
|
return {
|
|
requestUrl: normalizeUrl(input.url),
|
|
method: typeof input.method === 'string' && input.method !== ''
|
|
? input.method.toUpperCase()
|
|
: 'GET',
|
|
};
|
|
}
|
|
|
|
return {
|
|
requestUrl: normalizeUrl(typeof input === 'string' ? input : String(input ?? '')),
|
|
method: typeof init?.method === 'string' && init.method !== ''
|
|
? init.method.toUpperCase()
|
|
: 'GET',
|
|
};
|
|
};
|
|
|
|
if (typeof window.fetch === 'function' && !window.__tenantpilotUnhandledRejectionFetchInstrumented) {
|
|
const originalFetch = window.fetch.bind(window);
|
|
|
|
window.fetch = async (...args) => {
|
|
const [input, init] = args;
|
|
const transport = extractRequestMetadata(input, init);
|
|
|
|
try {
|
|
const response = await originalFetch(...args);
|
|
|
|
if (!response.ok) {
|
|
const clonedResponse = typeof response.clone === 'function' ? response.clone() : null;
|
|
|
|
if (clonedResponse && typeof clonedResponse.text === 'function') {
|
|
clonedResponse.text()
|
|
.then((body) => {
|
|
recordTransportFailure({
|
|
requestUrl: transport.requestUrl,
|
|
method: transport.method,
|
|
status: response.status,
|
|
body,
|
|
transportType: 'fetch',
|
|
});
|
|
})
|
|
.catch(() => {
|
|
recordTransportFailure({
|
|
requestUrl: transport.requestUrl,
|
|
method: transport.method,
|
|
status: response.status,
|
|
body: null,
|
|
transportType: 'fetch',
|
|
});
|
|
});
|
|
} else {
|
|
recordTransportFailure({
|
|
requestUrl: transport.requestUrl,
|
|
method: transport.method,
|
|
status: response.status,
|
|
body: null,
|
|
transportType: 'fetch',
|
|
});
|
|
}
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
recordTransportFailure({
|
|
requestUrl: transport.requestUrl,
|
|
method: transport.method,
|
|
status: null,
|
|
body: error instanceof Error ? error.message : String(error ?? ''),
|
|
transportType: 'fetch',
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
window.__tenantpilotUnhandledRejectionFetchInstrumented = true;
|
|
}
|
|
|
|
if (typeof XMLHttpRequest !== 'undefined' && !window.__tenantpilotUnhandledRejectionXhrInstrumented) {
|
|
const originalOpen = XMLHttpRequest.prototype.open;
|
|
const originalSend = XMLHttpRequest.prototype.send;
|
|
|
|
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
|
this.__tenantpilotRequestMethod = typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET';
|
|
this.__tenantpilotRequestUrl = normalizeUrl(typeof url === 'string' ? url : String(url ?? ''));
|
|
|
|
return originalOpen.call(this, method, url, ...rest);
|
|
};
|
|
|
|
XMLHttpRequest.prototype.send = function (...args) {
|
|
if (!this.__tenantpilotTransportFailureListenerApplied) {
|
|
this.addEventListener('loadend', () => {
|
|
if (typeof this.status === 'number' && this.status >= 400) {
|
|
recordTransportFailure({
|
|
requestUrl: this.__tenantpilotRequestUrl,
|
|
method: this.__tenantpilotRequestMethod,
|
|
status: this.status,
|
|
body: typeof this.responseText === 'string' ? this.responseText : null,
|
|
transportType: 'xhr',
|
|
});
|
|
}
|
|
});
|
|
|
|
this.__tenantpilotTransportFailureListenerApplied = true;
|
|
}
|
|
|
|
return originalSend.apply(this, args);
|
|
};
|
|
|
|
window.__tenantpilotUnhandledRejectionXhrInstrumented = true;
|
|
}
|
|
|
|
const isTransportEnvelope = (value) => {
|
|
return value !== null
|
|
&& typeof value === 'object'
|
|
&& Object.prototype.hasOwnProperty.call(value, 'status')
|
|
&& Object.prototype.hasOwnProperty.call(value, 'body')
|
|
&& Object.prototype.hasOwnProperty.call(value, 'json')
|
|
&& Object.prototype.hasOwnProperty.call(value, 'errors');
|
|
};
|
|
|
|
const isCancellationReason = (reason) => {
|
|
if (!isTransportEnvelope(reason)) {
|
|
return false;
|
|
}
|
|
|
|
return reason.status === null
|
|
&& reason.body === null
|
|
&& reason.json === null
|
|
&& reason.errors === null;
|
|
};
|
|
|
|
const isPageHiddenOrInactive = () => {
|
|
if (document.visibilityState !== 'visible') {
|
|
return true;
|
|
}
|
|
|
|
return typeof document.hasFocus === 'function'
|
|
? document.hasFocus() === false
|
|
: false;
|
|
};
|
|
|
|
const isExpectedBackgroundTransportFailure = (reason) => {
|
|
if (isCancellationReason(reason)) {
|
|
return true;
|
|
}
|
|
|
|
if (!isTransportEnvelope(reason) || !isPageHiddenOrInactive()) {
|
|
return false;
|
|
}
|
|
|
|
return (reason.status === 419 && typeof reason.body === 'string' && reason.body.includes('Page Expired'))
|
|
|| (reason.status === 404 && typeof reason.body === 'string' && reason.body.includes('Not Found'));
|
|
};
|
|
|
|
const normalizeReason = (value, depth = 0) => {
|
|
if (depth > 3) {
|
|
return '[max-depth-reached]';
|
|
}
|
|
|
|
if (value instanceof Error) {
|
|
return {
|
|
type: 'Error',
|
|
name: value.name,
|
|
message: value.message,
|
|
stack: value.stack,
|
|
};
|
|
}
|
|
|
|
if (value === null || value === undefined) {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
return value;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.slice(0, 10).map((item) => normalizeReason(item, depth + 1));
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
const result = {};
|
|
const allowedKeys = [
|
|
'message',
|
|
'stack',
|
|
'name',
|
|
'type',
|
|
'status',
|
|
'body',
|
|
'json',
|
|
'errors',
|
|
'reason',
|
|
'code',
|
|
'url',
|
|
'requestUrl',
|
|
'method',
|
|
];
|
|
|
|
for (const key of allowedKeys) {
|
|
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
result[key] = normalizeReason(value[key], depth + 1);
|
|
}
|
|
}
|
|
|
|
if (Object.keys(result).length > 0) {
|
|
return result;
|
|
}
|
|
|
|
const stringTag = Object.prototype.toString.call(value);
|
|
|
|
return {
|
|
type: stringTag,
|
|
value: String(value),
|
|
};
|
|
}
|
|
|
|
return String(value);
|
|
};
|
|
|
|
const toStableJson = (payload) => {
|
|
try {
|
|
return JSON.stringify(payload);
|
|
} catch {
|
|
return JSON.stringify({
|
|
source: payload.source,
|
|
href: payload.href,
|
|
timestamp: payload.timestamp,
|
|
reason: '[unserializable]',
|
|
});
|
|
}
|
|
};
|
|
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
const normalizedReason = normalizeReason(event.reason);
|
|
const transport = resolveTransportMetadata(normalizedReason);
|
|
const payload = {
|
|
source: 'window.unhandledrejection',
|
|
href: window.location.href,
|
|
timestamp: new Date().toISOString(),
|
|
requestUrl: transport?.requestUrl ?? null,
|
|
requestMethod: transport?.method ?? null,
|
|
transportType: transport?.transportType ?? null,
|
|
reason: normalizedReason,
|
|
};
|
|
|
|
if (isExpectedBackgroundTransportFailure(normalizedReason)) {
|
|
event.preventDefault();
|
|
|
|
return;
|
|
}
|
|
|
|
const dedupeKey = toStableJson({
|
|
source: payload.source,
|
|
href: payload.href,
|
|
requestUrl: payload.requestUrl,
|
|
reason: payload.reason,
|
|
});
|
|
|
|
const payloadJson = toStableJson(payload);
|
|
const nowMs = Date.now();
|
|
|
|
cleanupRecentKeys(nowMs);
|
|
|
|
if (recentKeys.has(dedupeKey)) {
|
|
return;
|
|
}
|
|
|
|
recentKeys.set(dedupeKey, nowMs);
|
|
|
|
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
|
|
});
|
|
})(); |