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/__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/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()) 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/apps/hosts/search.py b/server/apps/hosts/search.py new file mode 100644 index 00000000..de6bec60 --- /dev/null +++ b/server/apps/hosts/search.py @@ -0,0 +1,159 @@ +"""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.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]] + + +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..31dcd0f6 --- /dev/null +++ b/server/apps/hosts/test_search.py @@ -0,0 +1,145 @@ +"""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 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") + 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/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/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/stacks/tests.py b/server/apps/stacks/tests.py index 9bfaccab..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: @@ -238,6 +238,58 @@ 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 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): 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/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 4f8deb9d..09427d39 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; } @@ -1268,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 { @@ -2409,7 +2403,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 +2505,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; } @@ -3198,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; } @@ -3821,7 +3829,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); } @@ -3863,7 +3870,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 +3983,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 +4004,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 +4024,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; } @@ -4093,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-containers.js b/server/static/js/vigil-containers.js index 00cec937..b8a4c9c4 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,17 @@ 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') { + 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 = ''; wrap.innerHTML = '
No containers reported for this host.
'; @@ -160,6 +166,7 @@ async function renderDockerContainers(hostId) { html += `
${escHtml(label)} + ${stack ? editButton(stack) : ''} ${rows.length} ${noun} ${stack ? _stackStateBadge(rows) : ''} ${stackMeta}${stackActs} @@ -206,11 +213,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-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-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-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-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/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-stacks.js b/server/static/js/vigil-stacks.js index d19aebca..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: [], 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; @@ -27,19 +27,43 @@ 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.running = 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, withContainers) { + stackEd.listed = new Set(names); + stackEd.running = new Set(withContainers || []); + 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 @@ -50,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; @@ -96,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(); } @@ -136,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. @@ -522,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) @@ -530,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. @@ -562,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() { @@ -649,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/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/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..c38677e7 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -58,6 +58,9 @@ + @@ -220,8 +223,7 @@
Tags
- - +
Server-side tags. Agent.yml tags are merged in (server values always win).
@@ -372,15 +374,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 +512,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.
@@ -841,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/_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/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/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.
diff --git a/server/vigil/settings.py b/server/vigil/settings.py index c0ac5950..00e80e88 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 @@ -522,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/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") 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) 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 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