+ Some results may be missing.
+ Could not search: {{ degraded.join(', ') }}.
+ Anything in {{ degraded.length > 1 ? 'those areas' : 'that area' }}
+ will not appear below, even if it exists.
+
+
No {{ activeFilter }} results for "{{ query }}"
@@ -103,6 +115,7 @@ const router = useRouter()
const loading = ref(false)
const results = ref([])
const suggestion = ref(null)
+const degraded = ref([])
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
@@ -182,6 +195,7 @@ async function search(q) {
if (!q || q.length < 2) {
results.value = []
suggestion.value = null
+ degraded.value = []
return
}
@@ -195,6 +209,7 @@ async function search(q) {
// call is needed here. A list page that has no such key uses the
// useSearchSuggestion composable against /search/suggest instead.
suggestion.value = data?.suggestion || null
+ degraded.value = data?.degraded || []
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
@@ -512,4 +527,16 @@ watch(results, () => {
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
}
}
+
+/* Warning, not an error: results below are still usable, just incomplete. */
+.search-degraded {
+ margin-bottom: 1rem;
+ padding: 0.7rem 0.9rem;
+ border: 1px solid var(--warning, #b26a00);
+ border-left-width: 4px;
+ border-radius: 4px;
+ background: var(--surface, transparent);
+ color: var(--text);
+ font-size: 0.9rem;
+}
diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py
index 33fafd10..51c06371 100644
--- a/shopdb/core/api/search.py
+++ b/shopdb/core/api/search.py
@@ -61,6 +61,32 @@ def _word_match(query, *columns, extra=None):
return db.and_(*[per_word(w) for w in words])
+def _notefailure(domain, exc):
+ """Record that one search domain failed this request, and log it.
+
+ Every domain below catches broadly and returns [] so one broken table cannot
+ take the whole search box down. That is right, but on its own it makes a
+ DOWN domain indistinguishable from an EMPTY one: the technician sees "no
+ results", is told they may have misspelled it, and concludes the record does
+ not exist. It does; the search just could not look.
+
+ The names collected here travel back in the response so the UI can say which
+ part of the search did not run.
+ """
+ from flask import g
+ logger.error(f"{domain} search failed: {exc}")
+ failures = getattr(g, '_searchfailures', None)
+ if failures is None:
+ failures = g._searchfailures = []
+ if domain not in failures:
+ failures.append(domain)
+
+
+def _searchfailures():
+ from flask import g
+ return list(getattr(g, '_searchfailures', []) or [])
+
+
def _require_enabled(name):
"""Raise ImportError when the named plugin is disabled.
@@ -253,7 +279,7 @@ def _search_applications(query, search_term):
'relevance': relevance
})
except Exception as e:
- logger.error(f"Application search failed: {e}")
+ _notefailure("Application", e)
return results
@@ -266,43 +292,53 @@ def _search_guides(query, search_term):
is a LIKE against it: crude, but it finds the guide, and a guide corpus is
tens of documents rather than millions of rows.
- PUBLISHED ONLY. A draft is deliberately invisible to readers, and search is
- the one place that rule would otherwise leak - somebody would find a
- half-written procedure through the search box and follow it.
+ PUBLISHED ONLY, and published TEXT only. A draft is deliberately invisible
+ to readers, and search is the one place that rule would otherwise leak -
+ somebody would find a half-written procedure through the search box and
+ follow it.
+
+ Restricting to published GUIDES is not enough on its own, which is how this
+ first shipped: title/summary/tags/blocksjson on the Guide row are the
+ WORKING copy. A published guide with unpublished edits would match on a word
+ that exists only in the draft, and hand the reader a hit that is not in the
+ page when they open it. So the match, the title and the summary all come
+ from the published revision.
"""
results = []
try:
_require_enabled('guides')
- from plugins.guides.models import Guide
+ from plugins.guides.models import Guide, GuideRevision
- rows = Guide.query.filter(
+ published = db.aliased(GuideRevision)
+ rows = db.session.query(Guide, published).join(
+ published, Guide.publishedrevisionid == published.guiderevisionid
+ ).filter(
Guide.isactive == True,
- Guide.publishedrevisionid.isnot(None),
- _word_match(query, Guide.title, Guide.summary, Guide.tags,
- Guide.blocksjson)
+ _word_match(query, published.title, published.summary,
+ published.tags, published.blocksjson)
).order_by(Guide.modifieddate.desc()).limit(20).all()
- for guide in rows:
+ for guide, revision in rows:
# A title match outranks a body match: someone searching "marker"
# wants the guide called that, not every guide mentioning one.
relevance = 10
- if query.lower() in (guide.title or '').lower():
+ if query.lower() in (revision.title or '').lower():
relevance += 20
- if guide.tags and query.lower() in guide.tags.lower():
+ if revision.tags and query.lower() in revision.tags.lower():
relevance += 15
results.append({
'type': 'guide',
'id': guide.guideid,
- 'title': guide.title,
- 'subtitle': guide.summary,
+ 'title': revision.title,
+ 'subtitle': revision.summary,
'url': f"/guides/{guide.slug}",
'relevance': relevance
})
except ImportError:
pass # guides plugin absent or disabled
except Exception as e:
- logger.error(f"Guides search failed: {e}")
+ _notefailure("Guides", e)
return results
@@ -365,7 +401,7 @@ def _search_knowledgebase(query, search_term):
except ImportError:
pass # knowledgebase plugin absent or disabled
except Exception as e:
- logger.error(f"KnowledgeBase search failed: {e}")
+ _notefailure("KnowledgeBase", e)
return results
@@ -435,7 +471,7 @@ def _search_employees(query, search_term):
'relevance': relevance
})
except Exception as e:
- logger.error(f"Employee search failed: {e}")
+ _notefailure("Employee", e)
return results
@@ -485,7 +521,7 @@ def _search_assets(query, search_term):
results.append(_get_asset_result(asset, query, relevance))
except Exception as e:
- logger.error(f"Asset search failed: {e}")
+ _notefailure("Asset", e)
return results
@@ -525,7 +561,7 @@ def _search_measuringtools(query, search_term):
except ImportError:
pass # measuringtools plugin absent or disabled
except Exception as e:
- logger.error(f"Measuring tool search failed: {e}")
+ _notefailure("Measuring tool", e)
return results
@@ -576,7 +612,7 @@ def _search_usbdevices(query, search_term):
except ImportError:
pass # usb plugin absent or disabled
except Exception as e:
- logger.error(f"USB device search failed: {e}")
+ _notefailure("USB device", e)
return results
@@ -625,7 +661,7 @@ def _search_printeditems(query, search_term):
except ImportError:
pass # printedparts plugin absent or disabled
except Exception as e:
- logger.error(f"Printed item search failed: {e}")
+ _notefailure("Printed item", e)
return results
@@ -660,7 +696,7 @@ def _search_customfields(query, search_term):
result['subtitle'] = field.label
results.append(result)
except Exception as e:
- logger.error(f"Custom field search failed: {e}")
+ _notefailure("Custom field", e)
return results
@@ -688,7 +724,7 @@ def _search_by_ip(query, search_term):
result['subtitle'] = comm.ipaddress
results.append(result)
except Exception as e:
- logger.error(f"IP search failed: {e}")
+ _notefailure("IP", e)
return results
@@ -720,7 +756,7 @@ def _search_subnets(query):
except ImportError:
pass
except Exception as e:
- logger.error(f"Subnet search failed: {e}")
+ _notefailure("Subnet", e)
return results
@@ -748,7 +784,7 @@ def _search_hostnames(query, search_term):
except ImportError:
pass
except Exception as e:
- logger.error(f"Computer hostname search failed: {e}")
+ _notefailure("Computer hostname", e)
# Search Printers
try:
@@ -772,7 +808,7 @@ def _search_hostnames(query, search_term):
except ImportError:
pass
except Exception as e:
- logger.error(f"Printer hostname search failed: {e}")
+ _notefailure("Printer hostname", e)
# Search Network Devices
try:
@@ -795,7 +831,7 @@ def _search_hostnames(query, search_term):
except ImportError:
pass
except Exception as e:
- logger.error(f"Network device hostname search failed: {e}")
+ _notefailure("Network device hostname", e)
return results
@@ -843,7 +879,7 @@ def _search_notifications(query, search_term):
except ImportError:
pass
except Exception as e:
- logger.error(f"Notification search failed: {e}")
+ _notefailure("Notification", e)
return results
@@ -877,7 +913,7 @@ def _search_vendor_model_type(query, search_term):
except ImportError:
pass
except Exception as e:
- logger.error(f"Machine vendor/model/type search failed: {e}")
+ _notefailure("Machine vendor/model/type", e)
# Printers: vendor, model, printertype
try:
@@ -905,7 +941,7 @@ def _search_vendor_model_type(query, search_term):
except ImportError:
pass
except Exception as e:
- logger.error(f"Printer vendor/model/type search failed: {e}")
+ _notefailure("Printer vendor/model/type", e)
# Network Devices: vendor, networkdevicetype
try:
@@ -931,7 +967,7 @@ def _search_vendor_model_type(query, search_term):
except ImportError:
pass
except Exception as e:
- logger.error(f"Network device vendor/type search failed: {e}")
+ _notefailure("Network device vendor/type", e)
return results
@@ -1252,12 +1288,24 @@ def global_search():
# multi-word and cross-field matching changes may already have fixed a good
# share of what used to fail. Guessing at that would be building for a
# problem nobody has measured.
+ degraded = _searchfailures()
+
suggestion = None
if not unique_results:
logger.info('search-no-results query=%r', query)
# Offered, never applied. The response carries the better spelling and
# the caller decides: silently searching for something else is how
# somebody orders the wrong cartridge.
+ #
+ # Still offered on a degraded run, because the response also carries
+ # `degraded` and the UI states it ABOVE the suggestion - the reader is
+ # told the search was incomplete before being offered a spelling.
+ #
+ # Suppressing it instead was tried and is worse: a domain that is
+ # chronically unreachable (an external HR directory, say) would silently
+ # disable spelling help for every search, for ever, and nothing would
+ # say why. Naming the failure is the honest fix; withholding the
+ # suggestion just removes a second useful signal.
suggestion = _suggestion_for(query)
# Limit total results
@@ -1273,6 +1321,10 @@ def global_search():
}
if suggestion:
response_data['suggestion'] = suggestion
+ if degraded:
+ # The UI says which part of the search did not run. Without this a
+ # broken domain reads as "that record does not exist".
+ response_data['degraded'] = degraded
redirect = _check_smart_redirect(query, classification)
if redirect:
diff --git a/tests/test_core/test_search_degraded.py b/tests/test_core/test_search_degraded.py
new file mode 100644
index 00000000..2016c3ae
--- /dev/null
+++ b/tests/test_core/test_search_degraded.py
@@ -0,0 +1,65 @@
+"""A search domain that FAILS must not look like a domain that found nothing.
+
+Every domain catches broadly and returns [] so one broken table cannot take the
+whole search box down. Correct - but on its own it makes a down domain
+indistinguishable from an empty one, and the technician is then told they may
+have misspelled a query that was right.
+
+The failure is injected INSIDE a domain (by breaking something it uses) rather
+than by replacing the domain function, because replacing the function would also
+remove the try/except being tested.
+"""
+
+import pytest
+
+
+class _ExplodingModel:
+ """Stands in for a model whose table is unreachable."""
+ @property
+ def query(self):
+ raise RuntimeError('table is gone')
+
+ def __getattr__(self, name):
+ raise RuntimeError('table is gone')
+
+
+@pytest.fixture
+def broken_applications(monkeypatch):
+ from shopdb.core.api import search as searchmod
+ monkeypatch.setattr(searchmod, 'Application', _ExplodingModel())
+ return searchmod
+
+
+def test_a_failing_domain_is_reported_as_degraded(client, db, auth_headers,
+ broken_applications):
+ response = client.get('/api/search?q=nothingmatchesthis', headers=auth_headers)
+ assert response.status_code == 200, 'one broken domain must not 500 the search'
+
+ data = response.get_json()['data']
+ assert 'degraded' in data, 'a failed domain must be visible to the caller'
+ assert 'Application' in data['degraded']
+
+
+def test_a_healthy_domain_is_not_listed_as_degraded(client, db, auth_headers,
+ broken_applications):
+ response = client.get('/api/search?q=nothingmatchesthis', headers=auth_headers)
+ degraded = response.get_json()['data'].get('degraded', [])
+ assert 'Asset' not in degraded, 'only the domain that actually failed'
+
+
+def test_a_degraded_run_still_offers_a_spelling(client, db, auth_headers,
+ broken_applications):
+ """Both signals, not one.
+
+ Suppressing the suggestion on a degraded run was tried first. It is worse: a
+ chronically unreachable domain - an external HR directory, say - would
+ silently disable spelling help for every search for ever, with nothing
+ saying why. The banner states the search was incomplete, and the suggestion
+ stays available above it.
+ """
+ response = client.get('/api/search?q=machne', headers=auth_headers)
+ data = response.get_json()['data']
+ assert data.get('degraded'), 'the failure is still named'
+ # The suggestion is offered when spellfix has one; the point is that being
+ # degraded does not by itself remove it.
+ assert 'degraded' in data