Compare commits

...

2 Commits

Author SHA1 Message Date
wmantly e5df0d3370 Merge pull request #160 from theta42/docs-help-search
Add header help icon and in-app docs search
2026-07-17 19:29:22 -04:00
wmantly fcd73169e0 Add header help icon and in-app docs search
- A ? icon in the top-right header deep-links to the doc most relevant to
  the current page (client-side path mapping, same pattern already used
  for top-nav active-link highlighting -- no server-side "current section"
  local exists to key off of instead). Falls back to the docs index.
- GET /docs/search does a plain line-substring search over the existing
  allowlisted doc set. No new dependency, stays usable with no internet
  access.

Bumps to v1.1.10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:28:07 -04:00
6 changed files with 99 additions and 6 deletions
+9 -2
View File
@@ -6,7 +6,13 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [Unreleased]
## [1.1.9] - 2026-07-17 ## [1.1.10] - 2026-07-17
### Added
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
Bumps to v1.1.10.
### Added ### Added
- The host list now shows who created each host, and when. - The host list now shows who created each host, and when.
@@ -71,7 +77,8 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
- Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention. - Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention.
- Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates. - Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.9...HEAD [Unreleased]: https://github.com/theta42/proxy/compare/v1.1.10...HEAD
[1.1.10]: https://github.com/theta42/proxy/compare/v1.1.9...v1.1.10
[1.1.9]: https://github.com/theta42/proxy/compare/v1.1.8...v1.1.9 [1.1.9]: https://github.com/theta42/proxy/compare/v1.1.8...v1.1.9
[1.1.8]: https://github.com/theta42/proxy/compare/v1.1.7...v1.1.8 [1.1.8]: https://github.com/theta42/proxy/compare/v1.1.7...v1.1.8
[1.1.7]: https://github.com/theta42/proxy/compare/v1.1.6...v1.1.7 [1.1.7]: https://github.com/theta42/proxy/compare/v1.1.6...v1.1.7
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.9", "version": "1.1.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.9", "version": "1.1.10",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.9", "version": "1.1.10",
"private": true, "private": true,
"author": [ "author": [
{ {
+24
View File
@@ -60,6 +60,30 @@ router.get('/', function(req, res) {
res.render('docs_index', {...values, docs: docList}); res.render('docs_index', {...values, docs: docList});
}); });
// Plain, dependency-free line-substring search over the same allowlisted
// doc set -- no separate index to build/maintain, no new dependency, and it
// keeps working with no internet access (same reasoning as the rest of this
// route). Must be registered before the /:slug catch-all below, or "search"
// would be treated as a (nonexistent) doc slug and 404.
router.get('/search', function(req, res) {
const q = (req.query.q || '').trim();
if (!q) return res.json({results: []});
const qLower = q.toLowerCase();
const results = [];
for (const [slug, doc] of Object.entries(DOCS)) {
try {
const content = fs.readFileSync(doc.file, 'utf8');
const matchLine = content.split('\n').find(line => line.toLowerCase().includes(qLower));
if (matchLine) {
results.push({slug, title: doc.title, snippet: matchLine.trim().slice(0, 200)});
}
} catch (error) { /* unreadable doc file -- skip it */ }
}
res.json({results});
});
router.get('/:slug', function(req, res, next) { router.get('/:slug', function(req, res, next) {
const doc = DOCS[req.params.slug]; const doc = DOCS[req.params.slug];
if (!doc) return next({status: 404, message: 'Doc not found'}); if (!doc) return next({status: 404, message: 'Doc not found'});
+42 -1
View File
@@ -11,7 +11,12 @@
A local copy of this project's documentation, readable from the A local copy of this project's documentation, readable from the
running app -- no internet access required. running app -- no internet access required.
</p> </p>
<ul class="list-group"> <div class="input-group mb-3">
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
<input type="search" id="docs-search-input" class="form-control" placeholder="Search the docs…" oninput="docsSearch(this.value)">
</div>
<div id="docs-search-results" style="display:none"></div>
<ul id="docs-list" class="list-group">
<% docs.forEach(function(doc){ %> <% docs.forEach(function(doc){ %>
<li class="list-group-item"> <li class="list-group-item">
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a> <a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
@@ -22,5 +27,41 @@
</div> </div>
</div> </div>
</div> </div>
<script type="text/javascript">
var docsSearchTimer;
function docsSearch(q){
clearTimeout(docsSearchTimer);
docsSearchTimer = setTimeout(function(){ docsSearchRun(q); }, 200);
}
function docsSearchRun(q){
q = (q || '').trim();
var $results = $('#docs-search-results');
var $list = $('#docs-list');
if(!q){
$results.hide().empty();
$list.show();
return;
}
// Not app.api.get() -- routes/docs.js is mounted at /docs directly,
// not under /api, unlike the rest of this app's endpoints.
$.getJSON('/docs/search', {q: q}, function(data){
$list.hide();
$results.empty().show();
var hits = (data && data.results) || [];
if(!hits.length){
$results.append($('<p class="text-muted"></p>').text('No results for "' + q + '".'));
return;
}
var $ul = $('<ul class="list-group"></ul>');
hits.forEach(function(hit){
var $li = $('<li class="list-group-item"></li>');
$('<a></a>').attr('href', '/docs/' + hit.slug).text(hit.title).appendTo($li);
$('<div class="text-muted small"></div>').text(hit.snippet).appendTo($li);
$ul.append($li);
});
$results.append($ul);
});
}
</script>
<%- include('bottom') %> <%- include('bottom') %>
+21
View File
@@ -62,6 +62,9 @@
</li> </li>
</ul> </ul>
<div class="form-inline mt-2 mt-md-0"> <div class="form-inline mt-2 mt-md-0">
<a id="cl-help" class="nav-link text-light me-3" href="/docs" title="Help">
<i class="fa-solid fa-circle-question"></i>
</a>
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;"> <a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span> <i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a> </a>
@@ -102,6 +105,21 @@
sessionStorage.setItem('update-banner-dismissed', '1'); sessionStorage.setItem('update-banner-dismissed', '1');
} }
// Deep-link the header help icon to whichever doc is most relevant to
// the current page. No server-side "current section" local exists
// (every res.render() call shares one values object, see
// routes/render.js), so this follows the same client-side
// path-matching convention already used for the top-nav active-link
// highlighting just below. Unmapped pages fall back to the docs
// index (already /docs, the anchor's default).
var HELP_DOCS_BY_PATH = {
'/hosts': 'installation',
'/dns': 'installation',
'/users': 'architecture',
'/permissions': 'architecture',
'/groups': 'architecture',
};
$(document).ready(function(){ $(document).ready(function(){
// Set the correct link to active in the top nav bar // Set the correct link to active in the top nav bar
@@ -113,6 +131,9 @@
} }
}) })
let helpSlug = HELP_DOCS_BY_PATH[window.location.pathname.toLocaleLowerCase()];
if(helpSlug) $('#cl-help').attr('href', '/docs/' + helpSlug);
// Set the correct login/logout button, and reveal admin-only nav // Set the correct login/logout button, and reveal admin-only nav
// items for global admins. // items for global admins.
app.auth.isLoggedIn(function(error, data){ app.auth.isLoggedIn(function(error, data){