From 4a9b862c3b5ca12d2dca9e681dd85d09dff0ebf1 Mon Sep 17 00:00:00 2001 From: liamadale Date: Fri, 24 Apr 2026 17:50:39 -0700 Subject: [PATCH 01/49] feat(opensearch-web): add destination IP filter to query pipeline and search bar Extend the query backend and web form to support filtering by destination IP alongside the existing src_ip filter. - build_base_query gains a dest_ip_filter param that appends a destination.ip term clause when set - run_query guards dest_ip_filter the same way src_ip is guarded: only applied to modules that declare destination.ip in SOURCE_FIELDS - build_search_params_from_request reads the dest_ip form value - base.html search bar exposes a Dst IP text input --- apps/opensearch_web/queries.py | 1 + apps/opensearch_web/templates/base.html | 7 +++++++ src/querier/zeek_modules/base.py | 10 +++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/opensearch_web/queries.py b/apps/opensearch_web/queries.py index 42ae62d..cf30403 100644 --- a/apps/opensearch_web/queries.py +++ b/apps/opensearch_web/queries.py @@ -18,6 +18,7 @@ def build_search_params_from_request(request, extra_keys=None) -> dict: "limit": int(v) if (v := request.values.get("limit", "").strip()) and v.isdigit() else 500, "public_only": request.values.get("public_only") in ("on", "true", "1"), "src_ip": request.values.get("src_ip") or None, + "dest_ip": request.values.get("dest_ip") or None, "direction": request.values.get("direction") or None, "no_filters": False, "use_cache": False, diff --git a/apps/opensearch_web/templates/base.html b/apps/opensearch_web/templates/base.html index e6a2c83..460321e 100644 --- a/apps/opensearch_web/templates/base.html +++ b/apps/opensearch_web/templates/base.html @@ -111,6 +111,13 @@
+ + + +
+ + - {% for cat in categories %}{% endfor %} @@ -56,7 +56,7 @@ {% for cat, cat_data in categories.items() %} {% for sub in cat_data.get('subcategories', []) %} - {% endfor %} {% endfor %} diff --git a/apps/opensearch_web/templates/partials/record_detail.html b/apps/opensearch_web/templates/partials/record_detail.html index 7b01d50..09a0773 100644 --- a/apps/opensearch_web/templates/partials/record_detail.html +++ b/apps/opensearch_web/templates/partials/record_detail.html @@ -37,7 +37,7 @@
- {% set src_ip = record.get('src_ip') %} + {% set src_ip = record.get('src_ip') %} {% set dest_ip = record.get('dest_ip') %} {% if src_ip or dest_ip or record.get('notice_note') %}
@@ -137,5 +137,4 @@
{% endif %} - diff --git a/pyproject.toml b/pyproject.toml index 435e2ef..7767d18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,10 +80,11 @@ skips = ["B101"] profile = "jinja" indent = 2 max_line_length = 100 -ignore = "H021,H023,H030,H031" +ignore = "H021,H023,H030,H031,J018" # H021: inline styles are intentional in dashboard/chart templates # H023: HTML entity references (· etc.) are standard and intentional # H030/H031: meta description/keywords are false positives on Jinja child templates +# J018: cross-app internal links cannot use url_for() in a multi-app Flask setup [tool.pytest.ini_options] testpaths = ["tests"] From 0fca72ddb50967d27d02f49cc8850d0366364496 Mon Sep 17 00:00:00 2001 From: liamadale Date: Wed, 29 Apr 2026 09:58:06 -0700 Subject: [PATCH 06/49] feat(mantis): rewrite escalation detection and surface is_escalated on tickets Replace the single loose _ESCALATION_RE pattern with a two-stage function _note_is_escalation() that eliminates false positives: - Stage 1: matches past-tense client-contact phrases (informed/notified the client, let the client know, reached out to the client, etc.) and skips any match whose 20-char prefix contains "will" (future intent). - Stage 2: matches past-tense "escalated [this/it] to [the] client" and skips matches prefixed with "not" or "won't" (negated intent). Previously, bare "escalat*" triggered on "privilege escalation", conditional futures ("will let the client know"), and negations ("not going to escalate to the client"). The two-stage approach targets only confirmed past-action phrases. _normalize_issue() now exposes is_escalated and escalated_by on every normalised ticket dict. activity_report._ticket_ref() propagates is_escalated into StudentStats.created_tickets. student_activity adds an "Escalated" column to the summary table, shows a per-student count in the detail view, supports --org / --since / --until CLI flags, and refactors the graph helper into a reusable _plot_ticket_timeline() shared by per-student and per-org views. --- src/mantis/activity_report.py | 795 +++++++++++++++++++++++++++++++++ src/mantis/mantis_search.py | 60 +++ src/mantis/student_activity.py | 312 +++++++++++-- 3 files changed, 1138 insertions(+), 29 deletions(-) create mode 100644 src/mantis/activity_report.py diff --git a/src/mantis/activity_report.py b/src/mantis/activity_report.py new file mode 100644 index 0000000..5de7891 --- /dev/null +++ b/src/mantis/activity_report.py @@ -0,0 +1,795 @@ +#!/usr/bin/env python3 +""" +Student activity report — counts tickets created and notes written per student. + +Students are defined as any reporter who has never acted as a ticket handler +anywhere in the corpus (mirrors the handler_registry logic in mantis_index.py). + +Reads from the local offline index by default; pass --live to fetch from the +REST API instead (requires MANTIS_API_TOKEN + MANTIS_API_URL). + +When --student matches exactly one name, a detailed view is shown with ticket +titles and links. When it matches multiple, the user is prompted to pick one. + +Usage: + python src/mantis/activity_report.py + python src/mantis/activity_report.py --live + python src/mantis/activity_report.py --sort tickets + python src/mantis/activity_report.py --project bonney-lake + python src/mantis/activity_report.py --student alice + python src/mantis/activity_report.py --student alice --graph + python src/mantis/activity_report.py --org 'bellevue college' + python src/mantis/activity_report.py --org 'bellevue college' --student alice + python src/mantis/activity_report.py --since 2025-01-01 --until 2025-04-30 + python src/mantis/activity_report.py --input data/tickets/indexed/tickets_index.json +""" + +import argparse +import json +import os +import sys +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import date, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import urllib3 +from dotenv import load_dotenv +from rich import box +from rich.console import Console +from rich.rule import Rule +from rich.table import Table + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +_BASE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DEFAULT_INDEX = os.path.join(_BASE, "data", "tickets", "indexed", "tickets_index.json") + +console = Console() + + +def _ordinal(n: int) -> str: + """Return an integer with its ordinal suffix: 1st, 2nd, 3rd, 4th…""" + if 11 <= (n % 100) <= 13: + suffix = "th" + else: + suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") + return f"{n}{suffix}" + + +def _format_date_range(date_strs: list[str]) -> str: + """Format a collection of YYYY-MM-DD strings as a human-readable span. + + Returns strings like "Jan 1st – Apr 28th" for same-year ranges, or + "Jan 1st, 2024 – Apr 28th, 2025" when the range crosses calendar years. + Returns an empty string when no valid dates are present. + """ + valid = [d for d in date_strs if d and len(d) >= 10] + if not valid: + return "" + parsed = sorted(date.fromisoformat(d[:10]) for d in valid) + first, last = parsed[0], parsed[-1] + cross_year = first.year != last.year + + def _fmt(d: date) -> str: + base = f"{d.strftime('%b')} {_ordinal(d.day)}" + return f"{base}, {d.year}" if cross_year else base + + if first == last: + return _fmt(first) + return f"{_fmt(first)} – {_fmt(last)}" + + +def _filter_by_date_range( + tickets: list[dict], + since: date | None, + until: date | None, +) -> list[dict]: + """Return only tickets whose created_at falls within [since, until]. + + Tickets with no or unparseable created_at are kept so data is not silently lost. + """ + if not since and not until: + return tickets + result = [] + for t in tickets: + raw = (t.get("created_at") or "")[:10] + if not raw: + result.append(t) + continue + try: + d = date.fromisoformat(raw) + except ValueError: + result.append(t) + continue + if since and d < since: + continue + if until and d > until: + continue + result.append(t) + return result + + +# Minimal ticket ref stored per student — id, summary, url, status, created_at +_TicketRef = dict + + +@dataclass +class StudentStats: + """Activity data for a single student reporter.""" + + name: str + created_tickets: list[_TicketRef] = field(default_factory=list) + # Tickets they left notes on that they did NOT create (keyed by id to deduplicate) + _noted: dict[str, _TicketRef] = field(default_factory=dict, repr=False) + notes_written: int = 0 + projects: set[str] = field(default_factory=set) + + @property + def tickets_created(self) -> int: + return len(self.created_tickets) + + @property + def noted_tickets(self) -> list[_TicketRef]: + """Unique tickets this student commented on but did not create.""" + return list(self._noted.values()) + + escalated_tickets: int = 0 + categories: set[str] = field(default_factory=set) + + @property + def total_activity(self) -> int: + return self.tickets_created + self.notes_written + + def add_noted(self, ticket: _TicketRef) -> None: + """Record a ticket this student left a note on (deduplicates by id).""" + tid = ticket["id"] + if tid not in self._noted: + self._noted[tid] = ticket + + +def _ticket_ref(ticket: dict) -> _TicketRef: + """Extract the minimal displayable fields from a normalized ticket dict.""" + return { + "id": ticket.get("id", ""), + "summary": ticket.get("summary", ""), + "url": ticket.get("url", ""), + "status": ticket.get("status", ""), + "created_at": ticket.get("created_at", ""), + "is_escalated": bool(ticket.get("is_escalated")), + } + + +def _load_offline(path: str) -> list[dict]: + """Load normalized tickets from the offline index JSON.""" + if not os.path.exists(path): + console.print(f"[red]Offline index not found: {path}[/red]") + console.print("[dim]Run: python src/mantis/mantis_index.py[/dim]") + sys.exit(1) + with open(path) as fh: + return json.load(fh) + + +def _load_live(project_filter: str | None) -> list[dict]: + """Fetch all tickets from the MantisBT REST API.""" + import requests + + from src.mantis.mantis_search import _normalize_issue + + api_url = os.environ.get("MANTIS_API_URL", "").rstrip("/") + api_token = os.environ.get("MANTIS_API_TOKEN", "") + if not api_url or not api_token: + console.print("[red]MANTIS_API_URL and MANTIS_API_TOKEN are required for --live[/red]") + sys.exit(1) + + headers = {"Authorization": api_token} + all_raw: list[dict] = [] + page = 1 + + console.print("[dim]Fetching tickets from Mantis REST API...[/dim]") + while True: + resp = requests.get( + f"{api_url}/api/rest/issues", + headers=headers, + params={"page_size": 200, "page": page}, + timeout=30, + verify=False, + ) + if not resp.ok: + console.print(f"[red]API error {resp.status_code} on page {page}[/red]") + break + data = resp.json() + issues = data.get("issues", []) + if not issues: + break + all_raw.extend(issues) + total = data.get("total_count") + if total and page * 200 >= total: + break + if len(issues) < 200: + break + page += 1 + + console.print(f"[dim]Fetched {len(all_raw)} tickets — normalizing...[/dim]") + handler_registry: set[int] = { + issue["handler"]["id"] for issue in all_raw if issue.get("handler") + } + tickets = [_normalize_issue(issue, api_url, handler_registry) for issue in all_raw] + + if project_filter: + tickets = [t for t in tickets if project_filter.lower() in t.get("project", "").lower()] + + return tickets + + +def build_report( + tickets: list[dict], + project_filter: str | None = None, +) -> dict[int, StudentStats]: + """Aggregate per-student activity from a normalized ticket list. + + Args: + tickets: Normalized ticket dicts from the offline index or live API. + project_filter: Optional substring filter on the project/city field. + + Returns: + Mapping of reporter_id → StudentStats, excluding known handlers. + """ + if project_filter: + tickets = [t for t in tickets if project_filter.lower() in t.get("project", "").lower()] + + # Rebuild handler registry from this corpus + handler_ids: set[int] = set() + for t in tickets: + h = t.get("handler") + if h and h.get("id"): + handler_ids.add(h["id"]) + for note in t.get("notes", []): + if note.get("is_admin_note"): + handler_ids.add(note["reporter"]["id"]) + + stats: dict[int, StudentStats] = defaultdict(lambda: StudentStats(name="")) + + for ticket in tickets: + reporter = ticket.get("reporter", {}) + reporter_id = reporter.get("id", 0) + reporter_name = reporter.get("name", "unknown") + project = ticket.get("project", "") + ref = _ticket_ref(ticket) + + if reporter_id not in handler_ids: + stats[reporter_id].name = reporter_name + stats[reporter_id].created_tickets.append(ref) + if project: + stats[reporter_id].projects.add(project) + if ticket.get("is_escalated"): + stats[reporter_id].escalated_tickets += 1 + category = ticket.get("category", "") + if category: + stats[reporter_id].categories.add(category) + + for note in ticket.get("notes", []): + note_reporter = note.get("reporter", {}) + note_id = note_reporter.get("id", 0) + note_name = note_reporter.get("name", "unknown") + + if note_id in handler_ids: + continue + + stats[note_id].name = note_name + stats[note_id].notes_written += 1 + if project: + stats[note_id].projects.add(project) + # Only track as "noted" if they didn't create it + if note_id != reporter_id: + stats[note_id].add_noted(ref) + + return dict(stats) + + +def _pick_student(matches: list[tuple[int, StudentStats]]) -> tuple[int, StudentStats] | None: + """Prompt the user to select one student from a list of matches.""" + console.print(f"\n[yellow]Found {len(matches)} students matching that name:[/yellow]\n") + for i, (_, s) in enumerate(matches, 1): + console.print( + f" [cyan]{i}[/cyan]. {s.name} " + f"— {s.tickets_created} ticket(s), {s.notes_written} note(s)" + ) + console.print() + + while True: + try: + raw = input("Select a student (number), or press Enter to show all: ").strip() + except (EOFError, KeyboardInterrupt): + console.print() + return None + + if raw == "": + return None + + if raw.isdigit() and 1 <= int(raw) <= len(matches): + return matches[int(raw) - 1] + + console.print(f"[red]Enter a number between 1 and {len(matches)}.[/red]") + + +def _pick_org(matches: list[str]) -> str | None: + """Prompt the user to select one institution from a list of matches.""" + console.print(f"\n[yellow]Found {len(matches)} institutions matching that name:[/yellow]\n") + for i, name in enumerate(matches, 1): + console.print(f" [cyan]{i}[/cyan]. {name}") + console.print() + + while True: + try: + raw = input("Select an institution (number), or press Enter to show all: ").strip() + except (EOFError, KeyboardInterrupt): + console.print() + return None + + if raw == "": + return None + + if raw.isdigit() and 1 <= int(raw) <= len(matches): + return matches[int(raw) - 1] + + console.print(f"[red]Enter a number between 1 and {len(matches)}.[/red]") + + +def display_org_report( + stats: dict[int, StudentStats], + org_filter: str, + sort_by: str = "activity", + student_filter: str | None = None, + show_graph: bool = False, +) -> None: + """Render the activity report for a single institution. + + Finds all unique ticket categories matching org_filter (substring, + case-insensitive). If multiple categories match, prompts the user to pick + one or show all. Then renders the same summary table as display_report(), + scoped to students whose tickets belong to the matched category/categories. + + If student_filter is also provided, further narrows to a specific student + within the institution and shows the detail view. + + Args: + stats: Full per-student stats from build_report(). + org_filter: Substring to match against ticket category names. + sort_by: Column to sort the summary table by. + student_filter: Optional student name substring for further drill-down. + show_graph: Whether to show the submission graph in detail view. + """ + # Collect all unique categories across the corpus + all_categories: list[str] = sorted( + {cat for s in stats.values() for cat in s.categories}, + key=str.lower, + ) + matching_cats = [c for c in all_categories if org_filter.lower() in c.lower()] + + if not matching_cats: + console.print(f"[yellow]No institution matching '{org_filter}' found.[/yellow]") + console.print(f"[dim]Known institutions: {', '.join(all_categories) or 'none'}[/dim]") + return + + # Disambiguate if multiple categories match + if len(matching_cats) == 1: + chosen_cats = matching_cats + else: + chosen = _pick_org(matching_cats) + chosen_cats = [chosen] if chosen is not None else matching_cats + + org_label = chosen_cats[0] if len(chosen_cats) == 1 else org_filter + + # Filter students who have tickets in any of the chosen categories + org_stats = {k: v for k, v in stats.items() if v.categories & set(chosen_cats)} + + if not org_stats: + console.print(f"[yellow]No students found for institution '{org_label}'.[/yellow]") + return + + all_dates = [ref["created_at"] for s in org_stats.values() for ref in s.created_tickets] + date_range = _format_date_range(all_dates) + date_part = f" · {date_range}" if date_range else "" + console.print( + Rule(f"[bold]{org_label}[/bold] [dim]({len(org_stats)} students{date_part})[/dim]") + ) + + if student_filter: + # Delegate to the normal student-filter path, scoped to this org + display_report( + org_stats, + sort_by=sort_by, + student_filter=student_filter, + show_graph=show_graph, + ) + else: + display_report(org_stats, sort_by=sort_by) + if show_graph: + console.print() + draw_org_graph(org_stats, org_label) + + +def display_student_detail(student: StudentStats, show_graph: bool = False) -> None: + """Render a detailed activity breakdown for a single student.""" + console.print(Rule(f"[cyan]{student.name}[/cyan]")) + escalated_str = ( + f" Escalated: [red]{student.escalated_tickets}[/red]" if student.escalated_tickets else "" + ) + console.print( + f" Tickets created: [green]{student.tickets_created}[/green]" + f"{escalated_str} " + f"Notes written: [yellow]{student.notes_written}[/yellow] " + f"Total activity: [bold]{student.total_activity}[/bold]" + ) + if student.projects: + console.print(f" Projects: [dim]{', '.join(sorted(student.projects))}[/dim]") + console.print() + + if student.created_tickets: + created = Table(title="Tickets Created", box=box.SIMPLE, show_header=True) + created.add_column("#", style="dim", no_wrap=True) + created.add_column("Summary", ratio=3) + created.add_column("Status", no_wrap=True) + created.add_column("Created", no_wrap=True) + created.add_column("Link", style="blue") + + for ref in sorted( + student.created_tickets, + key=lambda r: int(r["id"]) if r["id"].isdigit() else 0, + reverse=True, + ): + created.add_row( + ref["id"], + ref["summary"] or "—", + ref["status"] or "—", + ref["created_at"] or "—", + ref["url"], + ) + console.print(created) + + noted = student.noted_tickets + if noted: + noted_table = Table( + title="Tickets Commented On (not created by this student)", box=box.SIMPLE + ) + noted_table.add_column("#", style="dim", no_wrap=True) + noted_table.add_column("Summary", ratio=3) + noted_table.add_column("Status", no_wrap=True) + noted_table.add_column("Link", style="blue") + + for ref in sorted( + noted, key=lambda r: int(r["id"]) if r["id"].isdigit() else 0, reverse=True + ): + noted_table.add_row( + ref["id"], + ref["summary"] or "—", + ref["status"] or "—", + ref["url"], + ) + console.print(noted_table) + + if show_graph: + console.print() + draw_submission_graph(student) + + +def _plot_ticket_timeline(date_strs: list[str], title: str) -> None: + """Render a terminal line graph of ticket submissions over time. + + Accepts a flat list of YYYY-MM-DD strings (duplicates allowed — each + represents one ticket). Granularity is chosen automatically: + - ≤ 5 weeks → daily + - ≤ 12 months → weekly (Mon-anchored) + - > 12 months → monthly + + The graph width is capped to the terminal width minus a small margin. + """ + import plotext as plt + + parsed = sorted(date.fromisoformat(d) for d in date_strs) + span_days = (parsed[-1] - parsed[0]).days + + if span_days <= 35: + granularity = "day" + label_fmt = "%b %d" + bucket_fn = lambda d: d # noqa: E731 + elif span_days <= 365: + granularity = "week" + label_fmt = "%b %d" + bucket_fn = lambda d: d - timedelta(days=d.weekday()) # noqa: E731 + else: + granularity = "month" + label_fmt = "%b '%y" + bucket_fn = lambda d: d.replace(day=1) # noqa: E731 + + buckets: dict[date, int] = defaultdict(int) + for d in parsed: + buckets[bucket_fn(d)] += 1 + + # Fill gaps so the x-axis is contiguous + all_buckets = sorted(buckets) + first, last = all_buckets[0], all_buckets[-1] + if granularity == "day": + cursor, step = first, timedelta(days=1) + full_range: list[date] = [] + while cursor <= last: + full_range.append(cursor) + cursor += step + elif granularity == "week": + cursor, step = first, timedelta(weeks=1) + full_range = [] + while cursor <= last: + full_range.append(cursor) + cursor += step + else: + full_range = [] + y, m = first.year, first.month + while date(y, m, 1) <= last: + full_range.append(date(y, m, 1)) + m += 1 + if m > 12: + m, y = 1, y + 1 + + counts = [buckets.get(b, 0) for b in full_range] + labels = [b.strftime(label_fmt) for b in full_range] + # Use numeric x indices — passing formatted strings causes plotext to attempt + # date parsing which fails for short formats like "Jan 12". + x_indices = list(range(len(counts))) + + try: + term_width = os.get_terminal_size().columns + except OSError: + term_width = 100 + plot_width = max(60, min(term_width - 4, 160)) + + tick_step = max(1, len(labels) // (plot_width // 10)) + tick_positions = x_indices[::tick_step] + tick_labels = labels[::tick_step] + + plt.clf() + plt.plot_size(plot_width, 14) + plt.theme("dark") + plt.title(f"{title} ({granularity}ly)") + plt.xlabel(granularity.capitalize()) + plt.ylabel("Tickets") + plt.plot(x_indices, counts, marker="braille") + plt.xticks(tick_positions, tick_labels) + plt.show() + console.print( + f" [dim]{len(parsed)} ticket(s) · " + f"{full_range[0].strftime('%Y-%m-%d')} → {full_range[-1].strftime('%Y-%m-%d')} " + f"· {granularity}ly buckets[/dim]" + ) + + +def draw_submission_graph(student: StudentStats) -> None: + """Render a submission timeline graph for a single student.""" + dated = [ + ref["created_at"] + for ref in student.created_tickets + if ref.get("created_at") and len(ref["created_at"]) == 10 + ] + if not dated: + console.print("[yellow]No dated tickets — cannot draw graph.[/yellow]") + return + _plot_ticket_timeline(dated, f"Ticket Submissions — {student.name}") + + +def draw_org_graph(org_stats: dict[int, StudentStats], org_label: str) -> None: + """Render an aggregate submission timeline graph for all students in an org.""" + dated = [ + ref["created_at"] + for s in org_stats.values() + for ref in s.created_tickets + if ref.get("created_at") and len(ref["created_at"]) == 10 + ] + if not dated: + console.print("[yellow]No dated tickets — cannot draw graph.[/yellow]") + return + _plot_ticket_timeline(dated, f"Ticket Submissions — {org_label}") + + +def display_report( + stats: dict[int, StudentStats], + sort_by: str = "activity", + student_filter: str | None = None, + org_filter: str | None = None, + show_graph: bool = False, +) -> None: + """Render the student activity report. + + If org_filter is set, delegates to display_org_report() which scopes the + table to students from that institution (matched by ticket category). + If student_filter matches exactly one student, shows the detail view. + If it matches multiple, prompts the user to pick one (or show all). + With no filter, renders the full summary table. + """ + if org_filter: + display_org_report( + stats, + org_filter=org_filter, + sort_by=sort_by, + student_filter=student_filter, + show_graph=show_graph, + ) + return + + if student_filter: + matches = [(k, v) for k, v in stats.items() if student_filter.lower() in v.name.lower()] + + if not matches: + console.print(f"[yellow]No student matching '{student_filter}' found.[/yellow]") + return + + if len(matches) == 1: + display_student_detail(matches[0][1], show_graph=show_graph) + return + + # Multiple matches — prompt + chosen = _pick_student(matches) + if chosen is not None: + display_student_detail(chosen[1], show_graph=show_graph) + return + + # User pressed Enter — fall through and show all matches in summary table + stats = dict(matches) + + rows = list(stats.values()) + + if sort_by == "tickets": + rows.sort(key=lambda s: s.tickets_created, reverse=True) + elif sort_by == "notes": + rows.sort(key=lambda s: s.notes_written, reverse=True) + elif sort_by == "name": + rows.sort(key=lambda s: s.name.lower()) + else: + rows.sort(key=lambda s: s.total_activity, reverse=True) + + all_dates = [ref["created_at"] for s in rows for ref in s.created_tickets] + date_range = _format_date_range(all_dates) + title = f"Student Activity Report ({len(rows)} students)" + if date_range: + title += f" · {date_range}" + + table = Table( + title=title, + box=box.SIMPLE, + show_footer=True, + ) + table.add_column("Student", style="cyan", footer="TOTAL") + table.add_column( + "Tickets Created", + justify="right", + style="green", + footer=str(sum(s.tickets_created for s in rows)), + ) + table.add_column( + "Escalated", + justify="right", + style="red", + footer=str(sum(s.escalated_tickets for s in rows)), + ) + table.add_column( + "Notes Written", + justify="right", + style="yellow", + footer=str(sum(s.notes_written for s in rows)), + ) + table.add_column( + "Total Activity", + justify="right", + footer=str(sum(s.total_activity for s in rows)), + ) + table.add_column("Projects", style="dim") + + for s in rows: + projects_str = ", ".join(sorted(s.projects)) if s.projects else "—" + escalated_str = str(s.escalated_tickets) if s.escalated_tickets else "—" + table.add_row( + s.name, + str(s.tickets_created), + escalated_str, + str(s.notes_written), + str(s.total_activity), + projects_str, + ) + + console.print(table) + + +def main() -> None: + parser = argparse.ArgumentParser(description="PISCES Student Activity Report") + parser.add_argument( + "--input", + default=DEFAULT_INDEX, + help="Path to tickets_index.json (default: data/tickets/indexed/tickets_index.json)", + ) + parser.add_argument( + "--live", + action="store_true", + help="Fetch from Mantis REST API instead of the offline index", + ) + parser.add_argument( + "--project", + metavar="NAME", + help="Filter to tickets in projects matching NAME (substring, e.g. bonney-lake)", + ) + parser.add_argument( + "--sort", + choices=["activity", "tickets", "notes", "name"], + default="activity", + help="Sort order for the summary table (default: activity)", + ) + parser.add_argument( + "--student", + metavar="NAME", + help="Filter to a specific student by name (substring, case-insensitive)", + ) + parser.add_argument( + "--org", + metavar="NAME", + help="Filter to students from a specific institution by category name (substring, " + "case-insensitive, e.g. 'bellevue college')", + ) + parser.add_argument( + "--since", + metavar="YYYY-MM-DD", + help="Include only tickets created on or after this date", + ) + parser.add_argument( + "--until", + metavar="YYYY-MM-DD", + help="Include only tickets created on or before this date", + ) + parser.add_argument( + "--graph", + action="store_true", + help="Show a terminal line graph of ticket submissions over time " + "(requires --student or --org)", + ) + args = parser.parse_args() + + if args.graph and not args.student and not args.org: + parser.error("--graph requires --student or --org") + + since: date | None = None + until: date | None = None + if args.since: + try: + since = date.fromisoformat(args.since) + except ValueError: + parser.error(f"--since: invalid date '{args.since}' (expected YYYY-MM-DD)") + if args.until: + try: + until = date.fromisoformat(args.until) + except ValueError: + parser.error(f"--until: invalid date '{args.until}' (expected YYYY-MM-DD)") + + load_dotenv() + + from src.utils.dns import setup_dns + + setup_dns() + + if args.live: + tickets = _load_live(project_filter=args.project) + stats = build_report(_filter_by_date_range(tickets, since, until)) + else: + tickets = _load_offline(args.input) + stats = build_report( + _filter_by_date_range(tickets, since, until), project_filter=args.project + ) + + display_report( + stats, + sort_by=args.sort, + student_filter=args.student, + org_filter=args.org, + show_graph=args.graph, + ) + + +if __name__ == "__main__": + main() diff --git a/src/mantis/mantis_search.py b/src/mantis/mantis_search.py index d112f02..841ebd9 100755 --- a/src/mantis/mantis_search.py +++ b/src/mantis/mantis_search.py @@ -77,6 +77,59 @@ def sensor_to_project(sensor_val: str) -> str | None: # Fields that carry structured source/dest labels (not free-form notes) _LABEL_FIELDS = ("description", "steps_to_reproduce", "additional_information") +# Escalation detection — two-stage approach applied only to admin notes. +# +# Stage 1 (_CLIENT_CONTACT_RE): the handler directly contacted/informed the +# client (past or present tense). Any match whose 20-char prefix contains +# "will" is treated as a future intent and skipped. +# +# Stage 2 (_ESC_PAST_TO_CLIENT_RE): the handler wrote "escalated [this/it] to +# [the] client" in the past tense. Matches whose 20-char prefix contains +# "not" or "won't" are skipped (negated intent). +# +# Intentionally excluded: +# - bare "escalat*" words (privilege escalation, "no need to escalate", etc.) +# - future-tense client contact ("we will let the client know") +# - conditional escalation ("worth escalating", "if we see more, we'll…") +_CLIENT_CONTACT_RE = re.compile( + r"(?:" + r"(?:informed|notif(?:ied|y))\s+(?:the\s+)?client" + r"|let\s+(?:the\s+)?client\s+know" + r"|message\s+to\s+(?:the\s+)?client" + r"|customer\s+communication" + r"|client\s+respond(?:ed|s)" + r"|reported\s+(?:this\s+)?to\s+(?:the\s+)?client" + r"|reached\s+out\s+to\s+(?:the\s+)?client" + r"|contacted\s+(?:the\s+)?client" + r")", + re.I, +) +_ESC_PAST_TO_CLIENT_RE = re.compile( + r"\bescalated\s+(?:(?:this|it)\s+)?to\s+(?:the\s+)?client\b", + re.I, +) +_WILL_RE = re.compile(r"\bwill\b", re.I) +_NOT_RE = re.compile(r"\bnot\b|\bwon't\b", re.I) + + +def _note_is_escalation(note: dict) -> bool: + """Return True if an admin note confirms the ticket was escalated to the client.""" + text = note.get("text", "") + # Stage 1: client-contact phrases (skip future-tense occurrences) + for m in _CLIENT_CONTACT_RE.finditer(text): + prefix = text[max(0, m.start() - 20) : m.start()] + if _WILL_RE.search(prefix): + continue + return True + # Stage 2: past-tense "escalated [this/it] to [the] client" + for m in _ESC_PAST_TO_CLIENT_RE.finditer(text): + prefix = text[max(0, m.start() - 20) : m.start()] + if _NOT_RE.search(prefix): + continue + return True + return False + + _DASHBOARD_DOMAINS = {"kibana", "opensearch", "elastic"} _TI_DOMAINS = {"greynoise", "abuseipdb", "shodan", "virustotal"} @@ -241,6 +294,11 @@ def _normalize_issue( admin_note_count = sum(1 for n in notes if n["is_admin_note"]) + escalation_note = next( + (n for n in notes if n["is_admin_note"] and _note_is_escalation(n)), + None, + ) + return { "id": issue_id, "url": f"{api_url}/view.php?id={issue_id}", @@ -270,6 +328,8 @@ def _normalize_issue( "ti_links": ti_links, "note_count": len(notes), "admin_note_count": admin_note_count, + "is_escalated": escalation_note is not None, + "escalated_by": escalation_note["reporter"]["name"] if escalation_note else None, } diff --git a/src/mantis/student_activity.py b/src/mantis/student_activity.py index d6b33fb..847f112 100644 --- a/src/mantis/student_activity.py +++ b/src/mantis/student_activity.py @@ -18,6 +18,9 @@ python src/mantis/student_activity.py --project bonney-lake python src/mantis/student_activity.py --student alice python src/mantis/student_activity.py --student alice --graph + python src/mantis/student_activity.py --org 'bellevue college' + python src/mantis/student_activity.py --org 'bellevue college' --student alice + python src/mantis/student_activity.py --since 2025-01-01 --until 2025-04-30 python src/mantis/student_activity.py --input data/tickets/indexed/tickets_index.json """ @@ -45,6 +48,69 @@ console = Console() + +def _ordinal(n: int) -> str: + """Return an integer with its ordinal suffix: 1st, 2nd, 3rd, 4th…""" + if 11 <= (n % 100) <= 13: + suffix = "th" + else: + suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") + return f"{n}{suffix}" + + +def _format_date_range(date_strs: list[str]) -> str: + """Format a collection of YYYY-MM-DD strings as a human-readable span. + + Returns strings like "Jan 1st – Apr 28th" for same-year ranges, or + "Jan 1st, 2024 – Apr 28th, 2025" when the range crosses calendar years. + Returns an empty string when no valid dates are present. + """ + valid = [d for d in date_strs if d and len(d) >= 10] + if not valid: + return "" + parsed = sorted(date.fromisoformat(d[:10]) for d in valid) + first, last = parsed[0], parsed[-1] + cross_year = first.year != last.year + + def _fmt(d: date) -> str: + base = f"{d.strftime('%b')} {_ordinal(d.day)}" + return f"{base}, {d.year}" if cross_year else base + + if first == last: + return _fmt(first) + return f"{_fmt(first)} – {_fmt(last)}" + + +def _filter_by_date_range( + tickets: list[dict], + since: date | None, + until: date | None, +) -> list[dict]: + """Return only tickets whose created_at falls within [since, until]. + + Tickets with no or unparseable created_at are kept so data is not silently lost. + """ + if not since and not until: + return tickets + result = [] + for t in tickets: + raw = (t.get("created_at") or "")[:10] + if not raw: + result.append(t) + continue + try: + d = date.fromisoformat(raw) + except ValueError: + result.append(t) + continue + if since and d < since: + continue + if until and d > until: + continue + result.append(t) + return result + + # Minimal ticket ref stored per student — id, summary, url, status, created_at _TicketRef = dict @@ -69,6 +135,9 @@ def noted_tickets(self) -> list[_TicketRef]: """Unique tickets this student commented on but did not create.""" return list(self._noted.values()) + escalated_tickets: int = 0 + categories: set[str] = field(default_factory=set) + @property def total_activity(self) -> int: return self.tickets_created + self.notes_written @@ -193,6 +262,11 @@ def build_report( stats[reporter_id].created_tickets.append(ref) if project: stats[reporter_id].projects.add(project) + if ticket.get("is_escalated"): + stats[reporter_id].escalated_tickets += 1 + category = ticket.get("category", "") + if category: + stats[reporter_id].categories.add(category) for note in ticket.get("notes", []): note_reporter = note.get("reporter", {}) @@ -239,11 +313,112 @@ def _pick_student(matches: list[tuple[int, StudentStats]]) -> tuple[int, Student console.print(f"[red]Enter a number between 1 and {len(matches)}.[/red]") +def _pick_org(matches: list[str]) -> str | None: + """Prompt the user to select one institution from a list of matches.""" + console.print(f"\n[yellow]Found {len(matches)} institutions matching that name:[/yellow]\n") + for i, name in enumerate(matches, 1): + console.print(f" [cyan]{i}[/cyan]. {name}") + console.print() + + while True: + try: + raw = input("Select an institution (number), or press Enter to show all: ").strip() + except (EOFError, KeyboardInterrupt): + console.print() + return None + + if raw == "": + return None + + if raw.isdigit() and 1 <= int(raw) <= len(matches): + return matches[int(raw) - 1] + + console.print(f"[red]Enter a number between 1 and {len(matches)}.[/red]") + + +def display_org_report( + stats: dict[int, StudentStats], + org_filter: str, + sort_by: str = "activity", + student_filter: str | None = None, + show_graph: bool = False, +) -> None: + """Render the activity report for a single institution. + + Finds all unique ticket categories matching org_filter (substring, + case-insensitive). If multiple categories match, prompts the user to pick + one or show all. Then renders the same summary table as display_report(), + scoped to students whose tickets belong to the matched category/categories. + + If student_filter is also provided, further narrows to a specific student + within the institution and shows the detail view. + + Args: + stats: Full per-student stats from build_report(). + org_filter: Substring to match against ticket category names. + sort_by: Column to sort the summary table by. + student_filter: Optional student name substring for further drill-down. + show_graph: Whether to show the submission graph in detail view. + """ + # Collect all unique categories across the corpus + all_categories: list[str] = sorted( + {cat for s in stats.values() for cat in s.categories}, + key=str.lower, + ) + matching_cats = [c for c in all_categories if org_filter.lower() in c.lower()] + + if not matching_cats: + console.print(f"[yellow]No institution matching '{org_filter}' found.[/yellow]") + console.print(f"[dim]Known institutions: {', '.join(all_categories) or 'none'}[/dim]") + return + + # Disambiguate if multiple categories match + if len(matching_cats) == 1: + chosen_cats = matching_cats + else: + chosen = _pick_org(matching_cats) + chosen_cats = [chosen] if chosen is not None else matching_cats + + org_label = chosen_cats[0] if len(chosen_cats) == 1 else org_filter + + # Filter students who have tickets in any of the chosen categories + org_stats = {k: v for k, v in stats.items() if v.categories & set(chosen_cats)} + + if not org_stats: + console.print(f"[yellow]No students found for institution '{org_label}'.[/yellow]") + return + + all_dates = [ref["created_at"] for s in org_stats.values() for ref in s.created_tickets] + date_range = _format_date_range(all_dates) + date_part = f" · {date_range}" if date_range else "" + console.print( + Rule(f"[bold]{org_label}[/bold] [dim]({len(org_stats)} students{date_part})[/dim]") + ) + + if student_filter: + # Delegate to the normal student-filter path, scoped to this org + display_report( + org_stats, + sort_by=sort_by, + student_filter=student_filter, + show_graph=show_graph, + ) + else: + display_report(org_stats, sort_by=sort_by) + if show_graph: + console.print() + draw_org_graph(org_stats, org_label) + + def display_student_detail(student: StudentStats, show_graph: bool = False) -> None: """Render a detailed activity breakdown for a single student.""" console.print(Rule(f"[cyan]{student.name}[/cyan]")) + escalated_str = ( + f" Escalated: [red]{student.escalated_tickets}[/red]" if student.escalated_tickets else "" + ) console.print( - f" Tickets created: [green]{student.tickets_created}[/green] " + f" Tickets created: [green]{student.tickets_created}[/green]" + f"{escalated_str} " f"Notes written: [yellow]{student.notes_written}[/yellow] " f"Total activity: [bold]{student.total_activity}[/bold]" ) @@ -299,32 +474,22 @@ def display_student_detail(student: StudentStats, show_graph: bool = False) -> N draw_submission_graph(student) -def draw_submission_graph(student: StudentStats) -> None: - """Render a terminal line graph of ticket submissions over time for one student. +def _plot_ticket_timeline(date_strs: list[str], title: str) -> None: + """Render a terminal line graph of ticket submissions over time. - Granularity is chosen automatically based on the date range: + Accepts a flat list of YYYY-MM-DD strings (duplicates allowed — each + represents one ticket). Granularity is chosen automatically: - ≤ 5 weeks → daily - ≤ 12 months → weekly (Mon-anchored) - > 12 months → monthly - The graph width is capped to the terminal width minus a small margin so it - fits comfortably on most screen sizes. + The graph width is capped to the terminal width minus a small margin. """ import plotext as plt - dated = [ - ref["created_at"] - for ref in student.created_tickets - if ref.get("created_at") and len(ref["created_at"]) == 10 - ] - if not dated: - console.print("[yellow]No dated tickets — cannot draw graph.[/yellow]") - return - - parsed = sorted(date.fromisoformat(d) for d in dated) + parsed = sorted(date.fromisoformat(d) for d in date_strs) span_days = (parsed[-1] - parsed[0]).days - # Choose bucket granularity if span_days <= 35: granularity = "day" label_fmt = "%b %d" @@ -338,7 +503,6 @@ def draw_submission_graph(student: StudentStats) -> None: label_fmt = "%b '%y" bucket_fn = lambda d: d.replace(day=1) # noqa: E731 - # Build ordered bucket counts buckets: dict[date, int] = defaultdict(int) for d in parsed: buckets[bucket_fn(d)] += 1 @@ -378,17 +542,15 @@ def draw_submission_graph(student: StudentStats) -> None: except OSError: term_width = 100 plot_width = max(60, min(term_width - 4, 160)) - plot_height = 14 - # Show one x-tick label every N buckets so they don't overlap tick_step = max(1, len(labels) // (plot_width // 10)) tick_positions = x_indices[::tick_step] tick_labels = labels[::tick_step] plt.clf() - plt.plot_size(plot_width, plot_height) + plt.plot_size(plot_width, 14) plt.theme("dark") - plt.title(f"Ticket Submissions — {student.name} ({granularity}ly)") + plt.title(f"{title} ({granularity}ly)") plt.xlabel(granularity.capitalize()) plt.ylabel("Tickets") plt.plot(x_indices, counts, marker="braille") @@ -401,18 +563,58 @@ def draw_submission_graph(student: StudentStats) -> None: ) +def draw_submission_graph(student: StudentStats) -> None: + """Render a submission timeline graph for a single student.""" + dated = [ + ref["created_at"] + for ref in student.created_tickets + if ref.get("created_at") and len(ref["created_at"]) == 10 + ] + if not dated: + console.print("[yellow]No dated tickets — cannot draw graph.[/yellow]") + return + _plot_ticket_timeline(dated, f"Ticket Submissions — {student.name}") + + +def draw_org_graph(org_stats: dict[int, StudentStats], org_label: str) -> None: + """Render an aggregate submission timeline graph for all students in an org.""" + dated = [ + ref["created_at"] + for s in org_stats.values() + for ref in s.created_tickets + if ref.get("created_at") and len(ref["created_at"]) == 10 + ] + if not dated: + console.print("[yellow]No dated tickets — cannot draw graph.[/yellow]") + return + _plot_ticket_timeline(dated, f"Ticket Submissions — {org_label}") + + def display_report( stats: dict[int, StudentStats], sort_by: str = "activity", student_filter: str | None = None, + org_filter: str | None = None, show_graph: bool = False, ) -> None: """Render the student activity report. + If org_filter is set, delegates to display_org_report() which scopes the + table to students from that institution (matched by ticket category). If student_filter matches exactly one student, shows the detail view. If it matches multiple, prompts the user to pick one (or show all). With no filter, renders the full summary table. """ + if org_filter: + display_org_report( + stats, + org_filter=org_filter, + sort_by=sort_by, + student_filter=student_filter, + show_graph=show_graph, + ) + return + if student_filter: matches = [(k, v) for k, v in stats.items() if student_filter.lower() in v.name.lower()] @@ -444,8 +646,14 @@ def display_report( else: rows.sort(key=lambda s: s.total_activity, reverse=True) + all_dates = [ref["created_at"] for s in rows for ref in s.created_tickets] + date_range = _format_date_range(all_dates) + title = f"Student Activity Report ({len(rows)} students)" + if date_range: + title += f" · {date_range}" + table = Table( - title=f"Student Activity Report ({len(rows)} students)", + title=title, box=box.SIMPLE, show_footer=True, ) @@ -456,6 +664,12 @@ def display_report( style="green", footer=str(sum(s.tickets_created for s in rows)), ) + table.add_column( + "Escalated", + justify="right", + style="red", + footer=str(sum(s.escalated_tickets for s in rows)), + ) table.add_column( "Notes Written", justify="right", @@ -471,9 +685,11 @@ def display_report( for s in rows: projects_str = ", ".join(sorted(s.projects)) if s.projects else "—" + escalated_str = str(s.escalated_tickets) if s.escalated_tickets else "—" table.add_row( s.name, str(s.tickets_created), + escalated_str, str(s.notes_written), str(s.total_activity), projects_str, @@ -510,15 +726,45 @@ def main() -> None: metavar="NAME", help="Filter to a specific student by name (substring, case-insensitive)", ) + parser.add_argument( + "--org", + metavar="NAME", + help="Filter to students from a specific institution by category name (substring, " + "case-insensitive, e.g. 'bellevue college')", + ) + parser.add_argument( + "--since", + metavar="YYYY-MM-DD", + help="Include only tickets created on or after this date", + ) + parser.add_argument( + "--until", + metavar="YYYY-MM-DD", + help="Include only tickets created on or before this date", + ) parser.add_argument( "--graph", action="store_true", - help="Show a terminal line graph of ticket submissions over time (requires --student)", + help="Show a terminal line graph of ticket submissions over time " + "(requires --student or --org)", ) args = parser.parse_args() - if args.graph and not args.student: - parser.error("--graph requires --student") + if args.graph and not args.student and not args.org: + parser.error("--graph requires --student or --org") + + since: date | None = None + until: date | None = None + if args.since: + try: + since = date.fromisoformat(args.since) + except ValueError: + parser.error(f"--since: invalid date '{args.since}' (expected YYYY-MM-DD)") + if args.until: + try: + until = date.fromisoformat(args.until) + except ValueError: + parser.error(f"--until: invalid date '{args.until}' (expected YYYY-MM-DD)") load_dotenv() @@ -528,12 +774,20 @@ def main() -> None: if args.live: tickets = _load_live(project_filter=args.project) - stats = build_report(tickets) + stats = build_report(_filter_by_date_range(tickets, since, until)) else: tickets = _load_offline(args.input) - stats = build_report(tickets, project_filter=args.project) + stats = build_report( + _filter_by_date_range(tickets, since, until), project_filter=args.project + ) - display_report(stats, sort_by=args.sort, student_filter=args.student, show_graph=args.graph) + display_report( + stats, + sort_by=args.sort, + student_filter=args.student, + org_filter=args.org, + show_graph=args.graph, + ) if __name__ == "__main__": From 8b44a2de409a75d9e651ce9c212b587106bc0813 Mon Sep 17 00:00:00 2001 From: liamadale Date: Wed, 29 Apr 2026 09:58:21 -0700 Subject: [PATCH 07/49] feat(mantis-explorer): add new Flask web app for student activity exploration New app at /mantis-explorer (port 5003 standalone via apps/mantis_explorer/run.py) that provides a browser-based view of the data from student_activity/activity_report. Key capabilities: - Institution overview table with ticket counts, escalation counts, and date ranges - Per-institution student breakdown with sortable activity table - Per-student slide panel showing created tickets (with escalated row tinting and red exclamation icon) and notes, plus a ticket detail view with an "Escalated" badge in the meta row when is_escalated is true - Resizable ticket slide panel with drag-to-resize handle; width persisted to localStorage across page loads - Charts: submission timeline, org bar chart, and escalation breakdown - Date-range filtering propagated from the URL query string - Dark SOC-analyst CSS theme consistent with the rest of the PISCES UI (me.css) --- apps/mantis_explorer/__init__.py | 0 apps/mantis_explorer/app.py | 211 ++++ apps/mantis_explorer/data.py | 311 +++++ apps/mantis_explorer/run.py | 10 + apps/mantis_explorer/static/me.css | 1108 +++++++++++++++++ apps/mantis_explorer/static/pisces-logo.ico | Bin 0 -> 152126 bytes apps/mantis_explorer/static/pisces-logo.png | Bin 0 -> 21465 bytes apps/mantis_explorer/templates/base.html | 315 +++++ apps/mantis_explorer/templates/index.html | 155 +++ apps/mantis_explorer/templates/org.html | 129 ++ .../templates/partials/chart_escalation.html | 59 + .../templates/partials/chart_orgs_bar.html | 38 + .../templates/partials/chart_timeline.html | 45 + .../templates/partials/org_stat_row.html | 24 + .../templates/partials/org_table.html | 18 + .../templates/partials/org_timeline.html | 39 + .../templates/partials/stat_row.html | 28 + .../templates/partials/student_panel.html | 110 ++ .../templates/partials/student_rows.html | 20 + .../templates/partials/ticket_detail.html | 201 +++ 20 files changed, 2821 insertions(+) create mode 100644 apps/mantis_explorer/__init__.py create mode 100644 apps/mantis_explorer/app.py create mode 100644 apps/mantis_explorer/data.py create mode 100644 apps/mantis_explorer/run.py create mode 100644 apps/mantis_explorer/static/me.css create mode 100644 apps/mantis_explorer/static/pisces-logo.ico create mode 100644 apps/mantis_explorer/static/pisces-logo.png create mode 100644 apps/mantis_explorer/templates/base.html create mode 100644 apps/mantis_explorer/templates/index.html create mode 100644 apps/mantis_explorer/templates/org.html create mode 100644 apps/mantis_explorer/templates/partials/chart_escalation.html create mode 100644 apps/mantis_explorer/templates/partials/chart_orgs_bar.html create mode 100644 apps/mantis_explorer/templates/partials/chart_timeline.html create mode 100644 apps/mantis_explorer/templates/partials/org_stat_row.html create mode 100644 apps/mantis_explorer/templates/partials/org_table.html create mode 100644 apps/mantis_explorer/templates/partials/org_timeline.html create mode 100644 apps/mantis_explorer/templates/partials/stat_row.html create mode 100644 apps/mantis_explorer/templates/partials/student_panel.html create mode 100644 apps/mantis_explorer/templates/partials/student_rows.html create mode 100644 apps/mantis_explorer/templates/partials/ticket_detail.html diff --git a/apps/mantis_explorer/__init__.py b/apps/mantis_explorer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/mantis_explorer/app.py b/apps/mantis_explorer/app.py new file mode 100644 index 0000000..8887d46 --- /dev/null +++ b/apps/mantis_explorer/app.py @@ -0,0 +1,211 @@ +"""Flask application factory and routes for Mantis Explorer.""" + +from flask import Flask, abort, render_template, request + +from apps.mantis_explorer.data import ( + SLUG_TO_ORG, + TICKETS_BY_ID, + compute_escalation_data, + compute_global_stats, + compute_org_report, + compute_org_rows, + compute_org_stats, + compute_timeline_data, + get_report, + parse_date_params, + sort_students, +) + + +def create_app() -> Flask: + """Create and configure the Mantis Explorer Flask app.""" + app = Flask(__name__, static_folder="static", template_folder="templates") + + @app.context_processor + def inject_globals() -> dict: + return {"script_name": request.environ.get("SCRIPT_NAME", "")} + + # ------------------------------------------------------------------ + # GET / — Overview page (skeleton; HTMX regions load on trigger) + # ------------------------------------------------------------------ + @app.route("/") + def index(): + since = request.args.get("since", "") + until = request.args.get("until", "") + return render_template("index.html", since=since, until=until) + + # ------------------------------------------------------------------ + # GET /api/stat-row — HTMX: global summary chips + # ------------------------------------------------------------------ + @app.route("/api/stat-row") + def api_stat_row(): + since, until = parse_date_params(request.args) + report = get_report(since, until) + stats = compute_global_stats(report) + return render_template("partials/stat_row.html", stats=stats) + + # ------------------------------------------------------------------ + # GET /api/org-table — HTMX: institution table tbody + # ------------------------------------------------------------------ + @app.route("/api/org-table") + def api_org_table(): + since, until = parse_date_params(request.args) + q = request.args.get("q", "").strip() + sort = request.args.get("sort", "tickets") + order = request.args.get("order", "desc") + report = get_report(since, until) + rows = compute_org_rows(report, q=q, sort=sort, order=order) + return render_template( + "partials/org_table.html", + rows=rows, + sort=sort, + order=order, + since=since, + until=until, + ) + + # ------------------------------------------------------------------ + # GET /api/chart/orgs-bar — HTMX: horizontal bar chart + # ------------------------------------------------------------------ + @app.route("/api/chart/orgs-bar") + def api_chart_orgs_bar(): + since, until = parse_date_params(request.args) + report = get_report(since, until) + rows = compute_org_rows(report) + return render_template("partials/chart_orgs_bar.html", rows=rows) + + # ------------------------------------------------------------------ + # GET /api/chart/timeline — HTMX: global area timeline + # ------------------------------------------------------------------ + @app.route("/api/chart/timeline") + def api_chart_timeline(): + since, until = parse_date_params(request.args) + report = get_report(since, until) + timeline = compute_timeline_data(report) + return render_template("partials/chart_timeline.html", timeline=timeline) + + # ------------------------------------------------------------------ + # GET /api/chart/escalation — HTMX: escalation grouped bar + # ------------------------------------------------------------------ + @app.route("/api/chart/escalation") + def api_chart_escalation(): + since, until = parse_date_params(request.args) + report = get_report(since, until) + esc = compute_escalation_data(report) + return render_template("partials/chart_escalation.html", esc=esc) + + # ------------------------------------------------------------------ + # GET /org/ — Org detail page + # ------------------------------------------------------------------ + @app.route("/org/") + def org_detail(slug: str): + org_name = SLUG_TO_ORG.get(slug) + if not org_name: + abort(404) + since = request.args.get("since", "") + until = request.args.get("until", "") + return render_template( + "org.html", + org_name=org_name, + slug=slug, + since=since, + until=until, + ) + + # ------------------------------------------------------------------ + # GET /api/org//stat-row — HTMX: org summary chips + # ------------------------------------------------------------------ + @app.route("/api/org//stat-row") + def api_org_stat_row(slug: str): + org_name = SLUG_TO_ORG.get(slug) + if not org_name: + abort(404) + since, until = parse_date_params(request.args) + report = get_report(since, until) + org_report = compute_org_report(report, org_name) + stats = compute_org_stats(org_report) + return render_template("partials/org_stat_row.html", stats=stats, org_name=org_name) + + # ------------------------------------------------------------------ + # GET /api/org//timeline — HTMX: org submission timeline + # ------------------------------------------------------------------ + @app.route("/api/org//timeline") + def api_org_timeline(slug: str): + org_name = SLUG_TO_ORG.get(slug) + if not org_name: + abort(404) + since, until = parse_date_params(request.args) + report = get_report(since, until) + org_report = compute_org_report(report, org_name) + timeline = compute_timeline_data(org_report) + return render_template("partials/org_timeline.html", timeline=timeline, org_name=org_name) + + # ------------------------------------------------------------------ + # GET /api/org//students — HTMX: student table tbody + # ------------------------------------------------------------------ + @app.route("/api/org//students") + def api_org_students(slug: str): + org_name = SLUG_TO_ORG.get(slug) + if not org_name: + abort(404) + since, until = parse_date_params(request.args) + q = request.args.get("q", "").strip() + sort = request.args.get("sort", "activity") + order = request.args.get("order", "desc") + report = get_report(since, until) + org_report = compute_org_report(report, org_name) + from src.mantis.activity_report import _format_date_range + + rows = [] + for sid, s in sort_students(org_report, sort=sort, order=order, q=q): + dates = [ref["created_at"] for ref in s.created_tickets if ref.get("created_at")] + rows.append( + { + "id": sid, + "name": s.name, + "tickets_created": s.tickets_created, + "escalated_tickets": s.escalated_tickets, + "notes_written": s.notes_written, + "total_activity": s.total_activity, + "date_range": _format_date_range(dates), + } + ) + return render_template( + "partials/student_rows.html", + rows=rows, + sort=sort, + order=order, + slug=slug, + since=since, + until=until, + ) + + # ------------------------------------------------------------------ + # GET /api/ticket/ — HTMX: full ticket detail for slide panel + # ------------------------------------------------------------------ + @app.route("/api/ticket/") + def api_ticket_detail(tid: str): + t = TICKETS_BY_ID.get(str(tid)) + if not t: + return ( + "

