diff --git a/resources/lang/en-US/table.php b/resources/lang/en-US/table.php
index 749127d9c6..fb8144f2a0 100644
--- a/resources/lang/en-US/table.php
+++ b/resources/lang/en-US/table.php
@@ -7,5 +7,9 @@ return [
'by' => 'By',
'item' => 'Item',
'no_matching_records' => 'No matching records found',
+ 'load_error_title' => 'Could not load results',
+ 'load_error_body' => 'The server returned an error. Please try again in a moment. If this keeps happening, check the application log.',
+ 'load_error_http_status' => 'HTTP status',
+ 'load_error_session_expired' => 'Your session has expired. Reloading the page.',
];
diff --git a/resources/views/partials/bootstrap-table.blade.php b/resources/views/partials/bootstrap-table.blade.php
index 99b2ee7475..da59bb6390 100644
--- a/resources/views/partials/bootstrap-table.blade.php
+++ b/resources/views/partials/bootstrap-table.blade.php
@@ -1,6 +1,21 @@
@push('css')
+ {{-- Bootstrap's default a:hover paints links Bootstrap-blue on hover.
+ For the advanced-search "clear all" pill and the individual
+ tag-remove pills, we want the label chip's white text to stay
+ white on hover (they already carry a colored background). --}}
+
@endpush
@push('js')
@@ -36,6 +51,56 @@
return (operator || defaultAdvancedSearchOperator).toString().toLowerCase() === 'or' ? 'or' : 'and';
};
+ // Shared teardown for the transport-error state onLoadError puts up.
+ // Called from onLoadSuccess (retry succeeded), onRefresh (user clicked
+ // the reload button), and any future entry points that should return
+ // the table to its clean baseline.
+ var clearBootstrapTableLoadError = function (instance) {
+ if (!instance || !instance.$el) {
+ return;
+ }
+ var $wrap = instance.$el.closest('.bootstrap-table');
+ $wrap.find('.table-load-error').remove();
+ $wrap.find('.fixed-table-body').show();
+ $wrap.find('.fixed-table-pagination').show();
+ };
+
+ // Both onLoadError banners (session-expired 401/419 and the generic
+ // 500-level failure) share the same DOM shape, insertion point, and
+ // cleanup rules. innerHtml must already be XSS-safe — callers HTML-
+ // encode dynamic pieces before passing them in.
+ //
+ // aria-live=polite is always emitted. role="alert" alone implies
+ // assertive, which interrupts screen readers mid-utterance; a table
+ // that failed to load doesn't warrant that. Polite queues the
+ // announcement for the next natural pause.
+ var renderBootstrapTableLoadErrorCallout = function (options) {
+ var $wrap = options.wrap;
+ $wrap.find('.table-load-error').remove();
+ $wrap.find('.fixed-table-body').hide();
+ $wrap.find('.fixed-table-pagination').hide();
+
+ // Insert below the advanced-search pill container if it's present
+ // (populated by an active filter), otherwise below the toolbar.
+ // Either way the banner ends up above .fixed-table-container.
+ var $tagContainer = $wrap.children('.snipe-advanced-search-tags');
+ var $anchor = $tagContainer.length && $tagContainer.children().length
+ ? $tagContainer
+ : $wrap.find('.fixed-table-toolbar').first();
+
+ // Marker class table-load-error MUST live on the outermost wrapper
+ // so the cleanup selector removes the whole banner in one shot.
+ // A nested layout would leave orphan outer wells behind on each
+ // re-render and stack up.
+ $anchor.after(
+ '
' +
+ options.innerHtml +
+ '
',
+ );
+ };
+
var getStoredAdvancedSearchOperator = function () {
try {
var storedOperator = localStorage.getItem(advancedSearchOperatorStorageKey);
@@ -730,6 +795,95 @@
exportTypes: ['xlsx', 'csv', 'pdf', 'json', 'xml', 'txt', 'sql', 'doc'],
onLoadSuccess: function () { // possible 'fixme'? this might be for contents, not for headers?
$('[data-tooltip="true"]').tooltip(); // Needed to attach tooltips after ajax call
+
+ // Clear any lingering "load failed" banner + restore the table body
+ // that onLoadError hid. Bootstrap-table appends the table instance as
+ // the last callback arg and binds `this` to the options bag (not the
+ // DOM), so reach for the table element via arguments rather than
+ // $(this).
+ var instance = arguments[arguments.length - 1];
+ if (instance && instance.$el) {
+ clearBootstrapTableLoadError(instance);
+ }
+ },
+ onRefresh: function () {
+ // Fires the moment the user clicks the toolbar's refresh button,
+ // before the retry AJAX call is issued. Clear the callout + restore
+ // the body/pagination up-front so that even if the retry also
+ // errors, we're not stacking state on top of the previous error.
+ // If the retry succeeds, onLoadSuccess is a no-op reassertion.
+ // If the retry errors, onLoadError re-hides the body and re-shows
+ // the callout with the new status.
+ var instance = arguments[arguments.length - 1];
+ if (instance && instance.$el) {
+ clearBootstrapTableLoadError(instance);
+ }
+ },
+ onLoadError: function (status, jqXHR) {
+ // Fires on any real transport-level failure (500, 502, 504, network
+ // error, 419 CSRF expiry, 401 unauthenticated, etc). Snipe's API
+ // convention is to return {status: "error"} at HTTP 200 for
+ // validation/business errors, so anything reaching this callback
+ // is a genuine "the request itself failed" case that would
+ // otherwise render as an empty "No matching records found", a
+ // silent, misleading empty-state.
+ //
+ // Blade's JSON directive below is what makes each translation
+ // string safe to embed in this JS context. It produces a
+ // properly quoted JS string literal that handles any characters
+ // the translation might contain (quotes, backslashes, newlines,
+ // unicode). Every dynamic value from the server response is then
+ // HTML-encoded via jQuery's .text().html() idiom before we
+ // concat it into the callout markup, so a poisoned
+ // responseJSON.message cannot inject script tags or event handlers.
+ //
+ // `this` inside bootstrap-table event callbacks is the options
+ // bag, not the DOM element. The library appends the table
+ // instance as the trailing callback arg, so we reach the
+ // wrapper via arguments[last].$el.
+ var instance = arguments[arguments.length - 1];
+ if (!instance || !instance.$el) {
+ return;
+ }
+ var $wrap = instance.$el.closest('.bootstrap-table');
+
+ // 401 / 419 mean the user's session died; show the session-
+ // expired message and reload so the login redirect can take over.
+ if (jqXHR && (jqXHR.status === 401 || jqXHR.status === 419)) {
+ renderBootstrapTableLoadErrorCallout({
+ wrap: $wrap,
+ innerHtml:
+ '' +
+ $('').text(@json(trans('table.load_error_session_expired'))).html(),
+ });
+ window.setTimeout(function () {
+ window.location.reload();
+ }, 1500);
+ return;
+ }
+
+ // Deliberately do NOT surface the raw server response body /
+ // statusText / responseJSON.message. On PHP fatals, Laravel's
+ // debug renderer can leak PDO DSN strings (with hostnames), file
+ // paths from the stack trace, and other environment detail that
+ // shouldn't reach a browser. The HTTP status alone is enough
+ // signal for the user to know something's wrong and enough
+ // signal for an admin to correlate in the app log.
+ var httpStatus = (jqXHR && jqXHR.status) || status || '?';
+
+ renderBootstrapTableLoadErrorCallout({
+ wrap: $wrap,
+ innerHtml:
+ '