From 2d1159b24039bcdd932a27087fcd548506a5c1e8 Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Sat, 10 Oct 2026 19:10:17 -0400 Subject: [PATCH 1/9] fix(containers): update a compose container under its own project update_container re-upped a compose service without -p, so compose named the project after the folder. For a Portainer stack (/data/compose/49) that made it "49", the running container belonged to another project, and the re-up failed on its container_name: 'The container name "/searxng" is already in use'. Pass the project from the container's label, as the stack actions already do. --- agent/tests/test_compose_host_paths.py | 5 ++++- agent/tests/test_update_container.py | 4 ++-- agent/vigil_agent/actions/containers.py | 8 +++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/agent/tests/test_compose_host_paths.py b/agent/tests/test_compose_host_paths.py index dbab4391..ea8e8569 100644 --- a/agent/tests/test_compose_host_paths.py +++ b/agent/tests/test_compose_host_paths.py @@ -191,7 +191,10 @@ def test_portainer_update_container(self): executor._update_container({"container_name": "searxng"}, _CFG) self.assertEqual(len(self.calls), 2) - tail = ["--project-directory", str(self.host / "compose" / "49"), + # -p keeps compose on the container's own project: without it the + # project is named after the folder ("49"), and the re-up fails with + # 'The container name "/searxng" is already in use'. + tail = ["-p", "searxng", "--project-directory", str(self.host / "compose" / "49"), "--env-file", str(self.env), "-f", str(self.compose)] self.assertEqual(self.calls[0], ["docker", "compose", *tail, "pull", "searxng"]) self.assertEqual(self.calls[1], ["docker", "compose", *tail, diff --git a/agent/tests/test_update_container.py b/agent/tests/test_update_container.py index 5359d9ed..fba0af52 100644 --- a/agent/tests/test_update_container.py +++ b/agent/tests/test_update_container.py @@ -92,11 +92,11 @@ def test_a_compose_container_updates_through_compose(self): pull = [c for c in calls if "compose" in c and "pull" in c] self.assertEqual(pull, [[ - "docker", "compose", "-f", "/srv/stack/docker-compose.yml", + "docker", "compose", "-p", "stack", "-f", "/srv/stack/docker-compose.yml", "pull", "web"]]) up = [c for c in calls if "compose" in c and "up" in c] self.assertEqual(up, [[ - "docker", "compose", "-f", "/srv/stack/docker-compose.yml", + "docker", "compose", "-p", "stack", "-f", "/srv/stack/docker-compose.yml", "up", "-d", "--no-deps", "web"]]) self.assertFalse(any(c[:2] == ["docker", "run"] for c in calls)) self.assertIn("via compose", out) diff --git a/agent/vigil_agent/actions/containers.py b/agent/vigil_agent/actions/containers.py index 44e85c36..5a69286d 100644 --- a/agent/vigil_agent/actions/containers.py +++ b/agent/vigil_agent/actions/containers.py @@ -373,6 +373,11 @@ def _update_container(params: dict, _config: AgentConfig) -> str: compose_file = "" if labels.get(_COMPOSE_PROJECT_LABEL): + # Re-up under the container's own project. Without -p compose names + # the project after the folder (Portainer's /data/compose/49 makes it + # "49"), does not recognise the running container, and fails on its + # container_name. + project = _validate_name(labels[_COMPOSE_PROJECT_LABEL], "compose project") service = labels.get("com.docker.compose.service") or name _validate_name(service, "service name") @@ -393,7 +398,8 @@ def _update_container(params: dict, _config: AgentConfig) -> str: dir_args = ["--project-directory", str(ex._validate_path(project_dir, "project directory"))] _clear_rollback(project_dir) # an update ends a rollback - cmd = [*ex._compose_cmd(), *dir_args, *_label_env_files(labels, known), "-f", compose_file] + cmd = [*ex._compose_cmd(), "-p", project, *dir_args, *_label_env_files(labels, known), + "-f", compose_file] ex._run(cmd + ["pull", service], timeout=600, extra_env=ex._compose_env()) ex._run(cmd + ["up", "-d", "--no-deps", service], timeout=300, extra_env=ex._compose_env()) From ff905da6de7fc5ffa7f0adf096c4317edf9a2b3e Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Sat, 10 Oct 2026 19:48:11 -0400 Subject: [PATCH 2/9] feat(containers): edit a managed stack from its own header A stack Vigil manages gets an Edit button next to its name on the Containers page, running or down. The Managed by Vigil bar above now holds only New stack and the managed stacks with no row on the page (saved, never deployed), instead of a chip for every stack. --- server/apps/stacks/tests.py | 14 ++++++++++++ server/static/js/vigil-containers.js | 11 ++++++++-- server/static/js/vigil-stacks.js | 33 +++++++++++++++++++++++----- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/server/apps/stacks/tests.py b/server/apps/stacks/tests.py index 9bfaccab..f3ac23a0 100644 --- a/server/apps/stacks/tests.py +++ b/server/apps/stacks/tests.py @@ -238,6 +238,20 @@ def test_editor_is_wired(self): "type=\"${e.revealed ? 'text' : 'password'}\"", "async function renderManagedStacks"): self.assertIn(needle, js) + def test_a_listed_stack_is_edited_from_its_own_header(self): + """Edit sits next to the stack's name; the bar above keeps only New + stack and the managed stacks with no row on the page.""" + import re + from pathlib import Path + root = Path(__file__).resolve().parents[2] + js = (root / "static/js/vigil-stacks.js").read_text(encoding="utf-8") + ctr = (root / "static/js/vigil-containers.js").read_text(encoding="utf-8") + self.assertIn("!stackEd.listed.has(s.name)", js) + self.assertIn("setListedStacks(", ctr) + headers = re.findall(r'[^\n]*\n\s*(\$\{[^\n]*\})', ctr) + self.assertEqual(len(headers), 2, "both header shapes (running and down) carry Edit") + self.assertTrue(all("editButton(" in h for h in headers), headers) + class StackAdoptTests(TestCase): def setUp(self): diff --git a/server/static/js/vigil-containers.js b/server/static/js/vigil-containers.js index 00cec937..559f0ff1 100644 --- a/server/static/js/vigil-containers.js +++ b/server/static/js/vigil-containers.js @@ -108,7 +108,8 @@ async function renderDockerContainers(hostId) { const wrap = document.getElementById('docker-stacks'); const countEl = document.getElementById('docker-count'); if (!wrap) return; - if (typeof renderManagedStacks === 'function') renderManagedStacks(hostId); + const managed = typeof renderManagedStacks === 'function' ? renderManagedStacks(hostId) : null; + const editButton = (name) => (typeof managedStackEditButton === 'function' ? managedStackEditButton(name) : ''); let containers = []; let stacks = []; @@ -120,12 +121,16 @@ async function renderDockerContainers(hostId) { if (resp.ok) containers = await resp.json(); if (stackResp.ok) stacks = await stackResp.json(); } catch { containers = []; } + await managed; // the Edit buttons need the managed stacks const stackInfo = Object.fromEntries((stacks || []).map(s => [s.project, s])); // A stack Vigil deployed or adopted keeps its row after `down` or after its // containers are removed: it is still there, just down, so it stays listed. const downStacks = (stacks || []).filter(s => s.ownership !== 'external' && !containers.some(c => c.stack === s.project)); + if (typeof setListedStacks === 'function') { + setListedStacks([...new Set(containers.map(c => c.stack).filter(Boolean)), ...downStacks.map(s => s.project)]); + } if (!containers.length && !downStacks.length) { countEl.textContent = ''; wrap.innerHTML = '
No containers reported for this host.
'; @@ -160,6 +165,7 @@ async function renderDockerContainers(hostId) { html += `
${escHtml(label)} + ${stack ? editButton(stack) : ''} ${rows.length} ${noun} ${stack ? _stackStateBadge(rows) : ''} ${stackMeta}${stackActs} @@ -206,11 +212,12 @@ async function renderDockerContainers(hostId) { html += `
${escHtml(s.project)} + ${editButton(s.project)} 0 containers down ${escHtml(s.ownership)}
-
No containers — it was taken down or its containers were removed. Deploy it again from Managed by Vigil above.
+
No containers — it was taken down or its containers were removed. Edit it to deploy it again.
`; } wrap.innerHTML = html; diff --git a/server/static/js/vigil-stacks.js b/server/static/js/vigil-stacks.js index d19aebca..f4f06a3e 100644 --- a/server/static/js/vigil-stacks.js +++ b/server/static/js/vigil-stacks.js @@ -15,7 +15,7 @@ // /git-credentials/ (list, add with TOTP, delete) const stackEd = { - hostId: null, stack: null, env: [], stacks: [], revisions: [], dirty: false, + hostId: null, stack: null, env: [], stacks: [], listed: new Set(), revisions: [], dirty: false, source: 'editor', gitCreds: null, gitSnapshot: null, }; let _stackCheckSeq = 0; @@ -27,19 +27,42 @@ const DEPLOY_SAVE_HINT = 'Save first: deploy ships a saved revision'; async function renderManagedStacks(hostId) { const wrap = document.getElementById('managed-stacks'); if (!wrap) return; - stackEd.hostId = hostId; let rows = []; try { rows = (await apiJson(`/api/v1/stacks/?host=${encodeURIComponent(hostId)}`)).results || []; } catch { rows = null; } - if (rows === null) { wrap.innerHTML = ''; return; } // not an admin: nothing to manage + if (rows === null) { stackEd.stacks = []; wrap.innerHTML = ''; return; } // not an admin: nothing to manage + if (stackEd.hostId !== hostId) stackEd.listed = new Set(); + stackEd.hostId = hostId; stackEd.stacks = rows; + _renderManagedBar(); +} + +// A managed stack listed below gets its Edit button in its own header, next +// to its name. The bar keeps New stack and the managed stacks with no row +// below (saved, never deployed). +function _renderManagedBar() { + const wrap = document.getElementById('managed-stacks'); + if (!wrap) return; + const rest = stackEd.stacks.filter(s => !stackEd.listed.has(s.name)); wrap.innerHTML = `
- Managed by Vigil - ${rows.map(s => ``).join('')} + ${rest.length ? 'Managed by Vigil, not deployed' : ''} + ${rest.map(s => ``).join('')}
`; } +/** The Containers page lists these stacks; their Edit sits in their header. */ +function setListedStacks(names) { + stackEd.listed = new Set(names); + if (stackEd.stacks) _renderManagedBar(); +} + +/** The Edit button for a stack header, or '' when Vigil does not manage it. */ +function managedStackEditButton(name) { + const s = (stackEd.stacks || []).find(m => m.name === name); + return s ? `` : ''; +} + function _renderStackEnv() { const wrap = document.getElementById('stack-env'); // Values go in as .value after the markup is built: never interpolate one From dc6b3bd817a2cab89e881aa189ded4d0e64ff9ff Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Sat, 10 Oct 2026 19:54:18 -0400 Subject: [PATCH 3/9] fix(static): link content-hashed static files from the image Scripts loaded from fixed URLs like /static/js/vigil-stacks.js, so a cache in front of Vigil (Cloudflare's browser TTL is 4 hours) served the 2026.14.0 script with the 2026.14.1 page after an upgrade. The old script still built the modal stack editor, so the full-screen editor, its YAML highlighting and the blank new-stack form never showed. The image now sets VIGIL_HASHED_STATIC=1: collectstatic writes hashed copies (manifest storage) and pages link those, served as immutable. Source-map comments in the vendored libraries are left alone; they name .map files Vigil does not ship. Tests and source checkouts keep plain names. --- server/Dockerfile | 3 +++ server/vigil/settings.py | 10 ++++++++++ server/vigil/storage.py | 18 ++++++++++++++++++ server/vigil/tests_hashed_static.py | 24 ++++++++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 server/vigil/storage.py create mode 100644 server/vigil/tests_hashed_static.py diff --git a/server/Dockerfile b/server/Dockerfile index 90894d19..18f075e9 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -90,6 +90,9 @@ RUN if [ ! -f /app/agent_dist/VERSION ]; then \ # VIGIL_SKIP_STARTUP_CHECKS=1 lets manage.py boot without VIGIL_SIGNING_KEY_SEED. # Build-time collectstatic doesn't need a real signing key — the runtime env # (compose/Portainer) supplies the seed. Required since DEBUG defaults to false. +# VIGIL_HASHED_STATIC: hashed static file names, so an upgrade can never be +# served last release's scripts from a cache (settings.py, STORAGES). +ENV VIGIL_HASHED_STATIC=1 RUN VIGIL_SKIP_STARTUP_CHECKS=1 python manage.py collectstatic --noinput EXPOSE 8000 diff --git a/server/vigil/settings.py b/server/vigil/settings.py index c0ac5950..8a7f17e3 100644 --- a/server/vigil/settings.py +++ b/server/vigil/settings.py @@ -237,6 +237,16 @@ STATIC_URL = "static/" STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [BASE_DIR / "static"] +#: The image sets VIGIL_HASHED_STATIC=1: collectstatic writes content-hashed +#: copies (vigil-stacks.3f2a….js) and pages link those. A plain +#: /static/js/x.js URL lets a cache in front of Vigil (Cloudflare's browser +#: TTL is 4 hours) serve last release's script with this release's page. +#: Off by default: tests and a source checkout have no collectstatic manifest. +if os.environ.get("VIGIL_HASHED_STATIC", "").lower() in ("1", "true", "yes"): + STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "vigil.storage.HashedStaticStorage"}, + } # --------------------------------------------------------------------------- # Media files — agent binaries and other uploaded files diff --git a/server/vigil/storage.py b/server/vigil/storage.py new file mode 100644 index 00000000..86cff40a --- /dev/null +++ b/server/vigil/storage.py @@ -0,0 +1,18 @@ +"""Static file storage for the image (VIGIL_HASHED_STATIC, see settings.py).""" +from whitenoise.storage import CompressedManifestStaticFilesStorage + + +def _without_source_maps(patterns): + def keep(p): + return "sourceMappingURL" not in (p if isinstance(p, str) else p[0]) + return tuple((ext, tuple(p for p in pats if keep(p))) for ext, pats in patterns) + + +class HashedStaticStorage(CompressedManifestStaticFilesStorage): + """Content-hashed names, without rewriting source-map comments. + + The vendored libraries (js/vendor/) end in a sourceMappingURL to a .map + file Vigil does not ship; rewriting that comment would fail collectstatic + on a file nobody loads. CSS url() and @import are still rewritten.""" + + patterns = _without_source_maps(CompressedManifestStaticFilesStorage.patterns) diff --git a/server/vigil/tests_hashed_static.py b/server/vigil/tests_hashed_static.py new file mode 100644 index 00000000..cab9487f --- /dev/null +++ b/server/vigil/tests_hashed_static.py @@ -0,0 +1,24 @@ +"""The image links content-hashed static files (VIGIL_HASHED_STATIC). + +A plain /static/js/vigil-stacks.js let a cache in front of Vigil serve the +2026.14.0 script with the 2026.14.1 page: the old script built its own modal +stack editor, so the new full-screen one never appeared.""" +from pathlib import Path + +from django.test import SimpleTestCase + +from vigil.storage import HashedStaticStorage + + +class HashedStaticTests(SimpleTestCase): + def test_the_image_turns_hashed_names_on_before_collectstatic(self): + dockerfile = (Path(__file__).resolve().parents[1] / "Dockerfile").read_text(encoding="utf-8") + flag = dockerfile.index("ENV VIGIL_HASHED_STATIC=1") + self.assertLess(flag, dockerfile.index("manage.py collectstatic")) + + def test_source_map_comments_are_left_alone(self): + """Vendored libraries point at .map files Vigil does not ship.""" + patterns = dict(HashedStaticStorage.patterns) + flat = [p if isinstance(p, str) else p[0] for pats in patterns.values() for p in pats] + self.assertFalse([p for p in flat if "sourceMappingURL" in p]) + self.assertTrue(any("url\\(" in p for p in flat), "CSS url() is still rewritten") From 80edb620b6ae7b90694350a0d657bd66b8897831 Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Sat, 10 Oct 2026 20:19:26 -0400 Subject: [PATCH 4/9] fix(stacks): the editor saves new stacks again, checks the name as you type, and closes with an X A new stack written in the editor was sent without its host or name since the Git source picker went in (4956962), so the server refused every one. - The name is checked as you type: lowercase letters, digits, _ and -, not a stack Vigil already has on the host, and not a compose project already running there. Save waits until it is valid. - The server refuses a running project's name too: deploying under it would take over that project's containers. A project with no containers left frees its name. - An X at the end of the editor bar closes it (asking first if unsaved). - The bar reaches up through the page's top padding, so nothing scrolled past it shows above it. - Scrolling past the end of the compose file stays in the editor, and the colored text and line numbers always have room to follow the textarea. - Show values says what happened next to where you clicked: an error, no saved variables, or how many values it shows. It only appears when there are saved values. --- server/apps/stacks/tests.py | 40 ++++++++++- server/apps/stacks/views.py | 7 ++ server/static/css/vigil.css | 30 ++++++-- server/static/js/vigil-containers.js | 3 +- server/static/js/vigil-stacks.js | 83 +++++++++++++++++++++-- server/templates/pages/_stack_editor.html | 6 +- 6 files changed, 154 insertions(+), 15 deletions(-) diff --git a/server/apps/stacks/tests.py b/server/apps/stacks/tests.py index f3ac23a0..57511043 100644 --- a/server/apps/stacks/tests.py +++ b/server/apps/stacks/tests.py @@ -9,7 +9,7 @@ from apps.hosts.models import Host from .models import ManagedStack, StackRevision -from .validation import StackError, parse_env, validate_compose +from .validation import NAME_RE, StackError, parse_env, validate_compose COMPOSE = """services: jellyfin: @@ -253,6 +253,44 @@ def test_a_listed_stack_is_edited_from_its_own_header(self): self.assertTrue(all("editButton(" in h for h in headers), headers) +class StackNameTests(TestCase): + def setUp(self): + from apps.hosts.models import ContainerStack + self.host = Host.objects.create(hostname="nas", agent_token="nastok", status=Host.Status.ONLINE, + mode="managed") + ContainerStack.objects.create(host=self.host, project="searxng", ownership="external", + container_count=1) + ContainerStack.objects.create(host=self.host, project="gone", ownership="vigil", + container_count=0) + user = get_user_model().objects.create_user("a", password="x") + UserProfile.objects.create(user=user, role=Role.ADMIN) + self.api = APIClient() + self.api.force_authenticate(user) + + def _create(self, name): + return self.api.post("/api/v1/stacks/", {"host_id": str(self.host.id), "name": name, + "compose_yaml": COMPOSE}, format="json") + + def test_a_running_projects_name_is_refused(self): + """compose -p would take over the running project's containers.""" + resp = self._create("searxng") + self.assertEqual(resp.status_code, 400) + self.assertIn("adopt", resp.json()["detail"]) + + def test_a_project_with_no_containers_left_frees_its_name(self): + self.assertEqual(self._create("gone").status_code, 201) + + def test_the_editor_checks_the_name_as_you_type_and_sends_it(self): + from pathlib import Path + js = (Path(__file__).resolve().parents[2] / "static/js/vigil-stacks.js").read_text(encoding="utf-8") + self.assertIn("const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;", js) + self.assertEqual(NAME_RE.pattern, "^[a-z0-9][a-z0-9_-]{0,62}$", "keep the two in step") + self.assertIn("stackEd.running.has(name)", js) + self.assertIn("stackEd.nameTouched = true;\n _checkStackName(false);", js) + # A new stack written here (not from Git) carries its host and name. + self.assertIn("else body = { host_id: stackEd.hostId, name: _stedEl('stack-name').value.trim(),", js) + + class StackAdoptTests(TestCase): def setUp(self): from apps.hosts.models import ContainerStack diff --git a/server/apps/stacks/views.py b/server/apps/stacks/views.py index 9185adf7..0097564e 100644 --- a/server/apps/stacks/views.py +++ b/server/apps/stacks/views.py @@ -121,6 +121,13 @@ def stack_index(request): if ManagedStack.objects.filter(host=host, name=name).exists(): return Response({"detail": f"{host.hostname} already has a stack called {name}"}, status=status.HTTP_400_BAD_REQUEST) + from apps.hosts.models import ContainerStack + if ContainerStack.objects.filter(host=host, project=name, container_count__gt=0).exists(): + # Deploying under a running project's name would take over its + # containers (compose -p ): adopt that one instead. + return Response({"detail": f"{host.hostname} already runs a compose stack called {name} " + f"— adopt it to manage it"}, + status=status.HTTP_400_BAD_REQUEST) stack = ManagedStack(host=host, name=name, created_by=request.user, working_dir=f"{STACKS_ROOT}/{name}") if "git" in request.data: diff --git a/server/static/css/vigil.css b/server/static/css/vigil.css index 4f8deb9d..853a1843 100644 --- a/server/static/css/vigil.css +++ b/server/static/css/vigil.css @@ -306,8 +306,9 @@ body { MAIN ═══════════════════════════════════════════ */ .main { + --main-pad-top: 36px; grid-column: 2; - padding: 36px 44px 60px; + padding: var(--main-pad-top) 44px 60px; overflow-y: auto; max-height: 100vh; } @@ -2409,7 +2410,7 @@ textarea.form-control { min-height: 110px; resize: vertical; font-family: 'IBM P } .te-highlight { pointer-events: none; color: var(--text-1); z-index: 1; } .te-yaml { z-index: 2; background: transparent; color: transparent; caret-color: var(--sky); - resize: none; outline: none; } + resize: none; outline: none; overscroll-behavior: contain; } .te-yaml::placeholder { color: var(--text-3); } .te-yaml::selection { background: rgba(var(--rgb-sky), var(--tint-a)); } @@ -2511,7 +2512,7 @@ select.form-control:hover { border-color: var(--s4); } } .toggle[aria-checked="true"]::after { transform: translateX(20px); background: #fff; } -:root[data-density="compact"] .main { padding: 20px 26px 40px; } +:root[data-density="compact"] .main { --main-pad-top: 20px; padding: var(--main-pad-top) 26px 40px; } :root[data-density="compact"] .page-header { margin-bottom: 18px; } :root[data-density="compact"] .section-label { margin: 22px 0 10px; } :root[data-density="compact"] .host-card { padding: 14px; gap: 12px; } @@ -3863,7 +3864,7 @@ button.fcn:focus-visible { outline: 2px solid var(--sky); outline-offset: 3px; } .managed-stack-chip { border: 0; cursor: pointer; font-family: inherit; } .stack-compose { min-height: 260px; font-family: var(--font-mono); font-size: 12px; } .stack-env { display: flex; flex-direction: column; gap: 6px; } -.stack-env-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto; gap: 6px; } +.stack-env-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) auto; gap: 6px; } .stack-env-actions { display: flex; gap: 8px; margin-top: 8px; } .stack-revisions { display: flex; flex-direction: column; gap: 4px; max-height: 180px; overflow-y: auto; } @@ -3976,10 +3977,14 @@ button.fcn:focus-visible { outline: 2px solid var(--sky); outline-offset: 3px; } /* ── Stack editor page (QA-20) ───────────────────────────────────────── */ .sted { width: 100%; } +/* The bar reaches up through .main's top padding and sticks at the very top: + a bar stuck at top: 0 sits below that padding, and the page scrolled past + it showed through the gap above. */ .sted-bar { - position: sticky; top: 0; z-index: 20; + position: sticky; top: calc(-1 * var(--main-pad-top, 36px)); z-index: 20; display: flex; align-items: center; gap: 14px; - margin: -12px 0 18px; padding: 12px 0; + margin: calc(-1 * var(--main-pad-top, 36px)) 0 18px; + padding: var(--main-pad-top, 36px) 0 12px; background: var(--bg); border-bottom: 1px solid var(--border); } .sted-crumbs { display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 13px; color: var(--text-2); } @@ -3993,6 +3998,10 @@ button.fcn:focus-visible { outline: 2px solid var(--sky); outline-offset: 3px; } .sted-status.is-good { background: rgba(var(--rgb-mint), 0.14); color: var(--mint-ink); } .sted-status.is-bad { background: rgba(var(--rgb-rose), 0.14); color: var(--rose-ink); } .sted-actions { display: flex; align-items: center; gap: 8px; } +.sted-close { margin-left: 4px; } +.sted-name-error { font-size: 12px; color: var(--rose-ink); } +.sted-name-error:empty { display: none; } +#stack-name.is-invalid { border-color: var(--rose); box-shadow: 0 0 0 3px rgba(var(--rgb-rose), 0.14); } .sted-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; align-items: start; } .sted-main { display: flex; flex-direction: column; gap: 10px; min-width: 0; } @@ -4009,6 +4018,15 @@ button.fcn:focus-visible { outline: 2px solid var(--sky); outline-offset: 3px; } border-right: 1px solid var(--border); } .sted-gutter .sted-ln.is-error { color: var(--rose-ink); font-weight: 700; } .sted-editor .te-highlight, .sted-editor .te-yaml { padding-left: 66px; } +/* Scrolling past the end stays in the editor instead of moving the page + under the cursor; and the layers that follow the textarea's scroll get + spare room below, so the textarea can never scroll further than the + colored text and the line numbers (browsers count a textarea's bottom + padding differently). */ +.sted-editor .te-yaml { overscroll-behavior: contain; } +.sted-editor .te-highlight { padding-bottom: 50vh; scrollbar-width: none; } +.sted-editor .te-highlight::-webkit-scrollbar { display: none; } +.sted-gutter { padding-bottom: 50vh; } .sted-check { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } .sted-check-ok { color: var(--mint-ink); font-weight: 600; } diff --git a/server/static/js/vigil-containers.js b/server/static/js/vigil-containers.js index 559f0ff1..b8a4c9c4 100644 --- a/server/static/js/vigil-containers.js +++ b/server/static/js/vigil-containers.js @@ -129,7 +129,8 @@ async function renderDockerContainers(hostId) { const downStacks = (stacks || []).filter(s => s.ownership !== 'external' && !containers.some(c => c.stack === s.project)); if (typeof setListedStacks === 'function') { - setListedStacks([...new Set(containers.map(c => c.stack).filter(Boolean)), ...downStacks.map(s => s.project)]); + const withContainers = [...new Set(containers.map(c => c.stack).filter(Boolean))]; + setListedStacks([...withContainers, ...downStacks.map(s => s.project)], withContainers); } if (!containers.length && !downStacks.length) { countEl.textContent = ''; diff --git a/server/static/js/vigil-stacks.js b/server/static/js/vigil-stacks.js index f4f06a3e..18c59e73 100644 --- a/server/static/js/vigil-stacks.js +++ b/server/static/js/vigil-stacks.js @@ -15,7 +15,7 @@ // /git-credentials/ (list, add with TOTP, delete) const stackEd = { - hostId: null, stack: null, env: [], stacks: [], listed: new Set(), revisions: [], dirty: false, + hostId: null, stack: null, env: [], stacks: [], listed: new Set(), running: new Set(), revisions: [], dirty: false, source: 'editor', gitCreds: null, gitSnapshot: null, }; let _stackCheckSeq = 0; @@ -31,7 +31,7 @@ async function renderManagedStacks(hostId) { try { rows = (await apiJson(`/api/v1/stacks/?host=${encodeURIComponent(hostId)}`)).results || []; } catch { rows = null; } if (rows === null) { stackEd.stacks = []; wrap.innerHTML = ''; return; } // not an admin: nothing to manage - if (stackEd.hostId !== hostId) stackEd.listed = new Set(); + if (stackEd.hostId !== hostId) { stackEd.listed = new Set(); stackEd.running = new Set(); } stackEd.hostId = hostId; stackEd.stacks = rows; _renderManagedBar(); @@ -52,8 +52,9 @@ function _renderManagedBar() { } /** The Containers page lists these stacks; their Edit sits in their header. */ -function setListedStacks(names) { +function setListedStacks(names, withContainers) { stackEd.listed = new Set(names); + stackEd.running = new Set(withContainers || []); if (stackEd.stacks) _renderManagedBar(); } @@ -73,6 +74,9 @@ function _renderStackEnv() { placeholder="${e.stored ? '•••••• (unchanged)' : 'value'}" aria-label="Value of ${escAttr(e.key || 'variable')}" autocomplete="off">
`).join('') || '
No variables.
'; + // Show values only when there are saved values to show. + const reveal = document.querySelector('.sted [data-stack-env-reveal]'); + if (reveal) reveal.hidden = !stackEd.env.some(e => e.stored && !e.revealed); wrap.querySelectorAll('[data-env-row]').forEach((row) => { const entry = stackEd.env[parseInt(row.dataset.envRow, 10)]; if (!entry) return; @@ -119,12 +123,53 @@ function _mirrorStackScroll() { gutter.scrollTop = ta.scrollTop; } +const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/; // apps/stacks/validation.py NAME_RE + +/** Why a new stack cannot take this name, or '' when it can. */ +function _stackNameProblem(name) { + const host = _stedHostLabel(null) || 'This host'; + if (!name) return 'Give the stack a name.'; + if (!STACK_NAME_RE.test(name)) { + return /[A-Z]/.test(name) && STACK_NAME_RE.test(name.toLowerCase()) + ? 'Use lowercase letters only.' + : 'Use lowercase letters, digits, _ and -, starting with a letter or digit (e.g. media or my-app-2).'; + } + if (stackEd.stacks.some(s => s.name === name)) return `${host} already has a stack called ${name}.`; + if (stackEd.running.has(name)) { + return `${host} already runs a compose stack called ${name}. Adopt it from the Containers page to manage it.`; + } + return ''; +} + +/** Live check of a new stack's name, as you type. ``force`` shows the + * problem even before the field was touched (a Save click). */ +function _checkStackName(force) { + const input = _stedEl('stack-name'); + const out = _stedEl('stack-name-error'); + const save = document.querySelector('.sted [data-stack-save]'); + if (stackEd.stack) { // an existing stack's name is fixed + input.classList.remove('is-invalid'); + out.textContent = ''; + save.disabled = false; + return true; + } + const problem = _stackNameProblem(input.value.trim()); + const show = problem && (force || stackEd.nameTouched); + input.classList.toggle('is-invalid', !!show); + input.setAttribute('aria-invalid', show ? 'true' : 'false'); + out.textContent = show ? problem : ''; + save.disabled = !!problem && !!stackEd.nameTouched; + save.title = save.disabled ? problem : ''; + return !problem; +} + function _stedButtons() { const editing = !!stackEd.stack; document.querySelector('.sted [data-stack-deploy]').hidden = !editing; document.querySelector('.sted [data-stack-remove]').hidden = !editing; _stedEl('stack-revisions-field').hidden = !editing; _stedEl('stack-name-field').hidden = editing; + _checkStackName(false); _updateDeployButton(); } @@ -159,6 +204,7 @@ async function openStackEditor(stackId) { const name = _stedEl('stack-name'); name.value = stack ? stack.name : ''; name.disabled = !!stack; + stackEd.nameTouched = false; _stedEl('stack-compose').value = stack ? stack.compose_yaml : _NEW_COMPOSE; _applyStackSource(); // A new stack starts from blank Git fields, not the last stack opened. @@ -545,6 +591,10 @@ function _gitFetchedToast(saved) { async function saveStack() { const editing = stackEd.stack; + if (!editing && !_checkStackName(true)) { + _stedEl('stack-name').focus(); + return; + } const gitStack = !!(editing && editing.git); const gitChanged = gitStack && _gitSettingsChanged(); const env = stackEd.env.filter(e => e.key.trim()).map(e => (e.stored && !e.value && !e.revealed) @@ -553,7 +603,9 @@ async function saveStack() { if (gitStack && gitChanged) body = _gitSettingsBody(env); else if (gitStack) body = { env, note: 'edited' }; // the compose file comes from Git, not from here else if (stackEd.source === 'git') body = _newGitStackBody(_stedEl('stack-name').value.trim(), env); - else body = { compose_yaml: _stedEl('stack-compose').value, env, note: 'edited' }; + else if (editing) body = { compose_yaml: _stedEl('stack-compose').value, env, note: 'edited' }; + else body = { host_id: stackEd.hostId, name: _stedEl('stack-name').value.trim(), + compose_yaml: _stedEl('stack-compose').value, env }; const wasNew = !editing; if (body.git && body.git.credential_id) { // Using a credential decides which server a secret goes to: the server asks for TOTP. @@ -585,11 +637,28 @@ async function _stackTotpAction(path, payload) { async function revealStackEnv() { if (!stackEd.stack) return; - const body = await _stackTotpAction(`/api/v1/stacks/${stackEd.stack.id}/env/reveal/`, {}); - if (!body) return; + if (!stackEd.env.some(e => e.stored)) { + showToast(`${stackEd.stack.name} has no saved variables to show`, 'info'); + return; + } + const code = await totpPrompt('Showing the .env values in the clear.', + { title: 'Show values', confirmText: 'Show values' }); + if (!code) return; + let body; + try { + body = await apiJson(`/api/v1/stacks/${stackEd.stack.id}/env/reveal/`, + { method: 'POST', body: JSON.stringify({ totp: code.trim() }) }); + } catch (e) { + // Said where the button is: the editor's error line sits below the + // compose file, out of sight of the .env card. + showToast(e.message, 'error'); + return; + } const values = Object.fromEntries(body.env.map(e => [e.key, e.value])); stackEd.env = stackEd.env.map(e => (e.stored && e.key in values) ? { ...e, value: values[e.key], revealed: true } : e); _renderStackEnv(); + const shown = stackEd.env.filter(e => e.revealed).length; + showToast(shown ? `Showing ${shown} value${shown === 1 ? '' : 's'}` : 'No saved values matched these variables', shown ? 'success' : 'info'); } async function deployStack() { @@ -672,6 +741,8 @@ document.addEventListener('input', (ev) => { _markStackDirty(); _scheduleStackCheck(); } else if (ev.target.id === 'stack-name') { + stackEd.nameTouched = true; + _checkStackName(false); _markStackDirty(); } }); diff --git a/server/templates/pages/_stack_editor.html b/server/templates/pages/_stack_editor.html index a52f67b1..9655478a 100644 --- a/server/templates/pages/_stack_editor.html +++ b/server/templates/pages/_stack_editor.html @@ -15,6 +15,9 @@ +
@@ -28,7 +31,8 @@
+ autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="stack-name-error"> +
Lowercase letters, digits, _ and -. It lives in /opt/vigil/stacks/<name> on the host.
From 66b57f27f2f5f351282c7050fcd40c38fe889a06 Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Sat, 10 Oct 2026 20:19:31 -0400 Subject: [PATCH 5/9] refactor(ui): every TOTP confirmation uses the one dialog Eight places asked for the code their own way: a field on the deploy, rollout start and enrolment screens, a whole modal for rollout halt and resume, fields on the policy and playbook pages, one in the rebuild dialog, and the browser's own prompt() to turn TOTP off. All now open totpPrompt, after the form's own checks pass, titled for what it confirms. totpPrompt takes a title, and Escape closes only the dialog, not the modal it was opened over. First-run setup keeps its field: there the code proves the authenticator was enrolled. With one dialog, a hardware key can later be offered in one place. --- server/apps/policies/test_policy_page.py | 13 +++- .../tasks/test_qa22_parallel_groups_ui.py | 2 +- server/static/css/vigil.css | 8 --- server/static/js/vigil-deploy.js | 12 ++-- server/static/js/vigil-enroll.js | 5 +- server/static/js/vigil-playbooks.js | 19 +++--- server/static/js/vigil-policies.js | 13 ++-- server/static/js/vigil-reprovision.js | 6 +- server/static/js/vigil-rollout.js | 61 +++++-------------- server/static/js/vigil-settings.js | 3 +- server/static/js/vigil-utils.js | 9 ++- server/templates/base.html | 43 +------------ server/templates/pages/_playbooks.html | 6 +- server/templates/pages/_policies.html | 2 - server/vigil/tests_totp_prompt.py | 32 ++++++++++ 15 files changed, 99 insertions(+), 135 deletions(-) create mode 100644 server/vigil/tests_totp_prompt.py diff --git a/server/apps/policies/test_policy_page.py b/server/apps/policies/test_policy_page.py index 9b12cfd3..563d2e44 100644 --- a/server/apps/policies/test_policy_page.py +++ b/server/apps/policies/test_policy_page.py @@ -18,7 +18,7 @@ def test_page_is_reachable_and_wired(self): self.assertIn('{% include "pages/_policies.html" %}', dash) self.assertIn('id="page-policies"', html) for element_id in ("pol-table-body", "pol-changes-list", "pol-name", "pol-rules", - "pol-classes", "pol-preview", "pol-run-totp", "pol-high-risk"): + "pol-classes", "pol-preview", "pol-run", "pol-high-risk"): self.assertIn(f'id="{element_id}"', html) self.assertIn(f"'{element_id}'", js) for act in ("openPolicyEditor", "closePolicyEditor", "savePolicy", "deletePolicy", @@ -36,3 +36,14 @@ def test_no_side_stripes(self): # A reset to 0 is fine; any real width on one side is a stripe. self.assertIsNone(re.search(r"border-(left|right)\s*:(?!\s*0[;\s])", block)) self.assertNotIn("box-shadow: inset", block) + + +class PolicyTotpTests(SimpleTestCase): + def test_codes_are_asked_by_the_shared_prompt(self): + """Approve and Run now ask through totpPrompt, not a field on the page.""" + root = Path(__file__).resolve().parents[2] + html = (root / "templates/pages/_policies.html").read_text(encoding="utf-8") + js = (root / "static/js/vigil-policies.js").read_text(encoding="utf-8") + self.assertNotIn("one-time-code", html) + for title in ("title: 'Approve changes'", "title: 'Run policy now'"): + self.assertIn(title, js) diff --git a/server/apps/tasks/test_qa22_parallel_groups_ui.py b/server/apps/tasks/test_qa22_parallel_groups_ui.py index a49d1ba3..7d1a1c95 100644 --- a/server/apps/tasks/test_qa22_parallel_groups_ui.py +++ b/server/apps/tasks/test_qa22_parallel_groups_ui.py @@ -113,7 +113,7 @@ def test_halt_all_uses_the_batch_endpoint_once(self): code = _code_only(JS.read_text()) self.assertIn("/api/v1/rollouts/batch/", code) self.assertIn("halt-batch", code) - self.assertEqual(1, _function(code, "confirmRolloutAction") + self.assertEqual(1, _function(code, "promptRolloutAction") .count("/api/v1/rollouts/batch/")) diff --git a/server/static/css/vigil.css b/server/static/css/vigil.css index 853a1843..9bc61f9b 100644 --- a/server/static/css/vigil.css +++ b/server/static/css/vigil.css @@ -1269,13 +1269,6 @@ textarea.form-control { min-height: 110px; resize: vertical; font-family: 'IBM P .deploy-right-col { display: flex; flex-direction: column; min-height: 0; } .deploy-right-col > #deploy-target-hosts, .deploy-right-col > #deploy-target-tags { display: flex; flex-direction: column; flex: 1; min-height: 0; } -.deploy-totp-row { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--border); - flex-shrink: 0; -} -.deploy-totp-row .form-control { letter-spacing: 4px; font-family: 'IBM Plex Mono', monospace; } /* Deploy tabs (LEFT pane) */ .deploy-tabs { @@ -3822,7 +3815,6 @@ button.fcn:focus-visible { outline: 2px solid var(--sky); outline-offset: 3px; } .pol-drift { max-height: 280px; overflow-y: auto; } .pol-run { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-top: 14px; } .pol-run-now { display: inline-flex; align-items: center; gap: 8px; } -.pol-totp { width: 120px; font-size: 12px; } .pol-compliance { margin-bottom: 16px; } .pol-explain { background: var(--s1); border: 1px solid var(--border); border-radius: var(--r-md); padding: 12px 16px; margin-bottom: 16px; font-size: 13px; color: var(--text-2); } .pol-explain summary { cursor: pointer; font-weight: 600; color: var(--text-1); } diff --git a/server/static/js/vigil-deploy.js b/server/static/js/vigil-deploy.js index 0b8c77af..13e92c0b 100644 --- a/server/static/js/vigil-deploy.js +++ b/server/static/js/vigil-deploy.js @@ -510,7 +510,6 @@ async function openDeployModal(definitionId, prefill) { document.getElementById('deploy-modal-title').textContent = 'Loading…'; document.getElementById('deploy-risk-label').innerHTML = ''; document.getElementById('deploy-yaml-view').innerHTML = ''; - document.getElementById('deploy-totp').value = ''; document.getElementById('deploy-host-search').value = ''; deployState.definitionId = definitionId; deployState.selectedHosts = new Set(); @@ -687,12 +686,8 @@ async function submitDeploy(event) { return; } - const totp = document.getElementById('deploy-totp').value.trim(); - if (!totp) { showToast('Enter your TOTP code', 'error'); return; } - const policy = _readDeployPolicy(); const body = { - totp, inputs: _readDeployInputs(), schedule: policy.schedule, on_failure: policy.on_failure, @@ -711,6 +706,13 @@ async function submitDeploy(event) { body.send_to_refusing = !!document.getElementById('deploy-send-refusing')?.checked; + const target = body.tags + ? `every host tagged ${body.tags.join(', ')}` + : `${body.host_ids.length} host${body.host_ids.length === 1 ? '' : 's'}`; + const totp = await totpPrompt(`Deploying to ${target}.`, { title: 'Confirm deploy', confirmText: 'Deploy' }); + if (!totp) return; + body.totp = totp; + const btn = document.getElementById('deploy-submit-btn'); btn.disabled = true; btn.style.opacity = '0.6'; try { diff --git a/server/static/js/vigil-enroll.js b/server/static/js/vigil-enroll.js index 2142f593..87b6bed8 100644 --- a/server/static/js/vigil-enroll.js +++ b/server/static/js/vigil-enroll.js @@ -126,8 +126,9 @@ async function _pollForHost() { async function enrollApproveHost() { if (!_enrollDetectedHost) return; - const totp = (document.getElementById('enroll-approve-totp').value || '').trim(); - if (!totp) { showToast('Enter your TOTP code to approve', 'error'); return; } + const totp = await totpPrompt(`Approving ${_enrollDetectedHost.hostname || 'this host'} lets it receive signed tasks.`, + { title: 'Approve host', confirmText: 'Approve' }); + if (!totp) return; const btn = document.getElementById('enroll-approve-btn'); btn.disabled = true; try { diff --git a/server/static/js/vigil-playbooks.js b/server/static/js/vigil-playbooks.js index 5774d39e..01b0311c 100644 --- a/server/static/js/vigil-playbooks.js +++ b/server/static/js/vigil-playbooks.js @@ -470,8 +470,6 @@ function _openEditor(data, editingId) { _blHighRiskWasOn = !!(editingId && data && data.allow_high_risk); const allowBox = document.getElementById('bl-allow-high-risk'); if (allowBox) allowBox.checked = _blHighRiskWasOn; - const totpInput = document.getElementById('bl-highrisk-totp'); - if (totpInput) totpInput.value = ''; _blSyncHighRiskWarning(); _editingTree = data ? flowTreeFromRow(data) : []; _blSel = null; @@ -500,14 +498,6 @@ async function _savePlaybook() { allow_high_risk: allowHighRisk, flow_steps: flowSerialize(_editingTree), }; - // Only sent when the flag is being turned on — the server ignores it - // otherwise, and a code spent on a no-op change is a code burned for - // nothing (they are single-use within their validity window). - if (allowHighRisk && !_blHighRiskWasOn) { - const totp = (document.getElementById('bl-highrisk-totp')?.value || '').trim(); - if (!totp) return showToast('Enter your TOTP code to allow high-risk steps', 'error'); - body.totp = totp; - } if (!body.name) return showToast('Give the playbook a name', 'error'); const allSteps = flowSteps(_editingTree); if (!allSteps.length) return showToast('Add at least one task', 'error'); @@ -517,6 +507,15 @@ async function _savePlaybook() { } const blocking = flowFindings(_editingTree).filter(f => f.text.startsWith('A branch has no condition')); if (blocking.length) return showToast('Give every branch a condition before saving', 'error'); + // Only asked when the flag is being turned on — the server ignores it + // otherwise, and a code spent on a no-op change is a code burned for + // nothing (they are single-use within their validity window). + if (allowHighRisk && !_blHighRiskWasOn) { + const totp = await totpPrompt('High-risk steps will run unattended on every host this playbook matches.', + { title: 'Allow high-risk steps', confirmText: 'Authorize and save' }); + if (!totp) return; + body.totp = totp; + } const editing = document.getElementById('bl-editor-modal').dataset.editing; try { if (editing) await apiJson(`/api/v1/playbooks/${editing}/`, { method: 'PATCH', body: JSON.stringify(body) }); diff --git a/server/static/js/vigil-policies.js b/server/static/js/vigil-policies.js index 732a4382..6a857384 100644 --- a/server/static/js/vigil-policies.js +++ b/server/static/js/vigil-policies.js @@ -126,16 +126,15 @@ async function decidePolicyChanges(kind) { if (!ids.length) return; const body = { ids }; if (kind === 'approve') { - body.totp = (document.getElementById('pol-changes-totp')?.value || '').trim(); - if (!body.totp) { showToast('Enter your TOTP code', 'error'); return; } + body.totp = await totpPrompt(`Sending ${ids.length} approved change${ids.length === 1 ? '' : 's'} to their hosts.`, + { title: 'Approve changes', confirmText: 'Approve and send' }); + if (!body.totp) return; } try { const result = await apiJson(`/api/v1/policies/changes/${kind}/`, { method: 'POST', body: JSON.stringify(body) }); showToast(kind === 'approve' ? `Sent to ${result.dispatched} host${result.dispatched === 1 ? '' : 's'}` : `Rejected ${result.rejected}`, 'success'); polState.selected.clear(); - const totp = document.getElementById('pol-changes-totp'); - if (totp) totp.value = ''; fetchPolicyChanges(); } catch (e) { showToast(e.message, 'error'); } } @@ -313,11 +312,11 @@ async function loadPolicyPreview() { } async function runPolicyNow() { - const totp = (_polEl('pol-run-totp')?.value || '').trim(); - if (!totp) { showToast('Enter your TOTP code', 'error'); return; } + const totp = await totpPrompt('Running this policy now applies it to every host it targets.', + { title: 'Run policy now', confirmText: 'Run now' }); + if (!totp) return; try { const result = await apiJson(`/api/v1/policies/${polState.editing}/run/`, { method: 'POST', body: JSON.stringify({ totp }) }); - _polEl('pol-run-totp').value = ''; if (result.error) { showToast(result.error, 'error'); return; } const text = result.mode === 'approval' ? `${result.queued} change${result.queued === 1 ? '' : 's'} waiting for approval` : result.mode === 'rollout' ? `Rollout started for ${result.dispatched} host${result.dispatched === 1 ? '' : 's'}` diff --git a/server/static/js/vigil-reprovision.js b/server/static/js/vigil-reprovision.js index 6a14c288..dee4235e 100644 --- a/server/static/js/vigil-reprovision.js +++ b/server/static/js/vigil-reprovision.js @@ -97,7 +97,6 @@ async function openRebuildModal(hostId, hostname) { : '') + `
` + `` + - `` + // placeholder is set as a DOM property below, not interpolated: escHtml // does not escape quotes, and the hostname is agent-supplied. `` + @@ -161,6 +160,9 @@ async function openRebuildModal(hostId, hostname) { _runPreflight(hostId); goBtn.onclick = async () => { + const totp = await totpPrompt(`Rebuilding ${hostname} erases it and reinstalls the operating system.`, + { title: 'Rebuild this machine', confirmText: 'Rebuild' }); + if (!totp) return; goBtn.disabled = true; try { const job = await apiJson('/api/v1/reprovision/jobs/', { @@ -170,7 +172,7 @@ async function openRebuildModal(hostId, hostname) { image: imageSel.value, profile: profileSel.value, password: document.getElementById('rb-password').value, - totp: document.getElementById('rb-totp').value.trim(), + totp, typed_hostname: hostnameInput.value, acknowledge_plaintext_transport: !!(ackBox && ackBox.checked), }), diff --git a/server/static/js/vigil-rollout.js b/server/static/js/vigil-rollout.js index 05d8fac0..5ce5c303 100644 --- a/server/static/js/vigil-rollout.js +++ b/server/static/js/vigil-rollout.js @@ -29,7 +29,6 @@ const ROLLOUT_STATE_COLOR = { const _rolloutState = { items: [], pollTimer: null, - pendingAction: null, // { title, message, fn(totp) } for the confirm modal groups: [], // picked wave-group tags, in pick order — order decides where a host on two ladders goes groupChoices: [], // the enabled groups GET /api/v1/wave-groups/ offered on open }; @@ -308,7 +307,6 @@ async function openRolloutStart() { // Cleared on every open so a previous pick can't be submitted by accident. document.getElementById('rollout-start-def').value = ''; document.getElementById('rollout-start-def-label').textContent = 'Choose a task or playbook…'; - document.getElementById('rollout-start-totp').value = ''; // The chips need no reset beyond clearing the pick: they are re-fetched and // repainted on every open. _rolloutState.groups = []; @@ -383,13 +381,14 @@ function closeRolloutStart() { async function submitRolloutStart() { const sel = document.getElementById('rollout-start-def'); - const totp = document.getElementById('rollout-start-totp').value.trim(); const threshold = parseInt(document.getElementById('rollout-start-threshold').value, 10); const minResults = parseInt(document.getElementById('rollout-start-min').value, 10); const btn = document.getElementById('rollout-start-submit'); if (!sel.value) { showToast('Pick something to roll out first', 'error'); return; } - if (!/^\d{6}$/.test(totp)) { showToast('Enter the 6-digit TOTP code', 'error'); return; } const picked = _rolloutState.groups; + const totp = await totpPrompt('Wave 1 starts as soon as you confirm.', + { title: 'Start rollout', confirmText: 'Start rollout' }); + if (!totp) return; btn.disabled = true; try { const r = await apiJson('/api/v1/rollouts/', { @@ -421,8 +420,6 @@ async function submitRolloutStart() { const msg = e.message || 'Request failed'; if (/TOTP|two-factor/i.test(msg)) { showToast('TOTP code rejected — check your authenticator', 'error'); - document.getElementById('rollout-start-totp').value = ''; - document.getElementById('rollout-start-totp').focus(); } else { showToast('Could not start rollout: ' + msg, 'error'); } @@ -431,12 +428,9 @@ async function submitRolloutStart() { } } -/* ── Halt / resume (TOTP confirm modal) ──────────────────────────────── */ +/* ── Halt / resume (confirmed with TOTP) ─────────────────────────────── */ -function promptRolloutAction(btn, kind) { - const modal = document.getElementById('rollout-action-modal'); - const overlay = document.getElementById('rollout-action-overlay'); - if (!modal || !overlay) return; +async function promptRolloutAction(btn, kind) { const rolloutId = btn ? btn.dataset.rlt : null; const batch = btn ? btn.dataset.rltBatch : null; const card = btn ? btn.closest('.rlt-batch') : null; @@ -453,58 +447,35 @@ function promptRolloutAction(btn, kind) { : kind === 'halt-batch' ? 'Halt every group' : 'Skip the validation window'; const message = kind === 'halt' - ? `Stop ${name} now? The current wave keeps running; nothing new is dispatched. An admin TOTP is required.` + ? `Stop ${name} now? The current wave keeps running; nothing new is dispatched.` : kind === 'resume' ? `Restart ${name} at its current wave? Failed tasks on the wave are re-queued and the gate re-evaluates.` : kind === 'halt-batch' - ? `Stop all ${lanes} groups of ${batchName} now? Every group keeps its current wave; nothing new is dispatched. An admin TOTP is required.` + ? `Stop all ${lanes} groups of ${batchName} now? Every group keeps its current wave; nothing new is dispatched.` : `End this wave's validation window for ${name} and move to the next wave now? ` + `The window exists so a slow failure has time to show up, so only do this if you have ` + `checked the wave yourself. The failure gate still applies.`; - _rolloutState.pendingAction = { title, message, rolloutId, batch, kind }; - document.getElementById('rollout-action-title').textContent = title; - document.getElementById('rollout-action-message').textContent = message; - document.getElementById('rollout-action-totp').value = ''; - overlay.classList.add('open'); - modal.classList.add('open'); - setTimeout(() => document.getElementById('rollout-action-totp').focus(), 50); -} - -function closeRolloutAction() { - const modal = document.getElementById('rollout-action-modal'); - const overlay = document.getElementById('rollout-action-overlay'); - if (modal) modal.classList.remove('open'); - if (overlay) overlay.classList.remove('open'); - _rolloutState.pendingAction = null; -} - -async function confirmRolloutAction() { - const pending = _rolloutState.pendingAction; - if (!pending) return; - const totp = document.getElementById('rollout-action-totp').value.trim(); - const btn = document.getElementById('rollout-action-confirm'); - if (!/^\d{6}$/.test(totp)) { showToast('Enter the 6-digit TOTP code', 'error'); return; } - btn.disabled = true; + const totp = await totpPrompt(message, { title, confirmText: title }); + if (!totp) return; try { let note; - if (pending.kind === 'halt-batch') { + if (kind === 'halt-batch') { // One request, one code: TOTP codes are single-use, so halting a batch // group by group would need a fresh code per group. - const res = await apiJson(`/api/v1/rollouts/batch/${encodeURIComponent(pending.batch)}/halt/`, { + const res = await apiJson(`/api/v1/rollouts/batch/${encodeURIComponent(batch)}/halt/`, { method: 'POST', body: JSON.stringify({ totp }), }); note = `Halted ${res.halted} rollouts`; } else { - await apiJson(`/api/v1/rollouts/${pending.rolloutId}/${pending.kind}/`, { + await apiJson(`/api/v1/rollouts/${rolloutId}/${kind}/`, { method: 'POST', body: JSON.stringify({ totp }), }); - note = pending.kind === 'halt' ? 'Rollout halted' - : pending.kind === 'resume' ? 'Rollout resumed' + note = kind === 'halt' ? 'Rollout halted' + : kind === 'resume' ? 'Rollout resumed' : 'Validation window skipped — moving to the next wave'; } - closeRolloutAction(); closeRolloutDetail(); showToast(note, 'success'); refreshRollouts(); @@ -512,15 +483,11 @@ async function confirmRolloutAction() { const msg = e.message || 'Request failed'; if (/TOTP|two-factor/i.test(msg)) { showToast('TOTP code rejected — check your authenticator', 'error'); - document.getElementById('rollout-action-totp').value = ''; - document.getElementById('rollout-action-totp').focus(); } else if (/admin|permission/i.test(msg)) { showToast('Halting a rollout requires an admin account', 'error'); } else { showToast('Action failed: ' + msg, 'error'); } - } finally { - btn.disabled = false; } } diff --git a/server/static/js/vigil-settings.js b/server/static/js/vigil-settings.js index f0c19881..bb97fa2f 100644 --- a/server/static/js/vigil-settings.js +++ b/server/static/js/vigil-settings.js @@ -72,7 +72,8 @@ async function totpConfirm() { } async function totpDisablePrompt() { - const code = prompt('Enter a current 6-digit code to disable TOTP:'); + const code = await totpPrompt('Turning off two-factor authentication. Enter a current code to confirm.', + { title: 'Disable TOTP', confirmText: 'Disable' }); if (!code) return; try { await apiJson('/api/v1/accounts/totp/disable/', { diff --git a/server/static/js/vigil-utils.js b/server/static/js/vigil-utils.js index 2c084871..062abeca 100644 --- a/server/static/js/vigil-utils.js +++ b/server/static/js/vigil-utils.js @@ -368,7 +368,8 @@ function promptModal(message, opts) { /* ── TOTP dialog (replaces the bare window.prompt the writes used) ────── */ //: The reason is operator copy, not untrusted data: the dialog renders the //: `reason` argument as plain text via textContent, so callers interpolate -//: freely without escaping. +//: freely without escaping. opts: title, confirmText. Every confirm-with-a-code +//: goes through here, so a second factor (a hardware key) has one place to land. function totpPrompt(reason, opts) { opts = opts || {}; return new Promise((resolve) => { @@ -390,6 +391,7 @@ function totpPrompt(reason, opts) { `); + m.modal.querySelector('#totp-title').textContent = opts.title || 'Confirm with your authenticator'; m.modal.querySelector('#totp-reason').textContent = reason || ''; const input = m.modal.querySelector('#totp-input'); const okBtn = m.modal.querySelector('#totp-ok'); @@ -410,8 +412,9 @@ function totpPrompt(reason, opts) { m.modal.querySelector('#totp-x').onclick = () => done(null); m.overlay.onclick = () => done(null); input.onkeydown = (ev) => { - if (ev.key === 'Enter') submit(); - if (ev.key === 'Escape') done(null); + if (ev.key === 'Enter') { ev.preventDefault(); submit(); } + // Escape closes this prompt only, not the modal it was opened over. + if (ev.key === 'Escape') { ev.stopPropagation(); done(null); } }; requestAnimationFrame(() => { m.open(); input.focus(); }); }); diff --git a/server/templates/base.html b/server/templates/base.html index c13b9c5b..60218dd2 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -372,15 +372,6 @@ - -
-
- - -
Enter the 6-digit code from your authenticator app. Enrollment is required (Settings → Two-Factor Authentication).
-
-
- -{# Rollout start modal — opened by openRolloutStart() in vigil-rollout.js; TOTP-gated like the deploy modal. #} +{# Rollout start modal — opened by openRolloutStart() in vigil-rollout.js; Start asks for TOTP (totpPrompt) like a deploy. #} -
- - -
Rollouts are 2FA-gated fleet-wide operations, same as deploys.
-
@@ -524,27 +510,6 @@
-{# Rollout halt/resume confirm — TOTP-gated operator actions. #} - - - {# Submit-to-community modal — opened from the task editor (openCommunitySubmit) #} -
Review the details above and approve the host to bring it online. Confirm with your TOTP code — approval is 2FA-gated because an approved host immediately becomes eligible to receive signed tasks.
-
- - -
+
Review the details above and approve the host to bring it online. Approving asks for your TOTP code — an approved host immediately becomes eligible to receive signed tasks.
diff --git a/server/templates/pages/_playbooks.html b/server/templates/pages/_playbooks.html index f6ed05b2..3bce0a10 100644 --- a/server/templates/pages/_playbooks.html +++ b/server/templates/pages/_playbooks.html @@ -126,13 +126,9 @@
High-risk steps will run unattended on every host this playbook matches, with nobody at the keyboard to approve or stop them. - Confirm with your TOTP code to authorize that now — it stands for every + Saving asks for your TOTP code to authorize that — it stands for every future enrollment.
- -
update_agent stays excluded either way — it replaces the executable that enforces the agent's own allowlist. diff --git a/server/templates/pages/_policies.html b/server/templates/pages/_policies.html index 2105a2ae..8a4ab1ab 100644 --- a/server/templates/pages/_policies.html +++ b/server/templates/pages/_policies.html @@ -45,7 +45,6 @@
@@ -153,7 +152,6 @@ diff --git a/server/vigil/tests_totp_prompt.py b/server/vigil/tests_totp_prompt.py new file mode 100644 index 00000000..ab922fc8 --- /dev/null +++ b/server/vigil/tests_totp_prompt.py @@ -0,0 +1,32 @@ +"""Every confirm-with-a-code goes through totpPrompt (vigil-utils.js). + +One dialog means one look, one Escape behaviour, and one place for a second +kind of factor (a hardware key) to land later. The only code field left on a +page is first-run setup's, where the code proves the app was enrolled.""" +import re +from pathlib import Path + +from django.test import SimpleTestCase + +ROOT = Path(__file__).resolve().parents[1] + + +class TotpPromptTests(SimpleTestCase): + def test_no_page_has_its_own_code_field(self): + allowed = {"setup.html"} + found = [str(p.relative_to(ROOT)) for p in (ROOT / "templates").rglob("*.html") + if p.name not in allowed and "one-time-code" in p.read_text(encoding="utf-8")] + found += [str(p.relative_to(ROOT)) for p in (ROOT / "static/js").glob("*.js") + if p.name != "vigil-utils.js" and "one-time-code" in p.read_text(encoding="utf-8")] + self.assertEqual(found, []) + + def test_no_code_is_asked_with_the_browser_prompt(self): + for p in (ROOT / "static/js").glob("*.js"): + for line in re.findall(r"[^.\w]prompt\([^)]*\)", p.read_text(encoding="utf-8")): + self.assertNotRegex(line.lower(), r"code|totp", f"{p.name}: {line}") + + def test_the_prompt_takes_a_title_and_keeps_escape_to_itself(self): + js = (ROOT / "static/js/vigil-utils.js").read_text(encoding="utf-8") + body = js[js.index("function totpPrompt("):js.index("function yamlToHtml(")] + self.assertIn("opts.title", body) + self.assertIn("ev.stopPropagation()", body) From fc15a6b0797843e67eb32f339acc310d52d81880 Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Sat, 10 Oct 2026 20:29:46 -0400 Subject: [PATCH 6/9] feat(hosts): pick a host's tags from a list, or create one, as you type The host drawer had a text field and a Pick button that only filled the field: a pick saved nothing until Add was pressed, and two picks saved as one tag "a, b". The field is now a combobox: it lists the existing tags with how many machines carry them, offers "Create tag" for a new name, refuses Vigil's own inventory tags (os:, pkg: ...), works from the keyboard, and saves each pick at once. The update policy's "Hosts tagged" field gets the Pick button every other tag field has, including inventory tags. --- server/apps/hosts/test_tag_combobox.py | 33 +++++++ server/static/css/vigil.css | 14 +++ server/static/js/vigil-host-cards.js | 23 ++--- server/static/js/vigil-tags.js | 121 ++++++++++++++++++++++++- server/templates/base.html | 3 +- 5 files changed, 174 insertions(+), 20 deletions(-) create mode 100644 server/apps/hosts/test_tag_combobox.py diff --git a/server/apps/hosts/test_tag_combobox.py b/server/apps/hosts/test_tag_combobox.py new file mode 100644 index 00000000..445cee38 --- /dev/null +++ b/server/apps/hosts/test_tag_combobox.py @@ -0,0 +1,33 @@ +"""The host drawer adds tags through a combobox (vigil-tags.js attachTagCombobox). + +It used to be a text field plus a Pick button that only filled the field: a +pick saved nothing until Add was pressed, and two picks became one tag +"a, b". The combobox lists existing tags as you type, offers to create a new +one, refuses Vigil's own inventory tags, and saves each pick at once.""" +from pathlib import Path + +from django.test import SimpleTestCase + +ROOT = Path(__file__).resolve().parents[2] + + +class TagComboboxTests(SimpleTestCase): + def setUp(self): + self.tags = (ROOT / "static/js/vigil-tags.js").read_text(encoding="utf-8") + self.cards = (ROOT / "static/js/vigil-host-cards.js").read_text(encoding="utf-8") + self.base = (ROOT / "templates/base.html").read_text(encoding="utf-8") + + def test_the_drawer_field_is_a_combobox_that_saves_each_pick(self): + self.assertIn("attachTagCombobox('detail-tag-input'", self.tags) + self.assertNotIn("attachTagPicker('detail-tag-input'", self.tags) + self.assertIn("async function addDetailTag(name)", self.cards) + self.assertNotIn('id="detail-tag-add"', self.base) + + def test_inventory_tags_cannot_be_added_by_hand(self): + self.assertIn("const _RESERVED_TAG = /^(os|os_family|pkg|arch|agent):/i;", self.tags) + body = self.tags[self.tags.index("function attachTagCombobox("):self.tags.index("/* ── Wiring")] + self.assertIn("disabled: true", body) + self.assertIn("escHtml(o.name)", body) + + def test_every_tag_field_has_a_picker(self): + self.assertIn("attachTagPicker('pol-tags'", self.tags) diff --git a/server/static/css/vigil.css b/server/static/css/vigil.css index 9bc61f9b..4168ee04 100644 --- a/server/static/css/vigil.css +++ b/server/static/css/vigil.css @@ -3192,6 +3192,20 @@ input[type="checkbox"]:disabled { opacity: 0.4; cursor: not-allowed; } /* The "add a tag" row in the host detail drawer. */ .host-tag-add-row { display: flex; gap: 8px; align-items: center; } +/* Tag combobox (attachTagCombobox in vigil-tags.js). */ +.tag-combo { position: relative; flex: 1; min-width: 0; } +.tag-combo > .form-control { width: 100%; } +.tag-combo-list { position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 30; + background: var(--s1); border: 1px solid var(--border); border-radius: var(--r-md); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); padding: 4px; max-height: 280px; overflow-y: auto; } +.tag-combo-opt { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; + padding: 7px 10px; border-radius: 6px; cursor: pointer; font-size: 12px; color: var(--text-1); } +.tag-combo-opt:hover, .tag-combo-opt.is-active { background: var(--s3); } +.tag-combo-opt.is-disabled { cursor: default; color: var(--text-3); background: none; } +.tag-combo-name b { font-weight: 600; } +.tag-combo-meta { font-size: 11px; color: var(--text-3); white-space: nowrap; } +.tag-combo-empty { padding: 8px 10px; font-size: 12px; color: var(--text-3); } + /* ── Widget dashboard ───────────────────────────────────────────────────── */ .dash-root { margin-bottom: 22px; } .dash-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } diff --git a/server/static/js/vigil-host-cards.js b/server/static/js/vigil-host-cards.js index 06b770dd..5faa5789 100644 --- a/server/static/js/vigil-host-cards.js +++ b/server/static/js/vigil-host-cards.js @@ -253,23 +253,12 @@ function _removeDetailTag(tag) { _saveDetailTags(next); } -document.addEventListener('DOMContentLoaded', () => { - const input = document.getElementById('detail-tag-input'); - const btn = document.getElementById('detail-tag-add'); - if (!input || !btn) return; - function commit() { - const v = (input.value || '').trim().toLowerCase(); - if (!v) return; - if (_detailTagState.tags.includes(v)) { input.value = ''; return; } - const next = [..._detailTagState.tags, v]; - input.value = ''; - _saveDetailTags(next); - } - btn.addEventListener('click', commit); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { e.preventDefault(); commit(); } - }); -}); +/** Add one tag to the open host and save (the drawer's tag combobox, vigil-tags.js). */ +async function addDetailTag(name) { + const v = String(name || '').trim().toLowerCase(); + if (!v || _detailTagState.tags.includes(v)) return; + await _saveDetailTags([..._detailTagState.tags, v]); +} let detailChartCpu = null; let detailChartNet = null; diff --git a/server/static/js/vigil-tags.js b/server/static/js/vigil-tags.js index 58071d8b..5330fa8c 100644 --- a/server/static/js/vigil-tags.js +++ b/server/static/js/vigil-tags.js @@ -200,6 +200,118 @@ function attachTagPicker(inputId, opts = {}) { }); } +/* ── Tag combobox: type, pick or create, one tag at a time ──────────── */ + +const _RESERVED_TAG = /^(os|os_family|pkg|arch|agent):/i; + +/** + * Turn a text input into a tag combobox. Typing filters the existing tags + * (with machine counts) in a list under the field; a name that does not exist + * yet is offered as "Create tag …", so a typo is a visible choice. Enter or a + * click calls `opts.onAdd(name)` and clears the field for the next one. + * + * opts.current() → the tags already on the thing, left out of the list + * opts.onAdd(name) → add it (may be async) + */ +function attachTagCombobox(inputId, opts) { + const input = document.getElementById(inputId); + if (!input || input.dataset.tagCombo) return; + input.dataset.tagCombo = '1'; + input.setAttribute('role', 'combobox'); + input.setAttribute('aria-autocomplete', 'list'); + input.setAttribute('aria-expanded', 'false'); + input.autocomplete = 'off'; + + const list = document.createElement('div'); + list.className = 'tag-combo-list'; + list.id = inputId + '-options'; + list.setAttribute('role', 'listbox'); + list.hidden = true; + input.setAttribute('aria-controls', list.id); + const wrap = document.createElement('div'); + wrap.className = 'tag-combo'; + input.parentNode.insertBefore(wrap, input); + wrap.appendChild(input); + wrap.appendChild(list); + + let options = []; // [{name, meta, create}] + let active = -1; + + const close = () => { list.hidden = true; input.setAttribute('aria-expanded', 'false'); active = -1; }; + + function render() { + const q = input.value.trim().toLowerCase(); + const have = new Set((opts.current() || []).map(t => String(t).toLowerCase())); + const matches = _allTags + .filter(t => t.editable && !have.has(t.name.toLowerCase()) + && (!q || t.name.toLowerCase().includes(q))) + .sort((a, b) => (b.name.toLowerCase().startsWith(q) - a.name.toLowerCase().startsWith(q)) + || (b.host_count - a.host_count) || a.name.localeCompare(b.name)) + .slice(0, 8) + .map(t => ({ name: t.name, + meta: t.host_count === 1 ? '1 machine' : `${t.host_count} machines` })); + options = matches; + if (q && !have.has(q) && !_allTags.some(t => t.name.toLowerCase() === q)) { + options.push(_RESERVED_TAG.test(q) + ? { name: q, meta: 'set by Vigil from inventory — cannot be added by hand', disabled: true } + : { name: q, meta: 'new tag', create: true }); + } + if (!options.length) { + list.innerHTML = `
${q ? 'Already on this machine.' : 'No other tags yet — type one to create it.'}
`; + } else { + list.innerHTML = options.map((o, i) => `
+ ${o.create ? 'Create tag ' : ''}${escHtml(o.name)} + ${escHtml(o.meta)}
`).join(''); + } + input.setAttribute('aria-activedescendant', active >= 0 ? `${list.id}-${active}` : ''); + list.hidden = false; + input.setAttribute('aria-expanded', 'true'); + } + + async function choose(i) { + const o = options[i]; + if (!o || o.disabled) return; + input.value = ''; + active = -1; + await opts.onAdd(o.name); + if (o.create) _allTags = []; // it exists now; refetch next time + await ensureTagsLoaded(); + if (document.activeElement === input) render(); else close(); + } + + input.addEventListener('focus', async () => { await ensureTagsLoaded(); render(); }); + input.addEventListener('input', async () => { active = -1; await ensureTagsLoaded(); render(); }); + input.addEventListener('blur', () => setTimeout(close, 120)); + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + if (list.hidden) { render(); return; } + const n = options.length; + if (!n) return; + active = e.key === 'ArrowDown' ? (active + 1) % n : (active - 1 + n) % n; + render(); + } else if (e.key === 'Enter') { + e.preventDefault(); + const typed = input.value.trim().toLowerCase(); + // Enter takes the highlighted option, else an exact or new name. + let i = active; + if (i < 0) i = options.findIndex(o => o.name.toLowerCase() === typed); + if (i < 0 && options.length === 1 && typed) i = 0; + if (i >= 0) choose(i); + } else if (e.key === 'Escape') { + if (!list.hidden) { e.preventDefault(); e.stopPropagation(); close(); } + } + }); + // mousedown, not click: a click lands after the input's blur closed the list. + list.addEventListener('mousedown', (e) => { + const row = e.target.closest('[data-combo-idx]'); + if (!row) return; + e.preventDefault(); + choose(Number(row.dataset.comboIdx)); + }); +} + /* ── Wiring ───────────────────────────────────────────────────────────── */ document.addEventListener('DOMContentLoaded', () => { @@ -221,7 +333,14 @@ document.addEventListener('DOMContentLoaded', () => { attachTagPicker('auto-target-tags', { title: 'Run on hosts tagged' }); attachTagPicker('repro-prof-tags', { title: 'Tag the rebuilt host with', includeReserved: true }); - attachTagPicker('detail-tag-input', { title: 'Add a tag to this machine' }); + attachTagPicker('pol-tags', { title: 'Hosts this policy applies to are tagged', + includeReserved: true }); + // The host drawer adds one tag at a time and saves it at once: a combobox, + // not a comma field (vigil-host-cards.js). + attachTagCombobox('detail-tag-input', { + current: () => _detailTagState.tags, + onAdd: (name) => addDetailTag(name), + }); }); diff --git a/server/templates/base.html b/server/templates/base.html index 60218dd2..42f23b4e 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -220,8 +220,7 @@
Tags
- - +
Server-side tags. Agent.yml tags are merged in (server values always win).
From 9f15c737e7ad87a5e7747e13c453da1c4c9df4b5 Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Sat, 10 Oct 2026 20:34:51 -0400 Subject: [PATCH 7/9] feat: search everything, from the sidebar or Ctrl+K Ported from Jackil's Search Everything: a Search page in the sidebar with one box, and Ctrl+K (or / when not typing) jumps to it from anywhere. GET /api/v1/search/?q= searches hosts (name, OS, IP, tags), containers (name, image), stacks, apps (one result per name with its host count), open CVEs (grouped, worst severity first), task definitions, playbooks, update policies and firing alerts, at most 8 of each. Each kind is filtered the way its own page is: hosts and everything on them by site scope, stacks and update policies for admins only, tasks the user's own plus community ones. A result opens where it lives: a host's monitor, a container's host, the stack editor, the task, playbook or policy editor. --- server/apps/hosts/search.py | 153 ++++++++++++++++++++++++++++ server/apps/hosts/test_search.py | 119 ++++++++++++++++++++++ server/static/css/vigil.css | 15 +++ server/static/js/vigil-search.js | 128 +++++++++++++++++++++++ server/templates/base.html | 5 + server/templates/dashboard.html | 4 + server/templates/pages/_search.html | 16 +++ server/vigil/urls.py | 2 + 8 files changed, 442 insertions(+) create mode 100644 server/apps/hosts/search.py create mode 100644 server/apps/hosts/test_search.py create mode 100644 server/static/js/vigil-search.js create mode 100644 server/templates/pages/_search.html diff --git a/server/apps/hosts/search.py b/server/apps/hosts/search.py new file mode 100644 index 00000000..19370cce --- /dev/null +++ b/server/apps/hosts/search.py @@ -0,0 +1,153 @@ +"""Search everything (QA-25) — one box over what the user may already see. + +Each kind is filtered exactly the way its own page filters it: hosts and +everything on a host through ``scoping.filter_by_site``, stacks and update +policies for admins only (their APIs are admin-only), task definitions the +user's own plus community ones (what the task library lists). +""" +import re + +from django.db.models import Count, Q +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from apps.accounts.permissions import IsAdmin + +from vigil import scoping + +LIMIT = 8 +MAX_QUERY = 100 +_IP_PREFIX = re.compile(r"^[0-9a-fA-F:.]+$") +_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} + + +def _plural(n: int, word: str) -> str: + return f"{n} {word}" if n == 1 else f"{n} {word}s" + + +def _hosts(request, q): + from .models import Host + match = Q(hostname__icontains=q) | Q(os__icontains=q) | Q(tags__icontains=q) + if _IP_PREFIX.match(q): + match |= Q(ip_address__startswith=q) + qs = scoping.filter_by_site(Host.objects.exclude(status="rejected"), request.user).filter(match) + return [{"kind": "host", "id": str(h.id), "title": h.hostname, + "subtitle": " · ".join(x for x in (h.os, h.ip_address or "") if x)} + for h in qs.order_by("hostname")[:LIMIT]] + + +def _containers(request, q): + from .models import DockerContainer + qs = (scoping.filter_by_site(DockerContainer.objects.exclude(host__status="rejected"), + request.user, path="host__") + .filter(Q(name__icontains=q) | Q(image__icontains=q)).select_related("host")) + return [{"kind": "container", "id": c.name, "host_id": str(c.host_id), "title": c.name, + "subtitle": f"{c.image} on {c.host.hostname}"} + for c in qs.order_by("name")[:LIMIT]] + + +def _stacks(request, q): + from apps.stacks.models import ManagedStack + qs = (scoping.filter_by_site(ManagedStack.objects.all(), request.user, path="host__") + .filter(name__icontains=q).select_related("host")) + return [{"kind": "stack", "id": str(s.id), "host_id": str(s.host_id), "title": s.name, + "subtitle": f"on {s.host.hostname} · r{s.revision}"} + for s in qs.order_by("name")[:LIMIT]] + + +def _apps(request, q): + from apps.software.models import SoftwareItem + qs = (scoping.filter_by_site(SoftwareItem.objects.exclude(host__status="rejected"), + request.user, path="host__") + .filter(Q(name__icontains=q) | Q(publisher__icontains=q)) + .values("name").annotate(hosts=Count("host", distinct=True)).order_by("-hosts", "name")) + return [{"kind": "app", "id": row["name"], "title": row["name"], + "subtitle": "on " + _plural(row["hosts"], "host")} + for row in qs[:LIMIT]] + + +def _cves(request, q): + from apps.vulns.models import VulnFinding + qs = (scoping.filter_by_site(VulnFinding.objects.filter(state="open").exclude(cve_id=""), + request.user, path="host__") + .filter(Q(cve_id__icontains=q) | Q(title__icontains=q))) + ids = list(qs.values("cve_id").annotate(hosts=Count("host", distinct=True)) + .order_by("-hosts", "cve_id")[:LIMIT]) + out = [] + for row in ids: + findings = list(qs.filter(cve_id=row["cve_id"]).values("severity", "title")[:50]) + worst = min((f["severity"] for f in findings), key=lambda s: _SEVERITY_RANK.get(s, 9)) + title = next((f["title"] for f in findings if f["title"]), "") + out.append({"kind": "cve", "id": row["cve_id"], "title": row["cve_id"], + "subtitle": " · ".join(x for x in (worst, _plural(row["hosts"], "host"), title) if x)}) + return out + + +def _tasks(request, q): + from apps.tasks.models import TaskDefinition + qs = (TaskDefinition.objects.filter(archived_at__isnull=True) + .filter(Q(owner=request.user) | Q(visibility=TaskDefinition.Visibility.COMMUNITY)) + .filter(Q(name__icontains=q) | Q(description__icontains=q))) + return [{"kind": "task", "id": str(d.id), "title": d.name, "subtitle": (d.description or "")[:80]} + for d in qs.order_by("name")[:LIMIT]] + + +def _playbooks(request, q): + from apps.playbooks.models import Playbook + qs = (scoping.filter_by_site(Playbook.objects.filter(archived_at__isnull=True), request.user, + cascade_global=True) + .filter(Q(name__icontains=q) | Q(description__icontains=q))) + return [{"kind": "playbook", "id": str(b.id), "title": b.name, + "subtitle": (b.description or "")[:80]} + for b in qs.order_by("name")[:LIMIT]] + + +def _policies(request, q): + from apps.policies.models import UpdatePolicy + qs = UpdatePolicy.objects.filter(name__icontains=q) + return [{"kind": "policy", "id": str(p.id), "title": p.name, + "subtitle": "enabled" if p.enabled else "disabled"} + for p in qs.order_by("name")[:LIMIT]] + + +def _alerts(request, q): + from apps.alerts.models import Alert + qs = (scoping.filter_by_site(Alert.objects.filter(state=Alert.State.FIRING), request.user, + path="host__") + .filter(Q(message__icontains=q) | Q(host__hostname__icontains=q)).select_related("host")) + return [{"kind": "alert", "id": str(a.id), "title": (a.message or "")[:100], + "subtitle": f"{a.severity} on {a.host.hostname}"} + for a in qs.order_by("-fired_at")[:LIMIT]] + + +#: (kind, label, finder, admin only) — in the order the page shows them. +_KINDS = ( + ("host", "Hosts", _hosts, False), + ("container", "Containers", _containers, False), + ("stack", "Stacks", _stacks, True), + ("app", "Apps", _apps, False), + ("cve", "Vulnerabilities", _cves, False), + ("task", "Tasks", _tasks, False), + ("playbook", "Playbooks", _playbooks, False), + ("policy", "Update policies", _policies, True), + ("alert", "Firing alerts", _alerts, False), +) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def search(request): + """One box over everything the user may already see (QA-25).""" + q = request.query_params.get("q", "").strip()[:MAX_QUERY] + if len(q) < 2: + return Response({"query": q, "groups": []}) + admin = IsAdmin().has_permission(request, None) + groups = [] + for kind, label, find, admin_only in _KINDS: + if admin_only and not admin: + continue + results = find(request, q) + if results: + groups.append({"kind": kind, "label": label, "results": results}) + return Response({"query": q, "groups": groups}) diff --git a/server/apps/hosts/test_search.py b/server/apps/hosts/test_search.py new file mode 100644 index 00000000..33f09b6e --- /dev/null +++ b/server/apps/hosts/test_search.py @@ -0,0 +1,119 @@ +"""Search everything (QA-25): each kind only as far as its own page shows it.""" +from datetime import timedelta +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.test import SimpleTestCase, TestCase +from django.utils.timezone import now +from rest_framework.test import APIClient + +from apps.accounts.models import Role, UserProfile +from apps.alerts.models import Alert +from apps.hosts.models import DockerContainer, Host +from apps.policies.models import UpdatePolicy +from apps.software.models import SoftwareItem +from apps.stacks.models import ManagedStack +from apps.tasks.models import TaskDefinition +from apps.vulns.models import VulnFinding + +ROOT = Path(__file__).resolve().parents[2] + + +def _client(user): + c = APIClient() + c.force_authenticate(user) + return c + + +class SearchApiTests(TestCase): + def setUp(self): + users = get_user_model().objects + self.admin = users.create_user("admin", password="x") + UserProfile.objects.create(user=self.admin, role=Role.ADMIN) + self.viewer = users.create_user("viewer", password="x") + UserProfile.objects.create(user=self.viewer, role=Role.VIEWER) + self.web = Host.objects.create(hostname="web-01", agent_token="t1", status=Host.Status.ONLINE, + os="Ubuntu 24.04", ip_address="10.0.0.21", tags=["canary"]) + self.nas = Host.objects.create(hostname="nas", agent_token="t2", status=Host.Status.ONLINE) + Host.objects.create(hostname="web-rejected", agent_token="t3", status="rejected") + + def _search(self, q, user=None): + resp = _client(user or self.admin).get("/api/v1/search/", {"q": q}) + self.assertEqual(resp.status_code, 200, resp.content) + return {g["kind"]: g["results"] for g in resp.json()["groups"]} + + def test_short_queries_return_nothing(self): + resp = _client(self.admin).get("/api/v1/search/", {"q": "w"}) + self.assertEqual(resp.json()["groups"], []) + + def test_hosts_by_name_tag_and_ip_never_rejected(self): + self.assertEqual([r["title"] for r in self._search("web")["host"]], ["web-01"]) + self.assertEqual([r["title"] for r in self._search("canary")["host"]], ["web-01"]) + self.assertEqual([r["title"] for r in self._search("10.0.0")["host"]], ["web-01"]) + + def test_a_container_carries_its_host(self): + DockerContainer.objects.create(host=self.nas, container_id="c1", name="vault", image="vaultwarden/server") + [hit] = self._search("vaultwarden")["container"] + self.assertEqual((hit["id"], hit["host_id"]), ("vault", str(self.nas.id))) + + def test_stacks_and_policies_are_for_admins_only(self): + ManagedStack.objects.create(host=self.nas, name="mediabox", compose_yaml="services: {}") + UpdatePolicy.objects.create(name="mediabox nightly") + found = self._search("mediabox") + self.assertIn("stack", found) + self.assertIn("policy", found) + found = self._search("mediabox", self.viewer) + self.assertNotIn("stack", found) + self.assertNotIn("policy", found) + + def test_apps_are_one_result_per_name(self): + for host in (self.web, self.nas): + SoftwareItem.objects.create(host=host, source="dpkg", package_id="firefox", name="Firefox", + name_key="firefox", first_seen=now()) + [hit] = self._search("firefox")["app"] + self.assertEqual(hit["subtitle"], "on 2 hosts") + + def test_cves_are_grouped_and_open_only(self): + for host in (self.web, self.nas): + VulnFinding.objects.create(host=host, scanner="trivy", plugin_id_or_oid="p", cve_id="CVE-2026-1111", + severity="high", state="open", title="openssl overflow") + VulnFinding.objects.create(host=self.web, scanner="trivy", plugin_id_or_oid="q", cve_id="CVE-2026-2222", + severity="critical", state="fixed") + [hit] = self._search("CVE-2026")["cve"] + self.assertEqual(hit["id"], "CVE-2026-1111") + self.assertTrue(hit["subtitle"].startswith("high · 2 hosts"), hit["subtitle"]) + + def test_tasks_mine_and_community_not_others_or_archived(self): + other = get_user_model().objects.create_user("other", password="x") + TaskDefinition.objects.create(owner=self.admin, name="restart nginx mine", yaml_source="x") + TaskDefinition.objects.create(owner=other, name="restart nginx shared", yaml_source="x", + visibility="community") + TaskDefinition.objects.create(owner=other, name="restart nginx private", yaml_source="x") + TaskDefinition.objects.create(owner=self.admin, name="restart nginx old", yaml_source="x", + archived_at=now() - timedelta(days=1)) + titles = sorted(r["title"] for r in self._search("restart nginx")["task"]) + self.assertEqual(titles, ["restart nginx mine", "restart nginx shared"]) + + def test_only_firing_alerts(self): + Alert.objects.create(host=self.web, severity="critical", message="disk nearly full", state="firing") + Alert.objects.create(host=self.web, severity="critical", message="disk was full", state="resolved") + self.assertEqual([r["title"] for r in self._search("disk")["alert"]], ["disk nearly full"]) + + def test_at_most_eight_per_kind(self): + for i in range(10): + Host.objects.create(hostname=f"fleet-{i}", agent_token=f"f{i}", status=Host.Status.ONLINE) + self.assertEqual(len(self._search("fleet")["host"]), 8) + + +class SearchWiringTests(SimpleTestCase): + def test_page_sidebar_and_script(self): + base = (ROOT / "templates/base.html").read_text(encoding="utf-8") + dash = (ROOT / "templates/dashboard.html").read_text(encoding="utf-8") + js = (ROOT / "static/js/vigil-search.js").read_text(encoding="utf-8") + self.assertIn('data-page="search"', base) + self.assertIn("js/vigil-search.js", base) + self.assertIn('{% include "pages/_search.html" %}', dash) + for needle in ("delegateClick('[data-search-kind]'", "/api/v1/search/", "escHtml(r.title)", + "seq !== searchState.seq"): + self.assertIn(needle, js) + self.assertNotIn("onclick=", js) diff --git a/server/static/css/vigil.css b/server/static/css/vigil.css index 4168ee04..09427d39 100644 --- a/server/static/css/vigil.css +++ b/server/static/css/vigil.css @@ -4117,3 +4117,18 @@ button.fcn:focus-visible { outline: 2px solid var(--sky); outline-offset: 3px; } } .sted-status:empty { visibility: hidden; } .managed-stack-chip .apps-zero { margin-left: 4px; } + +/* ── Search page (QA-25) ─────────────────────────────────────────────── */ +.srch-input { max-width: 720px; font-size: 15px; padding: 12px 14px; } +.srch-hint { font-size: 12px; color: var(--text-3); margin-top: 6px; } +.srch-results { display: flex; flex-direction: column; gap: 18px; margin-top: 18px; max-width: 720px; } +.srch-group { display: flex; flex-direction: column; gap: 6px; } +.srch-label { font-size: 11px; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase; + color: var(--text-3); } +.srch-count { font-weight: 600; margin-left: 4px; } +.srch-row { display: flex; flex-direction: column; gap: 2px; width: 100%; text-align: left; + border: 0; background: var(--s2); border-radius: var(--r-md); padding: 10px 14px; cursor: pointer; + font-family: inherit; } +.srch-row:hover, .srch-row:focus-visible { background: var(--s3); outline: none; } +.srch-title { font-size: 13px; color: var(--text-1); } +.srch-sub { font-size: 12px; color: var(--text-3); } diff --git a/server/static/js/vigil-search.js b/server/static/js/vigil-search.js new file mode 100644 index 00000000..7a3a8c72 --- /dev/null +++ b/server/static/js/vigil-search.js @@ -0,0 +1,128 @@ +// vigil-search.js +// Owns: the Search page (QA-25) — one box over hosts, containers, stacks, apps, +// vulnerabilities, tasks, playbooks, update policies and firing alerts, each +// only as far as the user may already see it; Ctrl+K (or /) jumps here. +// HTML: #page-search in templates/pages/_search.html. +// Depends on: vigil-utils.js (apiJson, escHtml, escAttr, delegateClick), +// vigil-nav.js (navigateTo — wrapped below), and the pages a result opens: +// vigil-monitor.js (selectMonitorHost), vigil-containers.js +// (selectContainersHost), vigil-stacks.js (renderManagedStacks, +// openStackEditor), vigil-tasks.js (openDefinitionEditor), vigil-playbooks.js +// (_startEdit), vigil-policies.js (openPolicyEditor). +// API: GET /api/v1/search/?q= + +const searchState = { seq: 0, timer: null }; + +function _srchEl(id) { return document.getElementById(id); } + +function _renderSearch(body) { + const out = _srchEl('srch-results'); + if (!body.groups.length) { + out.innerHTML = `
Nothing matched “${escHtml(body.query)}”.
`; + return; + } + out.innerHTML = body.groups.map(g => `
+
${escHtml(g.label)} ${g.results.length}
+ ${g.results.map(r => ``).join('')} +
`).join(''); +} + +async function runSearch() { + const q = _srchEl('srch-input').value.trim(); + const seq = ++searchState.seq; + if (q.length < 2) { _srchEl('srch-results').innerHTML = ''; return; } + try { + const body = await apiJson(`/api/v1/search/?q=${encodeURIComponent(q)}`); + if (seq !== searchState.seq) return; // a newer query is on its way + _renderSearch(body); + } catch (e) { + if (seq !== searchState.seq) return; + _srchEl('srch-results').innerHTML = `
${escHtml(e.message)}
`; + } +} + +/** Open a result where it lives. */ +async function openSearchResult(d) { + const id = d.searchId; + switch (d.searchKind) { + case 'host': + navigateTo('monitor'); + selectMonitorHost(id); + break; + case 'container': + navigateTo('containers'); + selectContainersHost(d.searchHost); + break; + case 'stack': + navigateTo('containers'); + selectContainersHost(d.searchHost); + await renderManagedStacks(d.searchHost); + openStackEditor(id); + break; + case 'app': navigateTo('apps'); break; + case 'cve': navigateTo('vulns'); break; + case 'task': openDefinitionEditor(id); break; + case 'playbook': + navigateTo('playbooks'); + _startEdit(id); + break; + case 'policy': + navigateTo('policies'); + openPolicyEditor(id); + break; + case 'alert': navigateTo('alerts'); break; + default: break; + } +} + +function focusSearch() { + navigateTo('search'); + const input = _srchEl('srch-input'); + if (input) { input.focus(); input.select(); } +} + +delegateClick('[data-search-kind]', (el) => openSearchResult(el.dataset)); + +document.addEventListener('input', (ev) => { + if (ev.target.id !== 'srch-input') return; + clearTimeout(searchState.timer); + searchState.timer = setTimeout(runSearch, 250); +}); + +document.addEventListener('keydown', (ev) => { + const t = ev.target; + const typing = t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)); + if (((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 'k' && !ev.altKey && !ev.shiftKey) + || (ev.key === '/' && !typing && !ev.ctrlKey && !ev.metaKey && !ev.altKey)) { + ev.preventDefault(); + focusSearch(); + return; + } + // Arrow keys walk from the box through the result rows and back. + if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return; + const rows = [...document.querySelectorAll('#srch-results .srch-row')]; + if (t && t.id === 'srch-input') { + if (ev.key === 'ArrowDown' && rows.length) { ev.preventDefault(); rows[0].focus(); } + return; + } + const i = rows.indexOf(t); + if (i < 0) return; + ev.preventDefault(); + if (ev.key === 'ArrowDown') (rows[i + 1] || rows[i]).focus(); + else if (i === 0) _srchEl('srch-input').focus(); + else rows[i - 1].focus(); +}); + +/* Opening the page puts the cursor in the box. */ +(function () { + const previous = window.navigateTo; + if (typeof previous !== 'function') return; + window.navigateTo = function (pageName) { + previous.apply(this, arguments); + if (pageName === 'search') setTimeout(() => _srchEl('srch-input')?.focus(), 0); + }; +})(); diff --git a/server/templates/base.html b/server/templates/base.html index 42f23b4e..c38677e7 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -58,6 +58,9 @@ + @@ -801,6 +804,8 @@ + + diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html index 31be23f6..538b8ca8 100644 --- a/server/templates/dashboard.html +++ b/server/templates/dashboard.html @@ -34,6 +34,10 @@ {# Logic: static/js/vigil-stacks.js | Data: client-side fetch /api/v1/stacks/ (+ //revisions/, POST /api/v1/stacks/validate/) #} {% include "pages/_stack_editor.html" %} + {# Search page — one box over hosts, containers, stacks, apps, CVEs, tasks, playbooks, policies, alerts #} + {# Logic: static/js/vigil-search.js | Data: client-side fetch /api/v1/search/?q= #} + {% include "pages/_search.html" %} + {# Playbooks page — sequence editor for auto-enroll task chains #} {# Logic: static/js/vigil-playbooks.js | Data: /api/v1/playbooks/ + /api/v1/tasks/definitions/ #} {% include "pages/_playbooks.html" %} diff --git a/server/templates/pages/_search.html b/server/templates/pages/_search.html new file mode 100644 index 00000000..831931cb --- /dev/null +++ b/server/templates/pages/_search.html @@ -0,0 +1,16 @@ +{# Search page section — one box over everything the user may see (QA-25, ported from Jackil's Search Everything) #} +{# Logic: static/js/vigil-search.js | Styles: vigil.css .srch-* | Data: client-side fetch /api/v1/search/?q= #} + diff --git a/server/vigil/urls.py b/server/vigil/urls.py index ed4acde1..38dc9234 100644 --- a/server/vigil/urls.py +++ b/server/vigil/urls.py @@ -14,6 +14,7 @@ from apps.alerts.models import Alert from apps.hosts.models import Host from apps.hosts import views as hosts_views +from apps.hosts import search as hosts_search from apps.hosts.enrollment import replacement_candidate from apps.hosts.logtail import agent_log_lines from apps.stacks.registries import agent_registry_auth @@ -132,6 +133,7 @@ def dashboard(request): path("api/v1/software/", include("apps.software.urls")), path("api/v1/policies/", include("apps.policies.urls")), path("api/v1/stacks/", include("apps.stacks.urls")), + path("api/v1/search/", hosts_search.search, name="search"), path("api/v1/dashboards/", include("apps.dashboards.urls")), # Playbooks were called baselines until 2026.11.0. Anything an operator # already scripted against the old path keeps working; nothing in Vigil From 7a480dc4b99acfd8312e1dc547b2ef7d63a12b37 Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Sat, 10 Oct 2026 20:39:02 -0400 Subject: [PATCH 8/9] fix(search): an admin of one site does not find another site's update policies A policy belongs to one site, but the search's admin check passes for a user who is admin in any site, so a West admin found Lab's policies by name. Narrow them to the sites the user can see, and test that a one-site admin finds nothing of another site's hosts, containers, stacks, alerts or policies. --- server/apps/hosts/search.py | 6 ++++++ server/apps/hosts/test_search.py | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/server/apps/hosts/search.py b/server/apps/hosts/search.py index 19370cce..de6bec60 100644 --- a/server/apps/hosts/search.py +++ b/server/apps/hosts/search.py @@ -104,8 +104,14 @@ def _playbooks(request, q): def _policies(request, q): + from apps.accounts.permissions import visible_site_ids from apps.policies.models import UpdatePolicy qs = UpdatePolicy.objects.filter(name__icontains=q) + # A policy belongs to one site (or none = every site). An admin of one + # site still counts as an admin, so narrow to the sites they can see. + sites = visible_site_ids(request.user) + if sites is not None: + qs = qs.filter(Q(site_id__isnull=True) | Q(site_id__in=sites)) return [{"kind": "policy", "id": str(p.id), "title": p.name, "subtitle": "enabled" if p.enabled else "disabled"} for p in qs.order_by("name")[:LIMIT]] diff --git a/server/apps/hosts/test_search.py b/server/apps/hosts/test_search.py index 33f09b6e..31dcd0f6 100644 --- a/server/apps/hosts/test_search.py +++ b/server/apps/hosts/test_search.py @@ -105,6 +105,32 @@ def test_at_most_eight_per_kind(self): self.assertEqual(len(self._search("fleet")["host"]), 8) +class SearchCrossSiteTests(TestCase): + """An admin of one site finds nothing that lives in another.""" + + def setUp(self): + from apps_business.sites.models import HostSiteAssignment, Site, UserSiteRole + west = Site.objects.create(name="West", slug="west") + lab = Site.objects.create(name="Lab", slug="lab") + self.dana = get_user_model().objects.create_user("dana", password="x") + UserSiteRole.objects.create(user=self.dana, site=west, role=Role.ADMIN) + for site in (west, lab): + host = Host.objects.create(hostname=f"probe-{site.slug}", agent_token=f"t-{site.slug}", + status=Host.Status.ONLINE) + HostSiteAssignment.objects.create(host=host, site=site) + ManagedStack.objects.create(host=host, name=f"probe-{site.slug}", compose_yaml="services: {}") + DockerContainer.objects.create(host=host, container_id=site.slug, name=f"probe-{site.slug}") + Alert.objects.create(host=host, severity="critical", message=f"probe {site.slug}", state="firing") + UpdatePolicy.objects.create(name=f"probe {site.slug}", site_id=site.id) + + def test_nothing_from_the_other_site(self): + resp = _client(self.dana).get("/api/v1/search/", {"q": "probe"}) + found = {g["kind"]: [r["title"] for r in g["results"]] for g in resp.json()["groups"]} + for kind in ("host", "container", "stack", "alert", "policy"): + self.assertTrue(found.get(kind), f"{kind}: West's own row is found") + self.assertFalse([t for t in found[kind] if "lab" in t], f"{kind} leaked Lab: {found[kind]}") + + class SearchWiringTests(SimpleTestCase): def test_page_sidebar_and_script(self): base = (ROOT / "templates/base.html").read_text(encoding="utf-8") From 12fbefdd22daa4257a9a5d8593b3600e4b9d05c1 Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Sat, 10 Oct 2026 22:01:25 -0400 Subject: [PATCH 9/9] Release 2026.14.2 --- agent/vigil_agent/__version__.py | 2 +- server/vigil/settings.py | 2 +- wiki/vigil-wiki.html | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/agent/vigil_agent/__version__.py b/agent/vigil_agent/__version__.py index ab1ee269..66a1926e 100644 --- a/agent/vigil_agent/__version__.py +++ b/agent/vigil_agent/__version__.py @@ -1 +1 @@ -__version__ = "2026.14.1" +__version__ = "2026.14.2" diff --git a/server/vigil/settings.py b/server/vigil/settings.py index 8a7f17e3..00e80e88 100644 --- a/server/vigil/settings.py +++ b/server/vigil/settings.py @@ -532,7 +532,7 @@ # Server build version — surfaced on the About page and the /api/v1/about/ # endpoint. Bump this on every release; the Git tag (v2026.2.3, etc.) and # this constant should stay in lockstep. -VIGIL_VERSION = "2026.14.1" +VIGIL_VERSION = "2026.14.2" # Opt-in daily refresh of the CISA KEV catalogue. Off by default: a self-hosted # install makes no outbound call unless its operator asks for one, and the diff --git a/wiki/vigil-wiki.html b/wiki/vigil-wiki.html index ba328484..a155cfb4 100644 --- a/wiki/vigil-wiki.html +++ b/wiki/vigil-wiki.html @@ -267,7 +267,7 @@ Vigil - +
@@ -4558,7 +4558,7 @@

CI / GitHub Actions

made by Susquehanna Syntax With ♥ from NEPA

"Sleep well. Vigil's watching."

-
Vigil v2026.14.1 · Released under AGPLv3 · View on GitHub
+
Vigil v2026.14.2 · Released under AGPLv3 · View on GitHub