Ticket not found.

", + 404, + ) + return render_template("partials/ticket_detail.html", t=t) + + # ------------------------------------------------------------------ + # GET /api/student//panel — HTMX: student detail slide panel + # ------------------------------------------------------------------ + @app.route("/api/student//panel") + def api_student_panel(student_id: int): + since, until = parse_date_params(request.args) + report = get_report(since, until) + student = report.get(student_id) + if not student: + return ( + "

Student not found.

", + 404, + ) + return render_template("partials/student_panel.html", student=student) + + return app diff --git a/apps/mantis_explorer/data.py b/apps/mantis_explorer/data.py new file mode 100644 index 0000000..7d4601f --- /dev/null +++ b/apps/mantis_explorer/data.py @@ -0,0 +1,311 @@ +"""Startup data loading and per-request computation for Mantis Explorer.""" + +import os +import re +from collections import defaultdict +from datetime import date, timedelta +from functools import lru_cache + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_REPO = os.path.dirname(os.path.dirname(_HERE)) + +# --------------------------------------------------------------------------- +# Raw tickets — reuse mantis_web's already-parsed list in hub mode so we +# don't parse the JSON twice. Falls back to loading independently when +# running standalone (mantis_web.data will load itself in that process). +# --------------------------------------------------------------------------- +from apps.mantis_web.data import _raw_tickets as RAW_TICKETS # noqa: E402 +from src.mantis.activity_report import ( # noqa: E402 + StudentStats, + _filter_by_date_range, + _format_date_range, + build_report, +) + +# --------------------------------------------------------------------------- +# Slug maps — built once at startup from all category values in the corpus +# --------------------------------------------------------------------------- + + +def _slugify(name: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + + +def _build_slug_maps(tickets: list[dict]) -> tuple[dict[str, str], dict[str, str]]: + """Return (slug→org, org→slug) for every unique category value.""" + orgs: set[str] = set() + for t in tickets: + cat = t.get("category", "") + if cat: + orgs.add(cat) + + slug_to_org: dict[str, str] = {} + org_to_slug: dict[str, str] = {} + for org in sorted(orgs): + slug = _slugify(org) + # Collision avoidance + if slug in slug_to_org: + n = 2 + while f"{slug}-{n}" in slug_to_org: + n += 1 + slug = f"{slug}-{n}" + slug_to_org[slug] = org + org_to_slug[org] = slug + return slug_to_org, org_to_slug + + +SLUG_TO_ORG, ORG_TO_SLUG = _build_slug_maps(RAW_TICKETS) + +# --------------------------------------------------------------------------- +# Per-request computation with caching +# --------------------------------------------------------------------------- + + +def parse_date_params(args: object) -> tuple[str, str]: + """Extract and validate since/until from request.args. + + Returns ("", "") for absent or invalid values so callers can pass + strings directly to get_report() without additional validation. + """ + + def _safe(key: str) -> str: + v = getattr(args, "get", lambda k, d="": d)(key, "").strip() + try: + date.fromisoformat(v) + return v + except ValueError: + return "" + + return _safe("since"), _safe("until") + + +@lru_cache(maxsize=32) +def get_report(since_str: str, until_str: str) -> dict[int, StudentStats]: + """Filter RAW_TICKETS by date range and run build_report(). Cached by (since, until).""" + since = date.fromisoformat(since_str) if since_str else None + until = date.fromisoformat(until_str) if until_str else None + filtered = _filter_by_date_range(RAW_TICKETS, since, until) + return build_report(filtered) + + +# --------------------------------------------------------------------------- +# Aggregation helpers +# --------------------------------------------------------------------------- + + +def compute_global_stats(report: dict[int, StudentStats]) -> dict: + """Summary numbers for the overview stat row.""" + all_orgs: set[str] = set() + total_tickets = 0 + total_escalations = 0 + total_notes = 0 + all_dates: list[str] = [] + + for s in report.values(): + all_orgs.update(s.categories) + total_tickets += s.tickets_created + total_escalations += s.escalated_tickets + total_notes += s.notes_written + all_dates.extend(ref["created_at"] for ref in s.created_tickets if ref.get("created_at")) + + return { + "institution_count": len(all_orgs), + "student_count": len(report), + "ticket_count": total_tickets, + "escalation_count": total_escalations, + "note_count": total_notes, + "date_range": _format_date_range(all_dates), + } + + +def _org_aggregate(report: dict[int, StudentStats], q: str = "") -> dict[str, dict]: + """Build a per-org aggregation dict keyed by raw category name.""" + agg: dict[str, dict] = {} + for sid, s in report.items(): + for cat in s.categories: + if q and q.lower() not in cat.lower(): + continue + if cat not in agg: + agg[cat] = { + "name": cat, + "slug": ORG_TO_SLUG.get(cat, _slugify(cat)), + "student_ids": set(), + "ticket_count": 0, + "escalation_count": 0, + "note_count": 0, + "dates": [], + } + d = agg[cat] + d["student_ids"].add(sid) + d["ticket_count"] += s.tickets_created + d["escalation_count"] += s.escalated_tickets + d["note_count"] += s.notes_written + d["dates"].extend( + ref["created_at"] for ref in s.created_tickets if ref.get("created_at") + ) + return agg + + +def compute_org_rows( + report: dict[int, StudentStats], + q: str = "", + sort: str = "tickets", + order: str = "desc", +) -> list[dict]: + """Return institution table rows, sorted and optionally name-filtered.""" + agg = _org_aggregate(report, q=q) + rows = [] + for d in agg.values(): + rows.append( + { + "name": d["name"], + "slug": d["slug"], + "student_count": len(d["student_ids"]), + "ticket_count": d["ticket_count"], + "escalation_count": d["escalation_count"], + "note_count": d["note_count"], + "date_range": _format_date_range(d["dates"]), + } + ) + + key_fn = { + "name": lambda r: r["name"].lower(), + "students": lambda r: r["student_count"], + "tickets": lambda r: r["ticket_count"], + "escalated": lambda r: r["escalation_count"], + "notes": lambda r: r["note_count"], + }.get(sort, lambda r: r["ticket_count"]) + rows.sort(key=key_fn, reverse=(order == "desc")) + return rows + + +def compute_timeline_data(report: dict[int, StudentStats]) -> dict: + """Auto-granularity timeline data for ECharts. + + Mirrors the _plot_ticket_timeline logic from activity_report.py but + returns a JSON-serialisable dict instead of drawing to the terminal. + + Returns: + {"labels": [...], "counts": [...], "granularity": "daily"|"weekly"|"monthly"} + or {"labels": [], "counts": [], "granularity": "daily"} when no dated tickets. + """ + raw_dates: list[str] = [ + ref["created_at"] + for s in report.values() + for ref in s.created_tickets + if ref.get("created_at") and len(ref["created_at"]) >= 10 + ] + if not raw_dates: + return {"labels": [], "counts": [], "granularity": "daily"} + + parsed = sorted(date.fromisoformat(d[:10]) for d in raw_dates) + span_days = (parsed[-1] - parsed[0]).days + + if span_days <= 35: + granularity = "daily" + label_fmt = "%b %d" + bucket_fn = lambda d: d # noqa: E731 + elif span_days <= 365: + granularity = "weekly" + label_fmt = "%b %d" + bucket_fn = lambda d: d - timedelta(days=d.weekday()) # noqa: E731 + else: + granularity = "monthly" + label_fmt = "%b '%y" + bucket_fn = lambda d: d.replace(day=1) # noqa: E731 + + buckets: dict[date, int] = defaultdict(int) + for d in parsed: + buckets[bucket_fn(d)] += 1 + + # Fill gaps + all_buckets = sorted(buckets) + first, last = all_buckets[0], all_buckets[-1] + full_range: list[date] = [] + if granularity == "daily": + cursor = first + while cursor <= last: + full_range.append(cursor) + cursor += timedelta(days=1) + elif granularity == "weekly": + cursor = first + while cursor <= last: + full_range.append(cursor) + cursor += timedelta(weeks=1) + else: + y, m = first.year, first.month + while date(y, m, 1) <= last: + full_range.append(date(y, m, 1)) + m += 1 + if m > 12: + m, y = 1, y + 1 + + return { + "labels": [b.strftime(label_fmt) for b in full_range], + "counts": [buckets.get(b, 0) for b in full_range], + "granularity": granularity, + } + + +def compute_escalation_data(report: dict[int, StudentStats]) -> dict: + """Per-org escalation data for the grouped bar chart. + + Returns: + {"orgs": [...], "totals": [...], "escalated": [...]} + sorted by total tickets descending. + """ + agg = _org_aggregate(report) + rows = sorted(agg.values(), key=lambda d: d["ticket_count"], reverse=True) + return { + "orgs": [d["name"] for d in rows], + "totals": [d["ticket_count"] for d in rows], + "escalated": [d["escalation_count"] for d in rows], + } + + +def compute_org_report(report: dict[int, StudentStats], org_name: str) -> dict[int, StudentStats]: + """Filter report to students who have at least one ticket in org_name's category.""" + return {k: v for k, v in report.items() if org_name in v.categories} + + +def compute_org_stats(org_report: dict[int, StudentStats]) -> dict: + """Summary numbers for the org-detail stat row.""" + total_tickets = sum(s.tickets_created for s in org_report.values()) + total_escalations = sum(s.escalated_tickets for s in org_report.values()) + total_notes = sum(s.notes_written for s in org_report.values()) + all_dates = [ + ref["created_at"] + for s in org_report.values() + for ref in s.created_tickets + if ref.get("created_at") + ] + return { + "student_count": len(org_report), + "ticket_count": total_tickets, + "escalation_count": total_escalations, + "note_count": total_notes, + "date_range": _format_date_range(all_dates), + } + + +def sort_students( + org_report: dict[int, StudentStats], + sort: str = "activity", + order: str = "desc", + q: str = "", +) -> list[tuple[int, StudentStats]]: + """Return (reporter_id, StudentStats) pairs sorted and optionally name-filtered.""" + pairs = list(org_report.items()) + if q: + pairs = [(k, v) for k, v in pairs if q.lower() in v.name.lower()] + + key_fn = { + "name": lambda p: p[1].name.lower(), + "tickets": lambda p: p[1].tickets_created, + "escalated": lambda p: p[1].escalated_tickets, + "notes": lambda p: p[1].notes_written, + "activity": lambda p: p[1].total_activity, + }.get(sort, lambda p: p[1].total_activity) + + pairs.sort(key=key_fn, reverse=(order != "asc" and sort != "name")) + return pairs diff --git a/apps/mantis_explorer/run.py b/apps/mantis_explorer/run.py new file mode 100644 index 0000000..36ca070 --- /dev/null +++ b/apps/mantis_explorer/run.py @@ -0,0 +1,10 @@ +"""Standalone launcher for Mantis Explorer on port 5005.""" + +from apps.mantis_explorer.app import create_app +from apps.mantis_explorer.data import SLUG_TO_ORG, get_report + +if __name__ == "__main__": + report = get_report("", "") + print(f"Mantis Explorer: {len(SLUG_TO_ORG)} institutions, {len(report)} students") + app = create_app() + app.run(host="0.0.0.0", port=5005, debug=False, threaded=True) diff --git a/apps/mantis_explorer/static/me.css b/apps/mantis_explorer/static/me.css new file mode 100644 index 0000000..6efa9a7 --- /dev/null +++ b/apps/mantis_explorer/static/me.css @@ -0,0 +1,1108 @@ +/* ================================================================ + Mantis Explorer — Material Design 3 Dark/Light Theme + Self-contained; does not depend on pisces.css + ================================================================ */ + +/* ── 1. Design Tokens ─────────────────────────────────── */ + +[data-theme="dark"] { + --surface: #0d0f14; + --surface-container-low: #161a24; + --surface-container: #1a1f2e; + --surface-container-high: #1e2433; + --surface-container-highest:#232840; + --outline: #2a3045; + --outline-dim: #1e2233; + --on-surface: #c8ccd8; + --on-surface-dim: #5a6278; + --primary: #4f8ef7; + --primary-dim: #3a7ae8; + --secondary: #7ec8e3; + --tertiary: #bc8cff; + --green: #3fb950; --yellow: #d29922; + --red: #f85149; --orange: #e3763c; --purple: #bc8cff; + --state-hover: rgba(79,142,247,0.08); + --badge-green-bg: rgba(63,185,80,0.2); + --badge-red-bg: rgba(248,81,73,0.2); + --badge-yellow-bg: rgba(210,153,34,0.2); + --badge-blue-bg: rgba(79,142,247,0.2); + --badge-gray-bg: rgba(90,98,120,0.3); + --focus-ring: rgba(79,142,247,0.25); + --panel-shadow: rgba(0,0,0,0.45); +} + +[data-theme="light"] { + --surface: #f8f9fc; + --surface-container-low: #f0f1f5; + --surface-container: #e8eaef; + --surface-container-high: #e1e3e9; + --surface-container-highest:#d8dbe2; + --outline: #c4c8d4; + --outline-dim: #dcdfe6; + --on-surface: #1a1c24; + --on-surface-dim: #5c6070; + --primary: #2563eb; + --primary-dim: #1d4fd8; + --secondary: #0d7490; + --tertiary: #7c3aed; + --green: #16a34a; --yellow: #ca8a04; + --red: #dc2626; --orange: #ea580c; --purple: #7c3aed; + --state-hover: rgba(37,99,235,0.06); + --badge-green-bg: rgba(22,163,74,0.12); + --badge-red-bg: rgba(220,38,38,0.12); + --badge-yellow-bg: rgba(202,138,4,0.12); + --badge-blue-bg: rgba(37,99,235,0.10); + --badge-gray-bg: rgba(92,96,112,0.12); + --focus-ring: rgba(37,99,235,0.25); + --panel-shadow: rgba(0,0,0,0.15); +} + +:root { + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-pill: 28px; + + --font: "JetBrains Mono","Fira Mono","Courier New",monospace; + --sans: system-ui,-apple-system,sans-serif; +} + + +/* ── 2. Reset & Base ──────────────────────────────────── */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { font-size: 14px; } + +body { + background: var(--surface); + color: var(--on-surface); + font-family: var(--font); + min-height: 100vh; + display: flex; + flex-direction: column; + line-height: 1.5; +} + +a { color: var(--primary); text-decoration: none; } +a:hover { text-decoration: underline; } + + +/* ── 3. Nav ───────────────────────────────────────────── */ + +nav { + flex-shrink: 0; + background: var(--surface-container-low); + border-bottom: 1px solid var(--outline); + padding: 0 1.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + height: 48px; + position: sticky; + top: 0; + z-index: 100; +} + +nav .brand-logo { + height: 28px; + width: auto; + vertical-align: middle; + margin-right: 8px; +} + +nav a { + font-family: var(--sans); + color: var(--on-surface-dim); + text-decoration: none; + font-size: 0.82rem; + padding: 5px 10px; + border-radius: var(--radius-pill); + transition: color 0.15s, background 0.15s; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 5px; +} + +nav a:hover { + color: var(--primary); + background: var(--state-hover); + text-decoration: none; +} + +nav .brand { + font-family: var(--sans); + font-weight: 700; + font-size: 1.1rem; + color: var(--secondary); + letter-spacing: 0.06em; + text-decoration: none; + white-space: nowrap; + margin-right: 0.5rem; +} + +nav .brand-sub { + font-weight: 400; + font-size: 0.8rem; + opacity: 0.6; + letter-spacing: 0; + margin-left: 4px; +} + +nav .spacer { flex: 1; } + +.btn-theme { + background: transparent; + border: none; + color: var(--on-surface-dim); + width: 36px; height: 36px; + border-radius: 50%; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1rem; + transition: color 0.15s; +} +.btn-theme:hover { color: var(--primary); } + +.btn-icon { + background: transparent; + border: none; + color: var(--on-surface-dim); + width: 36px; height: 36px; + border-radius: 50%; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1rem; + transition: color 0.15s; + text-decoration: none; +} +.btn-icon:hover { color: var(--primary); text-decoration: none; } + + +/* ── 4. Main & Page Wrapper ───────────────────────────── */ + +main { + flex: 1; + padding: 1rem 1.5rem 2rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 0; +} + + +/* ── 5. Breadcrumb ────────────────────────────────────── */ + +.me-breadcrumb { + display: flex; + align-items: center; + gap: 0.4rem; + font-family: var(--sans); + font-size: 0.82rem; + color: var(--on-surface-dim); + flex-wrap: wrap; +} + +.me-breadcrumb a { + color: var(--primary); + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: var(--radius-xs); + transition: background 0.15s; +} +.me-breadcrumb a:hover { background: var(--state-hover); text-decoration: none; } + +.me-breadcrumb .sep { color: var(--outline); font-size: 0.9rem; } + +.me-breadcrumb .current { + color: var(--on-surface); + font-weight: 600; +} + + +/* ── 6. Filter Bar ────────────────────────────────────── */ + +.me-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + background: var(--surface-container-low); + border: 1px solid var(--outline); + border-radius: var(--radius-md); + padding: 0.6rem 1rem; +} + +.me-filter-bar label { + font-family: var(--sans); + font-size: 0.78rem; + color: var(--on-surface-dim); + white-space: nowrap; +} + +.me-filter-bar input[type="date"], +.me-filter-bar input[type="text"] { + background: var(--surface-container-high); + border: 1px solid var(--outline); + color: var(--on-surface); + padding: 3px 8px; + border-radius: var(--radius-xs); + font-family: var(--font); + font-size: 0.82rem; + height: 30px; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + color-scheme: dark; +} +[data-theme="light"] .me-filter-bar input[type="date"], +[data-theme="light"] .me-filter-bar input[type="text"] { + color-scheme: light; +} + +.me-filter-bar input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--focus-ring); +} + +.me-filter-bar input[type="text"] { min-width: 180px; } + +.filter-sep { + width: 1px; height: 18px; + background: var(--outline); + margin: 0 2px; +} + +.me-filter-bar .spacer { flex: 1; } + + +/* ── 7. Stat Row ──────────────────────────────────────── */ + +.me-stat-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.me-stat-chip { + display: flex; + flex-direction: column; + gap: 1px; + background: var(--surface-container-low); + border: 1px solid var(--outline); + border-radius: var(--radius-md); + padding: 0.45rem 1rem; + min-width: 110px; +} + +.me-stat-chip .chip-value { + font-family: var(--sans); + font-size: 1.3rem; + font-weight: 700; + color: var(--on-surface); + line-height: 1.2; +} + +.me-stat-chip .chip-label { + font-family: var(--sans); + font-size: 0.72rem; + color: var(--on-surface-dim); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.me-stat-chip.accent-red .chip-value { color: var(--red); } +.me-stat-chip.accent-blue .chip-value { color: var(--primary); } +.me-stat-chip.accent-green .chip-value { color: var(--green); } + +.me-stat-date { + font-family: var(--sans); + font-size: 0.82rem; + color: var(--on-surface-dim); + display: flex; + align-items: center; + gap: 6px; + padding: 0 0.25rem; + align-self: center; +} + + +/* ── 8. Section Card (table / chart wrapper) ──────────── */ + +.me-section { + background: var(--surface-container-low); + border: 1px solid var(--outline); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + min-height: 0; +} + +.me-section-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: var(--surface-container-high); + border-bottom: 1px solid var(--outline); + border-radius: var(--radius-md) var(--radius-md) 0 0; + flex-shrink: 0; + flex-wrap: wrap; +} + +.me-section-header h2 { + font-family: var(--sans); + font-size: 0.9rem; + font-weight: 700; + color: var(--secondary); + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 0; + white-space: nowrap; +} + +.me-section-header .count-note { + font-family: var(--sans); + font-size: 0.78rem; + color: var(--on-surface-dim); +} + +.section-spacer { flex: 1; } + +/* Filter input inside section header */ +.me-section-header input[type="text"] { + background: var(--surface-container-highest); + border: 1px solid var(--outline); + color: var(--on-surface); + padding: 3px 8px; + border-radius: var(--radius-xs); + font-family: var(--font); + font-size: 0.82rem; + height: 28px; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + width: 180px; +} +.me-section-header input[type="text"]:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--focus-ring); +} + +.section-table-wrap { + overflow-x: auto; +} + + +/* ── 9. Tables ────────────────────────────────────────── */ + +table { + table-layout: auto; + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +thead th { + position: sticky; + top: 0; + z-index: 5; + background: var(--surface-container-high); + color: var(--on-surface-dim); + font-family: var(--sans); + font-weight: 600; + padding: 7px 12px; + text-align: left; + border-bottom: 1px solid var(--outline); + white-space: nowrap; + user-select: none; +} + +thead th.sortable { cursor: pointer; } +thead th.sortable:hover { color: var(--primary); } +thead th.sort-asc::after { content: " ↑"; color: var(--primary); } +thead th.sort-desc::after { content: " ↓"; color: var(--primary); } + +tbody tr { + border-bottom: 1px solid var(--outline-dim); + transition: background 0.1s; + cursor: pointer; +} +tbody tr:last-child { border-bottom: none; } +tbody tr:hover { background: var(--surface-container-highest); } +tbody tr.row-selected { + background: var(--surface-container-highest) !important; + box-shadow: inset 3px 0 0 var(--primary); +} + +tbody td { + padding: 5px 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 320px; +} + +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } +td.zero { color: var(--on-surface-dim); } +td.name-cell { max-width: 240px; } + +.loading-row td { + text-align: center; + color: var(--on-surface-dim); + padding: 24px; + font-style: italic; +} + + +/* ── 10. Badges ───────────────────────────────────────── */ + +.badge { + display: inline-block; + padding: 1px 7px; + border-radius: 10px; + font-family: var(--sans); + font-size: 0.73rem; + font-weight: 600; +} +.badge-green { background: var(--badge-green-bg); color: var(--green); } +.badge-red { background: var(--badge-red-bg); color: var(--red); } +.badge-yellow { background: var(--badge-yellow-bg); color: var(--yellow); } +.badge-blue { background: var(--badge-blue-bg); color: var(--primary); } +.badge-gray { background: var(--badge-gray-bg); color: var(--on-surface-dim); } + + +/* ── 11. Chart Grid ───────────────────────────────────── */ + +.me-chart-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.75rem; +} + +@media (max-width: 1100px) { + .me-chart-grid { grid-template-columns: 1fr 1fr; } +} + +@media (max-width: 720px) { + .me-chart-grid { grid-template-columns: 1fr; } +} + +.me-chart-card { + background: var(--surface-container-low); + border: 1px solid var(--outline); + border-radius: var(--radius-md); + overflow: hidden; +} + +.me-chart-title { + font-family: var(--sans); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--on-surface-dim); + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--outline); + background: var(--surface-container-high); +} + +.me-chart-body { + padding: 0.5rem; +} + +.me-chart-body .chart-empty { + font-family: var(--sans); + font-size: 0.82rem; + color: var(--on-surface-dim); + text-align: center; + padding: 2rem 1rem; + font-style: italic; +} + +/* Full-width chart (org timeline on org detail page) */ +.me-chart-full { + background: var(--surface-container-low); + border: 1px solid var(--outline); + border-radius: var(--radius-md); + overflow: hidden; +} + + +/* ── 12. Buttons ──────────────────────────────────────── */ + +.btn { + font-family: var(--sans); + background: var(--surface-container-high); + border: 1px solid var(--outline); + color: var(--on-surface); + padding: 0 14px; + border-radius: var(--radius-pill); + cursor: pointer; + font-size: 0.82rem; + height: 30px; + display: inline-flex; + align-items: center; + gap: 6px; + text-decoration: none; + transition: border-color 0.15s, color 0.15s; + white-space: nowrap; +} +.btn:hover { border-color: var(--primary); color: var(--primary); text-decoration: none; } +.btn-sm { padding: 0 10px; font-size: 0.78rem; height: 26px; } + + +/* ── 13. Student Slide Panel ──────────────────────────── */ + +.student-panel-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.35); + z-index: 599; +} +.student-panel-backdrop.open { display: block; } + +.student-panel { + position: fixed; + top: 0; right: 0; + width: 520px; + max-width: 94vw; + height: 100vh; + background: var(--surface-container-low); + border-left: 1px solid var(--outline); + transform: translateX(100%); + transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 600; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: none; +} + +.student-panel.open { + transform: translateX(0); + box-shadow: -8px 0 40px var(--panel-shadow); +} + +.student-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 10px 14px; + border-bottom: 1px solid var(--outline); + background: var(--surface-container-high); + flex-shrink: 0; +} + +.student-panel-title { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.student-panel-label { + font-family: var(--sans); + font-size: 0.75rem; + font-weight: 700; + color: var(--secondary); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.student-panel-name { + font-size: 1rem; + font-weight: 700; + color: var(--on-surface); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.student-panel-close { + background: transparent; + border: none; + color: var(--on-surface-dim); + width: 28px; height: 28px; + border-radius: 50%; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1rem; + flex-shrink: 0; + transition: color 0.15s; +} +.student-panel-close:hover { color: var(--primary); } + +.student-panel-body { + flex: 1; + overflow-y: auto; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Mini stat row inside panel */ +.student-panel-stats { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.student-panel-stat { + display: flex; + flex-direction: column; + gap: 1px; + background: var(--surface-container-high); + border: 1px solid var(--outline); + border-radius: var(--radius-sm); + padding: 0.35rem 0.75rem; + min-width: 80px; +} + +.student-panel-stat .sp-val { + font-family: var(--sans); + font-size: 1.1rem; + font-weight: 700; + color: var(--on-surface); + line-height: 1.2; +} + +.student-panel-stat .sp-lbl { + font-family: var(--sans); + font-size: 0.68rem; + color: var(--on-surface-dim); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.student-panel-stat.accent-red .sp-val { color: var(--red); } +.student-panel-stat.accent-blue .sp-val { color: var(--primary); } +.student-panel-stat.accent-green .sp-val { color: var(--green); } + +/* Ticket sections inside panel */ +.sp-ticket-section { + border-top: 1px solid var(--outline); + padding-top: 10px; +} + +.sp-ticket-section h4 { + font-family: var(--sans); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--on-surface-dim); + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; +} + +.sp-ticket-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.sp-ticket-table thead th { + background: var(--surface-container); + color: var(--on-surface-dim); + font-family: var(--sans); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 4px 8px; + border-bottom: 1px solid var(--outline); + white-space: nowrap; + cursor: default; +} + +.sp-ticket-table tbody tr { + border-bottom: 1px solid var(--outline-dim); + cursor: default; +} +.sp-ticket-table tbody tr:last-child { border-bottom: none; } +.sp-ticket-table tbody tr:hover { background: var(--surface-container); } + +.sp-ticket-table tbody td { + padding: 4px 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 260px; +} + +.ticket-id-link { + color: var(--primary); + font-weight: 600; + font-size: 0.8rem; +} +.ticket-id-link:hover { text-decoration: underline; } + +.sp-ticket-row-escalated { background: color-mix(in srgb, var(--red) 8%, transparent); } +.sp-ticket-row-escalated:hover { background: color-mix(in srgb, var(--red) 14%, transparent) !important; } + +.sp-escalated-icon { + color: var(--red); + font-size: 0.7rem; + margin-left: 4px; + vertical-align: middle; +} + +.sp-no-data { + color: var(--on-surface-dim); + font-style: italic; + font-size: 0.82rem; + padding: 0.5rem 0; +} + +/* Active row highlight when student panel is open */ +tr.student-row-active { + background: var(--surface-container-highest) !important; + box-shadow: inset 3px 0 0 var(--secondary); +} + + +/* ── 14. Ticket Slide Panel ───────────────────────────── */ + +.ticket-panel-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.35); + z-index: 699; +} +.ticket-panel-backdrop.open { display: block; } + +.ticket-panel { + position: fixed; + top: 0; right: 0; + width: 520px; + max-width: 94vw; + height: 100vh; + background: var(--surface-container-low); + border-left: 1px solid var(--outline); + transform: translateX(100%); + transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 700; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: none; +} + +.ticket-panel.open { + transform: translateX(0); + box-shadow: -8px 0 40px var(--panel-shadow); +} + +.ticket-panel-resize-handle { + position: absolute; + top: 0; left: 0; + width: 6px; + height: 100%; + cursor: ew-resize; + z-index: 10; + background: transparent; + transition: background 0.15s; +} +.ticket-panel-resize-handle:hover, +.ticket-panel-resize-handle.dragging { + background: var(--primary); + opacity: 0.35; +} + +.ticket-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 10px 14px; + border-bottom: 1px solid var(--outline); + background: var(--surface-container-high); + flex-shrink: 0; +} + +.ticket-panel-title { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.ticket-panel-id { + font-family: var(--sans); + font-size: 0.82rem; + font-weight: 700; + color: var(--secondary); + text-transform: uppercase; + letter-spacing: 0.06em; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.ticket-panel-summary { + font-size: 0.88rem; + color: var(--on-surface); + line-height: 1.4; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.ticket-panel-close { + background: transparent; + border: none; + color: var(--on-surface-dim); + width: 28px; height: 28px; + border-radius: 50%; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1rem; + flex-shrink: 0; + transition: color 0.15s; +} +.ticket-panel-close:hover { color: var(--primary); } + +.ticket-panel-body { + flex: 1; + overflow-y: auto; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Meta row */ +.ticket-meta-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ticket-mantis-link { + font-family: var(--sans); + font-size: 0.78rem; + color: var(--primary); + border: 1px solid var(--outline); + border-radius: var(--radius-pill); + padding: 2px 10px; + display: inline-flex; + align-items: center; + gap: 5px; + transition: border-color 0.15s; + text-decoration: none; +} +.ticket-mantis-link:hover { border-color: var(--primary); text-decoration: none; } + +.ticket-date { + font-family: var(--font); + font-size: 0.78rem; + color: var(--on-surface-dim); +} + +/* Field blocks */ +.ticket-field { + background: var(--surface-container-high); + border: 1px solid var(--outline); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.ticket-field-label { + font-family: var(--sans); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--on-surface-dim); + padding: 5px 10px 4px; + border-bottom: 1px solid var(--outline); + background: var(--surface-container-highest); +} + +.ticket-field-body { + padding: 8px 10px; + font-size: 0.82rem; + color: var(--on-surface); + white-space: pre-wrap; + word-break: break-word; + line-height: 1.55; + max-height: 240px; + overflow-y: auto; +} + +/* Notes */ +.ticket-notes-label { + font-family: var(--sans); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--on-surface-dim); + margin-bottom: 6px; +} + +.ticket-note { + border: 1px solid var(--outline); + border-radius: var(--radius-sm); + overflow: hidden; + margin-bottom: 8px; +} + +.ticket-note-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + background: var(--surface-container-high); + border-bottom: 1px solid var(--outline); + font-size: 0.76rem; + flex-wrap: wrap; +} + +.ticket-note-author { + font-family: var(--sans); + font-weight: 600; + color: var(--on-surface); +} + +.ticket-note-date { + font-family: var(--font); + color: var(--on-surface-dim); + font-size: 0.73rem; +} + +.ticket-note.admin-note { border-color: var(--primary); } +.ticket-note.admin-note .ticket-note-header { + background: rgba(79, 142, 247, 0.12); + border-bottom-color: var(--primary); +} + +.ticket-note-body { + padding: 7px 10px; + font-size: 0.81rem; + color: var(--on-surface); + white-space: pre-wrap; + word-break: break-word; + line-height: 1.55; + max-height: 200px; + overflow-y: auto; + background: var(--surface-container); +} + +.ticket-note.admin-note .ticket-note-body { + background: rgba(79, 142, 247, 0.05); +} + +/* IP role display */ +.ticket-ip-roles { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ip-role-group { + display: flex; + align-items: flex-start; + gap: 8px; + flex-wrap: wrap; +} + +.ip-role-label { + font-family: var(--sans); + font-size: 0.72rem; + font-weight: 600; + color: var(--on-surface-dim); + white-space: nowrap; + padding-top: 2px; + min-width: 72px; +} + +.ip-role-chips { display: flex; flex-wrap: wrap; gap: 4px; } + +.ip-chip { + font-size: 0.78rem; + padding: 1px 6px; + border-radius: var(--radius-xs); + border: 1px solid var(--outline); + background: var(--surface-container-high); + color: var(--on-surface); +} + +.ip-chip-src { border-color: rgba(248,81,73,0.4); color: var(--red); } +.ip-chip-dest { border-color: rgba(79,142,247,0.4); color: var(--primary); } +.ip-chip-unknown { border-color: var(--outline); color: var(--on-surface-dim); } + +/* Row highlight when ticket panel is open for that ticket */ +tr.sp-ticket-row { + cursor: pointer; +} +tr.sp-ticket-row-active { + background: var(--surface-container-highest) !important; + box-shadow: inset 3px 0 0 var(--primary); +} + +/* ── 16. HTMX Loading Bar ─────────────────────────────── */ + +#htmx-bar { + position: fixed; + top: 0; left: 0; right: 0; + height: 3px; + z-index: 9999; + pointer-events: none; + overflow: hidden; +} +#htmx-bar::after { + content: ''; + display: block; + height: 100%; + width: 45%; + background: var(--primary); + border-radius: 0 2px 2px 0; + transform: translateX(-120%); +} +#htmx-bar.htmx-bar-active::after { + animation: htmx-sweep 1.4s ease-in-out infinite; +} +#htmx-bar.htmx-bar-done::after { + animation: none; + transform: translateX(280%); + transition: transform 0.25s ease, opacity 0.25s ease; + opacity: 0; +} +@keyframes htmx-sweep { + 0% { transform: translateX(-120%); } + 100% { transform: translateX(280%); } +} + +.htmx-indicator { display: none; } +.htmx-request .htmx-indicator { display: inline; } + + +/* ── 17. Utilities ────────────────────────────────────── */ + +.empty-note { + color: var(--on-surface-dim); + font-style: italic; + font-size: 0.82rem; + padding: 1.5rem; + text-align: center; +} diff --git a/apps/mantis_explorer/static/pisces-logo.ico b/apps/mantis_explorer/static/pisces-logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..dd66c2316549dbe7302182a4b8cbf60f01d4f830 GIT binary patch literal 152126 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 K(*t+xf&UMKOS8WK literal 0 HcmV?d00001 diff --git a/apps/mantis_explorer/static/pisces-logo.png b/apps/mantis_explorer/static/pisces-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8980d6ae9a7c435e75e2ed59784cc6e01819fffb GIT binary patch literal 21465 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 DgZ#1D literal 0 HcmV?d00001 diff --git a/apps/mantis_explorer/templates/base.html b/apps/mantis_explorer/templates/base.html new file mode 100644 index 0000000..2ca56bc --- /dev/null +++ b/apps/mantis_explorer/templates/base.html @@ -0,0 +1,315 @@ + + + + + + {% block title %}Mantis Explorer{% endblock title %} + + + + + + + + + +
+ + + +
+{% block content %}{% endblock content %} +
+ +{# ── Ticket slide panel ───────────────────────── #} +
+ + +{# ── Student slide panel ──────────────────────── #} +
+ + + + +{% block extra_scripts %}{% endblock extra_scripts %} + + + diff --git a/apps/mantis_explorer/templates/index.html b/apps/mantis_explorer/templates/index.html new file mode 100644 index 0000000..9467e65 --- /dev/null +++ b/apps/mantis_explorer/templates/index.html @@ -0,0 +1,155 @@ +{% extends "base.html" %} +{% block title %}Mantis Explorer — Overview{% endblock title %} + +{% block content %} + +{# Hidden filter form — included by all HTMX regions #} +
+ + +
+ +{# ── Filter bar ─────────────────────────────────────────── #} +
+ + + + + +
+ +{# ── Stat row ───────────────────────────────────────────── #} +
+
+
Loading…
+
+
+ +{# ── Institution table ──────────────────────────────────── #} +
+
+

Institutions

+ + + {# Hidden sort state #} + + +
+
+ + + + + + + + + + + + + + +
InstitutionStudentsTicketsEscalatedNotesDate Range
Loading…
+
+
+ +{# ── Chart grid ─────────────────────────────────────────── #} +
+ + {# Orgs bar #} +
+
Tickets by Institution
+
Loading…
+
+ + {# Timeline #} +
+
Submissions Over Time
+
Loading…
+
+ + {# Escalation rate #} +
+
Escalations by Institution
+
Loading…
+
+ +
+ +{% endblock content %} + +{% block extra_scripts %} + +{% endblock extra_scripts %} diff --git a/apps/mantis_explorer/templates/org.html b/apps/mantis_explorer/templates/org.html new file mode 100644 index 0000000..b823922 --- /dev/null +++ b/apps/mantis_explorer/templates/org.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} +{% block title %}{{ org_name }} — Mantis Explorer{% endblock title %} + +{% block content %} + +{# Hidden filter form — included by all HTMX regions #} +
+ + +
+ +{# Hidden state for student table sort + slug #} + + + + +{# ── Breadcrumb ─────────────────────────────────────────── #} + + +{# ── Filter bar ─────────────────────────────────────────── #} +
+ + + + + +
+ +{# ── Stat row ───────────────────────────────────────────── #} +
+
+
Loading…
+
+
+ +{# ── Org timeline chart — full width ────────────────────── #} +
+
Submission Timeline
+
Loading…
+
+ +{# ── Student table ──────────────────────────────────────── #} +
+
+

Students

+ + +
+
+ + + + + + + + + + + + + + +
NameTicketsEscalatedNotesTotal ActivityDate Range
Loading…
+
+
+ +{% endblock content %} + +{% block extra_scripts %} + +{% endblock extra_scripts %} diff --git a/apps/mantis_explorer/templates/partials/chart_escalation.html b/apps/mantis_explorer/templates/partials/chart_escalation.html new file mode 100644 index 0000000..2ac6598 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/chart_escalation.html @@ -0,0 +1,59 @@ +
Escalations by Institution
+
+{% if esc.orgs %} +
+ +{% else %} +
No data available.
+{% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/chart_orgs_bar.html b/apps/mantis_explorer/templates/partials/chart_orgs_bar.html new file mode 100644 index 0000000..d67a420 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/chart_orgs_bar.html @@ -0,0 +1,38 @@ +
Tickets by Institution
+
+{% if rows %} +
+ +{% else %} +
No data available.
+{% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/chart_timeline.html b/apps/mantis_explorer/templates/partials/chart_timeline.html new file mode 100644 index 0000000..513da52 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/chart_timeline.html @@ -0,0 +1,45 @@ +
Submissions Over Time
+
+{% if timeline.labels %} +
+ +{% else %} +
No dated tickets in range.
+{% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/org_stat_row.html b/apps/mantis_explorer/templates/partials/org_stat_row.html new file mode 100644 index 0000000..855bafb --- /dev/null +++ b/apps/mantis_explorer/templates/partials/org_stat_row.html @@ -0,0 +1,24 @@ +
+
+ {{ stats.student_count }} + Students +
+
+ {{ stats.ticket_count }} + Tickets +
+
+ {{ stats.escalation_count }} + Escalated +
+
+ {{ stats.note_count }} + Notes +
+ {% if stats.date_range %} +
+ + {{ stats.date_range }} +
+ {% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/org_table.html b/apps/mantis_explorer/templates/partials/org_table.html new file mode 100644 index 0000000..686d6ae --- /dev/null +++ b/apps/mantis_explorer/templates/partials/org_table.html @@ -0,0 +1,18 @@ +{% if rows %} +{% for row in rows %} + + {{ row.name }} + {{ row.student_count }} + {{ row.ticket_count }} + + {{ row.escalation_count }} + + + {{ row.note_count }} + + {{ row.date_range or '—' }} + +{% endfor %} +{% else %} +No institutions found. +{% endif %} diff --git a/apps/mantis_explorer/templates/partials/org_timeline.html b/apps/mantis_explorer/templates/partials/org_timeline.html new file mode 100644 index 0000000..6bf3b26 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/org_timeline.html @@ -0,0 +1,39 @@ +
+ + Submission Timeline — {{ org_name }} +
+
+{% if timeline.labels %} +
+ +{% else %} +
No dated tickets in range.
+{% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/stat_row.html b/apps/mantis_explorer/templates/partials/stat_row.html new file mode 100644 index 0000000..4428582 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/stat_row.html @@ -0,0 +1,28 @@ +
+
+ {{ stats.institution_count }} + Institutions +
+
+ {{ stats.student_count }} + Students +
+
+ {{ stats.ticket_count }} + Tickets +
+
+ {{ stats.escalation_count }} + Escalated +
+
+ {{ stats.note_count }} + Notes +
+ {% if stats.date_range %} +
+ + {{ stats.date_range }} +
+ {% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/student_panel.html b/apps/mantis_explorer/templates/partials/student_panel.html new file mode 100644 index 0000000..aa1888c --- /dev/null +++ b/apps/mantis_explorer/templates/partials/student_panel.html @@ -0,0 +1,110 @@ +{# Student detail panel body — loaded via HTMX into #student-panel-content #} + +{# ── Mini stat row ─────────────────────────────────────── #} +
+
+ {{ student.tickets_created }} + Created +
+
+ {{ student.escalated_tickets }} + Escalated +
+
+ {{ student.notes_written }} + Notes +
+
+ {{ student.total_activity }} + Total +
+
+ +{# ── Tickets created ────────────────────────────────────── #} +
+

+ + Tickets Created + + ({{ student.created_tickets | length }}) + +

+ + {% if student.created_tickets %} + + + + + + + + + + + {% for t in student.created_tickets %} + + + + + + + {% endfor %} + +
IDSummaryStatusCreated
+ #{{ t.id }} + {% if t.is_escalated %} + + {% endif %} + {{ t.summary or '—' }}{{ t.status or '—' }} + {{ (t.created_at or '')[:10] or '—' }} +
+ {% else %} +

No tickets created.

+ {% endif %} +
+ +{# ── Tickets commented on ───────────────────────────────── #} +
+

+ + Tickets Commented On + + ({{ student.noted_tickets | length }}) + +

+ + {% if student.noted_tickets %} + + + + + + + + + + {% for t in student.noted_tickets %} + + + + + + {% endfor %} + +
IDSummaryStatus
#{{ t.id }}{{ t.summary or '—' }}{{ t.status or '—' }}
+ {% else %} +

No other tickets commented on.

+ {% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/student_rows.html b/apps/mantis_explorer/templates/partials/student_rows.html new file mode 100644 index 0000000..db17955 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/student_rows.html @@ -0,0 +1,20 @@ +{% if rows %} +{% for row in rows %} + + {{ row.name }} + {{ row.tickets_created }} + + {{ row.escalated_tickets }} + + + {{ row.notes_written }} + + {{ row.total_activity }} + {{ row.date_range or '—' }} + +{% endfor %} +{% else %} +No students found. +{% endif %} diff --git a/apps/mantis_explorer/templates/partials/ticket_detail.html b/apps/mantis_explorer/templates/partials/ticket_detail.html new file mode 100644 index 0000000..c0fd726 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/ticket_detail.html @@ -0,0 +1,201 @@ +{# Full ticket detail rendered into the slide panel body #} + +{# Meta row — status, severity, project, date #} +
+ {% set status = t.status or '' %} + {% if status in ('resolved', 'closed') %} + {{ status }} + {% elif status in ('new', 'acknowledged') %} + {{ status }} + {% else %} + {{ status or '—' }} + {% endif %} + + {% set sev = (t.severity or '')|lower %} + {% if sev == 'critical' %} + {{ t.severity }} + {% elif sev == 'major' %} + {{ t.severity }} + {% elif sev == 'minor' %} + {{ t.severity }} + {% else %} + {{ t.severity or '—' }} + {% endif %} + + {% if t.priority %} + {{ t.priority }} + {% endif %} + + {% if t.project %} + + {{ t.project }} + + {% endif %} + + {% if t.is_escalated %} + + Escalated + {% if t.escalated_by %} · {{ t.escalated_by }}{% endif %} + + {% endif %} + + {{ (t.created_at or '')[:10] }} + + {% if t.url %} + + Open in Mantis + + {% endif %} +
+ +{# Reporter / handler #} +{% set reporter_name = (t.reporter or {}).get('name') or (t.reporter if t.reporter is string else None) %} +{% set handler_name = (t.handler or {}).get('name') or (t.handler if t.handler is string else None) %} +{% if reporter_name or handler_name %} +
+ {% if reporter_name %} + Reporter: {{ reporter_name }} + {% endif %} + {% if handler_name %} + Handler: {{ handler_name }} + {% endif %} +
+{% endif %} + +{# IP roles — use pre-computed fields when available, fall back to flat list #} +{% set has_roles = t.ip_src is not none or t.ip_dest is not none or t.ip_unknown is not none %} +{% if has_roles %} +
+ {% if t.ip_src %} +
+ + Source + +
+ {% for ip in (t.ip_src or []) %} + {{ ip }} + {% endfor %} +
+
+ {% endif %} + {% if t.ip_dest %} +
+ + Destination + +
+ {% for ip in (t.ip_dest or []) %} + {% set is_private = ip.startswith('10.') or ip.startswith('192.168.') or ip.startswith('172.') %} + {% if is_private %}{% endif %}{{ ip }} + {% endfor %} +
+
+ {% endif %} + {% if t.ip_unknown %} +
+ + Unlabelled + +
+ {% for ip in (t.ip_unknown or []) %} + {% set is_private = ip.startswith('10.') or ip.startswith('192.168.') or ip.startswith('172.') %} + {% if is_private %}{% endif %}{{ ip }} + {% endfor %} +
+
+ {% endif %} + {% if not t.ip_src and not t.ip_dest and not t.ip_unknown %} + No IPs extracted. + {% endif %} +
+{% elif t.ips %} +
+ IPs: + {% for ip in (t.ips or [])[:20] %} + {{ ip }} + {% endfor %} + {% if (t.ips or [])|length > 20 %} + +{{ (t.ips|length) - 20 }} more + {% endif %} +
+{% endif %} + +{# Description #} +{% if t.description and t.description.strip() %} +
+
Description
+
{{ t.description.strip() }}
+
+{% endif %} + +{# Steps to Reproduce #} +{% if t.steps_to_reproduce and t.steps_to_reproduce.strip() %} +
+
Steps to Reproduce
+
{{ t.steps_to_reproduce.strip() }}
+
+{% endif %} + +{# Additional Information #} +{% if t.additional_information and t.additional_information.strip() %} +
+
Additional Information
+
{{ t.additional_information.strip() }}
+
+{% endif %} + +{# External links #} +{% set all_links = ((t.dashboard_links or []) + (t.ti_links or [])) %} +{% if all_links %} + +{% endif %} + +{# Notes / Comments #} +{% set notes = t.notes or [] %} +{% if notes %} +
+
+ + Comments ({{ notes|length }}) +
+ {% for note in notes %} + {% set is_admin = note.get('is_admin_note', False) %} + {% set note_reporter = (note.get('reporter') or {}).get('name') or 'Unknown' %} + {% set note_text = (note.get('text') or '').strip() %} + {% if note_text %} +
+
+ {% if is_admin %} + + Admin + + {% endif %} + {{ note_reporter }} + {% set note_date = note.get('date_submitted') or note.get('created_at') or '' %} + {% if note_date %} + {{ note_date[:10] }} + {% endif %} +
+
{{ note_text }}
+
+ {% endif %} + {% endfor %} +
+{% endif %} + +{% if not t.description and not t.steps_to_reproduce and not t.additional_information and not (t.notes or []) %} +

No additional details available for this ticket.

+{% endif %} From 5b90f0fdaf39a381ee2cd4e2235b7739e1b6df36 Mon Sep 17 00:00:00 2001 From: liamadale Date: Wed, 29 Apr 2026 09:58:27 -0700 Subject: [PATCH 08/49] feat(hub): register mantis-explorer in run_all.py and hub landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mount the new mantis-explorer app at /mantis-explorer in the DispatcherMiddleware and add a hub card linking to it with a fa-user-graduate icon. Also bumps locked dependency versions: rich 14→15, ruff 0.15.6→0.15.12, pre-commit 4.5.1→4.6.0, geoip2 >=4.8.0→>=5.2.0, pytest >=9.0.3. --- apps/hub/templates/index.html | 5 +++ run_all.py | 4 ++- uv.lock | 64 +++++++++++++++++------------------ 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/apps/hub/templates/index.html b/apps/hub/templates/index.html index d75381f..cebb391 100644 --- a/apps/hub/templates/index.html +++ b/apps/hub/templates/index.html @@ -104,6 +104,11 @@

PISCES Security Dashboard

Dashboard
Analytics and visualizations across all data sources.
+ +
+
Mantis Explorer
+
Student activity by institution. Tickets, escalations, and notes with date-range filtering.
+
+{% endif %} From 2ab3d2ad6976ad96cf73a76e60916a4d36a8ed7c Mon Sep 17 00:00:00 2001 From: liamadale Date: Wed, 29 Apr 2026 20:26:09 -0700 Subject: [PATCH 19/49] feat(dashboard): wire sensor filter and date controls into toolbar Register the tickets Blueprint in app.py and add the /api/dashboard/sensors route that serves sensor_summary.html content. In dashboard.html: add the Tickets tab button, sensor-badge button with JS state (_selectedSensors, getSensorParam), date picker row that appears for Mantis and Tickets tabs, and openSensorModal / applySensorSelection helpers. In base.html: add sensor-modal markup (backdrop + panel), CSS for .sensor-badge and #sensor-btn, pill-style sub-tab bar, uniform design tokens (--radius-*, height:28px inputs, rounded toolbar), and dark/light color-scheme for native date inputs. --- apps/dashboard_web/app.py | 22 ++++- apps/dashboard_web/templates/base.html | 99 ++++++++++++++++----- apps/dashboard_web/templates/dashboard.html | 94 ++++++++++++++++++- 3 files changed, 190 insertions(+), 25 deletions(-) diff --git a/apps/dashboard_web/app.py b/apps/dashboard_web/app.py index 23ec604..e8144b1 100644 --- a/apps/dashboard_web/app.py +++ b/apps/dashboard_web/app.py @@ -41,11 +41,31 @@ def api_cache_clear(): dcache.invalidate() return "", 204 + @app.route("/api/dashboard/sensors") + def api_sensor_summary(): + """Sensor browser modal content — terms agg on host.name.""" + from apps.dashboard_web.opensearch.aggregations import agg_opensearch_sensors + + time_range = request.args.get("time_range", "now-24h") + data = agg_opensearch_sensors(time_range) + # Build bucket-like dicts matching the sensor_summary.html template + buckets = [ + {"key": label, "doc_count": count} + for label, count in zip(data["labels"], data["counts"]) + ] + current = [ + s.strip() + for s in request.args.get("sensor", "").split(",") + if s.strip() and s.strip().lower() != "all" + ] + return render_template("sensor_summary.html", buckets=buckets, current_sensors=current) + from apps.dashboard_web.mantis import bp as mantis_bp from apps.dashboard_web.opensearch import bp as opensearch_bp from apps.dashboard_web.overview import bp as overview_bp + from apps.dashboard_web.tickets import bp as tickets_bp - for bp in [overview_bp, opensearch_bp, mantis_bp]: + for bp in [overview_bp, opensearch_bp, mantis_bp, tickets_bp]: app.register_blueprint(bp) return app diff --git a/apps/dashboard_web/templates/base.html b/apps/dashboard_web/templates/base.html index 798a133..0051f6a 100644 --- a/apps/dashboard_web/templates/base.html +++ b/apps/dashboard_web/templates/base.html @@ -24,10 +24,12 @@ .dash-toolbar { display: flex; align-items: center; - gap: 1rem; - padding: 0.75rem 1.25rem; + gap: 0.75rem; + padding: 0.6rem 1rem; + margin: 1rem 1.25rem 0; background: var(--surface-container-low); - border-bottom: 1px solid var(--outline); + border: 1px solid var(--outline); + border-radius: var(--radius-md); flex-wrap: wrap; } .tab-bar { @@ -36,13 +38,13 @@ flex: 1; } .tab-btn { - padding: 0.45rem 1rem; + padding: 0.4rem 0.85rem; border: 1px solid var(--outline); - border-radius: 6px; + border-radius: var(--radius-sm); background: transparent; color: var(--on-surface-dim); cursor: pointer; - font-size: 0.85rem; + font-size: 0.82rem; transition: background 0.15s, color 0.15s, border-color 0.15s; } .tab-btn:hover { @@ -59,41 +61,64 @@ align-items: center; gap: 0.5rem; color: var(--on-surface-dim); - font-size: 0.85rem; + font-size: 0.82rem; } - .dash-controls select { - background: var(--surface-container); + .dash-controls label { + font-size: 0.78rem; + color: var(--on-surface-dim); + white-space: nowrap; + } + .dash-controls select, + .dash-controls input[type="date"] { + background: var(--surface-container-high); color: var(--on-surface); border: 1px solid var(--outline); - border-radius: 4px; - padding: 0.3rem 0.5rem; - font-size: 0.85rem; + border-radius: var(--radius-xs); + padding: 3px 7px; + font-size: 0.82rem; + height: 28px; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + color-scheme: dark; + } + [data-theme="light"] .dash-controls select, + [data-theme="light"] .dash-controls input[type="date"] { + color-scheme: light; + } + .dash-controls select:focus, + .dash-controls input[type="date"]:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--focus-ring); } .tab-panel { - padding: 1.25rem; + padding: 1rem 1.25rem; } .sub-tab-bar { - display: flex; + display: inline-flex; gap: 0.25rem; + background: var(--surface-container-low); + border: 1px solid var(--outline); + border-radius: var(--radius-pill); + padding: 0.2rem; } .sub-tab-btn { - padding: 0.35rem 0.85rem; - border: 1px solid var(--outline); - border-radius: 5px; + padding: 0.3rem 0.9rem; + border: none; + border-radius: var(--radius-pill); background: transparent; color: var(--on-surface-dim); cursor: pointer; - font-size: 0.8rem; - transition: background 0.15s, color 0.15s, border-color 0.15s; + font-size: 0.78rem; + font-weight: 500; + transition: background 0.15s, color 0.15s; } .sub-tab-btn:hover { background: var(--state-hover); color: var(--on-surface); } .sub-tab-btn.active { - background: var(--secondary); - border-color: var(--secondary); - color: #0d0f14; + background: var(--primary); + color: #fff; } .loading-msg { color: var(--on-surface-dim); @@ -194,6 +219,21 @@ .ip-table tr:last-child td { border-bottom: none; } .ip-table tr:hover td { background: var(--state-hover); } .ip-mono { font-family: monospace; } + + /* ── Sensor badge ──────────────────────────────────── */ + .sensor-badge { + position: absolute; + top: -4px; right: -4px; + background: var(--primary); + color: #fff; + font-size: 0.6rem; + font-weight: 700; + min-width: 14px; height: 14px; + border-radius: 7px; + display: flex; align-items: center; justify-content: center; + padding: 0 3px; + } + #sensor-btn { position: relative; overflow: visible; } @@ -216,6 +256,21 @@ {% block content %}{% endblock content %} + + + + + * (the IIFE at the top applies the saved theme before CSS loads) + * + * : + */ + +/* Flash-prevention: apply saved theme immediately */ +(function () { + var t = localStorage.getItem('pisces-theme') || 'dark'; + document.documentElement.setAttribute('data-theme', t); +})(); + +/* Toggle between dark and light */ +window.toggleTheme = function () { + var html = document.documentElement; + var next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; + html.setAttribute('data-theme', next); + localStorage.setItem('pisces-theme', next); + updateThemeIcon(next); +}; + +/* Sync the icon to match the current theme */ +function updateThemeIcon(theme) { + var icon = document.getElementById('theme-icon'); + if (!icon) return; + icon.className = theme === 'dark' ? 'fa-solid fa-moon' : 'fa-solid fa-sun'; +} + +/* Set the correct icon on first load */ +updateThemeIcon(document.documentElement.getAttribute('data-theme') || 'dark'); diff --git a/apps/shared/static/tokens.css b/apps/shared/static/tokens.css new file mode 100644 index 0000000..3efbaab --- /dev/null +++ b/apps/shared/static/tokens.css @@ -0,0 +1,131 @@ +/* ================================================================ + PISCES Design Tokens — Single source of truth + Imported by all web apps before their own stylesheet. + ================================================================ */ + +/* ── Dark theme (default) ─────────────────────────────── */ + +[data-theme="dark"] { + /* MD3 surfaces */ + --surface: #0d0f14; + --surface-container-low: #161a24; + --surface-container: #1a1f2e; + --surface-container-high: #1e2433; + --surface-container-highest:#232840; + + /* Outline */ + --outline: #2a3045; + --outline-dim: #1e2233; + + /* Text */ + --on-surface: #c8ccd8; + --on-surface-dim: #5a6278; + + /* Primary */ + --primary: #4f8ef7; + --primary-dim: #3a7ae8; + + /* Secondary / Tertiary */ + --secondary: #7ec8e3; + --tertiary: #bc8cff; + + /* Semantic */ + --green: #3fb950; + --yellow: #d29922; + --red: #f85149; + --orange: #e3763c; + --purple: #bc8cff; + + /* State layers */ + --state-hover: rgba(79,142,247,0.08); + --state-press: rgba(79,142,247,0.12); + + /* Badges */ + --badge-green-bg: rgba(63,185,80,0.2); + --badge-red-bg: rgba(248,81,73,0.2); + --badge-yellow-bg: rgba(210,153,34,0.2); + --badge-blue-bg: rgba(79,142,247,0.2); + --badge-gray-bg: rgba(90,98,120,0.3); + + /* Focus ring */ + --focus-ring: rgba(79,142,247,0.25); + + /* Sidebar active link (used by OpenSearch) */ + --primary-container: rgba(79,142,247,0.15); + --on-primary-container: var(--primary); + + /* Elevation / overlay */ + --panel-shadow: rgba(0,0,0,0.45); + --modal-backdrop: rgba(0,0,0,0.55); +} + +/* ── Light theme ──────────────────────────────────────── */ + +[data-theme="light"] { + --surface: #f8f9fc; + --surface-container-low: #f0f1f5; + --surface-container: #e8eaef; + --surface-container-high: #e1e3e9; + --surface-container-highest:#d8dbe2; + + --outline: #c4c8d4; + --outline-dim: #dcdfe6; + + --on-surface: #1a1c24; + --on-surface-dim: #5c6070; + + --primary: #2563eb; + --primary-dim: #1d4fd8; + + --secondary: #0d7490; + --tertiary: #7c3aed; + + --green: #16a34a; + --yellow: #ca8a04; + --red: #dc2626; + --orange: #ea580c; + --purple: #7c3aed; + + --state-hover: rgba(37,99,235,0.06); + --state-press: rgba(37,99,235,0.10); + + --badge-green-bg: rgba(22,163,74,0.12); + --badge-red-bg: rgba(220,38,38,0.12); + --badge-yellow-bg: rgba(202,138,4,0.12); + --badge-blue-bg: rgba(37,99,235,0.10); + --badge-gray-bg: rgba(92,96,112,0.12); + + --focus-ring: rgba(37,99,235,0.25); + + --primary-container: rgba(37,99,235,0.12); + --on-primary-container: var(--primary); + + --panel-shadow: rgba(0,0,0,0.15); + --modal-backdrop: rgba(0,0,0,0.35); +} + +/* ── Legacy aliases + shape + typography ──────────────── */ + +:root { + --bg: var(--surface); + --bg2: var(--surface-container-low); + --bg3: var(--surface-container-high); + --surface-1: var(--surface-container-low); + --surface-2: var(--surface-container-high); + --surface-3: var(--surface-container-highest); + --text: var(--on-surface); + --text-dim: var(--on-surface-dim); + --border: var(--outline); + --accent: var(--primary); + --accent2: var(--secondary); + + /* Shape */ + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-pill: 28px; + + /* Typography */ + --font: "JetBrains Mono","Fira Mono","Courier New",monospace; + --sans: system-ui,-apple-system,sans-serif; +} From 326b094d1f1f1ff3e0613b3b832bf377f25657d8 Mon Sep 17 00:00:00 2001 From: liamadale Date: Wed, 29 Apr 2026 21:58:15 -0700 Subject: [PATCH 34/49] refactor(web): migrate all apps to shared static assets, remove duplicate files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five apps (hub, opensearch_web, mantis_explorer, threat_model, dashboard_web) now register make_shared_static_blueprint() so they serve tokens.css, base.css, theme.js, and logos from /shared/static/. Per-app changes: - app.py: register shared_static blueprint - templates: favicon, logo, and theme script tags point to /shared/static/; inline theme-flash-prevention IIFE and toggleTheme() removed (now in theme.js) - CSS: tokens, CSS custom properties, reset, and base component styles stripped — each file now contains only app-specific overrides Ten duplicate logo files deleted (~870 KB saved): apps/{hub,opensearch_web,mantis_explorer,threat_model,dashboard_web}/static/pisces-logo.{ico,png} --- apps/dashboard_web/app.py | 4 + apps/dashboard_web/static/pisces-logo.ico | Bin 152126 -> 0 bytes apps/dashboard_web/static/pisces-logo.png | Bin 21465 -> 0 bytes apps/dashboard_web/static/pisces.css | 386 +------------------- apps/dashboard_web/templates/base.html | 31 +- apps/hub/app.py | 4 + apps/hub/static/pisces-logo.ico | Bin 152126 -> 0 bytes apps/hub/static/pisces-logo.png | Bin 21465 -> 0 bytes apps/hub/static/pisces.css | 386 +------------------- apps/hub/templates/index.html | 28 +- apps/mantis_explorer/app.py | 4 + apps/mantis_explorer/static/me.css | 147 +------- apps/mantis_explorer/static/pisces-logo.ico | Bin 152126 -> 0 bytes apps/mantis_explorer/static/pisces-logo.png | Bin 21465 -> 0 bytes apps/mantis_explorer/templates/base.html | 30 +- apps/opensearch_web/app.py | 2 + apps/opensearch_web/static/pisces-logo.ico | Bin 152126 -> 0 bytes apps/opensearch_web/static/pisces-logo.png | Bin 21465 -> 0 bytes apps/opensearch_web/static/pisces.css | 335 +---------------- apps/opensearch_web/templates/base.html | 38 +- apps/threat_model/app.py | 4 + apps/threat_model/static/pisces-logo.ico | Bin 152126 -> 0 bytes apps/threat_model/static/pisces-logo.png | Bin 21465 -> 0 bytes apps/threat_model/static/tm.css | 136 +------ apps/threat_model/templates/base.html | 30 +- 25 files changed, 99 insertions(+), 1466 deletions(-) delete mode 100644 apps/dashboard_web/static/pisces-logo.ico delete mode 100644 apps/dashboard_web/static/pisces-logo.png delete mode 100644 apps/hub/static/pisces-logo.ico delete mode 100644 apps/hub/static/pisces-logo.png delete mode 100644 apps/mantis_explorer/static/pisces-logo.ico delete mode 100644 apps/mantis_explorer/static/pisces-logo.png delete mode 100644 apps/opensearch_web/static/pisces-logo.ico delete mode 100644 apps/opensearch_web/static/pisces-logo.png delete mode 100644 apps/threat_model/static/pisces-logo.ico delete mode 100644 apps/threat_model/static/pisces-logo.png diff --git a/apps/dashboard_web/app.py b/apps/dashboard_web/app.py index e8144b1..8b77813 100644 --- a/apps/dashboard_web/app.py +++ b/apps/dashboard_web/app.py @@ -68,4 +68,8 @@ def api_sensor_summary(): for bp in [overview_bp, opensearch_bp, mantis_bp, tickets_bp]: app.register_blueprint(bp) + from apps.shared.blueprints import make_shared_static_blueprint + + app.register_blueprint(make_shared_static_blueprint()) + return app diff --git a/apps/dashboard_web/static/pisces-logo.ico b/apps/dashboard_web/static/pisces-logo.ico deleted file mode 100644 index dd66c2316549dbe7302182a4b8cbf60f01d4f830..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 152126 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 K(*t+xf&UMKOS8WK diff --git a/apps/dashboard_web/static/pisces-logo.png b/apps/dashboard_web/static/pisces-logo.png deleted file mode 100644 index 8980d6ae9a7c435e75e2ed59784cc6e01819fffb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21465 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 DgZ#1D diff --git a/apps/dashboard_web/static/pisces.css b/apps/dashboard_web/static/pisces.css index a03c15f..c0b215c 100644 --- a/apps/dashboard_web/static/pisces.css +++ b/apps/dashboard_web/static/pisces.css @@ -1,167 +1,17 @@ /* ================================================================ - PISCES Web UI — Material Design 3 Dual-Theme SOC Analyst Console + PISCES Hub & Dashboard — App-specific styles + Requires: /shared/static/tokens.css, /shared/static/base.css ================================================================ */ -/* ── 1. Design Tokens ─────────────────────────────────── */ - -/* Dark theme (default) */ -[data-theme="dark"] { - /* MD3 surfaces (tonal elevation via colour, not shadow) */ - --surface: #0d0f14; - --surface-container-low: #161a24; - --surface-container: #1a1f2e; - --surface-container-high: #1e2433; - --surface-container-highest:#232840; - - /* Outline */ - --outline: #2a3045; - --outline-dim: #1e2233; - - /* Text */ - --on-surface: #c8ccd8; - --on-surface-dim: #5a6278; - - /* Primary */ - --primary: #4f8ef7; - --primary-dim: #3a7ae8; - - /* Secondary / Tertiary */ - --secondary: #7ec8e3; - --tertiary: #bc8cff; - - /* Semantic */ - --green: #3fb950; --yellow: #d29922; - --red: #f85149; --orange: #e3763c; --purple: #bc8cff; - - /* State layers */ - --state-hover: rgba(79,142,247,0.08); - --state-press: rgba(79,142,247,0.12); - - /* Badges (theme-aware) */ - --badge-green-bg: rgba(63,185,80,0.2); - --badge-red-bg: rgba(248,81,73,0.2); - --badge-yellow-bg: rgba(210,153,34,0.2); - --badge-blue-bg: rgba(79,142,247,0.2); - --badge-gray-bg: rgba(90,98,120,0.3); - - /* Focus ring */ - --focus-ring: rgba(79,142,247,0.25); - - /* Detail panel shadow */ - --panel-shadow: rgba(0,0,0,0.45); - --modal-backdrop: rgba(0,0,0,0.55); -} - -/* Light theme */ -[data-theme="light"] { - --surface: #f8f9fc; - --surface-container-low: #f0f1f5; - --surface-container: #e8eaef; - --surface-container-high: #e1e3e9; - --surface-container-highest:#d8dbe2; - - --outline: #c4c8d4; - --outline-dim: #dcdfe6; - - --on-surface: #1a1c24; - --on-surface-dim: #5c6070; - - --primary: #2563eb; - --primary-dim: #1d4fd8; - - --secondary: #0d7490; - --tertiary: #7c3aed; - - --green: #16a34a; --yellow: #ca8a04; - --red: #dc2626; --orange: #ea580c; --purple: #7c3aed; - - --state-hover: rgba(37,99,235,0.06); - --state-press: rgba(37,99,235,0.10); - - --badge-green-bg: rgba(22,163,74,0.12); - --badge-red-bg: rgba(220,38,38,0.12); - --badge-yellow-bg: rgba(202,138,4,0.12); - --badge-blue-bg: rgba(37,99,235,0.10); - --badge-gray-bg: rgba(92,96,112,0.12); - - --focus-ring: rgba(37,99,235,0.25); - - --panel-shadow: rgba(0,0,0,0.15); - --modal-backdrop: rgba(0,0,0,0.35); -} - -/* Legacy aliases — zero-breakage bridge from old var names */ -:root { - --bg: var(--surface); - --bg2: var(--surface-container-low); - --bg3: var(--surface-container-high); - --surface-1: var(--surface-container-low); - --surface-2: var(--surface-container-high); - --surface-3: var(--surface-container-highest); - --text: var(--on-surface); - --text-dim: var(--on-surface-dim); - --border: var(--outline); - --accent: var(--primary); - --accent2: var(--secondary); - - /* Shape */ - --radius-xs: 4px; - --radius-sm: 8px; - --radius-md: 12px; - --radius-pill: 28px; - - /* Typography */ - --font: "JetBrains Mono","Fira Mono","Courier New",monospace; - --sans: system-ui,-apple-system,sans-serif; -} - - -/* ── 2. Reset & Base ──────────────────────────────────── */ - -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - -html { font-size: 14px; } +/* ── Layout ───────────────────────────────────────────── */ body { - background: var(--surface); - color: var(--on-surface); - font-family: var(--font); height: 100vh; overflow: hidden; display: flex; flex-direction: column; - line-height: 1.5; -} - -h1 { - font-family: var(--sans); - font-size: 1.2rem; - font-weight: 600; - color: var(--secondary); - margin-bottom: 0.3rem; } -h2 { - font-family: var(--sans); - font-size: 1rem; - font-weight: 600; - color: var(--on-surface); - margin-bottom: 0.5rem; -} - -.subtitle { - font-size: 0.8rem; - color: var(--on-surface-dim); - margin-bottom: 1rem; -} - -a { color: var(--primary); text-decoration: none; } -a:hover { text-decoration: underline; } -.ip-link { color: var(--secondary); font-weight: 600; } - - -/* ── 3. Layout ────────────────────────────────────────── */ - /* Nav */ nav { flex-shrink: 0; @@ -283,7 +133,6 @@ main { padding: 1.5rem; } -/* Table-fill mode: header/subtitle stay pinned, only the table scrolls */ main.table-fill { overflow-y: hidden; display: flex; @@ -296,125 +145,7 @@ main.table-fill > .table-wrap { } -/* ── 4. Buttons ───────────────────────────────────────── */ - -.btn { - position: relative; - font-family: var(--sans); - background: var(--surface-container-high); - border: 1px solid var(--outline); - color: var(--on-surface); - padding: 0 14px; - border-radius: var(--radius-pill); - cursor: pointer; - font-size: 0.82rem; - height: 32px; - text-decoration: none; - display: inline-flex; - align-items: center; - gap: 6px; - overflow: hidden; - transition: border-color 0.15s, color 0.15s; -} - -.btn::before { - content: ''; - position: absolute; - inset: 0; - background: transparent; - transition: background 0.15s; - pointer-events: none; -} - -.btn:hover::before { background: var(--state-hover); } -.btn:active::before { background: var(--state-press); } -.btn:hover { border-color: var(--primary); color: var(--primary); } - -.btn:disabled { - opacity: 0.4; - cursor: not-allowed; - pointer-events: none; -} - -.btn-primary { - background: var(--primary); - border-color: var(--primary); - color: #fff; -} -.btn-primary:hover { background: var(--primary-dim); border-color: var(--primary-dim); color: #fff; } -.btn-primary:hover::before { background: transparent; } - -.btn-sm { padding: 0 10px; font-size: 0.78rem; height: 26px; } - -/* Icon-only circle button */ -.btn-icon { - position: relative; - background: transparent; - border: none; - color: var(--on-surface-dim); - width: 28px; - height: 28px; - border-radius: 50%; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - overflow: hidden; - transition: color 0.15s; -} - -.btn-icon::before { - content: ''; - position: absolute; - inset: 0; - border-radius: 50%; - background: transparent; - transition: background 0.15s; - pointer-events: none; -} - -.btn-icon:hover::before { background: var(--state-hover); } -.btn-icon:active::before { background: var(--state-press); } -.btn-icon:hover { color: var(--primary); } - -/* Chevron rotates when expand button is active */ -.btn-icon.active { color: var(--primary); } -.btn-icon .fa-chevron-down { transition: transform 0.2s ease; display: block; } -.btn-icon.active .fa-chevron-down { transform: rotate(180deg); } - -/* Theme toggle button */ -.btn-theme { - position: relative; - background: transparent; - border: none; - color: var(--on-surface-dim); - width: 36px; - height: 36px; - border-radius: 50%; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - overflow: hidden; - transition: color 0.15s; - font-size: 1rem; -} -.btn-theme::before { - content: ''; - position: absolute; - inset: 0; - border-radius: 50%; - background: transparent; - transition: background 0.15s; - pointer-events: none; -} -.btn-theme:hover::before { background: var(--state-hover); } -.btn-theme:hover { color: var(--primary); } - - -/* ── 5. Tables ────────────────────────────────────────── */ +/* ── Tables ───────────────────────────────────────────── */ .table-wrap { overflow-x: auto; @@ -432,7 +163,6 @@ table { thead th:last-child, tbody td:last-child { border-right: 1px solid var(--outline); } -/* Column width classes — min-width hints, auto layout sizes to content */ .col-num { min-width: 42px; } .col-time { min-width: 120px; } .col-sensor { min-width: 80px; } @@ -444,7 +174,6 @@ tbody td:last-child { border-right: 1px solid var(--outline); } .col-freq { min-width: 52px; } .col-detail { min-width: 44px; } -/* Risk bar visual (3-block tier indicator) */ .risk-bar { display: inline-flex; gap: 2px; align-items: center; } .rb { width: 8px; height: 12px; border-radius: 2px; } .rb-low { background: var(--green); } @@ -467,7 +196,6 @@ thead th { user-select: none; } -/* Column resize handle */ .resize-handle { position: absolute; right: 0; @@ -524,7 +252,7 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } td.zero { color: var(--on-surface-dim); } -/* ── 6. Detail Panel ──────────────────────────────────── */ +/* ── Detail Panel ─────────────────────────────────────── */ tr.detail-row td { background: var(--surface-container-low); @@ -543,7 +271,6 @@ tr.detail-row td { .detail-grid .dk { color: var(--on-surface-dim); } .detail-grid .dv { color: var(--on-surface); word-break: break-all; } -/* Slide-in detail panel */ .detail-panel-slide { position: fixed; top: 0; right: 0; @@ -601,7 +328,6 @@ tr.detail-row td { gap: 12px; } -/* Propagate flex layout through the HTMX swap target div */ #detail-panel-content { display: flex; flex-direction: column; @@ -617,12 +343,10 @@ tr.detail-row td { margin-bottom: 12px; } -/* Stack enrich-cols vertically inside the narrow panel */ .detail-panel-slide .enrich-cols { grid-template-columns: 1fr; } -/* Ensure enrich columns stretch full width inside panel */ .detail-panel-slide .enrich-col { display: flex; flex-direction: column; @@ -633,9 +357,8 @@ tr.detail-row td { } -/* ── 7. Enrichment ────────────────────────────────────── */ +/* ── Enrichment ───────────────────────────────────────── */ -/* Side-by-side enrichment grid */ .enrich-cols { display: grid; grid-template-columns: 1fr 1fr; @@ -657,10 +380,8 @@ tr.detail-row td { gap: 6px; } -/* Legacy enrich triggers */ .enrich-triggers { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; } -/* Enrich card */ .enrich-card { border: 2px solid var(--outline); border-radius: var(--radius-md); @@ -722,22 +443,8 @@ tr.detail-row td { .cve-list { color: var(--red); } -.badge { - display: inline-block; - padding: 1px 7px; - border-radius: 10px; - font-family: var(--sans); - font-size: 0.75rem; - font-weight: 600; -} -.badge-green { background: var(--badge-green-bg); color: var(--green); } -.badge-red { background: var(--badge-red-bg); color: var(--red); } -.badge-yellow { background: var(--badge-yellow-bg); color: var(--yellow); } -.badge-blue { background: var(--badge-blue-bg); color: var(--primary); } -.badge-gray { background: var(--badge-gray-bg); color: var(--on-surface-dim); } - -/* ── 7b. Mantis Ticket Card ───────────────────────────── */ +/* ── Mantis Ticket Card ───────────────────────────────── */ .mantis-lookup-btns { display: flex; @@ -823,7 +530,7 @@ tr.detail-row td { } -/* ── 8. Filter Forms ──────────────────────────────────── */ +/* ── Filter Forms ─────────────────────────────────────── */ .filter-form { background: var(--surface-container-low); @@ -860,7 +567,6 @@ tr.detail-row td { box-shadow: 0 0 0 2px var(--focus-ring); } -/* Filter creation form (detail panel) */ .filter-action-row { padding-top: 8px; margin-top: 6px; @@ -911,13 +617,12 @@ tr.detail-row td { margin-top: 4px; } -/* Filter zone fills panel width */ [id^="filter-zone-"] { width: 100%; } -/* ── 9. Pagination ────────────────────────────────────── */ +/* ── Pagination ───────────────────────────────────────── */ .pagination-bar { display: flex; @@ -948,7 +653,7 @@ tr.detail-row td { } -/* ── 10. Modals ───────────────────────────────────────── */ +/* ── Modals ───────────────────────────────────────────── */ .modal-backdrop { display: none; @@ -1001,7 +706,6 @@ tr.detail-row td { padding: 6px 0; } -/* Notice summary rows */ .ns-meta { font-size: 0.75rem; color: var(--on-surface-dim); @@ -1055,7 +759,7 @@ tr.detail-row td { .ns-row:hover .ns-bar-fill { opacity: 1; } -/* ── 10b. Sensor Summary Modal ────────────────────────── */ +/* ── Sensor Summary Modal ─────────────────────────────── */ .ss-meta { font-family: var(--sans); font-size: 0.78rem; @@ -1098,17 +802,8 @@ tr.detail-row td { } -/* ── 11. HTMX Indicators ─────────────────────────────── */ +/* ── Spinner ──────────────────────────────────────────── */ -.htmx-indicator { - display: none; - font-size: 0.8rem; - color: var(--on-surface-dim); - padding: 4px 8px; -} -.htmx-request .htmx-indicator { display: inline; } - -/* Spinner in table body */ .loading-row td { text-align: center; color: var(--on-surface-dim); @@ -1116,40 +811,8 @@ tr.detail-row td { font-style: italic; } -/* Global HTMX progress bar */ -#htmx-bar { - position: fixed; - top: 0; left: 0; right: 0; - height: 3px; - z-index: 9999; - pointer-events: none; - overflow: hidden; -} -#htmx-bar::after { - content: ''; - display: block; - height: 100%; - width: 45%; - background: var(--primary); - border-radius: 0 2px 2px 0; - transform: translateX(-120%); -} -#htmx-bar.htmx-bar-active::after { - animation: htmx-sweep 1.4s ease-in-out infinite; -} -#htmx-bar.htmx-bar-done::after { - animation: none; - transform: translateX(280%); - transition: transform 0.25s ease, opacity 0.25s ease; - opacity: 0; -} -@keyframes htmx-sweep { - 0% { transform: translateX(-120%); } - 100% { transform: translateX(280%); } -} - -/* ── 12. IP Pivot ─────────────────────────────────────── */ +/* ── IP Pivot ─────────────────────────────────────────── */ .pivot-section { margin-bottom: 2rem; @@ -1175,10 +838,8 @@ tr.detail-row td { letter-spacing: 0.05em; } -.empty-note { color: var(--on-surface-dim); font-size: 0.82rem; font-style: italic; } - -/* ── 13. Utilities ────────────────────────────────────── */ +/* ── Utilities ────────────────────────────────────────── */ .page-header { display: flex; @@ -1193,7 +854,6 @@ tr.detail-row td { color: var(--on-surface-dim); } -/* Checkbox */ .check-label { display: flex; align-items: center; @@ -1202,21 +862,3 @@ tr.detail-row td { cursor: pointer; color: var(--on-surface); } - -/* Org identity icons */ -.org-icon { - font-size: 0.72rem; - margin-right: 3px; - opacity: 0.85; - vertical-align: middle; -} -.org-icon.org-cdn { color: var(--primary); } -.org-icon.org-cloud { color: var(--secondary); } -.org-icon.org-scanner { color: var(--yellow); } -.org-icon.org-research { color: var(--purple); } -.org-icon.org-private { color: var(--on-surface-dim); } - -/* Links */ -.mt-1 { margin-top: 0.5rem; } -.mt-2 { margin-top: 1rem; } -.mb-1 { margin-bottom: 0.5rem; } diff --git a/apps/dashboard_web/templates/base.html b/apps/dashboard_web/templates/base.html index 0051f6a..79f9888 100644 --- a/apps/dashboard_web/templates/base.html +++ b/apps/dashboard_web/templates/base.html @@ -4,14 +4,10 @@ {% block title %}PISCES · Dashboard{% endblock title %} - - + + + +
diff --git a/apps/opensearch_web/templates/base.html b/apps/opensearch_web/templates/base.html index cb0ba8b..2b8a943 100644 --- a/apps/opensearch_web/templates/base.html +++ b/apps/opensearch_web/templates/base.html @@ -59,10 +59,6 @@ diff --git a/apps/mantis_explorer/static/me.css b/apps/mantis_explorer/static/me.css index 216118d..91820fa 100644 --- a/apps/mantis_explorer/static/me.css +++ b/apps/mantis_explorer/static/me.css @@ -184,8 +184,14 @@ main { transition: border-color 0.15s, box-shadow 0.15s; color-scheme: dark; } -[data-theme="light"] .me-filter-bar input[type="date"], -[data-theme="light"] .me-filter-bar input[type="text"] { +[data-theme="pisces-light"] .me-filter-bar input[type="date"], +[data-theme="pisces-light"] .me-filter-bar input[type="text"], +[data-theme="gruvbox-light"] .me-filter-bar input[type="date"], +[data-theme="gruvbox-light"] .me-filter-bar input[type="text"], +[data-theme="tokyonight-light"] .me-filter-bar input[type="date"], +[data-theme="tokyonight-light"] .me-filter-bar input[type="text"], +[data-theme="catppuccin-latte"] .me-filter-bar input[type="date"], +[data-theme="catppuccin-latte"] .me-filter-bar input[type="text"] { color-scheme: light; } diff --git a/apps/mantis_explorer/templates/base.html b/apps/mantis_explorer/templates/base.html index d602dfb..e873ac7 100644 --- a/apps/mantis_explorer/templates/base.html +++ b/apps/mantis_explorer/templates/base.html @@ -1,5 +1,5 @@ - + diff --git a/apps/opensearch_web/templates/base.html b/apps/opensearch_web/templates/base.html index 2b8a943..4eea1d0 100644 --- a/apps/opensearch_web/templates/base.html +++ b/apps/opensearch_web/templates/base.html @@ -1,5 +1,5 @@ - + diff --git a/apps/shared/static/theme.js b/apps/shared/static/theme.js index 5d3b6cf..2e4d94b 100644 --- a/apps/shared/static/theme.js +++ b/apps/shared/static/theme.js @@ -5,30 +5,19 @@ * : * (the IIFE at the top applies the saved theme before CSS loads) * - * : + * Settings page: - {% for tr in TIME_RANGES %} - - {% endfor %} - - -
- - - - - -
- - - - -
- - - - -
- - - - -
+
+
+ + +
- +
+ + + +
-
+
+ + +
- - +
+ + +
-
+
+ + +
- +
+ +
-
+
+ + +
+
- +
+ - @@ -201,15 +196,6 @@ ) would raise a ValueError whose str(exc) was passed to the template via data['error']. While Jinja2 autoescape mitigates this, defence in depth requires rejecting invalid input at the boundary. Add safe_date_param() to apps.dashboard_web and apply it in both the tickets and mantis dashboard routes. Invalid values are silently replaced with empty strings (no date filter applied). --- apps/dashboard_web/__init__.py | 19 ++++++++++++++++++ apps/dashboard_web/mantis/__init__.py | 5 +++-- apps/dashboard_web/tickets/__init__.py | 5 +++-- tests/test_dashboard_date_param.py | 27 ++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 tests/test_dashboard_date_param.py diff --git a/apps/dashboard_web/__init__.py b/apps/dashboard_web/__init__.py index e69de29..b37057d 100644 --- a/apps/dashboard_web/__init__.py +++ b/apps/dashboard_web/__init__.py @@ -0,0 +1,19 @@ +"""PISCES Dashboard web application package.""" + +from datetime import date + + +def safe_date_param(value: str) -> str: + """Validate a date query parameter, returning '' for invalid values. + + Prevents user-supplied strings from reaching exception messages + (reflected XSS via error rendering). + """ + v = value.strip() + if not v: + return "" + try: + date.fromisoformat(v) + return v + except ValueError: + return "" diff --git a/apps/dashboard_web/mantis/__init__.py b/apps/dashboard_web/mantis/__init__.py index 9ed6b4b..fc229d8 100644 --- a/apps/dashboard_web/mantis/__init__.py +++ b/apps/dashboard_web/mantis/__init__.py @@ -1,6 +1,7 @@ from flask import Blueprint, render_template, request from apps.dashboard_web import cache as dcache +from apps.dashboard_web import safe_date_param from apps.dashboard_web.mantis.aggregations import ( agg_mantis_attack_types, agg_mantis_infra_count, @@ -13,8 +14,8 @@ @bp.route("/api/dashboard/mantis") def section(): - since = request.args.get("since", "") - until = request.args.get("until", "") + since = safe_date_param(request.args.get("since", "")) + until = safe_date_param(request.args.get("until", "")) cache_key = {"since": since, "until": until} cached = dcache.get("mantis", cache_key) if cached is not None: diff --git a/apps/dashboard_web/tickets/__init__.py b/apps/dashboard_web/tickets/__init__.py index 38f15bd..62b8669 100644 --- a/apps/dashboard_web/tickets/__init__.py +++ b/apps/dashboard_web/tickets/__init__.py @@ -3,6 +3,7 @@ from flask import Blueprint, render_template, request from apps.dashboard_web import cache as dcache +from apps.dashboard_web import safe_date_param from apps.dashboard_web.tickets.aggregations import agg_tickets bp = Blueprint("tickets", __name__, template_folder="templates") @@ -10,8 +11,8 @@ @bp.route("/api/dashboard/tickets") def section() -> str: - since = request.args.get("since", "") - until = request.args.get("until", "") + since = safe_date_param(request.args.get("since", "")) + until = safe_date_param(request.args.get("until", "")) cache_key = {"since": since, "until": until} cached = dcache.get("tickets", cache_key) if cached is not None: diff --git a/tests/test_dashboard_date_param.py b/tests/test_dashboard_date_param.py new file mode 100644 index 0000000..363de3c --- /dev/null +++ b/tests/test_dashboard_date_param.py @@ -0,0 +1,27 @@ +"""Regression tests for dashboard date parameter sanitisation (reflected XSS).""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest + +from apps.dashboard_web import safe_date_param + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("2026-01-15", "2026-01-15"), + (" 2026-01-15 ", "2026-01-15"), + ("", ""), + (" ", ""), + ("", ""), + ("2026-99-99", ""), + ("not-a-date", ""), + ("2026-01-15; DROP TABLE", ""), + ], +) +def test_safe_date_param(raw: str, expected: str) -> None: + assert safe_date_param(raw) == expected From 8cd887abfbc33a6790c82e37f48f33c48c83100b Mon Sep 17 00:00:00 2001 From: liamadale Date: Wed, 29 Apr 2026 23:52:36 -0700 Subject: [PATCH 49/49] fix(dashboard): break CodeQL taint chain by returning parsed date isoformat Return date.fromisoformat(v).isoformat() instead of the original user string. This produces a new string from the parsed date object, which CodeQL can verify is not tainted by user input. --- apps/dashboard_web/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/dashboard_web/__init__.py b/apps/dashboard_web/__init__.py index b37057d..1a14eb6 100644 --- a/apps/dashboard_web/__init__.py +++ b/apps/dashboard_web/__init__.py @@ -13,7 +13,6 @@ def safe_date_param(value: str) -> str: if not v: return "" try: - date.fromisoformat(v) - return v + return date.fromisoformat(v).isoformat() except ValueError: return ""