Search: a domain that FAILED no longer looks like one that found nothing

Every domain catches broadly and returns no rows so one broken table cannot take
the whole search box down. That part is right. On its own, though, it makes a
DOWN domain indistinguishable from an EMPTY one: the technician sees no results,
is offered a spelling correction, and concludes the record does not exist. It
does - the search just could not look.

Failed domains are now named in the response and stated above the results.

The suggestion is deliberately still offered on a degraded run. Suppressing it
was tried first and 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. Naming the failure is the honest fix; withholding a
second useful signal is not.

Guides search also moves to the published revision. It correctly limited itself
to published GUIDES, but matched and displayed title/summary/tags/blocksjson
from the Guide row - which is the WORKING copy. A published guide with
unpublished edits matched on wording that only exists in the draft, and the
reader opened it to find the word absent.
This commit is contained in:
cproudlock
2026-08-26 15:05:54 -04:00
parent 9d96006eae
commit e60d6b74d8
3 changed files with 175 additions and 31 deletions

View File

@@ -35,6 +35,18 @@
<div v-if="loading" class="loading">Searching...</div> <div v-if="loading" class="loading">Searching...</div>
<template v-else-if="query"> <template v-else-if="query">
<!--
A domain that could not be searched is stated plainly. Without this,
a broken table reads as "that record does not exist" - and the person
goes off believing the machine was never entered.
-->
<div v-if="degraded.length" class="search-degraded">
<strong>Some results may be missing.</strong>
Could not search: {{ degraded.join(', ') }}.
Anything in {{ degraded.length > 1 ? 'those areas' : 'that area' }}
will not appear below, even if it exists.
</div>
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results"> <div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
No {{ activeFilter }} results for "{{ query }}" No {{ activeFilter }} results for "{{ query }}"
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button> <button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
@@ -103,6 +115,7 @@ const router = useRouter()
const loading = ref(false) const loading = ref(false)
const results = ref([]) const results = ref([])
const suggestion = ref(null) const suggestion = ref(null)
const degraded = ref([])
const query = ref('') const query = ref('')
const searchInput = ref('') const searchInput = ref('')
const activeFilter = ref('all') const activeFilter = ref('all')
@@ -182,6 +195,7 @@ async function search(q) {
if (!q || q.length < 2) { if (!q || q.length < 2) {
results.value = [] results.value = []
suggestion.value = null suggestion.value = null
degraded.value = []
return return
} }
@@ -195,6 +209,7 @@ async function search(q) {
// call is needed here. A list page that has no such key uses the // call is needed here. A list page that has no such key uses the
// useSearchSuggestion composable against /search/suggest instead. // useSearchSuggestion composable against /search/suggest instead.
suggestion.value = data?.suggestion || null suggestion.value = data?.suggestion || null
degraded.value = data?.degraded || []
// Handle ServiceNOW redirect // Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') { if (data?.redirect?.type === 'servicenow') {
@@ -512,4 +527,16 @@ watch(results, () => {
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91; --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;
}
</style> </style>

View File

@@ -61,6 +61,32 @@ def _word_match(query, *columns, extra=None):
return db.and_(*[per_word(w) for w in words]) 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): def _require_enabled(name):
"""Raise ImportError when the named plugin is disabled. """Raise ImportError when the named plugin is disabled.
@@ -253,7 +279,7 @@ def _search_applications(query, search_term):
'relevance': relevance 'relevance': relevance
}) })
except Exception as e: except Exception as e:
logger.error(f"Application search failed: {e}") _notefailure("Application", e)
return results 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 is a LIKE against it: crude, but it finds the guide, and a guide corpus is
tens of documents rather than millions of rows. tens of documents rather than millions of rows.
PUBLISHED ONLY. A draft is deliberately invisible to readers, and search is PUBLISHED ONLY, and published TEXT only. A draft is deliberately invisible
the one place that rule would otherwise leak - somebody would find a to readers, and search is the one place that rule would otherwise leak -
half-written procedure through the search box and follow it. 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 = [] results = []
try: try:
_require_enabled('guides') _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.isactive == True,
Guide.publishedrevisionid.isnot(None), _word_match(query, published.title, published.summary,
_word_match(query, Guide.title, Guide.summary, Guide.tags, published.tags, published.blocksjson)
Guide.blocksjson)
).order_by(Guide.modifieddate.desc()).limit(20).all() ).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" # A title match outranks a body match: someone searching "marker"
# wants the guide called that, not every guide mentioning one. # wants the guide called that, not every guide mentioning one.
relevance = 10 relevance = 10
if query.lower() in (guide.title or '').lower(): if query.lower() in (revision.title or '').lower():
relevance += 20 relevance += 20
if guide.tags and query.lower() in guide.tags.lower(): if revision.tags and query.lower() in revision.tags.lower():
relevance += 15 relevance += 15
results.append({ results.append({
'type': 'guide', 'type': 'guide',
'id': guide.guideid, 'id': guide.guideid,
'title': guide.title, 'title': revision.title,
'subtitle': guide.summary, 'subtitle': revision.summary,
'url': f"/guides/{guide.slug}", 'url': f"/guides/{guide.slug}",
'relevance': relevance 'relevance': relevance
}) })
except ImportError: except ImportError:
pass # guides plugin absent or disabled pass # guides plugin absent or disabled
except Exception as e: except Exception as e:
logger.error(f"Guides search failed: {e}") _notefailure("Guides", e)
return results return results
@@ -365,7 +401,7 @@ def _search_knowledgebase(query, search_term):
except ImportError: except ImportError:
pass # knowledgebase plugin absent or disabled pass # knowledgebase plugin absent or disabled
except Exception as e: except Exception as e:
logger.error(f"KnowledgeBase search failed: {e}") _notefailure("KnowledgeBase", e)
return results return results
@@ -435,7 +471,7 @@ def _search_employees(query, search_term):
'relevance': relevance 'relevance': relevance
}) })
except Exception as e: except Exception as e:
logger.error(f"Employee search failed: {e}") _notefailure("Employee", e)
return results return results
@@ -485,7 +521,7 @@ def _search_assets(query, search_term):
results.append(_get_asset_result(asset, query, relevance)) results.append(_get_asset_result(asset, query, relevance))
except Exception as e: except Exception as e:
logger.error(f"Asset search failed: {e}") _notefailure("Asset", e)
return results return results
@@ -525,7 +561,7 @@ def _search_measuringtools(query, search_term):
except ImportError: except ImportError:
pass # measuringtools plugin absent or disabled pass # measuringtools plugin absent or disabled
except Exception as e: except Exception as e:
logger.error(f"Measuring tool search failed: {e}") _notefailure("Measuring tool", e)
return results return results
@@ -576,7 +612,7 @@ def _search_usbdevices(query, search_term):
except ImportError: except ImportError:
pass # usb plugin absent or disabled pass # usb plugin absent or disabled
except Exception as e: except Exception as e:
logger.error(f"USB device search failed: {e}") _notefailure("USB device", e)
return results return results
@@ -625,7 +661,7 @@ def _search_printeditems(query, search_term):
except ImportError: except ImportError:
pass # printedparts plugin absent or disabled pass # printedparts plugin absent or disabled
except Exception as e: except Exception as e:
logger.error(f"Printed item search failed: {e}") _notefailure("Printed item", e)
return results return results
@@ -660,7 +696,7 @@ def _search_customfields(query, search_term):
result['subtitle'] = field.label result['subtitle'] = field.label
results.append(result) results.append(result)
except Exception as e: except Exception as e:
logger.error(f"Custom field search failed: {e}") _notefailure("Custom field", e)
return results return results
@@ -688,7 +724,7 @@ def _search_by_ip(query, search_term):
result['subtitle'] = comm.ipaddress result['subtitle'] = comm.ipaddress
results.append(result) results.append(result)
except Exception as e: except Exception as e:
logger.error(f"IP search failed: {e}") _notefailure("IP", e)
return results return results
@@ -720,7 +756,7 @@ def _search_subnets(query):
except ImportError: except ImportError:
pass pass
except Exception as e: except Exception as e:
logger.error(f"Subnet search failed: {e}") _notefailure("Subnet", e)
return results return results
@@ -748,7 +784,7 @@ def _search_hostnames(query, search_term):
except ImportError: except ImportError:
pass pass
except Exception as e: except Exception as e:
logger.error(f"Computer hostname search failed: {e}") _notefailure("Computer hostname", e)
# Search Printers # Search Printers
try: try:
@@ -772,7 +808,7 @@ def _search_hostnames(query, search_term):
except ImportError: except ImportError:
pass pass
except Exception as e: except Exception as e:
logger.error(f"Printer hostname search failed: {e}") _notefailure("Printer hostname", e)
# Search Network Devices # Search Network Devices
try: try:
@@ -795,7 +831,7 @@ def _search_hostnames(query, search_term):
except ImportError: except ImportError:
pass pass
except Exception as e: except Exception as e:
logger.error(f"Network device hostname search failed: {e}") _notefailure("Network device hostname", e)
return results return results
@@ -843,7 +879,7 @@ def _search_notifications(query, search_term):
except ImportError: except ImportError:
pass pass
except Exception as e: except Exception as e:
logger.error(f"Notification search failed: {e}") _notefailure("Notification", e)
return results return results
@@ -877,7 +913,7 @@ def _search_vendor_model_type(query, search_term):
except ImportError: except ImportError:
pass pass
except Exception as e: except Exception as e:
logger.error(f"Machine vendor/model/type search failed: {e}") _notefailure("Machine vendor/model/type", e)
# Printers: vendor, model, printertype # Printers: vendor, model, printertype
try: try:
@@ -905,7 +941,7 @@ def _search_vendor_model_type(query, search_term):
except ImportError: except ImportError:
pass pass
except Exception as e: except Exception as e:
logger.error(f"Printer vendor/model/type search failed: {e}") _notefailure("Printer vendor/model/type", e)
# Network Devices: vendor, networkdevicetype # Network Devices: vendor, networkdevicetype
try: try:
@@ -931,7 +967,7 @@ def _search_vendor_model_type(query, search_term):
except ImportError: except ImportError:
pass pass
except Exception as e: except Exception as e:
logger.error(f"Network device vendor/type search failed: {e}") _notefailure("Network device vendor/type", e)
return results return results
@@ -1252,12 +1288,24 @@ def global_search():
# multi-word and cross-field matching changes may already have fixed a good # 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 # share of what used to fail. Guessing at that would be building for a
# problem nobody has measured. # problem nobody has measured.
degraded = _searchfailures()
suggestion = None suggestion = None
if not unique_results: if not unique_results:
logger.info('search-no-results query=%r', query) logger.info('search-no-results query=%r', query)
# Offered, never applied. The response carries the better spelling and # Offered, never applied. The response carries the better spelling and
# the caller decides: silently searching for something else is how # the caller decides: silently searching for something else is how
# somebody orders the wrong cartridge. # 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) suggestion = _suggestion_for(query)
# Limit total results # Limit total results
@@ -1273,6 +1321,10 @@ def global_search():
} }
if suggestion: if suggestion:
response_data['suggestion'] = 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) redirect = _check_smart_redirect(query, classification)
if redirect: if redirect:

View File

@@ -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